Compare commits

..

48 Commits

Author SHA1 Message Date
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 / 🚑 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 / 🔍 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
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 / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (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
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
211 changed files with 3652 additions and 9462 deletions

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,11 @@ 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 using?
options: options:
- 0.47.1 - 0.54.1
- 0.30.2 - 0.51.0
- older - Older
- TestFlight/Development build - TestFlight/Development build
validations: validations:
required: true required: true
@@ -90,9 +90,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 +104,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

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

54
.github/renovate.json vendored
View File

@@ -44,22 +44,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

@@ -11,6 +11,15 @@ 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]'))
@@ -33,7 +42,7 @@ jobs:
swap-storage: false swap-storage: false
- name: 📥 Checkout code - name: 📥 Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
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
@@ -116,7 +125,7 @@ jobs:
swap-storage: false swap-storage: false
- name: 📥 Checkout code - name: 📥 Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
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
@@ -187,7 +196,7 @@ jobs:
steps: steps:
- name: 📥 Checkout code - name: 📥 Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
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
@@ -219,10 +228,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 +240,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
@@ -252,7 +263,7 @@ jobs:
steps: steps:
- name: 📥 Checkout code - name: 📥 Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
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
@@ -284,7 +295,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:
@@ -312,7 +323,7 @@ jobs:
steps: steps:
- name: 📥 Checkout code - name: 📥 Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
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
@@ -344,10 +355,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 +367,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
@@ -380,7 +391,7 @@ jobs:
steps: steps:
- name: 📥 Checkout code - name: 📥 Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
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
@@ -412,7 +423,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:

View File

@@ -19,7 +19,7 @@ jobs:
steps: steps:
- name: 📥 Checkout repository - name: 📥 Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
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

View File

@@ -24,16 +24,16 @@ jobs:
steps: steps:
- name: 📥 Checkout repository - name: 📥 Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- 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

@@ -17,7 +17,7 @@ jobs:
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

@@ -1,51 +1,51 @@
name: 🌐 Translation Sync name: 🌐 Translation Sync
on: on:
push: push:
branches: [develop] branches: [develop]
paths: paths:
- "translations/**" - "translations/**"
- "crowdin.yml" - "crowdin.yml"
- "i18n.ts" - "i18n.ts"
- ".github/workflows/crowdin.yml" - ".github/workflows/crowdin.yml"
# Run weekly to pull new translations # Run weekly to pull new translations
schedule: schedule:
- cron: "0 2 * * 1" # Every Monday at 2 AM UTC - cron: "0 2 * * 1" # Every Monday at 2 AM UTC
workflow_dispatch: workflow_dispatch:
permissions: permissions:
contents: write contents: write
pull-requests: write pull-requests: write
jobs: jobs:
sync-translations: sync-translations:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: 📥 Checkout Repository - name: 📥 Checkout Repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
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@8868a33591d21088edfc398968173a3b98d51706 # v2.16.2
with: with:
upload_sources: true upload_sources: true
upload_translations: true upload_translations: true
download_translations: true download_translations: true
localization_branch_name: I10n_crowdin_translations localization_branch_name: I10n_crowdin_translations
create_pull_request: true create_pull_request: true
pull_request_title: "feat: New Crowdin Translations" pull_request_title: "feat: New Crowdin Translations"
pull_request_body: "New Crowdin translations by [Crowdin GH Action](https://github.com/crowdin/github-action)" pull_request_body: "New Crowdin translations by [Crowdin GH Action](https://github.com/crowdin/github-action)"
pull_request_base_branch_name: "develop" pull_request_base_branch_name: "develop"
pull_request_labels: "🌐 translation" pull_request_labels: "🌐 translation"
# Quality control options # Quality control options
skip_untranslated_strings: false skip_untranslated_strings: false
skip_untranslated_files: false skip_untranslated_files: false
export_only_approved: false export_only_approved: false
# Commit customization # Commit customization
commit_message: "feat(i18n): update translations from Crowdin" commit_message: "feat(i18n): update translations from Crowdin"
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }} CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }} CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}

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

@@ -0,0 +1,38 @@
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-24.04
permissions:
issues: write
contents: read
steps:
- name: 📥 Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
- 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

@@ -51,7 +51,7 @@ jobs:
contents: read contents: read
steps: steps:
- name: Checkout Repository - name: Checkout Repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
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
@@ -69,7 +69,7 @@ jobs:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- name: 🛒 Checkout repository - name: 🛒 Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with: with:
ref: ${{ github.event.pull_request.head.sha || github.sha }} ref: ${{ github.event.pull_request.head.sha || github.sha }}
submodules: recursive submodules: recursive
@@ -97,10 +97,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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with: with:
ref: ${{ github.event.pull_request.head.sha || github.sha }} ref: ${{ github.event.pull_request.head.sha || github.sha }}
submodules: recursive submodules: recursive

View File

@@ -63,7 +63,7 @@ jobs:
steps: steps:
- name: 📥 Checkout code - name: 📥 Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with: with:
fetch-depth: 0 fetch-depth: 0
submodules: recursive submodules: recursive
@@ -88,7 +88,7 @@ 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 }}
@@ -182,7 +182,7 @@ jobs:
actions: read # required for `gh run download` to list/fetch this run's artifacts actions: read # required for `gh run download` to list/fetch this run's artifacts
steps: steps:
- name: 📥 Checkout code - name: 📥 Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with: with:
fetch-depth: 0 fetch-depth: 0
show-progress: false show-progress: false

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

@@ -0,0 +1,60 @@
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-24.04
permissions:
contents: read
security-events: write # upload SARIF to code scanning
steps:
- name: 📥 Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
# Rotate the DB cache weekly (matches the scheduled scan): cache hits within the week
# instead of a fresh immutable entry per run, still refreshing the DB every week.
- name: 🗓️ Compute weekly Trivy cache key
id: trivy-cache-key
run: echo "value=trivy-db-${{ runner.os }}-$(date -u +%G-%V)" >> "$GITHUB_OUTPUT"
- name: 💾 Cache Trivy vulnerability DB
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.cache/trivy
key: ${{ steps.trivy-cache-key.outputs.value }}
restore-keys: trivy-db-${{ runner.os }}-
- 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

@@ -18,7 +18,7 @@ jobs:
steps: steps:
- name: 📥 Checkout repository - name: 📥 Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: "🟢 Setup Node.js" - name: "🟢 Setup Node.js"
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0

11
.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,10 +66,6 @@ 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/

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

@@ -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

@@ -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

@@ -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({

View File

@@ -71,7 +71,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

@@ -60,7 +60,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

@@ -49,7 +49,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;
@@ -146,7 +160,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 +185,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}
> >
{/* Locked controls show the live admin value and can't be toggled —
local form state would let the switch flip while the write guard
drops the change. */}
<Switch <Switch
value={useForSearch} value={
disabled={!isStreamystatsEnabled} searchLocked
? settings?.searchEngine === "Streamystats"
: useForSearch
}
disabled={!isStreamystatsEnabled || searchLocked}
onValueChange={setUseForSearch} onValueChange={setUseForSearch}
/> />
</ListItem> </ListItem>
@@ -183,52 +204,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 <Switch
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 <Switch
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 <Switch
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 <Switch
value={hideWatchlistsTab} value={
hideWatchlistsTabLocked
? (settings?.hideWatchlistsTab ?? false)
: hideWatchlistsTab
}
onValueChange={setHideWatchlistsTab} onValueChange={setHideWatchlistsTab}
disabled={!isStreamystatsEnabled} disabled={!isStreamystatsEnabled || hideWatchlistsTabLocked}
/> />
</ListItem> </ListItem>
</ListGroup> </ListGroup>

View File

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

View File

@@ -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

@@ -11,8 +11,6 @@ import type {
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 { CastAutoplayWatcher } from "@/components/casting/CastAutoplayWatcher";
import { CastingMiniPlayer } from "@/components/casting/CastingMiniPlayer";
import { Colors } from "@/constants/Colors"; import { Colors } from "@/constants/Colors";
import { useTVHomeBackHandler } from "@/hooks/useTVBackHandler"; import { useTVHomeBackHandler } from "@/hooks/useTVBackHandler";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
@@ -104,8 +102,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
@@ -114,7 +112,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" }),
}} }}
/> />
@@ -125,8 +123,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
@@ -136,13 +134,11 @@ 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" }),
}} }}
/> />
</NativeTabs> </NativeTabs>
<CastingMiniPlayer />
<CastAutoplayWatcher />
<MiniPlayerBar /> <MiniPlayerBar />
<MusicPlaybackEngine /> <MusicPlaybackEngine />
</View> </View>

View File

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

View File

@@ -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

@@ -49,6 +49,7 @@ import { DownloadedItem } from "@/providers/Downloads/types";
import { useInactivity } from "@/providers/InactivityProvider"; import { useInactivity } from "@/providers/InactivityProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { OfflineModeProvider } from "@/providers/OfflineModeProvider"; import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles"; import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings"; import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
@@ -59,10 +60,6 @@ import {
getMpvSubtitleId, getMpvSubtitleId,
} from "@/utils/jellyfin/subtitleUtils"; } from "@/utils/jellyfin/subtitleUtils";
import { writeToLog } from "@/utils/log"; import { writeToLog } from "@/utils/log";
import {
type PlaybackController,
useRegisterPlaybackController,
} from "@/utils/playback/playbackController";
import { msToTicks, ticksToSeconds } from "@/utils/time"; import { msToTicks, ticksToSeconds } from "@/utils/time";
import { generateDeviceProfile } from "../../../utils/profiles/native"; import { generateDeviceProfile } from "../../../utils/profiles/native";
@@ -277,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]);
@@ -319,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;
@@ -391,6 +399,7 @@ export default function DirectPlayerPage() {
item, item,
user?.Id, user?.Id,
downloadedItem, downloadedItem,
offline,
]); ]);
useEffect(() => { useEffect(() => {
@@ -406,25 +415,39 @@ export default function DirectPlayerPage() {
reportPlaybackStart(); reportPlaybackStart();
}, [stream, api, offline]); }, [stream, api, offline]);
const togglePlay = async () => {
lightHapticFeedback();
setIsPlaying(!isPlaying);
if (isPlaying) {
await videoRef.current?.pause();
const progressInfo = currentPlayStateInfo();
if (progressInfo) {
playbackManager.reportPlaybackProgress(progressInfo);
}
} else {
videoRef.current?.play();
const progressInfo = currentPlayStateInfo();
if (!offline && api) {
await getPlaystateApi(api).reportPlaybackStart({
playbackStartInfo: progressInfo,
});
}
}
};
const reportPlaybackStopped = useCallback(async () => { const reportPlaybackStopped = useCallback(async () => {
if (!item?.Id || !stream?.sessionId || offline || !api) return; if (!item?.Id || !stream?.sessionId || offline || !api) return;
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
@@ -442,9 +465,10 @@ export default function DirectPlayerPage() {
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
@@ -479,35 +503,6 @@ export default function DirectPlayerPage() {
isMuted, isMuted,
]); ]);
// Declared after currentPlayStateInfo so the dependency array can reference
// it without hitting the temporal dead zone.
const togglePlay = useCallback(async () => {
lightHapticFeedback();
setIsPlaying(!isPlaying);
if (isPlaying) {
await videoRef.current?.pause();
const progressInfo = currentPlayStateInfo();
if (progressInfo) {
playbackManager.reportPlaybackProgress(progressInfo);
}
} else {
videoRef.current?.play();
const progressInfo = currentPlayStateInfo();
if (!offline && api) {
await getPlaystateApi(api).reportPlaybackStart({
playbackStartInfo: progressInfo,
});
}
}
}, [
lightHapticFeedback,
isPlaying,
currentPlayStateInfo,
playbackManager,
offline,
api,
]);
const lastUrlUpdateTime = useSharedValue(0); const lastUrlUpdateTime = useSharedValue(0);
const wasJustSeeking = useSharedValue(false); const wasJustSeeking = useSharedValue(false);
const URL_UPDATE_INTERVAL = 30000; // Update URL every 30 seconds instead of every second const URL_UPDATE_INTERVAL = 30000; // Update URL every 30 seconds instead of every second
@@ -936,47 +931,6 @@ export default function DirectPlayerPage() {
return (await videoRef.current?.getTechnicalInfo?.()) ?? {}; return (await videoRef.current?.getTechnicalInfo?.()) ?? {};
}, []); }, []);
// App-wide remote control: wrap the player's existing handlers so remote
// commands (e.g. dashboard, WebSocket) route to whatever is playing.
const playbackController = useMemo<PlaybackController>(
() => ({
// togglePlay flips play/pause and reports progress to the server.
playPause: () => {
void togglePlay();
},
pause: () => {
pause();
},
unpause: () => {
play();
},
stop: () => {
stop();
},
// PlaybackController seeks in ms; the player's seek already expects ms.
seek: (positionMs: number) => {
seek(positionMs);
},
// The player screen has no episode-loading path of its own — episode
// navigation is handled inside <Controls> via router replacement — so
// next/previous are honest no-ops here.
next: () => {},
previous: () => {},
// Volume is device-level (react-native-volume-manager); the slider sends
// 0-1 while setVolumeCb expects 0-100.
setVolume: (level: number) => {
void setVolumeCb(level * 100);
},
toggleMute: () => {
void toggleMuteCb();
},
}),
[togglePlay, pause, play, stop, seek, setVolumeCb, toggleMuteCb],
);
// Active for the whole lifetime of the player screen; cleared on unmount.
useRegisterPlaybackController(playbackController, true);
// Determine play method based on stream URL and media source // Determine play method based on stream URL and media source
const playMethod = useMemo< const playMethod = useMemo<
"DirectPlay" | "DirectStream" | "Transcode" | undefined "DirectPlay" | "DirectStream" | "Transcode" | undefined

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

@@ -10,6 +10,7 @@ import * as Device from "expo-device";
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 +85,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,
}), }),
@@ -333,9 +335,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 +535,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

