Compare commits

..

116 Commits

Author SHA1 Message Date
Gauvain
743a8a0ce5 Merge branch 'develop' into fix/ui-and-bugs
Resolve TechnicalInfoOverlay buffer line: keep the i18n buffer_seconds key and
append the mpv demuxer-cache caps from develop (#1765).
Fix TV tab _layout index-segment comparison after develop's route-name union change.
2026-06-29 01:57:41 +02:00
lance chant
cc0007926d fix: fixing some performance issues and mpv upgrade (#1765)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (i18n:check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
🐛 Update Issue Form Versions / 🔢 Populate version dropdown (push) Has been cancelled
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Gauvain <contact@uruk.dev>
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-06-28 17:35:00 +02:00
Gauvain
1e7f5a4ef8 fix(library): host filters in a single shared sheet to stop stacked sheets
Each FilterButton owned its own bottom-sheet modal, so tapping two
filter buttons quickly could present two sheets at once. Add a
FilterSheetProvider that hosts one shared sheet per screen; buttons just
swap its content via openFilter(). A first-wins guard in the provider
(one source of truth, reset on the single close path) ignores a second
tap while a sheet is opening. The generic FilterSheet is untouched, so
the bitrate/source/track selectors and provider-less screens (logs,
discover) keep their standalone sheet. Also drop the header deps that
remounted the whole filter bar on every fetch.
2026-06-28 01:24:05 +02:00
Gauvain
8a781f2462 feat(appearance): episode images for Next Up & Continue Watching
- Fix black episode thumbnails in the Next Up / Continue Watching rows:
  build the parent Thumb URL from the matched ParentThumbItemId +
  ParentThumbImageTag pair, instead of pairing ParentBackdropItemId with
  the thumb tag (different parent -> 404 -> black). Fixed on mobile
  (ContinueWatchingPoster) and TV (TVPosterCard, TVHeroCarousel).
- Add a "Use episode images for Next Up & Continue Watching" setting
  (default off = series image, matching Jellyfin), wired into the home
  rows on mobile and TV.
- Add helper descriptions under the Appearance settings rows.
2026-06-28 01:24:04 +02:00
Gauvain
4a0dc44251 fix(home): defer first-launch intro until the save-account sheet is dismissed
On first login with "save account" checked, the intro sheet's 1s timer
fired while the post-login save-account bottom sheet was still up and
stole its presentation (both are gorhom modals), closing it. Gate the
intro on pendingAccountSaveAtom so it only schedules once no account
save is pending: before the sheet opens, or after it is dismissed.
2026-06-28 01:24:03 +02:00
Gauvain
a3dc08c323 Merge branch 'develop' into fix/ui-and-bugs 2026-06-27 23:21:50 +02:00
renovate[bot]
9e29305e28 chore(deps): Update dependency lint-staged to v17.0.8 (#1771)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (i18n:check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
2026-06-25 20:27:25 +02:00
renovate[bot]
ae9c05637b chore(deps): Update actions/cache action to v6 (#1770)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (i18n:check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
Co-authored-by: Gauvain <contact@uruk.dev>
2026-06-25 13:30:54 +02:00
renovate[bot]
f820bedf6e chore(deps): Update CI dependencies (minor) (#1768) 2026-06-25 11:41:42 +02:00
bbijsterbosch
47c5d61f28 fix: Roadmap link in README (#1524)
Co-authored-by: Gauvain <contact@uruk.dev>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-06-25 10:45:50 +02:00
lance chant
517bc7bbb5 feat: android tv menu (#1709)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-06-25 09:08:12 +02:00
Gauvain
bd5b95c835 fix(library): per-library filter memory + reset-to-top, reset on app close
Genre/year/tag filters lived in global atoms with no per-library memory and
no reset on library switch, so a selection in one library (e.g. Anime) bled
into another (e.g. Shows). Sort/order/filterBy were already per-library but
persisted across app restarts.

Move all per-library filter state to in-memory preferences — genres/years/tags
get their own, and sort/order/filterBy stop using atomWithStorage: each library
remembers its own filters for the session and everything resets when the app is
fully closed. The library mount effect restores the active library's selection,
the shared useFilterReset clears it, and collections open with a clean slate.

Also fix the grid not returning to the top on a filter/sort change or reset:
it now pins to the top instantly and re-pins once the filtered fetch settles
(FlashList was restoring the previous offset as the new content grew);
pagination is left untouched.
2026-06-25 00:32:59 +02:00
Gauvain
8a37ec46bf fix(library): reset (X) also clears sort & order, via a shared hook
The mobile ResetFiltersButton and the TV filter header each had their own
active-state and reset logic, and both ignored sort & order — so the reset
chip never appeared for a changed sort and reset never restored it.

Centralise both in useFilterReset(libraryId): the chip now reflects a
non-default sort, and reset restores SortName/Ascending and clears the
per-library persisted sort/order/filter preferences so the reset sticks.
2026-06-24 23:14:55 +02:00
Gauvain
b70f644fbc fix(logs): scroll long entries on Android, full-row expand & copy to clipboard
- the code-block ScrollView is nested in the page ScrollView, so Android
  never scrolled it (the outer view captured the gesture); add
  nestedScrollEnabled so the debug payload scrolls
- move the "more info" hint inside the toggle so tapping anywhere on the
  collapsed row expands it
- make the JSON payload selectable
- add a Copy button (expo-clipboard, guarded for builds without the native
  module, mobile only) copying level + timestamp + message + data
2026-06-24 23:14:47 +02:00
Gauvain
d5ef69b612 fix(settings): apply the plugin (admin) default when a setting is unlocked
A setting that was always locked then unlocked showed the app's hardcoded
default instead of the plugin's configured default: the unlocked branch
treated the persisted app default as a meaningful user value, so the plugin
value was effectively dead code for any key with a non-empty app default.

Keep the user's value only when it diverges from the app default; otherwise
the plugin (admin) default wins over the hardcoded app default. Shared via
useSettings, so mobile and TV are both fixed.
2026-06-24 22:53:48 +02:00
Gauvain
2ac664de5a fix(settings): tidy spacing, uniform Android row heights & toggle layout
- spacing: top padding + inter-group gaps so section titles, descriptions
  and buttons no longer touch (playback buffer, audio/subtitles, music,
  marlin, kefin, streamystats, storage, hide-libraries)
- ListGroup: skip empty section titles
- SettingSwitch: cap the native Android switch in a fixed centered box so
  toggle rows match the other rows and stay put when toggled; iOS unchanged
- Appearance: move "Hide libraries" to the end of the list
2026-06-24 22:22:49 +02:00
Alex
91ea791663 Merge branch 'develop' into fix/ui-and-bugs 2026-06-23 18:24:32 +10:00
Niyazaki
b256e99fc8 fix(search): set typed text color on Android search bar (#1756)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (i18n:check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
2026-06-23 09:11:38 +02:00
Gauvain
e660b98871 fix(mpv): force software decoding on Android emulator (#1752)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (i18n:check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
🐛 Update Issue Form Versions / 🔢 Populate version dropdown (push) Has been cancelled
2026-06-19 00:12:40 +02:00
renovate[bot]
ce66f0256e chore(deps): Update dependency @shopify/flash-list to v2.0.3 (#1629) 2026-06-18 21:53:02 +02:00
renovate[bot]
2ec6594462 chore(deps): Update CI dependencies to v24.17.0 (#1745) 2026-06-18 21:19:10 +02:00
renovate[bot]
18f01fa4ab chore(deps): Update actions/checkout action to v7 (#1746)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (i18n:check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
2026-06-18 20:46:54 +02:00
Thomas Frøysa
df56d62acd fix(android): hold wake and wifi locks during downloads (#1546)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (i18n:check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
Co-authored-by: Simon Eklundh <github@oakgrove.site>
Co-authored-by: Gauvain <contact@uruk.dev>
2026-06-17 13:26:40 +02:00
lance chant
872d14786e fix: apple top shelf currently cropping images (#1726)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (i18n:check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
Co-authored-by: Gauvain <contact@uruk.dev>
2026-06-17 11:59:21 +02:00
github-actions[bot]
5bf07b4798 feat: New Crowdin Translations (#1699)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-06-17 11:32:38 +02:00
renovate[bot]
c3dceedad0 chore(deps): Update dependency lint-staged to v17.0.7 (#1607)
Co-authored-by: Gauvain <contact@uruk.dev>
2026-06-17 10:56:35 +02:00
Gauvain
bf3dc4a366 ci(artifact-comment): always-on dropdown, build ETA, signed/unsigned fix (#1734) 2026-06-17 09:39:43 +02:00
Gauvain
e1f7968352 fix(navigation): dismiss the keyboard when using the header back button
Leaving a screen with the keyboard up (e.g. the Jellyseerr login) left it
lingering over the previous screen. Dismiss it before navigating back.
2026-06-16 23:42:31 +02:00
Gauvain
f8e0baa0c0 refactor(kefin-tweaks): use the standard ListItem/disabledByAdmin pattern
Rebuild the KefinTweaks toggle on ListGroup + ListItem like the other
plugin settings: drop the bespoke card, the red label, the custom switch
colours, and the `t("Watchlist On")`/`t("Watchlist Off")` literal-as-key
strings. The admin lock now shows via the ListItem subtitle instead of
wrapping the whole screen in DisabledSetting.
2026-06-16 23:42:25 +02:00
Gauvain
9ed49e040e fix(jellyseerr): lock only the server URL, keep the password editable
The page greyed out the whole settings screen when the admin locked the
server URL, so the user couldn't even type their password to sign in to
the pinned server. Now only the URL field is disabled (greyed + "Disabled
by admin"); the password input stays editable.
2026-06-16 23:42:20 +02:00
Gauvain
e2acc40c49 refactor(settings): move "refresh from server" to the plugins index
The button pulls the centralised Streamyfin plugin settings for every
plugin, so it belongs on the plugins list page rather than buried inside
the Streamystats screen.
2026-06-16 23:42:12 +02:00
renovate[bot]
3e81291843 chore(deps): Update CI dependencies to v5.3.0 (#1737)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (i18n:check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
2026-06-16 23:28:08 +02:00
Gauvain
3d84b558fb fix(marlin-search): show the admin-lock notice via the ListItem subtitle
The "Disabled by admin" notice was rendered by DisabledSetting inside the
rounded, overflow-hidden card, which clipped the text. Switch to the same
pattern as the Streamystats settings: plain ListItems with the
`disabledByAdmin` prop, so the notice renders as the row subtitle and the
URL/toggle disable per-field.
2026-06-16 23:27:52 +02:00
Gauvain
8270c4c30c feat(quick-connect): add a paste button to the authorize code input
Codes often arrive over SMS/another app; add a "Paste code" button under
the PIN input that reads the clipboard (expo-clipboard, probed optionally)
and keeps the 6 digits.
2026-06-16 23:25:16 +02:00
Gauvain
4d9fc1d615 fix(library): scroll the grid back to the top when filters change
Resetting or changing a filter/sort refetches from page 1, but the
FlashList kept its previous offset, leaving the user stranded mid-list.
Hold a FlashListRef and scrollToOffset(0) whenever the active
filters/sort change.
2026-06-16 23:25:06 +02:00
Gauvain
3478f7a040 Merge branch 'develop' into fix/ui-and-bugs 2026-06-16 21:54:56 +02:00
renovate[bot]
7703a1c76f chore(deps): Update CI dependencies to v2.16.3 (#1735) 2026-06-16 21:51:59 +02:00
Gauvain
32adb5c43a fix(ui): unify the header back icon and fix its Android alignment
Use the Settings chevron-left for HeaderBackButton everywhere (was
Ionicons arrow-back) so every back button matches. On Android, replace
the `rounded-full p-2` (which pushed both the arrow and the title too far
right) with a 16px right margin, like the Settings back button.
2026-06-16 21:50:00 +02:00
Gauvain
b6da8c0784 fix(settings): align the Settings/Plugins back chevron with the edge
Drop the `pl-0.5` that pushed the back chevron slightly to the right on
the Settings and Plugins headers, matching the other back buttons.
2026-06-16 21:49:57 +02:00
Gauvain
fc53a7d760 fix(logs): keep the filter bar below the header on iOS
The logs page rendered its filter bar in a plain View with no top inset,
so under the transparent iOS header it sat behind the header and the
filter buttons weren't tappable. Use a root ScrollView with
contentInsetAdjustmentBehavior='automatic' (like the sibling settings
pages) and make the filter bar a sticky header, so iOS insets the content
below the header and the bar stays pinned while logs scroll.
2026-06-16 21:49:35 +02:00
Gauvain
3035a9019c Merge branch 'develop' into fix/ui-and-bugs 2026-06-16 21:03:12 +02:00
renovate[bot]
7983c68b9f chore(deps): Lock file maintenance (#1706) 2026-06-16 21:00:59 +02:00
Gauvain
182a2cf1e6 Merge remote-tracking branch 'origin/develop' into fix/ui-and-bugs
# Conflicts:
#	bun.lock
#	package.json
2026-06-16 20:59:00 +02:00
Gauvain
335f1efb2c chore(deps): upgrade TypeScript to 6.0.3 and align Expo SDK 56 packages (#1719) 2026-06-16 20:28:02 +02:00
Gauvain
434cb3bd39 ci: ARM Android runners, slimmer APK artifacts, Renovate-pinned tool versions (#1733)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (i18n:check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
2026-06-16 17:12:32 +02:00
Gauvain
7a6daa011d ci(build): drop free-disk-space apt-get warning + surface unsigned tvOS in artifact comment (#1732)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (i18n:check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
2026-06-15 20:32:53 +02:00
Gauvain
6195db2a83 Merge branch 'develop' into fix/ui-and-bugs 2026-06-15 01:36:00 +02:00
Gauvain
149e3b1b17 fix(hooks): correct useMemo dependency arrays (#1626)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (i18n:check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
🐛 Update Issue Form Versions / 🔢 Populate version dropdown (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
2026-06-15 01:01:38 +02:00
Gauvain
f00dad02ba ci(issue-form): auto-populate version dropdown from GitHub releases (#1641) 2026-06-15 00:24:44 +02:00
Gauvain
b7ec841118 refactor: remove dead code (#1625)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (i18n:check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
2026-06-14 18:30:50 +02:00
Gauvain
03864b2a9a feat(home): refresh Continue Watching instantly via WebSocket (#1439) 2026-06-14 17:20:27 +02:00
Gauvino
599096f883 fix(review): address second CodeRabbit pass
- streamystats: derive toggle enablement from the same effective URL the
  input renders (locked admin URL no longer disables every switch)
- FilterSheet: use the deep-equality rule for toggling that rendering
  already uses — option objects are recreated across renders
- DownloadCard: take t from useTranslation so badge labels re-render on
  language change
- fileOperations: count trickplay bytes in the storage total, matching
  the per-item size model
- PendingAccountSaveModal: warn instead of silently swallowing a failed
  account save
2026-06-12 16:23:08 +02:00
Gauvino
3247bf709c fix(review): address CodeRabbit feedback
- swap direct i18next t imports for the useTranslation hook so the four
  touched components re-render on language change
- localize the buffer seconds unit via a buffer_seconds key instead of a
  literal trailing s
- reword the useAppRouter guard comment to match its real scope
2026-06-12 15:00:49 +02:00
Gauvain
2af252d639 Merge branch 'develop' into fix/ui-and-bugs 2026-06-12 11:03:08 +02:00
Gauvain
96116e0451 feat(settings): show Actions run number for CI builds, hide store build number (#1711)
Some checks failed
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (i18n:check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
2026-06-11 11:08:07 +02:00
lance chant
938918fa06 fix: android tv issues (#1672)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Gauvain <contact@uruk.dev>
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-06-11 10:24:11 +02:00
renovate[bot]
a4b6f456f2 chore(deps): Update CI dependencies to v3.1.0 (#1715)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (i18n:check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
2026-06-11 09:10:19 +02:00
Gauvain
1636523d48 fix(login): ask how to protect a saved account after the login succeeds
The protection picker used to show before the login attempt, so a wrong
password still walked the user through choosing a PIN/password for an
account that never logged in - and a Quick Connect login could not save
the account at all.

Login flows now only flag the intent (pendingAccountSaveAtom); the
picker is a global PendingAccountSaveModal mounted at the root, shown
once the session is authorized - the login screen unmounts on success,
so it cannot host the modal itself. Works identically for the password
and Quick Connect flows; the credential is saved from the live session
token (saveCurrentAccount). Cancelling saves nothing, and a logout
before answering drops the intent.
2026-06-11 00:42:59 +02:00
Gauvain
855957707a fix(notifications): drop deprecated handler flags and payload logging
- shouldShowAlert is deprecated in expo-notifications: specify
  shouldShowBanner and shouldShowList instead (same behavior).
- The foreground listener logged the entire notification object, which
  touches the deprecated dataString getter (another deprecation warning)
  and dumps noisy payloads into the console - log only the title.
2026-06-11 00:42:59 +02:00
Gauvain
4bad8ae054 fix(filters): keep the search input responsive on large option lists
Typing in the filter-sheet search re-filtered and re-rendered up to 100
option rows per keystroke. On large lists (2000+ tags) that blocked the
JS thread long enough for the controlled TextInput to snap back to a
stale value - letters were dropped and deleted text reappeared.

Defer the search value (useDeferredValue) so the keystroke render stays
cheap and the filtering/list update runs after, and memoize the row
elements so urgent renders don't rebuild them.
2026-06-11 00:42:58 +02:00
Gauvain
16188ac2a3 fix(login): show the Quick Connect code in an auto-dismissing sheet
The code was shown in a native Alert, which has no programmatic
dismiss: after another device authorized the code and polling logged
the user in, the alert stayed open on top of the app.

Replace it with an in-app bottom sheet that closes itself once the
session is authorized. Dismissing only hides the code - polling
continues so login still completes if the code is authorized
afterwards; polling stops when leaving the login screen (parity with
TVLogin). The code can be tapped to copy (expo-clipboard, probed via
requireOptionalNativeModule so builds without the native module just
no-op).
2026-06-11 00:42:57 +02:00
Gauvain
d12d62863e fix(filters): present the filter sheet from the press handler
On the new architecture with Reanimated 4, BottomSheetModal.present()
called from a useEffect after a state update silently no-ops: the press
registered, open flipped to true, the effect called present() on a
valid ref - and nothing mounted (no onChange, nothing in the native
tree). Sheets that present() directly inside their press handler
(downloads, account picker) kept working, which is what pinned it down.

FilterSheet now takes a modalRef and the opener presents imperatively
from the gesture handler. The [open] effect only handles closing, and
never dismisses a modal that was never presented. The sheet also opens
immediately with a loader while options load, instead of the old
data-loaded press gate that left the button silently dead.

This restores genre/year/tag/sort filters in libraries and collections,
and the same pattern is applied to the bitrate/media-source/track
sheets that share FilterSheet.
2026-06-11 00:42:56 +02:00
Gauvain
7eb65ba430 fix(auth): clear the session on any 401 via a response interceptor
When the server revokes the token (device/session deleted), a 401 can
surface from any authenticated request. Nothing cleaned it up: the dead
token stayed in storage, every reload re-fired authenticated calls (401
spam, uncaught rejections) and the app lingered half-authenticated.

A response interceptor on the authenticated api clears the session once
on the first 401 so the app drops cleanly to the login screen. It only
attaches when api.accessToken is set, so a wrong-password 401 on the
login screen is never treated as session expiry. Saved credentials are
kept for quick re-login.
2026-06-11 00:42:55 +02:00
Gauvain
43d469f398 fix(auth): stop the offline splash hang and soften handled auth logs
On a cold start without network, startup awaited getCurrentUser on an
axios instance with no timeout, so the splash hung for the full OS TCP
timeout (75-120 s). Render from the cached user immediately and run the
token validation/refresh in the background; setInitialLoaded moves to a
finally so every path dismisses the splash.

Handled failures (quick-login with a revoked token, background
validation while offline) now log as warnings, and the background path
logs only status/message - axios errors carry the Authorization header.
2026-06-11 00:42:55 +02:00
Gauvain
d397233991 fix(filters): memoize useFilterOptions and drop debug logging
The hook returned a fresh array on every render (and console.logged
it). The unstable identity cascaded into list-header re-creation and,
under heavy re-rendering, tripped React maximum-update-depth.
2026-06-11 00:42:54 +02:00
Gauvain
7f020120b3 fix(settings): enforce admin-locked settings at write time
updateSettings persisted any key into user storage, including ones the
admin locked via the Streamyfin plugin. The read memo already overrides
locked keys at runtime, but the write still landed in storage and
several settings screens never disable their controls, so locked
settings appeared changeable. Strip locked keys before persisting.
2026-06-11 00:42:53 +02:00
Gauvain
aec3444829 fix(nav): drop duplicate pushes from rapid taps
Tapping an item twice before the pushed screen rendered stacked the
screen twice. A push blurs the source screen synchronously in the
navigation state, so a second tap sees an unfocused screen and is
dropped (focus-based guard, no timers).
2026-06-11 00:42:52 +02:00
Gauvain
24f9c38098 fix(downloads): key the series poster cache read on SeriesId
The cached base64 poster was read in a useMemo with empty deps, so
recycled list cells kept showing the first-rendered series poster.
2026-06-11 00:42:51 +02:00
Gauvain
1dd1940334 fix(downloads): compute storage usage from live file sizes
The storage bar showed 0.00% because calculateTotalDownloadedSize
summed the stored videoFileSize, which is 0 for items downloaded before
the size was recorded (or when fileInfo.size was undefined). Stat the
file on disk and fall back to the stored value.
2026-06-11 00:42:51 +02:00
Gauvain
1e537bc11e fix(downloads): confirm before deleting all downloaded files
The "Delete all downloaded files" row wiped everything on a single tap.
Ask for confirmation first (destructive action, cannot be undone).
2026-06-10 22:29:51 +02:00
Gauvain
b163c2abb4 fix(settings): restore plugin list order
Jellyseerr / Streamystats / Marlin Search rows were reordered by
mistake; put them back in the original order.
2026-06-10 22:29:48 +02:00
Gauvain
0d47c8d43a feat(i18n): localize hardcoded UI strings and fix misspelled keys
Move remaining hardcoded English strings (player menus, technical-info
overlay, music/now-playing, live TV, TV search badges, MPV subtitle
settings, accessibility labels, not-found screen, session picker) to
en.json, and correct misspelled keys (occured -> occurred, autorized ->
authorized, liraries -> libraries, jellyseer -> jellyseerr) along with
their usages.
2026-06-10 22:29:16 +02:00
Gauvain
0a2dadffd2 feat(settings): graduated version tracking (build, branch, commit) (#1677)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (i18n:check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
Co-authored-by: retardgerman <78982850+retardgerman@users.noreply.github.com>
2026-06-10 17:19:28 +02:00
Gauvain
6818ea380f fix(renovate): resolve maven lookups, unnest config, gate Expo SDK updates (#1708)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (i18n:check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
2026-06-10 11:22:49 +02:00
lance chant
7cf0a13317 fix: an issue with save account didn't show the modal (#1705)
Co-authored-by: Gauvain <contact@uruk.dev>
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-06-10 10:28:18 +02:00
renovate[bot]
168bf2e54e chore(deps): Update github/codeql-action action to v4.36.2 (#1687)
Some checks failed
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (i18n:check) (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
2026-06-09 23:25:26 +02:00
renovate[bot]
6f0230c2ca chore(deps): Update expo/expo-github-action digest to eab7a23 (#1685) 2026-06-09 22:37:14 +02:00
renovate[bot]
d12beee529 chore(deps): Update actions/checkout action to v6.0.3 (#1686) 2026-06-09 21:43:22 +02:00
renovate[bot]
02ffac167b chore(deps): Update dependency xcode to v26.5 (#1609)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (i18n:check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
2026-06-09 15:58:35 +02:00
renovate[bot]
4eb734c99f chore(deps): Update expo monorepo to v1.19.9 (#1606) 2026-06-09 15:05:23 +02:00
Gauvain
b7bae0072f ci(i18n): detect unused & missing translation keys + clean key debt (#1636) 2026-06-09 11:46:42 +02:00
boolemancer
1685571406 fix(downloads): Use mediaSource.Id instead of item.Id in direct download URL (#1666)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
Co-authored-by: lance chant <13349722+lancechant@users.noreply.github.com>
Co-authored-by: Gauvain <contact@uruk.dev>
2026-06-08 14:59:29 +02:00
Gauvain
36ed7539a2 ci(security): add Trivy filesystem scan to code scanning (#1644) 2026-06-08 14:05:23 +02:00
Gauvain
8f82ac481a chore: enforce LF line endings repo-wide via .gitattributes (#1643) 2026-06-08 13:33:54 +02:00
Gauvain
a242ff69fd chore(i18n): sentence-case and polish the en.json source (#1663) 2026-06-08 12:10:43 +02:00
Chris
b0c9dc114c Revise bug report template for clarity and specificity
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
Updated issue report template to specify improvements for Streamyfin and enhanced placeholder text for clarity.
2026-06-05 17:54:07 +02:00
Chris
89190b5054 Revise issue report template for clarity and accuracy
Updated placeholders and descriptions for device and server information in issue report template.
2026-06-05 17:51:10 +02:00
Chris
394262af5a Update Streamyfin version options in issue template
Add new version options for Streamyfin in issue report template.
2026-06-05 17:37:35 +02:00
Gauvain
3dbe5bb64c ci(issues): detect likely-duplicate issues on open (#1645)
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 / 🚑 Expo Doctor 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 (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-05 14:21:12 +02:00
Gauvain
801ab275ab fix(icon): downsize the icon and make them appropriate (#1660) 2026-06-05 11:29:38 +02:00
lance chant
f7033e7abb fix: player reporting when exiting and app splash load (#1662) 2026-06-05 08:14:45 +02:00
Alex
0d796d01b8 feat(mpv-ios): Fix controls not pressable after resuming from PIP (#1667)
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-04 21:44:04 +10:00
Fredrik Burmester
46d96d5965 fix(music): use white tab labels instead of default blue (#1657)
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
Co-authored-by: Gauvain <contact@uruk.dev>
2026-06-02 23:36:50 +02:00
Kylian Ronfleux--Corail
7d16e7d5c7 fix: Improve perf download database and correct crash when changing episode (#1547)
Co-authored-by: BuildTools <unconfigured@null.spigotmc.org>
Co-authored-by: Gauvain <contact@uruk.dev>
2026-06-02 22:37:16 +02:00
Alex
ceb9b5a1ae fix(downloads): poster images not showing for downloaded content (#1652)
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
Co-authored-by: Gauvain <contact@uruk.dev>
2026-06-02 19:47:14 +02:00
Gauvain
1144ff5049 fix(player): correct prev/next episode buttons at boundaries & for missing episodes (#1649) 2026-06-02 19:12:21 +02:00
Alex
4d508a4315 fix(platform-dropdown-component): Auto-size iOS trigger wrapper to pr… (#1650) 2026-06-02 18:40:47 +02:00
Alex
915a4febbb fix(player): respect safe area in chapter list and refine UX (#1653) 2026-06-02 22:31:49 +10:00
Alex
88163eb531 fix(mpv): release audio session on player exit so other apps' audio resumes (#1651)
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-02 19:20:56 +10:00
Fredrik Burmester
46bd2a784e fix(tv): keep focus on search field instead of jumping to results grid (#1637)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🚀 Release (EAS build + submit) / 🔐 Approve release (push) Has been cancelled
🚀 Release (EAS build + submit) / 🚀 🤖 Android APK (push) Has been cancelled
🚀 Release (EAS build + submit) / 🚀 📺 Android TV APK (push) Has been cancelled
🚀 Release (EAS build + submit) / 🚀 🍎 iOS (push) Has been cancelled
🚀 Release (EAS build + submit) / 🚀 📺 tvOS (push) Has been cancelled
🚀 Release (EAS build + submit) / 🚀 🤖 Android AAB (push) Has been cancelled
🚀 Release (EAS build + submit) / 📦 Draft GitHub Release (push) Has been cancelled
2026-06-01 21:50:52 +02:00
Fredrik Burmester
0a36fdfbec fix: icon alignment library header
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
2026-06-01 19:55:55 +02:00
Fredrik Burmester
45d1f752d6 fix: header left button icon alignment 2026-06-01 19:46:07 +02:00
lance chant
54ee507209 fix: fixing the time variable (#1638)
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-06-01 15:22:39 +02:00
lance chant
338fb9713b fix: qr code scanning not working ios (#1619)
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-06-01 12:38:54 +02:00
lance chant
939fd2512d fix: max episodes count (#1554)
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-06-01 12:38:34 +02:00
github-actions[bot]
32c99de874 feat: New Crowdin Translations (#1460)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀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-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
268 changed files with 12952 additions and 8887 deletions

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

29
.gitattributes vendored
View File

@@ -1 +1,28 @@
.modules/vlc-player/Frameworks/*.xcframework filter=lfs diff=lfs merge=lfs -text # Normalise line endings to LF for everyone. Files are stored as LF in git and
# checked out as LF on every OS, so Windows clones stop producing CRLF churn
# (no more "LF will be replaced by CRLF" warnings) regardless of core.autocrlf.
* text=auto eol=lf
# Windows-only scripts must stay CRLF
*.bat text eol=crlf
*.cmd text eol=crlf
# Binary assets — never touched / never normalised
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.webp binary
*.ico binary
*.icns binary
*.ttf binary
*.otf binary
*.woff binary
*.woff2 binary
*.mp3 binary
*.mp4 binary
*.mov binary
*.pdf binary
*.keystore binary
*.jks binary
*.p12 binary

View File

@@ -1,5 +1,5 @@
name: "🐛 Bug Report" name: "🐛 Bug Report"
description: Create a report to help us improve description: Create a report to help Streamyfin improve
title: "[Bug]: " title: "[Bug]: "
labels: labels:
- "🐛 bug" - "🐛 bug"
@@ -36,7 +36,7 @@ body:
attributes: attributes:
label: What happened? label: What happened?
description: A clear and concise description of what the bug is. description: A clear and concise description of what the bug is.
placeholder: Describe what happened in detail. placeholder: Describe what happened in detail, the more precise the better.
validations: validations:
required: true required: true
@@ -67,7 +67,7 @@ body:
attributes: attributes:
label: Which device and operating system are you using? label: Which device and operating system are you using?
description: Please provide your device model and OS version description: Please provide your device model and OS version
placeholder: e.g. iPhone 15 Pro, iOS 18.1.1 or Samsung Galaxy S24, Android 14 placeholder: e.g. iPhone 17 Pro / iOS 26.5.1, Samsung Galaxy S25 / Android 16, Apple TV / tvOS 26.5
validations: validations:
required: true required: true
@@ -75,11 +75,14 @@ body:
id: version id: version
attributes: attributes:
label: Streamyfin Version label: Streamyfin Version
description: What version of Streamyfin are you running? description: What version of Streamyfin are you running? On a TestFlight or development build, choose "TestFlight/Development build" and include the exact version string shown in the app's Settings.
options: options:
- 0.54.1
- 0.51.0
- 0.47.1 - 0.47.1
- 0.30.2 - 0.30.2
- older - 0.28.0
- Older
- TestFlight/Development build - TestFlight/Development build
validations: validations:
required: true required: true
@@ -90,9 +93,9 @@ body:
label: Jellyfin Server Information label: Jellyfin Server Information
description: Please provide details about your Jellyfin server description: Please provide details about your Jellyfin server
placeholder: | placeholder: |
- Jellyfin Server Version: e.g. 10.10.7 - Jellyfin Server Version: e.g. 10.11.10
- Server OS: e.g. Ubuntu 22.04, Windows 11, Docker - Server OS: e.g. Ubuntu 26.04, Windows 11, Docker, Proxmox
- Connection: e.g. Local network, Remote via domain, VPN - Connection: e.g. Local network, remote via domain, VPN
- type: textarea - type: textarea
id: screenshots id: screenshots
@@ -104,7 +107,7 @@ body:
id: logs id: logs
attributes: attributes:
label: Relevant logs (if available) label: Relevant logs (if available)
description: If you have access to app logs or crash reports, please include them here. **Remember to remove any personal information like server URLs or usernames.** description: If you have access to app logs or crash reports, please include them here. **Remember to remove any personal information like server URL, API keys or usernames.**
render: shell render: shell
- type: textarea - type: textarea

View File

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

64
.github/renovate.json vendored
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

39
.github/workflows/detect-duplicate.yml vendored Normal file
View File

@@ -0,0 +1,39 @@
name: 🔁 Detect Duplicate Issues
on:
issues:
types: [opened]
permissions:
contents: read
concurrency:
group: detect-duplicate-${{ github.event.issue.number }}
cancel-in-progress: true
jobs:
detect:
name: 🔍 Find similar issues
if: github.actor != 'github-actions[bot]'
runs-on: ubuntu-26.04
permissions:
issues: write
contents: read
steps:
- name: 📥 Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
# renovate: datasource=npm depName=bun
bun-version: "1.3.14"
- name: 🔍 Detect duplicate issues
run: bun scripts/detect-duplicate-issue.mjs
env:
GH_TOKEN: ${{ github.token }}
GITHUB_REPOSITORY: ${{ github.repository }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
ISSUE_TITLE: ${{ github.event.issue.title }}
ISSUE_BODY: ${{ github.event.issue.body }}

View File

@@ -15,7 +15,7 @@ jobs:
validate_pr_title: validate_pr_title:
name: "📝 Validate PR Title" name: "📝 Validate PR Title"
if: github.event_name == 'pull_request' if: github.event_name == 'pull_request'
runs-on: ubuntu-24.04 runs-on: ubuntu-26.04
permissions: permissions:
pull-requests: write pull-requests: write
contents: read contents: read
@@ -46,12 +46,12 @@ jobs:
dependency-review: dependency-review:
name: 🔍 Vulnerable Dependencies name: 🔍 Vulnerable Dependencies
runs-on: ubuntu-24.04 runs-on: ubuntu-26.04
permissions: permissions:
contents: read contents: read
steps: steps:
- name: Checkout Repository - name: Checkout Repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
ref: ${{ github.event.pull_request.head.sha || github.sha }} ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0 fetch-depth: 0
@@ -65,11 +65,10 @@ jobs:
expo-doctor: expo-doctor:
name: 🚑 Expo Doctor Check name: 🚑 Expo Doctor Check
if: false runs-on: ubuntu-26.04
runs-on: ubuntu-24.04
steps: steps:
- name: 🛒 Checkout repository - name: 🛒 Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
ref: ${{ github.event.pull_request.head.sha || github.sha }} ref: ${{ github.event.pull_request.head.sha || github.sha }}
submodules: recursive submodules: recursive
@@ -78,17 +77,21 @@ jobs:
- name: 🍞 Setup Bun - name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with: with:
bun-version: latest # renovate: datasource=npm depName=bun
bun-version: "1.3.14"
- name: 📦 Install dependencies (bun) - name: 📦 Install dependencies (bun)
run: bun install --frozen-lockfile run: bun install --frozen-lockfile
- name: 🚑 Run Expo Doctor - name: 🚑 Run Expo Doctor
# Re-enabled but non-blocking: surfaces doctor warnings in the logs
# without failing the gate (some checks are known-noisy for this setup).
continue-on-error: true
run: bun expo-doctor run: bun expo-doctor
code_quality: code_quality:
name: "🔍 Lint & Test (${{ matrix.command }})" name: "🔍 Lint & Test (${{ matrix.command }})"
runs-on: ubuntu-24.04 runs-on: ubuntu-26.04
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
@@ -97,10 +100,11 @@ jobs:
- "check" - "check"
- "format" - "format"
- "typecheck" - "typecheck"
- "i18n:check"
steps: steps:
- name: "📥 Checkout PR code" - name: "📥 Checkout PR code"
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
ref: ${{ github.event.pull_request.head.sha || github.sha }} ref: ${{ github.event.pull_request.head.sha || github.sha }}
submodules: recursive submodules: recursive
@@ -109,12 +113,14 @@ jobs:
- name: "🟢 Setup Node.js" - name: "🟢 Setup Node.js"
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with: with:
node-version: '24.x' # renovate: datasource=node-version depName=node versioning=node
node-version: "24.18.0"
- name: "🍞 Setup Bun" - name: "🍞 Setup Bun"
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with: with:
bun-version: latest # renovate: datasource=npm depName=bun
bun-version: "1.3.14"
- name: "📦 Install dependencies" - name: "📦 Install dependencies"
run: bun install --frozen-lockfile run: bun install --frozen-lockfile

View File

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

View File

@@ -1,9 +1,14 @@
name: 🚀 Release (EAS Build + Submit) name: 🚀 Release (EAS build + submit)
# Cloud EAS build + auto-submit for iOS, tvOS and Android on merge to main. # On merge to main (gated by the `production` GitHub Environment approval),
# A manual approval gate (the `production` GitHub Environment) pauses the run # build all targets on EAS in parallel via custom bun build configs:
# before any build/submit starts. Configure required reviewers on that # 1. iOS phone → App Store (auto-submit)
# environment in repo Settings → Environments → production. # 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: concurrency:
group: release-${{ github.ref }} group: release-${{ github.ref }}
@@ -17,16 +22,17 @@ on:
jobs: jobs:
approve: approve:
name: 🔐 Approve release name: 🔐 Approve release
runs-on: ubuntu-24.04 runs-on: ubuntu-26.04
environment: production environment: production
permissions: {}
steps: steps:
- name: ✅ Release approved - name: ✅ Release approved
run: echo "Release approved for ${{ github.sha }}" run: echo "Release approved for ${{ github.sha }}"
release: build:
name: 🚀 ${{ matrix.name }} name: 🚀 ${{ matrix.name }}
needs: approve needs: approve
runs-on: ubuntu-24.04 runs-on: ubuntu-26.04
permissions: permissions:
contents: read contents: read
strategy: strategy:
@@ -36,16 +42,29 @@ jobs:
- name: 🍎 iOS - name: 🍎 iOS
platform: ios platform: ios
profile: production profile: production
submit: true
- name: 📺 tvOS - name: 📺 tvOS
platform: ios platform: ios
profile: production_tv profile: production_tv
- name: 🤖 Android submit: true
- name: 🤖 Android AAB
platform: android platform: android
profile: production 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: steps:
- name: 📥 Checkout code - name: 📥 Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
fetch-depth: 0 fetch-depth: 0
submodules: recursive submodules: recursive
@@ -54,15 +73,16 @@ jobs:
- name: 🍞 Setup Bun - name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with: with:
bun-version: latest # renovate: datasource=npm depName=bun
bun-version: "1.3.14"
- name: 💾 Cache Bun dependencies - name: 💾 Cache Bun dependencies
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with: with:
path: ~/.bun/install/cache path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }} key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
restore-keys: | restore-keys: |
${{ runner.os }}-bun-cache ${{ runner.os }}-${{ runner.arch }}-bun-
- name: 📦 Install dependencies and reload submodules - name: 📦 Install dependencies and reload submodules
run: | run: |
@@ -70,16 +90,14 @@ jobs:
bun run submodule-reload bun run submodule-reload
- name: 🏗️ Setup EAS - name: 🏗️ Setup EAS
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
with: with:
eas-version: latest eas-version: latest
token: ${{ secrets.EXPO_TOKEN }} token: ${{ secrets.EXPO_TOKEN }}
eas-cache: true eas-cache: true
# tvOS uses local credentials (EAS can't manage tvOS provisioning # tvOS uses credentialsSource: local — restore the gitignored
# remotely, including the TopShelf extension target). Restore the # credentials.json + cert + provisioning profiles from secrets.
# gitignored credentials.json + cert + profiles from secrets so the
# cloud build can sign with `credentialsSource: local`.
- name: 🔐 Restore tvOS signing credentials - name: 🔐 Restore tvOS signing credentials
if: matrix.profile == 'production_tv' if: matrix.profile == 'production_tv'
env: env:
@@ -94,10 +112,14 @@ jobs:
echo "$TVOS_APP_PROFILE_BASE64" | base64 -d > profiles/Streamyfin_tvOS_App_Store.mobileprovision 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 echo "$TVOS_TOPSHELF_PROFILE_BASE64" | base64 -d > profiles/Streamyfin_TopShelf_tvOS_App_Store.mobileprovision
# iOS + tvOS submit upload to App Store Connect with an ASC API key. # Android Play submit needs the Google Play service account JSON.
# EAS reads it from EXPO_ASC_API_KEY_PATH / EXPO_ASC_KEY_ID / - name: 🔐 Restore Google Play service account
# EXPO_ASC_ISSUER_ID (set on the build step below). Write the .p8, if: matrix.platform == 'android' && matrix.submit
# tolerating either raw-PEM or base64-encoded secret content. 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 - name: 🔐 Restore App Store Connect API key
if: matrix.platform == 'ios' if: matrix.platform == 'ios'
env: env:
@@ -109,18 +131,11 @@ jobs:
printf '%s' "$APPLE_KEY_CONTENT" | base64 -d > "$RUNNER_TEMP/asc_api_key.p8" printf '%s' "$APPLE_KEY_CONTENT" | base64 -d > "$RUNNER_TEMP/asc_api_key.p8"
fi fi
# Android submit needs a Google Play service account JSON. eas.json's # ── Submit builds: cloud build + auto-submit to the store ──
# submit.production.android.serviceAccountKeyPath points at this file.
- name: 🔐 Restore Google Play service account
if: matrix.platform == 'android'
env:
GOOGLE_SERVICE_ACCOUNT_KEY: ${{ secrets.GOOGLE_SERVICE_ACCOUNT_KEY }}
run: printf '%s' "$GOOGLE_SERVICE_ACCOUNT_KEY" > google-service-account.json
- name: 🚀 Build & submit (${{ matrix.name }}) - name: 🚀 Build & submit (${{ matrix.name }})
if: matrix.submit
env: env:
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
# Consumed by eas submit for iOS/tvOS; ignored for Android.
EXPO_ASC_API_KEY_PATH: ${{ runner.temp }}/asc_api_key.p8 EXPO_ASC_API_KEY_PATH: ${{ runner.temp }}/asc_api_key.p8
EXPO_ASC_KEY_ID: ${{ secrets.APPLE_KEY_ID }} EXPO_ASC_KEY_ID: ${{ secrets.APPLE_KEY_ID }}
EXPO_ASC_ISSUER_ID: ${{ secrets.APPLE_KEY_ISSUER_ID }} EXPO_ASC_ISSUER_ID: ${{ secrets.APPLE_KEY_ISSUER_ID }}
@@ -129,4 +144,75 @@ jobs:
--platform ${{ matrix.platform }} \ --platform ${{ matrix.platform }} \
--profile ${{ matrix.profile }} \ --profile ${{ matrix.profile }} \
--auto-submit \ --auto-submit \
--non-interactive --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-26.04
permissions:
contents: write
actions: read # required for `gh run download` to list/fetch this run's artifacts
steps:
- name: 📥 Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
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

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

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

View File

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

14
.gitignore vendored
View File

@@ -1,6 +1,5 @@
# Dependencies and Package Managers # Dependencies and Package Managers
node_modules/ node_modules/
bun.lock
bun.lockb bun.lockb
package-lock.json package-lock.json
@@ -21,10 +20,8 @@ web-build/
# Gradle caches (top-level + per-module native projects) # Gradle caches (top-level + per-module native projects)
**/.gradle/ **/.gradle/
# Module-specific Builds # Native module build outputs (any module)
modules/mpv-player/android/build modules/*/android/build/
modules/player/android
modules/hls-downloader/android/build
# Generated Applications # Generated Applications
Streamyfin.app Streamyfin.app
@@ -69,13 +66,12 @@ certs/
# Version and Backup Files # Version and Backup Files
/version-backup-* /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 # ios:unsigned-build Artifacts
build/ build/
# but keep EAS custom build configs (the generic build/ rule above matches .eas/build/)
!.eas/build/
!.eas/build/**
.claude/ .claude/
.agents/skills/** .agents/skills/**
skills-lock.json skills-lock.json

View File

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

View File

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

View File

@@ -1,3 +1,47 @@
const { execFileSync } = require("node:child_process");
// Build metadata, injected into `extra.build` and read at runtime via
// expo-constants (see utils/version.ts). Sources in priority order:
// EAS cloud build → GitHub Actions → explicit EXPO_PUBLIC_* → local git → null.
const git = (args) => {
try {
return execFileSync("git", args, { stdio: ["ignore", "pipe", "ignore"] })
.toString()
.trim();
} catch {
return null;
}
};
const buildMeta = {
commit:
(
process.env.EAS_BUILD_GIT_COMMIT_HASH ||
process.env.GITHUB_SHA ||
process.env.EXPO_PUBLIC_GIT_COMMIT ||
git(["rev-parse", "HEAD"]) ||
""
).slice(0, 7) || null,
branch:
process.env.EAS_BUILD_GIT_BRANCH ||
process.env.GITHUB_HEAD_REF ||
process.env.GITHUB_REF_NAME ||
process.env.EXPO_PUBLIC_GIT_BRANCH ||
git(["rev-parse", "--abbrev-ref", "HEAD"]) ||
null,
profile:
process.env.EAS_BUILD_PROFILE ||
process.env.EXPO_PUBLIC_BUILD_PROFILE ||
null,
// GitHub Actions run number (#2098) — lets anyone map a sideloaded CI build back
// to its Actions run (artifacts + logs) without Expo access. Null outside CI.
runNumber:
process.env.GITHUB_RUN_NUMBER ||
process.env.EXPO_PUBLIC_GIT_RUN_NUMBER ||
null,
builtAt: new Date().toISOString(),
};
module.exports = ({ config }) => { module.exports = ({ config }) => {
if (process.env.EXPO_TV !== "1") { if (process.env.EXPO_TV !== "1") {
config.plugins.push("expo-background-task"); config.plugins.push("expo-background-task");
@@ -22,6 +66,8 @@ module.exports = ({ config }) => {
androidConfig.googleServicesFile = process.env.GOOGLE_SERVICES_JSON; androidConfig.googleServicesFile = process.env.GOOGLE_SERVICES_JSON;
} }
config.extra = { ...config.extra, build: buildMeta };
return { return {
...(Object.keys(androidConfig).length > 0 && { android: androidConfig }), ...(Object.keys(androidConfig).length > 0 && { android: androidConfig }),
...config, ...config,

View File

@@ -107,6 +107,9 @@
], ],
"expo-localization", "expo-localization",
"expo-asset", "expo-asset",
"expo-audio",
"expo-image",
"expo-sharing",
[ [
"react-native-edge-to-edge", "react-native-edge-to-edge",
{ {

View File

@@ -161,9 +161,7 @@ export default function FavoritesSeeAllScreen() {
/> />
{!itemType ? ( {!itemType ? (
<View className='flex-1 items-center justify-center px-6'> <View className='flex-1 items-center justify-center px-6'>
<Text className='text-neutral-500'> <Text className='text-neutral-500'>{t("favorites.noData")}</Text>
{t("favorites.noData", { defaultValue: "No items found." })}
</Text>
</View> </View>
) : isLoading ? ( ) : isLoading ? (
<View className='justify-center items-center h-full'> <View className='justify-center items-center h-full'>
@@ -194,7 +192,7 @@ export default function FavoritesSeeAllScreen() {
ListEmptyComponent={ ListEmptyComponent={
<View className='flex flex-col items-center justify-center h-full py-12'> <View className='flex flex-col items-center justify-center h-full py-12'>
<Text className='font-bold text-xl text-neutral-500'> <Text className='font-bold text-xl text-neutral-500'>
{t("home.no_items", { defaultValue: "No items" })} {t("home.no_items")}
</Text> </Text>
</View> </View>
} }

View File

@@ -73,7 +73,6 @@ export default function IndexLayout() {
headerLeft: () => ( headerLeft: () => (
<Pressable <Pressable
onPress={() => _router.back()} onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
> >
<Feather name='chevron-left' size={28} color='white' /> <Feather name='chevron-left' size={28} color='white' />
@@ -158,7 +157,6 @@ export default function IndexLayout() {
headerLeft: () => ( headerLeft: () => (
<Pressable <Pressable
onPress={() => _router.back()} onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
> >
<Feather name='chevron-left' size={28} color='white' /> <Feather name='chevron-left' size={28} color='white' />

View File

@@ -137,12 +137,12 @@ export default function DownloadsPage() {
deleteFileByType("Episode") deleteFileByType("Episode")
.then(() => .then(() =>
toast.success( toast.success(
t("home.downloads.toasts.deleted_all_tvseries_successfully"), t("home.downloads.toasts.deleted_all_series_successfully"),
), ),
) )
.catch((reason) => { .catch((reason) => {
writeToLog("ERROR", reason); writeToLog("ERROR", reason);
toast.error(t("home.downloads.toasts.failed_to_delete_all_tvseries")); toast.error(t("home.downloads.toasts.failed_to_delete_all_series"));
}); });
const deleteOtherMedia = () => const deleteOtherMedia = () =>
Promise.all( Promise.all(
@@ -207,7 +207,7 @@ export default function DownloadsPage() {
<View className='mb-4'> <View className='mb-4'>
<View className='flex flex-row items-center justify-between mb-2 px-4'> <View className='flex flex-row items-center justify-between mb-2 px-4'>
<Text className='text-lg font-bold'> <Text className='text-lg font-bold'>
{t("home.downloads.tvseries")} {t("home.downloads.series")}
</Text> </Text>
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'> <View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
<Text className='text-xs font-bold'> <Text className='text-xs font-bold'>
@@ -288,7 +288,7 @@ export default function DownloadsPage() {
{t("home.downloads.delete_all_movies_button")} {t("home.downloads.delete_all_movies_button")}
</Button> </Button>
<Button color='purple' onPress={deleteShows}> <Button color='purple' onPress={deleteShows}>
{t("home.downloads.delete_all_tvseries_button")} {t("home.downloads.delete_all_series_button")}
</Button> </Button>
{otherMedia.length > 0 && ( {otherMedia.length > 0 && (
<Button color='purple' onPress={deleteOtherMedia}> <Button color='purple' onPress={deleteOtherMedia}>

View File

@@ -59,17 +59,19 @@ function SettingsMobile() {
<QuickConnect className='mb-4' /> <QuickConnect className='mb-4' />
<View className='mb-4'> {Platform.OS !== "ios" && (
<ListGroup title={t("pairing.pair_with_phone_title")}> <View className='mb-4'>
<ListItem <ListGroup title={t("pairing.pair_with_phone_title")}>
onPress={() => <ListItem
router.push("/(auth)/(tabs)/(home)/companion-login") onPress={() =>
} router.push("/(auth)/(tabs)/(home)/companion-login")
title={t("pairing.pair_with_phone")} }
textColor='blue' title={t("pairing.pair_with_phone")}
/> textColor='blue'
</ListGroup> />
</View> </ListGroup>
</View>
)}
<View className='mb-4'> <View className='mb-4'>
<AppLanguageSelector /> <AppLanguageSelector />

View File

@@ -179,18 +179,15 @@ export default function SettingsTV() {
// Handle clearing all cache in the entire app // Handle clearing all cache in the entire app
const handleClearCache = async () => { const handleClearCache = async () => {
Alert.alert( Alert.alert(
t("home.settings.storage.clear_all_cache_confirm", "Clear All Cache?"), t("home.settings.storage.clear_all_cache_confirm"),
t( t("home.settings.storage.clear_all_cache_confirm_desc"),
"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"), text: t("common.cancel"),
style: "cancel", style: "cancel",
}, },
{ {
text: t("common.ok", "OK"), text: t("common.ok"),
onPress: async () => { onPress: async () => {
try { try {
// 1. Clear React Query Cache (memory & MMKV) // 1. Clear React Query Cache (memory & MMKV)
@@ -243,11 +240,8 @@ export default function SettingsTV() {
} catch (error) { } catch (error) {
console.error("Failed to clear cache:", error); console.error("Failed to clear cache:", error);
Alert.alert( Alert.alert(
t("home.settings.toasts.error_deleting_files", "Error"), t("home.settings.toasts.error_deleting_files"),
t( t("home.settings.storage.clear_all_cache_error_desc"),
"home.settings.storage.clear_all_cache_error_desc",
"An error occurred while clearing the cache.",
),
); );
} }
}, },
@@ -651,7 +645,7 @@ export default function SettingsTV() {
formatValue={(v) => `${v.toFixed(1)}x`} formatValue={(v) => `${v.toFixed(1)}x`}
/> />
<TVSettingsStepper <TVSettingsStepper
label='Vertical Margin' label={t("home.settings.subtitles.mpv_subtitle_margin_y")}
value={settings.mpvSubtitleMarginY ?? 0} value={settings.mpvSubtitleMarginY ?? 0}
onDecrease={() => { onDecrease={() => {
const newValue = Math.max( const newValue = Math.max(
@@ -669,11 +663,11 @@ export default function SettingsTV() {
}} }}
/> />
<TVSettingsOptionButton <TVSettingsOptionButton
label='Horizontal Alignment' label={t("home.settings.subtitles.mpv_subtitle_align_x")}
value={alignXLabel} value={alignXLabel}
onPress={() => onPress={() =>
showOptions({ showOptions({
title: "Horizontal Alignment", title: t("home.settings.subtitles.mpv_subtitle_align_x"),
options: alignXOptions, options: alignXOptions,
onSelect: (value) => onSelect: (value) =>
updateSettings({ updateSettings({
@@ -683,11 +677,11 @@ export default function SettingsTV() {
} }
/> />
<TVSettingsOptionButton <TVSettingsOptionButton
label='Vertical Alignment' label={t("home.settings.subtitles.mpv_subtitle_align_y")}
value={alignYLabel} value={alignYLabel}
onPress={() => onPress={() =>
showOptions({ showOptions({
title: "Vertical Alignment", title: t("home.settings.subtitles.mpv_subtitle_align_y"),
options: alignYOptions, options: alignYOptions,
onSelect: (value) => onSelect: (value) =>
updateSettings({ updateSettings({
@@ -860,6 +854,13 @@ export default function SettingsTV() {
updateSettings({ mergeNextUpAndContinueWatching: value }) updateSettings({ mergeNextUpAndContinueWatching: value })
} }
/> />
<TVSettingsToggle
label={t("home.settings.appearance.use_episode_images_next_up")}
value={settings.useEpisodeImagesForNextUp}
onToggle={(value) =>
updateSettings({ useEpisodeImagesForNextUp: value })
}
/>
<TVSettingsToggle <TVSettingsToggle
label={t("home.settings.appearance.show_home_backdrop")} label={t("home.settings.appearance.show_home_backdrop")}
value={settings.showHomeBackdrop} value={settings.showHomeBackdrop}

View File

@@ -2,8 +2,9 @@ import { getUserViewsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ScrollView, Switch, View } from "react-native"; import { ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { SettingSwitch } from "@/components/common/SettingSwitch";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader"; import { Loader } from "@/components/Loader";
import { ListGroup } from "@/components/list/ListGroup"; import { ListGroup } from "@/components/list/ListGroup";
@@ -50,12 +51,12 @@ export default function AppearanceHideLibrariesPage() {
> >
<DisabledSetting <DisabledSetting
disabled={pluginSettings?.hiddenLibraries?.locked === true} disabled={pluginSettings?.hiddenLibraries?.locked === true}
className='px-4' className='px-4 pt-4'
> >
<ListGroup title={t("home.settings.other.hide_libraries")}> <ListGroup title={t("home.settings.other.hide_libraries")}>
{data?.map((view) => ( {data?.map((view) => (
<ListItem key={view.Id} title={view.Name} onPress={() => {}}> <ListItem key={view.Id} title={view.Name} onPress={() => {}}>
<Switch <SettingSwitch
value={settings.hiddenLibraries?.includes(view.Id!) || false} value={settings.hiddenLibraries?.includes(view.Id!) || false}
onValueChange={(value) => { onValueChange={(value) => {
updateSettings({ updateSettings({
@@ -71,7 +72,7 @@ export default function AppearanceHideLibrariesPage() {
))} ))}
</ListGroup> </ListGroup>
<Text className='px-4 text-xs text-neutral-500 mt-1'> <Text className='px-4 text-xs text-neutral-500 mt-1'>
{t("home.settings.other.select_liraries_you_want_to_hide")} {t("home.settings.other.select_libraries_you_want_to_hide")}
</Text> </Text>
</DisabledSetting> </DisabledSetting>
</ScrollView> </ScrollView>

View File

@@ -2,7 +2,8 @@ import { getUserViewsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Switch, View } from "react-native"; import { View } from "react-native";
import { SettingSwitch } from "@/components/common/SettingSwitch";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader"; import { Loader } from "@/components/Loader";
import { ListGroup } from "@/components/list/ListGroup"; import { ListGroup } from "@/components/list/ListGroup";
@@ -46,7 +47,7 @@ export default function HideLibrariesPage() {
<ListGroup> <ListGroup>
{data?.map((view) => ( {data?.map((view) => (
<ListItem key={view.Id} title={view.Name} onPress={() => {}}> <ListItem key={view.Id} title={view.Name} onPress={() => {}}>
<Switch <SettingSwitch
value={settings.hiddenLibraries?.includes(view.Id!) || false} value={settings.hiddenLibraries?.includes(view.Id!) || false}
onValueChange={(value) => { onValueChange={(value) => {
updateSettings({ updateSettings({
@@ -60,7 +61,7 @@ export default function HideLibrariesPage() {
))} ))}
</ListGroup> </ListGroup>
<Text className='px-4 text-xs text-neutral-500 mt-1'> <Text className='px-4 text-xs text-neutral-500 mt-1'>
{t("home.settings.other.select_liraries_you_want_to_hide")} {t("home.settings.other.select_libraries_you_want_to_hide")}
</Text> </Text>
</DisabledSetting> </DisabledSetting>
); );

View File

@@ -1,4 +1,6 @@
import { Ionicons } from "@expo/vector-icons";
import { File, Paths } from "expo-file-system"; import { File, Paths } from "expo-file-system";
import { requireOptionalNativeModule } from "expo-modules-core";
import { useNavigation } from "expo-router"; import { useNavigation } from "expo-router";
import type * as SharingType from "expo-sharing"; import type * as SharingType from "expo-sharing";
import { useCallback, useEffect, useId, useMemo, useState } from "react"; import { useCallback, useEffect, useId, useMemo, useState } from "react";
@@ -6,6 +8,7 @@ import { useTranslation } from "react-i18next";
import { Platform, ScrollView, TouchableOpacity, View } from "react-native"; import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
import Collapsible from "react-native-collapsible"; import Collapsible from "react-native-collapsible";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { toast } from "sonner-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { FilterButton } from "@/components/filters/FilterButton"; import { FilterButton } from "@/components/filters/FilterButton";
import { Loader } from "@/components/Loader"; import { Loader } from "@/components/Loader";
@@ -72,6 +75,25 @@ export default function Page() {
} }
}, [filteredLogs, Sharing]); }, [filteredLogs, Sharing]);
const copyLog = useCallback(
async (log: NonNullable<typeof logs>[number]) => {
// Skip on builds that don't ship the expo-clipboard native module
// (probe returns null instead of throwing); same guard as Quick Connect.
if (!requireOptionalNativeModule("ExpoClipboard")) return;
const Clipboard = await import("expo-clipboard");
const text = [
`[${log.level}] ${new Date(log.timestamp).toLocaleString()}`,
log.message,
log.data ? JSON.stringify(log.data, null, 2) : null,
]
.filter(Boolean)
.join("\n");
await Clipboard.setStringAsync(text);
toast.success(t("home.settings.logs.copied"));
},
[logs, t],
);
useEffect(() => { useEffect(() => {
if (Platform.isTV) return; if (Platform.isTV) return;
@@ -88,8 +110,15 @@ export default function Page() {
}, [share, loading]); }, [share, loading]);
return ( return (
<View className='flex-1'> <ScrollView
<View className='flex flex-row justify-end py-2 px-4 space-x-2'> // Like the sibling settings pages, let iOS auto-inset the content below the
// transparent header (no manual header-height math). The filter bar is a
// sticky header so it stays pinned just under the header while logs scroll.
contentInsetAdjustmentBehavior='automatic'
stickyHeaderIndices={[0]}
contentContainerStyle={{ paddingBottom: insets.bottom }}
>
<View className='flex flex-row justify-end py-2 px-4 space-x-2 bg-black'>
<FilterButton <FilterButton
id={orderFilterId} id={orderFilterId}
queryKey='log' queryKey='log'
@@ -112,67 +141,77 @@ export default function Page() {
multiple={true} multiple={true}
/> />
</View> </View>
<ScrollView <View className='flex flex-col space-y-2 px-4'>
className='pb-4 px-4' {filteredLogs?.map((log, index) => (
contentContainerStyle={{ paddingBottom: insets.bottom }} <View className='bg-neutral-900 rounded-xl p-3' key={index}>
> <TouchableOpacity
<View className='flex flex-col space-y-2'> disabled={!log.data}
{filteredLogs?.map((log, index) => ( onPress={() =>
<View className='bg-neutral-900 rounded-xl p-3' key={index}> setState((v) => ({
<TouchableOpacity ...v,
disabled={!log.data} [log.timestamp]: !v[log.timestamp],
onPress={() => }))
setState((v) => ({ }
...v, >
[log.timestamp]: !v[log.timestamp], <View className='flex flex-row justify-between'>
})) <Text
} className={`mb-1
>
<View className='flex flex-row justify-between'>
<Text
className={`mb-1
${log.level === "INFO" && "text-blue-500"} ${log.level === "INFO" && "text-blue-500"}
${log.level === "ERROR" && "text-red-500"} ${log.level === "ERROR" && "text-red-500"}
${log.level === "DEBUG" && "text-purple-500"} ${log.level === "DEBUG" && "text-purple-500"}
`} `}
> >
{log.level} {log.level}
</Text>
<Text className='text-xs'>
{new Date(log.timestamp).toLocaleString()}
</Text>
</View>
<Text selectable className='text-xs'>
{log.message}
</Text> </Text>
</TouchableOpacity>
{log.data && ( <Text className='text-xs'>
<> {new Date(log.timestamp).toLocaleString()}
{!state[log.timestamp] && ( </Text>
<Text className='text-xs mt-0.5'> </View>
{t("home.settings.logs.click_for_more_info")} <Text className='text-xs'>{log.message}</Text>
</Text> {/* Keep the whole collapsed row tappable: the hint lives inside
)} the toggle so tapping it expands too. */}
<Collapsible collapsed={!state[log.timestamp]}> {log.data && !state[log.timestamp] && (
<View className='mt-2 flex flex-col space-y-2'> <Text className='text-xs mt-0.5'>
<ScrollView className='rounded-xl' style={codeBlockStyle}> {t("home.settings.logs.click_for_more_info")}
<Text>{JSON.stringify(log.data, null, 2)}</Text> </Text>
</ScrollView>
</View>
</Collapsible>
</>
)} )}
</View> </TouchableOpacity>
))}
{filteredLogs?.length === 0 && ( {log.data && (
<Text className='opacity-50'> <Collapsible collapsed={!state[log.timestamp]}>
{t("home.settings.logs.no_logs_available")} <View className='mt-2 flex flex-col space-y-2'>
</Text> <ScrollView
)} className='rounded-xl'
</View> style={codeBlockStyle}
</ScrollView> nestedScrollEnabled
</View> >
{/* Only the raw payload is selectable (per request); the
header/message stay tap-to-toggle. */}
<Text selectable>{JSON.stringify(log.data, null, 2)}</Text>
</ScrollView>
{!Platform.isTV && (
<TouchableOpacity
onPress={() => copyLog(log)}
className='flex flex-row items-center self-end px-2 py-1'
>
<Ionicons name='copy-outline' size={16} color='white' />
<Text className='text-xs ml-1'>
{t("home.settings.logs.copy")}
</Text>
</TouchableOpacity>
)}
</View>
</Collapsible>
)}
</View>
))}
{filteredLogs?.length === 0 && (
<Text className='opacity-50'>
{t("home.settings.logs.no_logs_available")}
</Text>
)}
</View>
</ScrollView>
); );
} }

View File

@@ -3,9 +3,9 @@ import { useQuery } from "@tanstack/react-query";
import { useCallback, useMemo } from "react"; import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Platform, ScrollView, View } from "react-native"; import { Platform, ScrollView, View } from "react-native";
import { Switch } from "react-native-gesture-handler";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { toast } from "sonner-native"; import { toast } from "sonner-native";
import { SettingSwitch } from "@/components/common/SettingSwitch";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { ListGroup } from "@/components/list/ListGroup"; import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem"; import { ListItem } from "@/components/list/ListItem";
@@ -136,7 +136,7 @@ export default function MusicSettingsPage() {
title={t("home.settings.music.prefer_downloaded")} title={t("home.settings.music.prefer_downloaded")}
disabled={pluginSettings?.preferLocalAudio?.locked} disabled={pluginSettings?.preferLocalAudio?.locked}
> >
<Switch <SettingSwitch
value={settings.preferLocalAudio} value={settings.preferLocalAudio}
disabled={pluginSettings?.preferLocalAudio?.locked} disabled={pluginSettings?.preferLocalAudio?.locked}
onValueChange={(value) => onValueChange={(value) =>
@@ -159,7 +159,7 @@ export default function MusicSettingsPage() {
title={t("home.settings.music.lookahead_enabled")} title={t("home.settings.music.lookahead_enabled")}
disabled={pluginSettings?.audioLookaheadEnabled?.locked} disabled={pluginSettings?.audioLookaheadEnabled?.locked}
> >
<Switch <SettingSwitch
value={settings.audioLookaheadEnabled} value={settings.audioLookaheadEnabled}
disabled={pluginSettings?.audioLookaheadEnabled?.locked} disabled={pluginSettings?.audioLookaheadEnabled?.locked}
onValueChange={(value) => onValueChange={(value) =>
@@ -233,7 +233,7 @@ export default function MusicSettingsPage() {
})} })}
/> />
</ListGroup> </ListGroup>
<ListGroup> <ListGroup className='mt-4'>
<ListItem <ListItem
textColor='red' textColor='red'
onPress={onDeleteDownloadedSongsClicked} onPress={onDeleteDownloadedSongsClicked}

View File

@@ -17,13 +17,14 @@ export default function PlaybackControlsPage() {
contentContainerStyle={{ contentContainerStyle={{
paddingLeft: insets.left, paddingLeft: insets.left,
paddingRight: insets.right, paddingRight: insets.right,
paddingBottom: insets.bottom,
}} }}
> >
<View <View
className='p-4 flex flex-col' className='p-4 flex flex-col'
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }} style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
> >
<View className='mb-4'> <View>
<MediaProvider> <MediaProvider>
<MediaToggles className='mb-4' /> <MediaToggles className='mb-4' />
<GestureControls className='mb-4' /> <GestureControls className='mb-4' />

View File

@@ -1,11 +1,8 @@
import { ScrollView } from "react-native"; import { ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import DisabledSetting from "@/components/settings/DisabledSetting";
import { JellyseerrSettings } from "@/components/settings/Jellyseerr"; import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
import { useSettings } from "@/utils/atoms/settings";
export default function JellyseerrPluginPage() { export default function JellyseerrPluginPage() {
const { pluginSettings } = useSettings();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
return ( return (
@@ -16,12 +13,9 @@ export default function JellyseerrPluginPage() {
paddingRight: insets.right, paddingRight: insets.right,
}} }}
> >
<DisabledSetting <View className='p-4'>
disabled={pluginSettings?.jellyseerrServerUrl?.locked === true}
className='p-4'
>
<JellyseerrSettings /> <JellyseerrSettings />
</DisabledSetting> </View>
</ScrollView> </ScrollView>
); );
} }

View File

@@ -1,11 +1,8 @@
import { ScrollView } from "react-native"; import { ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import DisabledSetting from "@/components/settings/DisabledSetting";
import { KefinTweaksSettings } from "@/components/settings/KefinTweaks"; import { KefinTweaksSettings } from "@/components/settings/KefinTweaks";
import { useSettings } from "@/utils/atoms/settings";
export default function KefinTweaksPage() { export default function KefinTweaksPage() {
const { pluginSettings } = useSettings();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
return ( return (
@@ -16,12 +13,9 @@ export default function KefinTweaksPage() {
paddingRight: insets.right, paddingRight: insets.right,
}} }}
> >
<DisabledSetting <View className='px-4 pt-4'>
disabled={pluginSettings?.useKefinTweaks?.locked === true}
className='p-4'
>
<KefinTweaksSettings /> <KefinTweaksSettings />
</DisabledSetting> </View>
</ScrollView> </ScrollView>
); );
} }

View File

@@ -1,35 +1,35 @@
import { useNavigation } from "expo-router"; import { useNavigation } from "expo-router";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
Linking, Linking,
ScrollView, ScrollView,
Switch,
TextInput, TextInput,
TouchableOpacity, TouchableOpacity,
View, View,
} from "react-native"; } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { toast } from "sonner-native"; import { toast } from "sonner-native";
import { SettingSwitch } from "@/components/common/SettingSwitch";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { ListGroup } from "@/components/list/ListGroup"; import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem"; import { ListItem } from "@/components/list/ListItem";
import DisabledSetting from "@/components/settings/DisabledSetting";
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient"; import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
export default function MarlinSearchPage() { export default function MarlinSearchPage() {
const navigation = useNavigation(); const navigation = useNavigation();
const { t } = useTranslation(); const { t } = useTranslation();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { settings, updateSettings, pluginSettings } = useSettings(); const { settings, updateSettings, pluginSettings } = useSettings();
const queryClient = useNetworkAwareQueryClient(); const queryClient = useNetworkAwareQueryClient();
const [value, setValue] = useState<string>(settings?.marlinServerUrl || ""); const [value, setValue] = useState<string>(settings?.marlinServerUrl || "");
const searchEngineLocked = pluginSettings?.searchEngine?.locked === true;
const marlinUrlLocked = pluginSettings?.marlinServerUrl?.locked === true;
const hasStreamystats = !!pluginSettings?.streamyStatsServerUrl?.value;
const onSave = (val: string) => { const onSave = (val: string) => {
updateSettings({ updateSettings({
marlinServerUrl: !val.endsWith("/") ? val : val.slice(0, -1), marlinServerUrl: !val.endsWith("/") ? val : val.slice(0, -1),
@@ -41,15 +41,8 @@ export default function MarlinSearchPage() {
Linking.openURL("https://github.com/fredrikburmester/marlin-search"); Linking.openURL("https://github.com/fredrikburmester/marlin-search");
}; };
const disabled = useMemo(() => {
return (
pluginSettings?.searchEngine?.locked === true &&
pluginSettings?.marlinServerUrl?.locked === true
);
}, [pluginSettings]);
useEffect(() => { useEffect(() => {
if (!pluginSettings?.marlinServerUrl?.locked) { if (!marlinUrlLocked) {
navigation.setOptions({ navigation.setOptions({
headerRight: () => ( headerRight: () => (
<TouchableOpacity onPress={() => onSave(value)} className='px-2'> <TouchableOpacity onPress={() => onSave(value)} className='px-2'>
@@ -60,7 +53,7 @@ export default function MarlinSearchPage() {
), ),
}); });
} }
}, [navigation, value, pluginSettings?.marlinServerUrl?.locked, t]); }, [navigation, value, marlinUrlLocked, t]);
if (!settings) return null; if (!settings) return null;
@@ -72,52 +65,39 @@ export default function MarlinSearchPage() {
paddingRight: insets.right, paddingRight: insets.right,
}} }}
> >
<DisabledSetting disabled={disabled} className='px-4'> <View className='px-4 pt-4'>
<ListGroup> <ListGroup>
<DisabledSetting {/* disabledByAdmin renders the "Disabled by admin" notice as the row's
disabled={ subtitle (same pattern as the Streamystats settings) — no clipping. */}
pluginSettings?.searchEngine?.locked === true || <ListItem
!!pluginSettings?.streamyStatsServerUrl?.value title={t(
} "home.settings.plugins.marlin_search.enable_marlin_search",
showText={!pluginSettings?.marlinServerUrl?.locked} )}
disabledByAdmin={searchEngineLocked}
onPress={() => {
updateSettings({ searchEngine: "Jellyfin" });
queryClient.invalidateQueries({ queryKey: ["search"] });
}}
> >
<ListItem <SettingSwitch
title={t( value={settings.searchEngine === "Marlin"}
"home.settings.plugins.marlin_search.enable_marlin_search", disabled={searchEngineLocked || hasStreamystats}
)} onValueChange={(val) => {
onPress={() => { updateSettings({ searchEngine: val ? "Marlin" : "Jellyfin" });
updateSettings({ searchEngine: "Jellyfin" });
queryClient.invalidateQueries({ queryKey: ["search"] }); queryClient.invalidateQueries({ queryKey: ["search"] });
}} }}
> />
<Switch </ListItem>
value={settings.searchEngine === "Marlin"}
disabled={!!pluginSettings?.streamyStatsServerUrl?.value}
onValueChange={(value) => {
updateSettings({
searchEngine: value ? "Marlin" : "Jellyfin",
});
queryClient.invalidateQueries({ queryKey: ["search"] });
}}
/>
</ListItem>
</DisabledSetting>
</ListGroup> </ListGroup>
<DisabledSetting <ListGroup className='mt-2'>
disabled={pluginSettings?.marlinServerUrl?.locked === true} <ListItem
showText={!pluginSettings?.searchEngine?.locked} title={t("home.settings.plugins.marlin_search.url")}
className='mt-2 flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900 px-4' disabledByAdmin={marlinUrlLocked}
>
<View
className={"flex flex-row items-center bg-neutral-900 h-11 pr-4"}
> >
<Text className='mr-4'>
{t("home.settings.plugins.marlin_search.url")}
</Text>
<TextInput <TextInput
editable={settings.searchEngine === "Marlin"} editable={!marlinUrlLocked && settings.searchEngine === "Marlin"}
className='text-white' className='text-white text-right flex-1'
placeholder={t( placeholder={t(
"home.settings.plugins.marlin_search.server_url_placeholder", "home.settings.plugins.marlin_search.server_url_placeholder",
)} )}
@@ -128,15 +108,16 @@ export default function MarlinSearchPage() {
textContentType='URL' textContentType='URL'
onChangeText={(text) => setValue(text)} onChangeText={(text) => setValue(text)}
/> />
</View> </ListItem>
</DisabledSetting> </ListGroup>
<Text className='px-4 text-xs text-neutral-500 mt-1'> <Text className='px-4 text-xs text-neutral-500 mt-1'>
{t("home.settings.plugins.marlin_search.marlin_search_hint")}{" "} {t("home.settings.plugins.marlin_search.marlin_search_hint")}{" "}
<Text className='text-blue-500' onPress={handleOpenLink}> <Text className='text-blue-500' onPress={handleOpenLink}>
{t("home.settings.plugins.marlin_search.read_more_about_marlin")} {t("home.settings.plugins.marlin_search.read_more_about_marlin")}
</Text> </Text>
</Text> </Text>
</DisabledSetting> </View>
</ScrollView> </ScrollView>
); );
} }

View File

@@ -1,9 +1,21 @@
import { Platform, ScrollView, View } from "react-native"; import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { toast } from "sonner-native";
import { Text } from "@/components/common/Text";
import { PluginSettings } from "@/components/settings/PluginSettings"; import { PluginSettings } from "@/components/settings/PluginSettings";
import { useSettings } from "@/utils/atoms/settings";
export default function PluginsPage() { export default function PluginsPage() {
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { t } = useTranslation();
const { refreshStreamyfinPluginSettings } = useSettings();
const handleRefreshFromServer = useCallback(async () => {
await refreshStreamyfinPluginSettings();
toast.success(t("home.settings.plugins.streamystats.toasts.refreshed"));
}, [refreshStreamyfinPluginSettings, t]);
return ( return (
<ScrollView <ScrollView
@@ -18,6 +30,17 @@ export default function PluginsPage() {
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }} style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
> >
<PluginSettings /> <PluginSettings />
{/* Pulls the centralised Streamyfin plugin settings for every plugin,
so it lives on the plugins index rather than inside Streamystats. */}
<TouchableOpacity
onPress={handleRefreshFromServer}
className='py-3 rounded-xl bg-neutral-800'
>
<Text className='text-center text-blue-500'>
{t("home.settings.plugins.streamystats.refresh_from_server")}
</Text>
</TouchableOpacity>
</View> </View>
</ScrollView> </ScrollView>
); );

View File

@@ -4,13 +4,13 @@ import { useTranslation } from "react-i18next";
import { import {
Linking, Linking,
ScrollView, ScrollView,
Switch,
TextInput, TextInput,
TouchableOpacity, TouchableOpacity,
View, View,
} from "react-native"; } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { toast } from "sonner-native"; import { toast } from "sonner-native";
import { SettingSwitch } from "@/components/common/SettingSwitch";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { ListGroup } from "@/components/list/ListGroup"; import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem"; import { ListItem } from "@/components/list/ListItem";
@@ -22,12 +22,7 @@ export default function StreamystatsPage() {
const navigation = useNavigation(); const navigation = useNavigation();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { const { settings, updateSettings, pluginSettings } = useSettings();
settings,
updateSettings,
pluginSettings,
refreshStreamyfinPluginSettings,
} = useSettings();
const queryClient = useNetworkAwareQueryClient(); const queryClient = useNetworkAwareQueryClient();
// Local state for all editable fields // Local state for all editable fields
@@ -49,7 +44,21 @@ export default function StreamystatsPage() {
); );
const isUrlLocked = pluginSettings?.streamyStatsServerUrl?.locked === true; const isUrlLocked = pluginSettings?.streamyStatsServerUrl?.locked === true;
const isStreamystatsEnabled = !!url; const searchLocked = pluginSettings?.searchEngine?.locked === true;
const movieRecsLocked =
pluginSettings?.streamyStatsMovieRecommendations?.locked === true;
const seriesRecsLocked =
pluginSettings?.streamyStatsSeriesRecommendations?.locked === true;
const promotedWatchlistsLocked =
pluginSettings?.streamyStatsPromotedWatchlists?.locked === true;
const hideWatchlistsTabLocked =
pluginSettings?.hideWatchlistsTab?.locked === true;
// The input renders the locked admin URL; enablement must follow the same
// effective value or every toggle stays disabled until local state syncs.
const effectiveUrl = isUrlLocked
? (settings?.streamyStatsServerUrl ?? "")
: url;
const isStreamystatsEnabled = !!effectiveUrl;
const onSave = useCallback(() => { const onSave = useCallback(() => {
const cleanUrl = url.endsWith("/") ? url.slice(0, -1) : url; const cleanUrl = url.endsWith("/") ? url.slice(0, -1) : url;
@@ -113,17 +122,6 @@ export default function StreamystatsPage() {
Linking.openURL("https://github.com/fredrikburmester/streamystats"); Linking.openURL("https://github.com/fredrikburmester/streamystats");
}; };
const handleRefreshFromServer = useCallback(async () => {
const newPluginSettings = await refreshStreamyfinPluginSettings(true);
// 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; if (!settings) return null;
return ( return (
@@ -134,7 +132,7 @@ export default function StreamystatsPage() {
paddingRight: insets.right, paddingRight: insets.right,
}} }}
> >
<View className='px-4'> <View className='px-4 pt-4'>
<ListGroup className='flex-1'> <ListGroup className='flex-1'>
<ListItem <ListItem
title={t("home.settings.plugins.streamystats.url")} title={t("home.settings.plugins.streamystats.url")}
@@ -146,7 +144,7 @@ export default function StreamystatsPage() {
placeholder={t( placeholder={t(
"home.settings.plugins.streamystats.server_url_placeholder", "home.settings.plugins.streamystats.server_url_placeholder",
)} )}
value={url} value={effectiveUrl}
keyboardType='url' keyboardType='url'
returnKeyType='done' returnKeyType='done'
autoCapitalize='none' autoCapitalize='none'
@@ -171,11 +169,18 @@ export default function StreamystatsPage() {
> >
<ListItem <ListItem
title={t("home.settings.plugins.streamystats.enable_search")} title={t("home.settings.plugins.streamystats.enable_search")}
disabledByAdmin={pluginSettings?.searchEngine?.locked === true} disabledByAdmin={searchLocked}
> >
<Switch {/* Locked controls show the live admin value and can't be toggled —
value={useForSearch} local form state would let the switch flip while the write guard
disabled={!isStreamystatsEnabled} drops the change. */}
<SettingSwitch
value={
searchLocked
? settings?.searchEngine === "Streamystats"
: useForSearch
}
disabled={!isStreamystatsEnabled || searchLocked}
onValueChange={setUseForSearch} onValueChange={setUseForSearch}
/> />
</ListItem> </ListItem>
@@ -183,52 +188,62 @@ export default function StreamystatsPage() {
title={t( title={t(
"home.settings.plugins.streamystats.enable_movie_recommendations", "home.settings.plugins.streamystats.enable_movie_recommendations",
)} )}
disabledByAdmin={ disabledByAdmin={movieRecsLocked}
pluginSettings?.streamyStatsMovieRecommendations?.locked === true
}
> >
<Switch <SettingSwitch
value={movieRecs} value={
movieRecsLocked
? (settings?.streamyStatsMovieRecommendations ?? false)
: movieRecs
}
onValueChange={setMovieRecs} onValueChange={setMovieRecs}
disabled={!isStreamystatsEnabled} disabled={!isStreamystatsEnabled || movieRecsLocked}
/> />
</ListItem> </ListItem>
<ListItem <ListItem
title={t( title={t(
"home.settings.plugins.streamystats.enable_series_recommendations", "home.settings.plugins.streamystats.enable_series_recommendations",
)} )}
disabledByAdmin={ disabledByAdmin={seriesRecsLocked}
pluginSettings?.streamyStatsSeriesRecommendations?.locked === true
}
> >
<Switch <SettingSwitch
value={seriesRecs} value={
seriesRecsLocked
? (settings?.streamyStatsSeriesRecommendations ?? false)
: seriesRecs
}
onValueChange={setSeriesRecs} onValueChange={setSeriesRecs}
disabled={!isStreamystatsEnabled} disabled={!isStreamystatsEnabled || seriesRecsLocked}
/> />
</ListItem> </ListItem>
<ListItem <ListItem
title={t( title={t(
"home.settings.plugins.streamystats.enable_promoted_watchlists", "home.settings.plugins.streamystats.enable_promoted_watchlists",
)} )}
disabledByAdmin={ disabledByAdmin={promotedWatchlistsLocked}
pluginSettings?.streamyStatsPromotedWatchlists?.locked === true
}
> >
<Switch <SettingSwitch
value={promotedWatchlists} value={
promotedWatchlistsLocked
? (settings?.streamyStatsPromotedWatchlists ?? false)
: promotedWatchlists
}
onValueChange={setPromotedWatchlists} onValueChange={setPromotedWatchlists}
disabled={!isStreamystatsEnabled} disabled={!isStreamystatsEnabled || promotedWatchlistsLocked}
/> />
</ListItem> </ListItem>
<ListItem <ListItem
title={t("home.settings.plugins.streamystats.hide_watchlists_tab")} title={t("home.settings.plugins.streamystats.hide_watchlists_tab")}
disabledByAdmin={pluginSettings?.hideWatchlistsTab?.locked === true} disabledByAdmin={hideWatchlistsTabLocked}
> >
<Switch <SettingSwitch
value={hideWatchlistsTab} value={
hideWatchlistsTabLocked
? (settings?.hideWatchlistsTab ?? false)
: hideWatchlistsTab
}
onValueChange={setHideWatchlistsTab} onValueChange={setHideWatchlistsTab}
disabled={!isStreamystatsEnabled} disabled={!isStreamystatsEnabled || hideWatchlistsTabLocked}
/> />
</ListItem> </ListItem>
</ListGroup> </ListGroup>
@@ -236,15 +251,6 @@ export default function StreamystatsPage() {
{t("home.settings.plugins.streamystats.home_sections_hint")} {t("home.settings.plugins.streamystats.home_sections_hint")}
</Text> </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 */} {/* Disable button - only show if URL is not locked and Streamystats is enabled */}
{!isUrlLocked && isStreamystatsEnabled && ( {!isUrlLocked && isStreamystatsEnabled && (
<TouchableOpacity <TouchableOpacity

View File

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

View File

@@ -6,6 +6,7 @@ import {
BottomSheetTextInput, BottomSheetTextInput,
BottomSheetView, BottomSheetView,
} from "@gorhom/bottom-sheet"; } from "@gorhom/bottom-sheet";
import type { BottomSheetModalMethods } from "@gorhom/bottom-sheet/lib/typescript/types";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router"; import { useLocalSearchParams, useNavigation } from "expo-router";
@@ -76,7 +77,7 @@ const MobilePage: React.FC = () => {
const [issueMessage, setIssueMessage] = useState<string>(); const [issueMessage, setIssueMessage] = useState<string>();
const [requestBody, _setRequestBody] = useState<MediaRequestBody>(); const [requestBody, _setRequestBody] = useState<MediaRequestBody>();
const [issueTypeDropdownOpen, setIssueTypeDropdownOpen] = useState(false); const [issueTypeDropdownOpen, setIssueTypeDropdownOpen] = useState(false);
const advancedReqModalRef = useRef<BottomSheetModal>(null); const advancedReqModalRef = useRef<BottomSheetModalMethods>(null);
const bottomSheetModalRef = useRef<BottomSheetModal>(null); const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const { const {

View File

@@ -9,12 +9,12 @@ import {
getItemsApi, getItemsApi,
getUserLibraryApi, getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api"; } from "@jellyfin/sdk/lib/utils/api";
import { FlashList } from "@shopify/flash-list"; import { FlashList, type FlashListRef } from "@shopify/flash-list";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router"; import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import React, { useCallback, useEffect, useMemo } from "react"; import React, { useCallback, useEffect, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
FlatList, FlatList,
@@ -30,6 +30,7 @@ import {
TouchableItemRouter, TouchableItemRouter,
} from "@/components/common/TouchableItemRouter"; } from "@/components/common/TouchableItemRouter";
import { FilterButton } from "@/components/filters/FilterButton"; import { FilterButton } from "@/components/filters/FilterButton";
import { FilterSheetProvider } from "@/components/filters/FilterSheetProvider";
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton"; import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
import { ItemCardText } from "@/components/ItemCardText"; import { ItemCardText } from "@/components/ItemCardText";
import { Loader } from "@/components/Loader"; import { Loader } from "@/components/Loader";
@@ -39,6 +40,7 @@ import { TVPosterCard } from "@/components/tv/TVPosterCard";
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes"; import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
import { useScaledTVTypography } from "@/constants/TVTypography"; import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { useFilterReset } from "@/hooks/useFilterReset";
import { useOrientation } from "@/hooks/useOrientation"; import { useOrientation } from "@/hooks/useOrientation";
import { useRefreshLibraryOnFocus } from "@/hooks/useRefreshLibraryOnFocus"; import { useRefreshLibraryOnFocus } from "@/hooks/useRefreshLibraryOnFocus";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal"; import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
@@ -50,7 +52,9 @@ import {
FilterByPreferenceAtom, FilterByPreferenceAtom,
filterByAtom, filterByAtom,
genreFilterAtom, genreFilterAtom,
genrePreferenceAtom,
getFilterByPreference, getFilterByPreference,
getMultiFilterPreference,
getSortByPreference, getSortByPreference,
getSortOrderPreference, getSortOrderPreference,
SortByOption, SortByOption,
@@ -61,11 +65,12 @@ import {
sortOrderAtom, sortOrderAtom,
sortOrderOptions, sortOrderOptions,
sortOrderPreferenceAtom, sortOrderPreferenceAtom,
tagPreferenceAtom,
tagsFilterAtom, tagsFilterAtom,
useFilterOptions, useFilterOptions,
yearFilterAtom, yearFilterAtom,
yearPreferenceAtom,
} from "@/utils/atoms/filters"; } from "@/utils/atoms/filters";
import { useSettings } from "@/utils/atoms/settings";
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal"; import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
@@ -102,6 +107,9 @@ const Page = () => {
const [sortOrderPreference, setOrderByPreference] = useAtom( const [sortOrderPreference, setOrderByPreference] = useAtom(
sortOrderPreferenceAtom, sortOrderPreferenceAtom,
); );
const [genrePreference, setGenrePreference] = useAtom(genrePreferenceAtom);
const [yearPreference, setYearPreference] = useAtom(yearPreferenceAtom);
const [tagPreference, setTagPreference] = useAtom(tagPreferenceAtom);
const { orientation } = useOrientation(); const { orientation } = useOrientation();
@@ -183,6 +191,13 @@ const Page = () => {
const fp = getFilterByPreference(libraryId, filterByPreference); const fp = getFilterByPreference(libraryId, filterByPreference);
_setFilterBy(fp ? [fp] : []); _setFilterBy(fp ? [fp] : []);
} }
// Genres / years / tags: per-library saved preference (no URL params), so
// switching libraries restores each library's own selection instead of
// bleeding the previous one.
setSelectedGenres(getMultiFilterPreference(libraryId, genrePreference));
setSelectedYears(getMultiFilterPreference(libraryId, yearPreference));
setSelectedTags(getMultiFilterPreference(libraryId, tagPreference));
}, [ }, [
libraryId, libraryId,
sortOrderPreference, sortOrderPreference,
@@ -191,6 +206,12 @@ const Page = () => {
_setSortBy, _setSortBy,
filterByPreference, filterByPreference,
_setFilterBy, _setFilterBy,
genrePreference,
yearPreference,
tagPreference,
setSelectedGenres,
setSelectedYears,
setSelectedTags,
searchParams.sortBy, searchParams.sortBy,
searchParams.sortOrder, searchParams.sortOrder,
searchParams.filterBy, searchParams.filterBy,
@@ -235,6 +256,32 @@ const Page = () => {
[libraryId, filterByPreference, setFilterByPreference, _setFilterBy], [libraryId, filterByPreference, setFilterByPreference, _setFilterBy],
); );
// Genres / years / tags: save the per-library memory then update the active
// atom (mirrors setSortBy; avoids a save-effect that would corrupt on switch).
const setGenres = useCallback(
(genres: string[]) => {
setGenrePreference({ ...genrePreference, [libraryId]: genres });
setSelectedGenres(genres);
},
[libraryId, genrePreference, setGenrePreference, setSelectedGenres],
);
const setYears = useCallback(
(years: string[]) => {
setYearPreference({ ...yearPreference, [libraryId]: years });
setSelectedYears(years);
},
[libraryId, yearPreference, setYearPreference, setSelectedYears],
);
const setTags = useCallback(
(tags: string[]) => {
setTagPreference({ ...tagPreference, [libraryId]: tags });
setSelectedTags(tags);
},
[libraryId, tagPreference, setTagPreference, setSelectedTags],
);
const nrOfCols = useMemo(() => { const nrOfCols = useMemo(() => {
if (Platform.isTV) { if (Platform.isTV) {
// TV uses flexWrap, so nrOfCols is just for mobile // TV uses flexWrap, so nrOfCols is just for mobile
@@ -376,6 +423,29 @@ const Page = () => {
); );
}, [data]); }, [data]);
const flashListRef = useRef<FlashListRef<BaseItemDto>>(null);
// Jump the grid to the top when the filters/sort change (incl. reset).
const filterSignature = `${selectedGenres}|${selectedYears}|${selectedTags}|${sortBy[0]}|${sortOrder[0]}|${filterBy}`;
const pendingScrollTopRef = useRef(false);
// Instant feedback: pin to the top the moment the filters change, without
// waiting for the new fetch — and flag a re-pin for once it settles.
useEffect(() => {
flashListRef.current?.scrollToOffset({ offset: 0, animated: false });
pendingScrollTopRef.current = true;
}, [filterSignature]);
// Safety net: FlashList can restore the previous offset as the filtered list
// grows, so re-pin once the fetch settles. Pagination keeps the same
// signature, so it never re-pins.
useEffect(() => {
if (pendingScrollTopRef.current && !isFetching) {
pendingScrollTopRef.current = false;
flashListRef.current?.scrollToOffset({ offset: 0, animated: false });
}
}, [isFetching, flatData]);
const renderItem = useCallback( const renderItem = useCallback(
({ item, index }: { item: BaseItemDto; index: number }) => ( ({ item, index }: { item: BaseItemDto; index: number }) => (
<TouchableItemRouter <TouchableItemRouter
@@ -491,7 +561,6 @@ const Page = () => {
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []); const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
const generalFilters = useFilterOptions(); const generalFilters = useFilterOptions();
const settings = useSettings();
const ListHeaderComponent = useCallback( const ListHeaderComponent = useCallback(
() => ( () => (
<FlatList <FlatList
@@ -506,7 +575,7 @@ const Page = () => {
data={[ data={[
{ {
key: "reset", key: "reset",
component: <ResetFiltersButton />, component: <ResetFiltersButton libraryId={libraryId} />,
}, },
{ {
key: "genre", key: "genre",
@@ -525,7 +594,7 @@ const Page = () => {
}); });
return response.data.Genres || []; return response.data.Genres || [];
}} }}
set={setSelectedGenres} set={setGenres}
values={selectedGenres} values={selectedGenres}
title={t("library.filters.genres")} title={t("library.filters.genres")}
renderItemLabel={(item) => item.toString()} renderItemLabel={(item) => item.toString()}
@@ -552,7 +621,7 @@ const Page = () => {
}); });
return response.data.Years || []; return response.data.Years || [];
}} }}
set={setSelectedYears} set={setYears}
values={selectedYears} values={selectedYears}
title={t("library.filters.years")} title={t("library.filters.years")}
renderItemLabel={(item) => item.toString()} renderItemLabel={(item) => item.toString()}
@@ -577,7 +646,7 @@ const Page = () => {
}); });
return response.data.Tags || []; return response.data.Tags || [];
}} }}
set={setSelectedTags} set={setTags}
values={selectedTags} values={selectedTags}
title={t("library.filters.tags")} title={t("library.filters.tags")}
renderItemLabel={(item) => item.toString()} renderItemLabel={(item) => item.toString()}
@@ -657,35 +726,23 @@ const Page = () => {
api, api,
user?.Id, user?.Id,
selectedGenres, selectedGenres,
setSelectedGenres, setGenres,
selectedYears, selectedYears,
setSelectedYears, setYears,
selectedTags, selectedTags,
setSelectedTags, setTags,
sortBy, sortBy,
setSortBy, setSortBy,
sortOrder, sortOrder,
setSortOrder, setSortOrder,
isFetching,
filterBy, filterBy,
setFilter, setFilter,
settings,
], ],
); );
// TV Filter bar header // Filter bar reset + visibility, shared with the mobile ResetFiltersButton so
const hasActiveFilters = // sort/order can't be forgotten on one path (it used to be reset on neither).
selectedGenres.length > 0 || const { hasActiveFilters, resetAllFilters } = useFilterReset(libraryId);
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 // TV Filter options - with "All" option for clearable filters
const tvGenreFilterOptions = useMemo( const tvGenreFilterOptions = useMemo(
@@ -779,15 +836,15 @@ const Page = () => {
options: tvGenreFilterOptions, options: tvGenreFilterOptions,
onSelect: (value: string) => { onSelect: (value: string) => {
if (value === "__all__") { if (value === "__all__") {
setSelectedGenres([]); setGenres([]);
} else if (selectedGenres.includes(value)) { } else if (selectedGenres.includes(value)) {
setSelectedGenres(selectedGenres.filter((g) => g !== value)); setGenres(selectedGenres.filter((g) => g !== value));
} else { } else {
setSelectedGenres([...selectedGenres, value]); setGenres([...selectedGenres, value]);
} }
}, },
}); });
}, [showOptions, t, tvGenreFilterOptions, selectedGenres, setSelectedGenres]); }, [showOptions, t, tvGenreFilterOptions, selectedGenres, setGenres]);
const handleShowYearFilter = useCallback(() => { const handleShowYearFilter = useCallback(() => {
showOptions({ showOptions({
@@ -795,15 +852,15 @@ const Page = () => {
options: tvYearFilterOptions, options: tvYearFilterOptions,
onSelect: (value: string) => { onSelect: (value: string) => {
if (value === "__all__") { if (value === "__all__") {
setSelectedYears([]); setYears([]);
} else if (selectedYears.includes(value)) { } else if (selectedYears.includes(value)) {
setSelectedYears(selectedYears.filter((y) => y !== value)); setYears(selectedYears.filter((y) => y !== value));
} else { } else {
setSelectedYears([...selectedYears, value]); setYears([...selectedYears, value]);
} }
}, },
}); });
}, [showOptions, t, tvYearFilterOptions, selectedYears, setSelectedYears]); }, [showOptions, t, tvYearFilterOptions, selectedYears, setYears]);
const handleShowTagFilter = useCallback(() => { const handleShowTagFilter = useCallback(() => {
showOptions({ showOptions({
@@ -811,15 +868,15 @@ const Page = () => {
options: tvTagFilterOptions, options: tvTagFilterOptions,
onSelect: (value: string) => { onSelect: (value: string) => {
if (value === "__all__") { if (value === "__all__") {
setSelectedTags([]); setTags([]);
} else if (selectedTags.includes(value)) { } else if (selectedTags.includes(value)) {
setSelectedTags(selectedTags.filter((tag) => tag !== value)); setTags(selectedTags.filter((tag) => tag !== value));
} else { } else {
setSelectedTags([...selectedTags, value]); setTags([...selectedTags, value]);
} }
}, },
}); });
}, [showOptions, t, tvTagFilterOptions, selectedTags, setSelectedTags]); }, [showOptions, t, tvTagFilterOptions, selectedTags, setTags]);
const handleShowSortByFilter = useCallback(() => { const handleShowSortByFilter = useCallback(() => {
showOptions({ showOptions({
@@ -867,42 +924,45 @@ const Page = () => {
// Mobile return // Mobile return
if (!Platform.isTV) { if (!Platform.isTV) {
return ( return (
<FlashList <FilterSheetProvider>
key={orientation} <FlashList
ListEmptyComponent={ ref={flashListRef}
<View className='flex flex-col items-center justify-center h-full'> key={orientation}
<Text className='font-bold text-xl text-neutral-500'> ListEmptyComponent={
{t("library.no_results")} <View className='flex flex-col items-center justify-center h-full'>
</Text> <Text className='font-bold text-xl text-neutral-500'>
</View> {t("library.no_results")}
} </Text>
contentInsetAdjustmentBehavior='automatic' </View>
data={flatData}
renderItem={renderItem}
extraData={[orientation, nrOfCols]}
keyExtractor={keyExtractor}
numColumns={nrOfCols}
onEndReached={() => {
if (hasNextPage) {
fetchNextPage();
} }
}} contentInsetAdjustmentBehavior='automatic'
onEndReachedThreshold={1} data={flatData}
ListHeaderComponent={ListHeaderComponent} renderItem={renderItem}
contentContainerStyle={{ extraData={[orientation, nrOfCols]}
paddingBottom: 24, keyExtractor={keyExtractor}
paddingLeft: insets.left, numColumns={nrOfCols}
paddingRight: insets.right, onEndReached={() => {
}} if (hasNextPage) {
ItemSeparatorComponent={() => ( fetchNextPage();
<View }
style={{ }}
width: 10, onEndReachedThreshold={1}
height: 10, ListHeaderComponent={ListHeaderComponent}
}} contentContainerStyle={{
/> paddingBottom: 24,
)} paddingLeft: insets.left,
/> paddingRight: insets.right,
}}
ItemSeparatorComponent={() => (
<View
style={{
width: 10,
height: 10,
}}
/>
)}
/>
</FilterSheetProvider>
); );
} }

View File

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

View File

@@ -40,6 +40,8 @@ const Layout = () => {
keyboardDismissMode='none' keyboardDismissMode='none'
screenOptions={{ screenOptions={{
tabBarBounces: true, tabBarBounces: true,
tabBarActiveTintColor: "#FFFFFF",
tabBarInactiveTintColor: "#9CA3AF",
tabBarLabelStyle: { tabBarLabelStyle: {
fontSize: TAB_LABEL_FONT_SIZE, fontSize: TAB_LABEL_FONT_SIZE,
fontWeight: "600", fontWeight: "600",

View File

@@ -89,7 +89,7 @@ export default function ArtistsScreen() {
return ( return (
<View className='flex-1 justify-center items-center bg-black px-6'> <View className='flex-1 justify-center items-center bg-black px-6'>
<Text className='text-neutral-500 text-center'> <Text className='text-neutral-500 text-center'>
Missing music library id. {t("music.missing_library_id")}
</Text> </Text>
</View> </View>
); );

View File

@@ -122,7 +122,7 @@ export default function PlaylistsScreen() {
return ( return (
<View className='flex-1 justify-center items-center bg-black px-6'> <View className='flex-1 justify-center items-center bg-black px-6'>
<Text className='text-neutral-500 text-center'> <Text className='text-neutral-500 text-center'>
Missing music library id. {t("music.missing_library_id")}
</Text> </Text>
</View> </View>
); );

View File

@@ -226,7 +226,7 @@ export default function SuggestionsScreen() {
return ( return (
<View className='flex-1 justify-center items-center bg-black px-6'> <View className='flex-1 justify-center items-center bg-black px-6'>
<Text className='text-neutral-500 text-center'> <Text className='text-neutral-500 text-center'>
Missing music library id. {t("music.missing_library_id")}
</Text> </Text>
</View> </View>
); );

View File

@@ -305,6 +305,8 @@ export default function SearchPage() {
}, },
hideWhenScrolling: false, hideWhenScrolling: false,
autoFocus: false, autoFocus: false,
// Android: color of the user-typed text (was dark and unreadable on the dark header)
textColor: "#fff",
// Android: placeholder and icon color // Android: placeholder and icon color
hintTextColor: "#fff", hintTextColor: "#fff",
headerIconColor: "#fff", headerIconColor: "#fff",

View File

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

View File

@@ -14,6 +14,7 @@ import React, {
useRef, useRef,
useState, useState,
} from "react"; } from "react";
import { useTranslation } from "react-i18next";
import { import {
ActivityIndicator, ActivityIndicator,
Dimensions, Dimensions,
@@ -72,6 +73,7 @@ const ARTWORK_SIZE = SCREEN_WIDTH - 80;
type ViewMode = "player" | "queue"; type ViewMode = "player" | "queue";
export default function NowPlayingScreen() { export default function NowPlayingScreen() {
const { t } = useTranslation();
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const router = useRouter(); const router = useRouter();
@@ -230,7 +232,9 @@ export default function NowPlayingScreen() {
paddingBottom: Platform.OS === "android" ? insets.bottom : 0, paddingBottom: Platform.OS === "android" ? insets.bottom : 0,
}} }}
> >
<Text className='text-neutral-500'>No track playing</Text> <Text className='text-neutral-500'>
{t("music.no_track_playing")}
</Text>
</View> </View>
</BottomSheetModalProvider> </BottomSheetModalProvider>
); );
@@ -267,7 +271,7 @@ export default function NowPlayingScreen() {
: "text-neutral-500" : "text-neutral-500"
} }
> >
Now Playing {t("music.now_playing")}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
@@ -718,6 +722,7 @@ const QueueView: React.FC<QueueViewProps> = ({
onRemoveFromQueue, onRemoveFromQueue,
onReorderQueue, onReorderQueue,
}) => { }) => {
const { t } = useTranslation();
const renderQueueItem = useCallback( const renderQueueItem = useCallback(
({ item, drag, isActive, getIndex }: RenderItemParams<BaseItemDto>) => { ({ item, drag, isActive, getIndex }: RenderItemParams<BaseItemDto>) => {
const index = getIndex() ?? 0; const index = getIndex() ?? 0;
@@ -831,13 +836,15 @@ const QueueView: React.FC<QueueViewProps> = ({
ListHeaderComponent={ ListHeaderComponent={
<View className='px-4 py-2'> <View className='px-4 py-2'>
<Text className='text-neutral-400 text-xs uppercase tracking-wider'> <Text className='text-neutral-400 text-xs uppercase tracking-wider'>
{history.length > 0 ? "Playing from queue" : "Up next"} {history.length > 0
? t("music.playing_from_queue")
: t("music.up_next")}
</Text> </Text>
</View> </View>
} }
ListEmptyComponent={ ListEmptyComponent={
<View className='flex-1 items-center justify-center py-20'> <View className='flex-1 items-center justify-center py-20'>
<Text className='text-neutral-500'>Queue is empty</Text> <Text className='text-neutral-500'>{t("music.queue_empty")}</Text>
</View> </View>
} }
/> />

View File

@@ -274,6 +274,11 @@ export default function DirectPlayerPage() {
}; };
if (itemId) { if (itemId) {
setItem(null);
setDownloadedItem(null);
// Clear the previous episode's stream so the loader gate stays closed
// until the new item's stream resolves (avoids a stale MPV source frame).
setStream(null);
fetchItemData(); fetchItemData();
} }
}, [itemId, offline, api, user?.Id]); }, [itemId, offline, api, user?.Id]);
@@ -316,6 +321,12 @@ export default function DirectPlayerPage() {
return null; return null;
} }
// Ensure item matches the current itemId to avoid race conditions
if (item.Id !== itemId) {
setStreamStatus({ isLoading: false, isError: false });
return null;
}
let result: Stream | null = null; let result: Stream | null = null;
if (offline && downloadedItem?.mediaSource) { if (offline && downloadedItem?.mediaSource) {
const url = downloadedItem.videoFilePath; const url = downloadedItem.videoFilePath;
@@ -388,6 +399,7 @@ export default function DirectPlayerPage() {
item, item,
user?.Id, user?.Id,
downloadedItem, downloadedItem,
offline,
]); ]);
useEffect(() => { useEffect(() => {
@@ -427,21 +439,15 @@ export default function DirectPlayerPage() {
if (!item?.Id || !stream?.sessionId || offline || !api) return; if (!item?.Id || !stream?.sessionId || offline || !api) return;
const currentTimeInTicks = msToTicks(progress.get()); const currentTimeInTicks = msToTicks(progress.get());
await getPlaystateApi(api).onPlaybackStopped({ await getPlaystateApi(api).reportPlaybackStopped({
itemId: item.Id, playbackStopInfo: {
mediaSourceId: mediaSourceId, ItemId: item.Id,
positionTicks: currentTimeInTicks, MediaSourceId: mediaSourceId,
playSessionId: stream.sessionId, PositionTicks: currentTimeInTicks,
PlaySessionId: stream.sessionId,
},
}); });
}, [ }, [api, item, mediaSourceId, stream, progress, offline]);
api,
item,
mediaSourceId,
stream,
progress,
offline,
revalidateProgressCache,
]);
const stop = useCallback(() => { const stop = useCallback(() => {
// Update URL with final playback position before stopping // Update URL with final playback position before stopping
@@ -450,18 +456,32 @@ export default function DirectPlayerPage() {
}); });
reportPlaybackStopped(); reportPlaybackStopped();
setIsPlaybackStopped(true); setIsPlaybackStopped(true);
videoRef.current?.pause(); // Synchronously destroy the mpv instance + decoder + surface buffers
// BEFORE the screen unmounts. Otherwise the next screen (or the next
// episode's player) mounts while the old 4K decoder is still alive,
// causing OOM on low-RAM devices. Native stop() is idempotent so the
// later React unmount cleanup is still safe.
videoRef.current?.destroy().catch(() => {});
// Pre-libmpv-1.0 used `stop()`:
// videoRef.current?.stop();
revalidateProgressCache(); revalidateProgressCache();
// Resume inactivity timer when leaving player (TV only) // Resume inactivity timer when leaving player (TV only)
resumeInactivityTimer(); resumeInactivityTimer();
// Release the keep-awake wakelock acquired during playback so it
// doesn't follow us back to the home screen and block the TV
// screensaver. activateKeepAwakeAsync() is tag-scoped to this module
// and only released on the "paused" event; without this, navigating
// away mid-play leaves FLAG_KEEP_SCREEN_ON set on the window.
deactivateKeepAwake();
}, [videoRef, reportPlaybackStopped, progress, resumeInactivityTimer]); }, [videoRef, reportPlaybackStopped, progress, resumeInactivityTimer]);
useEffect(() => { useEffect(() => {
const beforeRemoveListener = navigation.addListener("beforeRemove", stop); const beforeRemoveListener = navigation.addListener("beforeRemove", stop);
return () => { return () => {
reportPlaybackStopped();
beforeRemoveListener(); beforeRemoveListener();
}; };
}, [navigation, stop]); }, [navigation, stop, reportPlaybackStopped]);
const currentPlayStateInfo = useCallback((): const currentPlayStateInfo = useCallback(():
| PlaybackProgressInfo | PlaybackProgressInfo
@@ -1098,6 +1118,15 @@ export default function DirectPlayerPage() {
nextItem.UserData?.PlaybackPositionTicks?.toString() ?? "", nextItem.UserData?.PlaybackPositionTicks?.toString() ?? "",
}).toString(); }).toString();
// Destroy the current mpv instance BEFORE navigating so the old 4K
// decoder + surface buffers are freed before the new player screen
// mounts. Without this, Expo Router briefly holds two simultaneous
// mpv instances during the transition (~768 MB of surface buffers
// for two 4K HDR10+ decoders) and OOM-kills the app on low-RAM
// devices. Native stop() is idempotent so the subsequent React
// unmount cleanup is still safe.
videoRef.current?.destroy().catch(() => {});
router.replace(`player/direct-player?${queryParams}` as any); router.replace(`player/direct-player?${queryParams}` as any);
}, [ }, [
nextItem, nextItem,
@@ -1108,6 +1137,7 @@ export default function DirectPlayerPage() {
bitrateValue, bitrateValue,
router, router,
isPlaybackStopped, isPlaybackStopped,
videoRef,
]); ]);
// Apply subtitle settings when video loads // Apply subtitle settings when video loads
@@ -1260,7 +1290,7 @@ export default function DirectPlayerPage() {
console.error("Video Error:", e.nativeEvent); console.error("Video Error:", e.nativeEvent);
Alert.alert( Alert.alert(
t("player.error"), t("player.error"),
t("player.an_error_occured_while_playing_the_video"), t("player.an_error_occurred_while_playing_the_video"),
); );
writeToLog("ERROR", "Video Error", e.nativeEvent); writeToLog("ERROR", "Video Error", e.nativeEvent);
}} }}

View File

@@ -192,6 +192,7 @@ const SubtitleResultCard = React.forwardRef<
>(({ result, hasTVPreferredFocus, isDownloading, onPress }, ref) => { >(({ result, hasTVPreferredFocus, isDownloading, onPress }, ref) => {
const { focused, handleFocus, handleBlur, animatedStyle } = const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.03 }); useTVFocusAnimation({ scaleAmount: 1.03 });
const { t } = useTranslation();
return ( return (
<Pressable <Pressable
@@ -328,7 +329,7 @@ const SubtitleResultCard = React.forwardRef<
]} ]}
> >
<Text style={[styles.flagText, { fontSize: scaleSize(10) }]}> <Text style={[styles.flagText, { fontSize: scaleSize(10) }]}>
Hash Match {t("player.hash_match")}
</Text> </Text>
</View> </View>
)} )}

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 22 KiB

BIN
assets/icons/link.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

BIN
assets/icons/list.star.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" fill="none" viewBox="0 0 96 96"><path fill="url(#paint0_linear)" fill-rule="evenodd" d="M48 96C74.5097 96 96 74.5097 96 48C96 21.4903 74.5097 0 48 0C21.4903 0 0 21.4903 0 48C0 74.5097 21.4903 96 48 96ZM80.0001 52C80.0001 67.464 67.4641 80 52.0001 80C36.5361 80 24.0001 67.464 24.0001 52C24.0001 49.1303 24.4318 46.3615 25.2338 43.7548C27.4288 48.6165 32.3194 52 38.0001 52C45.7321 52 52.0001 45.732 52.0001 38C52.0001 32.3192 48.6166 27.4287 43.755 25.2337C46.3616 24.4317 49.1304 24 52.0001 24C67.4641 24 80.0001 36.536 80.0001 52Z" clip-rule="evenodd"/><path fill="#131928" fill-rule="evenodd" d="M80.0002 52C80.0002 67.464 67.4642 80 52.0002 80C36.864 80 24.5329 67.9897 24.017 52.9791C24.0057 53.318 24 53.6583 24 54C24 70.5685 37.4315 84 54 84C70.5685 84 84 70.5685 84 54C84 37.4315 70.5685 24 54 24C53.6597 24 53.3207 24.0057 52.9831 24.0169C67.9919 24.5347 80.0002 36.865 80.0002 52Z" clip-rule="evenodd" opacity=".2"/><path fill="url(#paint1_linear)" fill-rule="evenodd" d="M48 12C28.1177 12 12 28.1177 12 48C12 50.2091 10.2091 52 8 52C5.79086 52 4 50.2091 4 48C4 23.6995 23.6995 4 48 4C50.2091 4 52 5.79086 52 8C52 10.2091 50.2091 12 48 12Z" clip-rule="evenodd"/><defs><linearGradient id="paint0_linear" x1="48" x2="117.5" y1="0" y2="69.5" gradientUnits="userSpaceOnUse"><stop stop-color="#C395FC"/><stop offset="1" stop-color="#4F65F5"/></linearGradient><linearGradient id="paint1_linear" x1="28" x2="28" y1="8" y2="48" gradientUnits="userSpaceOnUse"><stop stop-color="#fff" stop-opacity=".4"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -1,65 +0,0 @@
<svg
type="certified"
viewBox="0 0 80 80"
preserveAspectRatio="xMidYMid"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
>
<g transform="translate(2.29, 0)">
<path
d="M42.1942857,18.8022857 C44.3794286,18.608 49.1565714,18.7177143 51.4902857,21.0057143 C51.6297143,21.1451429 51.5085714,21.4605714 51.3097143,21.408 C47.8902857,20.4868571 42.5577143,25.0217143 39.1017143,22.0891429 C39.008,22.9485714 38.2331429,27.0857143 32.3314286,26.4731429 C32.192,26.4594286 32.1371429,26.304 32.24,26.2171429 C33.1542857,25.44 34.2765714,23.2891429 33.3142857,21.9154286 C30.3108571,23.9085714 28.7565714,23.9954286 23.2182857,21.5954286 C23.0377143,21.5177143 23.1451429,21.2228571 23.3577143,21.1748571 C24.5074286,20.9165714 27.2434286,19.9222857 29.696,19.4582857 C30.1645714,19.3691429 30.624,19.3165714 31.0674286,19.312 C28.528,18.7062857 27.4217143,18.1805714 25.7485714,18.1874286 C25.5657143,18.1897143 25.4742857,17.9611429 25.6068571,17.8354286 C28.224,15.3188571 32.9691429,15.1885714 35.2548571,17.0628571 L33.2068571,12.7862857 L35.696,12.4114286 C35.696,12.4114286 36.3451429,14.6925714 36.9257143,16.7428571 C39.5177143,13.904 43.5268571,14.192 44.8777143,16.672 C44.9577143,16.8182857 44.8251429,16.992 44.6605714,16.9622857 C43.3005714,16.7314286 42.3702857,17.8628571 42.1737143,18.7977143 L42.1942857,18.8022857"
id="Fill-2"
fill="#00912D"
></path>
<mask id="mask-2" fill="white">
<polygon
points="0.137142857 0.921142857 75.0534777 0.921142857 75.0534777 79.8628571 0.137142857 79.8628571"
></polygon>
</mask>
<path
d="M13.0491429,59.1817143 C9.90628571,55.3554286 7.86971429,50.576 7.51771429,44.9622857 C6.912,35.2342857 10.2354286,26.0845714 23.1794286,21.4834286 C23.1908571,21.5245714 23.1725714,21.5748571 23.2182857,21.5954286 C23.0377143,21.5177143 23.1451429,21.2228571 23.3577143,21.1748571 C24.5074286,20.9165714 27.2434286,19.92 29.696,19.4582857 C30.1645714,19.3691429 30.624,19.3165714 31.0674286,19.3097143 C28.528,18.7062857 27.4217143,18.1805714 25.7485714,18.1874286 C25.5657143,18.1897143 25.4742857,17.9611429 25.6068571,17.8331429 C28.224,15.3165714 32.9691429,15.1885714 35.2548571,17.0628571 L33.2068571,12.784 L35.696,12.4114286 C35.696,12.4114286 36.3451429,14.6902857 36.9257143,16.7428571 C39.5177143,13.904 43.5268571,14.192 44.8777143,16.672 C44.9577143,16.8182857 44.8251429,16.992 44.6605714,16.9622857 C43.3005714,16.7314286 42.3702857,17.8628571 42.1737143,18.7977143 L42.1942857,18.8022857 C44.3794286,18.608 49.1565714,18.7177143 51.4902857,21.0057143 C51.328,20.8502857 51.1337143,20.7245714 50.9508571,20.5874286 C60.2765714,23.504 66.7474286,30.1531429 67.44,41.2251429 C67.8811429,48.2948571 65.5702857,54.3885714 61.568,59.1154286 C62.784,59.2891429 63.9931429,59.4925714 65.2045714,59.6937143 C70.304,53.4537143 73.2502857,45.5428571 73.2502857,37.056 C73.2502857,17.7165714 57.5337143,2.56685714 37.472,2.56685714 C17.4102857,2.56685714 1.69371429,17.7165714 1.69371429,37.056 C1.69371429,45.5565714 4.64,53.472 9.744,59.7097143 C10.8434286,59.5268571 11.9451429,59.3462857 13.0491429,59.1817143"
fill="#FFD700"
mask="url(#mask-2)"
></path>
<path
d="M9.744,59.7097143 C4.64,53.472 1.69371429,45.5565714 1.69371429,37.056 C1.69371429,17.7165714 17.4102857,2.56685714 37.472,2.56685714 C57.5337143,2.56685714 73.2502857,17.7165714 73.2502857,37.056 C73.2502857,45.5428571 70.304,53.4537143 65.2045714,59.6937143 C65.8125714,59.7942857 66.4205714,59.8742857 67.0285714,59.984 C71.9497143,53.6457143 74.8937143,45.6982857 74.8937143,37.056 C74.8937143,16.3862857 58.1394286,0.921142857 37.472,0.921142857 C16.8022857,0.921142857 0.048,16.3862857 0.048,37.056 C0.048,45.7074286 2.99885714,53.6594286 7.92914286,59.9977143 C8.53257143,59.8902857 9.13828571,59.8102857 9.744,59.7097143"
fill="#FA6E0F"
mask="url(#mask-2)"
></path>
<path
d="M58.2857143,74.9394286 C62.3748571,75.1954286 65.7874286,77.2137143 67.8468571,79.9474286 C67.9131429,80.0182857 68.0114286,80.016 68.0411429,79.9382857 C68.7451429,77.0971429 68.9394286,74.0662857 68.5851429,71.0125714 C68.5874286,70.9805714 68.6125714,70.9577143 68.6537143,70.9485714 C70.576,70.3428571 72.7017143,70.0137143 74.9645714,70.0457143 C75.0857143,70.0594286 75.0834286,69.9405714 74.9554286,69.8194286 C72.5577143,67.4994286 69.6297143,65.6914286 66.416,64.5417143 C65.3051429,67.68 64.2217143,70.816 63.1565714,73.9634286 C63.136,74.0228571 63.0514286,74.0594286 62.9645714,74.0434286 L58.2857143,74.9394286"
fill="#0AC855"
mask="url(#mask-2)"
></path>
<path
d="M62.9645714,74.0434286 L58.2857143,74.9394286 C58.2857143,74.9394286 58.3451429,74.512 58.528,73.3325714 C60.9417143,73.6754286 62.9645714,74.0434286 62.9645714,74.0434286"
fill="#0B4902"
></path>
<g transform="translate(0, 20.57)">
<mask id="mask-4" fill="white">
<polygon
points="0.137142857 0.016 67.4935952 0.016 67.4935952 59.2914286 0.137142857 59.2914286"
></polygon>
</mask>
<path
d="M13.0765714,38.6057143 C29.1177143,36.2605714 45.5222857,36.2354286 61.568,38.544 C65.5702857,33.8171429 67.8811429,27.7234286 67.44,20.6537143 C66.7474286,9.58171429 60.2765714,2.93257143 50.9508571,0.016 C51.1337143,0.153142857 51.328,0.278857143 51.4902857,0.434285714 C51.6297143,0.573714286 51.5085714,0.889142857 51.3097143,0.836571429 C47.8902857,-0.0845714286 42.5577143,4.45028571 39.1017143,1.51771429 C39.008,2.37485714 38.2331429,6.51428571 32.3314286,5.90171429 C32.192,5.888 32.1371429,5.73257143 32.24,5.64571429 C33.1542857,4.86857143 34.2765714,2.71542857 33.3142857,1.344 C30.3108571,3.33714286 28.7565714,3.424 23.2182857,1.024 C23.1725714,1.00342857 23.1908571,0.953142857 23.1794286,0.912 C10.2354286,5.51314286 6.912,14.6628571 7.51771429,24.3908571 C7.86971429,30.0091429 9.93142857,34.7748571 13.0765714,38.6057143"
fill="#FA3200"
mask="url(#mask-4)"
></path>
<path
d="M12.0868571,53.472 C12,53.488 11.9154286,53.4514286 11.8948571,53.392 C10.8274286,50.2445714 9.73485714,47.0971429 8.62171429,43.9611429 C5.41028571,45.1108571 2.49371429,46.9302857 0.0982857143,49.248 C-0.0297142857,49.3691429 -0.032,49.488 0.0891428571,49.4742857 C2.352,49.4422857 4.47771429,49.7714286 6.4,50.3771429 C6.44114286,50.3862857 6.46628571,50.4091429 6.46857143,50.4411429 C6.11428571,53.4948571 6.30857143,56.5257143 7.01257143,59.3668571 C7.04228571,59.4445714 7.14057143,59.4468571 7.20685714,59.376 C9.26628571,56.6422857 12.6742857,54.624 16.7657143,54.368 L12.0868571,53.472"
fill="#0AC855"
mask="url(#mask-4)"
></path>
</g>
<path
d="M62.9645714,74.0434286 C46.192,71.104 28.8571429,71.104 12.0868571,74.0434286 C12,74.0594286 11.9154286,74.0228571 11.8948571,73.9634286 C10.3428571,69.3851429 8.74285714,64.8182857 7.09257143,60.2628571 C7.06971429,60.1988571 7.14057143,60.1257143 7.248,60.1074286 C27.1885714,56.464 47.8605714,56.464 67.8034286,60.1074286 C67.9108571,60.1257143 67.9817143,60.1988571 67.9565714,60.2628571 C66.3085714,64.8182857 64.7085714,69.3851429 63.1565714,73.9634286 C63.136,74.0228571 63.0514286,74.0594286 62.9645714,74.0434286"
fill="#00912D"
></path>
<path
d="M12.0868571,74.0434286 L16.7657143,74.9394286 C16.7657143,74.9394286 16.704,74.512 16.5211429,73.3325714 C14.1074286,73.6754286 12.0868571,74.0434286 12.0868571,74.0434286"
fill="#0B4902"
></path>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 560 560" xmlns="http://www.w3.org/2000/svg"><path fill="#fff" d="M370.57 474.214l23.466-237.956c14.93-4.796 29.498-11.15 40.23-20.262L404.16 446.278c-6.748 10.248-19.863 20.86-33.59 27.936zm-78.197 21.631l2.947-244.528c20.894-.599 47.933-3.43 70.97-8.346l-19.07 241.17c-22.724 7.518-35.934 9.848-54.847 11.704zm-99.694-252.874c23.038 4.916 50.077 7.747 70.971 8.346l2.948 244.528c-18.914-1.856-32.123-4.186-54.847-11.705l-19.072-241.17zm-67.974-26.975c10.732 9.112 25.3 15.466 40.23 20.262l23.464 237.956c-13.726-7.075-26.84-17.688-33.59-27.936l-30.104-230.282z"/><path fill="gold" d="M118.905 157.445c1.357 28.827 72.771 51.677 160.578 51.176 76.687-.438 140.659-18.546 156.329-42.336a22.976 22.976 0 00-14.058-7.426c.06-.7.098-1.406.095-2.122-.065-11.4-8.429-20.788-19.327-22.54.287-1.474.438-2.999.43-4.559-.072-12.696-10.426-22.928-23.124-22.856-.287.001-.568.036-.853.049a22.911 22.911 0 001.254-7.56c-.074-12.697-10.425-22.93-23.123-22.858a22.914 22.914 0 00-8.247 1.6c-3.632-6.835-10.606-11.6-18.737-12.149-1.416-11.4-11.157-20.195-22.93-20.129-7.41.042-13.963 3.6-18.136 9.065-4.233-4.605-10.3-7.494-17.047-7.456-12.698.072-22.932 10.424-22.86 23.118a22.983 22.983 0 001.115 6.946 22.918 22.918 0 00-13.07 7.459c-2.644-9.847-11.637-17.084-22.314-17.024-9.975.057-18.406 6.47-21.537 15.366-8.474 3.426-14.439 11.738-14.383 21.433.012 2.154.342 4.227.907 6.202a22.876 22.876 0 00-9.328-1.932c-10.012.058-18.47 6.516-21.574 15.465a22.83 22.83 0 00-9.788-2.149c-12.698.072-22.934 10.422-22.86 23.118a22.833 22.833 0 003.159 11.463c-.202.203-.379.426-.571.636"/><path fill="#FA320A" d="M404.161 446.278c-6.749 10.248-19.864 20.86-33.59 27.936l23.465-237.956c14.93-4.796 29.498-11.15 40.23-20.262L404.16 446.278zM347.22 484.14c-22.723 7.519-35.934 9.85-54.847 11.705l2.947-244.528c20.894-.599 47.933-3.43 70.973-8.346L347.22 484.14zm-135.47 0l-19.07-241.17c23.037 4.917 50.076 7.748 70.97 8.347l2.948 244.528c-18.914-1.856-32.123-4.186-54.847-11.705zm-56.94-37.862l-30.105-230.282c10.732 9.112 25.3 15.466 40.23 20.262l23.464 237.956c-13.726-7.075-26.84-17.688-33.588-27.936zm247.668-321.143c.298 1.453.465 2.955.473 4.498a23.018 23.018 0 01-.43 4.56c10.9 1.749 19.263 11.137 19.328 22.54a23.59 23.59 0 01-.095 2.12 22.976 22.976 0 0114.058 7.425c-15.669 23.792-79.642 41.9-156.327 42.34-87.807.502-159.221-22.346-160.58-51.175.192-.208.37-.433.57-.634-1.355-2.311-2.29-4.887-2.773-7.62-8.408 7.979-13.495 14.412-12.6 23.78.085 1.251 37.196 266.911 37.196 266.911 4.282 42.075 65.391 75.703 138.187 76.12 72.796-.417 133.907-34.045 138.187-76.12 0 0 37.11-265.66 37.197-266.912 1.777-18.736-20.15-35.745-52.39-47.833z"/></svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 560 560" xmlns="http://www.w3.org/2000/svg"><g transform="translate(33 140)"><path d="m43.802 267.32l237.94 23.482c4.7937 14.937 11.149 29.517 20.259 40.256l-230.27-30.125c-10.248-6.7519-20.861-19.877-27.936-33.612zm222.88-75.298c0.60053 20.906 3.4316 47.964 8.3462 71.017l-241.15-19.083c-7.518-22.739-9.8466-35.959-11.704-54.885l244.51 2.951zm8.3462-102.71c-4.9146 23.053-7.7456 50.111-8.3462 71.017l-244.51 2.951c1.8576-18.926 4.1862-32.146 11.704-54.885l241.15-19.083zm26.973-68.019c-9.1095 10.74-15.465 25.318-20.259 40.257l-237.94 23.48c7.0751-13.735 17.689-26.859 27.936-33.612l230.27-30.125z" fill="#fff"/><path d="m303.57 264.67c3.155-7.8209 14.337-12.586 22.367-12.028 8.5825 0.59581 17.699 9.6258 19.292 18.507 0.29589-0.32244 0.60578-0.62735 0.92093-0.92701 2.7558-2.6356 6.2084-4.3845 9.9867-4.8664-0.57777-2.562-0.71609-5.3045-0.3204-8.1188 1.3954-9.901 9.3336-17.326 18.422-17.252 5.8652 0.047314 11.011 3.0509 14.364 7.6649 0.29939-0.37501 0.6303-0.71848 0.95245-1.069 3.8343-19.991 6.2644-42.578 6.8177-66.547 1.8996-82.42-18.993-149.75-46.663-150.39-27.672-0.63962-51.644 65.656-53.544 148.08 0 0-1.4654 30.062 7.4042 86.951" fill="#00641E"/><path d="m490.91 354.8c1.6353-2.732 2.5492-6.0072 2.4862-9.4874 0.5305-11.245-7.1819-21.439-17.913-20.31 0.3099-1.2862 0.51299-2.6233 0.59178-4.0024 0.64255-11.264-7.1188-20.972-17.337-21.682-0.2241-0.014019-0.44471-0.021029-0.66706-0.028039 1.0627-2.795 1.5897-5.916 1.4024-9.2228-0.52875-9.3717-6.9858-17.268-15.386-18.822-3.0342-0.56076-5.9773-0.28213-8.6718 0.65188-2.5457-6.277-7.8909-10.933-14.393-11.916-0.51474-10.192-7.8699-18.58-17.34-19.238-5.9545-0.41356-11.426 2.3237-15.085 6.9061-3.3528-4.614-8.4985-7.6158-14.364-7.6632-9.0885-0.075352-17.027 7.3495-18.422 17.252-0.39569 2.8126-0.25737 5.555 0.3204 8.1188-3.7783 0.48015-7.2309 2.2308-9.9867 4.8646-0.31515 0.29966-0.62504 0.60457-0.92093 0.92701-1.5932-8.8811-10.71-17.909-19.292-18.507-8.0293-0.55726-19.357 4.3249-22.367 12.028 1.3201 13.434 9.71 50.053 40.055 82.903l0.26963 0.019276c2.9256 2.6496 6.7459 4.1093 10.818 3.7466 2.5247-0.22606 4.8498-1.1303 6.8527-2.5252l0.48848 0.033295c2.67 1.8558 5.8793 2.8266 9.2706 2.5252 1.2956-0.11566 2.5317-0.42758 3.7065-0.87269 2.9064 6.0142 9.3879 9.901 16.622 9.2631 5.6026-0.49417 10.365-3.5959 13.164-7.9611l0.90692 0.063086c2.7961 2.774 6.513 4.3897 10.522 4.2688 3.3143 5.0188 9.4019 8.1065 16.12 7.516 2.5299-0.22255 4.8918-0.95505 6.998-2.0643 3.5139 4.3266 9.2811 6.8991 15.609 6.3419 6.2557-0.5485 11.54-4.02 14.414-8.8197 2.8241 2.2693 6.3625 3.4872 10.12 3.1525 3.6452-0.32594 6.8842-2.0485 9.3179-4.6543l0.40619 0.028038c0.55326-0.80259 1.026-1.6245 1.4654-2.4533 0.010505-0.015772 0.019259-0.033296 0.028014-0.049067 0.059527-0.1104 0.13306-0.21905 0.18909-0.3312" fill="#FFD700"/><path d="m281.75 61.547l-237.94 23.48c7.0751-13.735 17.689-26.859 27.936-33.612l230.27-30.125c-9.1095 10.74-15.465 25.318-20.259 40.257zm20.259 269.51l-230.27-30.125c-10.248-6.7519-20.861-19.877-27.936-33.611l237.94 23.48c4.7937 14.937 11.149 29.517 20.259 40.256zm-268.13-87.102c-7.518-22.739-9.8466-35.957-11.704-54.885l244.51 2.951c0.60053 20.906 3.4316 47.964 8.3462 71.017l-241.15-19.083zm0-135.56l241.15-19.083c-4.9146 23.053-7.7456 50.111-8.3462 71.019l-244.51 2.9493c1.8576-18.926 4.1862-32.146 11.704-54.885zm344.72-82.679c-15.255-17.778-26.206-26.124-35.587-25.04-1.7491 0.22255-266.89 37.222-266.89 37.222-42.074 4.2828-75.7 65.432-76.117 138.28 0.4167 72.843 34.043 133.99 76.117 138.28 0 0 265.64 37.135 266.89 37.221 2.2183-0.014019 4.4086-0.31192 6.5673-0.86568-2.101-0.6256-4.0391-1.7208-5.6867-3.2139l-0.26963-0.019276c-30.345-32.848-38.735-69.47-40.055-82.903 0.003501-0.010514 0.010505-0.019276 0.014006-0.02979-0.003501 0.010514-0.010505 0.019276-0.014006 0.02979-8.8697-56.889-7.4042-86.951-7.4042-86.951 1.8996-82.42 25.872-148.72 53.544-148.08 27.67 0.63962 48.562 67.973 46.663 150.39-0.55326 23.969-2.9834 46.556-6.8177 66.547 3.9393-4.3512 9.2233-6.1842 14.133-5.8372 0.90342 0.064838 1.7823 0.2173 2.6437 0.41531 16.804-92.03-0.81238-181.89-27.729-215.44z" fill="#04A53C"/></g></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 560 560" xmlns="http://www.w3.org/2000/svg"><path d="m478.29 296.98c-3.99-63.966-36.52-111.82-85.468-138.58 0.278 1.56-1.109 3.508-2.688 2.818-32.016-14.006-86.328 31.32-124.28 7.584 0.285 8.519-1.378 50.072-59.914 52.483-1.382 0.056-2.142-1.355-1.268-2.354 7.828-8.929 15.732-31.535 4.367-43.586-24.338 21.81-38.472 30.017-85.138 19.186-29.878 31.241-46.809 74-43.485 127.26 6.78 108.74 108.63 170.89 211.19 164.49 102.56-6.395 193.47-80.572 186.68-189.31" fill="#FA320A"/><path d="M291.375 132.293c21.075-5.023 81.693-.49 101.114 25.274 1.166 1.545-.475 4.468-2.355 3.648-32.016-14.006-86.328 31.32-124.282 7.584.285 8.519-1.378 50.072-59.914 52.483-1.382.056-2.142-1.355-1.268-2.354 7.828-8.929 15.73-31.535 4.367-43.586-26.512 23.758-40.884 31.392-98.426 15.838-1.883-.508-1.241-3.535.762-4.298 10.876-4.157 35.515-22.361 58.824-30.385 4.438-1.526 8.862-2.71 13.18-3.4-25.665-2.293-37.235-5.862-53.559-3.4-1.789.27-3.004-1.813-1.895-3.241 21.995-28.332 62.513-36.888 87.512-21.837-15.41-19.094-27.48-34.321-27.48-34.321l28.601-16.246s11.817 26.4 20.414 45.614c21.275-31.435 60.86-34.336 77.585-12.033.992 1.326-.045 3.21-1.702 3.171-13.612-.331-21.107 12.05-21.675 21.466l.197.023" fill="#00912D"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 560 560" xmlns="http://www.w3.org/2000/svg"><path d="M445.185 444.684c-79.369 4.167-95.587-86.652-126.726-86.006-13.268.279-23.726 14.151-19.133 30.32 2.525 8.888 9.53 21.923 13.944 30.011 15.57 28.544-7.447 60.845-34.383 63.577-44.76 4.54-63.433-21.426-62.278-48.007 1.3-29.84 26.6-60.331.65-73.305-27.194-13.597-49.301 39.572-75.325 51.439-23.553 10.741-56.248 2.413-67.872-23.741-8.164-18.379-6.68-53.768 29.67-67.27 22.706-8.433 73.305 11.029 75.9-13.623 2.992-28.416-53.155-30.812-70.06-37.626-29.912-12.055-47.567-37.85-33.734-65.522 10.378-20.757 40.915-29.203 64.223-20.11 27.922 10.892 32.404 39.853 46.71 51.897 12.324 10.38 29.19 11.68 40.22 4.543 8.135-5.265 10.843-16.828 7.774-27.39-4.07-14.023-14.875-22.773-25.415-31.346-18.758-15.249-45.24-28.36-29.222-69.983 13.13-34.11 51.642-35.34 51.642-35.34 15.3-1.72 29.002 2.9 40.167 12.875 14.927 13.335 17.834 31.16 15.336 50.176-2.283 17.358-8.426 32.56-11.63 49.759-3.717 19.966 6.954 40.086 27.249 40.869 26.694 1.031 34.698-19.486 37.964-32.492 4.782-19.028 11.058-36.694 28.718-47.82 25.346-15.97 60.552-12.47 76.886 18.222 12.92 24.284 8.772 57.715-11.047 75.97-8.892 8.188-19.584 11.075-31.148 11.156-16.585.117-33.162-.29-48.556 7.471-10.48 5.281-15.047 13.888-15.045 25.423 0 11.242 5.853 18.585 15.336 23.363 17.86 9.003 37.577 10.843 56.871 14.222 27.98 4.9 52.581 14.755 68.375 40.72.142.228.28.458.415.69 18.139 30.741-.831 75.005-36.476 76.878" fill="#0AC855"/></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 190.24 81.52"><defs><linearGradient id="a" y1="40.76" x2="190.24" y2="40.76" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#90cea1"/><stop offset=".56" stop-color="#3cbec9"/><stop offset="1" stop-color="#00b3e5"/></linearGradient></defs><path d="M105.67 36.06h66.9a17.67 17.67 0 0017.67-17.66A17.67 17.67 0 00172.57.73h-66.9A17.67 17.67 0 0088 18.4a17.67 17.67 0 0017.67 17.66zm-88 45h76.9a17.67 17.67 0 0017.67-17.66 17.67 17.67 0 00-17.67-17.67h-76.9A17.67 17.67 0 000 63.4a17.67 17.67 0 0017.67 17.66zm-7.26-45.64h7.8V6.92h10.1V0h-28v6.9h10.1zm28.1 0h7.8V8.25h.1l9 27.15h6l9.3-27.15h.1V35.4h7.8V0H66.76l-8.2 23.1h-.1L50.31 0h-11.8zm113.92 20.25a15.07 15.07 0 00-4.52-5.52 18.57 18.57 0 00-6.68-3.08 33.54 33.54 0 00-8.07-1h-11.7v35.4h12.75a24.58 24.58 0 007.55-1.15 19.34 19.34 0 006.35-3.32 16.27 16.27 0 004.37-5.5 16.91 16.91 0 001.63-7.58 18.5 18.5 0 00-1.68-8.25zM145 68.6a8.8 8.8 0 01-2.64 3.4 10.7 10.7 0 01-4 1.82 21.57 21.57 0 01-5 .55h-4.05v-21h4.6a17 17 0 014.67.63 11.66 11.66 0 013.88 1.87A9.14 9.14 0 01145 59a9.87 9.87 0 011 4.52 11.89 11.89 0 01-1 5.08zm44.63-.13a8 8 0 00-1.58-2.62 8.38 8.38 0 00-2.42-1.85 10.31 10.31 0 00-3.17-1v-.1a9.22 9.22 0 004.42-2.82 7.43 7.43 0 001.68-5 8.42 8.42 0 00-1.15-4.65 8.09 8.09 0 00-3-2.72 12.56 12.56 0 00-4.18-1.3 32.84 32.84 0 00-4.62-.33h-13.2v35.4h14.5a22.41 22.41 0 004.72-.5 13.53 13.53 0 004.28-1.65 9.42 9.42 0 003.1-3 8.52 8.52 0 001.2-4.68 9.39 9.39 0 00-.55-3.18zm-19.42-15.75h5.3a10 10 0 011.85.18 6.18 6.18 0 011.7.57 3.39 3.39 0 011.22 1.13 3.22 3.22 0 01.48 1.82 3.63 3.63 0 01-.43 1.8 3.4 3.4 0 01-1.12 1.2 4.92 4.92 0 01-1.58.65 7.51 7.51 0 01-1.77.2h-5.65zm11.72 20a3.9 3.9 0 01-1.22 1.3 4.64 4.64 0 01-1.68.7 8.18 8.18 0 01-1.82.2h-7v-8h5.9a15.35 15.35 0 012 .15 8.47 8.47 0 012.05.55 4 4 0 011.57 1.18 3.11 3.11 0 01.63 2 3.71 3.71 0 01-.43 1.92z" fill="url(#a)"/></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

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

View File

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

View File

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

404
bun.lock

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -35,8 +35,10 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`; return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
} }
if (item.Type === "Episode") { if (item.Type === "Episode") {
if (item.ParentBackdropItemId && item.ParentThumbImageTag) { // Matched pair: the parent that owns the Thumb (ParentThumbItemId), not the
return `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ParentThumbImageTag}`; // backdrop owner — otherwise the Thumb tag is requested on the wrong item → black.
if (item.ParentThumbItemId && item.ParentThumbImageTag) {
return `${api?.basePath}/Items/${item.ParentThumbItemId}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ParentThumbImageTag}`;
} }
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`; return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
@@ -61,7 +63,9 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
} }
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`; return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
}, [item]); // useEpisodePoster in deps so flipping the setting re-computes the URL live
// (no app restart needed).
}, [item, useEpisodePoster]);
if (!url) if (!url)
return <View className='aspect-video border border-neutral-800 w-44' />; return <View className='aspect-video border border-neutral-800 w-44' />;

View File

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

View File

@@ -89,14 +89,14 @@ export const IntroSheet = forwardRef<IntroSheetRef>((_, ref) => {
</Text> </Text>
<View className='flex flex-row items-center mt-4'> <View className='flex flex-row items-center mt-4'>
<Image <Image
source={require("@/assets/icons/jellyseerr-logo.svg")} source={require("@/assets/icons/seerr-logo.svg")}
style={{ style={{
width: 50, width: 50,
height: 50, height: 50,
}} }}
/> />
<View className='shrink ml-2'> <View className='shrink ml-2'>
<Text className='font-bold mb-1'>Jellyseerr</Text> <Text className='font-bold mb-1'>Seerr</Text>
<Text className='shrink text-xs'> <Text className='shrink text-xs'>
{t("home.intro.jellyseerr_feature_description")} {t("home.intro.jellyseerr_feature_description")}
</Text> </Text>

View File

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

View File

@@ -0,0 +1,45 @@
import { useAtom, useAtomValue } from "jotai";
import type React from "react";
import { useEffect } from "react";
import { Platform } from "react-native";
import { SaveAccountModal } from "@/components/SaveAccountModal";
import {
pendingAccountSaveAtom,
useJellyfin,
userAtom,
} from "@/providers/JellyfinProvider";
/**
* Post-login save-account prompt. Login flows (password or Quick Connect)
* only flag the intent via pendingAccountSaveAtom; the protection picker
* shows here, AFTER the session is authorized — the login screen itself
* unmounts as soon as the user is set, so it can't host the modal.
*/
export const PendingAccountSaveModal: React.FC = () => {
const [pending, setPending] = useAtom(pendingAccountSaveAtom);
const user = useAtomValue(userAtom);
const { saveCurrentAccount } = useJellyfin();
// A logout before answering drops the intent — it must not resurface on
// the next (possibly different) login.
useEffect(() => {
if (!user && pending) setPending(null);
}, [user, pending, setPending]);
if (Platform.isTV) return null;
return (
<SaveAccountModal
visible={!!pending && !!user}
username={user?.Name ?? ""}
onClose={() => setPending(null)}
onSave={(securityType, pinCode) => {
const serverName = pending?.serverName;
setPending(null);
saveCurrentAccount({ securityType, pinCode, serverName }).catch(
(error) => console.warn("Failed to save account:", error),
);
}}
/>
);
};

View File

@@ -1,13 +1,9 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { BottomSheetScrollView } from "@gorhom/bottom-sheet"; import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
import React, { useEffect } from "react"; import React, { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Platform, StyleSheet, TouchableOpacity, View } from "react-native"; import { Platform, StyleSheet, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import {
MeasuredTriggerHost,
OptionGroupCard,
ToggleSwitch,
} from "@/components/common/dropdownShared";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { useGlobalModal } from "@/providers/GlobalModalProvider"; import { useGlobalModal } from "@/providers/GlobalModalProvider";
@@ -15,7 +11,7 @@ import { useGlobalModal } from "@/providers/GlobalModalProvider";
// A static top-level import evaluates requireNativeModule('ExpoUI') at module // A static top-level import evaluates requireNativeModule('ExpoUI') at module
// load and crashes the entire route tree on tvOS (expo-router requires every // load and crashes the entire route tree on tvOS (expo-router requires every
// route file). Load it lazily and only off-TV; TV never renders these. // route file). Load it lazily and only off-TV; TV never renders these.
const { Button, Menu } = Platform.isTV const { Button, Host, Menu } = Platform.isTV
? ({} as typeof import("@expo/ui/swift-ui")) ? ({} as typeof import("@expo/ui/swift-ui"))
: require("@expo/ui/swift-ui"); : require("@expo/ui/swift-ui");
const { disabled } = Platform.isTV const { disabled } = Platform.isTV
@@ -71,6 +67,16 @@ interface PlatformDropdownProps {
}; };
} }
const ToggleSwitch: React.FC<{ value: boolean }> = ({ value }) => (
<View
className={`w-12 h-7 rounded-full ${value ? "bg-purple-600" : "bg-neutral-600"} flex-row items-center`}
>
<View
className={`w-5 h-5 rounded-full bg-white shadow-md transform transition-transform ${value ? "translate-x-6" : "translate-x-1"}`}
/>
</View>
);
const OptionItem: React.FC<{ option: Option; isLast?: boolean }> = ({ const OptionItem: React.FC<{ option: Option; isLast?: boolean }> = ({
option, option,
isLast, isLast,
@@ -110,15 +116,28 @@ const OptionItem: React.FC<{ option: Option; isLast?: boolean }> = ({
}; };
const OptionGroupComponent: React.FC<{ group: OptionGroup }> = ({ group }) => ( const OptionGroupComponent: React.FC<{ group: OptionGroup }> = ({ group }) => (
<OptionGroupCard title={group.title}> <View className='mb-6'>
{group.options.map((option, index) => ( {group.title && (
<OptionItem <Text className='text-lg font-semibold mb-3 text-neutral-300'>
key={index} {group.title}
option={option} </Text>
isLast={index === group.options.length - 1} )}
/> <View
))} style={{
</OptionGroupCard> borderRadius: 12,
overflow: "hidden",
}}
className='bg-neutral-800 rounded-xl overflow-hidden'
>
{group.options.map((option, index) => (
<OptionItem
key={index}
option={option}
isLast={index === group.options.length - 1}
/>
))}
</View>
</View>
); );
const BottomSheetContent: React.FC<{ const BottomSheetContent: React.FC<{
@@ -191,6 +210,7 @@ const PlatformDropdownComponent = ({
expoUIConfig, expoUIConfig,
bottomSheetConfig, bottomSheetConfig,
}: PlatformDropdownProps) => { }: PlatformDropdownProps) => {
const { t } = useTranslation();
const { showModal, hideModal, isVisible } = useGlobalModal(); const { showModal, hideModal, isVisible } = useGlobalModal();
// Handle controlled open state for Android // Handle controlled open state for Android
@@ -223,42 +243,68 @@ const PlatformDropdownComponent = ({
}, [isVisible, controlledOpen, controlledOnOpenChange]); }, [isVisible, controlledOpen, controlledOnOpenChange]);
if (Platform.OS === "ios" && !Platform.isTV) { if (Platform.OS === "ios" && !Platform.isTV) {
// @expo/ui's <Host> can't size to content, so an in-flow invisible copy of
// the trigger sizes the wrapper while the Host overlays the real Menu.
return ( return (
<MeasuredTriggerHost <View>
trigger={trigger} <View pointerEvents='none' aria-hidden style={{ opacity: 0 }}>
hostStyle={expoUIConfig?.hostStyle} {trigger}
> </View>
<Menu label={trigger}> <Host style={[StyleSheet.absoluteFill, expoUIConfig?.hostStyle as any]}>
{groups.flatMap((group, groupIndex) => { <Menu label={trigger}>
// Check if this group has radio options {groups.flatMap((group, groupIndex) => {
const radioOptions = group.options.filter( // Check if this group has radio options
(opt) => opt.type === "radio", const radioOptions = group.options.filter(
) as RadioOption[]; (opt) => opt.type === "radio",
const toggleOptions = group.options.filter( ) as RadioOption[];
(opt) => opt.type === "toggle", const toggleOptions = group.options.filter(
) as ToggleOption[]; (opt) => opt.type === "toggle",
const actionOptions = group.options.filter( ) as ToggleOption[];
(opt) => opt.type === "action", const actionOptions = group.options.filter(
) as ActionOption[]; (opt) => opt.type === "action",
) as ActionOption[];
const items = []; const items = [];
// Group radio options under a submenu ONLY if there's a title // Group radio options under a submenu ONLY if there's a title
// Otherwise render as individual buttons // Otherwise render as individual buttons
if (radioOptions.length > 0) { if (radioOptions.length > 0) {
if (group.title) { if (group.title) {
// Use a nested Menu as a submenu for grouped options. This // Use a nested Menu as a submenu for grouped options. This
// reads as "Title: Selected" and expands to the choices on // reads as "Title: Selected" and expands to the choices on
// tap, keeping the nested look while staying a dropdown. // tap, keeping the nested look while staying a dropdown.
// (Menu opens on a single tap and nests cleanly; ContextMenu // (Menu opens on a single tap and nests cleanly; ContextMenu
// would require a long-press and read as a context menu.) // would require a long-press and read as a context menu.)
const selectedOption = radioOptions.find((opt) => opt.selected); const selectedOption = radioOptions.find(
const displayTitle = selectedOption (opt) => opt.selected,
? `${group.title}: ${selectedOption.label}` );
: group.title; const displayTitle = selectedOption
items.push( ? `${group.title}: ${selectedOption.label}`
<Menu key={`submenu-${groupIndex}`} label={displayTitle}> : group.title;
{radioOptions.map((option, optionIndex) => ( items.push(
<Menu key={`submenu-${groupIndex}`} label={displayTitle}>
{radioOptions.map((option, optionIndex) => (
<Button
key={`radio-${groupIndex}-${optionIndex}`}
label={option.label}
systemImage={
option.selected ? "checkmark.circle.fill" : "circle"
}
modifiers={
option.disabled ? [disabled(true)] : undefined
}
onPress={() => {
option.onPress();
onOptionSelect?.(option.value);
}}
/>
))}
</Menu>,
);
} else {
// Render radio options as direct buttons
radioOptions.forEach((option, optionIndex) => {
items.push(
<Button <Button
key={`radio-${groupIndex}-${optionIndex}`} key={`radio-${groupIndex}-${optionIndex}`}
label={option.label} label={option.label}
@@ -272,67 +318,49 @@ const PlatformDropdownComponent = ({
option.onPress(); option.onPress();
onOptionSelect?.(option.value); onOptionSelect?.(option.value);
}} }}
/> />,
))} );
</Menu>, });
); }
} else {
// Render radio options as direct buttons
radioOptions.forEach((option, optionIndex) => {
items.push(
<Button
key={`radio-${groupIndex}-${optionIndex}`}
label={option.label}
systemImage={
option.selected ? "checkmark.circle.fill" : "circle"
}
modifiers={option.disabled ? [disabled(true)] : undefined}
onPress={() => {
option.onPress();
onOptionSelect?.(option.value);
}}
/>,
);
});
} }
}
// Add Buttons for toggle options // Add Buttons for toggle options
toggleOptions.forEach((option, optionIndex) => { toggleOptions.forEach((option, optionIndex) => {
items.push( items.push(
<Button <Button
key={`toggle-${groupIndex}-${optionIndex}`} key={`toggle-${groupIndex}-${optionIndex}`}
label={option.label} label={option.label}
systemImage={ systemImage={
option.value ? "checkmark.circle.fill" : "circle" option.value ? "checkmark.circle.fill" : "circle"
} }
modifiers={option.disabled ? [disabled(true)] : undefined} modifiers={option.disabled ? [disabled(true)] : undefined}
onPress={() => { onPress={() => {
option.onToggle(); option.onToggle();
onOptionSelect?.(option.value); onOptionSelect?.(option.value);
}} }}
/>, />,
); );
}); });
// Add Buttons for action options (no icon) // Add Buttons for action options (no icon)
actionOptions.forEach((option, optionIndex) => { actionOptions.forEach((option, optionIndex) => {
items.push( items.push(
<Button <Button
key={`action-${groupIndex}-${optionIndex}`} key={`action-${groupIndex}-${optionIndex}`}
label={option.label} label={option.label}
modifiers={option.disabled ? [disabled(true)] : undefined} modifiers={option.disabled ? [disabled(true)] : undefined}
onPress={() => { onPress={() => {
option.onPress(); option.onPress();
}} }}
/>, />,
); );
}); });
return items; return items;
})} })}
</Menu> </Menu>
</MeasuredTriggerHost> </Host>
</View>
); );
} }
@@ -354,7 +382,7 @@ const PlatformDropdownComponent = ({
return ( return (
<TouchableOpacity onPress={handlePress} activeOpacity={0.7}> <TouchableOpacity onPress={handlePress} activeOpacity={0.7}>
{trigger || <Text className='text-white'>Open Menu</Text>} {trigger || <Text className='text-white'>{t("common.open_menu")}</Text>}
</TouchableOpacity> </TouchableOpacity>
); );
}; };

View File

@@ -502,8 +502,8 @@ export const PlayButton: React.FC<Props> = ({
return ( return (
<TouchableOpacity <TouchableOpacity
disabled={!item} disabled={!item}
accessibilityLabel='Play button' accessibilityLabel={t("accessibility.play_button")}
accessibilityHint='Tap to play the media' accessibilityHint={t("accessibility.play_hint")}
onPress={onPress} onPress={onPress}
className={"relative flex-1"} className={"relative flex-1"}
> >

View File

@@ -2,6 +2,7 @@ import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useCallback, useEffect } from "react"; import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { TouchableOpacity, View } from "react-native"; import { TouchableOpacity, View } from "react-native";
import Animated, { import Animated, {
Easing, Easing,
@@ -36,6 +37,7 @@ export const PlayButton: React.FC<Props> = ({
colors, colors,
...props ...props
}: Props) => { }: Props) => {
const { t } = useTranslation();
const [globalColorAtom] = useAtom(itemThemeColorAtom); const [globalColorAtom] = useAtom(itemThemeColorAtom);
// Use colors prop if provided, otherwise fallback to global atom // Use colors prop if provided, otherwise fallback to global atom
@@ -168,8 +170,8 @@ export const PlayButton: React.FC<Props> = ({
return ( return (
<TouchableOpacity <TouchableOpacity
accessibilityLabel='Play button' accessibilityLabel={t("accessibility.play_button")}
accessibilityHint='Tap to play the media' accessibilityHint={t("accessibility.play_hint")}
onPress={onPress} onPress={onPress}
className={"relative"} className={"relative"}
{...props} {...props}

View File

@@ -6,6 +6,7 @@ import {
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api"; import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import React, { useState } from "react"; import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { import {
FlatList, FlatList,
Modal, Modal,
@@ -31,6 +32,7 @@ export const PlayInRemoteSessionButton: React.FC<Props> = ({
const [modalVisible, setModalVisible] = useState(false); const [modalVisible, setModalVisible] = useState(false);
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);
const { sessions, isLoading } = useAllSessions({} as useSessionsProps); const { sessions, isLoading } = useAllSessions({} as useSessionsProps);
const { t } = useTranslation();
const handlePlayInSession = async (sessionId: string) => { const handlePlayInSession = async (sessionId: string) => {
if (!api || !item.Id) return; if (!api || !item.Id) return;
@@ -65,7 +67,9 @@ export const PlayInRemoteSessionButton: React.FC<Props> = ({
<View style={styles.centeredView}> <View style={styles.centeredView}>
<View style={styles.modalView}> <View style={styles.modalView}>
<View style={styles.modalHeader}> <View style={styles.modalHeader}>
<Text style={styles.modalTitle}>Select Session</Text> <Text style={styles.modalTitle}>
{t("home.sessions.select_session")}
</Text>
<TouchableOpacity onPress={() => setModalVisible(false)}> <TouchableOpacity onPress={() => setModalVisible(false)}>
<Ionicons name='close' size={24} color='white' /> <Ionicons name='close' size={24} color='white' />
</TouchableOpacity> </TouchableOpacity>
@@ -78,7 +82,7 @@ export const PlayInRemoteSessionButton: React.FC<Props> = ({
</View> </View>
) : !sessions || sessions.length === 0 ? ( ) : !sessions || sessions.length === 0 ? (
<Text style={styles.noSessionsText}> <Text style={styles.noSessionsText}>
No active sessions found {t("home.sessions.no_active_sessions")}
</Text> </Text>
) : ( ) : (
<FlatList <FlatList
@@ -98,7 +102,7 @@ export const PlayInRemoteSessionButton: React.FC<Props> = ({
</Text> </Text>
{session.NowPlayingItem && ( {session.NowPlayingItem && (
<Text style={styles.nowPlaying} numberOfLines={1}> <Text style={styles.nowPlaying} numberOfLines={1}>
Now playing:{" "} {t("home.sessions.now_playing")}{" "}
{session.NowPlayingItem.SeriesName {session.NowPlayingItem.SeriesName
? `${session.NowPlayingItem.SeriesName} :` ? `${session.NowPlayingItem.SeriesName} :`
: ""} : ""}

View File

@@ -40,8 +40,8 @@ export const Ratings: React.FC<Props> = ({ item, ...props }) => {
<Image <Image
source={ source={
item.CriticRating < 60 item.CriticRating < 60
? require("@/assets/images/rotten-tomatoes.png") ? require("@/assets/images/rt_rotten.svg")
: require("@/assets/images/not-rotten-tomatoes.svg") : require("@/assets/images/rt_fresh.svg")
} }
style={{ style={{
width: 14, width: 14,
@@ -89,8 +89,8 @@ export const JellyserrRatings: React.FC<{
className='mr-1' className='mr-1'
source={ source={
data?.criticsRating === "Rotten" data?.criticsRating === "Rotten"
? require("@/utils/jellyseerr/src/assets/rt_rotten.svg") ? require("@/assets/images/rt_rotten.svg")
: require("@/utils/jellyseerr/src/assets/rt_fresh.svg") : require("@/assets/images/rt_fresh.svg")
} }
style={{ style={{
width: 14, width: 14,
@@ -109,8 +109,8 @@ export const JellyserrRatings: React.FC<{
className='mr-1' className='mr-1'
source={ source={
data?.audienceRating === "Spilled" data?.audienceRating === "Spilled"
? require("@/utils/jellyseerr/src/assets/rt_aud_rotten.svg") ? require("@/assets/images/rt_aud_rotten.svg")
: require("@/utils/jellyseerr/src/assets/rt_aud_fresh.svg") : require("@/assets/images/rt_aud_fresh.svg")
} }
style={{ style={{
width: 14, width: 14,
@@ -127,7 +127,7 @@ export const JellyserrRatings: React.FC<{
iconLeft={ iconLeft={
<Image <Image
className='mr-1' className='mr-1'
source={require("@/utils/jellyseerr/src/assets/tmdb_logo.svg")} source={require("@/assets/images/tmdb_logo.svg")}
style={{ style={{
width: 14, width: 14,
height: 14, height: 14,

View File

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

View File

@@ -1,5 +1,6 @@
import type { BottomSheetModal } from "@gorhom/bottom-sheet";
import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models"; import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
import { useMemo, useState } from "react"; import { useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Platform, TouchableOpacity, View } from "react-native"; import { Platform, TouchableOpacity, View } from "react-native";
import { Text } from "./common/Text"; import { Text } from "./common/Text";
@@ -26,7 +27,7 @@ export const TrackSheet: React.FC<Props> = ({
const streams = useMemo( const streams = useMemo(
() => source?.MediaStreams?.filter((x) => x.Type === streamType), () => source?.MediaStreams?.filter((x) => x.Type === streamType),
[source], [source, streamType],
); );
const selectedSteam = useMemo( const selectedSteam = useMemo(
@@ -49,6 +50,7 @@ export const TrackSheet: React.FC<Props> = ({
return streams; return streams;
}, [streams, streamType, noneOption]); }, [streams, streamType, noneOption]);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const sheetModalRef = useRef<BottomSheetModal | null>(null);
if (isTv || (streams && streams.length === 0)) return null; if (isTv || (streams && streams.length === 0)) return null;
@@ -58,18 +60,22 @@ export const TrackSheet: React.FC<Props> = ({
<Text className='opacity-50 mb-1 text-xs'>{title}</Text> <Text className='opacity-50 mb-1 text-xs'>{title}</Text>
<TouchableOpacity <TouchableOpacity
className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between' className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'
onPress={() => setOpen(true)} onPress={() => {
setOpen(true);
sheetModalRef.current?.present();
}}
> >
<Text numberOfLines={1}> <Text numberOfLines={1}>
{selected === -1 && streamType === "Subtitle" {selected === -1 && streamType === "Subtitle"
? t("common.none") ? t("common.none")
: selectedSteam?.DisplayTitle || t("common.select", "Select")} : selectedSteam?.DisplayTitle || t("common.select")}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
<FilterSheet <FilterSheet
open={open} open={open}
setOpen={setOpen} setOpen={setOpen}
modalRef={sheetModalRef}
title={title} title={title}
data={addNoneToSubtitles || []} data={addNoneToSubtitles || []}
values={ values={

View File

@@ -11,6 +11,7 @@ import { useTranslation } from "react-i18next";
import { FlatList, Modal, Pressable, StyleSheet, View } from "react-native"; import { FlatList, Modal, Pressable, StyleSheet, View } from "react-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { Colors } from "@/constants/Colors"; import { Colors } from "@/constants/Colors";
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
import { import {
type ChapterEntry, type ChapterEntry,
chapterStartsMs, chapterStartsMs,
@@ -38,6 +39,7 @@ function ChapterListComponent({
onClose, onClose,
}: ChapterListProps) { }: ChapterListProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const safeArea = useControlsSafeAreaInsets();
const listRef = useRef<FlatList<ChapterEntry>>(null); const listRef = useRef<FlatList<ChapterEntry>>(null);
const entries = useMemo(() => sortedChapters(chapters), [chapters]); const entries = useMemo(() => sortedChapters(chapters), [chapters]);
@@ -74,9 +76,22 @@ function ChapterListComponent({
transparent transparent
animationType='slide' animationType='slide'
onRequestClose={onClose} onRequestClose={onClose}
// iOS defaults <Modal> to portrait-only; without this it rotates the app
// back to portrait when opened from the landscape player. Android ignores it.
supportedOrientations={["portrait", "landscape"]}
> >
<Pressable onPress={onClose} style={styles.backdrop}> <Pressable onPress={onClose} style={styles.backdrop}>
<Pressable onPress={(e) => e.stopPropagation()} style={styles.sheet}> <Pressable
onPress={(e) => e.stopPropagation()}
style={[
styles.sheet,
{
marginLeft: safeArea.left,
marginRight: safeArea.right,
paddingBottom: safeArea.bottom,
},
]}
>
<View style={styles.header}> <View style={styles.header}>
<Text style={styles.title}>{t("chapters.title")}</Text> <Text style={styles.title}>{t("chapters.title")}</Text>
<Pressable <Pressable
@@ -157,14 +172,12 @@ const styles = StyleSheet.create({
backdrop: { backdrop: {
flex: 1, flex: 1,
justifyContent: "flex-end", justifyContent: "flex-end",
backgroundColor: "rgba(0,0,0,0.6)",
}, },
sheet: { sheet: {
backgroundColor: Colors.background, backgroundColor: Colors.background,
borderTopLeftRadius: 16, borderTopLeftRadius: 16,
borderTopRightRadius: 16, borderTopRightRadius: 16,
maxHeight: "70%", maxHeight: "70%",
paddingBottom: 24,
}, },
header: { header: {
flexDirection: "row", flexDirection: "row",

View File

@@ -1,6 +1,6 @@
import { Ionicons } from "@expo/vector-icons"; import { Feather } from "@expo/vector-icons";
import { BlurView, type BlurViewProps } from "expo-blur"; import { BlurView, type BlurViewProps } from "expo-blur";
import { Platform } from "react-native"; import { Keyboard, Platform } from "react-native";
import { Pressable, type PressableProps } from "react-native-gesture-handler"; import { Pressable, type PressableProps } from "react-native-gesture-handler";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
@@ -16,30 +16,37 @@ export const HeaderBackButton: React.FC<Props> = ({
}) => { }) => {
const router = useRouter(); const router = useRouter();
// Dismiss the keyboard before navigating — otherwise it lingers over the
// previous screen (e.g. leaving the Jellyseerr login while typing).
const handleBack = () => {
Keyboard.dismiss();
router.back();
};
if (Platform.OS === "ios") { if (Platform.OS === "ios") {
return ( return (
<Pressable <Pressable
onPress={() => router.back()} onPress={handleBack}
className='flex items-center justify-center w-9 h-9' className='flex items-center justify-center w-9 h-9'
{...pressableProps} {...pressableProps}
> >
<Ionicons name='arrow-back' size={24} color='white' /> <Feather name='chevron-left' size={28} color='white' />
</Pressable> </Pressable>
); );
} }
if (background === "transparent" && Platform.OS !== "android") if (background === "transparent" && Platform.OS !== "android")
return ( return (
<Pressable onPress={() => router.back()} {...pressableProps}> <Pressable onPress={handleBack} {...pressableProps}>
<BlurView <BlurView
{...props} {...props}
intensity={100} intensity={100}
className='overflow-hidden rounded-full p-2' className='overflow-hidden rounded-full p-2'
> >
<Ionicons <Feather
className='drop-shadow-2xl' className='drop-shadow-2xl'
name='arrow-back' name='chevron-left'
size={24} size={28}
color='white' color='white'
/> />
</BlurView> </BlurView>
@@ -48,14 +55,17 @@ export const HeaderBackButton: React.FC<Props> = ({
return ( return (
<Pressable <Pressable
onPress={() => router.back()} onPress={handleBack}
className=' rounded-full p-2' // Match the Settings page back button: chevron flush to the edge with a
// 16px gap before the title (the old `p-2` pushed both arrow and title
// too far right). drop-shadow keeps it readable over images.
style={{ marginRight: 16 }}
{...pressableProps} {...pressableProps}
> >
<Ionicons <Feather
className='drop-shadow-2xl' className='drop-shadow-2xl'
name='arrow-back' name='chevron-left'
size={24} size={28}
color='white' color='white'
/> />
</Pressable> </Pressable>

View File

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

View File

@@ -0,0 +1,40 @@
import type React from "react";
import { Platform, Switch, type SwitchProps, View } from "react-native";
/**
* Settings toggle. Android's native Switch lays out ~40px tall / ~56px wide and
* inflates list rows (iOS renders it ~31px). A plain `transform: scale` is
* visual-only and does NOT shrink the layout box, so we pin the Switch inside a
* FIXED-SIZE box (overflow hidden) and center it:
* - the fixed height caps the row height (compact, uniform rows),
* - the fixed width + centering keep the switch in the exact same spot in the
* on/off states (a non-fixed wrapper let its width fluctuate between states,
* which shifted the switch sideways on toggle).
* iOS renders the switch untouched.
*
* Tunables: BOX_H drives the row height; SCALE shrinks the visual to fit the
* box; keep BOX_W >= scaled visual width to avoid clipping the switch sideways.
*/
const BOX_W = 40;
const BOX_H = 30;
const SCALE = 0.9;
export const SettingSwitch: React.FC<SwitchProps> = (props) => {
if (Platform.OS !== "android") return <Switch {...props} />;
return (
<View
style={{
width: BOX_W,
height: BOX_H,
alignItems: "center",
justifyContent: "center",
overflow: "hidden",
}}
>
<Switch
{...props}
style={[props.style, { transform: [{ scale: SCALE }] }]}
/>
</View>
);
};

View File

@@ -2,6 +2,7 @@ import { useActionSheet } from "@expo/react-native-action-sheet";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useSegments } from "expo-router"; import { useSegments } from "expo-router";
import { type PropsWithChildren, useCallback } from "react"; import { type PropsWithChildren, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { import {
Platform, Platform,
TouchableOpacity, TouchableOpacity,
@@ -149,6 +150,7 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
children, children,
...props ...props
}) => { }) => {
const { t } = useTranslation();
const segments = useSegments(); const segments = useSegments();
const { showActionSheetWithOptions } = useActionSheet(); const { showActionSheetWithOptions } = useActionSheet();
const markAsPlayedStatus = useMarkAsPlayed([item]); const markAsPlayedStatus = useMarkAsPlayed([item]);
@@ -182,11 +184,13 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
return; return;
const options: string[] = [ const options: string[] = [
"Mark as Played", t("common.mark_as_played"),
"Mark as Not Played", t("common.mark_as_not_played"),
isFavorite ? "Unmark as Favorite" : "Mark as Favorite", isFavorite
...(isOffline ? ["Delete Download"] : []), ? t("music.track_options.remove_from_favorites")
"Cancel", : t("music.track_options.add_to_favorites"),
...(isOffline ? [t("home.downloads.delete_download")] : []),
t("common.cancel"),
]; ];
const cancelButtonIndex = options.length - 1; const cancelButtonIndex = options.length - 1;
const destructiveButtonIndex = isOffline const destructiveButtonIndex = isOffline
@@ -219,6 +223,7 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
isOffline, isOffline,
deleteFile, deleteFile,
item.Id, item.Id,
t,
]); ]);
if ( if (

View File

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

View File

@@ -1,142 +0,0 @@
// Shared internals for PlatformDropdown and PlayerSettingsPopover.
// Both components host SwiftUI content (Menu / Popover) inside @expo/ui's
// <Host>, both render an Android bottom-sheet card for the same three core
// option types (radio / toggle / action), and both wear the same wrapper
// boilerplate. This module is the single source of truth for those pieces.
//
// What lives here:
// - useTriggerSize() — measures the RN trigger's intrinsic size
// - MeasuredTriggerHost — pins <Host> to that measured size (workaround
// for @expo/ui SDK 55 sizing behaviour; see notes below)
// - ToggleSwitch — the small purple switch used in the Android sheet
// - OptionGroupCard — the rounded dark card with optional title that
// wraps a group's option rows on Android
//
// What deliberately doesn't live here:
// - The iOS rendering — PlatformDropdown uses a Menu, PlayerSettingsPopover
// uses a hand-styled Popover. Nothing meaningful to share.
// - The Android per-row renderers — PlatformDropdown handles 3 option types,
// PlayerSettingsPopover handles 6 (adds slider/stepper/subgroup). Forcing
// a shared abstraction would couple them. Each owns its own OptionItem.
import React, { useCallback, useState } from "react";
import {
type LayoutChangeEvent,
Platform,
StyleSheet,
View,
} from "react-native";
import { Text } from "@/components/common/Text";
// @expo/ui's SwiftUI native module (ExpoUI) does not exist in tvOS builds.
// A static top-level import evaluates requireNativeModule('ExpoUI') at module
// load and crashes the entire route tree on tvOS. Load it lazily and only
// off-TV; both consumers also gate rendering on Platform.OS === "ios".
const { Host } = Platform.isTV
? ({} as typeof import("@expo/ui/swift-ui"))
: require("@expo/ui/swift-ui");
type TriggerSize = { width: number; height: number };
/**
* Measures and remembers the intrinsic size of a RN trigger view so the
* surrounding <Host> can be pinned to a concrete box.
*
* Returns `[size, handleLayout]` — pass `handleLayout` to a hidden,
* absolutely-positioned mirror of the trigger and use `size` as the
* wrapper's `style` once measured.
*/
export function useTriggerSize(): [
TriggerSize | null,
(e: LayoutChangeEvent) => void,
] {
const [size, setSize] = useState<TriggerSize | null>(null);
const onLayout = useCallback((e: LayoutChangeEvent) => {
const { width, height } = e.nativeEvent.layout;
setSize((prev) =>
prev && prev.width === width && prev.height === height
? prev
: { width, height },
);
}, []);
return [size, onLayout];
}
interface MeasuredTriggerHostProps {
trigger: React.ReactNode;
hostStyle?: any;
children: React.ReactNode;
}
/**
* Pins @expo/ui's <Host> to the trigger's measured size.
*
* @expo/ui's <Host> (SDK 55) fills its parent and reports its own size via
* `setStyleSize`, so it can't size itself to content. If the wrapper has no
* size, the Host's `flex: 1` height depends on the parent while the parent
* depends on the Host — a circular dependency that collapses to 0 for any
* dropdown nested more than one level deep (so only the first, shallowest
* dropdown on screen stays visible).
*
* Giving the wrapper the measured trigger size breaks the cycle; the Host
* then fills a concrete box.
*/
export const MeasuredTriggerHost: React.FC<MeasuredTriggerHostProps> = ({
trigger,
hostStyle,
children,
}) => {
const [size, handleMeasure] = useTriggerSize();
return (
<View style={size ?? { opacity: 0 }}>
{/* Hidden measurer: lays the trigger out off-flow to capture its
intrinsic size. Absolutely positioned WITHOUT right/bottom so it
sizes to the trigger's content rather than to its parent. */}
<View
style={{ position: "absolute", top: 0, left: 0, opacity: 0 }}
pointerEvents='none'
aria-hidden
onLayout={handleMeasure}
>
{trigger}
</View>
<Host style={[StyleSheet.absoluteFill, hostStyle as any]}>
{children}
</Host>
</View>
);
};
/** Small pill switch used by Android sheet rows. */
export const ToggleSwitch: React.FC<{ value: boolean }> = ({ value }) => (
<View
className={`w-12 h-7 rounded-full ${value ? "bg-purple-600" : "bg-neutral-600"} flex-row items-center`}
>
<View
className={`w-5 h-5 rounded-full bg-white shadow-md transform transition-transform ${value ? "translate-x-6" : "translate-x-1"}`}
/>
</View>
);
/**
* Rounded dark card with an optional title above it. Wraps a group's option
* rows in the Android bottom sheet.
*/
export const OptionGroupCard: React.FC<{
title?: string;
children: React.ReactNode;
}> = ({ title, children }) => (
<View className='mb-6'>
{title && (
<Text className='text-lg font-semibold mb-3 text-neutral-300'>
{title}
</Text>
)}
<View
style={{ borderRadius: 12, overflow: "hidden" }}
className='bg-neutral-800 rounded-xl overflow-hidden'
>
{children}
</View>
</View>
);

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