Compare commits

...

666 Commits

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

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

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

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

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

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

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

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

Pin the wrapper View to the measured trigger size and let the Host fill it
via absoluteFill, breaking the cycle so Video/Audio/Subtitle render too.
2026-05-30 11:50:03 +02:00
Fredrik Burmester
a190122eea Merge branch 'develop' into feat/tv-interface
# Conflicts:
#	.gitignore
#	app/(auth)/(tabs)/(home)/_layout.tsx
#	components/PlatformDropdown.tsx
#	components/search/DiscoverFilters.tsx
#	components/video-player/controls/BottomControls.tsx
#	components/video-player/controls/TrickplayBubble.tsx
#	modules/mpv-player/ios/MPVLayerRenderer.swift
2026-05-30 11:08:22 +02:00
Fredrik Burmester
0bf8fac079 chore: update pr template
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
2026-05-30 10:41:24 +02:00
Fredrik Burmester
37b51abd34 chore: deps 2026-05-30 10:41:10 +02:00
Fredrik Burmester
6fe464088b fix(mpv): prevent UI freeze on player exit by tearing down mpv off main thread
mpv_terminate_destroy() blocks until mpv's threads (including the
vo_avfoundation output thread) are joined, and that teardown needs the
main run loop to complete. Calling it via queue.sync from MpvPlayerView
deinit (main thread) deadlocked/froze the UI on playback exit.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-05-27 07:32:48 +02:00
renovate[bot]
11ec778bd8 chore(deps): Update github/codeql-action action to v4.36.0 (#1593)
Some checks failed
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
2026-05-26 15:23:11 +02:00
Lance Chant
796e12b4ac Chore: fix the storage reading and search colour
Fixed the storage calc to be GB instead of GIB so changed to 1000
instead of 1024
Changed some of the search colours

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

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

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

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-05-26 11:34:37 +02:00
renovate[bot]
c8ddb9a892 chore(deps): Update actions/upload-artifact action to v7 (#1584) 2026-05-26 11:20:23 +02:00
renovate[bot]
9ee71a002d chore(deps): Update oven-sh/setup-bun action to v2.2.0 (#1583)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
2026-05-26 09:40:57 +02:00
Lance Chant
5ede3f30d0 chore: more scaling fixes and selection improve
Fixed the scaling in the direct player controls to use the scaleTV
settings
Fixed 2 items in settings not being selectable (added style:flex:1)

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 02:36:25 +02:00
Uruk
5cfd110ad5 docs(casting): add Chromecast cast test matrix 2026-05-21 02:33:12 +02:00
Uruk
6e63afc61a feat(casting): replace H265 toggle with Chromecast profile selector 2026-05-21 02:32:18 +02:00
renovate[bot]
80fdd579f3 chore(deps): Update dependency @react-native-community/cli to v20.1.3 (#1571)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
2026-05-21 02:28:38 +02:00
Uruk
bcf6b705e1 refactor(casting): route all cast loads through loadCastMedia
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 02:27:20 +02:00
Uruk
fb8c649f6f feat(casting): add unified loadCastMedia with downgrade-on-failure 2026-05-21 02:20:06 +02:00
Uruk
6ecadecb87 feat(casting): add chromecastProfile and chromecastMaxBitrate settings 2026-05-21 02:17:59 +02:00
Uruk
e3f105691b feat(casting): add Chromecast device profile builder 2026-05-21 02:16:29 +02:00
Uruk
bcfa8c6d63 feat(casting): add Chromecast capability detection 2026-05-21 02:13:31 +02:00
Uruk
17450e3811 docs(casting): add device profiles implementation plan
7-task TDD plan for sub-project A: capability detection, profile builder,
unified loadCastMedia, call-site rewiring, settings UI, test matrix.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-05-20 15:04:15 +02:00
renovate[bot]
7bccafc476 chore(deps): Update actions/dependency-review-action action to v4.8.3 (#1499)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
2026-05-20 14:49:24 +02:00
renovate[bot]
8df61838d4 chore(deps): Update actions/cache action to v5.0.5 (#1429) 2026-05-20 13:43:17 +02:00
renovate[bot]
55776d887f chore(deps): Update maxim-lobanov/setup-xcode digest to ed7a3b1 (#1498) 2026-05-20 13:17:20 +02:00
renovate[bot]
2e7079cb5a chore(deps): Update github/codeql-action action to v4.35.5 (#1508) 2026-05-20 12:54:50 +02:00
Steve Byatt
a1c98f9285 fix: tv overlay focus navigation (#1558) 2026-05-20 12:53:01 +02:00
Steve Byatt
ca4f24ded0 fix: handle TV menu and back navigation (#1559) 2026-05-20 09:57:19 +02:00
lance chant
92deba14f3 Adding QR code login (#1557)
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-05-20 08:41:49 +02:00
lance chant
ece5750d34 Feat/tv interface uniform scale (#1562)
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-05-20 07:56:39 +02:00
renovate[bot]
428455f6a6 chore(deps): Update actions/setup-node action to v6.4.0 (#1503)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
2026-05-20 01:08:17 +02:00
renovate[bot]
8c749cdc4d chore(deps): Update oven-sh/setup-bun action to v2.2.0 (#1509) 2026-05-20 00:44:45 +02:00
renovate[bot]
7ed0c00ce7 chore(deps): Update actions/upload-artifact action to v7 (#1510) 2026-05-20 00:24:58 +02:00
renovate[bot]
222ae69644 chore(deps): Update marocchino/sticky-pull-request-comment action to v3 (#1512) 2026-05-20 00:03:36 +02:00
renovate[bot]
fec8df37f7 chore(deps): Pin dependencies (#1537) 2026-05-19 23:40:40 +02:00
renovate[bot]
0e0e722e1c chore(deps): Update crowdin/github-action action to v2.16.2 (#1504)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
2026-05-19 19:58:56 +02:00
Théo FORTIN
2ce810c191 fix(player/ios): Disable subtitles embedding for iOS simulator (#1544)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
2026-04-24 10:44:30 +02:00
Alex Collado
564a593a3a fix: Close modal when back button is pressed on Android (#1487)
Some checks failed
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
2026-03-12 13:45:17 +01:00
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
44caf4b1ff feat(i18n): add swedish translations for tv interface and fix hardcoded strings 2026-01-26 08:17:00 +01:00
Fredrik Burmester
92c70fadd1 refactor(tv): reorganize item detail page layout and improve episode list 2026-01-26 08:16:59 +01:00
Fredrik Burmester
f637367b82 style(tv): update episode section heading typography and spacing 2026-01-26 07:51:55 +01:00
Fredrik Burmester
715764cef8 feat(tv): add season episode list to episode detail page 2026-01-25 23:23:03 +01:00
Fredrik Burmester
36d6686258 feat(tv): increase typography scale multipliers for better visibility 2026-01-25 23:02:52 +01:00
Fredrik Burmester
dca7cc99f2 feat(tv): add setting to show series poster on episode detail pages 2026-01-25 23:01:08 +01:00
Fredrik Burmester
875a017e8c feat(tv): add scalable typography with user-configurable text size 2026-01-25 22:55:44 +01:00
Fredrik Burmester
0c6c20f563 feat(tv): add horizontal gradient fade to hero carousel backdrop 2026-01-25 22:53:24 +01:00
Fredrik Burmester
2c9906377d feat(tv): update skeleton layout to match swapped poster position 2026-01-25 21:31:37 +01:00
Fredrik Burmester
d5f7a18fe5 chore: docs 2026-01-25 20:19:45 +01:00
Fredrik Burmester
4606b9718e feat(tv): swap layout and add horizontal posters for episodes 2026-01-25 20:18:12 +01:00
Fredrik Burmester
c2d61654b0 feat(tv): add glass poster module and refactor grid layouts 2026-01-25 17:02:10 +01:00
Fredrik Burmester
2c6938c739 fix: design 2026-01-25 14:50:05 +01:00
Fredrik Burmester
1f454c0f12 feat(tv): add Apple TV+ style hero carousel to home page 2026-01-24 23:43:40 +01:00
Fredrik Burmester
c215fda973 feat(tv): add 60s background refresh for idle screens 2026-01-24 10:48:42 +01:00
Fredrik Burmester
a852e2e769 fix(tv): reposition streamystats sections after recently added 2026-01-24 10:35:15 +01:00
Fredrik Burmester
29873e08d7 feat(tv): add shared filter components and collections page support 2026-01-24 10:31:03 +01:00
Fredrik Burmester
5ce5cc2d99 refactor(home): reposition streamystats sections after recently added 2026-01-24 10:29:04 +01:00
Fredrik Burmester
ae5a71ff29 fix(auth): clear react query cache on logout and enable home screen refetch 2026-01-24 10:25:33 +01:00
Fredrik Burmester
0e3e8b8016 Revert "fix: scaling"
This reverts commit 566ff485fb.
2026-01-24 10:05:22 +01:00
Fredrik Burmester
d07a521f60 feat(tv): add trickplay bubble positioning aligned with progress bar 2026-01-23 22:39:44 +01:00
Lance Chant
566ff485fb fix: scaling
Hopefully fixing scaling across different TV types for android/ios
Test for login screen at the moment

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

All settings use mpvSubtitle* settings for direct MPV control.
Includes English translations for all new settings.
2026-01-18 20:41:28 +01:00
Fredrik Burmester
f8d1fad6d5 refactor 2026-01-18 20:06:09 +01:00
Fredrik Burmester
81af2afef8 feat(tv): add see all card to recently added sections with focus handling improvements 2026-01-18 19:58:10 +01:00
Fredrik Burmester
9ef79ef364 Merge branch 'develop' into feat/tv-interface 2026-01-18 19:38:27 +01:00
lance chant
c7077bbcfe fix: subrip mpv (#1375)
Some checks failed
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-01-18 19:35:14 +01:00
Alex
c0f25a2b8b Add caching progress in seek slider bar (#1376) 2026-01-18 19:34:39 +01:00
Fredrik Burmester
83babc2687 refactor 2026-01-18 19:33:42 +01:00
Fredrik Burmester
f9a3a1f9f6 feat(tv): add live subtitle track refresh after opensubs download 2026-01-18 17:44:13 +01:00
Fredrik Burmester
0f076d197f style(tv): update stepper buttons to square with rounded corners 2026-01-18 17:22:44 +01:00
Fredrik Burmester
d28b5411d5 style(tv): add apple tv-style badges to search page 2026-01-18 17:22:41 +01:00
Fredrik Burmester
1da49d29d7 style(tv): update settings to use apple tv-style white and green accents 2026-01-18 16:25:00 +01:00
Fredrik Burmester
7af4b913d7 fix(tv): add keyboard focus to text inputs and polish poster styling 2026-01-18 16:13:53 +01:00
Fredrik Burmester
a667723d93 fix(tv): improve subtitle modal loading state and card consistency 2026-01-18 15:25:40 +01:00
Fredrik Burmester
94bfa26041 feat(tv): add opensubtitles api key setting to tv interface 2026-01-18 15:25:31 +01:00
Fredrik Burmester
d545ca3584 fix(tv): modals 2026-01-18 15:22:44 +01:00
Fredrik Burmester
773701d0c1 fix: translations 2026-01-18 14:52:45 +01:00
Fredrik Burmester
a3f7d0c275 feat(tv): add metadata refresh button to item details page 2026-01-18 14:52:06 +01:00
Fredrik Burmester
5b7ded08cc refactor(tv): extract shared components to reduce code duplication 2026-01-18 14:45:18 +01:00
Fredrik Burmester
60dd00ad7e fix: close button modals 2026-01-18 14:14:23 +01:00
Fredrik Burmester
ec653cae15 docs: hdr 2026-01-18 13:53:19 +01:00
Fredrik Burmester
18bc45ea0a feat: open subtitles 2026-01-18 13:20:17 +01:00
Fredrik Burmester
ebb33854d7 wip 2026-01-18 12:37:12 +01:00
Fredrik Burmester
9efa2bbaa2 wip: hdr 2026-01-18 11:58:32 +01:00
Fredrik Burmester
c515d037cf refactor(tv): unify subtitle track selector and search into tabbed sheet 2026-01-18 11:13:57 +01:00
Fredrik Burmester
ee3a288fa0 wip 2026-01-18 10:38:06 +01:00
Fredrik Burmester
c0171aa656 feat(tv): add actor detail page with dynamic backdrop crossfade 2026-01-17 09:32:47 +01:00
Fredrik Burmester
41d3e61261 feat(tv): add bidirectional focus navigation between options and cast list 2026-01-17 09:10:27 +01:00
Fredrik Burmester
8f74c3edc7 feat(tv): actors and stuff 2026-01-16 23:36:15 +01:00
Fredrik Burmester
56ffec3173 fix(player): add null guards for item in play settings 2026-01-16 21:26:56 +01:00
Fredrik Burmester
9509a427c8 wip 2026-01-16 21:22:23 +01:00
Fredrik Burmester
cfcfb486bf wip 2026-01-16 21:21:58 +01:00
Fredrik Burmester
407ea69425 fix(tv): add opening animations to bottom sheet option selectors 2026-01-16 21:03:06 +01:00
Fredrik Burmester
e1e91ea1a6 fix: sheet 2026-01-16 21:00:46 +01:00
Fredrik Burmester
e7ea8a2c3b fix: remove back button 2026-01-16 19:51:27 +01:00
Fredrik Burmester
9f1791ce93 wip 2026-01-16 19:05:25 +01:00
Fredrik Burmester
38cb7068ef style(search): remove redundant search label on TV search page 2026-01-16 19:04:13 +01:00
Fredrik Burmester
cc154f0c16 fix(tv): fix subtitle sheet issues on TV
- Hide subtitle button when no subtitle tracks available
- Add back/menu button handling to close option sheets
2026-01-16 18:57:38 +01:00
Fredrik Burmester
866aa44277 wip: controls next up 2026-01-16 17:16:08 +01:00
Fredrik Burmester
ff3f88c53b wip 2026-01-16 15:59:26 +01:00
Fredrik Burmester
3fd76b1356 wip 2026-01-16 15:29:12 +01:00
Fredrik Burmester
a86df6c46b wip 2026-01-16 14:48:08 +01:00
Fredrik Burmester
bdd284b9a6 fix(i18n): add missing common.login translation key 2026-01-16 13:22:26 +01:00
Fredrik Burmester
fff7d4459f feat(tv): improve settings focus management with disabled props pattern 2026-01-16 13:17:12 +01:00
Fredrik Burmester
b85549016d style(tv): increase top padding on item content page 2026-01-16 13:15:53 +01:00
Fredrik Burmester
6c35608404 fix(tv): regenerate icons with proper aspect ratios 2026-01-16 13:09:30 +01:00
Fredrik Burmester
74e3465a84 feat(tv): add tv card design to watchlist detail page 2026-01-16 13:06:12 +01:00
Fredrik Burmester
be32d933bb feat(tv): add option selector for playback settings 2026-01-16 13:00:26 +01:00
Fredrik Burmester
db89295d9b feat(player): add Apple TV remote play/pause and AirPlay support
- Add playPause event handling in useRemoteControl hook
- Configure AVAudioSession for tvOS with longFormAudio policy
- Add AVInitialRouteSharingPolicy to enable AirPlay suggestions
2026-01-16 12:42:13 +01:00
Fredrik Burmester
8d90fe3a8b fix(tv): implement remote control seeking on tv interface 2026-01-16 12:40:37 +01:00
Fredrik Burmester
4880392197 fix(tv): login form 2026-01-16 12:19:47 +01:00
Fredrik Burmester
e10a99cc48 wip: build for tv 2026-01-16 10:47:48 +01:00
Fredrik Burmester
55b897883b wip 2026-01-16 10:06:41 +01:00
Fredrik Burmester
fe26a74451 wip: home page 2026-01-16 09:11:27 +01:00
Fredrik Burmester
4cdbab7d19 wip 2026-01-16 08:57:22 +01:00
Fredrik Burmester
3e695def23 wip 2026-01-16 08:57:19 +01:00
Fredrik Burmester
15e4c18d54 fix(tvos): settings 2026-01-16 08:42:53 +01:00
Fredrik Burmester
87169480a1 chore 2026-01-16 08:32:05 +01:00
Fredrik Burmester
bd9467b09e fix: remove music provider for tv 2026-01-16 08:32:02 +01:00
Fredrik Burmester
6216e7fdb7 fix: items content for tv 2026-01-16 08:31:53 +01:00
Fredrik Burmester
6d2e897c9f fix: badge for tv 2026-01-16 08:31:44 +01:00
Fredrik Burmester
ad5148daad fix: login stuff for tv 2026-01-16 08:31:37 +01:00
Fredrik Burmester
c1e12d5898 fix: login page for tv 2026-01-16 08:30:50 +01:00
Fredrik Burmester
7416c8297a fix: hide music bar 2026-01-16 08:30:40 +01:00
Fredrik Burmester
9727bec7ab fix: hide header buttons 2026-01-16 08:30:33 +01:00
Fredrik Burmester
6ba767a848 fix: tvos 2026-01-16 08:04:23 +01:00
Fredrik Burmester
4ad103acb6 fix: conditionals for tv to build / run 2026-01-16 08:04:09 +01:00
renovate[bot]
36304ad58e chore(deps): Update dependency react-native-nitro-modules to v0.33.1 (#1340)
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
🚦 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-15 16:48:51 +01:00
renovate[bot]
baeb83581e chore(deps): Update actions/setup-node action to v6.2.0 (#1372)
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
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-15 15:22:19 +01:00
Fredrik Burmester
05b7a4c50d fix: downloads should work when connecting through QC 2026-01-15 07:54:08 +01:00
Fredrik Burmester
28b67f3ad6 fix(mpv): handle audio track selection for transcoded streams on iOS 2026-01-15 07:53:15 +01:00
Chris
51cd195bfe Reverting Jellyseer/Seer logo update
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
Final logo not yet released, holding off until further updates
2026-01-14 20:23:39 +01:00
Chris
0184e266a0 Update Jellyseer logo to Seer logo
Updated logo in order to reflecting the new branding
2026-01-14 20:17:25 +01:00
Gauvain
ae658aa5b0 fix: remove Android emulator detection from MPV player (#1369) 2026-01-14 16:46:17 +01:00
Alex
81f79a54af fix(mpv): Add progress throttling for mpv (#1366)
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-01-14 13:14:52 +01:00
renovate[bot]
ca1b640a61 chore(deps): Update dependency @babel/core to v7.28.6 (#1363)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-14 11:41:57 +01:00
renovate[bot]
e771949c95 chore(deps): Update dependency @tanstack/react-query to v5.90.17 (#1365)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-14 10:57:40 +01:00
renovate[bot]
78bfa68a17 chore(deps): Update dependency react-i18next to v16.5.3 (#1364)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-14 10:06:03 +01:00
renovate[bot]
ac59615d79 chore(deps): Update dependency jotai to v2.16.2 (#1329)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-14 09:17:32 +01:00
Fredrik Burmester
4dd80cd8f5 Merge branch 'develop' into fix/external-sub-selection
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-01-13 22:43:05 +01:00
renovate[bot]
db9f02b225 chore(deps): Update BRAINSia/free-disk-space action to v2.1.3 (#1358)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-13 21:42:18 +01:00
renovate[bot]
7a0bbb1084 chore(deps): Update actions/upload-artifact action to v6 (#1362)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-13 21:20:42 +01:00
renovate[bot]
05925530c0 chore(deps): Update actions/checkout action to v6 (#1361)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-13 21:20:28 +01:00
renovate[bot]
625a292e26 chore(deps): Update actions/cache action to v5 (#1360)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-13 21:20:15 +01:00
renovate[bot]
1acd3102ea chore(deps): Update oven-sh/setup-bun action to v2.1.0 (#1359)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-13 21:19:58 +01:00
renovate[bot]
543881dc41 chore(deps): Update BRAINSia/free-disk-space digest to 7ef2f7e (#1357)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-13 21:19:27 +01:00
Cristea Florian Victor
5d93483dc2 feat: xcode build script (#1296)
Co-authored-by: Fredrik Burmester <fredrik.burmester@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Gauvain <68083474+Gauvino@users.noreply.github.com>
2026-01-13 19:32:58 +01:00
Gauvain
d54a29020a fix(ci): code scanning alert no. 219 (#1353)
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-01-13 18:41:56 +01:00
renovate[bot]
1d04e39b85 chore(deps): Update github/codeql-action action to v4.31.10 (#1352)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-13 15:05:27 +01:00
Alex Kim
ecc62259fc Re-add config version 2026-01-13 22:21:19 +11:00
Alex Kim
ffd96e05fe Merge branch 'fix/external-sub-selection' of github.com:streamyfin/streamyfin into fix/external-sub-selection 2026-01-13 22:19:58 +11:00
Alex Kim
8541ba02d4 Add android version for stopping auto selection of subtitles 2026-01-13 22:19:47 +11:00
Chris
6c955d8a2a Enhance README with Discord link and badge
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
Added Discord link and badge to the README.
2026-01-12 22:06:18 +01:00
Fredrik Burmester
b0bb6c6c9a feat: add technical stream info overlay for MPV player 2026-01-12 21:55:32 +01:00
Fredrik Burmester
82abc291d4 Merge branch 'develop' into fix/external-sub-selection 2026-01-12 21:02:16 +01:00
renovate[bot]
3da4b42ca3 chore(deps): Update dependency @tanstack/react-query to v5.90.16 (#1328)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-12 15:16:38 +01:00
Alex Kim
16940075b2 Stop external subs from being selected when added 2026-01-12 22:50:36 +11:00
renovate[bot]
a3bbb1bc3a chore(deps): Update dependency @types/lodash to v4.17.23 (#1344)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-12 12:11:05 +01:00
renovate[bot]
1874c116a6 chore(deps): Update dependency react-i18next to v16.5.2 (#1345)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-12 11:05:17 +01:00
Simon Eklundh
7a0f70778d fix: fix music videos and home videos on library (#1326)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
Co-authored-by: Fredrik Burmester <fredrik.burmester@gmail.com>
2026-01-11 22:31:25 +01:00
Simon Eklundh
6957c4fd64 feat: add autorotate for landscape (#1265)
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
Co-authored-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
Co-authored-by: Fredrik Burmester <fredrik.burmester@gmail.com>
2026-01-11 22:27:19 +01:00
github-actions[bot]
1c0ed82deb feat: New Crowdin Translations (#1343)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-01-11 17:39:23 +01:00
Alex
ad54823f96 refactor: downloads to minimize prop drilling and improve layout and design (#1337)
Co-authored-by: Alex Kim <alexkim@Alexs-MacBook-Pro.local>
Co-authored-by: Fredrik Burmester <fredrik.burmester@gmail.com>
Co-authored-by: Simon-Eklundh <simon.eklundh@proton.me>
2026-01-11 17:38:41 +01:00
Fredrik Burmester
cfa638afc6 chore: deps
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
2026-01-11 13:12:03 +01:00
Fredrik Burmester
467bea7192 feat(network): add local network auto-switch feature (#1334) 2026-01-11 13:08:14 +01:00
github-actions[bot]
ac9ac5d423 feat: New Crowdin Translations (#1341)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-01-11 13:07:53 +01:00
Fredrik Burmester
62b45121e5 feat(settings): add toggle to disable auto-play next episode (#1342) 2026-01-11 13:07:38 +01:00
Fredrik Burmester
0e238ad10e feat(ios): glassview container for badges 2026-01-11 11:48:30 +01:00
Fredrik Burmester
ce793e3469 fix: ensure continue watching overlay appears above controls 2026-01-11 11:30:28 +01:00
Fredrik Burmester
beba4853b9 Revert "feat(settings): add toggle to disable auto-play next episode"
This reverts commit d1b15a9dde.
2026-01-11 10:19:35 +01:00
Fredrik Burmester
d1b15a9dde feat(settings): add toggle to disable auto-play next episode
Add new setting to completely disable the auto-play next episode feature.
When disabled, the countdown button is hidden and the max episode count
setting appears greyed out.
2026-01-11 10:16:10 +01:00
Fredrik Burmester
12847894df feat(watchlists): add see all navigation for promoted watchlists
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
2026-01-11 00:19:31 +01:00
Fredrik Burmester
773eae8cb9 feat(home): add see all navigation from recently added sections 2026-01-11 00:16:18 +01:00
Fredrik Burmester
781c309332 feat(search): lower debounce to 200ms and add AbortController using TanStack Pacer 2026-01-10 23:51:16 +01:00
Fredrik Burmester
8a7f5fd4c5 chore: ai docs 2026-01-10 22:54:08 +01:00
Fredrik Burmester
838c8f48a3 fix(mpv-player): resolve swift compiler warnings 2026-01-10 21:57:53 +01:00
Fredrik Burmester
399eb0a351 fix(chromecast): add missing MediaInfo fields
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
2026-01-10 21:32:09 +01:00
Fredrik Burmester
f1575ca48b feat: MPV player for both Android and iOS with added HW decoding PiP (with subtitles) (#1332)
Co-authored-by: Alex Kim <alexkim@Alexs-MacBook-Pro.local>
Co-authored-by: Alex <111128610+Alexk2309@users.noreply.github.com>
Co-authored-by: Simon-Eklundh <simon.eklundh@proton.me>
2026-01-10 19:35:27 +01:00
github-actions[bot]
df2f44e086 feat: New Crowdin Translations (#1333)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-01-10 12:13:30 +01:00
Fredrik Burmester
9d566d697d fix: crowdin setting 2026-01-10 11:35:10 +01:00
Fredrik Burmester
35f7257a6d chore(claude): add /reflect skill for session learnings 2026-01-10 11:33:07 +01:00
Fredrik Burmester
d4ab62bdd6 chore: update ai doc 2026-01-10 11:33:07 +01:00
Fredrik Burmester
ca7591ad21 fix: add back redirect for startup tab 2026-01-10 11:33:07 +01:00
Fredrik Burmester
7514bc2c2c fix: redirect when intro modal first time 2026-01-10 11:33:07 +01:00
Fredrik Burmester
241f8c949a fix: cache issues with songs, partial downloads, component rerenders, etc 2026-01-10 11:33:07 +01:00
Fredrik Burmester
f369738f7b fix: remove console errors when offline - since offline is supported 2026-01-10 11:33:07 +01:00
Fredrik Burmester
ac6288d9a4 Update Crowdin configuration file 2026-01-10 11:18:56 +01:00
lance chant
91128944c6 fix: fixing an issue where offline playback wasn't working/tracking when back online (#1239)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
Co-authored-by: retardgerman <78982850+retardgerman@users.noreply.github.com>
2026-01-09 09:17:35 +01:00
Fredrik Burmester
81449963fa feat(auth): add multi-account support with PIN/password protection 2026-01-09 08:37:30 +01:00
Fredrik Burmester
8569cd390b fix(android): quick connect not showing bc keyboard 2026-01-09 07:56:17 +01:00
Fredrik Burmester
3959aa2f72 fix(android): convert intro modal to bottom sheet bc broken on smaller screens 2026-01-09 07:15:53 +01:00
Fredrik Burmester
097001b092 fix(player): load external subtitles for offline playback
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
2026-01-08 21:51:27 +01:00
Fredrik Burmester
d1387ec725 fix(downloads): preserve audio track selection for offline playback 2026-01-08 21:51:27 +01:00
Fredrik Burmester
51ecde1565 fix(vlc): add audio transcoding mode to fix 7.1 TrueHD playback 2026-01-08 21:51:27 +01:00
Gauvain
0a0da687d5 ci(crowdin): include untranslated files in exports (#1323)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
2026-01-08 13:32:18 +01:00
Gauvain
0a3cc87b2a chore(i18n): include untranslated strings in Crowdin sync (#1321)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
2026-01-07 22:55:13 +01:00
Gauvain
77596bd788 fix(ci): use default GITHUB_TOKEN for Crowdin workflow (#1320) 2026-01-07 22:48:41 +01:00
Fredrik Burmester
630e7a1a36 chore: fix deps 2026-01-07 22:38:28 +01:00
renovate[bot]
04bc412bce chore(deps): Update dependency expo-constants to v18.0.13 (#1319)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-07 22:36:43 +01:00
Fredrik Burmester
99aa6bd342 fix(player): hide sub size when vlc 2026-01-07 22:20:11 +01:00
renovate[bot]
a3751c4801 chore(deps): Update dependency @biomejs/biome to v2.3.11 (#1228)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-07 22:19:03 +01:00
renovate[bot]
e8ddd1cad7 chore(deps): Pin dependencies (#1235)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-07 22:18:40 +01:00
Fredrik Burmester
be8651357b feat(player): vlc subtitle options 2026-01-07 22:18:11 +01:00
Fredrik Burmester
588c8ffeb5 fix(player): audio and subtitle dropdown not visible in player 2026-01-07 22:18:11 +01:00
renovate[bot]
3e01310466 chore(deps): Update github/codeql-action action to v4.31.9 (#1230)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-07 22:11:51 +01:00
renovate[bot]
f3417b5a75 chore(deps): Update dependency lint-staged to v16.2.7 (#1236)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-07 22:11:25 +01:00
renovate[bot]
f97d7d78c1 chore(deps): Update dependency react-native-mmkv to v4.0.1 (#1238)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-07 22:10:49 +01:00
renovate[bot]
eaa519fdda chore(deps): Update dependency @types/lodash to v4.17.21 (#1248)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-07 22:10:39 +01:00
renovate[bot]
d84ff82590 chore(deps): Update dependency react-i18next to v16.5.1 (#1249)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-07 22:10:30 +01:00
renovate[bot]
ea271b8cdd chore(deps): Update dependency expo-doctor to v1.17.14 (#1260)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-07 22:10:22 +01:00
renovate[bot]
b6270f1157 chore(deps): Update crowdin/github-action action to v2.13.0 (#1263)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-07 22:09:53 +01:00
renovate[bot]
fe6299c22b chore(deps): Update actions/checkout action to v6 (#1264)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-07 22:09:43 +01:00
renovate[bot]
88722b3e17 chore(deps): Update actions/cache action to v5 (#1280)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-07 22:09:32 +01:00
renovate[bot]
1a4159ffee chore(deps): Update actions/upload-artifact action to v6 (#1281)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-07 22:09:13 +01:00
renovate[bot]
bd13791a83 chore(deps): Update peter-evans/create-pull-request action to v8 (#1282)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-07 22:09:00 +01:00
renovate[bot]
968c3e35d5 chore(deps): Update dependency @react-native-community/cli to v20.1.0 (#1317)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-07 22:08:42 +01:00
renovate[bot]
b822e315a8 chore(deps): Update oven-sh/setup-bun action to v2.1.0 (#1318)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-07 22:08:26 +01:00
Fredrik Burmester
f862a2424e fix(player): restore missing subtitle and audio dropdown for vlc
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
2026-01-07 20:25:32 +01:00
Simon Eklundh
77ee4aca27 chore: Delete .github/workflows/stale.yml (#1254)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
2026-01-07 07:32:43 +01:00
Fredrik Burmester
d4ea8debfa feat: marquee for music title in modal
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
2026-01-06 19:15:17 +01:00
Fredrik Burmester
e1dd410f73 feat: airplay and chromecast for music 2026-01-06 19:10:19 +01:00
Fredrik Burmester
896c7460df refactor(music): move storage clear options 2026-01-06 18:15:53 +01:00
Fredrik Burmester
3453fd22b8 fix: better music modal design and favorite song 2026-01-06 17:55:37 +01:00
Fredrik Burmester
1c3369c61f feat: search for artists, albums and songs
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
2026-01-06 16:40:47 +01:00
Fredrik Burmester
99c9caf03c feat: 3-dot menu open song options 2026-01-06 16:03:25 +01:00
Fredrik Burmester
055357de60 feat: swipe to remove individual server logins 2026-01-06 15:46:12 +01:00
Fredrik Burmester
a24e254a9e fix: better animation 2026-01-06 15:37:13 +01:00
Fredrik Burmester
4bdb8003bb chore: lint 2026-01-06 15:28:58 +01:00
Fredrik Burmester
9825ccce6e fix: width to be same as bottom tab bar ios specifically 2026-01-06 15:27:08 +01:00
Fredrik Burmester
9bfac2a4d9 fix: improve downloaded music queue
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 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-01-05 21:32:41 +01:00
Fredrik Burmester
478052599f chore: version 2026-01-05 21:32:41 +01:00
Fredrik Burmester
37b0b10098 feat: save login credentials when switching servers 2026-01-05 21:32:41 +01:00
Fredrik Burmester
9ca852bb7e fix: intro can't be closed on small phones / large zoom 2026-01-05 21:32:41 +01:00
Fredrik Burmester
a37a8753c2 revert: remove accidentally merged ios widget commits
Reverts commits d58c1a77 (feat: ios widget) and 08fe871d (wip: widget)
that were accidentally merged from feat/ios-widget-build-target branch.
2026-01-05 21:32:41 +01:00
Fredrik Burmester
5eae6e6cd0 fix(websocket): persist reconnect counter across retry attempts
Move reconnectAttempts from local variable to useRef so it persists
across recursive connectWebSocket calls. Previously, each call reset
the counter to 0, causing infinite reconnection attempts instead of
stopping at the max of 5.

Also reset the counter on successful connection (onopen) so fresh
reconnection attempts get full retries.
2026-01-05 21:32:41 +01:00
Fredrik Burmester
e0c408452d fix(auth): await push token deletion before clearing API state on logout
The DELETE request for removing the push token was not awaited,
causing a race condition where setApi(null) could execute while
the request was still in flight. This ensures proper sequencing.
2026-01-05 21:32:41 +01:00
Fredrik Burmester
4c8dfa0e2f wip: widget 2026-01-05 21:32:41 +01:00
Fredrik Burmester
6c95962e12 feat: ios widget 2026-01-05 21:32:41 +01:00
Fredrik Burmester
cbbac3c25c fix(hls): parse M3U8 attributes respecting quoted strings
The previous implementation split on commas without respecting quoted
values, causing subtitles with commas in names (e.g., "English, Forced")
or URIs with special characters to be incorrectly parsed.

Uses regex to properly match key=value pairs where values can be either
quoted (containing any characters except quotes) or unquoted (no commas).
2026-01-05 21:32:41 +01:00
Fredrik Burmester
964d53cc79 feat(music): add haptic feedback to track options menu 2026-01-05 21:32:41 +01:00
Fredrik Burmester
a26980ddab refactor: improve music design 2026-01-05 21:32:41 +01:00
Fredrik Burmester
090ed98233 fix: refresh data on open page in background replace cached data 2026-01-05 21:32:41 +01:00
Fredrik Burmester
24d04c1003 fix: BOOT_COMPLETED runtime protection 2026-01-05 21:32:41 +01:00
Fredrik Burmester
7da52441ab chore: version 2026-01-05 21:32:41 +01:00
Fredrik Burmester
70268e6120 fix: use vlc build with 16kb page size 2026-01-05 21:32:41 +01:00
Fredrik Burmester
96fbb9fe1f fix: target version 35 2026-01-05 21:32:40 +01:00
Fredrik Burmester
3b104b91fc chore: version 2026-01-05 21:32:40 +01:00
Fredrik Burmester
e4134d6f9a fix: item content header button colors 2026-01-05 21:32:40 +01:00
Fredrik Burmester
5b2e7b3883 fix: lower build taget to solve 16kb page requirement
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 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
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
2026-01-05 08:48:57 +01:00
Elias
1fde3c82a3 feat: add Dolby Vision badge (#1177) 2026-01-05 08:48:40 +01:00
Fredrik Burmester
054fb05651 feat: delete playlist
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
2026-01-04 23:01:03 +01:00
Fredrik Burmester
a2058a8009 fix: music downloading not playing + queue drag and drop
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 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
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
2026-01-04 16:28:35 +01:00
Fredrik Burmester
d22827bc9b feat: add toggle to hide streamystats watchlist tab/functions 2026-01-04 14:31:55 +01:00
Fredrik Burmester
4121502bfe fix: remove song from playlist 2026-01-04 13:44:53 +01:00
Fredrik Burmester
b6e59aab01 chore: version 2026-01-04 12:54:28 +01:00
Fredrik Burmester
ab3465aec5 feat: cache and download music 2026-01-04 12:50:41 +01:00
Fredrik Burmester
b1da9f8777 feat: add actions to song rows 2026-01-04 00:09:21 +01:00
Fredrik Burmester
36d24176ae fix: music rows full width 2026-01-03 23:55:52 +01:00
Fredrik Burmester
bfdc2c053b fix: improve gestures 2026-01-03 23:51:27 +01:00
Fredrik Burmester
245c9597c4 feat: music bar geastures 2026-01-03 23:44:15 +01:00
Fredrik Burmester
966a8e8f24 feat: music info badges in music modal 2026-01-03 23:23:40 +01:00
Fredrik Burmester
f941c88457 fix: device profile for music for rntp ios/android 2026-01-03 23:08:46 +01:00
lance chant
bd4e5bb70a fix: jellyseer categories (#1233)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
Co-authored-by: Fredrik Burmester <fredrik.burmester@gmail.com>
2026-01-03 19:58:17 +01:00
Fredrik Burmester
9334263414 chore: update submodule 2026-01-03 19:49:44 +01:00
Fredrik Burmester
4ae3c44d02 fix: bug in playback speed 2026-01-03 19:49:39 +01:00
Fredrik Burmester
4fb3fb195c fix: ignore android build files 2026-01-03 19:49:18 +01:00
Fredrik Burmester
e8089cfd20 fix: key id fail 2026-01-03 19:39:04 +01:00
Fredrik Burmester
039bf9729a feat: hide volume or/and brightness in controls setting 2026-01-03 19:34:45 +01:00
Fredrik Burmester
3ff7c47b7f feat: playback speed options 2026-01-03 19:14:20 +01:00
Fredrik Burmester
1d8d92175a fix: correct control buttions top right for each player
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
2026-01-03 18:22:38 +01:00
Fredrik Burmester
60b0040681 docs: add claude code guidance file 2026-01-03 17:58:33 +01:00
Fredrik Burmester
9cd55cf544 feat: setting to hide the item page header session button 2026-01-03 17:56:31 +01:00
Emre Sanden
090e0cb170 feat: add button to toggle video orientation in player (#743)
Co-authored-by: Gauvain <68083474+Gauvino@users.noreply.github.com>
Co-authored-by: Fredrik Burmester <fredrik.burmester@gmail.com>
2026-01-03 17:52:45 +01:00
Fredrik Burmester
85d707ef45 fix: improve music page design, mostly headers 2026-01-03 17:45:28 +01:00
Fredrik Burmester
792eef20a9 fix(player): hide pip button when vlc player selected 2026-01-03 17:40:34 +01:00
Fredrik Burmester
6487c8b5a1 fix: show loading indicator when pressing song in music 2026-01-03 16:24:23 +01:00
Leo THIVILLON
baa96d222f fix(readme): Add Obtainium button (#1293)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: retardgerman <78982850+retardgerman@users.noreply.github.com>
2026-01-03 16:15:18 +01:00
Fredrik Burmester
74d86b5d12 feat: KSPlayer as an option for iOS + other improvements (#1266) 2026-01-03 13:05:50 +01:00
Cristea Florian Victor
d1795c9df8 fix(player): Fix skip credits seeking past video end causing pause (#1277)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
2025-12-21 10:09:57 +01:00
renovate[bot]
149609f46e chore(deps): Update actions/setup-node action to v6.1.0 (#1262)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-09 22:54:57 +01:00
Simon Eklundh
cf269ba83e chore: Add version 0.47.1 to issue report template (#1251)
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
🔒 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
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
This update keeps the issue report template current by adding version 0.47.1 and removing outdated entries. It reduces confusion for users creating reports and improves triage accuracy for maintainers
2025-12-01 16:27:33 +01:00
Gauvain
24d5fdefdf ci: fix CodeQL checkout (#1170)
Some checks failed
🛡️ 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
🔒 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 / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
2025-11-26 09:56:23 +01:00
lostb1t
c05cef295e refactor: pass down items with sources to children (#1218)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
2025-11-17 21:15:59 +01:00
Fredrik Burmester
3c57829360 feat: prefer downloaded file (#1217)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-16 21:47:51 +01:00
Fredrik Burmester
06349a4319 chore: remove unused files 2025-11-16 21:16:07 +01:00
Fredrik Burmester
55ac9ae9d4 chore: lint
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
2025-11-16 16:41:15 +01:00
Fredrik Burmester
c8bdcc4df0 feat: add snap scroll to horizontal lists 2025-11-16 16:40:42 +01:00
lostb1t
e7013edd84 refactor: Move media sources preload higher up the tree (#1216) 2025-11-16 14:59:09 +01:00
Fredrik Burmester
991b45de06 fix: season episode scroll list snapping and scrolling 2025-11-16 14:36:31 +01:00
Fredrik Burmester
97fe899cb0 feat: approve jellyserr requests (#1214) 2025-11-16 14:24:20 +01:00
lostb1t
86d7642dca fix: show tech details when avaiable (#1213) 2025-11-16 14:22:05 +01:00
Fredrik Burmester
631a5ef94e chore: update + correct deps 2025-11-16 13:36:22 +01:00
renovate[bot]
8b8b928837 chore(deps): Pin dependencies (#1209)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-16 13:32:26 +01:00
lostb1t
56a3c62ed2 feat: move source and track selection to seperate sheet (#1176)
Co-authored-by: Fredrik Burmester <fredrik.burmester@gmail.com>
2025-11-16 13:31:20 +01:00
Fredrik Burmester
82683407da chore: version 2025-11-16 11:25:17 +01:00
Fredrik Burmester
7b146e30bd fix: disable gestures from top and bottom of screen because of interference with notification shade pull down (#1206)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
2025-11-16 10:49:54 +01:00
Fredrik Burmester
5f48bec0f2 feat: vlc apple integration - pause on other media play + controls (#1211) 2025-11-16 10:49:35 +01:00
Fredrik Burmester
94362169b6 fix: version 2025-11-16 10:25:20 +01:00
Fredrik Burmester
8aefdac50f fix: use setupxcode with correct xcode version 2025-11-16 10:24:44 +01:00
Fredrik Burmester
665a79924a fix: use macos 26 runner 2025-11-16 09:57:24 +01:00
Fredrik Burmester
b9ddcf8404 fix: update to latest macos runner 2025-11-16 09:30:37 +01:00
Fredrik Burmester
64ffc8db8b fix: lint issues 2025-11-16 08:50:01 +01:00
680 changed files with 172034 additions and 12576 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

@@ -0,0 +1,70 @@
---
description: Reflect on this session to extract and store learned facts about the codebase
---
Analyze the current conversation to extract useful facts that should be remembered for future sessions. Focus on:
1. **Corrections**: Things the user corrected you about
2. **Clarifications**: Misunderstandings about how the codebase works
3. **Patterns**: Important conventions or patterns you learned
4. **Gotchas**: Surprising behaviors or edge cases discovered
5. **Locations**: Files or code that was hard to find
## Instructions
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:
- 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. If a new category is needed, add it to the index in `CLAUDE.md`
## Fact File Template
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]
```
## Index Entry Format
Append to the appropriate category in the Learned Facts Index section of `CLAUDE.md`:
```
- `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).

48
.claude/learned-facts.md Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -77,13 +77,8 @@ body:
label: Streamyfin Version
description: What version of Streamyfin are you running?
options:
- 0.47.1
- 0.30.2
- 0.29.0
- 0.28.0
- 0.27.0
- 0.26.1
- 0.26.0
- 0.25.0
- older
- TestFlight/Development build
validations:
@@ -116,4 +111,4 @@ body:
id: additional-info
attributes:
label: Additional information
description: Any additional context that might help us understand and reproduce the issue.
description: Any additional context that might help us understand and reproduce the issue.

View File

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

12
.github/crowdin.yml vendored
View File

@@ -1,12 +0,0 @@
"project_id_env": "CROWDIN_PROJECT_ID"
"api_token_env": "CROWDIN_PERSONAL_TOKEN"
"base_path": "."
"preserve_hierarchy": true
"files": [
{
"source": "translations/en.json",
"translation": "translations/%two_letters_code%.json"
}
]

View File

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

19
.github/renovate.json vendored
View File

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

@@ -20,8 +20,20 @@ jobs:
contents: read
steps:
- name: 🗑️ Free Disk Space
uses: BRAINSia/free-disk-space@7048ffbf50819342ac964ef3998a51c2564a8a75 # v2.1.3
with:
tool-cache: false
mandb: true
android: false
dotnet: true
haskell: true
large-packages: true
docker-images: true
swap-storage: false
- name: 📥 Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
@@ -29,12 +41,12 @@ jobs:
show-progress: false
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
- name: 💾 Cache Bun dependencies
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
@@ -48,7 +60,7 @@ jobs:
bun run submodule-reload
- name: 💾 Cache Gradle global
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/.gradle/caches
@@ -61,7 +73,7 @@ jobs:
run: bun run prebuild
- name: 💾 Cache project Gradle (.gradle)
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
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') }}
@@ -76,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@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: streamyfin-android-phone-apk-${{ env.DATE_TAG }}
path: |
@@ -91,8 +103,20 @@ jobs:
contents: read
steps:
- name: 🗑️ Free Disk Space
uses: BRAINSia/free-disk-space@7048ffbf50819342ac964ef3998a51c2564a8a75 # v2.1.3
with:
tool-cache: false
mandb: true
android: false
dotnet: true
haskell: true
large-packages: true
docker-images: true
swap-storage: false
- name: 📥 Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
@@ -100,12 +124,12 @@ jobs:
show-progress: false
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
- name: 💾 Cache Bun dependencies
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
@@ -119,7 +143,7 @@ jobs:
bun run submodule-reload
- name: 💾 Cache Gradle global
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/.gradle/caches
@@ -132,7 +156,7 @@ jobs:
run: bun run prebuild:tv
- name: 💾 Cache project Gradle (.gradle)
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
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') }}
@@ -147,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@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: streamyfin-android-tv-apk-${{ env.DATE_TAG }}
path: |
@@ -156,14 +180,14 @@ jobs:
build-ios-phone:
if: (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin'))
runs-on: macos-15
runs-on: macos-26
name: 🍎 Build iOS IPA (Phone)
permissions:
contents: read
steps:
- name: 📥 Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
@@ -171,12 +195,12 @@ jobs:
show-progress: false
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
- name: 💾 Cache Bun dependencies
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
@@ -191,16 +215,19 @@ jobs:
- name: 🛠️ Generate project files
run: bun run prebuild
- name: 🔧 Setup Xcode
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
with:
# renovate: datasource=custom.xcode depName=xcode versioning=loose
xcode-version: "26.4"
- name: 🏗️ Setup EAS
uses: expo/expo-github-action@main
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main
with:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
eas-cache: true
- name: ⚙️ Ensure iOS SDKs installed
run: xcodebuild -downloadPlatform iOS
- name: 🚀 Build iOS app
env:
EXPO_TV: 0
@@ -210,71 +237,194 @@ jobs:
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
- name: 📤 Upload IPA artifact
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: streamyfin-ios-phone-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-15
# 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 EAS
# uses: expo/expo-github-action@main
# with:
# eas-version: latest
# token: ${{ secrets.EXPO_TOKEN }}
# eas-cache: true
#
# - name: ⚙️ Ensure tvOS SDKs installed
# run: xcodebuild -downloadPlatform tvOS
#
# - 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-phone-unsigned:
if: (!contains(github.event.head_commit.message, '[skip ci]'))
runs-on: macos-26
name: 🍎 Build iOS IPA (Phone - 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
- name: 🔧 Setup Xcode
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
with:
# renovate: datasource=custom.xcode depName=xcode versioning=loose
xcode-version: "26.4"
- name: 🚀 Build iOS app
env:
EXPO_TV: 0
run: bun run ios:unsigned-build ${{ 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-phone-unsigned-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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
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@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
- name: 💾 Cache Bun dependencies
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/.bun/install/cache

View File

@@ -24,20 +24,16 @@ jobs:
steps:
- name: 📥 Checkout repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
show-progress: false
fetch-depth: 0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: 🏁 Initialize CodeQL
uses: github/codeql-action/init@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
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@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
uses: github/codeql-action/autobuild@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
- name: 🧪 Perform CodeQL Analysis
uses: github/codeql-action/analyze@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0

View File

@@ -23,12 +23,12 @@ jobs:
steps:
- name: 📥 Checkout Repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: 🌐 Sync Translations with Crowdin
uses: crowdin/github-action@08713f00a50548bfe39b37e8f44afb53e7a802d4 # v2.12.0
uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2.16.2
with:
upload_sources: true
upload_translations: true
@@ -40,11 +40,12 @@ jobs:
pull_request_base_branch_name: "develop"
pull_request_labels: "🌐 translation"
# Quality control options
skip_untranslated_strings: true
skip_untranslated_strings: false
skip_untranslated_files: false
export_only_approved: false
# Commit customization
commit_message: "feat(i18n): update translations from Crowdin"
env:
GITHUB_TOKEN: ${{ secrets.CROWDIN_GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}

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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
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@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
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@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '24.x'
- name: "🍞 Setup Bun"
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest

View File

@@ -1,4 +1,5 @@
name: 🛎️ Discord Notification
permissions: {}
on:
pull_request:

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

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

View File

@@ -1,49 +0,0 @@
name: 🕒 Handle Stale Issues
on:
schedule:
# Runs daily at 1:30 AM UTC (3:30 AM CEST - France time)
- cron: "30 1 * * *"
jobs:
stale-issues:
name: 🗑️ Cleanup Stale Issues
runs-on: ubuntu-24.04
permissions:
issues: write
pull-requests: write
steps:
- name: 🔄 Mark/Close Stale Issues
uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
with:
# Global settings
repo-token: ${{ secrets.GITHUB_TOKEN }}
operations-per-run: 500 # Increase if you have >1000 issues
enable-statistics: true
# Issue configuration
days-before-issue-stale: 90
days-before-issue-close: 7
stale-issue-label: "🕰️ stale"
exempt-issue-labels: "Roadmap v1,help needed,enhancement"
# Notifications messages
stale-issue-message: |
⏳ This issue has been automatically marked as **stale** because it has had no activity for 90 days.
**Next steps:**
- If this is still relevant, add a comment to keep it open
- Otherwise, it will be closed in 7 days
Thank you for your contributions! 🙌
close-issue-message: |
🚮 This issue has been automatically closed due to inactivity (7 days since being marked stale).
**Need to reopen?**
Click "Reopen" and add a comment explaining why this should stay open.
# Disable PR handling
days-before-pr-stale: -1
days-before-pr-close: -1

View File

@@ -18,17 +18,17 @@ jobs:
steps:
- name: 📥 Checkout repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: "🟢 Setup Node.js"
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.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@271a8d0340265f705b14b6d32b9829c1cb33d45e #v7.0.8
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
with:
add-paths: .github/ISSUE_TEMPLATE/bug_report.yml
branch: ci-update-bug-report

23
.gitignore vendored
View File

@@ -18,8 +18,11 @@ web-build/
/androidmobile
/androidtv
# Gradle caches (top-level + per-module native projects)
**/.gradle/
# Module-specific Builds
modules/vlc-player/android/build
modules/mpv-player/android/build
modules/player/android
modules/hls-downloader/android/build
@@ -50,7 +53,6 @@ npm-debug.*
.idea/
.ruby-lsp
.cursor/
.claude/
# Environment and Configuration
expo-env.d.ts
@@ -62,7 +64,24 @@ 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-*
/modules/sf-player/android/build
/modules/music-controls/android/build
modules/background-downloader/android/build/*
/modules/mpv-player/android/build
# ios:unsigned-build Artifacts
build/
# but keep EAS custom build configs (the generic build/ rule above matches .eas/build/)
!.eas/build/
!.eas/build/**
.claude/
.agents/skills/**
skills-lock.json
# CI-injected Google Play service account key (written at build time)
google-service-account.json

177
.vscode/settings.json vendored
View File

@@ -1,178 +1,25 @@
{
// ==========================================
// FORMATTING & LINTING
// ==========================================
// Biome as default formatter
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true,
"editor.formatOnPaste": true,
"editor.formatOnType": false,
// Language-specific formatters
"[javascript]": {
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true
"editor.codeActionsOnSave": {
"source.fixAll.biome": "explicit"
},
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true
"editor.defaultFormatter": "biomejs.biome"
},
"[typescriptreact]": {
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true
"editor.defaultFormatter": "biomejs.biome"
},
"[javascript]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[javascriptreact]": {
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true
"editor.defaultFormatter": "biomejs.biome"
},
"[json]": {
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true
"editor.defaultFormatter": "biomejs.biome"
},
"[jsonc]": {
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true
},
"[swift]": {
"editor.insertSpaces": true,
"editor.tabSize": 2
},
// ==========================================
// TYPESCRIPT & JAVASCRIPT
// ==========================================
// TypeScript performance optimizations
"typescript.preferences.includePackageJsonAutoImports": "auto",
"typescript.suggest.autoImports": true,
"typescript.updateImportsOnFileMove.enabled": "always",
"typescript.preferences.preferTypeOnlyAutoImports": true,
"typescript.preferences.importModuleSpecifier": "relative",
"typescript.preferences.includeCompletionsForImportStatements": true,
"typescript.preferences.includeCompletionsWithSnippetText": true,
// JavaScript settings
"javascript.preferences.importModuleSpecifier": "relative",
"javascript.suggest.autoImports": true,
"javascript.updateImportsOnFileMove.enabled": "always",
// ==========================================
// REACT NATIVE & EXPO
// ==========================================
// File associations for React Native
"files.associations": {
"*.expo.ts": "typescript",
"*.expo.tsx": "typescriptreact",
"*.expo.js": "javascript",
"*.expo.jsx": "javascriptreact",
"metro.config.js": "javascript",
"babel.config.js": "javascript",
"app.config.js": "javascript",
"eas.json": "jsonc"
},
// React Native specific settings
"emmet.includeLanguages": {
"typescriptreact": "html",
"javascriptreact": "html"
},
"emmet.triggerExpansionOnTab": true,
// Exclude build directories from search
"search.exclude": {
"**/node_modules": true
},
// ==========================================
// EDITOR PERFORMANCE & UX
// ==========================================
// Performance optimizations
"editor.largeFileOptimizations": true,
"files.watcherExclude": {
"**/.git/objects/**": true,
"**/.git/subtree-cache/**": true,
"**/node_modules/**": true,
"**/.expo/**": true,
"**/ios/**": true,
"**/android/**": true,
"**/build/**": true,
"**/dist/**": true
},
// Better editor behavior
"editor.suggestSelection": "first",
"editor.quickSuggestions": {
"strings": true,
"comments": true,
"other": true
},
"editor.snippetSuggestions": "top",
"editor.tabCompletion": "on",
"editor.wordBasedSuggestions": "off",
// ==========================================
// TERMINAL & DEVELOPMENT
// ==========================================
// Terminal settings for Bun (Windows-specific)
"terminal.integrated.profiles.windows": {
"Command Prompt": {
"path": "C:\\Windows\\System32\\cmd.exe",
"env": {
"PATH": "${env:PATH};./node_modules/.bin"
}
}
},
// ==========================================
// WORKSPACE & NAVIGATION
// ==========================================
// Better workspace navigation
"explorer.fileNesting.enabled": true,
"explorer.fileNesting.expand": false,
"explorer.fileNesting.patterns": {
"*.ts": "${capture}.js",
"*.tsx": "${capture}.js",
"*.js": "${capture}.js,${capture}.js.map,${capture}.min.js,${capture}.d.ts",
"*.jsx": "${capture}.js",
"package.json": "package-lock.json,yarn.lock,bun.lock,bun.lockb,.yarnrc,.yarnrc.yml",
"tsconfig.json": "tsconfig.*.json",
".env": ".env.*",
"app.json": "app.config.js,eas.json,expo-env.d.ts",
"README.md": "LICENSE.txt,SECURITY.md,CODE_OF_CONDUCT.md,CONTRIBUTING.md"
},
// Better breadcrumbs and navigation
"breadcrumbs.enabled": true,
"outline.showVariables": true,
"outline.showConstants": true,
// ==========================================
// GIT & VERSION CONTROL
// ==========================================
// Git integration
"git.autofetch": true,
"git.enableSmartCommit": true,
"git.confirmSync": false,
"git.ignoreLimitWarning": true,
// ==========================================
// CODE QUALITY & ERRORS
// ==========================================
// Better error detection
"typescript.validate.enable": true,
"javascript.validate.enable": true,
"editor.codeActionsOnSave": {
"source.fixAll": "explicit",
"source.organizeImports": "explicit"
},
// Problem matcher for better error display
"typescript.tsc.autoDetect": "on"
"typescript.tsdk": "node_modules/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true,
"editor.formatOnSaveMode": "file"
}

290
CLAUDE.md Normal file
View File

@@ -0,0 +1,290 @@
# CLAUDE.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.
## Development Commands
**CRITICAL: Always use `bun` for package management. Never use `npm`, `yarn`, or `npx`.**
```bash
# Setup
bun i && bun run submodule-reload
# Development builds
bun run prebuild # Mobile prebuild
bun run ios # Run iOS
bun run android # Run Android
# TV builds (suffix with :tv)
bun run prebuild:tv
bun run ios:tv
bun run android:tv
# Code quality
bun run typecheck # TypeScript check
bun run check # BiomeJS check
bun run lint # BiomeJS lint + fix
bun run format # BiomeJS format
bun run test # Run all checks (typecheck, lint, format, doctor)
# iOS-specific
bun run ios:install-metal-toolchain # Fix "missing Metal Toolchain" build errors
```
## Tech Stack
- **Runtime**: Bun
- **Framework**: React Native (Expo SDK 54)
- **Language**: TypeScript (strict mode)
- **State Management**: Jotai (global state atoms) + React Query (server state)
- **API**: Jellyfin SDK (`@jellyfin/sdk`)
- **Navigation**: Expo Router (file-based)
- **Linting/Formatting**: BiomeJS
- **Storage**: react-native-mmkv
## Architecture
### File Structure
- `app/` - Expo Router screens with file-based routing
- `components/` - Reusable UI components
- `providers/` - React Context providers
- `hooks/` - Custom React hooks
- `utils/` - Utilities including Jotai atoms
- `modules/` - Native modules (vlc-player, mpv-player, background-downloader)
- `translations/` - i18n translation files
### Key Patterns
**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`
**Jellyfin API Access**:
- Use `apiAtom` from `JellyfinProvider` for authenticated API calls
- Access user via `userAtom`
- Use Jellyfin SDK utilities from `@jellyfin/sdk/lib/utils/api`
**Navigation**:
- File-based routing in `app/` directory
- Tab navigation: `(home)`, `(search)`, `(favorites)`, `(libraries)`, `(watchlists)`
- Shared routes use parenthesized groups like `(home,libraries,search,favorites,watchlists)`
- **IMPORTANT**: Always use `useAppRouter` from `@/hooks/useAppRouter` instead of `useRouter` from `expo-router`. This custom hook automatically handles offline mode state preservation across navigation:
```typescript
// ✅ Correct
import useRouter from "@/hooks/useAppRouter";
const router = useRouter();
// ❌ Never use this
import { useRouter } from "expo-router";
import { router } from "expo-router";
```
**Offline Mode**:
- Use `OfflineModeProvider` from `@/providers/OfflineModeProvider` to wrap pages that support offline content
- Use `useOfflineMode()` hook to check if current context is offline
- The `useAppRouter` hook automatically injects `offline=true` param when navigating within an offline context
**Providers** (wrapping order in `app/_layout.tsx`):
1. JotaiProvider
2. QueryClientProvider
3. JellyfinProvider (auth, API)
4. NetworkStatusProvider
5. PlaySettingsProvider
6. WebSocketProvider
7. DownloadProvider
8. MusicPlayerProvider
### Native Modules
Located in `modules/`:
- `vlc-player` - VLC video player integration
- `mpv-player` - MPV video player integration (iOS)
- `background-downloader` - Background download functionality
- `sf-player` - Swift player module
### Path Aliases
Use `@/` prefix for imports (configured in `tsconfig.json`):
```typescript
import { useSettings } from "@/utils/atoms/settings";
import { apiAtom } from "@/providers/JellyfinProvider";
```
## Coding Standards
- Use TypeScript for all files (no .js)
- Use functional React components with hooks
- Use Jotai atoms for global state, React Query for server state
- Follow BiomeJS formatting rules (2-space indent, semicolons, LF line endings)
- 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
- TV version uses `:tv` suffix for scripts
- Platform checks: `Platform.isTV`, `Platform.OS === "android"` or `"ios"`
- Some features disabled on TV (e.g., notifications, Chromecast)
- **TV Design**: Don't use purple accent colors on TV. Use white for focused states and `expo-blur` (`BlurView`) for backgrounds/overlays.
- **TV Typography**: Use `TVTypography` from `@/components/tv/TVTypography` for all text on TV. It provides consistent font sizes optimized for TV viewing distance.
- **TV Button Sizing**: Ensure buttons placed next to each other have the same size for visual consistency.
- **TV Focus Scale Padding**: Add sufficient padding around focusable items in tables/rows/columns/lists. The focus scale animation (typically 1.05x) will clip against parent containers without proper padding. Use `overflow: "visible"` on containers and add padding to prevent clipping.
- **TV Modals**: Never use React Native's `Modal` component or overlay/absolute-positioned modals for full-screen modals on TV. Use the navigation-based modal pattern instead. **See [docs/tv-modal-guide.md](docs/tv-modal-guide.md) for detailed documentation.**
### TV Component Rendering Pattern
**IMPORTANT**: The `.tv.tsx` file suffix does NOT work in this project - neither for pages nor components. Metro bundler doesn't resolve platform-specific suffixes. Always use `Platform.isTV` conditional rendering instead.
**Pattern for TV-specific pages and components**:
```typescript
// In page file (e.g., app/login.tsx)
import { Platform } from "react-native";
import { Login } from "@/components/login/Login";
import { TVLogin } from "@/components/login/TVLogin";
const LoginPage: React.FC = () => {
if (Platform.isTV) {
return <TVLogin />;
}
return <Login />;
};
export default LoginPage;
```
- Create separate component files for mobile and TV (e.g., `MyComponent.tsx` and `TVMyComponent.tsx`)
- Use `Platform.isTV` to conditionally render the appropriate component
- TV components typically use `TVInput`, `TVServerCard`, and other TV-prefixed components with focus handling
- **Never use `.tv.tsx` file suffix** - it will not be resolved correctly
### TV Option Selectors and Focus Management
For dropdown/select components, bottom sheets, and overlay focus management on TV, see [docs/tv-modal-guide.md](docs/tv-modal-guide.md).
### TV Focus Flickering Between Zones (Lists with Headers)
When you have a page with multiple focusable zones (e.g., a filter bar above a grid), the TV focus engine can rapidly flicker between elements when navigating between zones. This is a known issue with React Native TV.
**Solutions:**
1. **Use FlatList instead of FlashList for TV** - FlashList has known focus issues on TV platforms. Use regular FlatList with `Platform.isTV` check:
```typescript
{Platform.isTV ? (
<FlatList
data={items}
renderItem={renderTVItem}
removeClippedSubviews={false}
// ...
/>
) : (
<FlashList data={items} renderItem={renderItem} />
)}
```
2. **Add `removeClippedSubviews={false}`** - Prevents the list from unmounting off-screen items, which can cause focus to "fall through" to other elements.
3. **Only ONE element should have `hasTVPreferredFocus`** - Never have multiple elements competing for initial focus. Choose one element (usually the first filter button or first list item) to have preferred focus:
```typescript
// ✅ Good - only first filter button has preferred focus
<TVFilterButton hasTVPreferredFocus={index === 0} />
<TVFocusablePoster /> // No hasTVPreferredFocus
// ❌ Bad - both compete for focus
<TVFilterButton hasTVPreferredFocus />
<TVFocusablePoster hasTVPreferredFocus={index === 0} />
```
4. **Keep headers/filter bars outside the list** - Instead of using `ListHeaderComponent`, render the filter bar as a separate View above the FlatList:
```typescript
<View style={{ flex: 1 }}>
{/* Filter bar - separate from list */}
<View style={{ flexDirection: "row", gap: 12 }}>
<TVFilterButton />
<TVFilterButton />
</View>
{/* Grid */}
<FlatList data={items} renderItem={renderTVItem} />
</View>
```
5. **Avoid multiple scrollable containers** - Don't use ScrollView for the filter bar if you have a FlatList below. Use a simple View instead to prevent focus conflicts between scrollable containers.
**Reference implementation**: See `app/(auth)/(tabs)/(libraries)/[libraryId].tsx` for the TV filter bar + grid pattern.
### TV Focus Guide Navigation (Non-Adjacent Sections)
When you need focus to navigate between sections that aren't geometrically aligned (e.g., left-aligned buttons to a horizontal ScrollView), use `TVFocusGuideView` with the `destinations` prop:
```typescript
// 1. Track destination with useState (NOT useRef - won't trigger re-renders)
const [firstCardRef, setFirstCardRef] = useState<View | null>(null);
// 2. Place invisible focus guide between sections
{firstCardRef && (
<TVFocusGuideView
destinations={[firstCardRef]}
style={{ height: 1, width: "100%" }}
/>
)}
// 3. Target component must use forwardRef
const MyCard = React.forwardRef<View, Props>(({ ... }, ref) => (
<Pressable ref={ref} ...>
...
</Pressable>
));
// 4. Pass state setter as callback ref to first item
{items.map((item, index) => (
<MyCard
ref={index === 0 ? setFirstCardRef : undefined}
...
/>
))}
```
**For detailed documentation and bidirectional navigation patterns, see [docs/tv-focus-guide.md](docs/tv-focus-guide.md)**
**Reference implementation**: See `components/ItemContent.tv.tsx` for bidirectional focus navigation between playback options and cast list.

View File

@@ -5,6 +5,12 @@
<img src="https://raw.githubusercontent.com/streamyfin/.github/refs/heads/main/streamyfin-github-banner.png" alt="Streamyfin" width="100%">
</p>
<p align="center">
<a href="https://discord.gg/aJvAYeycyY">
<img alt="Streamyfin Discord" src="https://img.shields.io/badge/Discord-Streamyfin-blue?style=flat-square&logo=discord">
</a>
</p>
**Streamyfin is a user-friendly Jellyfin video streaming client built with Expo. Designed as an alternative to other Jellyfin clients, it aims to offer a smooth and reliable streaming experience. We hope you'll find it a valuable addition to your media streaming toolbox.**
---
@@ -54,6 +60,11 @@ The Jellyfin Plugin for Streamyfin is a plugin you install into Jellyfin that ho
Chromecast support is currently under development. Video casting is already available, and we're actively working on adding subtitle support and additional features.
### 🎬 MPV Player
Streamyfin uses [MPV](https://mpv.io/) as its primary video player on all platforms, powered by [MPVKit](https://github.com/mpvkit/MPVKit). MPV is a powerful, open-source media player known for its wide format support and high-quality playback.
Thanks to [@Alexk2309](https://github.com/Alexk2309) for the hard work building the native MPV module in Streamyfin.
### 🔍 Jellysearch
[Jellysearch](https://gitlab.com/DomiStyle/jellysearch) works with Streamyfin
@@ -70,6 +81,7 @@ Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) To
<a href="https://apps.apple.com/app/streamyfin/id6593660679?l=en-GB"><img height=50 alt="Get Streamyfin on App Store" src="./assets/Download_on_the_App_Store_Badge.png"/></a>
<a href="https://play.google.com/store/apps/details?id=com.fredrikburmester.streamyfin"><img height=50 alt="Get Streamyfin on Google Play Store" src="./assets/Google_Play_Store_badge_EN.svg"/></a>
<a href="https://github.com/streamyfin/streamyfin/releases/latest"><img height=50 alt="Get Streamyfin on Github" src="./assets/Download_on_Github_.png"/></a>
<a href="https://apps.obtainium.imranr.dev/redirect.html?r=obtainium://add/https://github.com/streamyfin/streamyfin"><img height=50 alt="Add Streamyfin to Obtainium" src="./assets/Download_with_Obtainium.png"/></a>
</div>
### 🧪 Beta Testing
@@ -104,6 +116,7 @@ You can contribute translations directly on our [Crowdin project page](https://c
1. Use node `>20`
2. Install dependencies `bun i && bun run submodule-reload`
3. Make sure you have xcode and/or android studio installed. (follow the guides for expo: https://docs.expo.dev/workflow/android-studio-emulator/)
- If iOS builds fail with `missing Metal Toolchain` (KSPlayer shaders), run `npm run ios:install-metal-toolchain` once
4. Install BiomeJS extension in VSCode/Your IDE (https://biomejs.dev/)
4. run `npm run prebuild`
5. Create an expo dev build by running `npm run ios` or `npm run android`. This will open a simulator on your computer and run the app
@@ -113,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?
@@ -228,6 +245,7 @@ We also thank all other developers who have contributed to Streamyfin, your effo
A special mention to the following people and projects for their contributions:
- [@Alexk2309](https://github.com/Alexk2309) for building the native MPV module that integrates [MPVKit](https://github.com/mpvkit/MPVKit) with React Native
- [Reiverr](https://github.com/aleksilassila/reiverr) for invaluable help with understanding the Jellyfin API
- [Jellyfin TS SDK](https://github.com/jellyfin/jellyfin-sdk-typescript) for providing the TypeScript SDK
- [Seerr](https://github.com/seerr-team/seerr) for enabling API integration with their project

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.47.1",
"version": "0.54.1",
"orientation": "default",
"icon": "./assets/images/icon.png",
"scheme": "streamyfin",
"userInterfaceStyle": "dark",
"jsEngine": "hermes",
"newArchEnabled": true,
"assetBundlePatterns": ["**/*"],
"ios": {
"requireFullScreen": true,
@@ -17,24 +15,27 @@
"NSMicrophoneUsageDescription": "The app needs access to your microphone.",
"UIBackgroundModes": ["audio", "fetch"],
"NSLocalNetworkUsageDescription": "The app needs access to your local network to connect to your Jellyfin server.",
"NSLocationWhenInUseUsageDescription": "Streamyfin uses your location to detect your home WiFi network for automatic local server switching.",
"NSAppTransportSecurity": {
"NSAllowsArbitraryLoads": true
},
"UISupportsTrueScreenSizeOnMac": true,
"UIFileSharingEnabled": true,
"LSSupportsOpeningDocumentsInPlace": true
"LSSupportsOpeningDocumentsInPlace": true,
"AVInitialRouteSharingPolicy": "LongFormAudio"
},
"config": {
"usesNonExemptEncryption": false
},
"supportsTablet": true,
"entitlements": {
"com.apple.developer.networking.wifi-info": true
},
"bundleIdentifier": "com.fredrikburmester.streamyfin",
"icon": "./assets/images/icon-ios-liquid-glass.icon",
"appleTeamId": "MWD5K362T8"
},
"android": {
"jsEngine": "hermes",
"versionCode": 84,
"adaptiveIcon": {
"foregroundImage": "./assets/images/icon-android-plain.png",
"monochromeImage": "./assets/images/icon-android-themed.png",
@@ -44,42 +45,49 @@
"permissions": [
"android.permission.FOREGROUND_SERVICE",
"android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK",
"android.permission.WRITE_SETTINGS"
"android.permission.WRITE_SETTINGS",
"android.permission.ACCESS_FINE_LOCATION"
],
"blockedPermissions": ["android.permission.ACTIVITY_RECOGNITION"],
"googleServicesFile": "./google-services.json"
},
"plugins": [
"@react-native-tvos/config-tv",
"expo-router",
"expo-font",
[
"react-native-video",
"@react-native-tvos/config-tv",
{
"enableNotificationControls": true,
"enableBackgroundAudio": true,
"androidExtensions": {
"useExoplayerRtsp": false,
"useExoplayerSmoothStreaming": false,
"useExoplayerHls": true,
"useExoplayerDash": false
"appleTVImages": {
"icon": "./assets/images/icon-tvos.png",
"iconSmall": "./assets/images/icon-tvos-small.png",
"iconSmall2x": "./assets/images/icon-tvos-small-2x.png",
"topShelf": "./assets/images/icon-tvos-topshelf.png",
"topShelf2x": "./assets/images/icon-tvos-topshelf-2x.png",
"topShelfWide": "./assets/images/icon-tvos-topshelf-wide.png",
"topShelfWide2x": "./assets/images/icon-tvos-topshelf-wide-2x.png"
},
"infoPlist": {
"UIAppSupportsHDR": true
}
}
],
"expo-router",
"expo-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"],
"compileSdkVersion": 35,
"buildArchs": ["arm64-v8a", "x86_64", "armeabi-v7a"],
"compileSdkVersion": 36,
"targetSdkVersion": 35,
"buildToolsVersion": "35.0.0",
"kotlinVersion": "2.0.21",
"minSdkVersion": 24,
"kotlinVersion": "2.1.20",
"minSdkVersion": 26,
"usesCleartextTraffic": true,
"packagingOptions": {
"jniLibs": {
@@ -97,12 +105,6 @@
"initialOrientation": "DEFAULT"
}
],
[
"expo-sensors",
{
"motionPermission": "Allow Streamyfin to access your device motion for landscape video watching."
}
],
"expo-localization",
"expo-asset",
[
@@ -131,9 +133,20 @@
"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/withGradleProperties.js"],
["./plugins/withTVOSAppIcon.js"],
["./plugins/withTVOSTopShelf.js"],
["./plugins/withTVXcodeEnv.js"],
[
"./plugins/withGitPod.js",
{
"podName": "MPVKit",
"podspecUrl": "https://raw.githubusercontent.com/mpv-ios/MPVKit/0.41.0-av/MPVKit.podspec"
}
]
],
"experiments": {
"typedRoutes": true

View File

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

View File

@@ -16,7 +16,7 @@ export interface MenuLink {
icon: string;
}
export default function menuLinks() {
export default function CustomLinksPage() {
const [api] = useAtom(apiAtom);
const insets = useSafeAreaInsets();
const [menuLinks, setMenuLinks] = useState<MenuLink[]>([]);
@@ -29,7 +29,7 @@ export default function menuLinks() {
);
const config = response?.data;
if (!config && !Object.hasOwn(config, "menuLinks")) {
if (!config || !Object.hasOwn(config, "menuLinks")) {
console.error("Menu links not found");
return;
}

View File

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

View File

@@ -0,0 +1,212 @@
import type { Api } from "@jellyfin/sdk";
import type {
BaseItemDto,
BaseItemKind,
} from "@jellyfin/sdk/lib/generated-client";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { FlashList } from "@shopify/flash-list";
import { useInfiniteQuery } from "@tanstack/react-query";
import { Stack, useLocalSearchParams } from "expo-router";
import { t } from "i18next";
import { useAtom } from "jotai";
import { useCallback, useMemo } from "react";
import { useWindowDimensions, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import { ItemCardText } from "@/components/ItemCardText";
import { Loader } from "@/components/Loader";
import { ItemPoster } from "@/components/posters/ItemPoster";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
type FavoriteTypes =
| "Series"
| "Movie"
| "Episode"
| "Video"
| "BoxSet"
| "Playlist";
const favoriteTypes: readonly FavoriteTypes[] = [
"Series",
"Movie",
"Episode",
"Video",
"BoxSet",
"Playlist",
] as const;
function isFavoriteType(value: unknown): value is FavoriteTypes {
return (
typeof value === "string" &&
(favoriteTypes as readonly string[]).includes(value)
);
}
export default function FavoritesSeeAllScreen() {
const insets = useSafeAreaInsets();
const { width: screenWidth } = useWindowDimensions();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const searchParams = useLocalSearchParams<{
type?: string;
title?: string;
}>();
const typeParam = searchParams.type;
const titleParam = searchParams.title;
const itemType = useMemo(() => {
if (!isFavoriteType(typeParam)) return null;
return typeParam as BaseItemKind;
}, [typeParam]);
const headerTitle = useMemo(() => {
if (typeof titleParam === "string" && titleParam.trim().length > 0)
return titleParam;
return "";
}, [titleParam]);
const pageSize = 50;
const fetchItems = useCallback(
async ({ pageParam }: { pageParam: number }): Promise<BaseItemDto[]> => {
if (!api || !user?.Id || !itemType) return [];
const response = await getItemsApi(api as Api).getItems({
userId: user.Id,
sortBy: ["SeriesSortName", "SortName"],
sortOrder: ["Ascending"],
filters: ["IsFavorite"],
recursive: true,
fields: ["PrimaryImageAspectRatio"],
collapseBoxSetItems: false,
excludeLocationTypes: ["Virtual"],
enableTotalRecordCount: true,
startIndex: pageParam,
limit: pageSize,
includeItemTypes: [itemType],
});
return response.data.Items || [];
},
[api, itemType, user?.Id],
);
const { data, isFetching, fetchNextPage, hasNextPage, isLoading } =
useInfiniteQuery({
queryKey: ["favorites", "see-all", itemType],
queryFn: ({ pageParam = 0 }) => fetchItems({ pageParam }),
getNextPageParam: (lastPage, pages) => {
if (!lastPage || lastPage.length < pageSize) return undefined;
return pages.reduce((acc, page) => acc + page.length, 0);
},
initialPageParam: 0,
enabled: !!api && !!user?.Id && !!itemType,
});
const flatData = useMemo(() => data?.pages.flat() ?? [], [data]);
const nrOfCols = useMemo(() => {
if (screenWidth < 350) return 2;
if (screenWidth < 600) return 3;
if (screenWidth < 900) return 5;
return 6;
}, [screenWidth]);
const renderItem = useCallback(
({ item, index }: { item: BaseItemDto; index: number }) => (
<TouchableItemRouter
item={item}
style={{
width: "100%",
}}
>
<View
style={{
alignSelf:
index % nrOfCols === 0
? "flex-end"
: (index + 1) % nrOfCols === 0
? "flex-start"
: "center",
width: "89%",
}}
>
<ItemPoster item={item} />
<ItemCardText item={item} />
</View>
</TouchableItemRouter>
),
[nrOfCols],
);
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
const handleEndReached = useCallback(() => {
if (hasNextPage) {
fetchNextPage();
}
}, [fetchNextPage, hasNextPage]);
return (
<>
<Stack.Screen
options={{
headerTitle: headerTitle,
headerBlurEffect: "none",
headerTransparent: true,
headerShadowVisible: false,
}}
/>
{!itemType ? (
<View className='flex-1 items-center justify-center px-6'>
<Text className='text-neutral-500'>
{t("favorites.noData", { defaultValue: "No items found." })}
</Text>
</View>
) : isLoading ? (
<View className='justify-center items-center h-full'>
<Loader />
</View>
) : (
<FlashList
data={flatData}
renderItem={renderItem}
keyExtractor={keyExtractor}
numColumns={nrOfCols}
onEndReached={handleEndReached}
onEndReachedThreshold={0.8}
contentInsetAdjustmentBehavior='automatic'
contentContainerStyle={{
paddingBottom: 24,
paddingLeft: insets.left,
paddingRight: insets.right,
}}
ItemSeparatorComponent={() => (
<View
style={{
width: 10,
height: 10,
}}
/>
)}
ListEmptyComponent={
<View className='flex flex-col items-center justify-center h-full py-12'>
<Text className='font-bold text-xl text-neutral-500'>
{t("home.no_items", { defaultValue: "No items" })}
</Text>
</View>
}
ListFooterComponent={
isFetching ? (
<View style={{ paddingVertical: 16 }}>
<Loader />
</View>
) : null
}
/>
)}
</>
);
}

View File

@@ -1,12 +1,15 @@
import { Feather, Ionicons } from "@expo/vector-icons";
import { Stack, useRouter } from "expo-router";
import { Stack } from "expo-router";
import { useTranslation } from "react-i18next";
import { Platform, TouchableOpacity, View } from "react-native";
import { Platform, View } from "react-native";
import { Pressable } from "react-native-gesture-handler";
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
import useRouter from "@/hooks/useAppRouter";
const Chromecast = Platform.isTV ? null : require("@/components/Chromecast");
import { useAtom } from "jotai";
import { HeaderBackButton } from "@/components/common/HeaderBackButton";
import { useSessions, type useSessionsProps } from "@/hooks/useSessions";
import { userAtom } from "@/providers/JellyfinProvider";
@@ -41,164 +44,125 @@ export default function IndexLayout() {
<Stack.Screen
name='downloads/index'
options={{
headerShown: true,
headerShown: !Platform.isTV,
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
title: t("home.downloads.downloads_title"),
headerLeft: () => (
<TouchableOpacity
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),
}}
/>
<Stack.Screen
name='downloads/[seriesId]'
options={{
headerShown: true,
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
title: t("home.downloads.tvseries"),
headerLeft: () => (
<TouchableOpacity
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),
headerLeft: () => <HeaderBackButton />,
}}
/>
<Stack.Screen
name='sessions/index'
options={{
title: t("home.sessions.title"),
headerShown: true,
headerShown: !Platform.isTV,
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<TouchableOpacity
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),
headerLeft: () => <HeaderBackButton />,
}}
/>
<Stack.Screen
name='settings'
options={{
title: t("home.settings.settings_title"),
headerShown: !Platform.isTV,
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<TouchableOpacity
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
</Pressable>
),
}}
/>
<Stack.Screen
name='companion-login'
options={{
title: t("companion_login.title"),
headerShown: !Platform.isTV,
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => <HeaderBackButton />,
}}
/>
<Stack.Screen
name='settings/playback-controls/page'
options={{
title: t("home.settings.playback_controls.title"),
headerShown: !Platform.isTV,
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<TouchableOpacity
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),
headerLeft: () => <HeaderBackButton />,
}}
/>
<Stack.Screen
name='settings/audio-subtitles/page'
options={{
title: t("home.settings.audio_subtitles.title"),
headerShown: !Platform.isTV,
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<TouchableOpacity
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),
headerLeft: () => <HeaderBackButton />,
}}
/>
<Stack.Screen
name='settings/appearance/page'
options={{
title: t("home.settings.appearance.title"),
headerShown: !Platform.isTV,
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<TouchableOpacity
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),
headerLeft: () => <HeaderBackButton />,
}}
/>
<Stack.Screen
name='settings/music/page'
options={{
title: t("home.settings.music.title"),
headerShown: !Platform.isTV,
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => <HeaderBackButton />,
}}
/>
<Stack.Screen
name='settings/appearance/hide-libraries/page'
options={{
title: t("home.settings.other.hide_libraries"),
headerShown: !Platform.isTV,
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<TouchableOpacity
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),
headerLeft: () => <HeaderBackButton />,
}}
/>
<Stack.Screen
name='settings/plugins/page'
options={{
title: t("home.settings.plugins.plugins_title"),
headerShown: !Platform.isTV,
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<TouchableOpacity
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
</Pressable>
),
}}
/>
@@ -206,85 +170,77 @@ export default function IndexLayout() {
name='settings/plugins/marlin-search/page'
options={{
title: "Marlin Search",
headerShown: !Platform.isTV,
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<TouchableOpacity
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),
headerLeft: () => <HeaderBackButton />,
}}
/>
<Stack.Screen
name='settings/plugins/jellyseerr/page'
options={{
title: "Jellyseerr",
headerShown: !Platform.isTV,
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<TouchableOpacity
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),
headerLeft: () => <HeaderBackButton />,
}}
/>
<Stack.Screen
name='settings/plugins/streamystats/page'
options={{
title: "Streamystats",
headerShown: !Platform.isTV,
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => <HeaderBackButton />,
}}
/>
<Stack.Screen
name='settings/plugins/kefinTweaks/page'
options={{
title: "KefinTweaks",
headerShown: !Platform.isTV,
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => <HeaderBackButton />,
}}
/>
<Stack.Screen
name='settings/intro/page'
options={{
title: t("home.settings.intro.title"),
headerShown: !Platform.isTV,
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<TouchableOpacity
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),
headerLeft: () => <HeaderBackButton />,
}}
/>
<Stack.Screen
name='settings/logs/page'
options={{
title: t("home.settings.logs.logs_title"),
headerShown: !Platform.isTV,
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<TouchableOpacity
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),
headerLeft: () => <HeaderBackButton />,
}}
/>
<Stack.Screen
name='intro/page'
name='settings/network/page'
options={{
headerShown: false,
title: "",
headerLeft: () => (
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),
presentation: "modal",
title: t("home.settings.network.title"),
headerShown: !Platform.isTV,
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => <HeaderBackButton />,
}}
/>
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
@@ -294,12 +250,8 @@ export default function IndexLayout() {
name='collections/[collectionId]'
options={{
title: "",
headerLeft: () => (
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),
headerShown: true,
headerLeft: () => <HeaderBackButton />,
headerShown: !Platform.isTV,
headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
@@ -313,13 +265,13 @@ const SettingsButton = () => {
const router = useRouter();
return (
<TouchableOpacity
<Pressable
onPress={() => {
router.push("/(auth)/settings");
}}
>
<Feather name='settings' color={"white"} size={22} />
</TouchableOpacity>
</Pressable>
);
};
@@ -328,7 +280,7 @@ const SessionsButton = () => {
const { sessions = [] } = useSessions({} as useSessionsProps);
return (
<TouchableOpacity
<Pressable
onPress={() => {
router.push("/(auth)/sessions");
}}
@@ -339,6 +291,6 @@ const SessionsButton = () => {
color={sessions.length === 0 ? "white" : "#9333ea"}
size={28}
/>
</TouchableOpacity>
</Pressable>
);
};

View File

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

View File

@@ -1,181 +0,0 @@
import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { FlashList } from "@shopify/flash-list";
import { router, useLocalSearchParams, useNavigation } from "expo-router";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Alert, Platform, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { EpisodeCard } from "@/components/downloads/EpisodeCard";
import {
SeasonDropdown,
type SeasonIndexState,
} from "@/components/series/SeasonDropdown";
import { useDownload } from "@/providers/DownloadProvider";
import { storage } from "@/utils/mmkv";
export default function page() {
const navigation = useNavigation();
const local = useLocalSearchParams();
const { seriesId, episodeSeasonIndex } = local as {
seriesId: string;
episodeSeasonIndex: number | string | undefined;
};
const [seasonIndexState, setSeasonIndexState] = useState<SeasonIndexState>(
{},
);
const { downloadedItems, deleteItems } = useDownload();
const insets = useSafeAreaInsets();
const series = useMemo(() => {
try {
return (
downloadedItems
?.filter((f) => f.item.SeriesId === seriesId)
?.sort(
(a, b) =>
(a.item.ParentIndexNumber ?? 0) - (b.item.ParentIndexNumber ?? 0),
) || []
);
} catch {
return [];
}
}, [downloadedItems, seriesId]);
// Group episodes by season in a single pass
const seasonGroups = useMemo(() => {
const groups: Record<number, BaseItemDto[]> = {};
series.forEach((episode) => {
const seasonNumber = episode.item.ParentIndexNumber;
if (seasonNumber !== undefined && seasonNumber !== null) {
if (!groups[seasonNumber]) {
groups[seasonNumber] = [];
}
groups[seasonNumber].push(episode.item);
}
});
// Sort episodes within each season
Object.values(groups).forEach((episodes) => {
episodes.sort((a, b) => (a.IndexNumber || 0) - (b.IndexNumber || 0));
});
return groups;
}, [series]);
// Get unique seasons (just the season numbers, sorted)
const uniqueSeasons = useMemo(() => {
const seasonNumbers = Object.keys(seasonGroups)
.map(Number)
.sort((a, b) => a - b);
return seasonNumbers.map((seasonNum) => seasonGroups[seasonNum][0]); // First episode of each season
}, [seasonGroups]);
const seasonIndex =
seasonIndexState[series?.[0]?.item?.ParentId ?? ""] ??
episodeSeasonIndex ??
series?.[0]?.item?.ParentIndexNumber ??
"";
const groupBySeason = useMemo<BaseItemDto[]>(() => {
return seasonGroups[Number(seasonIndex)] ?? [];
}, [seasonGroups, seasonIndex]);
const initialSeasonIndex = useMemo(
() =>
groupBySeason?.[0]?.ParentIndexNumber ??
series?.[0]?.item?.ParentIndexNumber,
[groupBySeason, series],
);
useEffect(() => {
if (series.length > 0) {
navigation.setOptions({
title: series[0].item.SeriesName,
});
} else {
storage.remove(seriesId);
router.back();
}
}, [series]);
const deleteSeries = useCallback(() => {
Alert.alert(
"Delete season",
"Are you sure you want to delete the entire season?",
[
{
text: "Cancel",
style: "cancel",
},
{
text: "Delete",
onPress: () =>
deleteItems(
groupBySeason
.map((item) => item.Id)
.filter((id) => id !== undefined),
),
style: "destructive",
},
],
);
}, [groupBySeason, deleteItems]);
const ListHeaderComponent = useCallback(() => {
if (series.length === 0) return null;
return (
<View className='flex flex-row items-center justify-start pb-2'>
<SeasonDropdown
item={series[0].item}
seasons={uniqueSeasons}
state={seasonIndexState}
initialSeasonIndex={initialSeasonIndex!}
onSelect={(season) => {
setSeasonIndexState((prev) => ({
...prev,
[series[0].item.ParentId ?? ""]: season.ParentIndexNumber,
}));
}}
/>
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center ml-2'>
<Text className='text-xs font-bold'>{groupBySeason.length}</Text>
</View>
<View className='bg-neutral-800/80 rounded-full h-9 w-9 flex items-center justify-center ml-auto'>
<TouchableOpacity onPress={deleteSeries}>
<Ionicons name='trash' size={20} color='white' />
</TouchableOpacity>
</View>
</View>
);
}, [
series,
uniqueSeasons,
seasonIndexState,
initialSeasonIndex,
groupBySeason,
deleteSeries,
]);
return (
<View className='flex-1'>
<FlashList
key={seasonIndex}
data={groupBySeason}
renderItem={({ item }) => <EpisodeCard item={item} />}
keyExtractor={(item, index) => item.Id ?? `episode-${index}`}
ListHeaderComponent={ListHeaderComponent}
contentInsetAdjustmentBehavior='automatic'
contentContainerStyle={{
paddingHorizontal: 16,
paddingLeft: insets.left + 16,
paddingRight: insets.right + 16,
paddingTop: Platform.OS === "android" ? 10 : 8,
}}
/>
</View>
);
}

View File

@@ -1,29 +1,32 @@
import { BottomSheetModal } from "@gorhom/bottom-sheet";
import { useNavigation, useRouter } from "expo-router";
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import { useNavigation } from "expo-router";
import { useAtom } from "jotai";
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Alert,
Platform,
ScrollView,
TouchableOpacity,
View,
} from "react-native";
import { Alert, Platform, ScrollView, View } from "react-native";
import { Pressable } from "react-native-gesture-handler";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { toast } from "sonner-native";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import ActiveDownloads from "@/components/downloads/ActiveDownloads";
import { DownloadSize } from "@/components/downloads/DownloadSize";
import { MovieCard } from "@/components/downloads/MovieCard";
import { SeriesCard } from "@/components/downloads/SeriesCard";
import useRouter from "@/hooks/useAppRouter";
import { useDownload } from "@/providers/DownloadProvider";
import { type DownloadedItem } from "@/providers/Downloads/types";
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
import { queueAtom } from "@/utils/atoms/queue";
import { writeToLog } from "@/utils/log";
export default function page() {
export default function DownloadsPage() {
const navigation = useNavigation();
const { t } = useTranslation();
const [_queue, _setQueue] = useAtom(queueAtom);
@@ -103,12 +106,12 @@ export default function page() {
useEffect(() => {
navigation.setOptions({
headerRight: () => (
<TouchableOpacity
onPress={bottomSheetModalRef.current?.present}
<Pressable
onPress={() => bottomSheetModalRef.current?.present()}
className='px-2'
>
<DownloadSize items={downloadedFiles?.map((f) => f.item) || []} />
</TouchableOpacity>
</Pressable>
),
});
}, [downloadedFiles]);
@@ -119,7 +122,7 @@ export default function page() {
}
}, [showMigration]);
const _deleteMovies = () =>
const deleteMovies = () =>
deleteFileByType("Movie")
.then(() =>
toast.success(
@@ -130,7 +133,7 @@ export default function page() {
writeToLog("ERROR", reason);
toast.error(t("home.downloads.toasts.failed_to_delete_all_movies"));
});
const _deleteShows = () =>
const deleteShows = () =>
deleteFileByType("Episode")
.then(() =>
toast.success(
@@ -141,7 +144,7 @@ export default function page() {
writeToLog("ERROR", reason);
toast.error(t("home.downloads.toasts.failed_to_delete_all_tvseries"));
});
const _deleteOtherMedia = () =>
const deleteOtherMedia = () =>
Promise.all(
otherMedia
.filter((item) => item.item.Type)
@@ -165,146 +168,139 @@ export default function page() {
),
);
const deleteAllMedia = async () =>
await Promise.all([deleteMovies(), deleteShows(), deleteOtherMedia()]);
return (
<ScrollView
showsVerticalScrollIndicator={false}
contentInsetAdjustmentBehavior='automatic'
>
<View style={{ paddingTop: Platform.OS === "android" ? 17 : 0 }}>
<View className='mb-4 flex flex-col space-y-4 px-4'>
{/* Queue card - hidden */}
{/* <View className='bg-neutral-900 p-4 rounded-2xl'>
<OfflineModeProvider isOffline={true}>
<ScrollView
showsVerticalScrollIndicator={false}
contentInsetAdjustmentBehavior='automatic'
>
<View style={{ paddingTop: Platform.OS === "android" ? 17 : 0 }}>
<View className='mb-4 flex flex-col space-y-4 px-4'>
<ActiveDownloads />
</View>
{movies.length > 0 && (
<View className='mb-4'>
<View className='flex flex-row items-center justify-between mb-2 px-4'>
<Text className='text-lg font-bold'>
{t("home.downloads.queue")}
{t("home.downloads.movies")}
</Text>
<Text className='text-xs opacity-70 text-red-600'>
{t("home.downloads.queue_hint")}
</Text>
<View className='flex flex-col space-y-2 mt-2'>
{queue.map((q, index) => (
<TouchableOpacity
onPress={() =>
router.push(`/(auth)/items/page?id=${q.item.Id}`)
}
className='relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between'
key={index}
>
<View>
<Text className='font-semibold'>{q.item.Name}</Text>
<Text className='text-xs opacity-50'>
{q.item.Type}
</Text>
</View>
<TouchableOpacity
onPress={() => {
removeProcess(q.id);
setQueue((prev) => {
if (!prev) return [];
return [...prev.filter((i) => i.id !== q.id)];
});
}}
>
<Ionicons name='close' size={24} color='red' />
</TouchableOpacity>
</TouchableOpacity>
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
<Text className='text-xs font-bold'>{movies?.length}</Text>
</View>
</View>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className='px-4 flex flex-row'>
{movies?.map((item) => (
<TouchableItemRouter item={item.item} key={item.item.Id}>
<MovieCard item={item.item} />
</TouchableItemRouter>
))}
</View>
{queue.length === 0 && (
<Text className='opacity-50'>
{t("home.downloads.no_items_in_queue")}
</Text>
)}
</View> */}
<ActiveDownloads />
</View>
{movies.length > 0 && (
<View className='mb-4'>
<View className='flex flex-row items-center justify-between mb-2 px-4'>
<Text className='text-lg font-bold'>
{t("home.downloads.movies")}
</Text>
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
<Text className='text-xs font-bold'>{movies?.length}</Text>
</View>
</ScrollView>
</View>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className='px-4 flex flex-row'>
{movies?.map((item) => (
<TouchableItemRouter
item={item.item}
isOffline
key={item.item.Id}
>
<MovieCard item={item.item} />
</TouchableItemRouter>
))}
</View>
</ScrollView>
</View>
)}
{groupedBySeries.length > 0 && (
<View className='mb-4'>
<View className='flex flex-row items-center justify-between mb-2 px-4'>
<Text className='text-lg font-bold'>
{t("home.downloads.tvseries")}
</Text>
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
<Text className='text-xs font-bold'>
{groupedBySeries?.length}
)}
{groupedBySeries.length > 0 && (
<View className='mb-4'>
<View className='flex flex-row items-center justify-between mb-2 px-4'>
<Text className='text-lg font-bold'>
{t("home.downloads.tvseries")}
</Text>
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
<Text className='text-xs font-bold'>
{groupedBySeries?.length}
</Text>
</View>
</View>
</View>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className='px-4 flex flex-row'>
{groupedBySeries?.map((items) => (
<View className='mb-2 last:mb-0' key={items[0].item.SeriesId}>
<SeriesCard
items={items.map((i) => i.item)}
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className='px-4 flex flex-row'>
{groupedBySeries?.map((items) => (
<View
className='mb-2 last:mb-0'
key={items[0].item.SeriesId}
/>
</View>
))}
</View>
</ScrollView>
</View>
)}
{otherMedia.length > 0 && (
<View className='mb-4'>
<View className='flex flex-row items-center justify-between mb-2 px-4'>
<Text className='text-lg font-bold'>
{t("home.downloads.other_media")}
</Text>
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
<Text className='text-xs font-bold'>{otherMedia?.length}</Text>
</View>
>
<SeriesCard
items={items.map((i) => i.item)}
key={items[0].item.SeriesId}
/>
</View>
))}
</View>
</ScrollView>
</View>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className='px-4 flex flex-row'>
{otherMedia?.map((item) => (
<TouchableItemRouter
item={item.item}
isOffline
key={item.item.Id}
>
<MovieCard item={item.item} />
</TouchableItemRouter>
))}
)}
{otherMedia.length > 0 && (
<View className='mb-4'>
<View className='flex flex-row items-center justify-between mb-2 px-4'>
<Text className='text-lg font-bold'>
{t("home.downloads.other_media")}
</Text>
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
<Text className='text-xs font-bold'>
{otherMedia?.length}
</Text>
</View>
</View>
</ScrollView>
</View>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className='px-4 flex flex-row'>
{otherMedia?.map((item) => (
<TouchableItemRouter item={item.item} key={item.item.Id}>
<MovieCard item={item.item} />
</TouchableItemRouter>
))}
</View>
</ScrollView>
</View>
)}
{downloadedFiles?.length === 0 && (
<View className='flex px-4'>
<Text className='opacity-50'>
{t("home.downloads.no_downloaded_items")}
</Text>
</View>
)}
</View>
</ScrollView>
<BottomSheetModal
ref={bottomSheetModalRef}
enableDynamicSizing
handleIndicatorStyle={{
backgroundColor: "white",
}}
backgroundStyle={{
backgroundColor: "#171717",
}}
backdropComponent={(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
)}
{downloadedFiles?.length === 0 && (
<View className='flex px-4'>
<Text className='opacity-50'>
{t("home.downloads.no_downloaded_items")}
</Text>
>
<BottomSheetView>
<View className='p-4 space-y-4 mb-4'>
<Button color='purple' onPress={deleteMovies}>
{t("home.downloads.delete_all_movies_button")}
</Button>
<Button color='purple' onPress={deleteShows}>
{t("home.downloads.delete_all_tvseries_button")}
</Button>
{otherMedia.length > 0 && (
<Button color='purple' onPress={deleteOtherMedia}>
{t("home.downloads.delete_all_other_media_button")}
</Button>
)}
<Button color='red' onPress={deleteAllMedia}>
{t("home.downloads.delete_all_button")}
</Button>
</View>
)}
</View>
</ScrollView>
</BottomSheetView>
</BottomSheetModal>
</OfflineModeProvider>
);
}

View File

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

View File

@@ -1,154 +0,0 @@
import { Feather, Ionicons } from "@expo/vector-icons";
import { Image } from "expo-image";
import { useFocusEffect, useRouter } from "expo-router";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Linking, Platform, TouchableOpacity, View } from "react-native";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { storage } from "@/utils/mmkv";
export default function page() {
const router = useRouter();
const { t } = useTranslation();
useFocusEffect(
useCallback(() => {
storage.set("hasShownIntro", true);
}, []),
);
return (
<View
className={`bg-neutral-900 h-full ${Platform.isTV ? "py-5 space-y-4" : "py-16 space-y-8"} px-4`}
>
<View>
<Text className='text-3xl font-bold text-center mb-2'>
{t("home.intro.welcome_to_streamyfin")}
</Text>
<Text className='text-center'>
{t("home.intro.a_free_and_open_source_client_for_jellyfin")}
</Text>
</View>
<View>
<Text className='text-lg font-bold'>
{t("home.intro.features_title")}
</Text>
<Text className='text-xs'>{t("home.intro.features_description")}</Text>
<View className='flex flex-row items-center mt-4'>
<Image
source={require("@/assets/icons/jellyseerr-logo.svg")}
style={{
width: 50,
height: 50,
}}
/>
<View className='shrink ml-2'>
<Text className='font-bold mb-1'>Jellyseerr</Text>
<Text className='shrink text-xs'>
{t("home.intro.jellyseerr_feature_description")}
</Text>
</View>
</View>
{!Platform.isTV && (
<>
<View className='flex flex-row items-center mt-4'>
<View
style={{
width: 50,
height: 50,
}}
className='flex items-center justify-center'
>
<Ionicons
name='cloud-download-outline'
size={32}
color='white'
/>
</View>
<View className='shrink ml-2'>
<Text className='font-bold mb-1'>
{t("home.intro.downloads_feature_title")}
</Text>
<Text className='shrink text-xs'>
{t("home.intro.downloads_feature_description")}
</Text>
</View>
</View>
<View className='flex flex-row items-center mt-4'>
<View
style={{
width: 50,
height: 50,
}}
className='flex items-center justify-center'
>
<Feather name='cast' size={28} color={"white"} />
</View>
<View className='shrink ml-2'>
<Text className='font-bold mb-1'>Chromecast</Text>
<Text className='shrink text-xs'>
{t("home.intro.chromecast_feature_description")}
</Text>
</View>
</View>
</>
)}
<View className='flex flex-row items-center mt-4'>
<View
style={{
width: 50,
height: 50,
}}
className='flex items-center justify-center'
>
<Feather name='settings' size={28} color={"white"} />
</View>
<View className='shrink ml-2'>
<Text className='font-bold mb-1'>
{t("home.intro.centralised_settings_plugin_title")}
</Text>
<View className='flex-row flex-wrap items-baseline'>
<Text className='shrink text-xs'>
{t("home.intro.centralised_settings_plugin_description")}{" "}
</Text>
<TouchableOpacity
onPress={() => {
Linking.openURL(
"https://github.com/streamyfin/jellyfin-plugin-streamyfin",
);
}}
>
<Text className='text-xs text-purple-600 underline'>
{t("home.intro.read_more")}
</Text>
</TouchableOpacity>
</View>
</View>
</View>
</View>
<View>
<Button
onPress={() => {
router.back();
}}
className='mt-4'
>
{t("home.intro.done_button")}
</Button>
<TouchableOpacity
onPress={() => {
router.back();
router.push("/settings");
}}
className='mt-4'
>
<Text className='text-purple-600 text-center'>
{t("home.intro.go_to_settings_button")}
</Text>
</TouchableOpacity>
</View>
</View>
);
}

View File

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

View File

@@ -1,4 +1,4 @@
import { useNavigation, useRouter } from "expo-router";
import { useNavigation } from "expo-router";
import { t } from "i18next";
import { useAtom } from "jotai";
import { useEffect } from "react";
@@ -11,9 +11,14 @@ import { AppLanguageSelector } from "@/components/settings/AppLanguageSelector";
import { QuickConnect } from "@/components/settings/QuickConnect";
import { StorageSettings } from "@/components/settings/StorageSettings";
import { UserInfo } from "@/components/settings/UserInfo";
import useRouter from "@/hooks/useAppRouter";
import { useJellyfin, userAtom } from "@/providers/JellyfinProvider";
export default function settings() {
// TV-specific settings component
const SettingsTV = Platform.isTV ? require("./settings.tv").default : null;
// Mobile settings component
function SettingsMobile() {
const router = useRouter();
const insets = useSafeAreaInsets();
const [_user] = useAtom(userAtom);
@@ -54,6 +59,20 @@ export default function settings() {
<QuickConnect className='mb-4' />
{Platform.OS !== "ios" && (
<View className='mb-4'>
<ListGroup title={t("pairing.pair_with_phone_title")}>
<ListItem
onPress={() =>
router.push("/(auth)/(tabs)/(home)/companion-login")
}
title={t("pairing.pair_with_phone")}
textColor='blue'
/>
</ListGroup>
</View>
)}
<View className='mb-4'>
<AppLanguageSelector />
</View>
@@ -70,6 +89,11 @@ export default function settings() {
showArrow
title={t("home.settings.audio_subtitles.title")}
/>
<ListItem
onPress={() => router.push("/settings/music/page")}
showArrow
title={t("home.settings.music.title")}
/>
<ListItem
onPress={() => router.push("/settings/appearance/page")}
showArrow
@@ -85,6 +109,11 @@ export default function settings() {
showArrow
title={t("home.settings.intro.title")}
/>
<ListItem
onPress={() => router.push("/settings/network/page")}
showArrow
title={t("home.settings.network.title")}
/>
<ListItem
onPress={() => router.push("/settings/logs/page")}
showArrow
@@ -93,8 +122,17 @@ export default function settings() {
</ListGroup>
</View>
{!Platform.isTV && <StorageSettings />}
<StorageSettings />
</View>
</ScrollView>
);
}
export default function settings() {
// Use TV settings component on TV platforms
if (Platform.isTV && SettingsTV) {
return <SettingsTV />;
}
return <SettingsMobile />;
}

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ import { Platform, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { AudioToggles } from "@/components/settings/AudioToggles";
import { MediaProvider } from "@/components/settings/MediaContext";
import { MpvSubtitleSettings } from "@/components/settings/MpvSubtitleSettings";
import { SubtitleToggles } from "@/components/settings/SubtitleToggles";
export default function AudioSubtitlesPage() {
@@ -22,6 +23,7 @@ export default function AudioSubtitlesPage() {
<MediaProvider>
<AudioToggles className='mb-4' />
<SubtitleToggles className='mb-4' />
<MpvSubtitleSettings className='mb-4' />
</MediaProvider>
</View>
</ScrollView>

View File

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

View File

@@ -1,13 +1,13 @@
import { useRouter } from "expo-router";
import { useTranslation } from "react-i18next";
import { Platform, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem";
import { useIntroSheet } from "@/providers/IntroSheetProvider";
import { storage } from "@/utils/mmkv";
export default function IntroPage() {
const router = useRouter();
const { showIntro } = useIntroSheet();
const insets = useSafeAreaInsets();
const { t } = useTranslation();
@@ -26,7 +26,7 @@ export default function IntroPage() {
<ListGroup title={t("home.settings.intro.title")}>
<ListItem
onPress={() => {
router.push("/intro/page");
showIntro();
}}
title={t("home.settings.intro.show_intro")}
/>

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

@@ -0,0 +1,251 @@
import { Ionicons } from "@expo/vector-icons";
import { useQuery } from "@tanstack/react-query";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Platform, ScrollView, View } from "react-native";
import { Switch } from "react-native-gesture-handler";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { toast } from "sonner-native";
import { Text } from "@/components/common/Text";
import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem";
import { PlatformDropdown } from "@/components/PlatformDropdown";
import { useHaptic } from "@/hooks/useHaptic";
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
import {
clearCache,
clearPermanentDownloads,
getStorageStats,
} from "@/providers/AudioStorage";
import { useSettings } from "@/utils/atoms/settings";
const CACHE_SIZE_OPTIONS = [
{ label: "100 MB", value: 100 },
{ label: "250 MB", value: 250 },
{ label: "500 MB", value: 500 },
{ label: "1 GB", value: 1024 },
{ label: "2 GB", value: 2048 },
];
const LOOKAHEAD_COUNT_OPTIONS = [
{ label: "1 song", value: 1 },
{ label: "2 songs", value: 2 },
{ label: "3 songs", value: 3 },
{ label: "5 songs", value: 5 },
];
export default function MusicSettingsPage() {
const insets = useSafeAreaInsets();
const { settings, updateSettings, pluginSettings } = useSettings();
const { t } = useTranslation();
const queryClient = useNetworkAwareQueryClient();
const successHapticFeedback = useHaptic("success");
const errorHapticFeedback = useHaptic("error");
const { data: musicCacheStats } = useQuery({
queryKey: ["musicCacheStats"],
queryFn: () => getStorageStats(),
});
const onClearMusicCacheClicked = useCallback(async () => {
try {
await clearCache();
queryClient.invalidateQueries({ queryKey: ["musicCacheStats"] });
queryClient.invalidateQueries({ queryKey: ["appSize"] });
successHapticFeedback();
toast.success(t("home.settings.storage.music_cache_cleared"));
} catch (_e) {
errorHapticFeedback();
toast.error(t("home.settings.toasts.error_deleting_files"));
}
}, [queryClient, successHapticFeedback, errorHapticFeedback, t]);
const onDeleteDownloadedSongsClicked = useCallback(async () => {
try {
await clearPermanentDownloads();
queryClient.invalidateQueries({ queryKey: ["musicCacheStats"] });
queryClient.invalidateQueries({ queryKey: ["appSize"] });
successHapticFeedback();
toast.success(t("home.settings.storage.downloaded_songs_deleted"));
} catch (_e) {
errorHapticFeedback();
toast.error(t("home.settings.toasts.error_deleting_files"));
}
}, [queryClient, successHapticFeedback, errorHapticFeedback, t]);
const cacheSizeOptions = useMemo(
() => [
{
options: CACHE_SIZE_OPTIONS.map((option) => ({
type: "radio" as const,
label: option.label,
value: String(option.value),
selected: option.value === settings?.audioMaxCacheSizeMB,
onPress: () => updateSettings({ audioMaxCacheSizeMB: option.value }),
})),
},
],
[settings?.audioMaxCacheSizeMB, updateSettings],
);
const currentCacheSizeLabel =
CACHE_SIZE_OPTIONS.find((o) => o.value === settings?.audioMaxCacheSizeMB)
?.label ?? `${settings?.audioMaxCacheSizeMB} MB`;
const lookaheadCountOptions = useMemo(
() => [
{
options: LOOKAHEAD_COUNT_OPTIONS.map((option) => ({
type: "radio" as const,
label: option.label,
value: String(option.value),
selected: option.value === settings?.audioLookaheadCount,
onPress: () => updateSettings({ audioLookaheadCount: option.value }),
})),
},
],
[settings?.audioLookaheadCount, updateSettings],
);
const currentLookaheadLabel =
LOOKAHEAD_COUNT_OPTIONS.find(
(o) => o.value === settings?.audioLookaheadCount,
)?.label ?? `${settings?.audioLookaheadCount} songs`;
return (
<ScrollView
contentInsetAdjustmentBehavior='automatic'
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
}}
>
<View
className='p-4 flex flex-col'
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
>
<ListGroup
title={t("home.settings.music.playback_title")}
description={
<Text className='text-[#8E8D91] text-xs'>
{t("home.settings.music.playback_description")}
</Text>
}
>
<ListItem
title={t("home.settings.music.prefer_downloaded")}
disabled={pluginSettings?.preferLocalAudio?.locked}
>
<Switch
value={settings.preferLocalAudio}
disabled={pluginSettings?.preferLocalAudio?.locked}
onValueChange={(value) =>
updateSettings({ preferLocalAudio: value })
}
/>
</ListItem>
</ListGroup>
<View className='mt-4'>
<ListGroup
title={t("home.settings.music.caching_title")}
description={
<Text className='text-[#8E8D91] text-xs'>
{t("home.settings.music.caching_description")}
</Text>
}
>
<ListItem
title={t("home.settings.music.lookahead_enabled")}
disabled={pluginSettings?.audioLookaheadEnabled?.locked}
>
<Switch
value={settings.audioLookaheadEnabled}
disabled={pluginSettings?.audioLookaheadEnabled?.locked}
onValueChange={(value) =>
updateSettings({ audioLookaheadEnabled: value })
}
/>
</ListItem>
<ListItem
title={t("home.settings.music.lookahead_count")}
disabled={
pluginSettings?.audioLookaheadCount?.locked ||
!settings.audioLookaheadEnabled
}
>
<PlatformDropdown
groups={lookaheadCountOptions}
trigger={
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>
{currentLookaheadLabel}
</Text>
<Ionicons
name='chevron-expand-sharp'
size={18}
color='#5A5960'
/>
</View>
}
title={t("home.settings.music.lookahead_count")}
/>
</ListItem>
<ListItem
title={t("home.settings.music.max_cache_size")}
disabled={pluginSettings?.audioMaxCacheSizeMB?.locked}
>
<PlatformDropdown
groups={cacheSizeOptions}
trigger={
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>
{currentCacheSizeLabel}
</Text>
<Ionicons
name='chevron-expand-sharp'
size={18}
color='#5A5960'
/>
</View>
}
title={t("home.settings.music.max_cache_size")}
/>
</ListItem>
</ListGroup>
</View>
{!Platform.isTV && (
<View className='mt-4'>
<ListGroup
title={t("home.settings.storage.music_cache_title")}
description={
<Text className='text-[#8E8D91] text-xs'>
{t("home.settings.storage.music_cache_description")}
</Text>
}
>
<ListItem
onPress={onClearMusicCacheClicked}
title={t("home.settings.storage.clear_music_cache")}
subtitle={t("home.settings.storage.music_cache_size", {
size: (musicCacheStats?.cacheSize ?? 0).bytesToReadable(),
})}
/>
</ListGroup>
<ListGroup>
<ListItem
textColor='red'
onPress={onDeleteDownloadedSongsClicked}
title={t("home.settings.storage.delete_all_downloaded_songs")}
subtitle={t("home.settings.storage.downloaded_songs_size", {
size: (musicCacheStats?.permanentSize ?? 0).bytesToReadable(),
})}
/>
</ListGroup>
</View>
)}
</View>
</ScrollView>
);
}

View File

@@ -0,0 +1,48 @@
import { useAtomValue } from "jotai";
import { useTranslation } from "react-i18next";
import { Platform, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem";
import { LocalNetworkSettings } from "@/components/settings/LocalNetworkSettings";
import { apiAtom } from "@/providers/JellyfinProvider";
import { storage } from "@/utils/mmkv";
export default function NetworkSettingsPage() {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const api = useAtomValue(apiAtom);
const remoteUrl = storage.getString("serverUrl");
return (
<ScrollView
contentInsetAdjustmentBehavior='automatic'
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
paddingBottom: insets.bottom + 20,
}}
>
<View
className='p-4 flex flex-col'
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
>
<ListGroup title={t("home.settings.network.current_server")}>
<ListItem
title={t("home.settings.network.remote_url")}
subtitle={remoteUrl ?? t("home.settings.network.not_configured")}
/>
<ListItem
title={t("home.settings.network.active_url")}
subtitle={api?.basePath ?? t("home.settings.network.not_connected")}
/>
</ListGroup>
<View className='mt-4'>
<LocalNetworkSettings />
</View>
</View>
</ScrollView>
);
}

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

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

View File

@@ -0,0 +1,27 @@
import { ScrollView } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import DisabledSetting from "@/components/settings/DisabledSetting";
import { KefinTweaksSettings } from "@/components/settings/KefinTweaks";
import { useSettings } from "@/utils/atoms/settings";
export default function KefinTweaksPage() {
const { pluginSettings } = useSettings();
const insets = useSafeAreaInsets();
return (
<ScrollView
contentInsetAdjustmentBehavior='automatic'
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
}}
>
<DisabledSetting
disabled={pluginSettings?.useKefinTweaks?.locked === true}
className='p-4'
>
<KefinTweaksSettings />
</DisabledSetting>
</ScrollView>
);
}

View File

@@ -1,4 +1,3 @@
import { useQueryClient } from "@tanstack/react-query";
import { useNavigation } from "expo-router";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -16,9 +15,10 @@ import { Text } from "@/components/common/Text";
import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem";
import DisabledSetting from "@/components/settings/DisabledSetting";
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
import { useSettings } from "@/utils/atoms/settings";
export default function page() {
export default function MarlinSearchPage() {
const navigation = useNavigation();
const { t } = useTranslation();
@@ -26,7 +26,7 @@ export default function page() {
const insets = useSafeAreaInsets();
const { settings, updateSettings, pluginSettings } = useSettings();
const queryClient = useQueryClient();
const queryClient = useNetworkAwareQueryClient();
const [value, setValue] = useState<string>(settings?.marlinServerUrl || "");
@@ -60,7 +60,7 @@ export default function page() {
),
});
}
}, [navigation, value]);
}, [navigation, value, pluginSettings?.marlinServerUrl?.locked, t]);
if (!settings) return null;
@@ -75,7 +75,10 @@ export default function page() {
<DisabledSetting disabled={disabled} className='px-4'>
<ListGroup>
<DisabledSetting
disabled={pluginSettings?.searchEngine?.locked === true}
disabled={
pluginSettings?.searchEngine?.locked === true ||
!!pluginSettings?.streamyStatsServerUrl?.value
}
showText={!pluginSettings?.marlinServerUrl?.locked}
>
<ListItem
@@ -89,6 +92,7 @@ export default function page() {
>
<Switch
value={settings.searchEngine === "Marlin"}
disabled={!!pluginSettings?.streamyStatsServerUrl?.value}
onValueChange={(value) => {
updateSettings({
searchEngine: value ? "Marlin" : "Jellyfin",

View File

@@ -0,0 +1,262 @@
import { useNavigation } from "expo-router";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Linking,
ScrollView,
Switch,
TextInput,
TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { toast } from "sonner-native";
import { Text } from "@/components/common/Text";
import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem";
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
import { useSettings } from "@/utils/atoms/settings";
export default function StreamystatsPage() {
const { t } = useTranslation();
const navigation = useNavigation();
const insets = useSafeAreaInsets();
const {
settings,
updateSettings,
pluginSettings,
refreshStreamyfinPluginSettings,
} = useSettings();
const queryClient = useNetworkAwareQueryClient();
// Local state for all editable fields
const [url, setUrl] = useState<string>(settings?.streamyStatsServerUrl || "");
const [useForSearch, setUseForSearch] = useState<boolean>(
settings?.searchEngine === "Streamystats",
);
const [movieRecs, setMovieRecs] = useState<boolean>(
settings?.streamyStatsMovieRecommendations ?? false,
);
const [seriesRecs, setSeriesRecs] = useState<boolean>(
settings?.streamyStatsSeriesRecommendations ?? false,
);
const [promotedWatchlists, setPromotedWatchlists] = useState<boolean>(
settings?.streamyStatsPromotedWatchlists ?? false,
);
const [hideWatchlistsTab, setHideWatchlistsTab] = useState<boolean>(
settings?.hideWatchlistsTab ?? false,
);
const isUrlLocked = pluginSettings?.streamyStatsServerUrl?.locked === true;
const isStreamystatsEnabled = !!url;
const onSave = useCallback(() => {
const cleanUrl = url.endsWith("/") ? url.slice(0, -1) : url;
updateSettings({
streamyStatsServerUrl: cleanUrl,
searchEngine: useForSearch ? "Streamystats" : "Jellyfin",
streamyStatsMovieRecommendations: movieRecs,
streamyStatsSeriesRecommendations: seriesRecs,
streamyStatsPromotedWatchlists: promotedWatchlists,
hideWatchlistsTab: hideWatchlistsTab,
});
queryClient.invalidateQueries({ queryKey: ["search"] });
queryClient.invalidateQueries({ queryKey: ["streamystats"] });
toast.success(t("home.settings.plugins.streamystats.toasts.saved"));
}, [
url,
useForSearch,
movieRecs,
seriesRecs,
promotedWatchlists,
hideWatchlistsTab,
updateSettings,
queryClient,
t,
]);
// Set up header save button
useEffect(() => {
navigation.setOptions({
headerRight: () => (
<TouchableOpacity onPress={onSave}>
<Text className='text-blue-500 font-medium'>
{t("home.settings.plugins.streamystats.save")}
</Text>
</TouchableOpacity>
),
});
}, [navigation, onSave, t]);
const handleClearStreamystats = useCallback(() => {
setUrl("");
setUseForSearch(false);
setMovieRecs(false);
setSeriesRecs(false);
setPromotedWatchlists(false);
setHideWatchlistsTab(false);
updateSettings({
streamyStatsServerUrl: "",
searchEngine: "Jellyfin",
streamyStatsMovieRecommendations: false,
streamyStatsSeriesRecommendations: false,
streamyStatsPromotedWatchlists: false,
hideWatchlistsTab: false,
});
queryClient.invalidateQueries({ queryKey: ["streamystats"] });
queryClient.invalidateQueries({ queryKey: ["search"] });
toast.success(t("home.settings.plugins.streamystats.toasts.disabled"));
}, [updateSettings, queryClient, t]);
const handleOpenLink = () => {
Linking.openURL("https://github.com/fredrikburmester/streamystats");
};
const handleRefreshFromServer = useCallback(async () => {
const newPluginSettings = await refreshStreamyfinPluginSettings();
// Update local state with new values
const newUrl = newPluginSettings?.streamyStatsServerUrl?.value || "";
setUrl(newUrl);
if (newUrl) {
setUseForSearch(true);
}
toast.success(t("home.settings.plugins.streamystats.toasts.refreshed"));
}, [refreshStreamyfinPluginSettings, t]);
if (!settings) return null;
return (
<ScrollView
contentInsetAdjustmentBehavior='automatic'
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
}}
>
<View className='px-4'>
<ListGroup className='flex-1'>
<ListItem
title={t("home.settings.plugins.streamystats.url")}
disabledByAdmin={isUrlLocked}
>
<TextInput
editable={!isUrlLocked}
className='text-white text-right flex-1'
placeholder={t(
"home.settings.plugins.streamystats.server_url_placeholder",
)}
value={url}
keyboardType='url'
returnKeyType='done'
autoCapitalize='none'
textContentType='URL'
onChangeText={setUrl}
/>
</ListItem>
</ListGroup>
<Text className='px-4 text-xs text-neutral-500 mt-1'>
{t("home.settings.plugins.streamystats.streamystats_search_hint")}{" "}
<Text className='text-blue-500' onPress={handleOpenLink}>
{t(
"home.settings.plugins.streamystats.read_more_about_streamystats",
)}
</Text>
</Text>
<ListGroup
title={t("home.settings.plugins.streamystats.features_title")}
className='mt-4'
>
<ListItem
title={t("home.settings.plugins.streamystats.enable_search")}
disabledByAdmin={pluginSettings?.searchEngine?.locked === true}
>
<Switch
value={useForSearch}
disabled={!isStreamystatsEnabled}
onValueChange={setUseForSearch}
/>
</ListItem>
<ListItem
title={t(
"home.settings.plugins.streamystats.enable_movie_recommendations",
)}
disabledByAdmin={
pluginSettings?.streamyStatsMovieRecommendations?.locked === true
}
>
<Switch
value={movieRecs}
onValueChange={setMovieRecs}
disabled={!isStreamystatsEnabled}
/>
</ListItem>
<ListItem
title={t(
"home.settings.plugins.streamystats.enable_series_recommendations",
)}
disabledByAdmin={
pluginSettings?.streamyStatsSeriesRecommendations?.locked === true
}
>
<Switch
value={seriesRecs}
onValueChange={setSeriesRecs}
disabled={!isStreamystatsEnabled}
/>
</ListItem>
<ListItem
title={t(
"home.settings.plugins.streamystats.enable_promoted_watchlists",
)}
disabledByAdmin={
pluginSettings?.streamyStatsPromotedWatchlists?.locked === true
}
>
<Switch
value={promotedWatchlists}
onValueChange={setPromotedWatchlists}
disabled={!isStreamystatsEnabled}
/>
</ListItem>
<ListItem
title={t("home.settings.plugins.streamystats.hide_watchlists_tab")}
disabledByAdmin={pluginSettings?.hideWatchlistsTab?.locked === true}
>
<Switch
value={hideWatchlistsTab}
onValueChange={setHideWatchlistsTab}
disabled={!isStreamystatsEnabled}
/>
</ListItem>
</ListGroup>
<Text className='px-4 text-xs text-neutral-500 mt-1'>
{t("home.settings.plugins.streamystats.home_sections_hint")}
</Text>
<TouchableOpacity
onPress={handleRefreshFromServer}
className='mt-6 py-3 rounded-xl bg-neutral-800'
>
<Text className='text-center text-blue-500'>
{t("home.settings.plugins.streamystats.refresh_from_server")}
</Text>
</TouchableOpacity>
{/* Disable button - only show if URL is not locked and Streamystats is enabled */}
{!isUrlLocked && isStreamystatsEnabled && (
<TouchableOpacity
onPress={handleClearStreamystats}
className='mt-3 mb-4 py-3 rounded-xl bg-neutral-800'
>
<Text className='text-center text-red-500'>
{t("home.settings.plugins.streamystats.disable_streamystats")}
</Text>
</TouchableOpacity>
)}
</View>
</ScrollView>
);
}

View File

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

View File

@@ -1,420 +0,0 @@
import type {
BaseItemDto,
BaseItemDtoQueryResult,
ItemSortBy,
} from "@jellyfin/sdk/lib/generated-client/models";
import {
getFilterApi,
getItemsApi,
getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api";
import { FlashList } from "@shopify/flash-list";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import type React from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { FlatList, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import { FilterButton } from "@/components/filters/FilterButton";
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
import { ItemCardText } from "@/components/ItemCardText";
import { ItemPoster } from "@/components/posters/ItemPoster";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import {
genreFilterAtom,
SortByOption,
SortOrderOption,
sortByAtom,
sortOptions,
sortOrderAtom,
sortOrderOptions,
tagsFilterAtom,
yearFilterAtom,
} from "@/utils/atoms/filters";
const page: React.FC = () => {
const searchParams = useLocalSearchParams();
const { collectionId } = searchParams as { collectionId: string };
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const navigation = useNavigation();
const [orientation, _setOrientation] = useState(
ScreenOrientation.Orientation.PORTRAIT_UP,
);
const { t } = useTranslation();
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
const [sortBy, setSortBy] = useAtom(sortByAtom);
const [sortOrder, setSortOrder] = useAtom(sortOrderAtom);
const { data: collection } = useQuery({
queryKey: ["collection", collectionId],
queryFn: async () => {
if (!api) return null;
const response = await getUserLibraryApi(api).getItem({
itemId: collectionId,
userId: user?.Id,
});
const data = response.data;
return data;
},
enabled: !!api && !!user?.Id && !!collectionId,
staleTime: 60 * 1000,
});
useEffect(() => {
navigation.setOptions({ title: collection?.Name || "" });
setSortOrder([SortOrderOption.Ascending]);
if (!collection) return;
// Convert the DisplayOrder to SortByOption
const displayOrder = collection.DisplayOrder as ItemSortBy;
const sortByOption = displayOrder
? SortByOption[displayOrder as keyof typeof SortByOption] ||
SortByOption.PremiereDate
: SortByOption.PremiereDate;
setSortBy([sortByOption]);
}, [navigation, collection]);
const fetchItems = useCallback(
async ({
pageParam,
}: {
pageParam: number;
}): Promise<BaseItemDtoQueryResult | null> => {
if (!api || !collection) return null;
const response = await getItemsApi(api).getItems({
userId: user?.Id,
parentId: collectionId,
limit: 18,
startIndex: pageParam,
// Set one ordering at a time. As collections do not work with correctly with multiple.
sortBy: [sortBy[0]],
sortOrder: [sortOrder[0]],
fields: [
"ItemCounts",
"PrimaryImageAspectRatio",
"CanDelete",
"MediaSourceCount",
],
// true is needed for merged versions
recursive: true,
genres: selectedGenres,
tags: selectedTags,
years: selectedYears.map((year) => Number.parseInt(year, 10)),
includeItemTypes: ["Movie", "Series"],
});
return response.data || null;
},
[
api,
user?.Id,
collection,
selectedGenres,
selectedYears,
selectedTags,
sortBy,
sortOrder,
],
);
const { data, isFetching, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey: [
"collection-items",
collection,
selectedGenres,
selectedYears,
selectedTags,
sortBy,
sortOrder,
],
queryFn: fetchItems,
getNextPageParam: (lastPage, pages) => {
if (
!lastPage?.Items ||
!lastPage?.TotalRecordCount ||
lastPage?.TotalRecordCount === 0
)
return undefined;
const totalItems = lastPage.TotalRecordCount;
const accumulatedItems = pages.reduce(
(acc, curr) => acc + (curr?.Items?.length || 0),
0,
);
if (accumulatedItems < totalItems) {
return lastPage?.Items?.length * pages.length;
}
return undefined;
},
initialPageParam: 0,
enabled: !!api && !!user?.Id && !!collection,
});
const flatData = useMemo(() => {
return (
(data?.pages.flatMap((p) => p?.Items).filter(Boolean) as BaseItemDto[]) ||
[]
);
}, [data]);
const renderItem = useCallback(
({ item, index }: { item: BaseItemDto; index: number }) => (
<TouchableItemRouter
key={item.Id}
style={{
width: "100%",
marginBottom:
orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 4 : 16,
}}
item={item}
>
<View
style={{
alignSelf:
index % 3 === 0
? "flex-end"
: (index + 1) % 3 === 0
? "flex-start"
: "center",
width: "89%",
}}
>
<ItemPoster item={item} />
{/* <MoviePoster item={item} /> */}
<ItemCardText item={item} />
</View>
</TouchableItemRouter>
),
[orientation],
);
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
const _insets = useSafeAreaInsets();
const ListHeaderComponent = useCallback(
() => (
<FlatList
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{
display: "flex",
paddingHorizontal: 15,
paddingVertical: 16,
flexDirection: "row",
}}
extraData={[
selectedGenres,
selectedYears,
selectedTags,
sortBy,
sortOrder,
]}
data={[
{
key: "reset",
component: <ResetFiltersButton />,
},
{
key: "genre",
component: (
<FilterButton
className='mr-1'
id={collectionId}
queryKey='genreFilter'
queryFn={async () => {
if (!api) return null;
const response = await getFilterApi(
api,
).getQueryFiltersLegacy({
userId: user?.Id,
parentId: collectionId,
});
return response.data.Genres || [];
}}
set={setSelectedGenres}
values={selectedGenres}
title={t("library.filters.genres")}
renderItemLabel={(item) => item.toString()}
searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase())
}
/>
),
},
{
key: "year",
component: (
<FilterButton
className='mr-1'
id={collectionId}
queryKey='yearFilter'
queryFn={async () => {
if (!api) return null;
const response = await getFilterApi(
api,
).getQueryFiltersLegacy({
userId: user?.Id,
parentId: collectionId,
});
return response.data.Years || [];
}}
set={setSelectedYears}
values={selectedYears}
title={t("library.filters.years")}
renderItemLabel={(item) => item.toString()}
searchFilter={(item, search) => item.includes(search)}
/>
),
},
{
key: "tags",
component: (
<FilterButton
className='mr-1'
id={collectionId}
queryKey='tagsFilter'
queryFn={async () => {
if (!api) return null;
const response = await getFilterApi(
api,
).getQueryFiltersLegacy({
userId: user?.Id,
parentId: collectionId,
});
return response.data.Tags || [];
}}
set={setSelectedTags}
values={selectedTags}
title={t("library.filters.tags")}
renderItemLabel={(item) => item.toString()}
searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase())
}
/>
),
},
{
key: "sortBy",
component: (
<FilterButton
className='mr-1'
id={collectionId}
queryKey='sortBy'
queryFn={async () => sortOptions.map((s) => s.key)}
set={setSortBy}
values={sortBy}
title={t("library.filters.sort_by")}
renderItemLabel={(item) =>
sortOptions.find((i) => i.key === item)?.value || ""
}
searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase())
}
/>
),
},
{
key: "sortOrder",
component: (
<FilterButton
className='mr-1'
id={collectionId}
queryKey='sortOrder'
queryFn={async () => sortOrderOptions.map((s) => s.key)}
set={setSortOrder}
values={sortOrder}
title={t("library.filters.sort_order")}
renderItemLabel={(item) =>
sortOrderOptions.find((i) => i.key === item)?.value || ""
}
searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase())
}
/>
),
},
]}
renderItem={({ item }) => item.component}
keyExtractor={(item) => item.key}
/>
),
[
collectionId,
api,
user?.Id,
selectedGenres,
setSelectedGenres,
selectedYears,
setSelectedYears,
selectedTags,
setSelectedTags,
sortBy,
setSortBy,
sortOrder,
setSortOrder,
isFetching,
],
);
if (!collection) return null;
return (
<FlashList
ListEmptyComponent={
<View className='flex flex-col items-center justify-center h-full'>
<Text className='font-bold text-xl text-neutral-500'>
{t("search.no_results")}
</Text>
</View>
}
extraData={[
selectedGenres,
selectedYears,
selectedTags,
sortBy,
sortOrder,
]}
contentInsetAdjustmentBehavior='automatic'
data={flatData}
renderItem={renderItem}
keyExtractor={keyExtractor}
numColumns={
orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 3 : 5
}
onEndReached={() => {
if (hasNextPage) {
fetchNextPage();
}
}}
onEndReachedThreshold={0.5}
ListHeaderComponent={ListHeaderComponent}
contentContainerStyle={{ paddingBottom: 24 }}
ItemSeparatorComponent={() => (
<View
style={{
width: 10,
height: 10,
}}
/>
)}
/>
);
};
export default page;

View File

@@ -1,98 +0,0 @@
import { useLocalSearchParams } from "expo-router";
import type React from "react";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import Animated, {
runOnJS,
useAnimatedStyle,
useSharedValue,
withTiming,
} from "react-native-reanimated";
import { Text } from "@/components/common/Text";
import { ItemContent } from "@/components/ItemContent";
import { useItemQuery } from "@/hooks/useItemQuery";
const Page: React.FC = () => {
const { id } = useLocalSearchParams() as { id: string };
const { t } = useTranslation();
const { offline } = useLocalSearchParams() as { offline?: string };
const isOffline = offline === "true";
const { data: item, isError } = useItemQuery(id, isOffline);
const opacity = useSharedValue(1);
const animatedStyle = useAnimatedStyle(() => {
return {
opacity: opacity.value,
};
});
const fadeOut = (callback: any) => {
setTimeout(() => {
opacity.value = withTiming(0, { duration: 500 }, (finished) => {
if (finished) {
runOnJS(callback)();
}
});
}, 100);
};
const fadeIn = (callback: any) => {
setTimeout(() => {
opacity.value = withTiming(1, { duration: 500 }, (finished) => {
if (finished) {
runOnJS(callback)();
}
});
}, 100);
};
useEffect(() => {
if (item) {
fadeOut(() => {});
} else {
fadeIn(() => {});
}
}, [item]);
if (isError)
return (
<View className='flex flex-col items-center justify-center h-screen w-screen'>
<Text>{t("item_card.could_not_load_item")}</Text>
</View>
);
return (
<View className='flex flex-1 relative'>
<Animated.View
pointerEvents={"none"}
style={[animatedStyle]}
className='absolute top-0 left-0 flex flex-col items-start h-screen w-screen px-4 z-50 bg-black'
>
<View
style={{
height: item?.Type === "Episode" ? 300 : 450,
}}
className='bg-transparent rounded-lg mb-4 w-full'
/>
<View className='h-6 bg-neutral-900 rounded mb-4 w-14' />
<View className='h-10 bg-neutral-900 rounded-lg mb-2 w-1/2' />
<View className='h-3 bg-neutral-900 rounded mb-3 w-8' />
<View className='flex flex-row space-x-1 mb-8'>
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
</View>
<View className='h-3 bg-neutral-900 rounded w-2/3 mb-1' />
<View className='h-10 bg-neutral-900 rounded-lg w-full mb-2' />
<View className='h-12 bg-neutral-900 rounded-lg w-full mb-2' />
<View className='h-24 bg-neutral-900 rounded-lg mb-1 w-full' />
</Animated.View>
{item && <ItemContent item={item} isOffline={isOffline} />}
</View>
);
};
export default Page;

View File

@@ -1,163 +0,0 @@
import { Ionicons } from "@expo/vector-icons";
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import type React from "react";
import { useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Platform, View } from "react-native";
import { AddToFavorites } from "@/components/AddToFavorites";
import { DownloadItems } from "@/components/DownloadItem";
import { ParallaxScrollView } from "@/components/ParallaxPage";
import { NextUp } from "@/components/series/NextUp";
import { SeasonPicker } from "@/components/series/SeasonPicker";
import { SeriesHeader } from "@/components/series/SeriesHeader";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
const page: React.FC = () => {
const navigation = useNavigation();
const { t } = useTranslation();
const params = useLocalSearchParams();
const { id: seriesId, seasonIndex } = params as {
id: string;
seasonIndex: string;
};
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { data: item } = useQuery({
queryKey: ["series", seriesId],
queryFn: async () =>
await getUserItemData({
api,
userId: user?.Id,
itemId: seriesId,
}),
staleTime: 60 * 1000,
});
const backdropUrl = useMemo(
() =>
getBackdropUrl({
api,
item,
quality: 90,
width: 1000,
}),
[item],
);
const logoUrl = useMemo(
() =>
getLogoImageUrlById({
api,
item,
}),
[item],
);
const { data: allEpisodes, isLoading } = useQuery({
queryKey: ["AllEpisodes", item?.Id],
queryFn: async () => {
if (!api || !user?.Id || !item?.Id) return [];
const res = await getTvShowsApi(api).getEpisodes({
seriesId: item.Id,
userId: user.Id,
enableUserData: true,
// Note: Including trick play is necessary to enable trick play downloads
fields: ["MediaSources", "MediaStreams", "Overview", "Trickplay"],
});
return res?.data.Items || [];
},
select: (data) =>
// This needs to be sorted by parent index number and then index number, that way we can download the episodes in the correct order.
[...(data || [])].sort(
(a, b) =>
(a.ParentIndexNumber ?? 0) - (b.ParentIndexNumber ?? 0) ||
(a.IndexNumber ?? 0) - (b.IndexNumber ?? 0),
),
staleTime: 60,
enabled: !!api && !!user?.Id && !!item?.Id,
});
useEffect(() => {
navigation.setOptions({
headerRight: () =>
!isLoading &&
item &&
allEpisodes &&
allEpisodes.length > 0 && (
<View className='flex flex-row items-center space-x-2'>
<AddToFavorites item={item} />
{!Platform.isTV && (
<DownloadItems
size='large'
title={t("item_card.download.download_series")}
items={allEpisodes || []}
MissingDownloadIconComponent={() => (
<Ionicons name='download' size={22} color='white' />
)}
DownloadedIconComponent={() => (
<Ionicons
name='checkmark-done-outline'
size={24}
color='#9333ea'
/>
)}
/>
)}
</View>
),
});
}, [allEpisodes, isLoading, item]);
if (!item || !backdropUrl) return null;
return (
<ParallaxScrollView
headerHeight={400}
headerImage={
<Image
source={{
uri: backdropUrl,
}}
style={{
width: "100%",
height: "100%",
}}
/>
}
logo={
logoUrl ? (
<Image
source={{
uri: logoUrl,
}}
style={{
height: 130,
width: "100%",
}}
contentFit='contain'
/>
) : undefined
}
>
<View className='flex flex-col pt-4'>
<SeriesHeader item={item} />
<View className='mb-4'>
<NextUp seriesId={seriesId} />
</View>
<SeasonPicker item={item} initialSeasonIndex={Number(seasonIndex)} />
</View>
</ParallaxScrollView>
);
};
export default page;

View File

@@ -0,0 +1,785 @@
import type {
BaseItemDto,
BaseItemDtoQueryResult,
ItemSortBy,
} from "@jellyfin/sdk/lib/generated-client/models";
import {
getFilterApi,
getItemsApi,
getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api";
import { FlashList } from "@shopify/flash-list";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import type React from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { FlatList, Platform, useWindowDimensions, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import {
getItemNavigation,
TouchableItemRouter,
} from "@/components/common/TouchableItemRouter";
import { FilterButton } from "@/components/filters/FilterButton";
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
import { ItemCardText } from "@/components/ItemCardText";
import { Loader } from "@/components/Loader";
import { ItemPoster } from "@/components/posters/ItemPoster";
import { TVFilterButton } from "@/components/tv";
import { TVPosterCard } from "@/components/tv/TVPosterCard";
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
import useRouter from "@/hooks/useAppRouter";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import {
genreFilterAtom,
SortByOption,
SortOrderOption,
sortByAtom,
sortOptions,
sortOrderAtom,
sortOrderOptions,
tagsFilterAtom,
yearFilterAtom,
} from "@/utils/atoms/filters";
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
const TV_ITEM_GAP = 16;
const TV_SCALE_PADDING = 20;
const page: React.FC = () => {
const 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,
);
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
const [sortBy, setSortBy] = useAtom(sortByAtom);
const [sortOrder, setSortOrder] = useAtom(sortOrderAtom);
const { data: collection, isLoading: isCollectionLoading } = useQuery({
queryKey: ["collection", collectionId],
queryFn: async () => {
if (!api) return null;
const response = await getUserLibraryApi(api).getItem({
itemId: collectionId,
userId: user?.Id,
});
const data = response.data;
return data;
},
enabled: !!api && !!user?.Id && !!collectionId,
staleTime: 60 * 1000,
});
// TV Filter queries
const { data: tvGenreOptions } = useQuery({
queryKey: ["filters", "Genres", "tvGenreFilter", collectionId],
queryFn: async () => {
if (!api) return [];
const response = await getFilterApi(api).getQueryFiltersLegacy({
userId: user?.Id,
parentId: collectionId,
});
return response.data.Genres || [];
},
enabled: Platform.isTV && !!api && !!user?.Id && !!collectionId,
});
const { data: tvYearOptions } = useQuery({
queryKey: ["filters", "Years", "tvYearFilter", collectionId],
queryFn: async () => {
if (!api) return [];
const response = await getFilterApi(api).getQueryFiltersLegacy({
userId: user?.Id,
parentId: collectionId,
});
return response.data.Years || [];
},
enabled: Platform.isTV && !!api && !!user?.Id && !!collectionId,
});
const { data: tvTagOptions } = useQuery({
queryKey: ["filters", "Tags", "tvTagFilter", collectionId],
queryFn: async () => {
if (!api) return [];
const response = await getFilterApi(api).getQueryFiltersLegacy({
userId: user?.Id,
parentId: collectionId,
});
return response.data.Tags || [];
},
enabled: Platform.isTV && !!api && !!user?.Id && !!collectionId,
});
useEffect(() => {
navigation.setOptions({ title: collection?.Name || "" });
setSortOrder([SortOrderOption.Ascending]);
if (!collection) return;
// Convert the DisplayOrder to SortByOption
const displayOrder = collection.DisplayOrder as ItemSortBy;
const sortByOption = displayOrder
? SortByOption[displayOrder as keyof typeof SortByOption] ||
SortByOption.PremiereDate
: SortByOption.PremiereDate;
setSortBy([sortByOption]);
}, [navigation, collection]);
// Calculate columns for TV grid
const nrOfCols = useMemo(() => {
if (Platform.isTV) {
const itemWidth = posterSizes.poster + TV_ITEM_GAP;
return Math.max(
1,
Math.floor((screenWidth - TV_SCALE_PADDING * 2) / itemWidth),
);
}
return orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 3 : 5;
}, [screenWidth, orientation]);
const fetchItems = useCallback(
async ({
pageParam,
}: {
pageParam: number;
}): Promise<BaseItemDtoQueryResult | null> => {
if (!api || !collection) return null;
const response = await getItemsApi(api).getItems({
userId: user?.Id,
parentId: collectionId,
limit: Platform.isTV ? 36 : 18,
startIndex: pageParam,
// Set one ordering at a time. As collections do not work with correctly with multiple.
sortBy: [sortBy[0]],
sortOrder: [sortOrder[0]],
fields: [
"ItemCounts",
"PrimaryImageAspectRatio",
"CanDelete",
"MediaSourceCount",
],
// true is needed for merged versions
recursive: true,
genres: selectedGenres,
tags: selectedTags,
years: selectedYears.map((year) => Number.parseInt(year, 10)),
includeItemTypes: ["Movie", "Series", "Season"],
});
return response.data || null;
},
[
api,
user?.Id,
collection,
collectionId,
selectedGenres,
selectedYears,
selectedTags,
sortBy,
sortOrder,
],
);
const { data, isFetching, fetchNextPage, hasNextPage, isLoading } =
useInfiniteQuery({
queryKey: [
"collection-items",
collectionId,
selectedGenres,
selectedYears,
selectedTags,
sortBy,
sortOrder,
],
queryFn: fetchItems,
getNextPageParam: (lastPage, pages) => {
if (
!lastPage?.Items ||
!lastPage?.TotalRecordCount ||
lastPage?.TotalRecordCount === 0
)
return undefined;
const totalItems = lastPage.TotalRecordCount;
const accumulatedItems = pages.reduce(
(acc, curr) => acc + (curr?.Items?.length || 0),
0,
);
if (accumulatedItems < totalItems) {
return lastPage?.Items?.length * pages.length;
}
return undefined;
},
initialPageParam: 0,
enabled: !!api && !!user?.Id && !!collection,
});
const flatData = useMemo(() => {
return (
(data?.pages.flatMap((p) => p?.Items).filter(Boolean) as BaseItemDto[]) ||
[]
);
}, [data]);
const renderItem = useCallback(
({ item, index }: { item: BaseItemDto; index: number }) => (
<TouchableItemRouter
key={item.Id}
style={{
width: "100%",
marginBottom:
orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 4 : 16,
}}
item={item}
>
<View
style={{
alignSelf:
index % 3 === 0
? "flex-end"
: (index + 1) % 3 === 0
? "flex-start"
: "center",
width: "89%",
}}
>
<ItemPoster item={item} />
<ItemCardText item={item} />
</View>
</TouchableItemRouter>
),
[orientation],
);
const renderTVItem = useCallback(
({ item }: { item: BaseItemDto }) => {
const handlePress = () => {
const navTarget = getItemNavigation(item, "(home)");
router.push(navTarget as any);
};
return (
<View
style={{
marginRight: TV_ITEM_GAP,
marginBottom: TV_ITEM_GAP,
}}
>
<TVPosterCard
item={item}
orientation='vertical'
onPress={handlePress}
onLongPress={() => showItemActions(item)}
width={posterSizes.poster}
/>
</View>
);
},
[router, showItemActions, posterSizes.poster],
);
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
const ListHeaderComponent = useCallback(
() => (
<FlatList
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{
display: "flex",
paddingHorizontal: 15,
paddingVertical: 16,
flexDirection: "row",
}}
extraData={[
selectedGenres,
selectedYears,
selectedTags,
sortBy,
sortOrder,
]}
data={[
{
key: "reset",
component: <ResetFiltersButton />,
},
{
key: "genre",
component: (
<FilterButton
className='mr-1'
id={collectionId}
queryKey='genreFilter'
queryFn={async () => {
if (!api) return null;
const response = await getFilterApi(
api,
).getQueryFiltersLegacy({
userId: user?.Id,
parentId: collectionId,
});
return response.data.Genres || [];
}}
set={setSelectedGenres}
values={selectedGenres}
title={t("library.filters.genres")}
renderItemLabel={(item) => item.toString()}
searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase())
}
/>
),
},
{
key: "year",
component: (
<FilterButton
className='mr-1'
id={collectionId}
queryKey='yearFilter'
queryFn={async () => {
if (!api) return null;
const response = await getFilterApi(
api,
).getQueryFiltersLegacy({
userId: user?.Id,
parentId: collectionId,
});
return response.data.Years || [];
}}
set={setSelectedYears}
values={selectedYears}
title={t("library.filters.years")}
renderItemLabel={(item) => item.toString()}
searchFilter={(item, search) => item.includes(search)}
/>
),
},
{
key: "tags",
component: (
<FilterButton
className='mr-1'
id={collectionId}
queryKey='tagsFilter'
queryFn={async () => {
if (!api) return null;
const response = await getFilterApi(
api,
).getQueryFiltersLegacy({
userId: user?.Id,
parentId: collectionId,
});
return response.data.Tags || [];
}}
set={setSelectedTags}
values={selectedTags}
title={t("library.filters.tags")}
renderItemLabel={(item) => item.toString()}
searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase())
}
/>
),
},
{
key: "sortBy",
component: (
<FilterButton
className='mr-1'
id={collectionId}
queryKey='sortBy'
queryFn={async () => sortOptions.map((s) => s.key)}
set={setSortBy}
values={sortBy}
title={t("library.filters.sort_by")}
renderItemLabel={(item) =>
sortOptions.find((i) => i.key === item)?.value || ""
}
searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase())
}
/>
),
},
{
key: "sortOrder",
component: (
<FilterButton
className='mr-1'
id={collectionId}
queryKey='sortOrder'
queryFn={async () => sortOrderOptions.map((s) => s.key)}
set={setSortOrder}
values={sortOrder}
title={t("library.filters.sort_order")}
renderItemLabel={(item) =>
sortOrderOptions.find((i) => i.key === item)?.value || ""
}
searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase())
}
/>
),
},
]}
renderItem={({ item }) => item.component}
keyExtractor={(item) => item.key}
/>
),
[
collectionId,
api,
user?.Id,
selectedGenres,
setSelectedGenres,
selectedYears,
setSelectedYears,
selectedTags,
setSelectedTags,
sortBy,
setSortBy,
sortOrder,
setSortOrder,
isFetching,
],
);
// TV Filter options - with "All" option for clearable filters
const tvGenreFilterOptions = useMemo(
(): TVOptionItem<string>[] => [
{
label: t("library.filters.all"),
value: "__all__",
selected: selectedGenres.length === 0,
},
...(tvGenreOptions || []).map((genre) => ({
label: genre,
value: genre,
selected: selectedGenres.includes(genre),
})),
],
[tvGenreOptions, selectedGenres, t],
);
const tvYearFilterOptions = useMemo(
(): TVOptionItem<string>[] => [
{
label: t("library.filters.all"),
value: "__all__",
selected: selectedYears.length === 0,
},
...(tvYearOptions || []).map((year) => ({
label: String(year),
value: String(year),
selected: selectedYears.includes(String(year)),
})),
],
[tvYearOptions, selectedYears, t],
);
const tvTagFilterOptions = useMemo(
(): TVOptionItem<string>[] => [
{
label: t("library.filters.all"),
value: "__all__",
selected: selectedTags.length === 0,
},
...(tvTagOptions || []).map((tag) => ({
label: tag,
value: tag,
selected: selectedTags.includes(tag),
})),
],
[tvTagOptions, selectedTags, t],
);
const tvSortByOptions = useMemo(
(): TVOptionItem<SortByOption>[] =>
sortOptions.map((option) => ({
label: option.value,
value: option.key,
selected: sortBy[0] === option.key,
})),
[sortBy],
);
const tvSortOrderOptions = useMemo(
(): TVOptionItem<SortOrderOption>[] =>
sortOrderOptions.map((option) => ({
label: option.value,
value: option.key,
selected: sortOrder[0] === option.key,
})),
[sortOrder],
);
// TV Filter handlers using navigation-based modal
const handleShowGenreFilter = useCallback(() => {
showOptions({
title: t("library.filters.genres"),
options: tvGenreFilterOptions,
onSelect: (value: string) => {
if (value === "__all__") {
setSelectedGenres([]);
} else if (selectedGenres.includes(value)) {
setSelectedGenres(selectedGenres.filter((g) => g !== value));
} else {
setSelectedGenres([...selectedGenres, value]);
}
},
});
}, [showOptions, t, tvGenreFilterOptions, selectedGenres, setSelectedGenres]);
const handleShowYearFilter = useCallback(() => {
showOptions({
title: t("library.filters.years"),
options: tvYearFilterOptions,
onSelect: (value: string) => {
if (value === "__all__") {
setSelectedYears([]);
} else if (selectedYears.includes(value)) {
setSelectedYears(selectedYears.filter((y) => y !== value));
} else {
setSelectedYears([...selectedYears, value]);
}
},
});
}, [showOptions, t, tvYearFilterOptions, selectedYears, setSelectedYears]);
const handleShowTagFilter = useCallback(() => {
showOptions({
title: t("library.filters.tags"),
options: tvTagFilterOptions,
onSelect: (value: string) => {
if (value === "__all__") {
setSelectedTags([]);
} else if (selectedTags.includes(value)) {
setSelectedTags(selectedTags.filter((tag) => tag !== value));
} else {
setSelectedTags([...selectedTags, value]);
}
},
});
}, [showOptions, t, tvTagFilterOptions, selectedTags, setSelectedTags]);
const handleShowSortByFilter = useCallback(() => {
showOptions({
title: t("library.filters.sort_by"),
options: tvSortByOptions,
onSelect: (value: SortByOption) => {
setSortBy([value]);
},
});
}, [showOptions, t, tvSortByOptions, setSortBy]);
const handleShowSortOrderFilter = useCallback(() => {
showOptions({
title: t("library.filters.sort_order"),
options: tvSortOrderOptions,
onSelect: (value: SortOrderOption) => {
setSortOrder([value]);
},
});
}, [showOptions, t, tvSortOrderOptions, setSortOrder]);
// TV filter bar state
const hasActiveFilters =
selectedGenres.length > 0 ||
selectedYears.length > 0 ||
selectedTags.length > 0;
const resetAllFilters = useCallback(() => {
setSelectedGenres([]);
setSelectedYears([]);
setSelectedTags([]);
}, [setSelectedGenres, setSelectedYears, setSelectedTags]);
if (isLoading || isCollectionLoading) {
return (
<View className='w-full h-full flex items-center justify-center'>
<Loader />
</View>
);
}
if (!collection) return null;
// Mobile return
if (!Platform.isTV) {
return (
<FlashList
ListEmptyComponent={
<View className='flex flex-col items-center justify-center h-full'>
<Text className='font-bold text-xl text-neutral-500'>
{t("search.no_results")}
</Text>
</View>
}
extraData={[
selectedGenres,
selectedYears,
selectedTags,
sortBy,
sortOrder,
]}
contentInsetAdjustmentBehavior='automatic'
data={flatData}
renderItem={renderItem}
keyExtractor={keyExtractor}
numColumns={nrOfCols}
onEndReached={() => {
if (hasNextPage) {
fetchNextPage();
}
}}
onEndReachedThreshold={0.5}
ListHeaderComponent={ListHeaderComponent}
contentContainerStyle={{ paddingBottom: 24 }}
ItemSeparatorComponent={() => (
<View
style={{
width: 10,
height: 10,
}}
/>
)}
/>
);
}
// TV return with filter bar
return (
<View style={{ flex: 1 }}>
{/* Filter bar */}
<View
style={{
flexDirection: "row",
flexWrap: "nowrap",
marginTop: insets.top + 100,
paddingBottom: 8,
paddingHorizontal: TV_SCALE_PADDING,
gap: 12,
}}
>
{hasActiveFilters && (
<TVFilterButton
label=''
value={t("library.filters.reset")}
onPress={resetAllFilters}
hasActiveFilter
/>
)}
<TVFilterButton
label={t("library.filters.genres")}
value={
selectedGenres.length > 0
? `${selectedGenres.length} selected`
: t("library.filters.all")
}
onPress={handleShowGenreFilter}
hasTVPreferredFocus={!hasActiveFilters}
hasActiveFilter={selectedGenres.length > 0}
/>
<TVFilterButton
label={t("library.filters.years")}
value={
selectedYears.length > 0
? `${selectedYears.length} selected`
: t("library.filters.all")
}
onPress={handleShowYearFilter}
hasActiveFilter={selectedYears.length > 0}
/>
<TVFilterButton
label={t("library.filters.tags")}
value={
selectedTags.length > 0
? `${selectedTags.length} selected`
: t("library.filters.all")
}
onPress={handleShowTagFilter}
hasActiveFilter={selectedTags.length > 0}
/>
<TVFilterButton
label={t("library.filters.sort_by")}
value={sortOptions.find((o) => o.key === sortBy[0])?.value || ""}
onPress={handleShowSortByFilter}
/>
<TVFilterButton
label={t("library.filters.sort_order")}
value={
sortOrderOptions.find((o) => o.key === sortOrder[0])?.value || ""
}
onPress={handleShowSortOrderFilter}
/>
</View>
{/* Grid */}
<FlatList
key={`${orientation}-${nrOfCols}`}
ListEmptyComponent={
<View className='flex flex-col items-center justify-center h-full'>
<Text className='font-bold text-xl text-neutral-500'>
{t("search.no_results")}
</Text>
</View>
}
contentInsetAdjustmentBehavior='automatic'
data={flatData}
renderItem={renderTVItem}
extraData={[orientation, nrOfCols]}
keyExtractor={keyExtractor}
numColumns={nrOfCols}
removeClippedSubviews={false}
onEndReached={() => {
if (hasNextPage) {
fetchNextPage();
}
}}
onEndReachedThreshold={1}
contentContainerStyle={{
paddingBottom: 24,
paddingLeft: TV_SCALE_PADDING,
paddingRight: TV_SCALE_PADDING,
paddingTop: 20,
}}
ItemSeparatorComponent={() => (
<View
style={{
width: 10,
height: 10,
}}
/>
)}
/>
</View>
);
};
export default page;

View File

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

View File

@@ -13,7 +13,7 @@ import {
} from "@/utils/jellyseerr/server/models/Search";
import { COMPANY_LOGO_IMAGE_FILTER } from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
export default function page() {
export default function JellyseerrCompanyPage() {
const local = useLocalSearchParams();
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
@@ -21,19 +21,18 @@ export default function page() {
companyId: string;
name: string;
image: string;
type: DiscoverSliderType;
type: DiscoverSliderType; //This gets converted to a string because it's a url param
};
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
const { data, fetchNextPage, hasNextPage, isLoading } = useInfiniteQuery({
queryKey: ["jellyseerr", "company", type, companyId],
queryFn: async ({ pageParam }) => {
const params: any = {
page: Number(pageParam),
};
return jellyseerrApi?.discover(
`${
type === DiscoverSliderType.NETWORKS
Number(type) === DiscoverSliderType.NETWORKS
? Endpoints.DISCOVER_TV_NETWORK
: Endpoints.DISCOVER_MOVIES_STUDIO
}/${companyId}`,
@@ -86,6 +85,7 @@ export default function page() {
fetchNextPage();
}
}}
isLoading={isLoading}
logo={
<Image
id={companyId}

View File

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

View File

@@ -6,26 +6,30 @@ import {
BottomSheetTextInput,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import type { BottomSheetModalMethods } from "@gorhom/bottom-sheet/lib/typescript/types";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation, useRouter } from "expo-router";
import { useLocalSearchParams, useNavigation } from "expo-router";
import type React from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { toast } from "sonner-native";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { GenreTags } from "@/components/GenreTags";
import Cast from "@/components/jellyseerr/Cast";
import DetailFacts from "@/components/jellyseerr/DetailFacts";
import RequestModal from "@/components/jellyseerr/RequestModal";
import { TVJellyseerrPage } from "@/components/jellyseerr/tv";
import { OverviewText } from "@/components/OverviewText";
import { ParallaxScrollView } from "@/components/ParallaxPage";
import { PlatformDropdown } from "@/components/PlatformDropdown";
import { JellyserrRatings } from "@/components/Ratings";
import JellyseerrSeasons from "@/components/series/JellyseerrSeasons";
import { ItemActions } from "@/components/series/SeriesActions";
import useRouter from "@/hooks/useAppRouter";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";
@@ -33,8 +37,16 @@ import {
type IssueType,
IssueTypeName,
} from "@/utils/jellyseerr/server/constants/issue";
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
import {
MediaRequestStatus,
MediaType,
} from "@/utils/jellyseerr/server/constants/media";
import type MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
import {
hasPermission,
Permission,
} from "@/utils/jellyseerr/server/lib/permissions";
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
import type {
MovieResult,
@@ -42,7 +54,8 @@ import type {
} from "@/utils/jellyseerr/server/models/Search";
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
const Page: React.FC = () => {
// Mobile page component
const MobilePage: React.FC = () => {
const insets = useSafeAreaInsets();
const params = useLocalSearchParams();
const { t } = useTranslation();
@@ -58,13 +71,13 @@ const Page: React.FC = () => {
} & Partial<MovieResult | TvResult | MovieDetails | TvDetails>;
const navigation = useNavigation();
const { jellyseerrApi, requestMedia } = useJellyseerr();
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
const [issueType, setIssueType] = useState<IssueType>();
const [issueMessage, setIssueMessage] = useState<string>();
const [requestBody, _setRequestBody] = useState<MediaRequestBody>();
const [issueTypeDropdownOpen, setIssueTypeDropdownOpen] = useState(false);
const advancedReqModalRef = useRef<BottomSheetModal>(null);
const advancedReqModalRef = useRef<BottomSheetModalMethods>(null);
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const {
@@ -91,6 +104,46 @@ const Page: React.FC = () => {
const [canRequest, hasAdvancedRequestPermission] =
useJellyseerrCanRequest(details);
const canManageRequests = useMemo(() => {
if (!jellyseerrUser) return false;
return hasPermission(
Permission.MANAGE_REQUESTS,
jellyseerrUser.permissions,
);
}, [jellyseerrUser]);
const pendingRequest = useMemo(() => {
return details?.mediaInfo?.requests?.find(
(r: MediaRequest) => r.status === MediaRequestStatus.PENDING,
);
}, [details]);
const handleApproveRequest = useCallback(async () => {
if (!pendingRequest?.id) return;
try {
await jellyseerrApi?.approveRequest(pendingRequest.id);
toast.success(t("jellyseerr.toasts.request_approved"));
refetch();
} catch (error) {
toast.error(t("jellyseerr.toasts.failed_to_approve_request"));
console.error("Failed to approve request:", error);
}
}, [jellyseerrApi, pendingRequest, refetch, t]);
const handleDeclineRequest = useCallback(async () => {
if (!pendingRequest?.id) return;
try {
await jellyseerrApi?.declineRequest(pendingRequest.id);
toast.success(t("jellyseerr.toasts.request_declined"));
refetch();
} catch (error) {
toast.error(t("jellyseerr.toasts.failed_to_decline_request"));
console.error("Failed to decline request:", error);
}
}, [jellyseerrApi, pendingRequest, refetch, t]);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
@@ -334,6 +387,60 @@ const Page: React.FC = () => {
</View>
)
)}
{canManageRequests && pendingRequest && (
<View className='flex flex-col space-y-2 mt-4'>
<View className='flex flex-row items-center space-x-2'>
<Ionicons name='person-outline' size={16} color='#9CA3AF' />
<Text className='text-sm text-neutral-400'>
{t("jellyseerr.requested_by", {
user:
pendingRequest.requestedBy?.displayName ||
pendingRequest.requestedBy?.username ||
pendingRequest.requestedBy?.jellyfinUsername ||
t("jellyseerr.unknown_user"),
})}
</Text>
</View>
<View className='flex flex-row space-x-2'>
<Button
className='flex-1 bg-green-600/50 border-green-400 ring-green-400 text-green-100'
color='transparent'
onPress={handleApproveRequest}
iconLeft={
<Ionicons
name='checkmark-outline'
size={20}
color='white'
/>
}
style={{
borderWidth: 1,
borderStyle: "solid",
}}
>
<Text className='text-sm'>{t("jellyseerr.approve")}</Text>
</Button>
<Button
className='flex-1 bg-red-600/50 border-red-400 ring-red-400 text-red-100'
color='transparent'
onPress={handleDeclineRequest}
iconLeft={
<Ionicons
name='close-outline'
size={20}
color='white'
/>
}
style={{
borderWidth: 1,
borderStyle: "solid",
}}
>
<Text className='text-sm'>{t("jellyseerr.decline")}</Text>
</Button>
</View>
</View>
)}
<OverviewText text={result.overview} className='mt-4' />
</View>
@@ -438,4 +545,12 @@ const Page: React.FC = () => {
);
};
// Platform-conditional page component
const Page: React.FC = () => {
if (Platform.isTV) {
return <TVJellyseerrPage />;
}
return <MobilePage />;
};
export default Page;

View File

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

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

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

View File

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

View File

@@ -2,12 +2,21 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
import { 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

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

View File

@@ -0,0 +1,300 @@
import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
import { FlashList } from "@shopify/flash-list";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
Dimensions,
TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal";
import { MusicTrackItem } from "@/components/music/MusicTrackItem";
import { PlaylistPickerSheet } from "@/components/music/PlaylistPickerSheet";
import { TrackOptionsSheet } from "@/components/music/TrackOptionsSheet";
import {
downloadTrack,
isPermanentlyDownloaded,
} from "@/providers/AudioStorage";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useMusicPlayer } from "@/providers/MusicPlayerProvider";
import { getAudioStreamUrl } from "@/utils/jellyfin/audio/getAudioStreamUrl";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { runtimeTicksToMinutes } from "@/utils/time";
const { width: SCREEN_WIDTH } = Dimensions.get("window");
const ARTWORK_SIZE = SCREEN_WIDTH * 0.5;
export default function AlbumDetailScreen() {
const { albumId } = useLocalSearchParams<{ albumId: string }>();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const insets = useSafeAreaInsets();
const navigation = useNavigation();
const { t } = useTranslation();
const { playQueue } = useMusicPlayer();
const [selectedTrack, setSelectedTrack] = useState<BaseItemDto | null>(null);
const [trackOptionsOpen, setTrackOptionsOpen] = useState(false);
const [playlistPickerOpen, setPlaylistPickerOpen] = useState(false);
const [createPlaylistOpen, setCreatePlaylistOpen] = useState(false);
const [isDownloading, setIsDownloading] = useState(false);
const handleTrackOptionsPress = useCallback((track: BaseItemDto) => {
setSelectedTrack(track);
setTrackOptionsOpen(true);
}, []);
const handleAddToPlaylist = useCallback(() => {
setPlaylistPickerOpen(true);
}, []);
const handleCreateNewPlaylist = useCallback(() => {
setCreatePlaylistOpen(true);
}, []);
const { data: album, isLoading: loadingAlbum } = useQuery({
queryKey: ["music-album", albumId, user?.Id],
queryFn: async () => {
const response = await getUserLibraryApi(api!).getItem({
userId: user?.Id,
itemId: albumId!,
});
return response.data;
},
enabled: !!api && !!user?.Id && !!albumId,
});
const { data: tracks, isLoading: loadingTracks } = useQuery({
queryKey: ["music-album-tracks", albumId, user?.Id],
queryFn: async () => {
const response = await getItemsApi(api!).getItems({
userId: user?.Id,
parentId: albumId,
sortBy: ["IndexNumber"],
sortOrder: ["Ascending"],
});
return response.data.Items || [];
},
enabled: !!api && !!user?.Id && !!albumId,
});
useEffect(() => {
navigation.setOptions({
title: album?.Name ?? "",
headerTransparent: true,
headerStyle: { backgroundColor: "transparent" },
headerShadowVisible: false,
});
}, [album?.Name, navigation]);
const imageUrl = useMemo(
() => (album ? getPrimaryImageUrl({ api, item: album }) : null),
[api, album],
);
const totalDuration = useMemo(() => {
if (!tracks) return "";
const totalTicks = tracks.reduce(
(acc, track) => acc + (track.RunTimeTicks || 0),
0,
);
return runtimeTicksToMinutes(totalTicks);
}, [tracks]);
const handlePlayAll = useCallback(() => {
if (tracks && tracks.length > 0) {
playQueue(tracks, 0);
}
}, [playQueue, tracks]);
const handleShuffle = useCallback(() => {
if (tracks && tracks.length > 0) {
const shuffled = [...tracks].sort(() => Math.random() - 0.5);
playQueue(shuffled, 0);
}
}, [playQueue, tracks]);
// Check if all tracks are already permanently downloaded
const allTracksDownloaded = useMemo(() => {
if (!tracks || tracks.length === 0) return false;
return tracks.every((track) => isPermanentlyDownloaded(track.Id));
}, [tracks]);
const handleDownloadAlbum = useCallback(async () => {
if (!tracks || !api || !user?.Id || isDownloading) return;
setIsDownloading(true);
try {
for (const track of tracks) {
if (!track.Id || isPermanentlyDownloaded(track.Id)) continue;
const result = await getAudioStreamUrl(api, user.Id, track.Id);
if (result?.url && !result.isTranscoding) {
await downloadTrack(track.Id, result.url, {
permanent: true,
container: result.mediaSource?.Container || undefined,
});
}
}
} catch {
// Silent fail
}
setIsDownloading(false);
}, [tracks, api, user?.Id, isDownloading]);
const isLoading = loadingAlbum || loadingTracks;
// Only show loading if we have no cached data to display
if (isLoading && !album) {
return (
<View className='flex-1 justify-center items-center bg-black'>
<Loader />
</View>
);
}
if (!album) {
return (
<View className='flex-1 justify-center items-center bg-black'>
<Text className='text-neutral-500'>{t("music.album_not_found")}</Text>
</View>
);
}
return (
<FlashList
data={tracks || []}
contentContainerStyle={{
paddingBottom: insets.bottom + 100,
}}
ListHeaderComponent={
<View
className='items-center px-4 pb-6 bg-black'
style={{ paddingTop: insets.top + 60 }}
>
{/* Album artwork */}
<View
style={{
width: ARTWORK_SIZE,
height: ARTWORK_SIZE,
borderRadius: 8,
overflow: "hidden",
backgroundColor: "#1a1a1a",
shadowColor: "#000",
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.3,
shadowRadius: 12,
elevation: 8,
}}
>
{imageUrl ? (
<Image
source={{ uri: imageUrl }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
cachePolicy='memory-disk'
/>
) : (
<View className='flex-1 items-center justify-center bg-neutral-800'>
<Ionicons name='disc' size={60} color='#666' />
</View>
)}
</View>
{/* Album info */}
<Text className='text-white text-xl font-bold mt-4 text-center'>
{album.Name}
</Text>
<Text className='text-purple-400 text-base mt-1'>
{album.AlbumArtist || album.Artists?.join(", ")}
</Text>
<Text className='text-neutral-500 text-sm mt-1'>
{album.ProductionYear && `${album.ProductionYear}`}
{tracks?.length} tracks {totalDuration}
</Text>
{/* Play buttons */}
<View className='flex flex-row mt-4 items-center'>
<TouchableOpacity
onPress={handlePlayAll}
className='flex flex-row items-center bg-purple-600 px-6 py-3 rounded-full mr-3'
>
<Ionicons name='play' size={20} color='white' />
<Text className='text-white font-medium ml-2'>
{t("music.play")}
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={handleShuffle}
className='flex flex-row items-center bg-neutral-800 px-6 py-3 rounded-full mr-3'
>
<Ionicons name='shuffle' size={20} color='white' />
<Text className='text-white font-medium ml-2'>
{t("music.shuffle")}
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={handleDownloadAlbum}
disabled={allTracksDownloaded || isDownloading}
className='flex items-center justify-center bg-neutral-800 p-3 rounded-full'
>
{isDownloading ? (
<ActivityIndicator size={20} color='white' />
) : (
<Ionicons
name={
allTracksDownloaded
? "checkmark-circle"
: "download-outline"
}
size={20}
color={allTracksDownloaded ? "#22c55e" : "white"}
/>
)}
</TouchableOpacity>
</View>
</View>
}
renderItem={({ item, index }) => (
<MusicTrackItem
track={item}
index={index + 1}
queue={tracks}
showArtwork={false}
onOptionsPress={handleTrackOptionsPress}
/>
)}
keyExtractor={(item) => item.Id!}
ListFooterComponent={
<>
<TrackOptionsSheet
open={trackOptionsOpen}
setOpen={setTrackOptionsOpen}
track={selectedTrack}
onAddToPlaylist={handleAddToPlaylist}
/>
<PlaylistPickerSheet
open={playlistPickerOpen}
setOpen={setPlaylistPickerOpen}
trackToAdd={selectedTrack}
onCreateNew={handleCreateNewPlaylist}
/>
<CreatePlaylistModal
open={createPlaylistOpen}
setOpen={setCreatePlaylistOpen}
initialTrackId={selectedTrack?.Id}
/>
</>
}
/>
);
}

View File

@@ -0,0 +1,273 @@
import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
import { FlashList } from "@shopify/flash-list";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Dimensions, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { HorizontalScroll } from "@/components/common/HorizontalScroll";
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal";
import { MusicAlbumCard } from "@/components/music/MusicAlbumCard";
import { MusicTrackItem } from "@/components/music/MusicTrackItem";
import { PlaylistPickerSheet } from "@/components/music/PlaylistPickerSheet";
import { TrackOptionsSheet } from "@/components/music/TrackOptionsSheet";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useMusicPlayer } from "@/providers/MusicPlayerProvider";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
const { width: SCREEN_WIDTH } = Dimensions.get("window");
const ARTWORK_SIZE = SCREEN_WIDTH * 0.4;
export default function ArtistDetailScreen() {
const { artistId } = useLocalSearchParams<{ artistId: string }>();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const insets = useSafeAreaInsets();
const navigation = useNavigation();
const { t } = useTranslation();
const { playQueue } = useMusicPlayer();
const [selectedTrack, setSelectedTrack] = useState<BaseItemDto | null>(null);
const [trackOptionsOpen, setTrackOptionsOpen] = useState(false);
const [playlistPickerOpen, setPlaylistPickerOpen] = useState(false);
const [createPlaylistOpen, setCreatePlaylistOpen] = useState(false);
const handleTrackOptionsPress = useCallback((track: BaseItemDto) => {
setSelectedTrack(track);
setTrackOptionsOpen(true);
}, []);
const handleAddToPlaylist = useCallback(() => {
setPlaylistPickerOpen(true);
}, []);
const handleCreateNewPlaylist = useCallback(() => {
setCreatePlaylistOpen(true);
}, []);
const { data: artist, isLoading: loadingArtist } = useQuery({
queryKey: ["music-artist", artistId, user?.Id],
queryFn: async () => {
const response = await getUserLibraryApi(api!).getItem({
userId: user?.Id,
itemId: artistId!,
});
return response.data;
},
enabled: !!api && !!user?.Id && !!artistId,
});
const { data: albums, isLoading: loadingAlbums } = useQuery({
queryKey: ["music-artist-albums", artistId, user?.Id],
queryFn: async () => {
const response = await getItemsApi(api!).getItems({
userId: user?.Id,
artistIds: [artistId!],
includeItemTypes: ["MusicAlbum"],
sortBy: ["ProductionYear", "SortName"],
sortOrder: ["Descending", "Ascending"],
recursive: true,
});
return response.data.Items || [];
},
enabled: !!api && !!user?.Id && !!artistId,
});
const { data: topTracks, isLoading: loadingTracks } = useQuery({
queryKey: ["music-artist-top-tracks", artistId, user?.Id],
queryFn: async () => {
const response = await getItemsApi(api!).getItems({
userId: user?.Id,
artistIds: [artistId!],
includeItemTypes: ["Audio"],
sortBy: ["PlayCount"],
sortOrder: ["Descending"],
limit: 10,
recursive: true,
filters: ["IsPlayed"],
});
return response.data.Items || [];
},
enabled: !!api && !!user?.Id && !!artistId,
});
useEffect(() => {
navigation.setOptions({
title: artist?.Name ?? "",
headerTransparent: true,
headerStyle: { backgroundColor: "transparent" },
headerShadowVisible: false,
});
}, [artist?.Name, navigation]);
const imageUrl = useMemo(
() => (artist ? getPrimaryImageUrl({ api, item: artist }) : null),
[api, artist],
);
const handlePlayAllTracks = useCallback(() => {
if (topTracks && topTracks.length > 0) {
playQueue(topTracks, 0);
}
}, [playQueue, topTracks]);
const isLoading = loadingArtist || loadingAlbums || loadingTracks;
// Only show loading if we have no cached data to display
if (isLoading && !artist) {
return (
<View className='flex-1 justify-center items-center bg-black'>
<Loader />
</View>
);
}
if (!artist) {
return (
<View className='flex-1 justify-center items-center bg-black'>
<Text className='text-neutral-500'>{t("music.artist_not_found")}</Text>
</View>
);
}
const sections = [];
// Top tracks section
if (topTracks && topTracks.length > 0) {
sections.push({
id: "top-tracks",
title: t("music.top_tracks"),
type: "tracks" as const,
data: topTracks,
});
}
// Albums section
if (albums && albums.length > 0) {
sections.push({
id: "albums",
title: t("music.tabs.albums"),
type: "albums" as const,
data: albums,
});
}
return (
<FlashList
data={sections}
contentContainerStyle={{
paddingBottom: insets.bottom + 100,
}}
ListHeaderComponent={
<View
className='items-center px-4 pb-6 bg-black'
style={{ paddingTop: insets.top + 50 }}
>
{/* Artist image */}
<View
style={{
width: ARTWORK_SIZE,
height: ARTWORK_SIZE,
borderRadius: ARTWORK_SIZE / 2,
overflow: "hidden",
backgroundColor: "#1a1a1a",
shadowColor: "#000",
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.3,
shadowRadius: 12,
elevation: 8,
}}
>
{imageUrl ? (
<Image
source={{ uri: imageUrl }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
cachePolicy='memory-disk'
/>
) : (
<View className='flex-1 items-center justify-center bg-neutral-800'>
<Ionicons name='person' size={60} color='#666' />
</View>
)}
</View>
{/* Artist info */}
<Text className='text-white text-2xl font-bold mt-4 text-center'>
{artist.Name}
</Text>
<Text className='text-neutral-500 text-sm mt-1'>
{albums?.length || 0} {t("music.tabs.albums").toLowerCase()}
</Text>
{/* Play button */}
{topTracks && topTracks.length > 0 && (
<TouchableOpacity
onPress={handlePlayAllTracks}
className='flex flex-row items-center bg-purple-600 px-6 py-3 rounded-full mt-4'
>
<Ionicons name='play' size={20} color='white' />
<Text className='text-white font-medium ml-2'>
{t("music.play_top_tracks")}
</Text>
</TouchableOpacity>
)}
</View>
}
renderItem={({ item: section }) => (
<View className='mb-6'>
<Text className='text-lg font-bold px-4 mb-3'>{section.title}</Text>
{section.type === "albums" ? (
<HorizontalScroll
data={section.data}
height={178}
keyExtractor={(item) => item.Id!}
renderItem={(item) => <MusicAlbumCard album={item} />}
/>
) : (
section.data
.slice(0, 5)
.map((track, index) => (
<MusicTrackItem
key={track.Id}
track={track}
index={index + 1}
queue={section.data}
onOptionsPress={handleTrackOptionsPress}
/>
))
)}
</View>
)}
keyExtractor={(item) => item.id}
ListFooterComponent={
<>
<TrackOptionsSheet
open={trackOptionsOpen}
setOpen={setTrackOptionsOpen}
track={selectedTrack}
onAddToPlaylist={handleAddToPlaylist}
/>
<PlaylistPickerSheet
open={playlistPickerOpen}
setOpen={setPlaylistPickerOpen}
trackToAdd={selectedTrack}
onCreateNew={handleCreateNewPlaylist}
/>
<CreatePlaylistModal
open={createPlaylistOpen}
setOpen={setCreatePlaylistOpen}
initialTrackId={selectedTrack?.Id}
/>
</>
}
/>
);
}

View File

@@ -0,0 +1,315 @@
import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
import { FlashList } from "@shopify/flash-list";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal";
import { MusicTrackItem } from "@/components/music/MusicTrackItem";
import { PlaylistOptionsSheet } from "@/components/music/PlaylistOptionsSheet";
import { PlaylistPickerSheet } from "@/components/music/PlaylistPickerSheet";
import { TrackOptionsSheet } from "@/components/music/TrackOptionsSheet";
import { useRemoveFromPlaylist } from "@/hooks/usePlaylistMutations";
import { downloadTrack, getLocalPath } from "@/providers/AudioStorage";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useMusicPlayer } from "@/providers/MusicPlayerProvider";
import { getAudioStreamUrl } from "@/utils/jellyfin/audio/getAudioStreamUrl";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { runtimeTicksToMinutes } from "@/utils/time";
const ARTWORK_SIZE = 120;
export default function PlaylistDetailScreen() {
const { playlistId } = useLocalSearchParams<{ playlistId: string }>();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const insets = useSafeAreaInsets();
const navigation = useNavigation();
const { t } = useTranslation();
const { playQueue } = useMusicPlayer();
const [selectedTrack, setSelectedTrack] = useState<BaseItemDto | null>(null);
const [trackOptionsOpen, setTrackOptionsOpen] = useState(false);
const [playlistPickerOpen, setPlaylistPickerOpen] = useState(false);
const [createPlaylistOpen, setCreatePlaylistOpen] = useState(false);
const [playlistOptionsOpen, setPlaylistOptionsOpen] = useState(false);
const [isDownloading, setIsDownloading] = useState(false);
const removeFromPlaylist = useRemoveFromPlaylist();
const handleTrackOptionsPress = useCallback((track: BaseItemDto) => {
setSelectedTrack(track);
setTrackOptionsOpen(true);
}, []);
const handleAddToPlaylist = useCallback(() => {
setPlaylistPickerOpen(true);
}, []);
const handleCreateNewPlaylist = useCallback(() => {
setCreatePlaylistOpen(true);
}, []);
const handleRemoveFromPlaylist = useCallback(() => {
if (selectedTrack?.Id && playlistId) {
removeFromPlaylist.mutate({
playlistId,
entryIds: [selectedTrack.PlaylistItemId ?? selectedTrack.Id],
});
}
}, [selectedTrack, playlistId, removeFromPlaylist]);
const { data: playlist, isLoading: loadingPlaylist } = useQuery({
queryKey: ["music-playlist", playlistId, user?.Id],
queryFn: async () => {
const response = await getUserLibraryApi(api!).getItem({
userId: user?.Id,
itemId: playlistId!,
});
return response.data;
},
enabled: !!api && !!user?.Id && !!playlistId,
});
const { data: tracks, isLoading: loadingTracks } = useQuery({
queryKey: ["music-playlist-tracks", playlistId, user?.Id],
queryFn: async () => {
const response = await getItemsApi(api!).getItems({
userId: user?.Id,
parentId: playlistId,
});
return response.data.Items || [];
},
enabled: !!api && !!user?.Id && !!playlistId,
});
useEffect(() => {
navigation.setOptions({
title: playlist?.Name ?? "",
headerTransparent: true,
headerStyle: { backgroundColor: "transparent" },
headerShadowVisible: false,
headerRight: () => (
<TouchableOpacity
onPress={() => setPlaylistOptionsOpen(true)}
className='p-1.5'
>
<Ionicons name='ellipsis-horizontal' size={24} color='white' />
</TouchableOpacity>
),
});
}, [playlist?.Name, navigation]);
const imageUrl = useMemo(
() => (playlist ? getPrimaryImageUrl({ api, item: playlist }) : null),
[api, playlist],
);
const totalDuration = useMemo(() => {
if (!tracks) return "";
const totalTicks = tracks.reduce(
(acc, track) => acc + (track.RunTimeTicks || 0),
0,
);
return runtimeTicksToMinutes(totalTicks);
}, [tracks]);
const handlePlayAll = useCallback(() => {
if (tracks && tracks.length > 0) {
playQueue(tracks, 0);
}
}, [playQueue, tracks]);
const handleShuffle = useCallback(() => {
if (tracks && tracks.length > 0) {
const shuffled = [...tracks].sort(() => Math.random() - 0.5);
playQueue(shuffled, 0);
}
}, [playQueue, tracks]);
// Check if all tracks are already downloaded
const allTracksDownloaded = useMemo(() => {
if (!tracks || tracks.length === 0) return false;
return tracks.every((track) => !!getLocalPath(track.Id));
}, [tracks]);
const handleDownloadPlaylist = useCallback(async () => {
if (!tracks || !api || !user?.Id || isDownloading) return;
setIsDownloading(true);
try {
for (const track of tracks) {
if (!track.Id || getLocalPath(track.Id)) continue;
const result = await getAudioStreamUrl(api, user.Id, track.Id);
if (result?.url && !result.isTranscoding) {
await downloadTrack(track.Id, result.url, {
permanent: true,
container: result.mediaSource?.Container || undefined,
});
}
}
} catch {
// Silent fail
}
setIsDownloading(false);
}, [tracks, api, user?.Id, isDownloading]);
const isLoading = loadingPlaylist || loadingTracks;
// Only show loading if we have no cached data to display
if (isLoading && !playlist) {
return (
<View className='flex-1 justify-center items-center bg-black'>
<Loader />
</View>
);
}
if (!playlist) {
return (
<View className='flex-1 justify-center items-center bg-black'>
<Text className='text-neutral-500'>
{t("music.playlist_not_found")}
</Text>
</View>
);
}
return (
<FlashList
data={tracks || []}
contentContainerStyle={{
paddingBottom: insets.bottom + 100,
}}
ListHeaderComponent={
<View
className='items-center px-4 pb-6 bg-black'
style={{ paddingTop: insets.top + 50 }}
>
{/* Playlist artwork */}
<View
style={{
width: ARTWORK_SIZE,
height: ARTWORK_SIZE,
borderRadius: 8,
overflow: "hidden",
backgroundColor: "#1a1a1a",
shadowColor: "#000",
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.3,
shadowRadius: 12,
elevation: 8,
}}
>
{imageUrl ? (
<Image
source={{ uri: imageUrl }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
cachePolicy='memory-disk'
/>
) : (
<View className='flex-1 items-center justify-center bg-neutral-800'>
<Ionicons name='list' size={60} color='#666' />
</View>
)}
</View>
{/* Playlist info */}
<Text className='text-white text-xl font-bold mt-4 text-center'>
{playlist.Name}
</Text>
<Text className='text-neutral-500 text-sm mt-1'>
{tracks?.length} tracks {totalDuration}
</Text>
{/* Play buttons */}
<View className='flex flex-row mt-4 items-center'>
<TouchableOpacity
onPress={handlePlayAll}
className='flex flex-row items-center bg-purple-600 px-6 py-3 rounded-full mr-3'
>
<Ionicons name='play' size={20} color='white' />
<Text className='text-white font-medium ml-2'>
{t("music.play")}
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={handleShuffle}
className='flex flex-row items-center bg-neutral-800 px-6 py-3 rounded-full mr-3'
>
<Ionicons name='shuffle' size={20} color='white' />
<Text className='text-white font-medium ml-2'>
{t("music.shuffle")}
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={handleDownloadPlaylist}
disabled={allTracksDownloaded || isDownloading}
className='flex items-center justify-center bg-neutral-800 p-3 rounded-full'
>
{isDownloading ? (
<ActivityIndicator size={20} color='white' />
) : (
<Ionicons
name={
allTracksDownloaded
? "checkmark-circle"
: "download-outline"
}
size={20}
color={allTracksDownloaded ? "#22c55e" : "white"}
/>
)}
</TouchableOpacity>
</View>
</View>
}
renderItem={({ item, index }) => (
<MusicTrackItem
track={item}
index={index + 1}
queue={tracks}
onOptionsPress={handleTrackOptionsPress}
/>
)}
keyExtractor={(item) => item.Id!}
ListFooterComponent={
<>
<TrackOptionsSheet
open={trackOptionsOpen}
setOpen={setTrackOptionsOpen}
track={selectedTrack}
onAddToPlaylist={handleAddToPlaylist}
playlistId={playlistId}
onRemoveFromPlaylist={handleRemoveFromPlaylist}
/>
<PlaylistPickerSheet
open={playlistPickerOpen}
setOpen={setPlaylistPickerOpen}
trackToAdd={selectedTrack}
onCreateNew={handleCreateNewPlaylist}
/>
<CreatePlaylistModal
open={createPlaylistOpen}
setOpen={setCreatePlaylistOpen}
initialTrackId={selectedTrack?.Id}
/>
<PlaylistOptionsSheet
open={playlistOptionsOpen}
setOpen={setPlaylistOptionsOpen}
playlist={playlist}
/>
</>
}
/>
);
}

View File

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

View File

@@ -0,0 +1,232 @@
import { Ionicons } from "@expo/vector-icons";
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import type React from "react";
import { useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Platform, View } from "react-native";
import { AddToFavorites } from "@/components/AddToFavorites";
import { DownloadItems } from "@/components/DownloadItem";
import { ParallaxScrollView } from "@/components/ParallaxPage";
import { NextUp } from "@/components/series/NextUp";
import { SeasonPicker } from "@/components/series/SeasonPicker";
import { SeriesHeader } from "@/components/series/SeriesHeader";
import { TVSeriesPage } from "@/components/series/TVSeriesPage";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
import {
buildOfflineSeriesFromEpisodes,
getDownloadedEpisodesForSeries,
} from "@/utils/downloads/offline-series";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { storage } from "@/utils/mmkv";
const page: React.FC = () => {
const navigation = useNavigation();
const { t } = useTranslation();
const params = useLocalSearchParams();
const {
id: seriesId,
seasonIndex,
offline: offlineParam,
} = params as {
id: string;
seasonIndex: string;
offline?: string;
};
const isOffline = offlineParam === "true";
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { getDownloadedItems, downloadedItems } = useDownload();
// For offline mode, construct series data from downloaded episodes
// Include downloadedItems.length so query refetches when items are deleted
const { data: item } = useQuery({
queryKey: ["series", seriesId, isOffline, downloadedItems.length],
queryFn: async () => {
if (isOffline) {
return buildOfflineSeriesFromEpisodes(getDownloadedItems(), seriesId);
}
return await getUserItemData({
api,
userId: user?.Id,
itemId: seriesId,
});
},
staleTime: isOffline ? Infinity : 60 * 1000,
refetchInterval: !isOffline && Platform.isTV ? 60 * 1000 : undefined,
enabled: isOffline || (!!api && !!user?.Id),
});
// For offline mode, use stored base64 image
const base64Image = useMemo(() => {
if (isOffline) {
return storage.getString(seriesId);
}
return null;
}, [isOffline, seriesId]);
const backdropUrl = useMemo(() => {
if (isOffline && base64Image) {
return `data:image/jpeg;base64,${base64Image}`;
}
return getBackdropUrl({
api,
item,
quality: 90,
width: 1000,
});
}, [isOffline, base64Image, api, item]);
const logoUrl = useMemo(() => {
if (isOffline) {
return null; // No logo in offline mode
}
return getLogoImageUrlById({
api,
item,
});
}, [isOffline, api, item]);
const { data: allEpisodes, isLoading } = useQuery({
queryKey: ["AllEpisodes", seriesId, isOffline, downloadedItems.length],
queryFn: async () => {
if (isOffline) {
return getDownloadedEpisodesForSeries(getDownloadedItems(), seriesId);
}
if (!api || !user?.Id) return [];
const res = await getTvShowsApi(api).getEpisodes({
seriesId: seriesId,
userId: user.Id,
enableUserData: true,
fields: ["MediaSources", "MediaStreams", "Overview", "Trickplay"],
});
return res?.data.Items || [];
},
select: (data) =>
[...(data || [])].sort(
(a, b) =>
(a.ParentIndexNumber ?? 0) - (b.ParentIndexNumber ?? 0) ||
(a.IndexNumber ?? 0) - (b.IndexNumber ?? 0),
),
staleTime: isOffline ? Infinity : 60 * 1000,
refetchInterval: !isOffline && Platform.isTV ? 60 * 1000 : undefined,
enabled: isOffline || (!!api && !!user?.Id),
});
useEffect(() => {
// Don't show header buttons in offline mode
if (isOffline) {
navigation.setOptions({
headerRight: () => null,
});
return;
}
navigation.setOptions({
headerRight: () =>
!isLoading && item && allEpisodes && allEpisodes.length > 0 ? (
<View className='flex flex-row items-center space-x-2'>
<AddToFavorites item={item} />
{!Platform.isTV && (
<DownloadItems
size='large'
title={t("item_card.download.download_series")}
items={allEpisodes || []}
MissingDownloadIconComponent={() => (
<Ionicons name='download' size={22} color='white' />
)}
DownloadedIconComponent={() => (
<Ionicons
name='checkmark-done-outline'
size={24}
color='#9333ea'
/>
)}
/>
)}
</View>
) : null,
});
}, [allEpisodes, isLoading, item, isOffline]);
// For offline mode, we can show the page even without backdropUrl
if (!item || (!isOffline && !backdropUrl)) return null;
// TV version
if (Platform.isTV) {
return (
<OfflineModeProvider isOffline={isOffline}>
<TVSeriesPage
item={item}
allEpisodes={allEpisodes}
isLoading={isLoading}
/>
</OfflineModeProvider>
);
}
return (
<OfflineModeProvider isOffline={isOffline}>
<ParallaxScrollView
headerHeight={400}
headerImage={
backdropUrl ? (
<Image
source={{
uri: backdropUrl,
}}
style={{
width: "100%",
height: "100%",
}}
/>
) : (
<View
style={{
width: "100%",
height: "100%",
backgroundColor: "#1a1a1a",
}}
/>
)
}
logo={
logoUrl ? (
<Image
source={{
uri: logoUrl,
}}
style={{
height: 130,
width: "100%",
}}
contentFit='contain'
/>
) : undefined
}
>
<View className='flex flex-col pt-4'>
<SeriesHeader item={item} />
{!isOffline && (
<View className='mb-4'>
<NextUp seriesId={seriesId} />
</View>
)}
<SeasonPicker item={item} initialSeasonIndex={Number(seasonIndex)} />
</View>
</ParallaxScrollView>
</OfflineModeProvider>
);
};
export default page;

View File

@@ -2,6 +2,7 @@ import type {
BaseItemDto,
BaseItemDtoQueryResult,
BaseItemKind,
ItemFilter,
} from "@jellyfin/sdk/lib/generated-client/models";
import {
getFilterApi,
@@ -10,24 +11,46 @@ 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";
import { useTranslation } from "react-i18next";
import { FlatList, useWindowDimensions, View } from "react-native";
import {
FlatList,
Platform,
ScrollView,
useWindowDimensions,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import {
getItemNavigation,
TouchableItemRouter,
} from "@/components/common/TouchableItemRouter";
import { FilterButton } from "@/components/filters/FilterButton";
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
import { ItemCardText } from "@/components/ItemCardText";
import { Loader } from "@/components/Loader";
import { ItemPoster } from "@/components/posters/ItemPoster";
import { TVFilterButton, TVFocusablePoster } from "@/components/tv";
import { TVPosterCard } from "@/components/tv/TVPosterCard";
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { useOrientation } from "@/hooks/useOrientation";
import { useRefreshLibraryOnFocus } from "@/hooks/useRefreshLibraryOnFocus";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import {
FilterByOption,
FilterByPreferenceAtom,
filterByAtom,
genreFilterAtom,
getFilterByPreference,
getSortByPreference,
getSortOrderPreference,
SortByOption,
@@ -39,13 +62,29 @@ import {
sortOrderOptions,
sortOrderPreferenceAtom,
tagsFilterAtom,
useFilterOptions,
yearFilterAtom,
} 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();
const { libraryId } = searchParams as { libraryId: string };
const searchParams = useLocalSearchParams() as {
libraryId: string;
sortBy?: string;
sortOrder?: string;
filterBy?: string;
};
const { libraryId } = searchParams;
const typography = useScaledTVTypography();
const posterSizes = useScaledTVPosterSizes();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { width: screenWidth } = useWindowDimensions();
@@ -54,28 +93,95 @@ const Page = () => {
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
const [sortBy, _setSortBy] = useAtom(sortByAtom);
const [filterBy, _setFilterBy] = useAtom(filterByAtom);
const [sortOrder, _setSortOrder] = useAtom(sortOrderAtom);
const [sortByPreference, setSortByPreference] = useAtom(sortByPreferenceAtom);
const [sortOrderPreference, setOderByPreference] = useAtom(
const [filterByPreference, setFilterByPreference] = useAtom(
FilterByPreferenceAtom,
);
const [sortOrderPreference, setOrderByPreference] = useAtom(
sortOrderPreferenceAtom,
);
const { orientation } = useOrientation();
// Fallback refresh for newly added content when returning to the library
// (primary path is the LibraryChanged WebSocket event).
useRefreshLibraryOnFocus();
const { t } = useTranslation();
const router = useRouter();
const { showOptions } = useTVOptionModal();
const { showItemActions } = useTVItemActionModal();
// TV Filter queries
const { data: tvGenreOptions } = useQuery({
queryKey: ["filters", "Genres", "tvGenreFilter", libraryId],
queryFn: async () => {
if (!api) return [];
const response = await getFilterApi(api).getQueryFiltersLegacy({
userId: user?.Id,
parentId: libraryId,
});
return response.data.Genres || [];
},
enabled: Platform.isTV && !!api && !!user?.Id && !!libraryId,
});
const { data: tvYearOptions } = useQuery({
queryKey: ["filters", "Years", "tvYearFilter", libraryId],
queryFn: async () => {
if (!api) return [];
const response = await getFilterApi(api).getQueryFiltersLegacy({
userId: user?.Id,
parentId: libraryId,
});
return response.data.Years || [];
},
enabled: Platform.isTV && !!api && !!user?.Id && !!libraryId,
});
const { data: tvTagOptions } = useQuery({
queryKey: ["filters", "Tags", "tvTagFilter", libraryId],
queryFn: async () => {
if (!api) return [];
const response = await getFilterApi(api).getQueryFiltersLegacy({
userId: user?.Id,
parentId: libraryId,
});
return response.data.Tags || [];
},
enabled: Platform.isTV && !!api && !!user?.Id && !!libraryId,
});
useEffect(() => {
const sop = getSortOrderPreference(libraryId, sortOrderPreference);
if (sop) {
_setSortOrder([sop]);
// Check for URL params first (from "See All" navigation)
const urlSortBy = searchParams.sortBy as SortByOption | undefined;
const urlSortOrder = searchParams.sortOrder as SortOrderOption | undefined;
const urlFilterBy = searchParams.filterBy as FilterByOption | undefined;
// Apply sortOrder: URL param > saved preference > default
if (urlSortOrder && Object.values(SortOrderOption).includes(urlSortOrder)) {
_setSortOrder([urlSortOrder]);
} else {
_setSortOrder([SortOrderOption.Ascending]);
const sop = getSortOrderPreference(libraryId, sortOrderPreference);
_setSortOrder([sop || SortOrderOption.Ascending]);
}
const obp = getSortByPreference(libraryId, sortByPreference);
if (obp) {
_setSortBy([obp]);
// Apply sortBy: URL param > saved preference > default
if (urlSortBy && Object.values(SortByOption).includes(urlSortBy)) {
_setSortBy([urlSortBy]);
} else {
_setSortBy([SortByOption.SortName]);
const obp = getSortByPreference(libraryId, sortByPreference);
_setSortBy([obp || SortByOption.SortName]);
}
// Apply filterBy: URL param > saved preference > default
if (urlFilterBy && Object.values(FilterByOption).includes(urlFilterBy)) {
_setFilterBy([urlFilterBy]);
} else {
const fp = getFilterByPreference(libraryId, filterByPreference);
_setFilterBy(fp ? [fp] : []);
}
}, [
libraryId,
@@ -83,6 +189,11 @@ const Page = () => {
sortByPreference,
_setSortOrder,
_setSortBy,
filterByPreference,
_setFilterBy,
searchParams.sortBy,
searchParams.sortOrder,
searchParams.filterBy,
]);
const setSortBy = useCallback(
@@ -100,17 +211,35 @@ const Page = () => {
(sortOrder: SortOrderOption[]) => {
const sop = getSortOrderPreference(libraryId, sortOrderPreference);
if (sortOrder[0] !== sop) {
setOderByPreference({
setOrderByPreference({
...sortOrderPreference,
[libraryId]: sortOrder[0],
});
}
_setSortOrder(sortOrder);
},
[libraryId, sortOrderPreference, setOderByPreference, _setSortOrder],
[libraryId, sortOrderPreference, setOrderByPreference, _setSortOrder],
);
const setFilter = useCallback(
(filterBy: FilterByOption[]) => {
const fp = getFilterByPreference(libraryId, filterByPreference);
if (filterBy[0] !== fp) {
setFilterByPreference({
...filterByPreference,
[libraryId]: filterBy[0],
});
}
_setFilterBy(filterBy);
},
[libraryId, filterByPreference, setFilterByPreference, _setFilterBy],
);
const nrOfCols = useMemo(() => {
if (Platform.isTV) {
// TV uses flexWrap, so nrOfCols is just for mobile
return 1;
}
if (screenWidth < 300) return 2;
if (screenWidth < 500) return 3;
if (screenWidth < 800) return 5;
@@ -158,6 +287,12 @@ const Page = () => {
itemType = "Series";
} else if (library.CollectionType === "boxsets") {
itemType = "BoxSet";
} else if (library.CollectionType === "homevideos") {
itemType = "Video";
} else if (library.CollectionType === "musicvideos") {
itemType = "MusicVideo";
} else if (library.CollectionType === "playlists") {
itemType = "Playlist";
}
const response = await getItemsApi(api).getItems({
@@ -168,6 +303,7 @@ const Page = () => {
sortBy: [sortBy[0], "SortName", "ProductionYear"],
sortOrder: [sortOrder[0]],
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
filters: filterBy as ItemFilter[],
// true is needed for merged versions
recursive: true,
imageTypeLimit: 1,
@@ -176,6 +312,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;
@@ -190,6 +329,7 @@ const Page = () => {
selectedTags,
sortBy,
sortOrder,
filterBy,
],
);
@@ -203,6 +343,7 @@ const Page = () => {
selectedTags,
sortBy,
sortOrder,
filterBy,
],
queryFn: fetchItems,
getNextPageParam: (lastPage, pages) => {
@@ -264,11 +405,93 @@ const Page = () => {
</View>
</TouchableItemRouter>
),
[orientation],
[orientation, nrOfCols],
);
const renderTVItem = useCallback(
(item: BaseItemDto) => {
const handlePress = () => {
if (item.Type === "Playlist") {
router.push({
pathname: "/(auth)/(tabs)/(libraries)/[libraryId]",
params: { libraryId: item.Id! },
});
return;
}
const navTarget = getItemNavigation(item, "(libraries)");
router.push(navTarget as any);
};
// Special rendering for Playlist items (square thumbnails)
if (item.Type === "Playlist") {
const playlistImageUrl = getPrimaryImageUrl({
api,
item,
width: TV_PLAYLIST_SQUARE_SIZE * 2,
});
return (
<View
key={item.Id}
style={{
width: TV_PLAYLIST_SQUARE_SIZE,
alignItems: "center",
}}
>
<TVFocusablePoster
onPress={handlePress}
onLongPress={() => showItemActions(item)}
>
<View
style={{
width: TV_PLAYLIST_SQUARE_SIZE,
aspectRatio: 1,
borderRadius: 16,
overflow: "hidden",
backgroundColor: "#1a1a1a",
}}
>
<Image
source={playlistImageUrl ? { uri: playlistImageUrl } : null}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
cachePolicy='memory-disk'
/>
</View>
</TVFocusablePoster>
<View style={{ marginTop: 12, alignItems: "center" }}>
<Text
numberOfLines={1}
style={{
fontSize: typography.callout,
color: "#FFFFFF",
textAlign: "center",
}}
>
{item.Name}
</Text>
</View>
</View>
);
}
return (
<TVPosterCard
key={item.Id}
item={item}
orientation='vertical'
onPress={handlePress}
onLongPress={() => showItemActions(item)}
width={posterSizes.poster}
/>
);
},
[router, showItemActions, api, typography],
);
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
const generalFilters = useFilterOptions();
const settings = useSettings();
const ListHeaderComponent = useCallback(
() => (
<FlatList
@@ -404,6 +627,26 @@ const Page = () => {
/>
),
},
{
key: "filterOptions",
component: (
<FilterButton
className='mr-1'
id={libraryId}
queryKey='filters'
queryFn={async () => generalFilters.map((s) => s.key)}
set={setFilter}
values={filterBy}
title={t("library.filters.filter_by")}
renderItemLabel={(item) =>
generalFilters.find((i) => i.key === item)?.value || ""
}
searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase())
}
/>
),
},
]}
renderItem={({ item }) => item.component}
keyExtractor={(item) => item.key}
@@ -424,9 +667,194 @@ const Page = () => {
sortOrder,
setSortOrder,
isFetching,
filterBy,
setFilter,
settings,
],
);
// TV Filter bar header
const hasActiveFilters =
selectedGenres.length > 0 ||
selectedYears.length > 0 ||
selectedTags.length > 0 ||
filterBy.length > 0;
const resetAllFilters = useCallback(() => {
setSelectedGenres([]);
setSelectedYears([]);
setSelectedTags([]);
_setFilterBy([]);
}, [setSelectedGenres, setSelectedYears, setSelectedTags, _setFilterBy]);
// TV Filter options - with "All" option for clearable filters
const tvGenreFilterOptions = useMemo(
(): TVOptionItem<string>[] => [
{
label: t("library.filters.all"),
value: "__all__",
selected: selectedGenres.length === 0,
},
...(tvGenreOptions || []).map((genre) => ({
label: genre,
value: genre,
selected: selectedGenres.includes(genre),
})),
],
[tvGenreOptions, selectedGenres, t],
);
const tvYearFilterOptions = useMemo(
(): TVOptionItem<string>[] => [
{
label: t("library.filters.all"),
value: "__all__",
selected: selectedYears.length === 0,
},
...(tvYearOptions || []).map((year) => ({
label: String(year),
value: String(year),
selected: selectedYears.includes(String(year)),
})),
],
[tvYearOptions, selectedYears, t],
);
const tvTagFilterOptions = useMemo(
(): TVOptionItem<string>[] => [
{
label: t("library.filters.all"),
value: "__all__",
selected: selectedTags.length === 0,
},
...(tvTagOptions || []).map((tag) => ({
label: tag,
value: tag,
selected: selectedTags.includes(tag),
})),
],
[tvTagOptions, selectedTags, t],
);
const tvSortByOptions = useMemo(
(): TVOptionItem<SortByOption>[] =>
sortOptions.map((option) => ({
label: option.value,
value: option.key,
selected: sortBy[0] === option.key,
})),
[sortBy],
);
const tvSortOrderOptions = useMemo(
(): TVOptionItem<SortOrderOption>[] =>
sortOrderOptions.map((option) => ({
label: option.value,
value: option.key,
selected: sortOrder[0] === option.key,
})),
[sortOrder],
);
const tvFilterByOptions = useMemo(
(): TVOptionItem<string>[] => [
{
label: t("library.filters.all"),
value: "__all__",
selected: filterBy.length === 0,
},
...generalFilters.map((option) => ({
label: option.value,
value: option.key,
selected: filterBy.includes(option.key),
})),
],
[filterBy, generalFilters, t],
);
// TV Filter handlers using navigation-based modal
const handleShowGenreFilter = useCallback(() => {
showOptions({
title: t("library.filters.genres"),
options: tvGenreFilterOptions,
onSelect: (value: string) => {
if (value === "__all__") {
setSelectedGenres([]);
} else if (selectedGenres.includes(value)) {
setSelectedGenres(selectedGenres.filter((g) => g !== value));
} else {
setSelectedGenres([...selectedGenres, value]);
}
},
});
}, [showOptions, t, tvGenreFilterOptions, selectedGenres, setSelectedGenres]);
const handleShowYearFilter = useCallback(() => {
showOptions({
title: t("library.filters.years"),
options: tvYearFilterOptions,
onSelect: (value: string) => {
if (value === "__all__") {
setSelectedYears([]);
} else if (selectedYears.includes(value)) {
setSelectedYears(selectedYears.filter((y) => y !== value));
} else {
setSelectedYears([...selectedYears, value]);
}
},
});
}, [showOptions, t, tvYearFilterOptions, selectedYears, setSelectedYears]);
const handleShowTagFilter = useCallback(() => {
showOptions({
title: t("library.filters.tags"),
options: tvTagFilterOptions,
onSelect: (value: string) => {
if (value === "__all__") {
setSelectedTags([]);
} else if (selectedTags.includes(value)) {
setSelectedTags(selectedTags.filter((tag) => tag !== value));
} else {
setSelectedTags([...selectedTags, value]);
}
},
});
}, [showOptions, t, tvTagFilterOptions, selectedTags, setSelectedTags]);
const handleShowSortByFilter = useCallback(() => {
showOptions({
title: t("library.filters.sort_by"),
options: tvSortByOptions,
onSelect: (value: SortByOption) => {
setSortBy([value]);
},
});
}, [showOptions, t, tvSortByOptions, setSortBy]);
const handleShowSortOrderFilter = useCallback(() => {
showOptions({
title: t("library.filters.sort_order"),
options: tvSortOrderOptions,
onSelect: (value: SortOrderOption) => {
setSortOrder([value]);
},
});
}, [showOptions, t, tvSortOrderOptions, setSortOrder]);
const handleShowFilterByFilter = useCallback(() => {
showOptions({
title: t("library.filters.filter_by"),
options: tvFilterByOptions,
onSelect: (value: string) => {
if (value === "__all__") {
_setFilterBy([]);
} else {
setFilter([value as FilterByOption]);
}
},
});
}, [showOptions, t, tvFilterByOptions, setFilter, _setFilterBy]);
const insets = useSafeAreaInsets();
if (isLoading || isLibraryLoading)
@@ -436,43 +864,176 @@ const Page = () => {
</View>
);
// Mobile return
if (!Platform.isTV) {
return (
<FlashList
key={orientation}
ListEmptyComponent={
<View className='flex flex-col items-center justify-center h-full'>
<Text className='font-bold text-xl text-neutral-500'>
{t("library.no_results")}
</Text>
</View>
}
contentInsetAdjustmentBehavior='automatic'
data={flatData}
renderItem={renderItem}
extraData={[orientation, nrOfCols]}
keyExtractor={keyExtractor}
numColumns={nrOfCols}
onEndReached={() => {
if (hasNextPage) {
fetchNextPage();
}
}}
onEndReachedThreshold={1}
ListHeaderComponent={ListHeaderComponent}
contentContainerStyle={{
paddingBottom: 24,
paddingLeft: insets.left,
paddingRight: insets.right,
}}
ItemSeparatorComponent={() => (
<View
style={{
width: 10,
height: 10,
}}
/>
)}
/>
);
}
// TV return with filter bar
return (
<FlashList
key={orientation}
ListEmptyComponent={
<View className='flex flex-col items-center justify-center h-full'>
<Text className='font-bold text-xl text-neutral-500'>
{t("library.no_results")}
</Text>
</View>
}
contentInsetAdjustmentBehavior='automatic'
data={flatData}
renderItem={renderItem}
extraData={[orientation, nrOfCols]}
keyExtractor={keyExtractor}
numColumns={nrOfCols}
onEndReached={() => {
if (hasNextPage) {
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{
paddingTop: insets.top + 100,
paddingBottom: insets.bottom + 60,
paddingHorizontal: insets.left + TV_HORIZONTAL_PADDING,
}}
onScroll={({ nativeEvent }) => {
// Load more when near bottom
const { layoutMeasurement, contentOffset, contentSize } = nativeEvent;
const isNearBottom =
layoutMeasurement.height + contentOffset.y >=
contentSize.height - 500;
if (isNearBottom && hasNextPage && !isFetching) {
fetchNextPage();
}
}}
onEndReachedThreshold={1}
ListHeaderComponent={ListHeaderComponent}
contentContainerStyle={{
paddingBottom: 24,
paddingLeft: insets.left,
paddingRight: insets.right,
}}
ItemSeparatorComponent={() => (
scrollEventThrottle={400}
>
{/* Filter bar */}
<View
style={{
flexDirection: "row",
flexWrap: "nowrap",
justifyContent: "center",
paddingBottom: 24,
gap: 12,
}}
>
{hasActiveFilters && (
<TVFilterButton
label=''
value={t("library.filters.reset")}
onPress={resetAllFilters}
hasActiveFilter
/>
)}
<TVFilterButton
label={t("library.filters.genres")}
value={
selectedGenres.length > 0
? `${selectedGenres.length} selected`
: t("library.filters.all")
}
onPress={handleShowGenreFilter}
hasTVPreferredFocus={!hasActiveFilters}
hasActiveFilter={selectedGenres.length > 0}
/>
<TVFilterButton
label={t("library.filters.years")}
value={
selectedYears.length > 0
? `${selectedYears.length} selected`
: t("library.filters.all")
}
onPress={handleShowYearFilter}
hasActiveFilter={selectedYears.length > 0}
/>
<TVFilterButton
label={t("library.filters.tags")}
value={
selectedTags.length > 0
? `${selectedTags.length} selected`
: t("library.filters.all")
}
onPress={handleShowTagFilter}
hasActiveFilter={selectedTags.length > 0}
/>
<TVFilterButton
label={t("library.filters.sort_by")}
value={sortOptions.find((o) => o.key === sortBy[0])?.value || ""}
onPress={handleShowSortByFilter}
/>
<TVFilterButton
label={t("library.filters.sort_order")}
value={
sortOrderOptions.find((o) => o.key === sortOrder[0])?.value || ""
}
onPress={handleShowSortOrderFilter}
/>
<TVFilterButton
label={t("library.filters.filter_by")}
value={
filterBy.length > 0
? generalFilters.find((o) => o.key === filterBy[0])?.value || ""
: t("library.filters.all")
}
onPress={handleShowFilterByFilter}
hasActiveFilter={filterBy.length > 0}
/>
</View>
{/* Grid with flexWrap */}
{flatData.length === 0 ? (
<View
style={{
width: 10,
height: 10,
flex: 1,
justifyContent: "center",
alignItems: "center",
paddingTop: 100,
}}
/>
>
<Text style={{ fontSize: typography.body, color: "#737373" }}>
{t("library.no_results")}
</Text>
</View>
) : (
<View
style={{
flexDirection: "row",
flexWrap: "wrap",
justifyContent: "center",
gap: TV_ITEM_GAP,
}}
>
{flatData.map((item) => renderTVItem(item))}
</View>
)}
/>
{/* Loading indicator */}
{isFetching && (
<View style={{ paddingVertical: 20 }}>
<Loader />
</View>
)}
</ScrollView>
);
};

View File

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

View File

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

View File

@@ -0,0 +1,85 @@
import { Stack, useLocalSearchParams, withLayoutContext } from "expo-router";
import {
createMaterialTopTabNavigator,
MaterialTopTabNavigationEventMap,
MaterialTopTabNavigationOptions,
} from "expo-router/js-top-tabs";
import type {
ParamListBase,
TabNavigationState,
} from "expo-router/react-navigation";
import { useTranslation } from "react-i18next";
const { Navigator } = createMaterialTopTabNavigator();
const TAB_LABEL_FONT_SIZE = 13;
const TAB_ITEM_HORIZONTAL_PADDING = 12;
export const Tab = withLayoutContext<
MaterialTopTabNavigationOptions,
typeof Navigator,
TabNavigationState<ParamListBase>,
MaterialTopTabNavigationEventMap
>(Navigator);
const Layout = () => {
const { libraryId } = useLocalSearchParams<{ libraryId: string }>();
const { t } = useTranslation();
return (
<>
<Stack.Screen
options={{
title: t("music.title"),
headerStyle: { backgroundColor: "black" },
headerShadowVisible: false,
}}
/>
<Tab
initialRouteName='suggestions'
keyboardDismissMode='none'
screenOptions={{
tabBarBounces: true,
tabBarLabelStyle: {
fontSize: TAB_LABEL_FONT_SIZE,
fontWeight: "600",
flexWrap: "nowrap",
},
tabBarItemStyle: {
width: "auto",
paddingHorizontal: TAB_ITEM_HORIZONTAL_PADDING,
},
tabBarStyle: { backgroundColor: "black" },
animationEnabled: true,
lazy: true,
swipeEnabled: true,
tabBarIndicatorStyle: { backgroundColor: "#9334E9" },
tabBarScrollEnabled: true,
}}
>
<Tab.Screen
name='suggestions'
initialParams={{ libraryId }}
options={{ title: t("music.tabs.suggestions") }}
/>
<Tab.Screen
name='albums'
initialParams={{ libraryId }}
options={{ title: t("music.tabs.albums") }}
/>
<Tab.Screen
name='artists'
initialParams={{ libraryId }}
options={{ title: t("music.tabs.artists") }}
/>
<Tab.Screen
name='playlists'
initialParams={{ libraryId }}
options={{ title: t("music.tabs.playlists") }}
/>
</Tab>
</>
);
};
export default Layout;

View File

@@ -0,0 +1,120 @@
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
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";
import { RefreshControl, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { MusicAlbumRowCard } from "@/components/music/MusicAlbumRowCard";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
const ITEMS_PER_PAGE = 40;
export default function AlbumsScreen() {
const localParams = useLocalSearchParams<{ libraryId?: string | string[] }>();
const route = useRoute<any>();
const libraryId =
(Array.isArray(localParams.libraryId)
? localParams.libraryId[0]
: localParams.libraryId) ?? route?.params?.libraryId;
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const insets = useSafeAreaInsets();
const { t } = useTranslation();
const {
data,
isLoading,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
refetch,
} = useInfiniteQuery({
queryKey: ["music-albums", libraryId, user?.Id],
queryFn: async ({ pageParam = 0 }) => {
const response = await getItemsApi(api!).getItems({
userId: user?.Id,
parentId: libraryId,
includeItemTypes: ["MusicAlbum"],
sortBy: ["SortName"],
sortOrder: ["Ascending"],
limit: ITEMS_PER_PAGE,
startIndex: pageParam,
recursive: true,
});
return {
items: response.data.Items || [],
totalCount: response.data.TotalRecordCount || 0,
startIndex: pageParam,
};
},
getNextPageParam: (lastPage) => {
const nextStart = lastPage.startIndex + ITEMS_PER_PAGE;
return nextStart < lastPage.totalCount ? nextStart : undefined;
},
initialPageParam: 0,
enabled: !!api && !!user?.Id && !!libraryId,
});
const albums = useMemo(() => {
return data?.pages.flatMap((page) => page.items) || [];
}, [data]);
const handleEndReached = useCallback(() => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
if (isLoading) {
return (
<View className='flex-1 justify-center items-center bg-black'>
<Loader />
</View>
);
}
if (albums.length === 0) {
return (
<View className='flex-1 justify-center items-center bg-black'>
<Text className='text-neutral-500'>{t("music.no_albums")}</Text>
</View>
);
}
return (
<View className='flex-1 bg-black'>
<FlashList
data={albums}
contentContainerStyle={{
paddingBottom: insets.bottom + 100,
paddingTop: 8,
paddingHorizontal: 16,
}}
refreshControl={
<RefreshControl
refreshing={false}
onRefresh={refetch}
tintColor='#9334E9'
/>
}
onEndReached={handleEndReached}
onEndReachedThreshold={0.5}
renderItem={({ item }) => <MusicAlbumRowCard album={item} />}
keyExtractor={(item) => item.Id!}
ListFooterComponent={
isFetchingNextPage ? (
<View className='py-4'>
<Loader />
</View>
) : null
}
/>
</View>
);
}

View File

@@ -0,0 +1,157 @@
import { getArtistsApi } from "@jellyfin/sdk/lib/utils/api";
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";
import { RefreshControl, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { MusicArtistCard } from "@/components/music/MusicArtistCard";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
// Web uses Limit=100
const ITEMS_PER_PAGE = 100;
export default function ArtistsScreen() {
const localParams = useLocalSearchParams<{ libraryId?: string | string[] }>();
const route = useRoute<any>();
const libraryId =
(Array.isArray(localParams.libraryId)
? localParams.libraryId[0]
: localParams.libraryId) ?? route?.params?.libraryId;
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const insets = useSafeAreaInsets();
const { t } = useTranslation();
const isReady = Boolean(api && user?.Id && libraryId);
const {
data,
isLoading,
isError,
error,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
refetch,
} = useInfiniteQuery({
queryKey: ["music-artists", libraryId, user?.Id],
queryFn: async ({ pageParam = 0 }) => {
const response = await getArtistsApi(api!).getArtists({
userId: user?.Id,
parentId: libraryId,
sortBy: ["SortName"],
sortOrder: ["Ascending"],
fields: ["PrimaryImageAspectRatio", "SortName"],
imageTypeLimit: 1,
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
limit: ITEMS_PER_PAGE,
startIndex: pageParam,
});
return {
items: response.data.Items || [],
totalCount: response.data.TotalRecordCount || 0,
startIndex: pageParam,
};
},
getNextPageParam: (lastPage) => {
const nextStart = lastPage.startIndex + ITEMS_PER_PAGE;
return nextStart < lastPage.totalCount ? nextStart : undefined;
},
initialPageParam: 0,
enabled: isReady,
});
const artists = useMemo(() => {
return data?.pages.flatMap((page) => page.items) || [];
}, [data]);
const handleEndReached = useCallback(() => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
if (!api || !user?.Id) {
return (
<View className='flex-1 justify-center items-center bg-black'>
<Loader />
</View>
);
}
if (!libraryId) {
return (
<View className='flex-1 justify-center items-center bg-black px-6'>
<Text className='text-neutral-500 text-center'>
Missing music library id.
</Text>
</View>
);
}
// Only show loading if we have no cached data to display
if (isLoading && artists.length === 0) {
return (
<View className='flex-1 justify-center items-center bg-black'>
<Loader />
</View>
);
}
// Only show error if we have no cached data to display
// This allows offline access to previously cached artists
if (isError && artists.length === 0) {
return (
<View className='flex-1 justify-center items-center bg-black px-6'>
<Text className='text-neutral-500 text-center'>
Failed to load artists: {(error as Error)?.message || "Unknown error"}
</Text>
</View>
);
}
if (artists.length === 0) {
return (
<View className='flex-1 justify-center items-center bg-black'>
<Text className='text-neutral-500'>{t("music.no_artists")}</Text>
</View>
);
}
return (
<View className='flex-1 bg-black'>
<FlashList
data={artists}
contentContainerStyle={{
paddingBottom: insets.bottom + 100,
paddingTop: 8,
paddingHorizontal: 16,
}}
refreshControl={
<RefreshControl
refreshing={false}
onRefresh={refetch}
tintColor='#9334E9'
/>
}
onEndReached={handleEndReached}
onEndReachedThreshold={0.5}
renderItem={({ item }) => <MusicArtistCard artist={item} />}
keyExtractor={(item) => item.Id!}
ListFooterComponent={
isFetchingNextPage ? (
<View className='py-4'>
<Loader />
</View>
) : null
}
/>
</View>
);
}

View File

@@ -0,0 +1,234 @@
import { Ionicons } from "@expo/vector-icons";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
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";
import { RefreshControl, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal";
import { MusicPlaylistCard } from "@/components/music/MusicPlaylistCard";
import {
type PlaylistSortOption,
type PlaylistSortOrder,
PlaylistSortSheet,
} from "@/components/music/PlaylistSortSheet";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
const ITEMS_PER_PAGE = 40;
export default function PlaylistsScreen() {
const localParams = useLocalSearchParams<{ libraryId?: string | string[] }>();
const route = useRoute<any>();
const navigation = useNavigation();
const libraryId =
(Array.isArray(localParams.libraryId)
? localParams.libraryId[0]
: localParams.libraryId) ?? route?.params?.libraryId;
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const insets = useSafeAreaInsets();
const { t } = useTranslation();
const [createModalOpen, setCreateModalOpen] = useState(false);
const [sortSheetOpen, setSortSheetOpen] = useState(false);
const [sortBy, setSortBy] = useState<PlaylistSortOption>("SortName");
const [sortOrder, setSortOrder] = useState<PlaylistSortOrder>("Ascending");
const isReady = Boolean(api && user?.Id && libraryId);
const handleSortChange = useCallback(
(newSortBy: PlaylistSortOption, newSortOrder: PlaylistSortOrder) => {
setSortBy(newSortBy);
setSortOrder(newSortOrder);
},
[],
);
useLayoutEffect(() => {
navigation.setOptions({
headerRight: () => (
<TouchableOpacity
onPress={() => setCreateModalOpen(true)}
className='mr-4'
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Ionicons name='add' size={28} color='white' />
</TouchableOpacity>
),
});
}, [navigation]);
const {
data,
isLoading,
isError,
error,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
refetch,
} = useInfiniteQuery({
queryKey: ["music-playlists", libraryId, user?.Id, sortBy, sortOrder],
queryFn: async ({ pageParam = 0 }) => {
const response = await getItemsApi(api!).getItems({
userId: user?.Id,
includeItemTypes: ["Playlist"],
sortBy: [sortBy],
sortOrder: [sortOrder],
limit: ITEMS_PER_PAGE,
startIndex: pageParam,
recursive: true,
mediaTypes: ["Audio"],
});
return {
items: response.data.Items || [],
totalCount: response.data.TotalRecordCount || 0,
startIndex: pageParam,
};
},
getNextPageParam: (lastPage) => {
const nextStart = lastPage.startIndex + ITEMS_PER_PAGE;
return nextStart < lastPage.totalCount ? nextStart : undefined;
},
initialPageParam: 0,
enabled: isReady,
});
const playlists = useMemo(() => {
return data?.pages.flatMap((page) => page.items) || [];
}, [data]);
const handleEndReached = useCallback(() => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
if (!api || !user?.Id) {
return (
<View className='flex-1 justify-center items-center bg-black'>
<Loader />
</View>
);
}
if (!libraryId) {
return (
<View className='flex-1 justify-center items-center bg-black px-6'>
<Text className='text-neutral-500 text-center'>
Missing music library id.
</Text>
</View>
);
}
// Only show loading if we have no cached data to display
if (isLoading && playlists.length === 0) {
return (
<View className='flex-1 justify-center items-center bg-black'>
<Loader />
</View>
);
}
// Only show error if we have no cached data to display
// This allows offline access to previously cached playlists
if (isError && playlists.length === 0) {
return (
<View className='flex-1 justify-center items-center bg-black px-6'>
<Text className='text-neutral-500 text-center'>
Failed to load playlists:{" "}
{(error as Error)?.message || "Unknown error"}
</Text>
</View>
);
}
if (playlists.length === 0) {
return (
<View className='flex-1 justify-center items-center bg-black'>
<Text className='text-neutral-500 mb-4'>{t("music.no_playlists")}</Text>
<TouchableOpacity
onPress={() => setCreateModalOpen(true)}
className='flex-row items-center bg-purple-600 px-6 py-3 rounded-full'
>
<Ionicons name='add' size={20} color='white' />
<Text className='text-white font-semibold ml-2'>
{t("music.playlists.create_playlist")}
</Text>
</TouchableOpacity>
<CreatePlaylistModal
open={createModalOpen}
setOpen={setCreateModalOpen}
/>
</View>
);
}
return (
<View className='flex-1 bg-black'>
<FlashList
data={playlists}
contentContainerStyle={{
paddingBottom: insets.bottom + 100,
paddingTop: 8,
paddingHorizontal: 16,
}}
refreshControl={
<RefreshControl
refreshing={false}
onRefresh={refetch}
tintColor='#9334E9'
/>
}
onEndReached={handleEndReached}
onEndReachedThreshold={0.5}
ListHeaderComponent={
<TouchableOpacity
onPress={() => setSortSheetOpen(true)}
className='flex-row items-center mb-2 py-1'
>
<Ionicons name='swap-vertical' size={18} color='#9334E9' />
<Text className='text-purple-500 text-sm ml-1.5'>
{t(
`music.sort.${sortBy === "SortName" ? "alphabetical" : "date_created"}`,
)}
</Text>
<Ionicons
name={sortOrder === "Ascending" ? "arrow-up" : "arrow-down"}
size={14}
color='#9334E9'
style={{ marginLeft: 4 }}
/>
</TouchableOpacity>
}
renderItem={({ item }) => <MusicPlaylistCard playlist={item} />}
keyExtractor={(item) => item.Id!}
ListFooterComponent={
isFetchingNextPage ? (
<View className='py-4'>
<Loader />
</View>
) : null
}
/>
<CreatePlaylistModal
open={createModalOpen}
setOpen={setCreateModalOpen}
/>
<PlaylistSortSheet
open={sortSheetOpen}
setOpen={setSortSheetOpen}
sortBy={sortBy}
sortOrder={sortOrder}
onSortChange={handleSortChange}
/>
</View>
);
}

View File

@@ -0,0 +1,333 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
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";
import { RefreshControl, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { HorizontalScroll } from "@/components/common/HorizontalScroll";
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal";
import { MusicAlbumCard } from "@/components/music/MusicAlbumCard";
import { MusicTrackItem } from "@/components/music/MusicTrackItem";
import { PlaylistPickerSheet } from "@/components/music/PlaylistPickerSheet";
import { TrackOptionsSheet } from "@/components/music/TrackOptionsSheet";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { writeDebugLog } from "@/utils/log";
export default function SuggestionsScreen() {
const localParams = useLocalSearchParams<{ libraryId?: string | string[] }>();
const route = useRoute<any>();
const libraryId =
(Array.isArray(localParams.libraryId)
? localParams.libraryId[0]
: localParams.libraryId) ?? route?.params?.libraryId;
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const insets = useSafeAreaInsets();
const { t } = useTranslation();
const [selectedTrack, setSelectedTrack] = useState<BaseItemDto | null>(null);
const [trackOptionsOpen, setTrackOptionsOpen] = useState(false);
const [playlistPickerOpen, setPlaylistPickerOpen] = useState(false);
const [createPlaylistOpen, setCreatePlaylistOpen] = useState(false);
const handleTrackOptionsPress = useCallback((track: BaseItemDto) => {
setSelectedTrack(track);
setTrackOptionsOpen(true);
}, []);
const handleAddToPlaylist = useCallback(() => {
setPlaylistPickerOpen(true);
}, []);
const handleCreateNewPlaylist = useCallback(() => {
setCreatePlaylistOpen(true);
}, []);
const isReady = Boolean(api && user?.Id && libraryId);
writeDebugLog("Music suggestions params", {
libraryId,
localParams,
routeParams: route?.params,
isReady,
});
// Latest audio - uses the same endpoint as web: /Users/{userId}/Items/Latest
// This returns the most recently added albums
const {
data: latestAlbums,
isLoading: loadingLatest,
isError: isLatestError,
error: latestError,
refetch: refetchLatest,
} = useQuery({
queryKey: ["music-latest", libraryId, user?.Id],
queryFn: async () => {
// Prefer the exact endpoint the Web client calls (HAR):
// /Users/{userId}/Items/Latest?IncludeItemTypes=Audio&ParentId=...
// IMPORTANT: must use api.get(...) (not axiosInstance.get(fullUrl)) so the auth header is attached.
const res = await api!.get<BaseItemDto[]>(
`/Users/${user!.Id}/Items/Latest`,
{
params: {
IncludeItemTypes: "Audio",
Limit: 20,
Fields: "PrimaryImageAspectRatio",
ParentId: libraryId,
ImageTypeLimit: 1,
EnableImageTypes: "Primary,Backdrop,Banner,Thumb",
EnableTotalRecordCount: false,
},
},
);
if (Array.isArray(res.data) && res.data.length > 0) {
return res.data;
}
// Fallback: ask for albums directly via /Items (more reliable across server variants)
const fallback = await getItemsApi(api!).getItems({
userId: user!.Id,
parentId: libraryId,
includeItemTypes: ["MusicAlbum"],
sortBy: ["DateCreated"],
sortOrder: ["Descending"],
limit: 20,
recursive: true,
fields: ["PrimaryImageAspectRatio", "SortName"],
imageTypeLimit: 1,
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
enableTotalRecordCount: false,
});
return fallback.data.Items || [];
},
enabled: isReady,
});
// Recently played - matches web: SortBy=DatePlayed, Filters=IsPlayed
const {
data: recentlyPlayed,
isLoading: loadingRecentlyPlayed,
isError: isRecentlyPlayedError,
error: recentlyPlayedError,
refetch: refetchRecentlyPlayed,
} = useQuery({
queryKey: ["music-recently-played", libraryId, user?.Id],
queryFn: async () => {
const response = await getItemsApi(api!).getItems({
userId: user?.Id,
parentId: libraryId,
includeItemTypes: ["Audio"],
sortBy: ["DatePlayed"],
sortOrder: ["Descending"],
limit: 10,
recursive: true,
fields: ["PrimaryImageAspectRatio", "SortName"],
filters: ["IsPlayed"],
imageTypeLimit: 1,
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
enableTotalRecordCount: false,
});
return response.data.Items || [];
},
enabled: isReady,
});
// Frequently played - matches web: SortBy=PlayCount, Filters=IsPlayed
const {
data: frequentlyPlayed,
isLoading: loadingFrequent,
isError: isFrequentError,
error: frequentError,
refetch: refetchFrequent,
} = useQuery({
queryKey: ["music-frequently-played", libraryId, user?.Id],
queryFn: async () => {
const response = await getItemsApi(api!).getItems({
userId: user?.Id,
parentId: libraryId,
includeItemTypes: ["Audio"],
sortBy: ["PlayCount"],
sortOrder: ["Descending"],
limit: 10,
recursive: true,
fields: ["PrimaryImageAspectRatio", "SortName"],
filters: ["IsPlayed"],
imageTypeLimit: 1,
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
enableTotalRecordCount: false,
});
return response.data.Items || [];
},
enabled: isReady,
});
const isLoading = loadingLatest || loadingRecentlyPlayed || loadingFrequent;
const handleRefresh = useCallback(() => {
refetchLatest();
refetchRecentlyPlayed();
refetchFrequent();
}, [refetchLatest, refetchRecentlyPlayed, refetchFrequent]);
const sections = useMemo(() => {
const result: {
title: string;
data: BaseItemDto[];
type: "albums" | "tracks";
}[] = [];
// Latest albums section
if (latestAlbums && latestAlbums.length > 0) {
result.push({
title: t("music.recently_added"),
data: latestAlbums,
type: "albums",
});
}
// Recently played tracks
if (recentlyPlayed && recentlyPlayed.length > 0) {
result.push({
title: t("music.recently_played"),
data: recentlyPlayed,
type: "tracks",
});
}
// Frequently played tracks
if (frequentlyPlayed && frequentlyPlayed.length > 0) {
result.push({
title: t("music.frequently_played"),
data: frequentlyPlayed,
type: "tracks",
});
}
return result;
}, [latestAlbums, frequentlyPlayed, recentlyPlayed, t]);
if (!api || !user?.Id) {
return (
<View className='flex-1 justify-center items-center bg-black'>
<Loader />
</View>
);
}
if (!libraryId) {
return (
<View className='flex-1 justify-center items-center bg-black px-6'>
<Text className='text-neutral-500 text-center'>
Missing music library id.
</Text>
</View>
);
}
// Only show loading if we have no cached data to display
if (isLoading && sections.length === 0) {
return (
<View className='flex-1 justify-center items-center bg-black'>
<Loader />
</View>
);
}
// Only show error if we have no cached data to display
// This allows offline access to previously cached suggestions
if (
(isLatestError || isRecentlyPlayedError || isFrequentError) &&
sections.length === 0
) {
const msg =
(latestError as Error | undefined)?.message ||
(recentlyPlayedError as Error | undefined)?.message ||
(frequentError as Error | undefined)?.message ||
"Unknown error";
return (
<View className='flex-1 justify-center items-center bg-black px-6'>
<Text className='text-neutral-500 text-center'>
Failed to load music: {msg}
</Text>
</View>
);
}
if (sections.length === 0) {
return (
<View className='flex-1 justify-center items-center bg-black'>
<Text className='text-neutral-500'>{t("music.no_suggestions")}</Text>
</View>
);
}
return (
<View className='flex-1 bg-black'>
<FlashList
data={sections}
contentContainerStyle={{
paddingBottom: insets.bottom + 100,
paddingTop: 16,
}}
refreshControl={
<RefreshControl
refreshing={false}
onRefresh={handleRefresh}
tintColor='#9334E9'
/>
}
renderItem={({ item: section }) => (
<View className='mb-6'>
<Text className='text-lg font-bold px-4 mb-3'>{section.title}</Text>
{section.type === "albums" ? (
<HorizontalScroll
data={section.data}
height={178}
keyExtractor={(item) => item.Id!}
renderItem={(item) => <MusicAlbumCard album={item} />}
/>
) : (
section.data
.slice(0, 5)
.map((track, index, _tracks) => (
<MusicTrackItem
key={track.Id}
track={track}
index={index + 1}
queue={section.data}
onOptionsPress={handleTrackOptionsPress}
/>
))
)}
</View>
)}
keyExtractor={(item) => item.title}
/>
<TrackOptionsSheet
open={trackOptionsOpen}
setOpen={setTrackOptionsOpen}
track={selectedTrack}
onAddToPlaylist={handleAddToPlaylist}
/>
<PlaylistPickerSheet
open={playlistPickerOpen}
setOpen={setPlaylistPickerOpen}
trackToAdd={selectedTrack}
onCreateNew={handleCreateNewPlaylist}
/>
<CreatePlaylistModal
open={createPlaylistOpen}
setOpen={setCreatePlaylistOpen}
initialTrackId={selectedTrack?.Id}
/>
</View>
);
}

View File

@@ -3,10 +3,13 @@ import type {
BaseItemKind,
} from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useAsyncDebouncer } from "@tanstack/react-pacer";
import { useQuery } from "@tanstack/react-query";
import axios from "axios";
import { router, useLocalSearchParams, useNavigation } from "expo-router";
import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation, useSegments } from "expo-router";
import { useAtom } from "jotai";
import { orderBy, uniqBy } from "lodash";
import {
useCallback,
useEffect,
@@ -19,11 +22,12 @@ import {
import { useTranslation } from "react-i18next";
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useDebounce } from "use-debounce";
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import {
getItemNavigation,
TouchableItemRouter,
} from "@/components/common/TouchableItemRouter";
import { ItemCardText } from "@/components/ItemCardText";
import {
JellyseerrSearchSort,
@@ -35,10 +39,21 @@ import { DiscoverFilters } from "@/components/search/DiscoverFilters";
import { LoadingSkeleton } from "@/components/search/LoadingSkeleton";
import { SearchItemWrapper } from "@/components/search/SearchItemWrapper";
import { SearchTabButtons } from "@/components/search/SearchTabButtons";
import { TVSearchPage } from "@/components/search/TVSearchPage";
import useRouter from "@/hooks/useAppRouter";
import { useJellyseerr } 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";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
import type {
MovieResult,
PersonResult,
TvResult,
} from "@/utils/jellyseerr/server/models/Search";
import { createStreamystatsApi } from "@/utils/streamystats";
type SearchType = "Library" | "Discover";
@@ -51,9 +66,13 @@ const exampleSearches = [
"The Mandalorian",
];
export default function search() {
export default function SearchPage() {
const params = useLocalSearchParams();
const insets = useSafeAreaInsets();
const router = useRouter();
const { showItemActions } = useTVItemActionModal();
const segments = useSegments();
const from = (segments as string[])[2] || "(search)";
const [user] = useAtom(userAtom);
@@ -67,7 +86,23 @@ export default function search() {
const [searchType, setSearchType] = useState<SearchType>("Library");
const [search, setSearch] = useState<string>("");
const [debouncedSearch] = useDebounce(search, 500);
const [debouncedSearch, setDebouncedSearch] = useState("");
const abortControllerRef = useRef<AbortController | null>(null);
const searchDebouncer = useAsyncDebouncer(
async (query: string) => {
// Cancel previous in-flight requests
abortControllerRef.current?.abort();
abortControllerRef.current = new AbortController();
setDebouncedSearch(query);
return query;
},
{ wait: 200 },
);
useEffect(() => {
searchDebouncer.maybeExecute(search);
}, [search]);
const [api] = useAtom(apiAtom);
@@ -97,9 +132,11 @@ export default function search() {
async ({
types,
query,
signal,
}: {
types: BaseItemKind[];
query: string;
signal?: AbortSignal;
}): Promise<BaseItemDto[]> => {
if (!api || !query) {
return [];
@@ -107,46 +144,144 @@ export default function search() {
try {
if (searchEngine === "Jellyfin") {
const searchApi = await getItemsApi(api).getItems({
const searchApi = await getItemsApi(api).getItems(
{
searchTerm: query,
limit: 10,
includeItemTypes: types,
recursive: true,
userId: user?.Id,
},
{ signal },
);
return (searchApi.data.Items as BaseItemDto[]) || [];
}
if (searchEngine === "Streamystats") {
if (!settings?.streamyStatsServerUrl || !api.accessToken) {
return [];
}
const streamyStatsApi = createStreamystatsApi({
serverUrl: settings.streamyStatsServerUrl,
jellyfinToken: api.accessToken,
});
const typeMap: Record<BaseItemKind, string> = {
Movie: "movies",
Series: "series",
Episode: "episodes",
Person: "actors",
BoxSet: "movies",
Audio: "audio",
} as Record<BaseItemKind, string>;
const searchType = types.length === 1 ? typeMap[types[0]] : "media";
const response = await streamyStatsApi.searchIds(
query,
searchType as "movies" | "series" | "episodes" | "actors" | "media",
10,
signal,
);
const allIds: string[] = [
...(response.data.movies || []),
...(response.data.series || []),
...(response.data.episodes || []),
...(response.data.actors || []),
...(response.data.audio || []),
];
if (!allIds.length) {
return [];
}
const itemsResponse = await getItemsApi(api).getItems(
{
ids: allIds,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
},
{ signal },
);
return (itemsResponse.data.Items as BaseItemDto[]) || [];
}
// Marlin search
if (!settings?.marlinServerUrl) {
return [];
}
const url = `${settings.marlinServerUrl}/search?q=${encodeURIComponent(query)}&includeItemTypes=${types
.map((type) => encodeURIComponent(type))
.join("&includeItemTypes=")}`;
const response1 = await axios.get(url, { signal });
const ids = response1.data.ids;
if (!ids?.length) {
return [];
}
const response2 = await getItemsApi(api).getItems(
{
ids,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
},
{ signal },
);
return (response2.data.Items as BaseItemDto[]) || [];
} catch (error) {
// Silently handle aborted requests
if (error instanceof Error && error.name === "AbortError") {
return [];
}
return [];
}
},
[api, searchEngine, settings, user?.Id],
);
// Separate search function for music types - always uses Jellyfin since Streamystats doesn't support music
const jellyfinSearchFn = useCallback(
async ({
types,
query,
signal,
}: {
types: BaseItemKind[];
query: string;
signal?: AbortSignal;
}): Promise<BaseItemDto[]> => {
if (!api || !query) {
return [];
}
try {
const searchApi = await getItemsApi(api).getItems(
{
searchTerm: query,
limit: 10,
includeItemTypes: types,
recursive: true,
userId: user?.Id,
});
},
{ signal },
);
return (searchApi.data.Items as BaseItemDto[]) || [];
}
if (!settings?.marlinServerUrl) {
return [];
}
const url = `${
settings.marlinServerUrl
}/search?q=${encodeURIComponent(query)}&includeItemTypes=${types
.map((type) => encodeURIComponent(type))
.join("&includeItemTypes=")}`;
const response1 = await axios.get(url);
const ids = response1.data.ids;
if (!ids || !ids.length) {
return [];
}
const response2 = await getItemsApi(api).getItems({
ids,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
});
return (response2.data.Items as BaseItemDto[]) || [];
return (searchApi.data.Items as BaseItemDto[]) || [];
} catch (error) {
console.error("Error during search:", error);
return []; // Ensure an empty array is returned in case of an error
// Silently handle aborted requests
if (error instanceof Error && error.name === "AbortError") {
return [];
}
return [];
}
},
[api, searchEngine, settings],
[api, user?.Id],
);
type HeaderSearchBarRef = {
@@ -170,6 +305,9 @@ export default function search() {
},
hideWhenScrolling: false,
autoFocus: false,
// Android: placeholder and icon color
hintTextColor: "#fff",
headerIconColor: "#fff",
},
});
}, [navigation]);
@@ -195,6 +333,7 @@ export default function search() {
searchFn({
query: debouncedSearch,
types: ["Movie"],
signal: abortControllerRef.current?.signal,
}),
enabled: searchType === "Library" && debouncedSearch.length > 0,
});
@@ -205,6 +344,7 @@ export default function search() {
searchFn({
query: debouncedSearch,
types: ["Series"],
signal: abortControllerRef.current?.signal,
}),
enabled: searchType === "Library" && debouncedSearch.length > 0,
});
@@ -215,6 +355,7 @@ export default function search() {
searchFn({
query: debouncedSearch,
types: ["Episode"],
signal: abortControllerRef.current?.signal,
}),
enabled: searchType === "Library" && debouncedSearch.length > 0,
});
@@ -225,6 +366,7 @@ export default function search() {
searchFn({
query: debouncedSearch,
types: ["BoxSet"],
signal: abortControllerRef.current?.signal,
}),
enabled: searchType === "Library" && debouncedSearch.length > 0,
});
@@ -235,6 +377,52 @@ export default function search() {
searchFn({
query: debouncedSearch,
types: ["Person"],
signal: abortControllerRef.current?.signal,
}),
enabled: searchType === "Library" && debouncedSearch.length > 0,
});
// Music search queries - always use Jellyfin since Streamystats doesn't support music
const { data: artists, isFetching: l9 } = useQuery({
queryKey: ["search", "artists", debouncedSearch],
queryFn: () =>
jellyfinSearchFn({
query: debouncedSearch,
types: ["MusicArtist"],
signal: abortControllerRef.current?.signal,
}),
enabled: searchType === "Library" && debouncedSearch.length > 0,
});
const { data: albums, isFetching: l10 } = useQuery({
queryKey: ["search", "albums", debouncedSearch],
queryFn: () =>
jellyfinSearchFn({
query: debouncedSearch,
types: ["MusicAlbum"],
signal: abortControllerRef.current?.signal,
}),
enabled: searchType === "Library" && debouncedSearch.length > 0,
});
const { data: songs, isFetching: l11 } = useQuery({
queryKey: ["search", "songs", debouncedSearch],
queryFn: () =>
jellyfinSearchFn({
query: debouncedSearch,
types: ["Audio"],
signal: abortControllerRef.current?.signal,
}),
enabled: searchType === "Library" && debouncedSearch.length > 0,
});
const { data: playlists, isFetching: l12 } = useQuery({
queryKey: ["search", "playlists", debouncedSearch],
queryFn: () =>
jellyfinSearchFn({
query: debouncedSearch,
types: ["Playlist"],
signal: abortControllerRef.current?.signal,
}),
enabled: searchType === "Library" && debouncedSearch.length > 0,
});
@@ -245,13 +433,201 @@ export default function search() {
episodes?.length ||
series?.length ||
collections?.length ||
actors?.length
actors?.length ||
artists?.length ||
albums?.length ||
songs?.length ||
playlists?.length
);
}, [episodes, movies, series, collections, actors]);
}, [
episodes,
movies,
series,
collections,
actors,
artists,
albums,
songs,
playlists,
]);
const loading = useMemo(() => {
return l1 || l2 || l3 || l7 || l8;
}, [l1, l2, l3, l7, l8]);
return l1 || l2 || l3 || l7 || l8 || l9 || l10 || l11 || l12;
}, [l1, l2, l3, l7, l8, l9, l10, l11, l12]);
// TV item press handler
const handleItemPress = useCallback(
(item: BaseItemDto) => {
const navigation = getItemNavigation(item, from);
router.push(navigation as any);
},
[from, router],
);
// Jellyseerr search for TV
const { data: jellyseerrTVResults, isFetching: jellyseerrTVLoading } =
useQuery({
queryKey: ["search", "jellyseerr", "tv", debouncedSearch],
queryFn: async () => {
const params = {
query: new URLSearchParams(debouncedSearch || "").toString(),
};
return await Promise.all([
jellyseerrApi?.search({ ...params, page: 1 }),
jellyseerrApi?.search({ ...params, page: 2 }),
jellyseerrApi?.search({ ...params, page: 3 }),
jellyseerrApi?.search({ ...params, page: 4 }),
]).then((all) =>
uniqBy(
all.flatMap((v) => v?.results || []),
"id",
),
);
},
enabled:
Platform.isTV &&
!!jellyseerrApi &&
searchType === "Discover" &&
debouncedSearch.length > 0,
});
// Process Jellyseerr results for TV
const jellyseerrMovieResults = useMemo(
() =>
orderBy(
jellyseerrTVResults?.filter(
(r) => r.mediaType === MediaType.MOVIE,
) as MovieResult[],
[(m) => m?.title?.toLowerCase() === debouncedSearch.toLowerCase()],
"desc",
),
[jellyseerrTVResults, debouncedSearch],
);
const jellyseerrTvResults = useMemo(
() =>
orderBy(
jellyseerrTVResults?.filter(
(r) => r.mediaType === MediaType.TV,
) as TvResult[],
[(t) => t?.name?.toLowerCase() === debouncedSearch.toLowerCase()],
"desc",
),
[jellyseerrTVResults, debouncedSearch],
);
const jellyseerrPersonResults = useMemo(
() =>
orderBy(
jellyseerrTVResults?.filter(
(r) => r.mediaType === "person",
) as PersonResult[],
[(p) => p?.name?.toLowerCase() === debouncedSearch.toLowerCase()],
"desc",
),
[jellyseerrTVResults, debouncedSearch],
);
const jellyseerrTVNoResults = useMemo(() => {
return (
!jellyseerrMovieResults?.length &&
!jellyseerrTvResults?.length &&
!jellyseerrPersonResults?.length
);
}, [jellyseerrMovieResults, jellyseerrTvResults, jellyseerrPersonResults]);
// Fetch discover settings for TV (when no search query in Discover mode)
const { data: discoverSliders } = useQuery({
queryKey: ["search", "jellyseerr", "discoverSettings", "tv"],
queryFn: async () => jellyseerrApi?.discoverSettings(),
enabled:
Platform.isTV &&
!!jellyseerrApi &&
searchType === "Discover" &&
debouncedSearch.length === 0,
});
// TV Jellyseerr press handlers
const handleJellyseerrMoviePress = useCallback(
(item: MovieResult) => {
router.push({
pathname: "/(auth)/(tabs)/(search)/jellyseerr/page",
params: {
mediaTitle: item.title,
releaseYear: String(new Date(item.releaseDate || "").getFullYear()),
canRequest: "true",
posterSrc: jellyseerrApi?.imageProxy(item.posterPath) || "",
mediaType: MediaType.MOVIE,
id: String(item.id),
backdropPath: item.backdropPath || "",
overview: item.overview || "",
},
});
},
[router, jellyseerrApi],
);
const handleJellyseerrTvPress = useCallback(
(item: TvResult) => {
router.push({
pathname: "/(auth)/(tabs)/(search)/jellyseerr/page",
params: {
mediaTitle: item.name,
releaseYear: String(new Date(item.firstAirDate || "").getFullYear()),
canRequest: "true",
posterSrc: jellyseerrApi?.imageProxy(item.posterPath) || "",
mediaType: MediaType.TV,
id: String(item.id),
backdropPath: item.backdropPath || "",
overview: item.overview || "",
},
});
},
[router, jellyseerrApi],
);
const handleJellyseerrPersonPress = useCallback(
(item: PersonResult) => {
router.push(`/(auth)/jellyseerr/person/${item.id}` as any);
},
[router],
);
// Render TV search page
if (Platform.isTV) {
return (
<TVSearchPage
search={search}
setSearch={setSearch}
debouncedSearch={debouncedSearch}
movies={movies}
series={series}
episodes={episodes}
collections={collections}
actors={actors}
artists={artists}
albums={albums}
songs={songs}
playlists={playlists}
loading={loading}
noResults={noResults}
onItemPress={handleItemPress}
onItemLongPress={showItemActions}
searchType={searchType}
setSearchType={setSearchType}
showDiscover={!!jellyseerrApi}
jellyseerrMovies={jellyseerrMovieResults}
jellyseerrTv={jellyseerrTvResults}
jellyseerrPersons={jellyseerrPersonResults}
jellyseerrLoading={jellyseerrTVLoading}
jellyseerrNoResults={jellyseerrTVNoResults}
onJellyseerrMoviePress={handleJellyseerrMoviePress}
onJellyseerrTvPress={handleJellyseerrTvPress}
onJellyseerrPersonPress={handleJellyseerrPersonPress}
discoverSliders={discoverSliders}
/>
);
}
return (
<ScrollView
@@ -260,28 +636,9 @@ export default function search() {
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
paddingBottom: 60,
}}
>
{/* <View
className='flex flex-col'
style={{
marginTop: Platform.OS === "android" ? 16 : 0,
}}
> */}
{Platform.isTV && (
<Input
placeholder={t("search.search")}
onChangeText={(text) => {
router.setParams({ q: "" });
setSearch(text);
}}
keyboardType='default'
returnKeyType='done'
autoCapitalize='none'
clearButtonMode='while-editing'
maxLength={500}
/>
)}
<View
className='flex flex-col'
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
@@ -398,6 +755,172 @@ export default function search() {
</TouchableItemRouter>
)}
/>
{/* Music search results */}
<SearchItemWrapper
items={artists}
header={t("search.artists")}
renderItem={(item: BaseItemDto) => {
const imageUrl = getPrimaryImageUrl({ api, item });
return (
<TouchableItemRouter
item={item}
key={item.Id}
className='flex flex-col w-24 mr-2 items-center'
>
<View
style={{
width: 80,
height: 80,
borderRadius: 40,
overflow: "hidden",
backgroundColor: "#1a1a1a",
}}
>
{imageUrl ? (
<Image
source={{ uri: imageUrl }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
/>
) : (
<View className='flex-1 items-center justify-center bg-neutral-800'>
<Text className='text-xl'>👤</Text>
</View>
)}
</View>
<Text numberOfLines={2} className='mt-2 text-center'>
{item.Name}
</Text>
</TouchableItemRouter>
);
}}
/>
<SearchItemWrapper
items={albums}
header={t("search.albums")}
renderItem={(item: BaseItemDto) => {
const imageUrl = getPrimaryImageUrl({ api, item });
return (
<TouchableItemRouter
item={item}
key={item.Id}
className='flex flex-col w-28 mr-2'
>
<View
style={{
width: 112,
height: 112,
borderRadius: 8,
overflow: "hidden",
backgroundColor: "#1a1a1a",
}}
>
{imageUrl ? (
<Image
source={{ uri: imageUrl }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
/>
) : (
<View className='flex-1 items-center justify-center bg-neutral-800'>
<Text className='text-4xl'>🎵</Text>
</View>
)}
</View>
<Text numberOfLines={2} className='mt-2'>
{item.Name}
</Text>
<Text className='opacity-50 text-xs' numberOfLines={1}>
{item.AlbumArtist || item.Artists?.join(", ")}
</Text>
</TouchableItemRouter>
);
}}
/>
<SearchItemWrapper
items={songs}
header={t("search.songs")}
renderItem={(item: BaseItemDto) => {
const imageUrl = getPrimaryImageUrl({ api, item });
return (
<TouchableItemRouter
item={item}
key={item.Id}
className='flex flex-col w-28 mr-2'
>
<View
style={{
width: 112,
height: 112,
borderRadius: 8,
overflow: "hidden",
backgroundColor: "#1a1a1a",
}}
>
{imageUrl ? (
<Image
source={{ uri: imageUrl }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
/>
) : (
<View className='flex-1 items-center justify-center bg-neutral-800'>
<Text className='text-4xl'>🎵</Text>
</View>
)}
</View>
<Text numberOfLines={2} className='mt-2'>
{item.Name}
</Text>
<Text className='opacity-50 text-xs' numberOfLines={1}>
{item.Artists?.join(", ") || item.AlbumArtist}
</Text>
</TouchableItemRouter>
);
}}
/>
<SearchItemWrapper
items={playlists}
header={t("search.playlists")}
renderItem={(item: BaseItemDto) => {
const imageUrl = getPrimaryImageUrl({ api, item });
return (
<TouchableItemRouter
item={item}
key={item.Id}
className='flex flex-col w-28 mr-2'
>
<View
style={{
width: 112,
height: 112,
borderRadius: 8,
overflow: "hidden",
backgroundColor: "#1a1a1a",
}}
>
{imageUrl ? (
<Image
source={{ uri: imageUrl }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
/>
) : (
<View className='flex-1 items-center justify-center bg-neutral-800'>
<Text className='text-4xl'>🎶</Text>
</View>
)}
</View>
<Text numberOfLines={2} className='mt-2'>
{item.Name}
</Text>
<Text className='opacity-50 text-xs'>
{item.ChildCount} tracks
</Text>
</TouchableItemRouter>
);
}}
/>
</View>
) : (
<JellyserrIndexPage
@@ -418,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={() => {

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