@@ -31,6 +31,7 @@
"expo-brightness": "~56.0.5", "expo-brightness": "~56.0.5",
"expo-build-properties": "~56.0.15", "expo-build-properties": "~56.0.15",
"expo-camera": "~56.0.7", "expo-camera": "~56.0.7",
"expo-clipboard": "~56.0.4",
"expo-constants": "~56.0.16", "expo-constants": "~56.0.16",
"expo-crypto": "~56.0.4", "expo-crypto": "~56.0.4",
"expo-dev-client": "~56.0.16", "expo-dev-client": "~56.0.16",
@@ -108,7 +109,7 @@
"@types/react": "~19.2.10", "@types/react": "~19.2.10",
"@types/react-test-renderer": "19.1.0", "@types/react-test-renderer": "19.1.0",
"cross-env": "10.1.0", "cross-env": "10.1.0",
"expo-doctor": "1.19.7", "expo-doctor": "1.19.9",
"husky": "9.1.7", "husky": "9.1.7",
"lint-staged": "17.0.5", "lint-staged": "17.0.5",
"react-test-renderer": "19.2.3", "react-test-renderer": "19.2.3",
@@ -955,6 +956,8 @@
"expo-camera": ["expo-camera@56.0.7", "", { "dependencies": { "barcode-detector": "^3.0.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-c8z+UheidFintQyP9XLEDP43aK4PS/o9+TFLW0zEOjdqkYCBgoWq6Mw/Ps62kjBeftFY7xrp5ZLITbenNvbTaw=="], "expo-camera": ["expo-camera@56.0.7", "", { "dependencies": { "barcode-detector": "^3.0.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-c8z+UheidFintQyP9XLEDP43aK4PS/o9+TFLW0zEOjdqkYCBgoWq6Mw/Ps62kjBeftFY7xrp5ZLITbenNvbTaw=="],
"expo-clipboard": ["expo-clipboard@56.0.4", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-qb4DYlkiowHYHaUYVT2FN9nk/nI1xShXOUYsI7J9dVpQCOHcGFjCBPX1VAvEW4Ye4/Aagd6IuhOVAq/+scBOiA=="],
"expo-constants": ["expo-constants@56.0.16", "", { "dependencies": { "@expo/env": "~2.3.0" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-6tsiN+gmTUPp/atyA+uY9Tg8VOdXdmb4s/3TVGolfn6A/oCAraw1pcPZX5XllyD+xUguxB6eBSFAT8494hZVMA=="], "expo-constants": ["expo-constants@56.0.16", "", { "dependencies": { "@expo/env": "~2.3.0" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-6tsiN+gmTUPp/atyA+uY9Tg8VOdXdmb4s/3TVGolfn6A/oCAraw1pcPZX5XllyD+xUguxB6eBSFAT8494hZVMA=="],
"expo-crypto": ["expo-crypto@56.0.4", "", { "peerDependencies": { "expo": "*" } }, "sha512-fRNEhoXRXgAWBpe3/hq5X+KXTit3OZqdiAGts1YvNEUHQb+H5591mpPac0Yw+sZg9pXcrjRnzo5AxvZaENpc7g=="], "expo-crypto": ["expo-crypto@56.0.4", "", { "peerDependencies": { "expo": "*" } }, "sha512-fRNEhoXRXgAWBpe3/hq5X+KXTit3OZqdiAGts1YvNEUHQb+H5591mpPac0Yw+sZg9pXcrjRnzo5AxvZaENpc7g=="],
@@ -969,7 +972,7 @@
"expo-device": ["expo-device@56.0.4", "", { "dependencies": { "ua-parser-js": "^0.7.33" }, "peerDependencies": { "expo": "*" } }, "sha512-ucVcGPkvBrl2QHuy7XcYex2Y6BETvJ6TREutZrwLGUDnlvbpKS8KfQoNZOpvkyo5Nmm9RrasYQ0CrXmBHho2mg=="], "expo-device": ["expo-device@56.0.4", "", { "dependencies": { "ua-parser-js": "^0.7.33" }, "peerDependencies": { "expo": "*" } }, "sha512-ucVcGPkvBrl2QHuy7XcYex2Y6BETvJ6TREutZrwLGUDnlvbpKS8KfQoNZOpvkyo5Nmm9RrasYQ0CrXmBHho2mg=="],
"expo-doctor": ["expo-doctor@1.19.7", "", { "bin": { "expo-doctor": "bin/expo-doctor.js" } }, "sha512-pzn7QtCifRlvGIQz8k7kszeYFaI5Yn81WTHlk/20tmd3jwnXxPjlcdyhFSkuRtO2v4a9gA/6aUWVBOosfffj9w=="], "expo-doctor": ["expo-doctor@1.19.9", "", { "bin": { "expo-doctor": "bin/expo-doctor.js" } }, "sha512-SJW5HxEDQ9f5QdFvrUwfbdJZ4HI0EAAxsrJqrHBFjKBum+uSOcEIZPLRibwNQLTHOwTO1TWNLiMlF9sDUBWeYw=="],
"expo-file-system": ["expo-file-system@56.0.7", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-dcKzo8ShPloM7jgfnMcJStgQebhP8owVjCkNI/aX6NMFV1CYB8bxKGMdnzJ3mXk5nfaiW+F/lSKr2UIJ02WAUA=="], "expo-file-system": ["expo-file-system@56.0.7", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-dcKzo8ShPloM7jgfnMcJStgQebhP8owVjCkNI/aX6NMFV1CYB8bxKGMdnzJ3mXk5nfaiW+F/lSKr2UIJ02WAUA=="],
@@ -1599,7 +1602,7 @@
"react-native-text-ticker": ["react-native-text-ticker@1.15.0", "", {}, "sha512-d/uK+PIOhsYMy1r8h825iq/nADiHsabz3WMbRJSnkpQYn+K9aykUAXRRhu8ZbTAzk4CgnUWajJEFxS5ZDygsdg=="], "react-native-text-ticker": ["react-native-text-ticker@1.15.0", "", {}, "sha512-d/uK+PIOhsYMy1r8h825iq/nADiHsabz3WMbRJSnkpQYn+K9aykUAXRRhu8ZbTAzk4CgnUWajJEFxS5ZDygsdg=="],
"react-native-track-player": ["react-native-track-player@github:lovegaoshi/react-native-track-player#33a3ecd", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-windows": "*", "shaka-player": "^4.7.9" }, "optionalPeers": ["react-native-windows", "shaka-player"] }, "lovegaoshi-react-native-track-player-33a3ecd"], "react-native-track-player": ["react-native-track-player@github:lovegaoshi/react-native-track-player#33a3ecd", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-windows": "*", "shaka-player": "^4.7.9" }, "optionalPeers": ["react-native-windows", "shaka-player"] }, "lovegaoshi-react-native-track-player-33a3ecd", "sha512-vfkld2jUj7EPkAjIc/Vbx4Q4MtOOLmYtCYCE2dWJsyLnPqgj1f0xVzBxbeVP7dfT+eSh4KIXfdxESXaHgrXIlw=="],
"react-native-udp": ["react-native-udp@4.1.7", "", { "dependencies": { "buffer": "^5.6.0", "events": "^3.1.0" } }, "sha512-NUE3zewu61NCdSsLlj+l0ad6qojcVEZPT4hVG/x6DU9U4iCzwtfZSASh9vm7teAcVzLkdD+cO3411LHshAi/wA=="], "react-native-udp": ["react-native-udp@4.1.7", "", { "dependencies": { "buffer": "^5.6.0", "events": "^3.1.0" } }, "sha512-NUE3zewu61NCdSsLlj+l0ad6qojcVEZPT4hVG/x6DU9U4iCzwtfZSASh9vm7teAcVzLkdD+cO3411LHshAi/wA=="],

128
components/BitRateSheet.tsx Normal file
View File

@@ -0,0 +1,128 @@
import type { BottomSheetModal } from "@gorhom/bottom-sheet";
import { useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform, TouchableOpacity, View } from "react-native";
import { Text } from "./common/Text";
import { FilterSheet } from "./filters/FilterSheet";
export type Bitrate = {
key: string;
value: number | undefined;
};
export const BITRATES: Bitrate[] = [
{
key: "Max",
value: undefined,
},
{
key: "8 Mb/s",
value: 8000000,
height: 1080,
},
{
key: "4 Mb/s",
value: 4000000,
height: 1080,
},
{
key: "2 Mb/s",
value: 2000000,
},
{
key: "1 Mb/s",
value: 1000000,
},
{
key: "500 Kb/s",
value: 500000,
},
{
key: "250 Kb/s",
value: 250000,
},
].sort(
(a, b) =>
(b.value || Number.POSITIVE_INFINITY) -
(a.value || Number.POSITIVE_INFINITY),
);
interface Props extends React.ComponentProps<typeof View> {
onChange: (value: Bitrate) => void;
selected?: Bitrate | null;
inverted?: boolean | null;
}
export const BitrateSheet: React.FC<Props> = ({
onChange,
selected,
inverted,
...props
}) => {
const isTv = Platform.isTV;
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const sheetModalRef = useRef<BottomSheetModal | null>(null);
const sorted = useMemo(() => {
if (inverted)
return BITRATES.slice().sort(
(a, b) =>
(a.value || Number.POSITIVE_INFINITY) -
(b.value || Number.POSITIVE_INFINITY),
);
return BITRATES.slice().sort(
(a, b) =>
(b.value || Number.POSITIVE_INFINITY) -
(a.value || Number.POSITIVE_INFINITY),
);
}, [inverted]);
if (isTv) return null;
return (
<View
className='flex shrink'
style={{
minWidth: 60,
maxWidth: 200,
}}
>
<View className='flex flex-col' {...props}>
<Text className='opacity-50 mb-1 text-xs'>
{t("item_card.quality")}
</Text>
<TouchableOpacity
className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'
onPress={() => {
setOpen(true);
sheetModalRef.current?.present();
}}
>
<Text numberOfLines={1}>
{BITRATES.find((b) => b.value === selected?.value)?.key}
</Text>
</TouchableOpacity>
</View>
<FilterSheet
open={open}
setOpen={setOpen}
modalRef={sheetModalRef}
title={t("item_card.quality")}
data={sorted}
values={selected ? [selected] : []}
multiple={false}
searchFilter={(item, query) => {
const label = (item as any).key || "";
return label.toLowerCase().includes(query.toLowerCase());
}}
renderItemLabel={(item) => <Text>{(item as any).key || ""}</Text>}
set={(vals) => {
const chosen = vals[0] as Bitrate | undefined;
if (chosen) onChange(chosen);
}}
/>
</View>
);
};

View File

@@ -10,31 +10,36 @@ export type Bitrate = {
}; };
export const BITRATES: Bitrate[] = [ export const BITRATES: Bitrate[] = [
{ key: "Max", value: undefined }, {
{ key: "200 Mb/s", value: 200000000 }, key: "Max",
{ key: "180 Mb/s", value: 180000000 }, value: undefined,
{ key: "140 Mb/s", value: 140000000 }, },
{ key: "120 Mb/s", value: 120000000 }, {
{ key: "110 Mb/s", value: 110000000 }, key: "8 Mb/s",
{ key: "100 Mb/s", value: 100000000 }, value: 8000000,
{ key: "90 Mb/s", value: 90000000 }, height: 1080,
{ key: "80 Mb/s", value: 80000000 }, },
{ key: "70 Mb/s", value: 70000000 }, {
{ key: "60 Mb/s", value: 60000000 }, key: "4 Mb/s",
{ key: "50 Mb/s", value: 50000000 }, value: 4000000,
{ key: "40 Mb/s", value: 40000000 }, height: 1080,
{ key: "30 Mb/s", value: 30000000 }, },
{ key: "20 Mb/s", value: 20000000 }, {
{ key: "15 Mb/s", value: 15000000 }, key: "2 Mb/s",
{ key: "10 Mb/s", value: 10000000 }, value: 2000000,
{ key: "8 Mb/s", value: 8000000 }, },
{ key: "5 Mb/s", value: 5000000 }, {
{ key: "4 Mb/s", value: 4000000 }, key: "1 Mb/s",
{ key: "3 Mb/s", value: 3000000 }, value: 1000000,
{ key: "2 Mb/s", value: 2000000 }, },
{ key: "1 Mb/s", value: 1000000 }, {
{ key: "720 Kb/s", value: 720000 }, key: "500 Kb/s",
{ key: "420 Kb/s", value: 420000 }, value: 500000,
},
{
key: "250 Kb/s",
value: 250000,
},
].sort( ].sort(
(a, b) => (a, b) =>
(b.value || Number.POSITIVE_INFINITY) - (b.value || Number.POSITIVE_INFINITY) -

View File

@@ -1,23 +1,15 @@
import { Feather } from "@expo/vector-icons"; import { Feather } from "@expo/vector-icons";
import type { PlaybackProgressInfo } from "@jellyfin/sdk/lib/generated-client/models"; import { useCallback, useEffect } from "react";
import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api";
import { router } from "expo-router";
import { useAtomValue } from "jotai";
import { useCallback, useEffect, useRef, useState } from "react";
import { Platform } from "react-native"; import { Platform } from "react-native";
import { Pressable } from "react-native-gesture-handler"; import { Pressable } from "react-native-gesture-handler";
import GoogleCast, { import GoogleCast, {
CastButton, CastButton,
CastContext, CastContext,
CastState,
useCastDevice, useCastDevice,
useCastState,
useDevices, useDevices,
useMediaStatus, useMediaStatus,
useRemoteMediaClient, useRemoteMediaClient,
} from "react-native-google-cast"; } from "react-native-google-cast";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { ChromecastConnectionMenu } from "./chromecast/ChromecastConnectionMenu";
import { RoundButton } from "./RoundButton"; import { RoundButton } from "./RoundButton";
export function Chromecast({ export function Chromecast({
@@ -26,136 +18,23 @@ export function Chromecast({
background = "transparent", background = "transparent",
...props ...props
}) { }) {
// Hooks called for their side effects (keep Chromecast session active) const client = useRemoteMediaClient();
useRemoteMediaClient(); const castDevice = useCastDevice();
useCastDevice(); const devices = useDevices();
const castState = useCastState(); const sessionManager = GoogleCast.getSessionManager();
useDevices();
const discoveryManager = GoogleCast.getDiscoveryManager(); const discoveryManager = GoogleCast.getDiscoveryManager();
const mediaStatus = useMediaStatus(); const mediaStatus = useMediaStatus();
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
// Connection menu state
const [showConnectionMenu, setShowConnectionMenu] = useState(false);
const isConnected = castState === CastState.CONNECTED;
const lastReportedProgressRef = useRef(0);
const lastReportedPlayerStateRef = useRef<string | null>(null);
const playSessionIdRef = useRef<string | null>(null);
const lastContentIdRef = useRef<string | null>(null);
const discoveryAttempts = useRef(0);
const maxDiscoveryAttempts = 3;
// Enhanced discovery with retry mechanism - runs once on mount
useEffect(() => { useEffect(() => {
let isSubscribed = true; (async () => {
let retryTimeout: NodeJS.Timeout;
const startDiscoveryWithRetry = async () => {
if (!discoveryManager) { if (!discoveryManager) {
console.warn("DiscoveryManager is not initialized");
return; return;
} }
try { await discoveryManager.startDiscovery();
// Stop any existing discovery first })();
try { }, [client, devices, castDevice, sessionManager, discoveryManager]);
await discoveryManager.stopDiscovery();
} catch {
// Ignore errors when stopping
}
// Start fresh discovery
await discoveryManager.startDiscovery();
discoveryAttempts.current = 0; // Reset on success
} catch (error) {
console.error("[Chromecast Discovery] Failed:", error);
// Retry on error
if (discoveryAttempts.current < maxDiscoveryAttempts && isSubscribed) {
discoveryAttempts.current++;
retryTimeout = setTimeout(() => {
if (isSubscribed) {
startDiscoveryWithRetry();
}
}, 2000);
}
}
};
startDiscoveryWithRetry();
return () => {
isSubscribed = false;
if (retryTimeout) {
clearTimeout(retryTimeout);
}
};
}, [discoveryManager]); // Only re-run if discoveryManager changes
// Report video progress to Jellyfin server
useEffect(() => {
if (!api || !user?.Id || !mediaStatus?.mediaInfo?.contentId) {
return;
}
const streamPosition = mediaStatus.streamPosition || 0;
const playerState = mediaStatus.playerState || null;
// Report every 10 seconds OR immediately when playerState changes (pause/resume)
const positionChanged =
Math.abs(streamPosition - lastReportedProgressRef.current) >= 10;
const stateChanged = playerState !== lastReportedPlayerStateRef.current;
if (!positionChanged && !stateChanged) {
return;
}
const contentId = mediaStatus.mediaInfo.contentId;
// Generate a new PlaySessionId when the content changes
if (contentId !== lastContentIdRef.current) {
// Use Math.random()-based UUID v4 (React Native lacks global crypto)
playSessionIdRef.current = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(
/[xy]/g,
(c) => {
const r = (Math.random() * 16) | 0;
const v = c === "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
},
);
lastContentIdRef.current = contentId;
}
const positionTicks = Math.floor(streamPosition * 10000000);
const isPaused = mediaStatus.playerState === "paused";
const streamUrl = mediaStatus.mediaInfo.contentUrl || "";
const isTranscoding = /m3u8/i.test(streamUrl);
const progressInfo: PlaybackProgressInfo = {
ItemId: contentId,
PositionTicks: positionTicks,
IsPaused: isPaused,
PlayMethod: isTranscoding ? "Transcode" : "DirectStream",
PlaySessionId: playSessionIdRef.current || contentId,
};
getPlaystateApi(api)
.reportPlaybackProgress({ playbackProgressInfo: progressInfo })
.then(() => {
lastReportedProgressRef.current = streamPosition;
lastReportedPlayerStateRef.current = playerState;
})
.catch((error) => {
console.error("Failed to report Chromecast progress:", error);
});
}, [
api,
user?.Id,
mediaStatus?.streamPosition,
mediaStatus?.mediaInfo?.contentId,
mediaStatus?.playerState,
mediaStatus?.mediaInfo?.contentUrl,
]);
// Android requires the cast button to be present for startDiscovery to work // Android requires the cast button to be present for startDiscovery to work
const AndroidCastButton = useCallback( const AndroidCastButton = useCallback(
@@ -164,92 +43,50 @@ export function Chromecast({
[Platform.OS], [Platform.OS],
); );
// Handle press - show connection menu when connected, otherwise show cast dialog
const handlePress = useCallback(() => {
if (isConnected) {
if (mediaStatus?.currentItemId) {
// Media is playing - navigate to full player
router.push("/casting-player");
} else {
// Connected but no media - show connection menu
setShowConnectionMenu(true);
}
} else {
// Not connected - show cast dialog
CastContext.showCastDialog();
}
}, [isConnected, mediaStatus?.currentItemId]);
// Handle disconnect from Chromecast
const handleDisconnect = useCallback(async () => {
try {
const sessionManager = GoogleCast.getSessionManager();
await sessionManager.endCurrentSession(true);
} catch (error) {
console.error("[Chromecast] Disconnect error:", error);
}
}, []);
if (Platform.OS === "ios") { if (Platform.OS === "ios") {
return ( return (
<> <Pressable
<Pressable className='mr-4' onPress={handlePress} {...props}> className='mr-4'
<AndroidCastButton /> onPress={() => {
<Feather if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
name='cast' else CastContext.showCastDialog();
size={22} }}
color={isConnected ? "#a855f7" : "white"} {...props}
/> >
</Pressable> <AndroidCastButton />
<ChromecastConnectionMenu <Feather name='cast' size={22} color={"white"} />
visible={showConnectionMenu} </Pressable>
onClose={() => setShowConnectionMenu(false)}
onDisconnect={handleDisconnect}
/>
</>
); );
} }
if (background === "transparent") if (background === "transparent")
return ( return (
<> <RoundButton
<RoundButton size='large'
size='large' className='mr-2'
className='mr-2' background={false}
background={false} onPress={() => {
onPress={handlePress} if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
{...props} else CastContext.showCastDialog();
> }}
<AndroidCastButton /> {...props}
<Feather >
name='cast' <AndroidCastButton />
size={22} <Feather name='cast' size={22} color={"white"} />
color={isConnected ? "#a855f7" : "white"} </RoundButton>
/>
</RoundButton>
<ChromecastConnectionMenu
visible={showConnectionMenu}
onClose={() => setShowConnectionMenu(false)}
onDisconnect={handleDisconnect}
/>
</>
); );
return ( return (
<> <RoundButton
<RoundButton size='large' onPress={handlePress} {...props}> size='large'
<AndroidCastButton /> onPress={() => {
<Feather if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
name='cast' else CastContext.showCastDialog();
size={22} }}
color={isConnected ? "#a855f7" : "white"} {...props}
/> >
</RoundButton> <AndroidCastButton />
<ChromecastConnectionMenu <Feather name='cast' size={22} color={"white"} />
visible={showConnectionMenu} </RoundButton>
onClose={() => setShowConnectionMenu(false)}
onDisconnect={handleDisconnect}
/>
</>
); );
} }

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

@@ -6,8 +6,8 @@ import type {
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ActivityIndicator, TouchableOpacity, View } from "react-native"; import { ActivityIndicator, TouchableOpacity, View } from "react-native";
import { BITRATES } from "@/components/BitrateSelector";
import type { ThemeColors } from "@/hooks/useImageColorsReturn"; import type { ThemeColors } from "@/hooks/useImageColorsReturn";
import { BITRATES } from "./BitRateSheet";
import type { SelectedOptions } from "./ItemContent"; import type { SelectedOptions } from "./ItemContent";
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown"; import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";

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,8 @@
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, useState } from "react"; import React, { useEffect } from "react";
import { import { useTranslation } from "react-i18next";
type LayoutChangeEvent, import { Platform, StyleSheet, TouchableOpacity, View } from "react-native";
Platform,
StyleSheet,
TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { useGlobalModal } from "@/providers/GlobalModalProvider"; import { useGlobalModal } from "@/providers/GlobalModalProvider";
@@ -63,7 +58,6 @@ interface PlatformDropdownProps {
open?: boolean; open?: boolean;
onOpenChange?: (open: boolean) => void; onOpenChange?: (open: boolean) => void;
onOptionSelect?: (value?: any) => void; onOptionSelect?: (value?: any) => void;
disabled?: boolean;
expoUIConfig?: { expoUIConfig?: {
hostStyle?: any; hostStyle?: any;
}; };
@@ -214,31 +208,11 @@ const PlatformDropdownComponent = ({
onOpenChange: controlledOnOpenChange, onOpenChange: controlledOnOpenChange,
onOptionSelect, onOptionSelect,
expoUIConfig, expoUIConfig,
// Aliased to avoid shadowing the module-level `disabled` SwiftUI modifier
// (from @expo/ui/swift-ui/modifiers) used by the iOS <Menu> renderer below.
disabled: isDisabled,
bottomSheetConfig, bottomSheetConfig,
}: PlatformDropdownProps) => { }: PlatformDropdownProps) => {
const { t } = useTranslation();
const { showModal, hideModal, isVisible } = useGlobalModal(); const { showModal, hideModal, isVisible } = useGlobalModal();
// @expo/ui's <Host> (SDK 55) fills its available space by default, and
// `matchContents` doesn't help here: it reports the native Menu's size via
// setStyleSize and overrides any explicit size. Instead we measure the
// trigger's intrinsic size in plain RN (off-layout) and pin it on the Host.
const [triggerSize, setTriggerSize] = useState<{
width: number;
height: number;
} | null>(null);
const handleMeasureTrigger = (e: LayoutChangeEvent) => {
const { width, height } = e.nativeEvent.layout;
setTriggerSize((prev) =>
prev && prev.width === width && prev.height === height
? prev
: { width, height },
);
};
// Handle controlled open state for Android // Handle controlled open state for Android
useEffect(() => { useEffect(() => {
if (Platform.OS === "android" && controlledOpen === true) { if (Platform.OS === "android" && controlledOpen === true) {
@@ -269,32 +243,11 @@ const PlatformDropdownComponent = ({
}, [isVisible, controlledOpen, controlledOnOpenChange]); }, [isVisible, controlledOpen, controlledOnOpenChange]);
if (Platform.OS === "ios" && !Platform.isTV) { if (Platform.OS === "ios" && !Platform.isTV) {
if (isDisabled) { // @expo/ui's <Host> can't size to content, so an in-flow invisible copy of
return ( // the trigger sizes the wrapper while the Host overlays the real Menu.
<View style={{ opacity: 0.5 }} pointerEvents='none'>
{trigger || <Text className='text-white'>Open Menu</Text>}
</View>
);
}
// Pin the wrapper to the measured trigger 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 selector nested more than
// one level deep (so only the first, shallowest dropdown stays visible).
// Giving the wrapper the measured size breaks the cycle; the Host then
// fills a concrete box.
return ( return (
<View style={triggerSize ?? { opacity: 0 }}> <View>
{/* Hidden measurer: lays the trigger out off-flow to capture its <View pointerEvents='none' aria-hidden style={{ opacity: 0 }}>
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={handleMeasureTrigger}
>
{trigger} {trigger}
</View> </View>
<Host style={[StyleSheet.absoluteFill, expoUIConfig?.hostStyle as any]}> <Host style={[StyleSheet.absoluteFill, expoUIConfig?.hostStyle as any]}>
@@ -428,14 +381,8 @@ const PlatformDropdownComponent = ({
}; };
return ( return (
<TouchableOpacity <TouchableOpacity onPress={handlePress} activeOpacity={0.7}>
onPress={handlePress} {trigger || <Text className='text-white'>{t("common.open_menu")}</Text>}
activeOpacity={0.7}
disabled={isDisabled}
>
<View style={isDisabled ? { opacity: 0.5 } : undefined}>
{trigger || <Text className='text-white'>Open Menu</Text>}
</View>
</TouchableOpacity> </TouchableOpacity>
); );
}; };

View File

@@ -8,9 +8,8 @@ import { useTranslation } from "react-i18next";
import { Alert, Platform, TouchableOpacity, View } from "react-native"; import { Alert, Platform, TouchableOpacity, View } from "react-native";
import CastContext, { import CastContext, {
CastButton, CastButton,
MediaPlayerState, MediaStreamType,
PlayServicesState, PlayServicesState,
useCastDevice,
useMediaStatus, useMediaStatus,
useRemoteMediaClient, useRemoteMediaClient,
} from "react-native-google-cast"; } from "react-native-google-cast";
@@ -33,8 +32,12 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useOfflineMode } from "@/providers/OfflineModeProvider"; import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor"; import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { loadCastMedia } from "@/utils/casting/castLoad"; import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { runtimeTicksToMinutes } from "@/utils/time"; import { runtimeTicksToMinutes } from "@/utils/time";
import { chromecast } from "../utils/profiles/chromecast";
import { chromecasth265 } from "../utils/profiles/chromecasth265";
import { Button } from "./Button"; import { Button } from "./Button";
import { Text } from "./common/Text"; import { Text } from "./common/Text";
import type { SelectedOptions } from "./ItemContent"; import type { SelectedOptions } from "./ItemContent";
@@ -56,7 +59,6 @@ export const PlayButton: React.FC<Props> = ({
const isOffline = useOfflineMode(); const isOffline = useOfflineMode();
const { showActionSheetWithOptions } = useActionSheet(); const { showActionSheetWithOptions } = useActionSheet();
const client = useRemoteMediaClient(); const client = useRemoteMediaClient();
const castDevice = useCastDevice();
const mediaStatus = useMediaStatus(); const mediaStatus = useMediaStatus();
const { t } = useTranslation(); const { t } = useTranslation();
const { showModal, hideModal } = useGlobalModal(); const { showModal, hideModal } = useGlobalModal();
@@ -109,11 +111,7 @@ export const PlayButton: React.FC<Props> = ({
return; return;
} }
const options = [ const options = ["Chromecast", "Device", "Cancel"];
t("casting_player.chromecast"),
t("casting_player.device"),
t("casting_player.cancel"),
];
const cancelButtonIndex = 2; const cancelButtonIndex = 2;
showActionSheetWithOptions( showActionSheetWithOptions(
{ {
@@ -122,14 +120,9 @@ export const PlayButton: React.FC<Props> = ({
}, },
async (selectedIndex: number | undefined) => { async (selectedIndex: number | undefined) => {
if (!api) return; if (!api) return;
// Compare item IDs AND check if media is actually playing (not stopped/idle) const currentTitle = mediaStatus?.mediaInfo?.metadata?.title;
const currentContentId = mediaStatus?.mediaInfo?.contentId;
const isMediaActive =
mediaStatus?.playerState === MediaPlayerState.PLAYING ||
mediaStatus?.playerState === MediaPlayerState.PAUSED ||
mediaStatus?.playerState === MediaPlayerState.BUFFERING;
const isOpeningCurrentlyPlayingMedia = const isOpeningCurrentlyPlayingMedia =
isMediaActive && currentContentId && currentContentId === item?.Id; currentTitle && currentTitle === item?.Name;
switch (selectedIndex) { switch (selectedIndex) {
case 0: case 0:
@@ -137,8 +130,30 @@ export const PlayButton: React.FC<Props> = ({
if (state && state !== PlayServicesState.SUCCESS) { if (state && state !== PlayServicesState.SUCCESS) {
CastContext.showPlayServicesErrorDialog(state); CastContext.showPlayServicesErrorDialog(state);
} else { } else {
if (!api || !user?.Id || !item?.Id) { // Check if user wants H265 for Chromecast
console.warn("Missing parameters for Chromecast streaming"); const enableH265 = settings.enableH265ForChromecast;
// Validate required parameters before calling getStreamUrl
if (!api) {
console.warn("API not available for Chromecast streaming");
Alert.alert(
t("player.client_error"),
t("player.missing_parameters"),
);
return;
}
if (!user?.Id) {
console.warn(
"User not authenticated for Chromecast streaming",
);
Alert.alert(
t("player.client_error"),
t("player.missing_parameters"),
);
return;
}
if (!item?.Id) {
console.warn("Item not available for Chromecast streaming");
Alert.alert( Alert.alert(
t("player.client_error"), t("player.client_error"),
t("player.missing_parameters"), t("player.missing_parameters"),
@@ -146,37 +161,110 @@ export const PlayButton: React.FC<Props> = ({
return; return;
} }
const startPositionMs = // Get a new URL with the Chromecast device profile
(item.UserData?.PlaybackPositionTicks ?? 0) / 10000; try {
const data = await getStreamUrl({
const result = await loadCastMedia({ api,
client, item,
device: castDevice, deviceProfile: enableH265 ? chromecasth265 : chromecast,
api, startTimeTicks: item?.UserData?.PlaybackPositionTicks ?? 0,
item, userId: user.Id,
userId: user.Id,
profileMode: settings.chromecastProfile,
maxBitrateSetting: settings.chromecastMaxBitrate,
options: {
audioStreamIndex: selectedOptions.audioIndex, audioStreamIndex: selectedOptions.audioIndex,
maxStreamingBitrate: selectedOptions.bitrate?.value,
mediaSourceId: selectedOptions.mediaSource?.Id,
subtitleStreamIndex: selectedOptions.subtitleIndex, subtitleStreamIndex: selectedOptions.subtitleIndex,
maxBitrate: selectedOptions.bitrate?.value, });
mediaSourceId: selectedOptions.mediaSource?.Id ?? undefined,
startPositionMs,
},
});
if (!result.ok) { console.log("URL: ", data?.url, enableH265);
console.error("[PlayButton] cast load failed:", result.error);
Alert.alert(
t("player.client_error"),
t("player.could_not_create_stream_for_chromecast"),
);
return;
}
if (!isOpeningCurrentlyPlayingMedia) { if (!data?.url) {
router.push("/casting-player"); console.warn("No URL returned from getStreamUrl", data);
Alert.alert(
t("player.client_error"),
t("player.could_not_create_stream_for_chromecast"),
);
return;
}
// Calculate start time in seconds from playback position
const startTimeSeconds =
(item?.UserData?.PlaybackPositionTicks ?? 0) / 10000000;
// Calculate stream duration in seconds from runtime
const streamDurationSeconds = item.RunTimeTicks
? item.RunTimeTicks / 10000000
: undefined;
client
.loadMedia({
mediaInfo: {
contentId: item.Id,
contentUrl: data?.url,
contentType: "video/mp4",
streamType: MediaStreamType.BUFFERED,
streamDuration: streamDurationSeconds,
metadata:
item.Type === "Episode"
? {
type: "tvShow",
title: item.Name || "",
episodeNumber: item.IndexNumber || 0,
seasonNumber: item.ParentIndexNumber || 0,
seriesTitle: item.SeriesName || "",
images: [
{
url: getParentBackdropImageUrl({
api,
item,
quality: 90,
width: 2000,
})!,
},
],
}
: item.Type === "Movie"
? {
type: "movie",
title: item.Name || "",
subtitle: item.Overview || "",
images: [
{
url: getPrimaryImageUrl({
api,
item,
quality: 90,
width: 2000,
})!,
},
],
}
: {
type: "generic",
title: item.Name || "",
subtitle: item.Overview || "",
images: [
{
url: getPrimaryImageUrl({
api,
item,
quality: 90,
width: 2000,
})!,
},
],
},
},
startTime: startTimeSeconds,
})
.then(() => {
// state is already set when reopening current media, so skip it here.
if (isOpeningCurrentlyPlayingMedia) {
return;
}
CastContext.showExpandedControls();
});
} catch (e) {
console.log(e);
} }
} }
}); });
@@ -192,7 +280,6 @@ export const PlayButton: React.FC<Props> = ({
}, [ }, [
item, item,
client, client,
castDevice,
settings, settings,
api, api,
user, user,
@@ -415,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";
@@ -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

@@ -1,12 +0,0 @@
/**
* Always-mounted host for the Chromecast autoplay watcher. Renders nothing —
* it exists only to run `useCastAutoplay` for the app's lifetime, so autoplay
* fires regardless of which screen is open.
*/
import { useCastAutoplay } from "@/hooks/useCastAutoplay";
export function CastAutoplayWatcher() {
useCastAutoplay();
return null;
}

View File

@@ -1,358 +0,0 @@
/**
* Unified Casting Mini Player
* Works with all supported casting protocols
*/
import { Ionicons } from "@expo/vector-icons";
import { Image } from "expo-image";
import { router } from "expo-router";
import { useAtomValue } from "jotai";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { Pressable, View } from "react-native";
import { Slider } from "react-native-awesome-slider";
import {
MediaPlayerState,
useCastDevice,
useMediaStatus,
useRemoteMediaClient,
} from "react-native-google-cast";
import Animated, {
SlideInDown,
SlideOutDown,
useSharedValue,
} from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { CastTrickplayBubble } from "@/components/casting/player/CastTrickplayBubble";
import { Text } from "@/components/common/Text";
import { useCastPlayerItem } from "@/hooks/useCastPlayerItem";
import { useTrickplay } from "@/hooks/useTrickplay";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { formatTime, getPosterUrl } from "@/utils/casting/helpers";
import { CASTING_CONSTANTS } from "@/utils/casting/types";
import { msToTicks, ticksToSeconds } from "@/utils/time";
export const CastingMiniPlayer: React.FC = () => {
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const insets = useSafeAreaInsets();
const castDevice = useCastDevice();
const mediaStatus = useMediaStatus();
const remoteMediaClient = useRemoteMediaClient();
const { currentItem } = useCastPlayerItem({ api, user, mediaStatus });
// Trickplay support - pass currentItem as BaseItemDto or null
const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } = useTrickplay(
currentItem || null,
);
const [trickplayTime, setTrickplayTime] = useState({
hours: 0,
minutes: 0,
seconds: 0,
});
const isScrubbing = useRef(false);
// Slider shared values
const sliderProgress = useSharedValue(0);
const sliderMin = useSharedValue(0);
const sliderMax = useSharedValue(100);
// Live progress state that updates every second when playing
const [liveProgress, setLiveProgress] = useState(
mediaStatus?.streamPosition || 0,
);
// Track baseline for elapsed-time computation
const baselinePositionRef = useRef(mediaStatus?.streamPosition || 0);
const baselineTimestampRef = useRef(Date.now());
// Sync live progress with mediaStatus and poll every second when playing
useEffect(() => {
// Resync baseline whenever mediaStatus reports a new position
if (mediaStatus?.streamPosition !== undefined) {
baselinePositionRef.current = mediaStatus.streamPosition;
baselineTimestampRef.current = Date.now();
setLiveProgress(mediaStatus.streamPosition);
}
// Update based on elapsed real time when playing
const interval = setInterval(() => {
if (mediaStatus?.playerState === MediaPlayerState.PLAYING) {
const elapsed =
((Date.now() - baselineTimestampRef.current) *
(mediaStatus.playbackRate || 1)) /
1000;
setLiveProgress(baselinePositionRef.current + elapsed);
} else if (mediaStatus?.streamPosition !== undefined) {
// Sync with actual position when paused/buffering
baselinePositionRef.current = mediaStatus.streamPosition;
baselineTimestampRef.current = Date.now();
setLiveProgress(mediaStatus.streamPosition);
}
}, 1000);
return () => clearInterval(interval);
}, [
mediaStatus?.playerState,
mediaStatus?.streamPosition,
mediaStatus?.playbackRate,
]);
const progress = liveProgress * 1000; // Convert to ms
const duration = (mediaStatus?.mediaInfo?.streamDuration || 0) * 1000;
const isPlaying = mediaStatus?.playerState === MediaPlayerState.PLAYING;
// Update slider max value when duration changes
useEffect(() => {
if (duration > 0) {
sliderMax.value = duration;
}
}, [duration, sliderMax]);
// Sync slider progress with live progress (when not scrubbing)
useEffect(() => {
if (!isScrubbing.current && progress >= 0) {
sliderProgress.value = progress;
}
}, [progress, sliderProgress]);
// For episodes, use series poster; for other content, use item poster
const posterUrl = useMemo(() => {
if (!api?.basePath || !currentItem) return null;
if (
currentItem.Type === "Episode" &&
currentItem.SeriesId &&
currentItem.ParentIndexNumber !== undefined &&
currentItem.SeasonId
) {
// Build series poster URL using SeriesId and series-level image tag
const imageTag = currentItem.SeriesPrimaryImageTag || "";
const tagParam = imageTag ? `&tag=${imageTag}` : "";
return `${api.basePath}/Items/${currentItem.SeriesId}/Images/Primary?fillHeight=120&fillWidth=80&quality=96${tagParam}`;
}
// For non-episodes, use item's own poster
return getPosterUrl(
api.basePath,
currentItem.Id,
currentItem.ImageTags?.Primary,
80,
120,
);
}, [api?.basePath, currentItem]);
// Hide mini player when:
// - No cast device connected
// - No media info (currentItem)
// - No media status
// - Media is stopped (IDLE state)
// - Media is unknown state
const playerState = mediaStatus?.playerState;
const isMediaStopped = playerState === MediaPlayerState.IDLE;
if (!castDevice || !currentItem || !mediaStatus || isMediaStopped) {
return null;
}
const protocolColor = "#a855f7"; // Streamyfin purple
const TAB_BAR_HEIGHT = 80; // Standard tab bar height
const handlePress = () => {
router.push("/casting-player");
};
const handleTogglePlayPause = () => {
if (isPlaying) {
remoteMediaClient?.pause()?.catch((error: unknown) => {
console.error("[CastingMiniPlayer] Pause error:", error);
});
} else {
remoteMediaClient?.play()?.catch((error: unknown) => {
console.error("[CastingMiniPlayer] Play error:", error);
});
}
};
return (
<Animated.View
entering={SlideInDown.duration(CASTING_CONSTANTS.ANIMATION_DURATION)}
exiting={SlideOutDown.duration(CASTING_CONSTANTS.ANIMATION_DURATION)}
style={{
position: "absolute",
bottom: TAB_BAR_HEIGHT + insets.bottom,
left: 0,
right: 0,
backgroundColor: "#1a1a1a",
borderTopWidth: 1,
borderTopColor: "#333",
zIndex: 100,
}}
>
{/* Interactive progress slider with trickplay */}
<View style={{ paddingHorizontal: 8, paddingTop: 4 }}>
<Slider
style={{ width: "100%", height: 20 }}
progress={sliderProgress}
minimumValue={sliderMin}
maximumValue={sliderMax}
theme={{
maximumTrackTintColor: "#333",
minimumTrackTintColor: protocolColor,
bubbleBackgroundColor: protocolColor,
bubbleTextColor: "#fff",
}}
onSlidingStart={() => {
isScrubbing.current = true;
}}
onValueChange={(value) => {
// Calculate trickplay preview
const progressInTicks = msToTicks(value);
calculateTrickplayUrl(progressInTicks);
// Update time display for trickplay bubble
const progressInSeconds = Math.floor(
ticksToSeconds(progressInTicks),
);
const hours = Math.floor(progressInSeconds / 3600);
const minutes = Math.floor((progressInSeconds % 3600) / 60);
const seconds = progressInSeconds % 60;
setTrickplayTime({ hours, minutes, seconds });
}}
onSlidingComplete={(value) => {
isScrubbing.current = false;
// Seek to the position (value is in milliseconds, convert to seconds)
const positionSeconds = value / 1000;
if (remoteMediaClient && duration > 0) {
remoteMediaClient
.seek({ position: positionSeconds })
.catch((error) => {
console.error("[Mini Player] Seek error:", error);
});
}
}}
renderBubble={() => (
<CastTrickplayBubble
trickPlayUrl={trickPlayUrl}
trickplayInfo={trickplayInfo}
trickplayTime={trickplayTime}
tileWidth={190}
/>
)}
bubbleMaxWidth={190}
bubbleWidth={190}
bubbleTranslateY={-20}
sliderHeight={3}
thumbWidth={14}
panHitSlop={{ top: 20, bottom: 20, left: 5, right: 5 }}
/>
</View>
<Pressable onPress={handlePress}>
{/* Content */}
<View
style={{
flexDirection: "row",
alignItems: "center",
padding: 12,
paddingTop: 6,
gap: 12,
}}
>
{/* Poster */}
{posterUrl && (
<Image
source={{ uri: posterUrl }}
style={{
width: 40,
height: 60,
borderRadius: 4,
}}
contentFit='cover'
/>
)}
{/* Info */}
<View style={{ flex: 1 }}>
<Text
style={{
color: "white",
fontSize: 14,
fontWeight: "600",
}}
numberOfLines={1}
>
{currentItem.Name}
</Text>
{currentItem.SeriesName && (
<Text
style={{
color: "#999",
fontSize: 12,
}}
numberOfLines={1}
>
{currentItem.SeriesName}
</Text>
)}
<View
style={{
flexDirection: "row",
alignItems: "center",
gap: 8,
marginTop: 2,
}}
>
<Ionicons name='tv' size={12} color={protocolColor} />
<Text
style={{
color: protocolColor,
fontSize: 11,
}}
numberOfLines={1}
>
{castDevice.friendlyName || "Chromecast"}
</Text>
<Text
style={{
color: "#666",
fontSize: 11,
}}
>
{formatTime(progress)} / {formatTime(duration)}
</Text>
</View>
</View>
{/* Stop button */}
<Pressable
onPress={(e) => {
e.stopPropagation();
remoteMediaClient?.stop()?.catch((error: unknown) => {
console.error("[CastingMiniPlayer] Stop error:", error);
});
}}
style={{ padding: 8 }}
>
<Ionicons name='stop' size={24} color='white' />
</Pressable>
{/* Play/Pause button */}
<Pressable
onPress={(e) => {
e.stopPropagation();
handleTogglePlayPause();
}}
style={{ padding: 8 }}
>
<Ionicons
name={isPlaying ? "pause" : "play"}
size={28}
color='white'
/>
</Pressable>
</View>
</Pressable>
</Animated.View>
);
};

View File

@@ -1,175 +0,0 @@
/**
* Casting Player Episode Controls
* Fixed control row: episode list, previous, next, stop.
* Episode-specific buttons (list / previous / next) are conditional;
* Stop is always rendered so movies still get a Stop button.
*/
import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import type { ImperativeRouter } from "expo-router";
import { useTranslation } from "react-i18next";
import { Pressable, View } from "react-native";
import type { RemoteMediaClient } from "react-native-google-cast";
import { Text } from "@/components/common/Text";
interface CastPlayerEpisodeControlsProps {
/** Bottom safe-area inset, used to offset the fixed control row. */
insetBottom: number;
/** Id of the currently playing episode. */
currentItemId: BaseItemDto["Id"];
/** Full episode list for the series. */
episodes: BaseItemDto[];
/** Next episode in the list, or null if none. */
nextEpisode: BaseItemDto | null;
/** Remote media client, or null when no session. */
remoteMediaClient: RemoteMediaClient | null;
/** Open the episode list modal. */
onPressEpisodes: () => void;
/** Whether the current item exposes chapter markers. */
hasChapters: boolean;
/** Open the chapter list modal. */
onPressChapters: () => void;
/** Load a different episode on the Chromecast. */
loadEpisode: (episode: BaseItemDto) => Promise<void>;
/** Expo Router instance for navigation on stop. */
router: ImperativeRouter;
}
export function CastPlayerEpisodeControls({
insetBottom,
currentItemId,
episodes,
nextEpisode,
remoteMediaClient,
onPressEpisodes,
hasChapters,
onPressChapters,
loadEpisode,
router,
}: CastPlayerEpisodeControlsProps) {
const { t } = useTranslation();
const hasEpisodeList = episodes.length > 0;
const hasPrevious = episodes.findIndex((ep) => ep.Id === currentItemId) > 0;
const hasNext = nextEpisode != null;
// Count of buttons actually rendered (Stop is always rendered).
const buttonCount =
1 +
(hasEpisodeList ? 1 : 0) +
(hasChapters ? 1 : 0) +
(hasPrevious ? 1 : 0) +
(hasNext ? 1 : 0);
// When Stop is the only button (movies), render it full-width with a label.
const isLoneStop = buttonCount === 1;
// Each button stretches evenly only when the row holds more than one;
// a lone Stop button keeps its intrinsic size and stays centered.
const buttonStyle = {
...(buttonCount > 1 ? { flex: 1 } : {}),
backgroundColor: "#1a1a1a",
padding: 12,
borderRadius: 12,
flexDirection: "row" as const,
justifyContent: "center" as const,
alignItems: "center" as const,
};
return (
<View
style={{
position: "absolute",
bottom: insetBottom + 200,
left: 0,
right: 0,
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
gap: 16,
paddingHorizontal: 20,
}}
>
{/* Episodes button - only rendered when an episode list exists (not for movies) */}
{hasEpisodeList && (
<Pressable onPress={onPressEpisodes} style={buttonStyle}>
<Ionicons name='list' size={22} color='white' />
</Pressable>
)}
{/* Chapter list button - rendered for both episodes and movies when chapters exist */}
{hasChapters && (
<Pressable onPress={onPressChapters} style={buttonStyle}>
<Ionicons name='bookmarks' size={22} color='white' />
</Pressable>
)}
{/* Previous episode button - only rendered when a previous episode exists */}
{hasPrevious && (
<Pressable
onPress={async () => {
const currentIndex = episodes.findIndex(
(ep) => ep.Id === currentItemId,
);
if (currentIndex > 0) {
await loadEpisode(episodes[currentIndex - 1]);
}
}}
style={buttonStyle}
>
<Ionicons name='play-skip-back' size={22} color='white' />
</Pressable>
)}
{/* Next episode button - only rendered when a next episode exists */}
{hasNext && (
<Pressable
onPress={async () => {
if (nextEpisode) {
await loadEpisode(nextEpisode);
}
}}
style={buttonStyle}
>
<Ionicons name='play-skip-forward' size={22} color='white' />
</Pressable>
)}
{/* Stop playback button - always rendered; stops media but stays connected to Chromecast */}
<Pressable
onPress={async () => {
try {
// Stop the current media playback (don't disconnect from Chromecast)
if (remoteMediaClient) {
await remoteMediaClient.stop();
}
// Navigate back/close the player (mini player will disappear since no media is playing)
if (router.canGoBack()) {
router.back();
} else {
router.replace("/(auth)/(tabs)/(home)/");
}
} catch (error) {
console.error("[Casting Player] Error stopping playback:", error);
// Navigate anyway
if (router.canGoBack()) {
router.back();
} else {
router.replace("/(auth)/(tabs)/(home)/");
}
}
}}
style={[buttonStyle, isLoneStop && { flex: 1, gap: 8 }]}
>
<Ionicons name='stop-circle' size={22} color='white' />
{isLoneStop && (
<Text style={{ color: "white", fontSize: 15, fontWeight: "600" }}>
{t("casting_player.stop")}
</Text>
)}
</Pressable>
</View>
);
}

View File

@@ -1,94 +0,0 @@
/**
* Casting Player Header
* Fixed top bar: dismiss button, connection indicator, settings button.
*/
import { Ionicons } from "@expo/vector-icons";
import type { TFunction } from "i18next";
import { Pressable, View } from "react-native";
import { Text } from "@/components/common/Text";
interface CastPlayerHeaderProps {
/** Top safe-area inset, used to offset the fixed header. */
insetTop: number;
/** Streamyfin protocol accent color. */
protocolColor: string;
/** Friendly name of the connected cast device, or null. */
currentDevice: string | null;
/** Translation function. */
t: TFunction;
/** Dismiss the casting player modal. */
onDismiss: () => void;
/** Open the device sheet (connection indicator press). */
onPressConnectionIndicator: () => void;
/** Open the settings menu. */
onPressSettings: () => void;
}
export function CastPlayerHeader({
insetTop,
protocolColor,
currentDevice,
t,
onDismiss,
onPressConnectionIndicator,
onPressSettings,
}: CastPlayerHeaderProps) {
return (
<View
style={{
position: "absolute",
top: insetTop + 8,
left: 20,
right: 20,
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
zIndex: 100,
}}
>
<Pressable onPress={onDismiss} style={{ padding: 8, marginLeft: -8 }}>
<Ionicons name='chevron-down' size={32} color='white' />
</Pressable>
{/* Connection indicator */}
<Pressable
onPress={onPressConnectionIndicator}
style={{
flexDirection: "row",
alignItems: "center",
gap: 6,
paddingHorizontal: 12,
paddingVertical: 6,
backgroundColor: "#1a1a1a",
borderRadius: 16,
}}
>
<View
style={{
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: protocolColor,
}}
/>
<Text
style={{
color: protocolColor,
fontSize: 14,
fontWeight: "500",
}}
>
{currentDevice || t("casting_player.unknown_device")}
</Text>
</Pressable>
<Pressable
onPress={onPressSettings}
style={{ padding: 8, marginRight: -8 }}
>
<Ionicons name='settings-outline' size={24} color='white' />
</Pressable>
</View>
);
}

View File

@@ -1,176 +0,0 @@
/**
* Casting Player Poster
* Poster image with empty-state fallback, skip intro/credits bar, and buffering overlay.
*/
import { Ionicons } from "@expo/vector-icons";
import { Image } from "expo-image";
import type { TFunction } from "i18next";
import { ActivityIndicator, Pressable, View } from "react-native";
import {
MediaPlayerState,
type MediaStatus,
type RemoteMediaClient,
} from "react-native-google-cast";
import type { useChromecastSegments } from "@/components/chromecast/hooks/useChromecastSegments";
import { Text } from "@/components/common/Text";
type ChromecastSegments = ReturnType<typeof useChromecastSegments>;
interface CastPlayerPosterProps {
/** Poster image URL, or null when unavailable. */
posterUrl: string | null;
/** Whether the cast media is currently buffering. */
isBuffering: boolean;
/** The current playback segment (intro/credits/etc.), or null. */
currentSegment: ChromecastSegments["currentSegment"];
/** Skip the intro segment. */
skipIntro: ChromecastSegments["skipIntro"];
/** Skip the credits segment. */
skipCredits: ChromecastSegments["skipCredits"];
/** Skip the current generic segment. */
skipSegment: ChromecastSegments["skipSegment"];
/** The remote media client, or null when no session. */
remoteMediaClient: RemoteMediaClient | null;
/** Raw Chromecast media status. */
mediaStatus: MediaStatus | null;
/** Theme accent color. */
protocolColor: string;
/** Translation function. */
t: TFunction;
}
export function CastPlayerPoster({
posterUrl,
isBuffering,
currentSegment,
skipIntro,
skipCredits,
skipSegment,
remoteMediaClient,
mediaStatus,
protocolColor,
t,
}: CastPlayerPosterProps) {
return (
<View
style={{
alignItems: "center",
marginBottom: 40,
}}
>
<View
style={{
width: 280,
height: 420,
borderRadius: 12,
overflow: "hidden",
position: "relative",
}}
>
{posterUrl ? (
<Image
source={{ uri: posterUrl }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
/>
) : (
<View
style={{
width: "100%",
height: "100%",
backgroundColor: "#1a1a1a",
justifyContent: "center",
alignItems: "center",
}}
>
<Ionicons name='film-outline' size={64} color='#333' />
</View>
)}
{/* Skip intro/credits bar at bottom of poster */}
{currentSegment && (
<Pressable
onPress={async () => {
if (!remoteMediaClient) return;
try {
const seekFn = async (positionMs: number) => {
if (
mediaStatus?.playerState === MediaPlayerState.PLAYING ||
mediaStatus?.playerState === MediaPlayerState.PAUSED
) {
await remoteMediaClient.seek({
position: positionMs / 1000,
});
}
};
if (currentSegment.type === "intro") {
await skipIntro(seekFn);
} else if (currentSegment.type === "credits") {
await skipCredits(seekFn);
} else {
await skipSegment(seekFn);
}
} catch (error) {
console.error("[Casting Player] Skip error:", error);
}
}}
style={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
backgroundColor: protocolColor,
paddingVertical: 12,
paddingHorizontal: 16,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 8,
}}
>
<Ionicons name='play-skip-forward' size={18} color='white' />
<Text
style={{
color: "white",
fontSize: 14,
fontWeight: "600",
}}
>
{t(
`player.skip_${currentSegment.type === "credits" ? "outro" : currentSegment.type}`,
)}
</Text>
</Pressable>
)}
{/* Buffering overlay */}
{isBuffering && (
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "rgba(0,0,0,0.7)",
justifyContent: "center",
alignItems: "center",
}}
>
<ActivityIndicator size='large' color={protocolColor} />
<Text
style={{
color: "white",
fontSize: 16,
marginTop: 16,
}}
>
{t("casting_player.buffering")}
</Text>
</View>
)}
</View>
</View>
);
}

View File

@@ -1,163 +0,0 @@
/**
* Casting Player Progress Bar
* Progress slider with trickplay preview bubble and current/end time display.
*/
import type { ChapterInfo } from "@jellyfin/sdk/lib/generated-client/models";
import type { TFunction } from "i18next";
import { Text, View } from "react-native";
import { Slider } from "react-native-awesome-slider";
import type { RemoteMediaClient } from "react-native-google-cast";
import type { SharedValue } from "react-native-reanimated";
import { CastTrickplayBubble } from "@/components/casting/player/CastTrickplayBubble";
import { ChapterTicks } from "@/components/chapters/ChapterTicks";
import type { useTrickplay } from "@/hooks/useTrickplay";
import { calculateEndingTime, formatTime } from "@/utils/casting/helpers";
import { chapterMarkers } from "@/utils/chapters";
import { msToTicks, ticksToSeconds } from "@/utils/time";
type TrickplayReturn = ReturnType<typeof useTrickplay>;
interface CastPlayerProgressBarProps {
/** Shared value tracking the slider progress, in milliseconds. */
sliderProgress: SharedValue<number>;
/** Shared value for the slider minimum, in milliseconds. */
sliderMin: SharedValue<number>;
/** Shared value for the slider maximum, in milliseconds. */
sliderMax: SharedValue<number>;
/** Mutable ref flag set true while the user is scrubbing. */
isScrubbing: { current: boolean };
/** Trickplay time display state for the bubble. */
trickplayTime: { hours: number; minutes: number; seconds: number };
/** Updates the trickplay time display state. */
setTrickplayTime: (time: {
hours: number;
minutes: number;
seconds: number;
}) => void;
/** Current trickplay image URL/coordinates, or null. */
trickPlayUrl: TrickplayReturn["trickPlayUrl"];
/** Computes the trickplay URL for a given progress in ticks. */
calculateTrickplayUrl: TrickplayReturn["calculateTrickplayUrl"];
/** Parsed trickplay metadata, or null. */
trickplayInfo: TrickplayReturn["trickplayInfo"];
/** Current playback progress, in seconds. */
progress: number;
/** Total media duration, in seconds. */
duration: number;
/** Remote media client, or null when no session. */
remoteMediaClient: RemoteMediaClient | null;
/** Theme color used for the slider track and bubbles. */
protocolColor: string;
/** Chapter markers for the current item, or null/undefined if none. */
chapters?: ChapterInfo[] | null;
/** Translation function. */
t: TFunction;
}
export function CastPlayerProgressBar({
sliderProgress,
sliderMin,
sliderMax,
isScrubbing,
trickplayTime,
setTrickplayTime,
trickPlayUrl,
calculateTrickplayUrl,
trickplayInfo,
progress,
duration,
remoteMediaClient,
protocolColor,
chapters,
t,
}: CastPlayerProgressBarProps) {
return (
<>
{/* Progress slider with trickplay preview */}
<View style={{ marginTop: 8, height: 40 }}>
<Slider
style={{ width: "100%", height: 40 }}
progress={sliderProgress}
minimumValue={sliderMin}
maximumValue={sliderMax}
theme={{
maximumTrackTintColor: "#333",
minimumTrackTintColor: protocolColor,
bubbleBackgroundColor: protocolColor,
bubbleTextColor: "#fff",
}}
onSlidingStart={() => {
isScrubbing.current = true;
}}
onValueChange={(value) => {
// Calculate trickplay preview
const progressInTicks = msToTicks(value);
calculateTrickplayUrl(progressInTicks);
// Update time display for trickplay bubble
const progressInSeconds = Math.floor(
ticksToSeconds(progressInTicks),
);
const hours = Math.floor(progressInSeconds / 3600);
const minutes = Math.floor((progressInSeconds % 3600) / 60);
const seconds = progressInSeconds % 60;
setTrickplayTime({ hours, minutes, seconds });
}}
onSlidingComplete={(value) => {
isScrubbing.current = false;
// Seek to the position (value is in milliseconds, convert to seconds)
const positionSeconds = value / 1000;
if (remoteMediaClient && duration > 0) {
remoteMediaClient
.seek({ position: positionSeconds })
.catch((error) => {
console.error("[Casting Player] Seek error:", error);
});
}
}}
renderBubble={() => (
<CastTrickplayBubble
trickPlayUrl={trickPlayUrl}
trickplayInfo={trickplayInfo}
trickplayTime={trickplayTime}
tileWidth={220}
/>
)}
bubbleMaxWidth={220}
bubbleWidth={220}
bubbleTranslateY={-20}
sliderHeight={6}
thumbWidth={16}
panHitSlop={{ top: 12, bottom: 12, left: 10, right: 10 }}
/>
<ChapterTicks
markers={chapterMarkers(chapters, duration * 1000)}
height={4}
color='#cccccc'
/>
</View>
{/* Time display */}
<View
style={{
flexDirection: "row",
justifyContent: "space-between",
marginBottom: 24,
}}
>
<Text style={{ color: "#999", fontSize: 13 }}>
{formatTime(progress * 1000)}
</Text>
<Text style={{ color: "#999", fontSize: 13 }}>
{t("casting_player.ending_at", {
time: calculateEndingTime(progress * 1000, duration * 1000),
})}
</Text>
<Text style={{ color: "#999", fontSize: 13 }}>
{formatTime(duration * 1000)}
</Text>
</View>
</>
);
}

View File

@@ -1,72 +0,0 @@
/**
* Casting Player Title Area
* Fixed title bar: item title and optional grey episode/season info.
*/
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import type { TFunction } from "i18next";
import { View } from "react-native";
import { Text } from "@/components/common/Text";
import { truncateTitle } from "@/utils/casting/helpers";
interface CastPlayerTitleProps {
/** Top safe-area inset, used to offset the fixed title area. */
insetTop: number;
/** The currently playing item. */
currentItem: BaseItemDto;
/** Translation function. */
t: TFunction;
}
export function CastPlayerTitle({
insetTop,
currentItem,
t,
}: CastPlayerTitleProps) {
return (
<View
style={{
position: "absolute",
top: insetTop + 50,
left: 0,
right: 0,
zIndex: 95,
alignItems: "center",
backgroundColor: "rgba(0, 0, 0, 0.8)",
paddingVertical: 16,
paddingHorizontal: 20,
}}
>
{/* Title */}
<Text
style={{
color: "white",
fontSize: 20,
fontWeight: "700",
textAlign: "center",
marginBottom: 6,
}}
>
{truncateTitle(currentItem.Name || t("casting_player.unknown"), 50)}
</Text>
{/* Grey episode/season info */}
{currentItem.Type === "Episode" &&
currentItem.ParentIndexNumber !== undefined &&
currentItem.IndexNumber !== undefined && (
<Text
style={{
color: "#999",
fontSize: 15,
textAlign: "center",
}}
>
{t("casting_player.season_episode_format", {
season: currentItem.ParentIndexNumber,
episode: currentItem.IndexNumber,
})}
</Text>
)}
</View>
);
}

View File

@@ -1,122 +0,0 @@
/**
* Casting Player Transport Controls
* Playback transport row: rewind, play/pause, forward.
*/
import { Ionicons } from "@expo/vector-icons";
import { Pressable, View } from "react-native";
import { Text } from "@/components/common/Text";
interface CastPlayerTransportControlsProps {
/** Whether playback is currently playing. */
isPlaying: boolean;
/** Toggle play/pause on the Chromecast. */
togglePlayPause: () => Promise<void>;
/** Skip backward by the given number of seconds. */
skipBackward: (seconds: number) => Promise<void>;
/** Skip forward by the given number of seconds. */
skipForward: (seconds: number) => Promise<void>;
/** Configured rewind skip time in seconds, shown on the rewind button. */
rewindSkipTime: number | null | undefined;
/** Configured forward skip time in seconds, shown on the forward button. */
forwardSkipTime: number | null | undefined;
/** Accent color used for the play/pause button background. */
protocolColor: string;
}
export function CastPlayerTransportControls({
isPlaying,
togglePlayPause,
skipBackward,
skipForward,
rewindSkipTime,
forwardSkipTime,
protocolColor,
}: CastPlayerTransportControlsProps) {
return (
<View
style={{
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
gap: 32,
marginBottom: 24,
}}
>
{/* Rewind (use settings) */}
<Pressable
onPress={() => skipBackward(rewindSkipTime ?? 10)}
style={{
position: "relative",
justifyContent: "center",
alignItems: "center",
}}
>
<Ionicons
name='refresh-outline'
size={48}
color='white'
style={{ transform: [{ scaleY: -1 }, { rotate: "180deg" }] }}
/>
{rewindSkipTime != null && (
<Text
style={{
position: "absolute",
color: "white",
fontSize: 15,
fontWeight: "bold",
bottom: 10,
}}
>
{rewindSkipTime}
</Text>
)}
</Pressable>
{/* Play/Pause */}
<Pressable
onPress={togglePlayPause}
style={{
width: 72,
height: 72,
borderRadius: 36,
backgroundColor: protocolColor,
justifyContent: "center",
alignItems: "center",
}}
>
<Ionicons
name={isPlaying ? "pause" : "play"}
size={36}
color='white'
style={{ marginLeft: isPlaying ? 0 : 4 }}
/>
</Pressable>
{/* Forward (use settings) */}
<Pressable
onPress={() => skipForward(forwardSkipTime ?? 10)}
style={{
position: "relative",
justifyContent: "center",
alignItems: "center",
}}
>
<Ionicons name='refresh-outline' size={48} color='white' />
{forwardSkipTime != null && (
<Text
style={{
position: "absolute",
color: "white",
fontSize: 15,
fontWeight: "bold",
bottom: 10,
}}
>
{forwardSkipTime}
</Text>
)}
</Pressable>
</View>
);
}

View File

@@ -1,110 +0,0 @@
/**
* Shared scrub-preview bubble for the casting progress bars.
*
* The slider (`react-native-awesome-slider`) sizes, centres and clamps this
* bubble on the thumb via its `bubbleMaxWidth` / `bubbleWidth` props. This
* component therefore does NO horizontal positioning — it only anchors itself
* vertically (`bottom: 0`, growing upward) so it sits above the progress bar.
*/
import { Image } from "expo-image";
import { View } from "react-native";
import { Text } from "@/components/common/Text";
import type { useTrickplay } from "@/hooks/useTrickplay";
import { formatTrickplayTime } from "@/utils/casting/helpers";
type TrickplayReturn = ReturnType<typeof useTrickplay>;
interface CastTrickplayBubbleProps {
/** Current trickplay image URL/coordinates, or null. */
trickPlayUrl: TrickplayReturn["trickPlayUrl"];
/** Parsed trickplay metadata, or null. */
trickplayInfo: TrickplayReturn["trickplayInfo"];
/** Scrub time to display. */
trickplayTime: { hours: number; minutes: number; seconds: number };
/** Trickplay tile width in px (220 main player, 140 mini-player). */
tileWidth: number;
}
export function CastTrickplayBubble({
trickPlayUrl,
trickplayInfo,
trickplayTime,
tileWidth,
}: CastTrickplayBubbleProps) {
const timeText = (
<Text
style={{
color: "#fff",
fontSize: 13,
fontWeight: "600",
textAlign: "center",
textShadowColor: "rgba(0, 0, 0, 0.85)",
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: 3,
}}
>
{formatTrickplayTime(trickplayTime)}
</Text>
);
// Anchored to the bottom of the slider-positioned container, growing upward,
// and filling the container width (left/right: 0) so it stays centred on the
// thumb. No horizontal maths here — the slider owns horizontal placement.
if (!trickPlayUrl || !trickplayInfo) {
return (
<View
style={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
alignItems: "center",
}}
>
{timeText}
</View>
);
}
const { x, y, url } = trickPlayUrl;
const tileHeight = tileWidth / (trickplayInfo.aspectRatio ?? 1.78);
return (
<View
style={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
alignItems: "center",
gap: 4,
}}
>
{timeText}
<View
style={{
width: tileWidth,
height: tileHeight,
borderRadius: 8,
overflow: "hidden",
backgroundColor: "#1a1a1a",
}}
>
<Image
cachePolicy='memory-disk'
style={{
width: tileWidth * (trickplayInfo.data?.TileWidth ?? 1),
height: tileHeight * (trickplayInfo.data?.TileHeight ?? 1),
transform: [
{ translateX: -x * tileWidth },
{ translateY: -y * tileHeight },
],
}}
source={{ uri: url }}
contentFit='cover'
/>
</View>
</View>
);
}

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]);
@@ -79,7 +81,17 @@ function ChapterListComponent({
supportedOrientations={["portrait", "landscape"]} 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
@@ -160,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,321 +0,0 @@
/**
* Chromecast Connection Menu
* Shows device info, volume control, and disconnect option
* Simple menu for when connected but not actively controlling playback
*/
import { Ionicons } from "@expo/vector-icons";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Modal, Pressable, View } from "react-native";
import { Slider } from "react-native-awesome-slider";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { useCastDevice, useCastSession } from "react-native-google-cast";
import { useSharedValue } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
interface ChromecastConnectionMenuProps {
visible: boolean;
onClose: () => void;
onDisconnect?: () => Promise<void>;
}
export const ChromecastConnectionMenu: React.FC<
ChromecastConnectionMenuProps
> = ({ visible, onClose, onDisconnect }) => {
const insets = useSafeAreaInsets();
const { t } = useTranslation();
const castDevice = useCastDevice();
const castSession = useCastSession();
// Volume state - use refs to avoid triggering re-renders during sliding
const [displayVolume, setDisplayVolume] = useState(50);
const [isMuted, setIsMuted] = useState(false);
const isMutedRef = useRef(false);
const volumeValue = useSharedValue(50);
const minimumValue = useSharedValue(0);
const maximumValue = useSharedValue(100);
const isSliding = useRef(false);
const lastSetVolume = useRef(50);
const protocolColor = "#a855f7";
// Get initial volume and mute state when menu opens
useEffect(() => {
if (!visible || !castSession) return;
// Get initial states
const fetchInitialState = async () => {
try {
const vol = await castSession.getVolume();
if (vol !== undefined) {
const percent = Math.round(vol * 100);
setDisplayVolume(percent);
volumeValue.value = percent;
lastSetVolume.current = percent;
}
const muted = await castSession.isMute();
isMutedRef.current = muted;
setIsMuted(muted);
} catch {
// Ignore errors
}
};
fetchInitialState();
// Poll for external volume changes (physical buttons) - only when not sliding
const interval = setInterval(async () => {
if (isSliding.current) return;
try {
const vol = await castSession.getVolume();
if (vol !== undefined) {
const percent = Math.round(vol * 100);
// Only update if external change detected (not our own change)
if (Math.abs(percent - lastSetVolume.current) > 2) {
setDisplayVolume(percent);
volumeValue.value = percent;
lastSetVolume.current = percent;
}
}
const muted = await castSession.isMute();
if (muted !== isMutedRef.current) {
isMutedRef.current = muted;
setIsMuted(muted);
}
} catch {
// Ignore errors
}
}, 1000); // Poll less frequently
return () => clearInterval(interval);
}, [visible, castSession, volumeValue]);
// Volume change during sliding - update display only, don't call API
const handleVolumeChange = useCallback((value: number) => {
const rounded = Math.round(value);
setDisplayVolume(rounded);
}, []);
// Volume change complete - call API
const handleVolumeComplete = useCallback(
async (value: number) => {
isSliding.current = false;
const rounded = Math.round(value);
setDisplayVolume(rounded);
lastSetVolume.current = rounded;
try {
if (castSession) {
await castSession.setVolume(rounded / 100);
}
} catch (error) {
console.error("[Connection Menu] Volume error:", error);
}
},
[castSession],
);
// Toggle mute
const handleToggleMute = useCallback(async () => {
if (!castSession) return;
try {
const newMute = !isMuted;
await castSession.setMute(newMute);
isMutedRef.current = newMute;
setIsMuted(newMute);
} catch (error) {
console.error("[Connection Menu] Mute error:", error);
}
}, [castSession, isMuted]);
// Disconnect
const handleDisconnect = useCallback(async () => {
try {
if (onDisconnect) {
await onDisconnect();
}
} catch (error) {
console.error("[Connection Menu] Disconnect error:", error);
} finally {
onClose();
}
}, [onDisconnect, onClose]);
return (
<Modal
visible={visible}
transparent={true}
animationType='slide'
onRequestClose={onClose}
>
<GestureHandlerRootView style={{ flex: 1 }}>
<Pressable
style={{
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.9)",
justifyContent: "flex-end",
}}
onPress={onClose}
>
<Pressable
style={{
backgroundColor: "#1a1a1a",
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
paddingBottom: insets.bottom + 16,
}}
onPress={(e) => e.stopPropagation()}
>
{/* Header with device name */}
<View
style={{
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
padding: 16,
borderBottomWidth: 1,
borderBottomColor: "#333",
}}
>
<View
style={{ flexDirection: "row", alignItems: "center", gap: 12 }}
>
<View
style={{
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: protocolColor,
justifyContent: "center",
alignItems: "center",
}}
>
<Ionicons name='tv' size={20} color='white' />
</View>
<View>
<Text
style={{ color: "white", fontSize: 16, fontWeight: "600" }}
>
{castDevice?.friendlyName || t("casting_player.chromecast")}
</Text>
<Text style={{ color: protocolColor, fontSize: 12 }}>
{t("casting_player.connected")}
</Text>
</View>
</View>
<Pressable onPress={onClose} style={{ padding: 8 }}>
<Ionicons name='close' size={24} color='white' />
</Pressable>
</View>
{/* Volume Control */}
<View style={{ padding: 16 }}>
<View
style={{
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 12,
}}
>
<Text style={{ color: "#999", fontSize: 12 }}>
{t("casting_player.volume")}
</Text>
<Text style={{ color: "white", fontSize: 14 }}>
{isMuted ? t("casting_player.muted") : `${displayVolume}%`}
</Text>
</View>
<View
style={{ flexDirection: "row", alignItems: "center", gap: 12 }}
>
<Pressable
onPress={handleToggleMute}
style={{
padding: 8,
borderRadius: 20,
backgroundColor: isMuted ? protocolColor : "transparent",
}}
>
<Ionicons
name={isMuted ? "volume-mute" : "volume-low"}
size={20}
color={isMuted ? "white" : "#999"}
/>
</Pressable>
<View style={{ flex: 1 }}>
<Slider
style={{ width: "100%", height: 40 }}
progress={volumeValue}
minimumValue={minimumValue}
maximumValue={maximumValue}
theme={{
disableMinTrackTintColor: "#333",
maximumTrackTintColor: "#333",
minimumTrackTintColor: isMuted ? "#666" : protocolColor,
bubbleBackgroundColor: protocolColor,
}}
onSlidingStart={() => {
isSliding.current = true;
}}
onValueChange={async (value) => {
volumeValue.value = value;
handleVolumeChange(value);
// Unmute when adjusting volume - use ref to avoid
// stale closure and prevent repeated async calls
if (isMutedRef.current) {
isMutedRef.current = false;
setIsMuted(false);
try {
await castSession?.setMute(false);
} catch (error: unknown) {
console.error(
"[ChromecastConnectionMenu] Failed to unmute:",
error,
);
isMutedRef.current = true;
setIsMuted(true); // Rollback on failure
}
}
}}
onSlidingComplete={handleVolumeComplete}
panHitSlop={{ top: 20, bottom: 20, left: 0, right: 0 }}
/>
</View>
<Ionicons
name='volume-high'
size={20}
color={isMuted ? "#666" : "#999"}
/>
</View>
</View>
{/* Disconnect button */}
<View style={{ paddingHorizontal: 16 }}>
<Pressable
onPress={handleDisconnect}
style={{
backgroundColor: protocolColor,
padding: 14,
borderRadius: 8,
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
gap: 8,
}}
>
<Ionicons name='power' size={20} color='white' />
<Text
style={{ color: "white", fontSize: 14, fontWeight: "500" }}
>
{t("casting_player.disconnect")}
</Text>
</Pressable>
</View>
</Pressable>
</Pressable>
</GestureHandlerRootView>
</Modal>
);
};

View File

@@ -1,348 +0,0 @@
/**
* Chromecast Device Info Sheet
* Shows device details, volume control, and disconnect option
*/
import { Ionicons } from "@expo/vector-icons";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Modal, Pressable, View } from "react-native";
import { Slider } from "react-native-awesome-slider";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { useCastSession } from "react-native-google-cast";
import { useSharedValue } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
interface ChromecastDeviceSheetProps {
visible: boolean;
onClose: () => void;
device: { friendlyName?: string } | null;
onDisconnect: () => Promise<void>;
volume?: number;
onVolumeChange?: (volume: number) => Promise<void>;
}
export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
visible,
onClose,
device,
onDisconnect,
volume = 0.5,
onVolumeChange,
}) => {
const insets = useSafeAreaInsets();
const { t } = useTranslation();
const [isDisconnecting, setIsDisconnecting] = useState(false);
const [displayVolume, setDisplayVolume] = useState(Math.round(volume * 100));
const volumeValue = useSharedValue(volume * 100);
const minimumValue = useSharedValue(0);
const maximumValue = useSharedValue(100);
const castSession = useCastSession();
const volumeDebounceRef = useRef<NodeJS.Timeout | null>(null);
const [isMuted, setIsMuted] = useState(false);
const isSliding = useRef(false);
const lastSetVolume = useRef(Math.round(volume * 100));
// Sync volume slider with prop changes (updates from physical buttons)
// Skip updates while user is actively sliding to avoid overwriting drag
useEffect(() => {
if (isSliding.current) return;
volumeValue.value = volume * 100;
setDisplayVolume(Math.round(volume * 100));
}, [volume, volumeValue]);
// Poll for volume and mute updates when sheet is visible to catch physical button changes
useEffect(() => {
if (!visible || !castSession) return;
// Get initial mute state
castSession
.isMute()
.then(setIsMuted)
.catch(() => {});
// Poll CastSession for device volume and mute state (only when not sliding)
const interval = setInterval(async () => {
if (isSliding.current) return;
try {
const deviceVolume = await castSession.getVolume();
if (deviceVolume !== undefined) {
const volumePercent = Math.round(deviceVolume * 100);
// Only update if external change (physical buttons)
if (Math.abs(volumePercent - lastSetVolume.current) > 2) {
setDisplayVolume(volumePercent);
volumeValue.value = volumePercent;
lastSetVolume.current = volumePercent;
}
}
// Check mute state
const muteState = await castSession.isMute();
setIsMuted(muteState);
} catch {
// Ignore errors - device might be disconnected
}
}, 1000);
return () => clearInterval(interval);
}, [visible, castSession, volumeValue]);
const handleDisconnect = async () => {
setIsDisconnecting(true);
try {
await onDisconnect();
onClose();
} catch (error) {
console.error("Failed to disconnect:", error);
} finally {
setIsDisconnecting(false);
}
};
const handleVolumeComplete = async (value: number) => {
const newVolume = value / 100;
setDisplayVolume(Math.round(value));
try {
// Use CastSession.setVolume for DEVICE volume control
// This works even when no media is playing, unlike setStreamVolume
if (castSession) {
await castSession.setVolume(newVolume);
} else if (onVolumeChange) {
// Fallback to prop method if session not available
await onVolumeChange(newVolume);
}
} catch (error) {
console.error("[Volume] Error setting volume:", error);
}
};
// Debounced volume update during sliding for smooth live feedback
const handleVolumeChange = useCallback(
(value: number) => {
setDisplayVolume(Math.round(value));
// Debounce the API call to avoid too many requests
if (volumeDebounceRef.current) {
clearTimeout(volumeDebounceRef.current);
}
volumeDebounceRef.current = setTimeout(async () => {
const newVolume = value / 100;
try {
if (castSession) {
await castSession.setVolume(newVolume);
}
} catch {
// Ignore errors during sliding
}
}, 150); // 150ms debounce
},
[castSession],
);
// Toggle mute state
const handleToggleMute = useCallback(async () => {
if (!castSession) return;
try {
const newMuteState = !isMuted;
await castSession.setMute(newMuteState);
setIsMuted(newMuteState);
} catch (error) {
console.error("[Volume] Error toggling mute:", error);
}
}, [castSession, isMuted]);
// Cleanup debounce timer on unmount
useEffect(() => {
return () => {
if (volumeDebounceRef.current) {
clearTimeout(volumeDebounceRef.current);
}
};
}, []);
return (
<Modal
visible={visible}
transparent={true}
animationType='slide'
onRequestClose={onClose}
>
<GestureHandlerRootView style={{ flex: 1 }}>
<Pressable
style={{
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.85)",
justifyContent: "flex-end",
}}
onPress={onClose}
>
<Pressable
style={{
backgroundColor: "#1a1a1a",
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
paddingBottom: insets.bottom + 16,
}}
onPress={(e) => e.stopPropagation()}
>
{/* Header */}
<View
style={{
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
padding: 16,
borderBottomWidth: 1,
borderBottomColor: "#333",
}}
>
<View
style={{ flexDirection: "row", alignItems: "center", gap: 12 }}
>
<Ionicons name='tv' size={24} color='#a855f7' />
<Text
style={{ color: "white", fontSize: 18, fontWeight: "600" }}
>
{t("casting_player.chromecast")}
</Text>
</View>
<Pressable onPress={onClose} style={{ padding: 8 }}>
<Ionicons name='close' size={24} color='white' />
</Pressable>
</View>
{/* Device info */}
<View style={{ padding: 16 }}>
<View style={{ marginBottom: 20 }}>
<Text style={{ color: "#999", fontSize: 12, marginBottom: 4 }}>
{t("casting_player.device_name")}
</Text>
<Text
style={{ color: "white", fontSize: 16, fontWeight: "500" }}
>
{device?.friendlyName || t("casting_player.unknown_device")}
</Text>
</View>
{/* Volume control */}
<View style={{ marginBottom: 24 }}>
<View
style={{
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 12,
}}
>
<Text style={{ color: "#999", fontSize: 12 }}>
{t("casting_player.volume")}
</Text>
<Text style={{ color: "white", fontSize: 14 }}>
{isMuted ? t("casting_player.muted") : `${displayVolume}%`}
</Text>
</View>
<View
style={{
flexDirection: "row",
alignItems: "center",
gap: 12,
}}
>
{/* Mute button */}
<Pressable
onPress={handleToggleMute}
style={{
padding: 8,
borderRadius: 20,
backgroundColor: isMuted ? "#a855f7" : "transparent",
}}
>
<Ionicons
name={isMuted ? "volume-mute" : "volume-low"}
size={20}
color={isMuted ? "white" : "#999"}
/>
</Pressable>
<View style={{ flex: 1 }}>
<Slider
style={{ width: "100%", height: 40 }}
progress={volumeValue}
minimumValue={minimumValue}
maximumValue={maximumValue}
theme={{
disableMinTrackTintColor: "#333",
maximumTrackTintColor: "#333",
minimumTrackTintColor: isMuted ? "#666" : "#a855f7",
bubbleBackgroundColor: "#a855f7",
}}
onSlidingStart={async () => {
isSliding.current = true;
// Auto-unmute when user starts adjusting volume
if (isMuted && castSession) {
setIsMuted(false);
try {
await castSession.setMute(false);
} catch (error) {
console.error("[Volume] Failed to unmute:", error);
setIsMuted(true); // Rollback on failure
}
}
}}
onValueChange={(value) => {
volumeValue.value = value;
handleVolumeChange(value);
}}
onSlidingComplete={(value) => {
isSliding.current = false;
lastSetVolume.current = Math.round(value);
handleVolumeComplete(value);
}}
panHitSlop={{ top: 20, bottom: 20, left: 0, right: 0 }}
/>
</View>
<Ionicons
name='volume-high'
size={20}
color={isMuted ? "#666" : "#999"}
/>
</View>
</View>
{/* Disconnect button */}
<Pressable
onPress={handleDisconnect}
disabled={isDisconnecting}
style={{
backgroundColor: "#a855f7",
padding: 16,
borderRadius: 8,
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
gap: 8,
opacity: isDisconnecting ? 0.5 : 1,
}}
>
<Ionicons
name='power'
size={20}
color='white'
style={{ marginTop: 2 }}
/>
<Text
style={{ color: "white", fontSize: 16, fontWeight: "600" }}
>
{isDisconnecting
? t("casting_player.disconnecting")
: t("casting_player.stop_casting")}
</Text>
</Pressable>
</View>
</Pressable>
</Pressable>
</GestureHandlerRootView>
</Modal>
);
};

View File

@@ -1,356 +0,0 @@
/**
* Episode List for Chromecast Player
* Displays list of episodes for TV shows with thumbnails
*/
import { Ionicons } from "@expo/vector-icons";
import type { Api } from "@jellyfin/sdk";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { Image } from "expo-image";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { FlatList, Modal, Pressable, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { truncateTitle } from "@/utils/casting/helpers";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
interface ChromecastEpisodeListProps {
visible: boolean;
onClose: () => void;
currentItem: BaseItemDto | null;
episodes: BaseItemDto[];
onSelectEpisode: (episode: BaseItemDto) => void;
api: Api | null;
}
export const ChromecastEpisodeList: React.FC<ChromecastEpisodeListProps> = ({
visible,
onClose,
currentItem,
episodes,
onSelectEpisode,
api,
}) => {
const insets = useSafeAreaInsets();
const { t } = useTranslation();
const flatListRef = useRef<FlatList>(null);
const [selectedSeason, setSelectedSeason] = useState<number | null>(null);
const scrollRetryCountRef = useRef(0);
const scrollRetryTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const MAX_SCROLL_RETRIES = 3;
// Cleanup pending retry timeout on unmount
useEffect(() => {
return () => {
if (scrollRetryTimeoutRef.current) {
clearTimeout(scrollRetryTimeoutRef.current);
scrollRetryTimeoutRef.current = null;
}
scrollRetryCountRef.current = 0;
};
}, []);
// Get unique seasons from episodes
const seasons = useMemo(() => {
const seasonSet = new Set<number>();
for (const ep of episodes) {
if (ep.ParentIndexNumber !== undefined && ep.ParentIndexNumber !== null) {
seasonSet.add(ep.ParentIndexNumber);
}
}
return Array.from(seasonSet).sort((a, b) => a - b);
}, [episodes]);
// Filter episodes by selected season and exclude virtual episodes
const filteredEpisodes = useMemo(() => {
let eps = episodes;
// Filter by season if selected
if (selectedSeason !== null) {
eps = eps.filter((ep) => ep.ParentIndexNumber === selectedSeason);
}
// Filter out virtual episodes (episodes without actual video files)
// LocationType === "Virtual" means the episode doesn't have a media file
eps = eps.filter((ep) => ep.LocationType !== "Virtual");
return eps;
}, [episodes, selectedSeason]);
// Set initial season to current episode's season
useEffect(() => {
if (currentItem?.ParentIndexNumber !== undefined) {
setSelectedSeason(currentItem.ParentIndexNumber);
}
}, [currentItem]);
useEffect(() => {
// Reset retry counter when visibility or data changes
scrollRetryCountRef.current = 0;
if (scrollRetryTimeoutRef.current) {
clearTimeout(scrollRetryTimeoutRef.current);
}
if (visible && currentItem && filteredEpisodes.length > 0) {
const currentIndex = filteredEpisodes.findIndex(
(ep) => ep.Id === currentItem.Id,
);
if (currentIndex !== -1 && flatListRef.current) {
// Delay to ensure FlatList is rendered
const timeoutId = setTimeout(() => {
flatListRef.current?.scrollToIndex({
index: currentIndex,
animated: true,
viewPosition: 0.5, // Center the item
});
}, 300);
return () => {
clearTimeout(timeoutId);
if (scrollRetryTimeoutRef.current) {
clearTimeout(scrollRetryTimeoutRef.current);
}
};
}
}
}, [visible, currentItem, filteredEpisodes]);
const renderEpisode = ({ item }: { item: BaseItemDto }) => {
const isCurrentEpisode = item.Id === currentItem?.Id;
return (
<Pressable
onPress={() => {
onSelectEpisode(item);
onClose();
}}
style={{
flexDirection: "row",
padding: 12,
// Translucent (not solid) purple so the dark base shows through and
// the row's text — incl. the purple S:E label — stays readable. The
// play-circle icon also marks the current episode.
backgroundColor: isCurrentEpisode
? "rgba(168, 85, 247, 0.25)"
: "transparent",
borderRadius: 8,
marginBottom: 8,
}}
>
{/* Thumbnail */}
<View
style={{
width: 120,
height: 68,
borderRadius: 4,
overflow: "hidden",
backgroundColor: "#1a1a1a",
}}
>
{(() => {
const imageUrl =
api && item.Id ? getPrimaryImageUrl({ api, item }) : null;
if (imageUrl) {
return (
<Image
source={{ uri: imageUrl }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
/>
);
}
return (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
}}
>
<Ionicons name='film-outline' size={32} color='#333' />
</View>
);
})()}
</View>
{/* Episode info */}
<View style={{ flex: 1, marginLeft: 12, justifyContent: "center" }}>
<Text
style={{
color: "white",
fontSize: 14,
fontWeight: "600",
marginBottom: 4,
}}
numberOfLines={1}
>
{item.IndexNumber != null ? `${item.IndexNumber}. ` : ""}
{truncateTitle(item.Name || t("casting_player.unknown"), 30)}
</Text>
{item.Overview && (
<Text
style={{
color: "#999",
fontSize: 12,
marginBottom: 4,
}}
numberOfLines={2}
>
{item.Overview}
</Text>
)}
<View style={{ flexDirection: "row", gap: 8, alignItems: "center" }}>
{item.ParentIndexNumber !== undefined &&
item.IndexNumber !== undefined && (
<Text
style={{ color: "#a855f7", fontSize: 11, fontWeight: "600" }}
>
S{String(item.ParentIndexNumber).padStart(2, "0")}:E
{String(item.IndexNumber).padStart(2, "0")}
</Text>
)}
{item.ProductionYear && (
<Text style={{ color: "#666", fontSize: 11 }}>
{item.ProductionYear}
</Text>
)}
{item.RunTimeTicks && (
<Text style={{ color: "#666", fontSize: 11 }}>
{Math.round(item.RunTimeTicks / 600000000)}{" "}
{t("casting_player.minutes_short")}
</Text>
)}
</View>
</View>
{isCurrentEpisode && (
<View
style={{
justifyContent: "center",
marginLeft: 8,
}}
>
<Ionicons name='play-circle' size={24} color='white' />
</View>
)}
</Pressable>
);
};
return (
<Modal
visible={visible}
transparent={true}
animationType='slide'
onRequestClose={onClose}
>
<Pressable
style={{
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.85)",
}}
onPress={onClose}
>
<Pressable
style={{
flex: 1,
paddingTop: insets.top,
}}
onPress={(e) => e.stopPropagation()}
>
{/* Header */}
<View
style={{
paddingHorizontal: 16,
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: "#333",
}}
>
<View
style={{
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: seasons.length > 1 ? 12 : 0,
}}
>
<Text style={{ color: "white", fontSize: 18, fontWeight: "600" }}>
{t("casting_player.episodes")}
</Text>
<Pressable onPress={onClose} style={{ padding: 8 }}>
<Ionicons name='close' size={24} color='white' />
</Pressable>
</View>
{/* Season selector */}
{seasons.length > 1 && (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ gap: 8 }}
>
{seasons.map((season) => (
<Pressable
key={season}
onPress={() => setSelectedSeason(season)}
style={{
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 20,
backgroundColor:
selectedSeason === season ? "#a855f7" : "#1a1a1a",
}}
>
<Text
style={{
color: "white",
fontSize: 14,
fontWeight: selectedSeason === season ? "600" : "400",
}}
>
{t("casting_player.season", { number: season })}
</Text>
</Pressable>
))}
</ScrollView>
)}
</View>
{/* Episode list */}
<FlatList
ref={flatListRef}
data={filteredEpisodes}
renderItem={renderEpisode}
keyExtractor={(item, index) => item.Id || `episode-${index}`}
contentContainerStyle={{
padding: 16,
paddingBottom: insets.bottom + 16,
}}
showsVerticalScrollIndicator={false}
onScrollToIndexFailed={(info) => {
// Bounded retry for scroll failures
if (
scrollRetryCountRef.current >= MAX_SCROLL_RETRIES ||
info.index >= filteredEpisodes.length
) {
return;
}
scrollRetryCountRef.current += 1;
if (scrollRetryTimeoutRef.current) {
clearTimeout(scrollRetryTimeoutRef.current);
}
scrollRetryTimeoutRef.current = setTimeout(() => {
flatListRef.current?.scrollToIndex({
index: info.index,
animated: true,
viewPosition: 0.5,
});
}, 500);
}}
/>
</Pressable>
</Pressable>
</Modal>
);
};

View File

@@ -1,304 +0,0 @@
/**
* Chromecast Settings Menu
* Configure version, quality (bitrate cap), audio, subtitles, and playback speed.
* Every "selected" row is driven by the active CastSelection — no [0] fallbacks.
*/
import { Ionicons } from "@expo/vector-icons";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { Modal, Pressable, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import type { AudioTrack, SubtitleTrack } from "@/utils/casting/types";
export interface VersionOption {
id: string;
name: string;
}
export interface QualityOption {
key: string;
value: number | undefined;
}
interface ChromecastSettingsMenuProps {
visible: boolean;
onClose: () => void;
versions: VersionOption[];
selectedVersionId: string;
onVersionChange: (id: string) => void;
qualities: QualityOption[];
selectedMaxBitrate: number | undefined;
onQualityChange: (value: number | undefined) => void;
audioTracks: AudioTrack[];
selectedAudioIndex: number;
onAudioChange: (index: number) => void;
subtitleTracks: SubtitleTrack[];
/** -1 = subtitles off. */
selectedSubtitleIndex: number;
onSubtitleChange: (index: number) => void;
playbackSpeed: number;
onPlaybackSpeedChange: (speed: number) => void;
}
const PLAYBACK_SPEEDS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2];
const ACCENT = "#a855f7";
export const ChromecastSettingsMenu: React.FC<ChromecastSettingsMenuProps> = ({
visible,
onClose,
versions,
selectedVersionId,
onVersionChange,
qualities,
selectedMaxBitrate,
onQualityChange,
audioTracks,
selectedAudioIndex,
onAudioChange,
subtitleTracks,
selectedSubtitleIndex,
onSubtitleChange,
playbackSpeed,
onPlaybackSpeedChange,
}) => {
const insets = useSafeAreaInsets();
const { t } = useTranslation();
const [expandedSection, setExpandedSection] = useState<string | null>(null);
const toggleSection = (section: string) => {
setExpandedSection(expandedSection === section ? null : section);
};
const renderSectionHeader = (
title: string,
icon: keyof typeof Ionicons.glyphMap,
sectionKey: string,
) => (
<Pressable
onPress={() => toggleSection(sectionKey)}
style={{
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
padding: 16,
borderBottomWidth: 1,
borderBottomColor: "#333",
}}
>
<View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
<Ionicons name={icon} size={20} color='white' />
<Text style={{ color: "white", fontSize: 16, fontWeight: "500" }}>
{title}
</Text>
</View>
<Ionicons
name={expandedSection === sectionKey ? "chevron-up" : "chevron-down"}
size={20}
color='#999'
/>
</Pressable>
);
const renderRow = (
key: string | number,
label: string,
sublabel: string | null,
selected: boolean,
onPress: () => void,
) => (
<Pressable
key={key}
onPress={() => {
onPress();
setExpandedSection(null);
}}
style={{
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
padding: 16,
backgroundColor: selected ? "#2a2a2a" : "transparent",
}}
>
<View>
<Text style={{ color: "white", fontSize: 15 }}>{label}</Text>
{sublabel ? (
<Text style={{ color: "#999", fontSize: 13, marginTop: 2 }}>
{sublabel}
</Text>
) : null}
</View>
{selected ? <Ionicons name='checkmark' size={20} color={ACCENT} /> : null}
</Pressable>
);
return (
<Modal
visible={visible}
transparent={true}
animationType='slide'
onRequestClose={onClose}
>
<Pressable
style={{
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.85)",
justifyContent: "flex-end",
}}
onPress={onClose}
>
<Pressable
style={{
backgroundColor: "#1a1a1a",
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
maxHeight: "80%",
paddingBottom: insets.bottom,
}}
onPress={(e) => e.stopPropagation()}
>
<View
style={{
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
padding: 16,
borderBottomWidth: 1,
borderBottomColor: "#333",
}}
>
<Text style={{ color: "white", fontSize: 18, fontWeight: "600" }}>
{t("casting_player.playback_settings")}
</Text>
<Pressable onPress={onClose} style={{ padding: 8 }}>
<Ionicons name='close' size={24} color='white' />
</Pressable>
</View>
<ScrollView>
{/* Version — only when the item has more than one MediaSource */}
{versions.length > 1 &&
renderSectionHeader(
t("casting_player.version"),
"albums-outline",
"version",
)}
{versions.length > 1 && expandedSection === "version" && (
<View style={{ paddingVertical: 8 }}>
{versions.map((v) =>
renderRow(
v.id,
v.name,
null,
v.id === selectedVersionId,
() => onVersionChange(v.id),
),
)}
</View>
)}
{/* Quality (bitrate cap) */}
{renderSectionHeader(
t("casting_player.quality"),
"film-outline",
"quality",
)}
{expandedSection === "quality" && (
<View style={{ paddingVertical: 8 }}>
{qualities.map((q) =>
renderRow(
q.key,
q.key,
null,
q.value === selectedMaxBitrate,
() => onQualityChange(q.value),
),
)}
</View>
)}
{/* Audio — only when more than one track */}
{audioTracks.length > 1 &&
renderSectionHeader(
t("casting_player.audio"),
"musical-notes",
"audio",
)}
{audioTracks.length > 1 && expandedSection === "audio" && (
<View style={{ paddingVertical: 8 }}>
{audioTracks.map((track) =>
renderRow(
track.index,
track.displayTitle ||
track.language ||
t("casting_player.unknown"),
track.codec ? track.codec.toUpperCase() : null,
track.index === selectedAudioIndex,
() => onAudioChange(track.index),
),
)}
</View>
)}
{/* Subtitles */}
{subtitleTracks.length > 0 &&
renderSectionHeader(
t("casting_player.subtitles"),
"text",
"subtitles",
)}
{subtitleTracks.length > 0 && expandedSection === "subtitles" && (
<View style={{ paddingVertical: 8 }}>
{renderRow(
"off",
t("casting_player.none"),
null,
selectedSubtitleIndex < 0,
() => onSubtitleChange(-1),
)}
{subtitleTracks.map((track) =>
renderRow(
track.index,
track.displayTitle ||
track.language ||
t("casting_player.unknown"),
[
track.codec ? track.codec.toUpperCase() : "",
track.isForced ? t("casting_player.forced") : "",
]
.filter(Boolean)
.join(" • ") || null,
track.index === selectedSubtitleIndex,
() => onSubtitleChange(track.index),
),
)}
</View>
)}
{/* Playback speed */}
{renderSectionHeader(
t("casting_player.playback_speed"),
"speedometer",
"speed",
)}
{expandedSection === "speed" && (
<View style={{ paddingVertical: 8 }}>
{PLAYBACK_SPEEDS.map((speed) =>
renderRow(
speed,
speed === 1 ? t("casting_player.normal") : `${speed}x`,
null,
Math.abs(playbackSpeed - speed) < 0.01,
() => onPlaybackSpeedChange(speed),
),
)}
</View>
)}
</ScrollView>
</Pressable>
</Pressable>
</Modal>
);
};

View File

@@ -1,171 +0,0 @@
/**
* Hook for managing Chromecast segments (intro, credits, recap, commercial, preview)
* Integrates with autoskip API for segment detection
*/
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useAtomValue } from "jotai";
import { useCallback, useMemo } from "react";
import { apiAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { isWithinSegment } from "@/utils/casting/helpers";
import type { ChromecastSegmentData } from "@/utils/chromecast/options";
import { useSegments } from "@/utils/segments";
export const useChromecastSegments = (
item: BaseItemDto | null,
currentProgressMs: number,
isOffline = false,
) => {
const api = useAtomValue(apiAtom);
const { settings } = useSettings();
// Fetch segments from autoskip API
const { data: segmentData } = useSegments(
item?.Id || "",
isOffline,
undefined, // downloadedFiles parameter
api,
);
// Parse segments into usable format
const segments = useMemo<ChromecastSegmentData>(() => {
if (!segmentData) {
return {
intro: null,
credits: null,
recap: null,
commercial: [],
preview: [],
};
}
const intro =
segmentData.introSegments && segmentData.introSegments.length > 0
? {
start: segmentData.introSegments[0].startTime,
end: segmentData.introSegments[0].endTime,
}
: null;
const credits =
segmentData.creditSegments && segmentData.creditSegments.length > 0
? {
start: segmentData.creditSegments[0].startTime,
end: segmentData.creditSegments[0].endTime,
}
: null;
const recap =
segmentData.recapSegments && segmentData.recapSegments.length > 0
? {
start: segmentData.recapSegments[0].startTime,
end: segmentData.recapSegments[0].endTime,
}
: null;
const commercial = (segmentData.commercialSegments || []).map((seg) => ({
start: seg.startTime,
end: seg.endTime,
}));
const preview = (segmentData.previewSegments || []).map((seg) => ({
start: seg.startTime,
end: seg.endTime,
}));
return { intro, credits, recap, commercial, preview };
}, [segmentData]);
// Check which segment we're currently in
// currentProgressMs is in milliseconds; isWithinSegment() converts ms→seconds internally
// before comparing with segment times (which are in seconds from the autoskip API)
const currentSegment = useMemo(() => {
if (isWithinSegment(currentProgressMs, segments.intro)) {
return { type: "intro" as const, segment: segments.intro };
}
if (isWithinSegment(currentProgressMs, segments.credits)) {
return { type: "credits" as const, segment: segments.credits };
}
if (isWithinSegment(currentProgressMs, segments.recap)) {
return { type: "recap" as const, segment: segments.recap };
}
for (const commercial of segments.commercial) {
if (isWithinSegment(currentProgressMs, commercial)) {
return { type: "commercial" as const, segment: commercial };
}
}
for (const preview of segments.preview) {
if (isWithinSegment(currentProgressMs, preview)) {
return { type: "preview" as const, segment: preview };
}
}
return null;
}, [currentProgressMs, segments]);
// Skip functions
const skipIntro = useCallback(
async (seekFn: (positionMs: number) => Promise<void>): Promise<void> => {
if (segments.intro) {
await seekFn(segments.intro.end * 1000);
}
},
[segments.intro],
);
const skipCredits = useCallback(
async (seekFn: (positionMs: number) => Promise<void>): Promise<void> => {
if (segments.credits) {
await seekFn(segments.credits.end * 1000);
}
},
[segments.credits],
);
const skipSegment = useCallback(
async (seekFn: (positionMs: number) => Promise<void>): Promise<void> => {
if (currentSegment?.segment) {
await seekFn(currentSegment.segment.end * 1000);
}
},
[currentSegment],
);
// Auto-skip logic based on settings
const shouldAutoSkip = useMemo(() => {
if (!currentSegment) return false;
switch (currentSegment.type) {
case "intro":
return settings?.skipIntro === "auto";
case "credits":
return settings?.skipOutro === "auto";
case "recap":
return settings?.skipRecap === "auto";
case "commercial":
return settings?.skipCommercial === "auto";
case "preview":
return settings?.skipPreview === "auto";
default:
return false;
}
}, [
currentSegment,
settings?.skipIntro,
settings?.skipOutro,
settings?.skipRecap,
settings?.skipCommercial,
settings?.skipPreview,
]);
return {
segments,
currentSegment,
skipIntro,
skipCredits,
skipSegment,
shouldAutoSkip,
hasIntro: !!segments.intro,
hasCredits: !!segments.credits,
};
};

View File

@@ -1,7 +1,7 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { t } from "i18next";
import { useMemo } from "react"; import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { import {
ActivityIndicator, ActivityIndicator,
TouchableOpacity, TouchableOpacity,
@@ -35,6 +35,7 @@ interface DownloadCardProps extends TouchableOpacityProps {
} }
export const DownloadCard = ({ process, ...props }: DownloadCardProps) => { export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
const { t } = useTranslation();
const { cancelDownload } = useDownload(); const { cancelDownload } = useDownload();
const router = useRouter(); const router = useRouter();
const queryClient = useNetworkAwareQueryClient(); const queryClient = useNetworkAwareQueryClient();
@@ -173,7 +174,9 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
{isTranscoding && ( {isTranscoding && (
<View className='bg-purple-600/20 px-2 py-0.5 rounded-md mt-1 self-start'> <View className='bg-purple-600/20 px-2 py-0.5 rounded-md mt-1 self-start'>
<Text className='text-xs text-purple-400'>Transcoding</Text> <Text className='text-xs text-purple-400'>
{t("home.downloads.transcoding")}
</Text>
</View> </View>
)} )}

View File

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

View File

@@ -1,6 +1,7 @@
import { FontAwesome, Ionicons } from "@expo/vector-icons"; import { FontAwesome, Ionicons } from "@expo/vector-icons";
import type { BottomSheetModal } from "@gorhom/bottom-sheet";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useState } from "react"; import { useRef, useState } from "react";
import { TouchableOpacity, View, type ViewProps } from "react-native"; import { TouchableOpacity, View, type ViewProps } from "react-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { FilterSheet } from "./FilterSheet"; import { FilterSheet } from "./FilterSheet";
@@ -34,8 +35,9 @@ export const FilterButton = <T,>({
...props ...props
}: FilterButtonProps<T>) => { }: FilterButtonProps<T>) => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const sheetModalRef = useRef<BottomSheetModal | null>(null);
const { data: filters } = useQuery<T[]>({ const { data: filters, isLoading } = useQuery<T[]>({
queryKey: ["filters", title, queryKey, id], queryKey: ["filters", title, queryKey, id],
queryFn, queryFn,
staleTime: 0, staleTime: 0,
@@ -44,9 +46,15 @@ export const FilterButton = <T,>({
return ( return (
<> <>
{/* present() must be called here, inside the press handler: calling it
from an effect after a state update silently no-ops on the new
architecture and the sheet never appears. Opening immediately also
replaces the old data-loaded gate that left the button silently
dead while options were still loading (the sheet shows a loader). */}
<TouchableOpacity <TouchableOpacity
onPress={() => { onPress={() => {
filters?.length && setOpen(true); setOpen(true);
sheetModalRef.current?.present();
}} }}
> >
<View <View
@@ -89,6 +97,8 @@ export const FilterButton = <T,>({
title={title} title={title}
open={open} open={open}
setOpen={setOpen} setOpen={setOpen}
modalRef={sheetModalRef}
loading={isLoading}
data={filters} data={filters}
values={values} values={values}
set={set} set={set}

View File

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

View File

@@ -60,7 +60,7 @@ export const ItemPeopleSections: React.FC<Props> = ({ item, ...props }) => {
return ( return (
<MoreMoviesWithActor <MoreMoviesWithActor
key={`${person.Id}-${idx}`} key={person.Id}
currentItem={item} currentItem={item}
actorId={person.Id} actorId={person.Id}
actorName={person.Name} actorName={person.Name}

View File

@@ -1,5 +1,6 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import React from "react"; import React from "react";
import { useTranslation } from "react-i18next";
import { Animated, Pressable, StyleSheet, View } from "react-native"; import { Animated, Pressable, StyleSheet, View } from "react-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation"; import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
@@ -22,6 +23,7 @@ export const TVGuideProgramCell: React.FC<TVGuideProgramCellProps> = ({
disabled = false, disabled = false,
refSetter, refSetter,
}) => { }) => {
const { t } = useTranslation();
const typography = useScaledTVTypography(); const typography = useScaledTVTypography();
const { focused, handleFocus, handleBlur } = useTVFocusAnimation({ const { focused, handleFocus, handleBlur } = useTVFocusAnimation({
scaleAmount: 1, scaleAmount: 1,
@@ -68,7 +70,7 @@ export const TVGuideProgramCell: React.FC<TVGuideProgramCellProps> = ({
<Text <Text
style={[styles.liveBadgeText, { fontSize: typography.callout }]} style={[styles.liveBadgeText, { fontSize: typography.callout }]}
> >
LIVE {t("player.live")}
</Text> </Text>
</View> </View>
)} )}

View File

@@ -235,7 +235,7 @@ export const TVLiveTVPage: React.FC = () => {
marginBottom: 24, marginBottom: 24,
}} }}
> >
Live TV {t("live_tv.title")}
</Text> </Text>
{/* Tab Bar */} {/* Tab Bar */}

View File

@@ -3,7 +3,7 @@ import type { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router"; import { useLocalSearchParams, useNavigation } from "expo-router";
import { t } from "i18next"; import { t } from "i18next";
import { useAtomValue } from "jotai"; import { useAtomValue, useSetAtom } from "jotai";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { import {
Alert, Alert,
@@ -20,14 +20,16 @@ import { Button } from "@/components/Button";
import { Input } from "@/components/common/Input"; import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery"; import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
import { QuickConnectCodeModal } from "@/components/login/QuickConnectCodeModal";
import { PreviousServersList } from "@/components/PreviousServersList"; import { PreviousServersList } from "@/components/PreviousServersList";
import { SaveAccountModal } from "@/components/SaveAccountModal";
import { Colors } from "@/constants/Colors"; import { Colors } from "@/constants/Colors";
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider"; import {
import type { apiAtom,
AccountSecurityType, pendingAccountSaveAtom,
SavedServer, useJellyfin,
} from "@/utils/secureCredentials"; userAtom,
} from "@/providers/JellyfinProvider";
import type { SavedServer } from "@/utils/secureCredentials";
const CredentialsSchema = z.object({ const CredentialsSchema = z.object({
username: z.string().min(1, t("login.username_required")), username: z.string().min(1, t("login.username_required")),
@@ -37,14 +39,17 @@ export const Login: React.FC = () => {
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);
const navigation = useNavigation(); const navigation = useNavigation();
const params = useLocalSearchParams(); const params = useLocalSearchParams();
const user = useAtomValue(userAtom);
const { const {
setServer, setServer,
login, login,
removeServer, removeServer,
initiateQuickConnect, initiateQuickConnect,
stopQuickConnectPolling,
loginWithSavedCredential, loginWithSavedCredential,
loginWithPassword, loginWithPassword,
} = useJellyfin(); } = useJellyfin();
const setPendingAccountSave = useSetAtom(pendingAccountSaveAtom);
const { const {
apiUrl: _apiUrl, apiUrl: _apiUrl,
@@ -64,13 +69,43 @@ export const Login: React.FC = () => {
password: _password || "", password: _password || "",
}); });
// Save account state // Quick Connect code shown in the in-app sheet while polling for authorization
const [quickConnectCode, setQuickConnectCode] = useState<string | null>(null);
// Close the code sheet as soon as the session is authorized — the native
// Alert used before had no programmatic dismiss and stayed open after login.
// A Quick Connect login with "save account" on flags the post-login save:
// the protection picker shows globally once the session exists (this screen
// unmounts on login, so it can't host the modal).
useEffect(() => {
if (user) {
if (quickConnectCode && saveAccount) {
setPendingAccountSave({ serverName });
}
setQuickConnectCode(null);
}
}, [user]);
// Stop Quick Connect polling when leaving the login page (parity with TVLogin)
useEffect(() => {
return () => {
stopQuickConnectPolling();
};
}, [stopQuickConnectPolling]);
// Going back to server selection keeps this component mounted (same screen,
// different state), so the unmount cleanup above doesn't run. Without this a
// code authorized after leaving would silently log the user in later.
useEffect(() => {
if (!api?.basePath) {
stopQuickConnectPolling();
setQuickConnectCode(null);
}
}, [api?.basePath, stopQuickConnectPolling]);
// Save account state — only the intent lives here; the protection picker is
// the global PendingAccountSaveModal, shown after the login succeeds.
const [saveAccount, setSaveAccount] = useState(false); const [saveAccount, setSaveAccount] = useState(false);
const [showSaveModal, setShowSaveModal] = useState(false);
const [pendingLogin, setPendingLogin] = useState<{
username: string;
password: string;
} | null>(null);
// Handle URL params for server connection // Handle URL params for server connection
useEffect(() => { useEffect(() => {
@@ -117,55 +152,34 @@ export const Login: React.FC = () => {
const result = CredentialsSchema.safeParse(credentials); const result = CredentialsSchema.safeParse(credentials);
if (!result.success) return; if (!result.success) return;
if (saveAccount) { const ok = await performLogin(credentials.username, credentials.password);
setPendingLogin({ // The protection picker shows AFTER a successful login (global modal) —
username: credentials.username, // never for a failed one.
password: credentials.password, if (ok && saveAccount) {
}); setPendingAccountSave({ serverName });
setShowSaveModal(true);
} else {
await performLogin(credentials.username, credentials.password);
} }
}; };
const performLogin = async ( const performLogin = async (
username: string, username: string,
password: string, password: string,
options?: { ): Promise<boolean> => {
saveAccount?: boolean;
securityType?: AccountSecurityType;
pinCode?: string;
},
) => {
setLoading(true); setLoading(true);
try { try {
await login(username, password, serverName, options); await login(username, password, serverName);
return true;
} catch (error) { } catch (error) {
if (error instanceof Error) { if (error instanceof Error) {
Alert.alert(t("login.connection_failed"), error.message); Alert.alert(t("login.connection_failed"), error.message);
} else { } else {
Alert.alert( Alert.alert(
t("login.connection_failed"), t("login.connection_failed"),
t("login.an_unexpected_error_occured"), t("login.an_unexpected_error_occurred"),
); );
} }
return false;
} finally { } finally {
setLoading(false); setLoading(false);
setPendingLogin(null);
}
};
const handleSaveAccountConfirm = async (
securityType: AccountSecurityType,
pinCode?: string,
) => {
setShowSaveModal(false);
if (pendingLogin) {
await performLogin(pendingLogin.username, pendingLogin.password, {
saveAccount: true,
securityType,
pinCode,
});
} }
}; };
@@ -259,15 +273,7 @@ export const Login: React.FC = () => {
try { try {
const code = await initiateQuickConnect(); const code = await initiateQuickConnect();
if (code) { if (code) {
Alert.alert( setQuickConnectCode(code);
t("login.quick_connect"),
t("login.enter_code_to_login", { code: code }),
[
{
text: t("login.got_it"),
},
],
);
} }
} catch (_error) { } catch (_error) {
Alert.alert( Alert.alert(
@@ -402,7 +408,7 @@ export const Login: React.FC = () => {
{t("server.enter_url_to_jellyfin_server")} {t("server.enter_url_to_jellyfin_server")}
</Text> </Text>
<Input <Input
aria-label='Server URL' aria-label={t("server.server_url")}
placeholder={t("server.server_url_placeholder")} placeholder={t("server.server_url_placeholder")}
onChangeText={setServerURL} onChangeText={setServerURL}
value={serverURL} value={serverURL}
@@ -444,14 +450,11 @@ export const Login: React.FC = () => {
)} )}
</KeyboardAvoidingView> </KeyboardAvoidingView>
<SaveAccountModal {/* Dismissing only hides the code — polling continues so the login still
visible={showSaveModal} completes if the code is authorized from another device afterwards. */}
onClose={() => { <QuickConnectCodeModal
setShowSaveModal(false); code={quickConnectCode}
setPendingLogin(null); onClose={() => setQuickConnectCode(null)}
}}
onSave={handleSaveAccountConfirm}
username={pendingLogin?.username || credentials.username}
/> />
</SafeAreaView> </SafeAreaView>
); );

View File

@@ -0,0 +1,137 @@
import { Ionicons } from "@expo/vector-icons";
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import { requireOptionalNativeModule } from "expo-modules-core";
import type React from "react";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { toast } from "sonner-native";
import { Button } from "../Button";
import { Text } from "../common/Text";
interface Props {
/** The Quick Connect code to display, or null when hidden. */
code: string | null;
onClose: () => void;
}
/**
* Shows the Quick Connect code while the app polls for authorization.
* In-app sheet instead of a native Alert so it can dismiss itself once the
* session is authorized — a native alert has no programmatic dismiss and
* lingers over the app after login completes.
*/
export const QuickConnectCodeModal: React.FC<Props> = ({ code, onClose }) => {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const snapPoints = useMemo(() => ["50%"], []);
const isPresentedRef = useRef(false);
// Keep the last code around so the dismiss animation doesn't flash empty
// when the parent clears the code to close the sheet.
const lastCodeRef = useRef<string | null>(null);
if (code) lastCodeRef.current = code;
useEffect(() => {
if (code) {
bottomSheetModalRef.current?.present();
} else if (isPresentedRef.current) {
bottomSheetModalRef.current?.dismiss();
isPresentedRef.current = false;
}
}, [code]);
const handleSheetChanges = useCallback(
(index: number) => {
if (index >= 0) {
isPresentedRef.current = true;
} else if (index === -1 && isPresentedRef.current) {
isPresentedRef.current = false;
onClose();
}
},
[onClose],
);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[],
);
const copyCode = useCallback(async () => {
const value = code ?? lastCodeRef.current;
if (!value) return;
// Builds that don't ship the expo-clipboard native module yet: probe with
// requireOptionalNativeModule (returns null instead of throwing/logging)
// and skip — importing the JS wrapper there would error out.
if (!requireOptionalNativeModule("ExpoClipboard")) return;
const Clipboard = await import("expo-clipboard");
await Clipboard.setStringAsync(value);
toast.success(t("login.code_copied"));
}, [code, t]);
return (
<BottomSheetModal
ref={bottomSheetModalRef}
snapPoints={snapPoints}
onChange={handleSheetChanges}
handleIndicatorStyle={{ backgroundColor: "white" }}
backgroundStyle={{ backgroundColor: "#171717" }}
backdropComponent={renderBackdrop}
>
<BottomSheetView
style={{
flex: 1,
paddingLeft: Math.max(16, insets.left),
paddingRight: Math.max(16, insets.right),
paddingBottom: Math.max(16, insets.bottom),
}}
>
<View className='flex-1'>
<Text className='font-bold text-2xl text-neutral-100'>
{t("login.quick_connect")}
</Text>
<TouchableOpacity
className='mt-6 p-6 border border-neutral-800 rounded-xl bg-neutral-900 flex flex-row items-center justify-center'
onPress={copyCode}
>
<Text
className='text-center font-bold text-5xl text-neutral-100'
style={{ letterSpacing: 10 }}
>
{code ?? lastCodeRef.current}
</Text>
<Ionicons
name='copy-outline'
size={22}
color='white'
style={{ opacity: 0.4, marginLeft: 16 }}
/>
</TouchableOpacity>
<Text className='mt-2 text-neutral-500 text-center text-xs'>
{t("login.tap_code_to_copy")}
</Text>
<Text className='mt-3 mb-5 text-neutral-400 text-center px-4'>
{t("login.quick_connect_instructions")}
</Text>
<Button className='mt-auto' color='purple' onPress={onClose}>
{t("login.got_it")}
</Button>
</View>
</BottomSheetView>
</BottomSheetModal>
);
};

View File

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

View File

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

View File

@@ -1,103 +0,0 @@
/**
* Player-agnostic "next episode" countdown card. The parent owns the timer and
* positioning — this component only renders the next episode's poster, title,
* the remaining seconds, and the Play-now / Cancel actions.
*/
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image";
import { useTranslation } from "react-i18next";
import { Pressable, View } from "react-native";
import { Text } from "@/components/common/Text";
interface AutoplayCountdownProps {
/** The episode that will play next. */
nextEpisode: BaseItemDto;
/** Poster image URL for the next episode, or null. */
posterUrl: string | null;
/** Seconds left before the next episode plays. */
secondsRemaining: number;
/** Play the next episode immediately. */
onPlayNow: () => void;
/** Cancel autoplay — the next episode will not play. */
onCancel: () => void;
}
export function AutoplayCountdown({
nextEpisode,
posterUrl,
secondsRemaining,
onPlayNow,
onCancel,
}: AutoplayCountdownProps) {
const { t } = useTranslation();
return (
<View
style={{
flexDirection: "row",
gap: 12,
width: 320,
padding: 12,
borderRadius: 12,
backgroundColor: "rgba(20, 20, 20, 0.94)",
}}
>
{posterUrl && (
<Image
source={{ uri: posterUrl }}
style={{ width: 62, height: 93, borderRadius: 6 }}
contentFit='cover'
/>
)}
<View style={{ flex: 1, justifyContent: "space-between" }}>
<View style={{ gap: 2 }}>
<Text style={{ color: "#999", fontSize: 12 }}>
{t("player.up_next")}
</Text>
<Text
style={{ color: "#fff", fontSize: 15, fontWeight: "600" }}
numberOfLines={2}
>
{nextEpisode.Name}
</Text>
<Text style={{ color: "#a855f7", fontSize: 13 }}>
{t("player.next_episode_in", { seconds: secondsRemaining })}
</Text>
</View>
<View style={{ flexDirection: "row", gap: 8, marginTop: 8 }}>
<Pressable
onPress={onPlayNow}
accessibilityRole='button'
style={{
flex: 1,
paddingVertical: 8,
borderRadius: 8,
backgroundColor: "#a855f7",
alignItems: "center",
}}
>
<Text style={{ color: "#fff", fontWeight: "600" }}>
{t("player.play_now")}
</Text>
</Pressable>
<Pressable
onPress={onCancel}
accessibilityRole='button'
style={{
flex: 1,
paddingVertical: 8,
borderRadius: 8,
backgroundColor: "#333",
alignItems: "center",
}}
>
<Text style={{ color: "#fff", fontWeight: "600" }}>
{t("player.cancel")}
</Text>
</Pressable>
</View>
</View>
</View>
);
}

View File

@@ -2,7 +2,7 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useMemo } from "react"; import { useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ScrollView, View } from "react-native"; import { Platform, ScrollView, TextInput, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { TVDiscover } from "@/components/jellyseerr/discover/TVDiscover"; import { TVDiscover } from "@/components/jellyseerr/discover/TVDiscover";
@@ -231,26 +231,48 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
paddingTop: insets.top + TOP_PADDING, paddingTop: insets.top + TOP_PADDING,
}} }}
> >
{/* Native tvOS search field (SwiftUI `.searchable`, our `tv-search` {/* Search bar: native tvOS SwiftUI `.searchable` on Apple TV, standard
module). It renders the native search bar + grid keyboard and TextInput fallback on Android TV (the native module is Apple-only). */}
forwards typed text into the existing query pipeline via setSearch; {Platform.OS === "ios" ? (
our own results grid renders below. */} <View
{/* No horizontal margin here: the native tvOS search bar centers itself style={{
and renders a trailing "Hold to Dictate in <Language>" hint. Extra marginBottom: 24,
margins squeeze the bar's width and clip that trailing hint, so let height: SEARCH_AREA_HEIGHT,
the native view span the full width and own its own insets. */} }}
<View >
style={{ {/* No horizontal margin here: the native tvOS search bar centers
marginBottom: 24, itself and renders a trailing "Hold to Dictate" hint. */}
height: SEARCH_AREA_HEIGHT, <TvSearchView
}} style={{ width: "100%", height: "100%" }}
> placeholder={t("search.search")}
<TvSearchView onChangeText={(e) => setSearch(e.nativeEvent.text)}
style={{ width: "100%", height: "100%" }} />
placeholder={t("search.search")} </View>
onChangeText={(e) => setSearch(e.nativeEvent.text)} ) : (
/> <View
</View> style={{
marginHorizontal: HORIZONTAL_PADDING,
marginBottom: 24,
}}
>
<TextInput
style={{
height: 56,
width: "100%",
backgroundColor: "#262626",
borderRadius: 12,
paddingHorizontal: 20,
fontSize: 28,
color: "#fff",
}}
placeholder={t("search.search")}
placeholderTextColor='rgba(255,255,255,0.4)'
onChangeText={setSearch}
defaultValue=''
autoFocus={false}
/>
</View>
)}
</View> </View>
<ScrollView <ScrollView

View File

@@ -1,4 +1,5 @@
import React from "react"; import React from "react";
import { useTranslation } from "react-i18next";
import { Animated, Pressable, View } from "react-native"; import { Animated, Pressable, View } from "react-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation"; import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
@@ -88,6 +89,8 @@ export const TVSearchTabBadges: React.FC<TVSearchTabBadgesProps> = ({
showDiscover, showDiscover,
disabled = false, disabled = false,
}) => { }) => {
const { t } = useTranslation();
if (!showDiscover) { if (!showDiscover) {
return null; return null;
} }
@@ -101,13 +104,13 @@ export const TVSearchTabBadges: React.FC<TVSearchTabBadgesProps> = ({
}} }}
> >
<TVSearchTabBadge <TVSearchTabBadge
label='Library' label={t("search.library")}
isSelected={searchType === "Library"} isSelected={searchType === "Library"}
onPress={() => setSearchType("Library")} onPress={() => setSearchType("Library")}
disabled={disabled} disabled={disabled}
/> />
<TVSearchTabBadge <TVSearchTabBadge
label='Discover' label={t("search.discover")}
isSelected={searchType === "Discover"} isSelected={searchType === "Discover"}
onPress={() => setSearchType("Discover")} onPress={() => setSearchType("Discover")}
disabled={disabled} disabled={disabled}

View File

@@ -31,8 +31,12 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
}) => { }) => {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const isOffline = useOfflineMode();
const router = useRouter(); const router = useRouter();
const isOffline = useOfflineMode();
// Read the live (cached) downloads DB inside the query rather than the
// provider's downloadedItems snapshot, so refetches after
// updateDownloadedItem() reflect the latest state instead of a stale
// refreshKey-gated snapshot. getAllDownloadedItems() is cached, so this stays cheap.
const { getDownloadedItems } = useDownload(); const { getDownloadedItems } = useDownload();
const scrollRef = useRef<HorizontalScrollRef>(null); const scrollRef = useRef<HorizontalScrollRef>(null);
@@ -100,7 +104,7 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
onPress={() => { onPress={() => {
router.setParams({ id: _item.Id }); router.setParams({ id: _item.Id });
}} }}
className={`flex flex-col w-44 className={`flex flex-col w-44
${item?.Id === _item.Id ? "" : "opacity-50"} ${item?.Id === _item.Id ? "" : "opacity-50"}
`} `}
> >

View File

@@ -1,62 +1,18 @@
import { Ionicons } from "@expo/vector-icons"; import { Switch, View } from "react-native";
import { useMemo } from "react";
import { View } from "react-native";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import type { ChromecastProfileMode } from "@/utils/casting/capabilities";
import { Text } from "../common/Text";
import { ListGroup } from "../list/ListGroup"; import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem"; import { ListItem } from "../list/ListItem";
import { PlatformDropdown } from "../PlatformDropdown";
const PROFILE_LABELS: Record<ChromecastProfileMode, string> = {
auto: "Automatic (recommended)",
"force-hevc": "Force HEVC / H265",
"force-h264": "Force H264",
};
export const ChromecastSettings: React.FC = ({ ...props }) => { export const ChromecastSettings: React.FC = ({ ...props }) => {
const { settings, updateSettings } = useSettings(); const { settings, updateSettings } = useSettings();
const profileOptions = useMemo(
() => [
{
options: (
["auto", "force-hevc", "force-h264"] as ChromecastProfileMode[]
).map((mode) => ({
type: "radio" as const,
label: PROFILE_LABELS[mode],
value: mode,
selected: (settings.chromecastProfile ?? "auto") === mode,
onPress: () => updateSettings({ chromecastProfile: mode }),
})),
},
],
[settings.chromecastProfile, updateSettings],
);
return ( return (
<View {...props}> <View {...props}>
<ListGroup title={"Chromecast"}> <ListGroup title={"Chromecast"}>
<ListItem <ListItem title={"Enable H265 for Chromecast"}>
title={"Profile"} <Switch
subtitle={ value={settings.enableH265ForChromecast}
"Automatic picks codecs per device. Override only if needed." onValueChange={(enableH265ForChromecast) =>
} updateSettings({ enableH265ForChromecast })
>
<PlatformDropdown
groups={profileOptions}
title={"Chromecast profile"}
trigger={
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>
{PROFILE_LABELS[settings.chromecastProfile ?? "auto"]}
</Text>
<Ionicons
name='chevron-expand-sharp'
size={18}
color='#5A5960'
/>
</View>
} }
/> />
</ListItem> </ListItem>

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