Compare commits

..

34 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
116 changed files with 2553 additions and 1201 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

54
.github/renovate.json vendored
View File

@@ -44,22 +44,42 @@
]
}
},
"lockFileMaintenance": {
"vulnerabilityAlerts": {
"enabled": true,
"addLabels": ["security", "vulnerability"],
"assigneesFromCodeOwners": true,
"commitMessageSuffix": " [SECURITY]"
"vulnerabilityAlerts": {
"enabled": true,
"addLabels": ["security", "vulnerability"],
"assigneesFromCodeOwners": true,
"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",
"matchManagers": ["github-actions"],
"groupName": "CI dependencies",
"groupSlug": "ci-deps",
"matchUpdateTypes": ["minor", "patch", "digest", "pin"],
"automerge": true
}
]
}
{
"description": "Group minor and patch GitHub Action updates into a single PR",
"matchManagers": ["github-actions"],
"groupName": "CI dependencies",
"groupSlug": "ci-deps",
"matchUpdateTypes": ["minor", "patch", "digest", "pin"],
"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:
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:
build-android-phone:
if: (!contains(github.event.head_commit.message, '[skip ci]'))
@@ -33,7 +42,7 @@ jobs:
swap-storage: false
- name: 📥 Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
@@ -116,7 +125,7 @@ jobs:
swap-storage: false
- name: 📥 Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
@@ -187,7 +196,7 @@ jobs:
steps:
- name: 📥 Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
@@ -219,10 +228,10 @@ jobs:
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
with:
# renovate: datasource=custom.xcode depName=xcode versioning=loose
xcode-version: "26.4"
xcode-version: "26.5"
- name: 🏗️ Setup EAS
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
with:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
@@ -231,7 +240,9 @@ jobs:
- name: 🚀 Build iOS app
env:
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
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
@@ -252,7 +263,7 @@ jobs:
steps:
- name: 📥 Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
@@ -284,7 +295,7 @@ jobs:
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
with:
# renovate: datasource=custom.xcode depName=xcode versioning=loose
xcode-version: "26.4"
xcode-version: "26.5"
- name: 🚀 Build iOS app
env:
@@ -312,7 +323,7 @@ jobs:
steps:
- name: 📥 Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
@@ -344,10 +355,10 @@ jobs:
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
with:
# renovate: datasource=custom.xcode depName=xcode versioning=loose
xcode-version: "26.4"
xcode-version: "26.5"
- name: 🏗️ Setup EAS
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
with:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
@@ -356,7 +367,7 @@ jobs:
- name: 🚀 Build iOS app
env:
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
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
@@ -380,7 +391,7 @@ jobs:
steps:
- name: 📥 Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
@@ -412,7 +423,7 @@ jobs:
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
with:
# renovate: datasource=custom.xcode depName=xcode versioning=loose
xcode-version: "26.4"
xcode-version: "26.5"
- name: 🚀 Build iOS app
env:

View File

@@ -19,7 +19,7 @@ jobs:
steps:
- name: 📥 Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
show-progress: false

View File

@@ -24,16 +24,16 @@ jobs:
steps:
- name: 📥 Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: 🏁 Initialize CodeQL
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with:
languages: ${{ matrix.language }}
queries: +security-extended,security-and-quality
- name: 🛠️ Autobuild
uses: github/codeql-action/autobuild@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
- 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
steps:
- 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:
dirtyLabel: '⚔️ merge-conflict'
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
on:
push:
branches: [develop]
paths:
- "translations/**"
- "crowdin.yml"
- "i18n.ts"
- ".github/workflows/crowdin.yml"
# Run weekly to pull new translations
schedule:
- cron: "0 2 * * 1" # Every Monday at 2 AM UTC
workflow_dispatch:
permissions:
contents: write
pull-requests: write
jobs:
sync-translations:
runs-on: ubuntu-latest
steps:
- name: 📥 Checkout Repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: 🌐 Sync Translations with Crowdin
uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2.16.2
with:
upload_sources: true
upload_translations: true
download_translations: true
localization_branch_name: I10n_crowdin_translations
create_pull_request: true
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_base_branch_name: "develop"
pull_request_labels: "🌐 translation"
# Quality control options
skip_untranslated_strings: false
skip_untranslated_files: false
export_only_approved: false
# Commit customization
commit_message: "feat(i18n): update translations from Crowdin"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
name: 🌐 Translation Sync
on:
push:
branches: [develop]
paths:
- "translations/**"
- "crowdin.yml"
- "i18n.ts"
- ".github/workflows/crowdin.yml"
# Run weekly to pull new translations
schedule:
- cron: "0 2 * * 1" # Every Monday at 2 AM UTC
workflow_dispatch:
permissions:
contents: write
pull-requests: write
jobs:
sync-translations:
runs-on: ubuntu-latest
steps:
- name: 📥 Checkout Repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
- name: 🌐 Sync Translations with Crowdin
uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2.16.2
with:
upload_sources: true
upload_translations: true
download_translations: true
localization_branch_name: I10n_crowdin_translations
create_pull_request: true
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_base_branch_name: "develop"
pull_request_labels: "🌐 translation"
# Quality control options
skip_untranslated_strings: false
skip_untranslated_files: false
export_only_approved: false
# Commit customization
commit_message: "feat(i18n): update translations from Crowdin"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}

View File

@@ -21,7 +21,7 @@ jobs:
contents: read
steps:
- name: 📥 Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0

View File

@@ -51,7 +51,7 @@ jobs:
contents: read
steps:
- name: Checkout Repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
@@ -69,7 +69,7 @@ jobs:
runs-on: ubuntu-24.04
steps:
- name: 🛒 Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
submodules: recursive
@@ -97,10 +97,11 @@ jobs:
- "check"
- "format"
- "typecheck"
- "i18n:check"
steps:
- name: "📥 Checkout PR code"
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
submodules: recursive

View File

@@ -63,7 +63,7 @@ jobs:
steps:
- name: 📥 Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
submodules: recursive
@@ -88,7 +88,7 @@ jobs:
bun run submodule-reload
- name: 🏗️ Setup EAS
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
with:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
@@ -182,7 +182,7 @@ jobs:
actions: read # required for `gh run download` to list/fetch this run's artifacts
steps:
- name: 📥 Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
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:
- name: 📥 Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: "🟢 Setup Node.js"
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0

11
.gitignore vendored
View File

@@ -1,6 +1,5 @@
# Dependencies and Package Managers
node_modules/
bun.lock
bun.lockb
package-lock.json
@@ -21,10 +20,8 @@ web-build/
# Gradle caches (top-level + per-module native projects)
**/.gradle/
# Module-specific Builds
modules/mpv-player/android/build
modules/player/android
modules/hls-downloader/android/build
# Native module build outputs (any module)
modules/*/android/build/
# Generated Applications
Streamyfin.app
@@ -69,10 +66,6 @@ certs/
# Version and Backup Files
/version-backup-*
/modules/sf-player/android/build
/modules/music-controls/android/build
modules/background-downloader/android/build/*
/modules/mpv-player/android/build
# ios:unsigned-build Artifacts
build/

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 }) => {
if (process.env.EXPO_TV !== "1") {
config.plugins.push("expo-background-task");
@@ -22,6 +66,8 @@ module.exports = ({ config }) => {
androidConfig.googleServicesFile = process.env.GOOGLE_SERVICES_JSON;
}
config.extra = { ...config.extra, build: buildMeta };
return {
...(Object.keys(androidConfig).length > 0 && { android: androidConfig }),
...config,

View File

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

View File

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

View File

@@ -179,18 +179,15 @@ export default function SettingsTV() {
// Handle clearing all cache in the entire app
const handleClearCache = async () => {
Alert.alert(
t("home.settings.storage.clear_all_cache_confirm", "Clear All Cache?"),
t(
"home.settings.storage.clear_all_cache_confirm_desc",
"Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
),
t("home.settings.storage.clear_all_cache_confirm"),
t("home.settings.storage.clear_all_cache_confirm_desc"),
[
{
text: t("common.cancel", "Cancel"),
text: t("common.cancel"),
style: "cancel",
},
{
text: t("common.ok", "OK"),
text: t("common.ok"),
onPress: async () => {
try {
// 1. Clear React Query Cache (memory & MMKV)
@@ -243,11 +240,8 @@ export default function SettingsTV() {
} catch (error) {
console.error("Failed to clear cache:", error);
Alert.alert(
t("home.settings.toasts.error_deleting_files", "Error"),
t(
"home.settings.storage.clear_all_cache_error_desc",
"An error occurred while clearing the cache.",
),
t("home.settings.toasts.error_deleting_files"),
t("home.settings.storage.clear_all_cache_error_desc"),
);
}
},
@@ -651,7 +645,7 @@ export default function SettingsTV() {
formatValue={(v) => `${v.toFixed(1)}x`}
/>
<TVSettingsStepper
label='Vertical Margin'
label={t("home.settings.subtitles.mpv_subtitle_margin_y")}
value={settings.mpvSubtitleMarginY ?? 0}
onDecrease={() => {
const newValue = Math.max(
@@ -669,11 +663,11 @@ export default function SettingsTV() {
}}
/>
<TVSettingsOptionButton
label='Horizontal Alignment'
label={t("home.settings.subtitles.mpv_subtitle_align_x")}
value={alignXLabel}
onPress={() =>
showOptions({
title: "Horizontal Alignment",
title: t("home.settings.subtitles.mpv_subtitle_align_x"),
options: alignXOptions,
onSelect: (value) =>
updateSettings({
@@ -683,11 +677,11 @@ export default function SettingsTV() {
}
/>
<TVSettingsOptionButton
label='Vertical Alignment'
label={t("home.settings.subtitles.mpv_subtitle_align_y")}
value={alignYLabel}
onPress={() =>
showOptions({
title: "Vertical Alignment",
title: t("home.settings.subtitles.mpv_subtitle_align_y"),
options: alignYOptions,
onSelect: (value) =>
updateSettings({

View File

@@ -71,7 +71,7 @@ export default function AppearanceHideLibrariesPage() {
))}
</ListGroup>
<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>
</DisabledSetting>
</ScrollView>

View File

@@ -60,7 +60,7 @@ export default function HideLibrariesPage() {
))}
</ListGroup>
<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>
</DisabledSetting>
);

View File

@@ -49,7 +49,21 @@ export default function StreamystatsPage() {
);
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 cleanUrl = url.endsWith("/") ? url.slice(0, -1) : url;
@@ -146,7 +160,7 @@ export default function StreamystatsPage() {
placeholder={t(
"home.settings.plugins.streamystats.server_url_placeholder",
)}
value={url}
value={effectiveUrl}
keyboardType='url'
returnKeyType='done'
autoCapitalize='none'
@@ -171,11 +185,18 @@ export default function StreamystatsPage() {
>
<ListItem
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
value={useForSearch}
disabled={!isStreamystatsEnabled}
value={
searchLocked
? settings?.searchEngine === "Streamystats"
: useForSearch
}
disabled={!isStreamystatsEnabled || searchLocked}
onValueChange={setUseForSearch}
/>
</ListItem>
@@ -183,52 +204,62 @@ export default function StreamystatsPage() {
title={t(
"home.settings.plugins.streamystats.enable_movie_recommendations",
)}
disabledByAdmin={
pluginSettings?.streamyStatsMovieRecommendations?.locked === true
}
disabledByAdmin={movieRecsLocked}
>
<Switch
value={movieRecs}
value={
movieRecsLocked
? (settings?.streamyStatsMovieRecommendations ?? false)
: movieRecs
}
onValueChange={setMovieRecs}
disabled={!isStreamystatsEnabled}
disabled={!isStreamystatsEnabled || movieRecsLocked}
/>
</ListItem>
<ListItem
title={t(
"home.settings.plugins.streamystats.enable_series_recommendations",
)}
disabledByAdmin={
pluginSettings?.streamyStatsSeriesRecommendations?.locked === true
}
disabledByAdmin={seriesRecsLocked}
>
<Switch
value={seriesRecs}
value={
seriesRecsLocked
? (settings?.streamyStatsSeriesRecommendations ?? false)
: seriesRecs
}
onValueChange={setSeriesRecs}
disabled={!isStreamystatsEnabled}
disabled={!isStreamystatsEnabled || seriesRecsLocked}
/>
</ListItem>
<ListItem
title={t(
"home.settings.plugins.streamystats.enable_promoted_watchlists",
)}
disabledByAdmin={
pluginSettings?.streamyStatsPromotedWatchlists?.locked === true
}
disabledByAdmin={promotedWatchlistsLocked}
>
<Switch
value={promotedWatchlists}
value={
promotedWatchlistsLocked
? (settings?.streamyStatsPromotedWatchlists ?? false)
: promotedWatchlists
}
onValueChange={setPromotedWatchlists}
disabled={!isStreamystatsEnabled}
disabled={!isStreamystatsEnabled || promotedWatchlistsLocked}
/>
</ListItem>
<ListItem
title={t("home.settings.plugins.streamystats.hide_watchlists_tab")}
disabledByAdmin={pluginSettings?.hideWatchlistsTab?.locked === true}
disabledByAdmin={hideWatchlistsTabLocked}
>
<Switch
value={hideWatchlistsTab}
value={
hideWatchlistsTabLocked
? (settings?.hideWatchlistsTab ?? false)
: hideWatchlistsTab
}
onValueChange={setHideWatchlistsTab}
disabled={!isStreamystatsEnabled}
disabled={!isStreamystatsEnabled || hideWatchlistsTabLocked}
/>
</ListItem>
</ListGroup>

View File

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

View File

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

View File

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

View File

@@ -14,6 +14,7 @@ import React, {
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
Dimensions,
@@ -72,6 +73,7 @@ const ARTWORK_SIZE = SCREEN_WIDTH - 80;
type ViewMode = "player" | "queue";
export default function NowPlayingScreen() {
const { t } = useTranslation();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const router = useRouter();
@@ -230,7 +232,9 @@ export default function NowPlayingScreen() {
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>
</BottomSheetModalProvider>
);
@@ -267,7 +271,7 @@ export default function NowPlayingScreen() {
: "text-neutral-500"
}
>
Now Playing
{t("music.now_playing")}
</Text>
</TouchableOpacity>
<TouchableOpacity
@@ -718,6 +722,7 @@ const QueueView: React.FC<QueueViewProps> = ({
onRemoveFromQueue,
onReorderQueue,
}) => {
const { t } = useTranslation();
const renderQueueItem = useCallback(
({ item, drag, isActive, getIndex }: RenderItemParams<BaseItemDto>) => {
const index = getIndex() ?? 0;
@@ -831,13 +836,15 @@ const QueueView: React.FC<QueueViewProps> = ({
ListHeaderComponent={
<View className='px-4 py-2'>
<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>
</View>
}
ListEmptyComponent={
<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 File

@@ -1267,7 +1267,7 @@ export default function DirectPlayerPage() {
console.error("Video Error:", e.nativeEvent);
Alert.alert(
t("player.error"),
t("player.an_error_occured_while_playing_the_video"),
t("player.an_error_occurred_while_playing_the_video"),
);
writeToLog("ERROR", "Video Error", e.nativeEvent);
}}

View File

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

View File

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

View File

@@ -10,6 +10,7 @@ import * as Device from "expo-device";
import { DarkTheme, ThemeProvider } from "expo-router/react-navigation";
import { Platform } from "react-native";
import { GlobalModal } from "@/components/GlobalModal";
import { PendingAccountSaveModal } from "@/components/PendingAccountSaveModal";
import { enableTVMenuKeyInterception } from "@/hooks/useTVBackHandler";
import i18n from "@/i18n";
import { DownloadProvider } from "@/providers/DownloadProvider";
@@ -84,7 +85,8 @@ configureReanimatedLogger({
if (!Platform.isTV) {
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldShowBanner: true,
shouldShowList: true,
shouldPlaySound: true,
shouldSetBadge: false,
}),
@@ -333,9 +335,12 @@ function Layout() {
notificationListener.current =
Notifications?.addNotificationReceivedListener(
(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(
"Notification received while app running",
notification,
"Notification received while app running:",
notification.request.content.title,
);
},
);
@@ -530,6 +535,7 @@ function Layout() {
closeButton
/>
{!Platform.isTV && <GlobalModal />}
{!Platform.isTV && <PendingAccountSaveModal />}
</ThemeProvider>
</IntroSheetProvider>
</BottomSheetModalProvider>

View File

@@ -31,6 +31,7 @@
"expo-brightness": "~56.0.5",
"expo-build-properties": "~56.0.15",
"expo-camera": "~56.0.7",
"expo-clipboard": "~56.0.4",
"expo-constants": "~56.0.16",
"expo-crypto": "~56.0.4",
"expo-dev-client": "~56.0.16",
@@ -79,7 +80,7 @@
"react-native-nitro-modules": "0.33.1",
"react-native-pager-view": "8.0.1",
"react-native-qrcode-svg": "^6.3.21",
"react-native-reanimated": "4.4.1",
"react-native-reanimated": "4.3.1",
"react-native-reanimated-carousel": "4.0.3",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "4.25.2",
@@ -108,7 +109,7 @@
"@types/react": "~19.2.10",
"@types/react-test-renderer": "19.1.0",
"cross-env": "10.1.0",
"expo-doctor": "1.19.7",
"expo-doctor": "1.19.9",
"husky": "9.1.7",
"lint-staged": "17.0.5",
"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-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-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-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=="],
@@ -1585,7 +1588,7 @@
"react-native-qrcode-svg": ["react-native-qrcode-svg@6.3.21", "", { "dependencies": { "prop-types": "^15.8.0", "qrcode": "^1.5.4", "text-encoding": "^0.7.0" }, "peerDependencies": { "react": "*", "react-native": ">=0.63.4", "react-native-svg": ">=14.0.0" } }, "sha512-6vcj4rcdpWedvphDR+NSJcudJykNuLgNGFwm2p4xYjR8RdyTzlrELKI5LkO4ANS9cQUbqsfkpippPv64Q2tUtA=="],
"react-native-reanimated": ["react-native-reanimated@4.4.1", "", { "dependencies": { "react-native-is-edge-to-edge": "^1.3.1", "semver": "^7.7.3" }, "peerDependencies": { "react": "*", "react-native": "0.83 - 0.86", "react-native-worklets": "0.9.x" } }, "sha512-WCVBfhLE+AYI2l4inL6PC1vcfNOfmVYRSVSBkPiD12N3jvzByipnygwVpmunyhaNqbiSEDrFYcl7cOJnbHKykw=="],
"react-native-reanimated": ["react-native-reanimated@4.3.1", "", { "dependencies": { "react-native-is-edge-to-edge": "^1.3.1", "semver": "^7.7.3" }, "peerDependencies": { "react": "*", "react-native": "0.81 - 0.85", "react-native-worklets": "0.8.x" } }, "sha512-KhGsS0YkCA+gusgyzlf9hnqzVPIR398KTpqXyqq/+yYJJPAvyEEPKcxlB0xtOOXSMrR2A9uRKVARVQhZwrOh+Q=="],
"react-native-reanimated-carousel": ["react-native-reanimated-carousel@4.0.3", "", { "peerDependencies": { "react": ">=18.0.0", "react-native": ">=0.70.3", "react-native-gesture-handler": ">=2.9.0", "react-native-reanimated": ">=3.0.0" } }, "sha512-YZXlvZNghR5shFcI9hTA7h7bEhh97pfUSLZvLBAshpbkuYwJDKmQXejO/199T6hqGq0wCRwR0CWf2P4Vs6A4Fw=="],

View File

@@ -1,4 +1,5 @@
import { useMemo, useState } from "react";
import type { BottomSheetModal } from "@gorhom/bottom-sheet";
import { useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform, TouchableOpacity, View } from "react-native";
import { Text } from "./common/Text";
@@ -61,6 +62,7 @@ export const BitrateSheet: React.FC<Props> = ({
const isTv = Platform.isTV;
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const sheetModalRef = useRef<BottomSheetModal | null>(null);
const sorted = useMemo(() => {
if (inverted)
@@ -92,7 +94,10 @@ export const BitrateSheet: React.FC<Props> = ({
</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)}
onPress={() => {
setOpen(true);
sheetModalRef.current?.present();
}}
>
<Text numberOfLines={1}>
{BITRATES.find((b) => b.value === selected?.value)?.key}
@@ -103,6 +108,7 @@ export const BitrateSheet: React.FC<Props> = ({
<FilterSheet
open={open}
setOpen={setOpen}
modalRef={sheetModalRef}
title={t("item_card.quality")}
data={sorted}
values={selected ? [selected] : []}

View File

@@ -1,8 +1,9 @@
import type { BottomSheetModal } from "@gorhom/bottom-sheet";
import type {
BaseItemDto,
MediaSourceInfo,
} 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 { Platform, TouchableOpacity, View } from "react-native";
import { Text } from "./common/Text";
@@ -23,6 +24,7 @@ export const MediaSourceSheet: React.FC<Props> = ({
const isTv = Platform.isTV;
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const sheetModalRef = useRef<BottomSheetModal | null>(null);
const getDisplayName = useCallback((source: MediaSourceInfo) => {
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>
<TouchableOpacity
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>
</TouchableOpacity>
@@ -53,6 +58,7 @@ export const MediaSourceSheet: React.FC<Props> = ({
<FilterSheet
open={open}
setOpen={setOpen}
modalRef={sheetModalRef}
title={t("item_card.video")}
data={item.MediaSources || []}
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,6 +1,7 @@
import { Ionicons } from "@expo/vector-icons";
import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
import React, { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Platform, StyleSheet, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
@@ -209,6 +210,7 @@ const PlatformDropdownComponent = ({
expoUIConfig,
bottomSheetConfig,
}: PlatformDropdownProps) => {
const { t } = useTranslation();
const { showModal, hideModal, isVisible } = useGlobalModal();
// Handle controlled open state for Android
@@ -380,7 +382,7 @@ const PlatformDropdownComponent = ({
return (
<TouchableOpacity onPress={handlePress} activeOpacity={0.7}>
{trigger || <Text className='text-white'>Open Menu</Text>}
{trigger || <Text className='text-white'>{t("common.open_menu")}</Text>}
</TouchableOpacity>
);
};

View File

@@ -502,8 +502,8 @@ export const PlayButton: React.FC<Props> = ({
return (
<TouchableOpacity
disabled={!item}
accessibilityLabel='Play button'
accessibilityHint='Tap to play the media'
accessibilityLabel={t("accessibility.play_button")}
accessibilityHint={t("accessibility.play_hint")}
onPress={onPress}
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 { useAtom } from "jotai";
import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { TouchableOpacity, View } from "react-native";
import Animated, {
Easing,
@@ -36,6 +37,7 @@ export const PlayButton: React.FC<Props> = ({
colors,
...props
}: Props) => {
const { t } = useTranslation();
const [globalColorAtom] = useAtom(itemThemeColorAtom);
// Use colors prop if provided, otherwise fallback to global atom
@@ -168,8 +170,8 @@ export const PlayButton: React.FC<Props> = ({
return (
<TouchableOpacity
accessibilityLabel='Play button'
accessibilityHint='Tap to play the media'
accessibilityLabel={t("accessibility.play_button")}
accessibilityHint={t("accessibility.play_hint")}
onPress={onPress}
className={"relative"}
{...props}

View File

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

View File

@@ -69,17 +69,23 @@ export const SaveAccountModal: React.FC<SaveAccountModalProps> = ({
[isAndroid],
);
const isPresentedRef = useRef(false);
useEffect(() => {
if (visible) {
bottomSheetModalRef.current?.present();
} else {
} else if (isPresentedRef.current) {
bottomSheetModalRef.current?.dismiss();
isPresentedRef.current = false;
}
}, [visible]);
const handleSheetChanges = useCallback(
(index: number) => {
if (index === -1) {
if (index >= 0) {
isPresentedRef.current = true;
} else if (index === -1 && isPresentedRef.current) {
isPresentedRef.current = false;
resetState();
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 { useMemo, useState } from "react";
import { useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform, TouchableOpacity, View } from "react-native";
import { Text } from "./common/Text";
@@ -49,6 +50,7 @@ export const TrackSheet: React.FC<Props> = ({
return streams;
}, [streams, streamType, noneOption]);
const [open, setOpen] = useState(false);
const sheetModalRef = useRef<BottomSheetModal | null>(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>
<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)}
onPress={() => {
setOpen(true);
sheetModalRef.current?.present();
}}
>
<Text numberOfLines={1}>
{selected === -1 && streamType === "Subtitle"
? t("common.none")
: selectedSteam?.DisplayTitle || t("common.select", "Select")}
: selectedSteam?.DisplayTitle || t("common.select")}
</Text>
</TouchableOpacity>
</View>
<FilterSheet
open={open}
setOpen={setOpen}
modalRef={sheetModalRef}
title={title}
data={addNoneToSubtitles || []}
values={

View File

@@ -1,7 +1,7 @@
import { Ionicons } from "@expo/vector-icons";
import { Image } from "expo-image";
import { t } from "i18next";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
TouchableOpacity,
@@ -35,6 +35,7 @@ interface DownloadCardProps extends TouchableOpacityProps {
}
export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
const { t } = useTranslation();
const { cancelDownload } = useDownload();
const router = useRouter();
const queryClient = useNetworkAwareQueryClient();
@@ -173,7 +174,9 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
{isTranscoding && (
<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 File

@@ -16,9 +16,12 @@ export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
const { showActionSheetWithOptions } = useActionSheet();
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(() => {
return storage.getString(items[0].SeriesId!);
}, []);
const seriesId = items[0]?.SeriesId;
return seriesId ? storage.getString(seriesId) : undefined;
}, [items[0]?.SeriesId]);
const deleteSeries = useCallback(
async () =>

View File

@@ -1,6 +1,7 @@
import { FontAwesome, Ionicons } from "@expo/vector-icons";
import type { BottomSheetModal } from "@gorhom/bottom-sheet";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { useRef, useState } from "react";
import { TouchableOpacity, View, type ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
import { FilterSheet } from "./FilterSheet";
@@ -34,8 +35,9 @@ export const FilterButton = <T,>({
...props
}: FilterButtonProps<T>) => {
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],
queryFn,
staleTime: 0,
@@ -44,9 +46,15 @@ export const FilterButton = <T,>({
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
onPress={() => {
filters?.length && setOpen(true);
setOpen(true);
sheetModalRef.current?.present();
}}
>
<View
@@ -89,6 +97,8 @@ export const FilterButton = <T,>({
title={title}
open={open}
setOpen={setOpen}
modalRef={sheetModalRef}
loading={isLoading}
data={filters}
values={values}
set={set}

View File

@@ -7,7 +7,14 @@ import {
} from "@gorhom/bottom-sheet";
import { isEqual } from "lodash";
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 {
StyleSheet,
@@ -19,11 +26,21 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { Button } from "../Button";
import { Input } from "../common/Input";
import { Loader } from "../Loader";
interface Props<T> extends ViewProps {
open: boolean;
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;
/** True while the options are loading — shows a loader inside the sheet. */
loading?: boolean;
values: T[];
set: (value: T[]) => void;
title: string;
@@ -66,16 +83,18 @@ const LIMIT = 100;
export const FilterSheet = <T,>({
values,
data: _data,
loading = false,
open,
set,
setOpen,
modalRef,
title,
searchFilter,
renderItemLabel,
disableSearch = false,
multiple = false,
}: Props<T>) => {
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const bottomSheetModalRef = modalRef;
const snapPoints = useMemo(() => ["85%"], []);
const { t } = useTranslation();
const insets = useSafeAreaInsets();
@@ -84,19 +103,24 @@ export const FilterSheet = <T,>({
const [offset, setOffset] = useState<number>(0);
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 filteredData = useMemo(() => {
if (!search) return _data;
if (!deferredSearch) return _data;
const results = [];
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]);
}
}
return results.slice(0, 100);
}, [search, _data, searchFilter]);
}, [deferredSearch, _data, searchFilter]);
useEffect(() => {
if (!data || data.length === 0 || disableSearch) return;
@@ -127,21 +151,28 @@ export const FilterSheet = <T,>({
setData(newData);
}, [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(() => {
if (open) bottomSheetModalRef.current?.present();
else bottomSheetModalRef.current?.dismiss();
if (!open && wasPresentedRef.current) {
bottomSheetModalRef.current?.dismiss();
}
}, [open]);
const handleSheetChanges = useCallback((index: number) => {
if (index === -1) {
if (index >= 0) {
wasPresentedRef.current = true;
} else if (index === -1) {
wasPresentedRef.current = false;
setOpen(false);
}
}, []);
const renderData = useMemo(() => {
if (search.length > 0 && showSearch) return filteredData;
if (deferredSearch.length > 0 && showSearch) return filteredData;
return data;
}, [search, filteredData, data]);
}, [deferredSearch, showSearch, filteredData, data]);
const renderBackdrop = useCallback(
(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 (
<BottomSheetModal
ref={bottomSheetModalRef}
@@ -182,9 +261,15 @@ export const FilterSheet = <T,>({
}}
>
<Text className='font-bold text-2xl'>{title}</Text>
<Text className='mb-2 text-neutral-500'>
{t("search.x_items", { count: _data?.length })}
</Text>
{loading ? (
<View className='my-8 flex items-center justify-center'>
<Loader />
</View>
) : (
<Text className='mb-2 text-neutral-500'>
{t("search.x_items", { count: _data?.length })}
</Text>
)}
{showSearch && (
<Input
placeholder={t("search.search")}
@@ -203,43 +288,7 @@ export const FilterSheet = <T,>({
}}
className='mb-4 flex flex-col rounded-xl overflow-hidden'
>
{renderData?.map((item, index) => (
<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>
))}
{renderedRows}
</View>
{data.length < (_data?.length || 0) && (
<Button

View File

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

View File

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

View File

@@ -3,7 +3,7 @@ import type { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router";
import { t } from "i18next";
import { useAtomValue } from "jotai";
import { useAtomValue, useSetAtom } from "jotai";
import { useCallback, useEffect, useState } from "react";
import {
Alert,
@@ -20,14 +20,16 @@ import { Button } from "@/components/Button";
import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text";
import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
import { QuickConnectCodeModal } from "@/components/login/QuickConnectCodeModal";
import { PreviousServersList } from "@/components/PreviousServersList";
import { SaveAccountModal } from "@/components/SaveAccountModal";
import { Colors } from "@/constants/Colors";
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
import type {
AccountSecurityType,
SavedServer,
} from "@/utils/secureCredentials";
import {
apiAtom,
pendingAccountSaveAtom,
useJellyfin,
userAtom,
} from "@/providers/JellyfinProvider";
import type { SavedServer } from "@/utils/secureCredentials";
const CredentialsSchema = z.object({
username: z.string().min(1, t("login.username_required")),
@@ -37,14 +39,17 @@ export const Login: React.FC = () => {
const api = useAtomValue(apiAtom);
const navigation = useNavigation();
const params = useLocalSearchParams();
const user = useAtomValue(userAtom);
const {
setServer,
login,
removeServer,
initiateQuickConnect,
stopQuickConnectPolling,
loginWithSavedCredential,
loginWithPassword,
} = useJellyfin();
const setPendingAccountSave = useSetAtom(pendingAccountSaveAtom);
const {
apiUrl: _apiUrl,
@@ -64,13 +69,43 @@ export const Login: React.FC = () => {
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 [showSaveModal, setShowSaveModal] = useState(false);
const [pendingLogin, setPendingLogin] = useState<{
username: string;
password: string;
} | null>(null);
// Handle URL params for server connection
useEffect(() => {
@@ -117,55 +152,34 @@ export const Login: React.FC = () => {
const result = CredentialsSchema.safeParse(credentials);
if (!result.success) return;
if (saveAccount) {
setPendingLogin({
username: credentials.username,
password: credentials.password,
});
setShowSaveModal(true);
} else {
await performLogin(credentials.username, credentials.password);
const ok = await performLogin(credentials.username, credentials.password);
// The protection picker shows AFTER a successful login (global modal) —
// never for a failed one.
if (ok && saveAccount) {
setPendingAccountSave({ serverName });
}
};
const performLogin = async (
username: string,
password: string,
options?: {
saveAccount?: boolean;
securityType?: AccountSecurityType;
pinCode?: string;
},
) => {
): Promise<boolean> => {
setLoading(true);
try {
await login(username, password, serverName, options);
await login(username, password, serverName);
return true;
} catch (error) {
if (error instanceof Error) {
Alert.alert(t("login.connection_failed"), error.message);
} else {
Alert.alert(
t("login.connection_failed"),
t("login.an_unexpected_error_occured"),
t("login.an_unexpected_error_occurred"),
);
}
return false;
} finally {
setLoading(false);
setPendingLogin(null);
}
};
const handleSaveAccountConfirm = async (
securityType: AccountSecurityType,
pinCode?: string,
) => {
setShowSaveModal(false);
if (pendingLogin) {
await performLogin(pendingLogin.username, pendingLogin.password, {
saveAccount: true,
securityType,
pinCode,
});
}
};
@@ -259,15 +273,7 @@ export const Login: React.FC = () => {
try {
const code = await initiateQuickConnect();
if (code) {
Alert.alert(
t("login.quick_connect"),
t("login.enter_code_to_login", { code: code }),
[
{
text: t("login.got_it"),
},
],
);
setQuickConnectCode(code);
}
} catch (_error) {
Alert.alert(
@@ -402,7 +408,7 @@ export const Login: React.FC = () => {
{t("server.enter_url_to_jellyfin_server")}
</Text>
<Input
aria-label='Server URL'
aria-label={t("server.server_url")}
placeholder={t("server.server_url_placeholder")}
onChangeText={setServerURL}
value={serverURL}
@@ -444,14 +450,11 @@ export const Login: React.FC = () => {
)}
</KeyboardAvoidingView>
<SaveAccountModal
visible={showSaveModal}
onClose={() => {
setShowSaveModal(false);
setPendingLogin(null);
}}
onSave={handleSaveAccountConfirm}
username={pendingLogin?.username || credentials.username}
{/* Dismissing only hides the code — polling continues so the login still
completes if the code is authorized from another device afterwards. */}
<QuickConnectCodeModal
code={quickConnectCode}
onClose={() => setQuickConnectCode(null)}
/>
</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 {
Alert.alert(
t("login.connection_failed"),
t("login.an_unexpected_error_occured"),
t("login.an_unexpected_error_occurred"),
);
}
} finally {
@@ -499,7 +499,7 @@ export const TVLogin: React.FC = () => {
const message =
error instanceof Error
? error.message
: t("login.an_unexpected_error_occured");
: t("login.an_unexpected_error_occurred");
Alert.alert(t("login.connection_failed"), message);
goToQRScreen();
} finally {
@@ -523,7 +523,7 @@ export const TVLogin: React.FC = () => {
} else {
Alert.alert(
t("login.connection_failed"),
t("login.an_unexpected_error_occured"),
t("login.an_unexpected_error_occurred"),
);
}
} finally {
@@ -768,7 +768,7 @@ export const TVLogin: React.FC = () => {
const message =
error instanceof Error
? error.message
: t("login.an_unexpected_error_occured");
: t("login.an_unexpected_error_occurred");
Alert.alert(t("login.connection_failed"), message);
goToQRScreen();
});

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import { Ionicons } from "@expo/vector-icons";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Platform, Switch, View, type ViewProps } from "react-native";
import { Stepper } from "@/components/inputs/Stepper";
import { Text } from "../common/Text";
@@ -17,20 +18,21 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
const isTv = Platform.isTV;
const media = useMedia();
const { settings, updateSettings } = media;
const { t } = useTranslation();
const alignXOptions: AlignX[] = ["left", "center", "right"];
const alignYOptions: AlignY[] = ["top", "center", "bottom"];
const alignXLabels: Record<AlignX, string> = {
left: "Left",
center: "Center",
right: "Right",
left: t("home.settings.subtitles.align.left"),
center: t("home.settings.subtitles.align.center"),
right: t("home.settings.subtitles.align.right"),
};
const alignYLabels: Record<AlignY, string> = {
top: "Top",
center: "Center",
bottom: "Bottom",
top: t("home.settings.subtitles.align.top"),
center: t("home.settings.subtitles.align.center"),
bottom: t("home.settings.subtitles.align.bottom"),
};
const alignXOptionGroups = useMemo(() => {
@@ -60,16 +62,18 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
return (
<View {...props}>
<ListGroup
title='MPV Subtitle Settings'
title={t("home.settings.subtitles.mpv_settings_title")}
description={
<Text className='text-[#8E8D91] text-xs'>
Advanced subtitle customization for MPV player
{t("home.settings.subtitles.mpv_settings_description")}
</Text>
}
>
{!isTv && (
<>
<ListItem title='Vertical Margin'>
<ListItem
title={t("home.settings.subtitles.mpv_subtitle_margin_y")}
>
<Stepper
value={settings.mpvSubtitleMarginY ?? 0}
step={5}
@@ -81,7 +85,7 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
/>
</ListItem>
<ListItem title='Horizontal Alignment'>
<ListItem title={t("home.settings.subtitles.mpv_subtitle_align_x")}>
<PlatformDropdown
groups={alignXOptionGroups}
trigger={
@@ -96,11 +100,11 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
/>
</View>
}
title='Horizontal Alignment'
title={t("home.settings.subtitles.mpv_subtitle_align_x")}
/>
</ListItem>
<ListItem title='Vertical Alignment'>
<ListItem title={t("home.settings.subtitles.mpv_subtitle_align_y")}>
<PlatformDropdown
groups={alignYOptionGroups}
trigger={
@@ -115,13 +119,13 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
/>
</View>
}
title='Vertical Alignment'
title={t("home.settings.subtitles.mpv_subtitle_align_y")}
/>
</ListItem>
</>
)}
<ListItem title='Opaque Background'>
<ListItem title={t("home.settings.subtitles.opaque_background")}>
<Switch
value={settings.mpvSubtitleBackgroundEnabled ?? false}
onValueChange={(value) =>
@@ -131,7 +135,7 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
</ListItem>
{settings.mpvSubtitleBackgroundEnabled && (
<ListItem title='Background Opacity'>
<ListItem title={t("home.settings.subtitles.background_opacity")}>
<Stepper
value={settings.mpvSubtitleBackgroundOpacity ?? 75}
step={5}

View File

@@ -20,12 +20,7 @@ export const PluginSettings = () => {
>
<ListItem
onPress={() => router.push("/settings/plugins/jellyseerr/page")}
title={"Jellyseerr"}
showArrow
/>
<ListItem
onPress={() => router.push("/settings/plugins/marlin-search/page")}
title='Marlin Search'
title='Jellyseerr'
showArrow
/>
<ListItem
@@ -33,6 +28,11 @@ export const PluginSettings = () => {
title='Streamystats'
showArrow
/>
<ListItem
onPress={() => router.push("/settings/plugins/marlin-search/page")}
title='Marlin Search'
showArrow
/>
<ListItem
onPress={() => router.push("/settings/plugins/kefinTweaks/page")}
title='KefinTweaks'

View File

@@ -58,7 +58,7 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
successHapticFeedback();
Alert.alert(
t("home.settings.quick_connect.success"),
t("home.settings.quick_connect.quick_connect_autorized"),
t("home.settings.quick_connect.quick_connect_authorized"),
);
setQuickConnectCode(undefined);
bottomSheetModalRef?.current?.close();

View File

@@ -1,6 +1,6 @@
import { useQuery } from "@tanstack/react-query";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { Platform, View } from "react-native";
import { Alert, Platform, View } from "react-native";
import { toast } from "sonner-native";
import { Text } from "@/components/common/Text";
import { Colors } from "@/constants/Colors";
@@ -12,6 +12,7 @@ import { ListItem } from "../list/ListItem";
export const StorageSettings = () => {
const { deleteAllFiles, appSizeUsage } = useDownload();
const { t } = useTranslation();
const queryClient = useQueryClient();
const successHapticFeedback = useHaptic("success");
const errorHapticFeedback = useHaptic("error");
@@ -27,16 +28,38 @@ export const StorageSettings = () => {
used: (app.total - app.remaining) / app.total,
};
},
// Keep the bar moving while a download is writing to disk.
refetchInterval: 10 * 1000,
});
const onDeleteClicked = async () => {
try {
await deleteAllFiles();
successHapticFeedback();
} catch (_e) {
errorHapticFeedback();
toast.error(t("home.settings.toasts.error_deleting_files"));
}
const onDeleteClicked = () => {
Alert.alert(
t("home.settings.storage.delete_all_downloaded_files_confirm"),
t("home.settings.storage.delete_all_downloaded_files_confirm_desc"),
[
{
text: t("common.cancel"),
style: "cancel",
},
{
text: t("common.ok"),
style: "destructive",
onPress: async () => {
try {
await deleteAllFiles();
successHapticFeedback();
} catch (_e) {
errorHapticFeedback();
toast.error(t("home.settings.toasts.error_deleting_files"));
} finally {
// Reflect the freed space immediately instead of waiting for
// the next poll.
queryClient.invalidateQueries({ queryKey: ["appSize"] });
}
},
},
],
);
};
const calculatePercentage = (value: number, total: number) => {

View File

@@ -1,8 +1,8 @@
import * as Application from "expo-application";
import { useAtom } from "jotai";
import { useTranslation } from "react-i18next";
import { View, type ViewProps } from "react-native";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getVersionInfo } from "@/utils/version";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
@@ -13,10 +13,9 @@ export const UserInfo: React.FC<Props> = ({ ...props }) => {
const [user] = useAtom(userAtom);
const { t } = useTranslation();
const version =
Application?.nativeApplicationVersion ||
Application?.nativeBuildVersion ||
"N/A";
// Graduated build identifier — see utils/version.ts:
// dev → "0.54.1 · branch · commit", develop/CI → "0.54.1 · commit · #run", production → "0.54.1".
const { display: version } = getVersionInfo();
return (
<View {...props}>

View File

@@ -3,6 +3,7 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image";
import { useAtomValue } from "jotai";
import React, { useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Animated,
Easing,
@@ -106,6 +107,7 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
scaleAmount = 1.05,
imageUrlGetter,
}) => {
const { t } = useTranslation();
const api = useAtomValue(apiAtom);
const posterSizes = useScaledTVPosterSizes();
const typography = useScaledTVTypography();
@@ -371,7 +373,7 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
fontWeight: "700",
}}
>
Now Playing
{t("music.now_playing")}
</Text>
</View>
) : null;

View File

@@ -1,5 +1,6 @@
import { Ionicons } from "@expo/vector-icons";
import React from "react";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
Animated,
@@ -28,6 +29,7 @@ export const TVSubtitleResultCard = React.forwardRef<
const styles = createStyles(typography);
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.03 });
const { t } = useTranslation();
return (
<Pressable
@@ -152,7 +154,7 @@ export const TVSubtitleResultCard = React.forwardRef<
},
]}
>
<Text style={styles.flagText}>Hash Match</Text>
<Text style={styles.flagText}>{t("player.hash_match")}</Text>
</View>
)}
{result.hearingImpaired && (

View File

@@ -183,7 +183,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
<SkipButton
showButton={showSkipButton}
onPress={skipIntro}
buttonText='Skip Intro'
buttonText={t("player.skip_intro")}
/>
{/* Smart Skip Credits behavior:
- Show "Skip Credits" if there's content after credits OR no next episode
@@ -193,7 +193,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
showSkipCreditButton && (hasContentAfterCredits || !nextItem)
}
onPress={skipCredit}
buttonText='Skip Credits'
buttonText={t("player.skip_credits")}
/>
{settings.autoPlayNextEpisode !== false &&
(settings.maxAutoPlayEpisodeCount.value === -1 ||

View File

@@ -27,7 +27,7 @@ const ContinueWatchingOverlay: React.FC<ContinueWatchingOverlayProps> = ({
}
>
<Text className='text-2xl font-bold text-white py-4 '>
Are you still watching ?
{t("player.still_watching")}
</Text>
<Button
onPress={() => {

View File

@@ -4,6 +4,7 @@ import type {
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client";
import { type FC, useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform, TouchableOpacity, View } from "react-native";
import useRouter from "@/hooks/useAppRouter";
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
@@ -57,6 +58,7 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
showTechnicalInfo = false,
onToggleTechnicalInfo,
}) => {
const { t } = useTranslation();
const router = useRouter();
const insets = useControlsSafeAreaInsets();
const lightHapticFeedback = useHaptic("light");
@@ -127,8 +129,8 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
onPress={toggleOrientation}
disabled={isTogglingOrientation}
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
accessibilityLabel='Toggle screen orientation'
accessibilityHint='Toggles the screen orientation between portrait and landscape'
accessibilityLabel={t("accessibility.toggle_orientation")}
accessibilityHint={t("accessibility.toggle_orientation_hint")}
>
<MaterialIcons
name='screen-rotation'

View File

@@ -7,6 +7,7 @@ import {
useMemo,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { Platform, StyleSheet, Text, View } from "react-native";
import Animated, {
Easing,
@@ -184,6 +185,7 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
currentAudioIndex,
}) => {
const typography = useScaledTVTypography();
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const safeInsets = useControlsSafeAreaInsets();
const [info, setInfo] = useState<TechnicalInfo | null>(null);
@@ -312,13 +314,13 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
)}
{info?.videoCodec && (
<Text style={textStyle}>
Video: {formatCodec(info.videoCodec)}
{t("player.technical_info.video")} {formatCodec(info.videoCodec)}
{info.fps ? ` @ ${formatFps(info.fps)} fps` : ""}
</Text>
)}
{info?.audioCodec && (
<Text style={textStyle}>
Audio: {formatCodec(info.audioCodec)}
{t("player.technical_info.audio")} {formatCodec(info.audioCodec)}
{streamInfo?.audioChannels
? ` ${formatAudioChannels(streamInfo.audioChannels)}`
: ""}
@@ -326,12 +328,13 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
)}
{streamInfo?.subtitleCodec && (
<Text style={textStyle}>
Subtitle: {formatCodec(streamInfo.subtitleCodec)}
{t("player.technical_info.subtitle")}{" "}
{formatCodec(streamInfo.subtitleCodec)}
</Text>
)}
{(info?.videoBitrate || info?.audioBitrate) && (
<Text style={textStyle}>
Bitrate:{" "}
{t("player.technical_info.bitrate")}{" "}
{info.videoBitrate
? formatBitrate(info.videoBitrate)
: info.audioBitrate
@@ -341,21 +344,27 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
)}
{info?.cacheSeconds !== undefined && (
<Text style={textStyle}>
Buffer: {info.cacheSeconds.toFixed(1)}s
{t("player.technical_info.buffer_seconds", {
seconds: info.cacheSeconds.toFixed(1),
})}
</Text>
)}
{info?.voDriver && (
<Text style={textStyle}>
VO: {info.voDriver}
{t("player.technical_info.vo")} {info.voDriver}
{info.hwdec ? ` / ${info.hwdec}` : ""}
</Text>
)}
{info?.droppedFrames !== undefined && info.droppedFrames > 0 && (
<Text style={[textStyle, styles.warningText]}>
Dropped: {info.droppedFrames} frames
{t("player.technical_info.dropped_frames", {
count: info.droppedFrames,
})}
</Text>
)}
{!info && !playMethod && <Text style={textStyle}>Loading...</Text>}
{!info && !playMethod && (
<Text style={textStyle}>{t("player.technical_info.loading")}</Text>
)}
</View>
</Animated.View>
);

View File

@@ -1,5 +1,6 @@
import { Ionicons } from "@expo/vector-icons";
import React, { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Platform, View } from "react-native";
import {
type OptionGroup,
@@ -54,6 +55,7 @@ export const AspectRatioSelector: React.FC<AspectRatioSelectorProps> = ({
onRatioChange,
disabled = false,
}) => {
const { t } = useTranslation();
const lightHapticFeedback = useHaptic("light");
const handleRatioSelect = (ratio: AspectRatio) => {
@@ -66,7 +68,10 @@ export const AspectRatioSelector: React.FC<AspectRatioSelectorProps> = ({
{
options: ASPECT_RATIO_OPTIONS.map((option) => ({
type: "radio" as const,
label: option.label,
label:
option.id === "default"
? t("player.aspect_ratio_original")
: option.label,
value: option.id,
selected: option.id === currentRatio,
onPress: () => handleRatioSelect(option.id),
@@ -94,7 +99,7 @@ export const AspectRatioSelector: React.FC<AspectRatioSelectorProps> = ({
return (
<PlatformDropdown
title='Aspect Ratio'
title={t("player.aspect_ratio")}
groups={optionGroups}
trigger={trigger}
bottomSheetConfig={{

View File

@@ -1,6 +1,7 @@
import { Ionicons } from "@expo/vector-icons";
import { useLocalSearchParams } from "expo-router";
import { useCallback, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { Platform, View } from "react-native";
import { BITRATES } from "@/components/BitrateSelector";
import {
@@ -47,6 +48,7 @@ const DropdownView = ({
const { settings, updateSettings } = useSettings();
const router = useRouter();
const isOffline = useOfflineMode();
const { t } = useTranslation();
const { subtitleIndex, audioIndex, bitrateValue, playbackPosition } =
useLocalSearchParams<{
@@ -101,7 +103,7 @@ const DropdownView = ({
// Quality Section
if (!isOffline) {
groups.push({
title: "Quality",
title: t("player.menu.quality"),
options:
BITRATES?.map((bitrate) => ({
type: "radio" as const,
@@ -116,7 +118,7 @@ const DropdownView = ({
// Subtitle Section
if (subtitleTracks && subtitleTracks.length > 0) {
groups.push({
title: "Subtitles",
title: t("player.menu.subtitles"),
options: subtitleTracks.map((sub) => ({
type: "radio" as const,
label: sub.name,
@@ -128,7 +130,7 @@ const DropdownView = ({
// Subtitle Scale Section
groups.push({
title: "Subtitle Scale",
title: t("player.menu.subtitle_scale"),
options: SUBTITLE_SCALE_PRESETS.map((preset) => ({
type: "radio" as const,
label: preset.label,
@@ -142,7 +144,7 @@ const DropdownView = ({
// Audio Section
if (audioTracks && audioTracks.length > 0) {
groups.push({
title: "Audio",
title: t("player.menu.audio"),
options: audioTracks.map((track) => ({
type: "radio" as const,
label: track.name,
@@ -156,7 +158,7 @@ const DropdownView = ({
// Speed Section
if (setPlaybackSpeed) {
groups.push({
title: "Speed",
title: t("player.menu.speed"),
options: PLAYBACK_SPEEDS.map((speed) => ({
type: "radio" as const,
label: speed.label,
@@ -174,8 +176,8 @@ const DropdownView = ({
{
type: "action" as const,
label: showTechnicalInfo
? "Hide Technical Info"
: "Show Technical Info",
? t("player.menu.hide_technical_info")
: t("player.menu.show_technical_info"),
onPress: onToggleTechnicalInfo,
},
],
@@ -185,6 +187,7 @@ const DropdownView = ({
return groups;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
t,
isOffline,
bitrateValue,
changeBitrate,
@@ -217,7 +220,7 @@ const DropdownView = ({
return (
<PlatformDropdown
title='Playback Options'
title={t("player.menu.playback_options")}
groups={optionGroups}
trigger={trigger}
expoUIConfig={{}}

View File

@@ -3,6 +3,7 @@ import { Alert } from "react-native";
import { type SharedValue, useSharedValue } from "react-native-reanimated";
import { useTVBackPress } from "@/hooks/useTVBackPress";
import { useTVEventHandler } from "@/hooks/useTVEventHandler";
import i18n from "@/i18n";
interface UseRemoteControlProps {
showControls: boolean;
@@ -124,17 +125,23 @@ export function useRemoteControl({
// Controls are hidden, so confirm before leaving playback.
Alert.alert(
"Stop Playback",
i18n.t("player.stopPlayback"),
videoTitleRef.current
? `Stop playing "${videoTitleRef.current}"?`
: "Are you sure you want to stop playback?",
? i18n.t("player.stopPlayingTitle", {
title: videoTitleRef.current,
})
: i18n.t("player.stopPlayingConfirm"),
[
{
text: "Cancel",
text: i18n.t("common.cancel"),
style: "cancel",
onPress: () => onCancelExitRef.current?.(),
},
{ text: "Stop", style: "destructive", onPress: onBackRef.current },
{
text: i18n.t("common.stop"),
style: "destructive",
onPress: onBackRef.current,
},
],
);
return true;

View File

@@ -97,6 +97,14 @@
"credentialsSource": "local",
"config": "ios-production.yml"
}
},
"ci": {
"extends": "production",
"autoIncrement": false
},
"ci_tv": {
"extends": "production_tv",
"autoIncrement": false
}
},
"submit": {

View File

@@ -1,13 +1,19 @@
// Imported from expo-router's bundled copy, NOT "@react-navigation/*": as of
// SDK 56 expo-router's Metro check rejects direct @react-navigation imports.
import { useRouter } from "expo-router";
import { useCallback, useMemo } from "react";
import { NavigationContext } from "expo-router/react-navigation";
import { useCallback, useContext, useMemo } from "react";
import { useOfflineMode } from "@/providers/OfflineModeProvider";
/**
* Drop-in replacement for expo-router's useRouter that automatically
* preserves offline state across navigation.
* preserves offline state across navigation and guards against duplicate
* screens from rapid taps.
*
* - For object-form navigation, automatically adds offline=true when in offline context
* - For string URLs, passes through unchanged (caller handles offline param)
* - push() is a no-op while the source screen is not focused, so taps fired
* before the pushed screen has rendered (slow devices) can't stack duplicates
*
* @example
* import useRouter from "@/hooks/useAppRouter";
@@ -19,8 +25,18 @@ export function useAppRouter() {
const router = useRouter();
const isOffline = useOfflineMode();
// Optional: undefined when used outside a navigator (root layout, providers).
// When present it reflects the focus state of the screen this hook lives in.
const navigation = useContext(NavigationContext);
const push = useCallback(
(href: Parameters<typeof router.push>[0]) => {
// Rapid-push guard: a push blurs the source screen synchronously in the
// navigation state (only the native render is slow). Any further push from
// this screen — duplicate or not — is dropped until focus returns, so taps
// fired before the pushed screen renders can't stack screens.
// No navigation context => nothing to guard (deep-link pushes from root).
if (navigation?.isFocused?.() === false) return;
if (typeof href === "string") {
router.push(href as any);
} else {
@@ -36,7 +52,7 @@ export function useAppRouter() {
} as any);
}
},
[router, isOffline],
[router, isOffline, navigation],
);
const replace = useCallback(

View File

@@ -143,7 +143,7 @@ export class JellyseerrApi {
if (inRange(status, 200, 299)) {
if (data.version < "2.0.0") {
const error = t(
"jellyseerr.toasts.jellyseer_does_not_meet_requirements",
"jellyseerr.toasts.jellyseerr_does_not_meet_requirements",
);
toast.error(error);
throw Error(error);

View File

@@ -1,10 +1,12 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<receiver
android:name=".TvRecommendationsReceiver"
android:exported="true">
<receiver android:name=".TvRecommendationsReceiver" android:exported="true">
<intent-filter>
<action android:name="android.media.tv.action.INITIALIZE_PROGRAMS" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<intent-filter>
<action android:name="android.media.tv.action.PREVIEW_PROGRAM_BROWSABLE_DISABLED" />
</intent-filter>
</receiver>
</application>

View File

@@ -16,12 +16,13 @@ import androidx.tvprovider.media.tv.PreviewProgram
import androidx.tvprovider.media.tv.TvContractCompat
import org.json.JSONArray
import org.json.JSONObject
import java.security.MessageDigest
internal object TvRecommendationsPublisher {
private const val TAG = "TvRecommendations"
private const val PREFS_NAME = "StreamyfinTvRecommendations"
private const val KEY_PAYLOAD = "payload"
private const val KEY_CHANNEL_ID = "channelId"
private const val KEY_CHANNEL_ID_PREFIX = "channelId_"
private const val KEY_PROGRAM_IDS = "programIds"
private const val DEFAULT_CHANNEL_NAME = "Continue and Next Up"
@@ -61,31 +62,61 @@ internal object TvRecommendationsPublisher {
fun clear(context: Context): Boolean {
val prefs = preferences(context)
val channelId = prefs.getLong(KEY_CHANNEL_ID, -1L)
val programIds = prefs.getString(KEY_PROGRAM_IDS, null)?.let(::JSONObject)
val contentResolver = context.contentResolver
if (programIds != null) {
// KEY_PROGRAM_IDS is now { channelId: "{ providerId: programId }" }
val allProgramIds = prefs.getString(KEY_PROGRAM_IDS, null)?.let(::JSONObject)
if (allProgramIds != null) {
var deletedPrograms = 0
val keys = programIds.keys()
while (keys.hasNext()) {
val key = keys.next()
val programId = programIds.optLong(key, -1L)
if (programId > 0L) {
contentResolver.delete(
TvContractCompat.buildPreviewProgramUri(programId),
null,
null
)
deletedPrograms += 1
val channelKeys = allProgramIds.keys()
while (channelKeys.hasNext()) {
val channelIdStr = channelKeys.next()
val programIdsJson = allProgramIds.optString(channelIdStr)
if (programIdsJson.isBlank()) continue
try {
val programIds = JSONObject(programIdsJson)
val keys = programIds.keys()
while (keys.hasNext()) {
val providerId = keys.next()
val programId = programIds.optLong(providerId, -1L)
if (programId > 0L) {
deletePreviewProgram(contentResolver, programId)
deletedPrograms += 1
}
}
} catch (e: Exception) {
Log.w(TAG, "clear(): failed to parse programIds for channel $channelIdStr", e)
}
// Notify the channel
val channelId = channelIdStr.toLongOrNull() ?: -1L
if (channelId > 0L) {
try {
contentResolver.notifyChange(TvContractCompat.buildChannelUri(channelId), null)
} catch (e: SecurityException) {
Log.w(TAG, "clear(): lost provider permission, cannot notify channel $channelId", e)
}
}
// Remove per-channel pref
prefs.edit().remove("programIds_$channelIdStr").apply()
}
Log.d(TAG, "clear(): deleted $deletedPrograms preview program(s)")
}
if (channelId > 0L) {
contentResolver.notifyChange(TvContractCompat.buildChannelUri(channelId), null)
Log.d(TAG, "clear(): notified channel $channelId")
// Also handle legacy format (flat { providerId: programId }) for migration
val legacyProgramIds = prefs.getString("legacy_" + KEY_PROGRAM_IDS, null)?.let(::JSONObject)
if (legacyProgramIds != null) {
val keys = legacyProgramIds.keys()
while (keys.hasNext()) {
val key = keys.next()
val programId = legacyProgramIds.optLong(key, -1L)
if (programId > 0L) {
deletePreviewProgram(contentResolver, programId)
}
}
prefs.edit().remove("legacy_" + KEY_PROGRAM_IDS).apply()
}
prefs.edit()
@@ -96,128 +127,274 @@ internal object TvRecommendationsPublisher {
return true
}
/**
* Delete a single preview program from the TvProvider.
* Called by clear(), synchronize() (stale programs), and TvRecommendationsReceiver (user removed).
*/
fun deletePreviewProgram(context: Context, programId: Long) {
try {
context.contentResolver.delete(
TvContractCompat.buildPreviewProgramUri(programId),
null,
null
)
Log.d(TAG, "deletePreviewProgram(): deleted programId=$programId")
// Also remove from stored programIds prefs
removeProgramFromPrefs(context, programId)
} catch (e: SecurityException) {
Log.w(TAG, "deletePreviewProgram(): lost provider permission for programId=$programId", e)
}
}
private fun deletePreviewProgram(contentResolver: android.content.ContentResolver, programId: Long) {
try {
contentResolver.delete(
TvContractCompat.buildPreviewProgramUri(programId),
null,
null
)
} catch (e: SecurityException) {
Log.w(TAG, "deletePreviewProgram(): lost provider permission for programId=$programId", e)
}
}
private fun removeProgramFromPrefs(context: Context, programId: Long) {
val prefs = preferences(context)
val programIdsJson = prefs.getString(KEY_PROGRAM_IDS, null) ?: return
try {
val channelMap = JSONObject(programIdsJson)
val channelKeys = channelMap.keys()
while (channelKeys.hasNext()) {
val channelId = channelKeys.next()
val inner = channelMap.optJSONObject(channelId) ?: continue
val providerKeys = inner.keys()
while (providerKeys.hasNext()) {
val providerId = providerKeys.next()
if (inner.optLong(providerId, -1L) == programId) {
inner.remove(providerId)
if (inner.length() == 0) {
channelMap.remove(channelId)
}
break
}
}
}
prefs.edit().putString(KEY_PROGRAM_IDS, channelMap.toString()).apply()
} catch (e: Exception) {
Log.w(TAG, "removeProgramFromPrefs(): failed to update prefs for programId=$programId", e)
}
}
private fun synchronize(context: Context, payload: JSONObject): Boolean {
val sections = payload.optJSONArray("sections") ?: JSONArray()
val firstSection = if (sections.length() > 0) sections.optJSONObject(0) else null
val sectionTitle = firstSection?.optString("title")?.takeIf { it.isNotBlank() } ?: DEFAULT_CHANNEL_NAME
val items = firstSection?.optJSONArray("items") ?: JSONArray()
Log.d(
TAG,
"synchronize(): using section \"$sectionTitle\" with ${items.length()} item(s)"
)
val channelId = getOrCreateChannel(context, sectionTitle)
if (channelId <= 0L) {
Log.w(TAG, "synchronize(): failed to get or create preview channel")
if (sections.length() == 0) {
Log.w(TAG, "synchronize(): no sections in payload")
return false
}
Log.d(TAG, "synchronize(): publishing into channelId=$channelId")
val prefs = preferences(context)
val allNextProgramIds = JSONObject()
var totalActive = 0
var totalDeleted = 0
val previousProgramIds = preferences(context)
.getString(KEY_PROGRAM_IDS, null)
?.let(::JSONObject)
?: JSONObject()
val nextProgramIds = JSONObject()
val activeProviderIds = mutableSetOf<String>()
for (sectionIndex in 0 until sections.length()) {
val section = sections.optJSONObject(sectionIndex) ?: continue
val sectionTitle = section.optString("title")?.takeIf { it.isNotBlank() } ?: DEFAULT_CHANNEL_NAME
val items = section.optJSONArray("items") ?: JSONArray()
for (index in 0 until items.length()) {
val item = items.optJSONObject(index) ?: continue
val providerId = item.optString("id")
if (providerId.isBlank()) continue
val programId = upsertPreviewProgram(
context = context,
channelId = channelId,
item = item,
previousProgramId = previousProgramIds.optLong(providerId, -1L),
weight = index
Log.d(
TAG,
"synchronize(): section \"$sectionTitle\" ($sectionIndex/${sections.length()}) with ${items.length()} item(s)"
)
if (programId > 0L) {
activeProviderIds += providerId
nextProgramIds.put(providerId, programId)
Log.d(TAG, "synchronize(): upserted program for item=$providerId programId=$programId")
val channelId = getOrCreateChannel(context, sectionTitle)
if (channelId <= 0L) {
Log.w(TAG, "synchronize(): failed to get or create channel for \"$sectionTitle\"")
continue
}
}
var deletedPrograms = 0
val previousKeys = previousProgramIds.keys()
while (previousKeys.hasNext()) {
val providerId = previousKeys.next()
if (activeProviderIds.contains(providerId)) continue
// Per Android docs: check channel.isBrowsable() and request if needed.
if (!isChannelBrowsable(context, channelId)) {
Log.d(TAG, "synchronize(): channel $channelId not browsable, requesting browsable")
TvContractCompat.requestChannelBrowsable(context, channelId)
}
val programId = previousProgramIds.optLong(providerId, -1L)
if (programId > 0L) {
context.contentResolver.delete(
TvContractCompat.buildPreviewProgramUri(programId),
null,
null
val prefKey = "programIds_$channelId"
val previousProgramIds = prefs.getString(prefKey, null)
?.let(::JSONObject)
?: JSONObject()
val nextProgramIds = JSONObject()
val activeProviderIds = mutableSetOf<String>()
for (index in 0 until items.length()) {
val item = items.optJSONObject(index) ?: continue
val providerId = item.optString("id")
if (providerId.isBlank()) continue
val programId = upsertPreviewProgram(
context = context,
channelId = channelId,
item = item,
previousProgramId = previousProgramIds.optLong(providerId, -1L),
weight = index
)
deletedPrograms += 1
Log.d(TAG, "synchronize(): deleted stale programId=$programId for item=$providerId")
if (programId > 0L) {
activeProviderIds += providerId
nextProgramIds.put(providerId, programId)
Log.d(TAG, "synchronize(): upserted program for item=$providerId programId=$programId")
}
}
var deletedPrograms = 0
val previousKeys = previousProgramIds.keys()
while (previousKeys.hasNext()) {
val providerId = previousKeys.next()
if (activeProviderIds.contains(providerId)) continue
val programId = previousProgramIds.optLong(providerId, -1L)
if (programId > 0L) {
deletePreviewProgram(context, programId)
deletedPrograms += 1
Log.d(TAG, "synchronize(): deleted stale programId=$programId for item=$providerId")
}
}
allNextProgramIds.put(channelId.toString(), nextProgramIds.toString())
prefs.edit().putString(prefKey, nextProgramIds.toString()).apply()
totalActive += activeProviderIds.size
totalDeleted += deletedPrograms
logProviderState(context, channelId)
}
preferences(context)
.edit()
.putLong(KEY_CHANNEL_ID, channelId)
.putString(KEY_PROGRAM_IDS, nextProgramIds.toString())
.apply()
logProviderState(context, channelId)
// Store all channel program IDs for clear() to use
prefs.edit().putString(KEY_PROGRAM_IDS, allNextProgramIds.toString()).apply()
Log.d(
TAG,
"synchronize(): completed with ${activeProviderIds.size} active program(s), deleted $deletedPrograms stale program(s)"
"synchronize(): completed across ${sections.length()} section(s), $totalActive active program(s), deleted $totalDeleted stale program(s)"
)
return true
}
/**
* Query provider to check if a channel is browsable.
* Per Android docs: "check channel.isBrowsable() before updating programs."
*/
private fun isChannelBrowsable(context: Context, channelId: Long): Boolean {
return try {
context.contentResolver.query(
TvContractCompat.buildChannelUri(channelId),
null,
null,
null,
null
)?.use { cursor ->
if (cursor.moveToFirst()) {
val browsableIndex = cursor.getColumnIndex(TvContractCompat.Channels.COLUMN_BROWSABLE)
if (browsableIndex >= 0) cursor.getInt(browsableIndex) == 1 else true
} else {
false
}
} ?: false
} catch (e: SecurityException) {
Log.w(TAG, "isChannelBrowsable(): lost provider permission for channelId=$channelId", e)
true // Assume browsable if we can't check, to avoid blocking updates
}
}
/**
* Query provider to verify a channel actually exists.
* Prevents the channel-delete-recreate bug: if update() returns 0 rows
* we must first check whether the channel was deleted by the system
* or if the update simply failed for another reason.
*/
private fun channelExistsInProvider(context: Context, channelId: Long): Boolean {
return try {
context.contentResolver.query(
TvContractCompat.buildChannelUri(channelId),
null,
null,
null,
null
)?.use { cursor ->
cursor.moveToFirst()
} ?: false
} catch (e: SecurityException) {
Log.w(TAG, "channelExistsInProvider(): lost provider permission for channelId=$channelId", e)
false
}
}
private fun getOrCreateChannel(context: Context, displayName: String): Long {
val prefs = preferences(context)
val existingChannelId = prefs.getLong(KEY_CHANNEL_ID, -1L)
val channelKey = getChannelKey(displayName)
val existingChannelId = prefs.getLong(channelKey, -1L)
val contentResolver = context.contentResolver
if (existingChannelId > 0L) {
val updated = Channel.Builder()
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
.setDisplayName(displayName)
.setAppLinkIntentUri(buildIntentUri(context, "streamyfin://"))
.build()
// Query provider first to verify channel actually exists (prevents recreate bug)
val exists = channelExistsInProvider(context, existingChannelId)
val updatedRows = contentResolver.update(
TvContractCompat.buildChannelUri(existingChannelId),
updated.toContentValues(),
null,
null
)
if (exists) {
// Channel exists — update it in place, never recreate
val updated = Channel.Builder()
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
.setDisplayName(displayName)
.setAppLinkIntentUri(buildIntentUri(context, "streamyfin://"))
.build()
if (updatedRows > 0) {
TvContractCompat.requestChannelBrowsable(context, existingChannelId)
storeChannelLogo(context, existingChannelId)
Log.d(TAG, "getOrCreateChannel(): updated existing channelId=$existingChannelId and requested browsable")
return existingChannelId
try {
val updatedRows = contentResolver.update(
TvContractCompat.buildChannelUri(existingChannelId),
updated.toContentValues(),
null,
null
)
if (updatedRows > 0) {
TvContractCompat.requestChannelBrowsable(context, existingChannelId)
storeChannelLogo(context, existingChannelId)
Log.d(TAG, "getOrCreateChannel(): updated existing channelId=$existingChannelId and requested browsable")
return existingChannelId
}
// Update returned 0 rows but channel exists — log and return existing ID, don't recreate
Log.e(TAG, "getOrCreateChannel(): update returned 0 for existing channelId=$existingChannelId but channel exists — not recreating")
return existingChannelId
} catch (e: SecurityException) {
Log.w(TAG, "getOrCreateChannel(): lost provider permission updating channelId=$existingChannelId", e)
return existingChannelId
}
}
Log.w(TAG, "getOrCreateChannel(): stored channelId=$existingChannelId was stale, recreating")
prefs.edit().remove(KEY_CHANNEL_ID).apply()
// Channel truly doesn't exist in provider — recreate
Log.w(TAG, "getOrCreateChannel(): channelId=$existingChannelId not in provider, recreating")
prefs.edit().remove(channelKey).apply()
}
// Create a new channel
val channel = Channel.Builder()
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
.setDisplayName(displayName)
.setAppLinkIntentUri(buildIntentUri(context, "streamyfin://"))
.build()
val channelUri = contentResolver.insert(
TvContractCompat.Channels.CONTENT_URI,
channel.toContentValues()
) ?: return -1L
val channelUri = try {
contentResolver.insert(
TvContractCompat.Channels.CONTENT_URI,
channel.toContentValues()
)
} catch (e: SecurityException) {
Log.e(TAG, "getOrCreateChannel(): lost provider permission, cannot create channel", e)
null
} ?: return -1L
val channelId = ContentUris.parseId(channelUri)
prefs.edit().putLong(channelKey, channelId).apply()
TvContractCompat.requestChannelBrowsable(context, channelId)
storeChannelLogo(context, channelId)
Log.d(TAG, "getOrCreateChannel(): created new channelId=$channelId displayName=\"$displayName\"")
@@ -225,6 +402,10 @@ internal object TvRecommendationsPublisher {
return channelId
}
private fun getChannelKey(displayName: String): String {
return KEY_CHANNEL_ID_PREFIX + displayName.hashCode()
}
private fun upsertPreviewProgram(
context: Context,
channelId: Long,
@@ -249,42 +430,67 @@ internal object TvRecommendationsPublisher {
builder.setDescription(it)
}
// Per Android docs: use unique URIs for all images to avoid stale cache
imageUrl.takeIf { it.isNotBlank() }?.let {
val imageUri = Uri.parse(it)
val uniqueImageUrl = appendCacheBuster(it)
val imageUri = Uri.parse(uniqueImageUrl)
builder.setPosterArtUri(imageUri)
builder.setThumbnailUri(imageUri)
}
val contentValues = builder.build().toContentValues()
val contentResolver = context.contentResolver
if (previousProgramId > 0L) {
val updatedRows = contentResolver.update(
TvContractCompat.buildPreviewProgramUri(previousProgramId),
contentValues,
null,
null
)
try {
val updatedRows = contentResolver.update(
TvContractCompat.buildPreviewProgramUri(previousProgramId),
contentValues,
null,
null
)
if (updatedRows > 0) {
Log.d(TAG, "upsertPreviewProgram(): updated existing programId=$previousProgramId")
return previousProgramId
if (updatedRows > 0) {
Log.d(TAG, "upsertPreviewProgram(): updated existing programId=$previousProgramId")
return previousProgramId
}
Log.w(TAG, "upsertPreviewProgram(): existing programId=$previousProgramId was stale, inserting new row")
} catch (e: SecurityException) {
Log.w(TAG, "upsertPreviewProgram(): lost provider permission for programId=$previousProgramId", e)
}
Log.w(TAG, "upsertPreviewProgram(): existing programId=$previousProgramId was stale, inserting new row")
}
val insertedUri = contentResolver.insert(
TvContractCompat.PreviewPrograms.CONTENT_URI,
contentValues
) ?: return -1L
val insertedUri = try {
contentResolver.insert(
TvContractCompat.PreviewPrograms.CONTENT_URI,
contentValues
)
} catch (e: SecurityException) {
Log.w(TAG, "upsertPreviewProgram(): lost provider permission, cannot insert program", e)
null
} ?: return -1L
val programId = ContentUris.parseId(insertedUri)
Log.d(TAG, "upsertPreviewProgram(): inserted new programId=$programId")
return programId
}
/**
* Append a stable cache key derived from the image URL.
* The Jellyfin image URLs already include a `tag=` query param (etag)
* that changes whenever the image content changes, so a deterministic
* hash of the URL is sufficient — the param only changes when the URL
* (and therefore the image) actually changes, avoiding unnecessary
* re-downloads on every sync.
*/
private fun appendCacheBuster(imageUrl: String): String {
val digest = MessageDigest.getInstance("MD5").digest(imageUrl.toByteArray())
val hash = digest.joinToString("") { "%02x".format(it) }.substring(0, 16)
val separator = if (imageUrl.contains("?")) "&" else "?"
return "$imageUrl${separator}_v=$hash"
}
private fun buildIntentUri(context: Context, deepLink: String): Uri {
val intent = Intent(Intent.ACTION_VIEW).apply {
data = Uri.parse(deepLink)
@@ -306,13 +512,17 @@ internal object TvRecommendationsPublisher {
private fun storeChannelLogo(context: Context, channelId: Long) {
val bitmap = applicationIconBitmap(context) ?: return
val outputStream = context.contentResolver.openOutputStream(
TvContractCompat.buildChannelLogoUri(channelId)
) ?: return
try {
val outputStream = context.contentResolver.openOutputStream(
TvContractCompat.buildChannelLogoUri(channelId)
) ?: return
outputStream.use { stream ->
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
stream.flush()
outputStream.use { stream ->
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
stream.flush()
}
} catch (e: SecurityException) {
Log.w(TAG, "storeChannelLogo(): lost provider permission for channelId=$channelId", e)
}
}
@@ -341,9 +551,14 @@ internal object TvRecommendationsPublisher {
return bitmap
}
fun getChannelId(context: Context, displayName: String = DEFAULT_CHANNEL_NAME): Long {
return preferences(context).getLong(getChannelKey(displayName), -1L)
}
private fun preferences(context: Context): SharedPreferences {
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
}
private fun logProviderState(context: Context, channelId: Long) {
val contentResolver = context.contentResolver
@@ -372,8 +587,10 @@ internal object TvRecommendationsPublisher {
Log.w(TAG, "logProviderState(): channelId=$channelId exists=false")
}
} ?: Log.w(TAG, "logProviderState(): channel query returned null for channelId=$channelId")
} catch (error: Exception) {
Log.w(TAG, "logProviderState(): failed to query channelId=$channelId", error)
}
} catch (error: SecurityException) {
Log.w(TAG, "logProviderState(): lost provider permission for channelId=$channelId", error)
} catch (error: Exception) {
Log.w(TAG, "logProviderState(): failed to query channelId=$channelId", error)
}
}
}

View File

@@ -3,16 +3,24 @@ package expo.modules.tvrecommendations
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.ContentUris
import android.util.Log
import androidx.tvprovider.media.tv.TvContractCompat
class TvRecommendationsReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != TvContractCompat.ACTION_INITIALIZE_PROGRAMS) {
return
when (intent.action) {
TvContractCompat.ACTION_INITIALIZE_PROGRAMS -> {
Log.d("TvRecommendations", "Handling INITIALIZE_PROGRAMS broadcast")
TvRecommendationsPublisher.refreshFromCache(context)
}
"android.media.tv.action.PREVIEW_PROGRAM_BROWSABLE_DISABLED" -> {
val programId = intent.data?.let { ContentUris.parseId(it) } ?: -1L
if (programId > 0L) {
Log.d("TvRecommendations", "Handling PREVIEW_PROGRAM_BROWSABLE_DISABLED for programId=$programId")
TvRecommendationsPublisher.deletePreviewProgram(context, programId)
}
}
}
Log.d("TvRecommendations", "Handling INITIALIZE_PROGRAMS broadcast")
TvRecommendationsPublisher.refreshFromCache(context)
}
}

View File

@@ -1,12 +1,19 @@
import { requireNativeView } from "expo";
import * as React from "react";
import type { View } from "react-native";
import { Platform } from "react-native";
import type { TvSearchViewProps } from "./TvSearchView.types";
// The native TvSearchModule is Apple-only (tvOS SwiftUI `.searchable`).
// On Android the component is never rendered, but we must avoid calling
// `requireNativeView` at module-scope because it would crash on import.
const NativeView: React.ComponentType<
TvSearchViewProps & React.RefAttributes<View>
> = requireNativeView("TvSearchModule");
> =
Platform.OS === "ios"
? requireNativeView("TvSearchModule")
: ((() => null) as any);
/**
* Forwards its ref to the underlying native view so it can be used as a

View File

@@ -22,7 +22,9 @@
"lint": "biome check --write --unsafe --max-diagnostics 1000",
"format": "biome format --write .",
"doctor": "expo-doctor",
"test": "bun run typecheck && bun run lint && bun run format && bun run doctor",
"i18n:check": "bun scripts/check-i18n-keys.mjs",
"i18n:fix-unused": "bun scripts/check-i18n-keys.mjs --fix-unused",
"test": "bun run typecheck && bun run lint && bun run format && bun run i18n:check && bun run doctor",
"postinstall": "patch-package"
},
"dependencies": {
@@ -52,6 +54,7 @@
"expo-brightness": "~56.0.5",
"expo-build-properties": "~56.0.15",
"expo-camera": "~56.0.7",
"expo-clipboard": "~56.0.4",
"expo-constants": "~56.0.16",
"expo-crypto": "~56.0.4",
"expo-dev-client": "~56.0.16",
@@ -100,7 +103,7 @@
"react-native-nitro-modules": "0.33.1",
"react-native-pager-view": "8.0.1",
"react-native-qrcode-svg": "^6.3.21",
"react-native-reanimated": "4.4.1",
"react-native-reanimated": "4.3.1",
"react-native-reanimated-carousel": "4.0.3",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "4.25.2",
@@ -129,7 +132,7 @@
"@types/react": "~19.2.10",
"@types/react-test-renderer": "19.1.0",
"cross-env": "10.1.0",
"expo-doctor": "1.19.7",
"expo-doctor": "1.19.9",
"husky": "9.1.7",
"lint-staged": "17.0.5",
"react-test-renderer": "19.2.3",

View File

@@ -96,5 +96,24 @@ export function getDownloadedItemSize(id: string): number {
*/
export function calculateTotalDownloadedSize(): number {
const items = getAllDownloadedItems();
return items.reduce((sum, item) => sum + (item.videoFileSize || 0), 0);
return items.reduce((sum, item) => {
// Trickplay bytes count too — getDownloadedItemSize models per-item size
// as video + trickplay, the total must match.
const trickplaySize = item.trickPlayData?.size ?? 0;
// Read the live file size on disk so the total reflects actual usage and
// self-heals items whose stored videoFileSize is 0 (old schema, or
// `fileInfo.size` was undefined at download time). Fall back to the stored
// value if the file can't be stat'd.
if (item.videoFilePath) {
try {
const file = new File(filePathToUri(item.videoFilePath));
if (file.exists) {
return sum + (file.size ?? item.videoFileSize ?? 0) + trickplaySize;
}
} catch (error) {
console.warn("Failed to stat downloaded file for size:", error);
}
}
return sum + (item.videoFileSize ?? 0) + trickplaySize;
}, 0);
}

View File

@@ -289,7 +289,24 @@ export function useDownloadOperations({
);
const appSizeUsage = useCallback(async () => {
const totalSize = calculateTotalDownloadedSize();
let totalSize = calculateTotalDownloadedSize();
// Also count in-progress downloads (they write straight to their final
// path) so the growing file shows up as app usage instead of drifting
// into the generic device share until completion.
for (const process of processes) {
try {
const file = new File(
Paths.document,
`${generateFilename(process.item)}.mp4`,
);
if (file.exists) {
totalSize += file.size ?? 0;
}
} catch {
// File not created yet — ignore.
}
}
try {
const [freeDiskStorage, totalDiskCapacity] = await Promise.all([
@@ -310,7 +327,7 @@ export function useDownloadOperations({
appSize: totalSize,
};
}
}, []);
}, [processes]);
return {
startBackgroundDownload,

View File

@@ -15,6 +15,7 @@ import {
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
@@ -40,6 +41,7 @@ import {
} from "@/utils/secureCredentials";
import { store } from "@/utils/store";
import { clearTVDiscoverySafely } from "@/utils/tvDiscovery/sync";
import { APP_VERSION } from "@/utils/version";
interface Server {
address: string;
@@ -53,7 +55,7 @@ const initialApi = (() => {
const id = getOrSetDeviceId();
const deviceName = getDeviceNameSync();
const jellyfinInstance = new Jellyfin({
clientInfo: { name: "Streamyfin", version: "0.54.1" },
clientInfo: { name: "Streamyfin", version: APP_VERSION },
deviceInfo: {
name: deviceName,
id,
@@ -90,6 +92,12 @@ export const apiAtom = atom<Api | null>(initialApi);
export const userAtom = atom<UserDto | null>(initialUser);
export const wsAtom = atom<WebSocket | null>(null);
export const cacheVersionAtom = atom<number>(0);
// Set by a login flow that wants the account saved: the protection picker
// shows AFTER the session is authorized (the login screen unmounts on
// success, so the modal lives at the root — see PendingAccountSaveModal).
export const pendingAccountSaveAtom = atom<{ serverName?: string } | null>(
null,
);
interface LoginOptions {
saveAccount?: boolean;
@@ -107,6 +115,11 @@ interface JellyfinContextValue {
serverName?: string,
options?: LoginOptions,
) => Promise<void>;
saveCurrentAccount: (options?: {
securityType?: AccountSecurityType;
pinCode?: string;
serverName?: string;
}) => Promise<void>;
logout: () => Promise<void>;
initiateQuickConnect: () => Promise<string | undefined>;
stopQuickConnectPolling: () => void;
@@ -135,7 +148,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
const id = getOrSetDeviceId();
const deviceName = getDeviceNameSync();
return new Jellyfin({
clientInfo: { name: "Streamyfin", version: "0.54.1" },
clientInfo: { name: "Streamyfin", version: APP_VERSION },
deviceInfo: {
name: deviceName,
id,
@@ -164,12 +177,52 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
const { clearAllJellyseerData, setJellyseerrUser } = useJellyseerr();
const queryClient = useQueryClient();
// --- Session-expiry handling ----------------------------------------------
// When the server revokes the token (e.g. the device/session is deleted), a
// 401 can surface from any authenticated request. Without central handling
// the dead token stays in storage, so every reload re-fires authed calls →
// 401 spam + uncaught rejections, and the app lingers in a half-authenticated
// state. A single response interceptor on the authenticated api clears the
// session on the first 401 so the app drops cleanly to the login screen.
const sessionExpiredRef = useRef(false);
const handleSessionExpired = useCallback(() => {
if (sessionExpiredRef.current) return; // run once per session
sessionExpiredRef.current = true;
storage.remove("token");
storage.remove("user");
setUser(null);
setApi(null);
queryClient.clear();
storage.remove("REACT_QUERY_OFFLINE_CACHE");
// Saved credentials are kept so the user can quick-login again.
}, [setUser, setApi, queryClient]);
useEffect(() => {
// Only guard an authenticated session. A pre-auth api (login screen) keeps
// its own handling — a wrong-password 401 is not a session expiry.
if (!api?.accessToken) return;
sessionExpiredRef.current = false; // re-arm for this fresh session
const interceptorId = api.axiosInstance.interceptors.response.use(
(response) => response,
(error) => {
if (error?.response?.status === 401) {
handleSessionExpired();
}
return Promise.reject(error);
},
);
return () => {
api.axiosInstance.interceptors.response.eject(interceptorId);
};
}, [api, handleSessionExpired]);
const headers = useMemo(() => {
if (!deviceId) return {};
return {
authorization: `MediaBrowser Client="Streamyfin", Device=${
Platform.OS === "android" ? "Android" : "iOS"
}, DeviceId="${deviceId}", Version="0.54.1"`,
}, DeviceId="${deviceId}", Version="${APP_VERSION}"`,
};
}, [deviceId]);
@@ -306,6 +359,37 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
},
});
// Persist the CURRENT session to secure storage — used by the post-login
// save-account modal (the protection picker shows AFTER a successful
// login, for both the password and Quick Connect flows).
const saveCurrentAccount = useCallback(
async (options?: {
securityType?: AccountSecurityType;
pinCode?: string;
serverName?: string;
}) => {
const token = storage.getString("token");
if (!api?.basePath || !user?.Id || !user.Name || !token) return;
const securityType = options?.securityType || "none";
let pinHash: string | undefined;
if (securityType === "pin" && options?.pinCode) {
pinHash = await hashPIN(options.pinCode);
}
await saveAccountCredential({
serverUrl: api.basePath,
serverName: options?.serverName || "",
token,
userId: user.Id,
username: user.Name,
savedAt: Date.now(),
securityType,
pinHash,
primaryImageTag: user.PrimaryImageTag ?? undefined,
});
},
[api?.basePath, user],
);
const loginMutation = useMutation({
mutationFn: async ({
username,
@@ -385,7 +469,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
default:
throw new Error(
t(
"login.an_unexpected_error_occured_did_you_enter_the_correct_url",
"login.an_unexpected_error_occurred_did_you_enter_the_correct_url",
),
);
}
@@ -508,7 +592,9 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
}
},
onError: (error) => {
console.error("Quick login failed:", error);
// Expected, handled case (e.g. revoked token → "Session Expired", or
// server unreachable): the UI surfaces the message, so warn, don't error.
console.warn("Quick login failed:", error);
},
});
@@ -619,54 +705,62 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
setUser(storedUser);
}
// Dismiss splash screen with cached data immediately,
// fetch fresh user data in the background
setInitialLoaded(true);
// Validate the token and refresh user data in the background. Do NOT
// await this: the Jellyfin SDK axios instance has no timeout, so when
// offline this call hangs for the full OS TCP timeout (75-120s) and
// blocks splash dismissal. The cached storedUser (set above) is enough
// to render; on success we just refresh it.
getUserApi(apiInstance)
.getCurrentUser()
.then(async (response) => {
setUser(response.data);
try {
const response = await getUserApi(apiInstance).getCurrentUser();
setUser(response.data);
// Migrate current session to secure storage if not already saved
if (storedUser?.Id && storedUser?.Name) {
const existingCredential = await getAccountCredential(
serverUrl,
storedUser.Id,
);
if (!existingCredential) {
await saveAccountCredential({
// Migrate current session to secure storage if not already saved
if (storedUser?.Id && storedUser?.Name) {
const existingCredential = await getAccountCredential(
serverUrl,
serverName: "",
token,
userId: storedUser.Id,
username: storedUser.Name,
savedAt: Date.now(),
securityType: "none",
primaryImageTag: response.data.PrimaryImageTag ?? undefined,
});
} else if (
response.data.PrimaryImageTag !==
existingCredential.primaryImageTag
) {
// Update image tag if it has changed
addAccountToServer(serverUrl, existingCredential.serverName, {
userId: existingCredential.userId,
username: existingCredential.username,
securityType: existingCredential.securityType,
savedAt: existingCredential.savedAt,
primaryImageTag: response.data.PrimaryImageTag ?? undefined,
});
storedUser.Id,
);
if (!existingCredential) {
await saveAccountCredential({
serverUrl,
serverName: "",
token,
userId: storedUser.Id,
username: storedUser.Name,
savedAt: Date.now(),
securityType: "none",
primaryImageTag: response.data.PrimaryImageTag ?? undefined,
});
} else if (
response.data.PrimaryImageTag !==
existingCredential.primaryImageTag
) {
// Update image tag if it has changed
addAccountToServer(serverUrl, existingCredential.serverName, {
userId: existingCredential.userId,
username: existingCredential.username,
securityType: existingCredential.securityType,
savedAt: existingCredential.savedAt,
primaryImageTag: response.data.PrimaryImageTag ?? undefined,
});
}
}
}
} catch (e) {
// Background fetch failed — app already rendered with cached data
console.warn("Background user fetch failed, using cached data:", e);
}
} else {
setInitialLoaded(true);
})
.catch((e) => {
// Expected, handled case (offline, or a token the server rejects —
// the UI prompts re-login): warn, don't error. Log only
// status/message — never the raw error (axios errors carry the
// request config incl. the Authorization header / token).
console.warn(
"Background user validation failed:",
e?.response?.status ?? e?.message ?? "unknown error",
);
});
}
} catch (e) {
console.error(e);
} finally {
setInitialLoaded(true);
}
};
@@ -680,6 +774,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
removeServer: () => removeServerMutation.mutateAsync(),
login: (username, password, serverName, options) =>
loginMutation.mutateAsync({ username, password, serverName, options }),
saveCurrentAccount,
logout: () => logoutMutation.mutateAsync(),
initiateQuickConnect,
stopQuickConnectPolling,

273
scripts/check-i18n-keys.mjs Normal file
View File

@@ -0,0 +1,273 @@
#!/usr/bin/env bun
/**
* i18n key checker for Streamyfin.
*
* Detects:
* - MISSING keys: a static `t("a.b.c")` / `i18nKey="a.b.c"` referenced in the code
* that does not exist in the source locale (translations/en.json). These are bugs —
* the app renders the raw key. Always fails CI.
* - UNUSED (dead) keys: a key in the source locale that is referenced nowhere in the
* code, neither statically nor via a detected dynamic prefix (`t(`a.b.${x}`)`).
* These are dead weight that also clutter every locale on Crowdin.
*
* Dynamic usage is handled conservatively:
* - `t(`prefix.${x}`)` -> every key starting with `prefix.` is considered used.
* - `t(`${x}`)` -> fully dynamic, reported for manual review, never used to
* whitelist keys (in Streamyfin these are user-defined section
* titles, not translation keys).
* - Edge cases the static scan cannot see can be allow-listed in the config file.
*
* Usage:
* bun scripts/check-i18n-keys.mjs # report + exit 1 on missing OR unused
* bun scripts/check-i18n-keys.mjs --unused=warn # exit 1 only on missing; unused = warning
* bun scripts/check-i18n-keys.mjs --unused=off # ignore unused entirely
* bun scripts/check-i18n-keys.mjs --json # machine-readable output
* bun scripts/check-i18n-keys.mjs --fix-unused # remove dead keys from en.json (Crowdin syncs the rest)
*/
import {
existsSync,
readdirSync,
readFileSync,
statSync,
writeFileSync,
} from "node:fs";
import { extname, join, relative } from "node:path";
const ROOT = process.cwd();
const args = process.argv.slice(2);
const flag = (name, def) => {
const a = args.find((x) => x === `--${name}` || x.startsWith(`--${name}=`));
if (!a) return def;
const [, v] = a.split("=");
return v === undefined ? true : v;
};
const UNUSED_MODE = String(flag("unused", "error")); // error | warn | off
const JSON_OUT = !!flag("json", false);
const FIX_UNUSED = !!flag("fix-unused", false);
// ---- config ----
const CONFIG_PATH = join(ROOT, "scripts", "i18n-keys.config.json");
const DEFAULT_CONFIG = {
localesDir: "translations",
sourceLocale: "en",
// Scan the whole repo by default so keys referenced outside the obvious dirs
// (e.g. packages/, key constants in utils/atoms) are not wrongly flagged as dead.
srcDirs: ["."],
srcExtensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"],
excludeDirs: [
"node_modules",
"ios",
"android",
".expo",
".git",
"dist",
"build",
"translations",
"scripts",
],
// Keys (or glob-ish prefixes ending with .* or *) known to be used dynamically / externally.
ignoreUnused: [],
};
const config = existsSync(CONFIG_PATH)
? { ...DEFAULT_CONFIG, ...JSON.parse(readFileSync(CONFIG_PATH, "utf8")) }
: DEFAULT_CONFIG;
// ---- helpers ----
const flatten = (obj, prefix = "", out = {}) => {
for (const [k, v] of Object.entries(obj)) {
const key = prefix ? `${prefix}.${k}` : k;
if (v && typeof v === "object" && !Array.isArray(v)) flatten(v, key, out);
else out[key] = v;
}
return out;
};
const globMatch = (key, pattern) => {
if (pattern.endsWith(".*"))
return key === pattern.slice(0, -2) || key.startsWith(pattern.slice(0, -1));
if (pattern.endsWith("*")) return key.startsWith(pattern.slice(0, -1));
return key === pattern;
};
const walk = (dir, files = []) => {
let entries;
try {
entries = readdirSync(dir);
} catch {
return files;
}
for (const name of entries) {
const full = join(dir, name);
let st;
try {
st = statSync(full);
} catch {
continue;
}
if (st.isDirectory()) {
if (config.excludeDirs.includes(name)) continue;
walk(full, files);
} else if (config.srcExtensions.includes(extname(name))) {
files.push(full);
}
}
return files;
};
// ---- load source keys ----
const sourcePath = join(ROOT, config.localesDir, `${config.sourceLocale}.json`);
const sourceKeys = Object.keys(
flatten(JSON.parse(readFileSync(sourcePath, "utf8"))),
);
const sourceKeySet = new Set(sourceKeys);
// ---- scan code ----
const STATIC_RE = /\bt\(\s*(['"])((?:\\.|(?!\1).)+?)\1/g; // t("a.b") / t('a.b')
const TPL_STATIC_RE = /\bt\(\s*`([^`$]+)`/g; // t(`a.b`) no interpolation
const TPL_DYN_RE = /\bt\(\s*`([^`$]*)\$\{/g; // t(`a.b.${x}`) -> prefix "a.b."
const I18NKEY_RE = /\bi18nKey\s*=\s*(?:\{\s*)?(['"])((?:\\.|(?!\1).)+?)\1/g; // <Trans i18nKey="a.b">
const KEY_SHAPE = /^[A-Za-z0-9_]+(\.[A-Za-z0-9_]+)+$/; // dotted key, e.g. home.x.y
const usedStatic = new Set(); // keys passed to t(...) / i18nKey — used for MISSING detection
const dynamicPrefixes = new Set();
const fullyDynamic = []; // { file, line }
let codeBlob = ""; // all (comment-stripped) source text — searched for delimited key literals
// Strip comments so keys mentioned in comments (e.g. `// t("old.key")`) are not counted as
// usage. Block comments and JSX {/* */} are blanked (preserving newlines for line numbers);
// line comments are only stripped when `//` follows start/whitespace/punctuation, which keeps
// `://` inside string URLs intact.
const stripComments = (src) =>
src
.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " "))
.replace(/(^|[\s;{}()[\],=>])\/\/[^\n]*/g, (_m, p) => p);
const files = config.srcDirs.flatMap((d) =>
walk(join(ROOT, d === "." ? "" : d) || ROOT),
);
for (const file of files) {
const text = readFileSync(file, "utf8");
const clean = stripComments(text);
codeBlob += `\n${clean}`;
for (const m of clean.matchAll(STATIC_RE)) usedStatic.add(m[2]);
for (const m of clean.matchAll(TPL_STATIC_RE)) usedStatic.add(m[1]);
for (const m of clean.matchAll(I18NKEY_RE)) usedStatic.add(m[2]);
for (const m of clean.matchAll(TPL_DYN_RE)) {
const prefix = m[1];
if (prefix?.includes(".")) dynamicPrefixes.add(prefix);
else {
const idx = clean.slice(0, m.index).split("\n").length;
fullyDynamic.push({ file: relative(ROOT, file), line: idx });
}
}
}
const prefixList = [...dynamicPrefixes];
// A key counts as used if its EXACT delimited literal ("k" / 'k' / `k`) appears anywhere in
// the code (covers t("k"), <Trans i18nKey>, and keys stored as bare string constants in
// arrays/config then resolved via t(variable)), or it is reached via a dynamic prefix, or
// explicitly allow-listed. Delimited search avoids substring false-matches (e.g. a.b vs a.b_c).
const literalUsed = (key) =>
codeBlob.includes(`"${key}"`) ||
codeBlob.includes(`'${key}'`) ||
codeBlob.includes(`\`${key}\``);
const isUsed = (key) =>
literalUsed(key) ||
prefixList.some((p) => key.startsWith(p)) ||
config.ignoreUnused.some((g) => globMatch(key, g));
// ---- compute ----
const unused = sourceKeys.filter((k) => !isUsed(k)).sort();
// Static references are always validated, even under a dynamic prefix: a dynamic prefix only
// affects the UNUSED calculation, never MISSING.
const missing = [...usedStatic]
.filter((k) => KEY_SHAPE.test(k) && !sourceKeySet.has(k))
.sort();
// Known limitation: only keys seen in a static t("…") / i18nKey="…" / t(`…`) call are
// validated for MISSING. A key stored as a bare string constant and resolved via t(variable)
// counts as USED (via literalUsed → not flagged unused) but its existence in en.json is not
// checked here — static analysis can't resolve which key a runtime variable holds. Streamyfin
// keys are static literals in practice; revisit if dynamic key constants become common.
// ---- optional fix: strip dead keys from the source locale (en.json) ----
const removeKey = (obj, parts) => {
const [head, ...rest] = parts;
if (!(head in obj)) return;
if (rest.length === 0) {
delete obj[head];
return;
}
removeKey(obj[head], rest);
if (
obj[head] &&
typeof obj[head] === "object" &&
Object.keys(obj[head]).length === 0
)
delete obj[head];
};
if (FIX_UNUSED && unused.length) {
// Only edit the SOURCE locale (en.json). Crowdin owns the target locales and removes
// the keys from them automatically on the next sync once they disappear from the source.
const data = JSON.parse(readFileSync(sourcePath, "utf8"));
for (const key of unused) removeKey(data, key.split("."));
writeFileSync(sourcePath, `${JSON.stringify(data, null, 2)}\n`);
console.log(
`🧹 Removed ${unused.length} dead key(s) from ${config.sourceLocale}.json (Crowdin will sync the other locales).`,
);
}
// ---- report ----
if (JSON_OUT) {
console.log(
JSON.stringify(
{
sourceKeys: sourceKeys.length,
missing,
unused,
dynamicPrefixes: prefixList,
fullyDynamic,
},
null,
2,
),
);
} else {
console.log(
`🔑 i18n key check — source: ${relative(ROOT, sourcePath)} (${sourceKeys.length} keys), scanned ${files.length} files`,
);
if (prefixList.length)
console.log(
` dynamic prefixes treated as used: ${prefixList.map((p) => `${p}*`).join(", ")}`,
);
if (fullyDynamic.length)
console.log(
` ⚠️ ${fullyDynamic.length} fully-dynamic t(\`\${…}\`) call(s) (not key-based, manual review): ${fullyDynamic.map((d) => `${d.file}:${d.line}`).join(", ")}`,
);
if (missing.length) {
console.log(
`\n❌ MISSING keys (used in code, absent from ${config.sourceLocale}.json) — ${missing.length}:`,
);
for (const k of missing) console.log(` - ${k}`);
} else console.log("\n✅ No missing keys.");
if (UNUSED_MODE !== "off") {
if (unused.length) {
console.log(
`\n${UNUSED_MODE === "error" ? "❌" : "⚠️ "} UNUSED keys (in ${config.sourceLocale}.json, referenced nowhere) — ${unused.length}:`,
);
for (const k of unused) console.log(` - ${k}`);
console.log(
`\n → remove with: bun scripts/check-i18n-keys.mjs --fix-unused`,
);
console.log(
` → or allow-list a dynamic key in scripts/i18n-keys.config.json ("ignoreUnused").`,
);
} else console.log("\n✅ No unused keys.");
}
}
const fail =
missing.length > 0 || (UNUSED_MODE === "error" && unused.length > 0);
process.exit(fail ? 1 : 0);

View File

@@ -0,0 +1,46 @@
{
"localesDir": "translations",
"sourceLocale": "en",
"srcDirs": [
"app",
"components",
"hooks",
"providers",
"utils",
"modules",
"packages",
"constants"
],
"srcExtensions": [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"],
"excludeDirs": [
"node_modules",
"ios",
"android",
".expo",
".git",
"dist",
"build",
"translations"
],
"_ignoreUnusedNote": "Keys present in en.json but referenced by no t() call — allow-listed so the unused-key cleanup keeps them. Two kinds: (1) ORPHANS of features that already exist but render hardcoded strings or use other keys — watchlists.* (add/remove, handled by WatchlistSheet via hooks), pin.* (confirm step of the existing PIN setup flow), player.* (in-player subtitle search, swipe-down hint, stop-playback confirm); these should be wired to the live UI or removed in a follow-up i18n pass, not assumed to be future work. (2) GENUINELY PLANNED, tracked by issues: home.settings.other.show_large_home_carousel (#1702 media-bar), home.settings.logs.delete_all_logs (#1703 iOS logs fix), home.suggested_episodes (#1704).",
"ignoreUnused": [
"watchlists.add_to_watchlist",
"watchlists.remove_from_watchlist",
"watchlists.create_one_first",
"watchlists.no_compatible_watchlists",
"pin.confirm_pin",
"pin.pins_dont_match",
"player.search_subtitles",
"player.subtitle_search",
"player.subtitle_download_hint",
"player.subtitle_tracks",
"player.using_jellyfin_server",
"player.swipe_down_settings",
"player.stopPlayback",
"player.stopPlayingTitle",
"player.stopPlayingConfirm",
"home.settings.other.show_large_home_carousel",
"home.settings.logs.delete_all_logs",
"home.suggested_episodes"
]
}

View File

@@ -516,7 +516,7 @@
},
"downloads": {
"downloads_title": "التنزيلات",
"tvseries": "مسلسلات",
"series": "مسلسلات",
"movies": "أفلام",
"queue": "قائمة الانتظار",
"other_media": "وسائط أخرى",
@@ -524,7 +524,7 @@
"no_items_in_queue": "لا توجد عناصر في قائمة الانتظار",
"no_downloaded_items": "لا توجد عناصر تم تنزيلها",
"delete_all_movies_button": "حذف جميع الأفلام",
"delete_all_tvseries_button": "حذف جميع المسلسلات",
"delete_all_series_button": "حذف جميع المسلسلات",
"delete_all_button": "حذف الكل",
"delete_all_other_media_button": "حذف الوسائط الأخرى",
"active_download": "التنزيل الجاري",
@@ -542,8 +542,8 @@
"you_are_not_allowed_to_download_files": "غير مسموح لك بتنزيل الملفات.",
"deleted_all_movies_successfully": "تم حذف جميع الأفلام بنجاح!",
"failed_to_delete_all_movies": "فشل حذف جميع الأفلام",
"deleted_all_tvseries_successfully": "تم حذف جميع المسلسلات بنجاح!",
"failed_to_delete_all_tvseries": "فشل حذف جميع المسلسلات",
"deleted_all_series_successfully": "تم حذف جميع المسلسلات بنجاح!",
"failed_to_delete_all_series": "فشل حذف جميع المسلسلات",
"deleted_media_successfully": "تم حذف الوسائط الأخرى بنجاح!",
"failed_to_delete_media": "فشل حذف الوسائط الأخرى",
"download_deleted": "تم حذف التنزيل",

View File

@@ -516,7 +516,7 @@
},
"downloads": {
"downloads_title": "Descàrregues",
"tvseries": "Sèries",
"series": "Sèries",
"movies": "Pel·lícules",
"queue": "Cua",
"other_media": "Other media",
@@ -524,7 +524,7 @@
"no_items_in_queue": "No hi ha elements a la cua",
"no_downloaded_items": "No hi ha elements descarregats",
"delete_all_movies_button": "Suprimeix totes les pel·lícules",
"delete_all_tvseries_button": "Suprimeix totes les sèries",
"delete_all_series_button": "Suprimeix totes les sèries",
"delete_all_button": "Suprimeix-ho tot",
"delete_all_other_media_button": "Delete other media",
"active_download": "Descàrrega activa",
@@ -542,8 +542,8 @@
"you_are_not_allowed_to_download_files": "No teniu permís per descarregar fitxers.",
"deleted_all_movies_successfully": "S'han suprimit totes les pel·lícules correctament!",
"failed_to_delete_all_movies": "No s'han pogut suprimir totes les pel·lícules",
"deleted_all_tvseries_successfully": "S'han suprimit totes les sèries correctament!",
"failed_to_delete_all_tvseries": "No s'han pogut suprimir totes les sèries",
"deleted_all_series_successfully": "S'han suprimit totes les sèries correctament!",
"failed_to_delete_all_series": "No s'han pogut suprimir totes les sèries",
"deleted_media_successfully": "Deleted other media Successfully!",
"failed_to_delete_media": "Failed to Delete other media",
"download_deleted": "Download Deleted",

View File

@@ -516,7 +516,7 @@
},
"downloads": {
"downloads_title": "Stahování",
"tvseries": "Televizní série",
"series": "Televizní série",
"movies": "Filmy",
"queue": "Fronta",
"other_media": "Ostatní média",
@@ -524,7 +524,7 @@
"no_items_in_queue": "Žádné položky ve frontě",
"no_downloaded_items": "Žádné stažené položky",
"delete_all_movies_button": "Odstranit všechny filmy",
"delete_all_tvseries_button": "Odstranit všechny TV-série",
"delete_all_series_button": "Odstranit všechny TV-série",
"delete_all_button": "Smazat vše",
"delete_all_other_media_button": "Odstranit ostatní média",
"active_download": "Aktivní stahování",
@@ -542,8 +542,8 @@
"you_are_not_allowed_to_download_files": "Nemáte oprávnění stahovat soubory.",
"deleted_all_movies_successfully": "Všechny filmy byly úspěšně smazány!",
"failed_to_delete_all_movies": "Nepodařilo se odstranit všechny filmy",
"deleted_all_tvseries_successfully": "Všechny série televizorů byly úspěšně smazány!",
"failed_to_delete_all_tvseries": "Nepodařilo se odstranit všechny TV-série",
"deleted_all_series_successfully": "Všechny série televizorů byly úspěšně smazány!",
"failed_to_delete_all_series": "Nepodařilo se odstranit všechny TV-série",
"deleted_media_successfully": "Ostatní média úspěšně smazána!",
"failed_to_delete_media": "Nepodařilo se odstranit ostatní média",
"download_deleted": "Stahování smazáno",

View File

@@ -516,7 +516,7 @@
},
"downloads": {
"downloads_title": "Downloads",
"tvseries": "TV-serier",
"series": "TV-serier",
"movies": "Film",
"queue": "Kø",
"other_media": "Andre medier",
@@ -524,7 +524,7 @@
"no_items_in_queue": "Ingen elementer i køen",
"no_downloaded_items": "Ingen downloadede elementer",
"delete_all_movies_button": "Slet alle film",
"delete_all_tvseries_button": "Slet alle TV-serier",
"delete_all_series_button": "Slet alle TV-serier",
"delete_all_button": "Slet alle",
"delete_all_other_media_button": "Slet andre medier",
"active_download": "Aktiv download",
@@ -542,8 +542,8 @@
"you_are_not_allowed_to_download_files": "Du har ikke tilladelse til at downloade filer.",
"deleted_all_movies_successfully": "Alle film er slettet med succes!",
"failed_to_delete_all_movies": "Kunne ikke slette alle film",
"deleted_all_tvseries_successfully": "Alle TV-serier er slettet med succes!",
"failed_to_delete_all_tvseries": "Kunne ikke slette alle TV-serier",
"deleted_all_series_successfully": "Alle TV-serier er slettet med succes!",
"failed_to_delete_all_series": "Kunne ikke slette alle TV-serier",
"deleted_media_successfully": "Slettede andre medier med succes!",
"failed_to_delete_media": "Kunne ikke slette andre medier",
"download_deleted": "Download Slettet",

View File

@@ -516,7 +516,7 @@
},
"downloads": {
"downloads_title": "Downloads",
"tvseries": "Serien",
"series": "Serien",
"movies": "Filme",
"queue": "Warteschlange",
"other_media": "Andere Medien",
@@ -524,7 +524,7 @@
"no_items_in_queue": "Keine Elemente in der Warteschlange",
"no_downloaded_items": "Keine heruntergeladenen Elemente",
"delete_all_movies_button": "Alle Filme löschen",
"delete_all_tvseries_button": "Alle Serien löschen",
"delete_all_series_button": "Alle Serien löschen",
"delete_all_button": "Alles löschen",
"delete_all_other_media_button": "Alle anderen Medien löschen",
"active_download": "Aktiver Download",
@@ -542,8 +542,8 @@
"you_are_not_allowed_to_download_files": "Du hast keine Berechtigung, Dateien herunterzuladen",
"deleted_all_movies_successfully": "Alle Filme erfolgreich gelöscht!",
"failed_to_delete_all_movies": "Fehler beim Löschen aller Filme",
"deleted_all_tvseries_successfully": "Alle Serien erfolgreich gelöscht!",
"failed_to_delete_all_tvseries": "Fehler beim Löschen aller Serien",
"deleted_all_series_successfully": "Alle Serien erfolgreich gelöscht!",
"failed_to_delete_all_series": "Fehler beim Löschen aller Serien",
"deleted_media_successfully": "Andere Medien erfolgreich gelöscht!",
"failed_to_delete_media": "Fehler beim Löschen anderer Medien",
"download_deleted": "Download gelöscht",

View File

@@ -516,7 +516,7 @@
},
"downloads": {
"downloads_title": "Λήψεις",
"tvseries": "Τηλεόραση-Σειρά",
"series": "Τηλεόραση-Σειρά",
"movies": "Ταινίες",
"queue": "Ουρά",
"other_media": "Άλλα μέσα",
@@ -524,7 +524,7 @@
"no_items_in_queue": "Δεν υπάρχουν αντικείμενα στην ουρά",
"no_downloaded_items": "Δεν Έχουν Ληφθεί Αντικείμενα",
"delete_all_movies_button": "Διαγραφή Όλων Των Ταινιών",
"delete_all_tvseries_button": "Διαγραφή Όλων Των Τηλεοπτικών Σειρών",
"delete_all_series_button": "Διαγραφή Όλων Των Τηλεοπτικών Σειρών",
"delete_all_button": "Διαγραφή Όλων",
"delete_all_other_media_button": "Διαγραφή άλλων μέσων",
"active_download": "Ενεργή Λήψη",
@@ -542,8 +542,8 @@
"you_are_not_allowed_to_download_files": "Δεν επιτρέπεται να κατεβάσετε αρχεία.",
"deleted_all_movies_successfully": "Διαγράφηκε Όλες Οι Ταινίες Επιτυχία!",
"failed_to_delete_all_movies": "Αποτυχία διαγραφής όλων των ταινιών",
"deleted_all_tvseries_successfully": "Διαγράφηκε Όλη Η Τηλεόραση-Σειρά Επιτυχία!",
"failed_to_delete_all_tvseries": "Αποτυχία διαγραφής Όλων των TV-Series",
"deleted_all_series_successfully": "Διαγράφηκε Όλη Η Τηλεόραση-Σειρά Επιτυχία!",
"failed_to_delete_all_series": "Αποτυχία διαγραφής Όλων των TV-Series",
"deleted_media_successfully": "Διαγράφηκε άλλο μέσο επιτυχώς!",
"failed_to_delete_media": "Αποτυχία διαγραφής άλλων πολυμέσων",
"download_deleted": "Η Λήψη Διαγράφηκε",

File diff suppressed because it is too large Load Diff

View File

@@ -229,14 +229,14 @@
},
"downloads": {
"downloads_title": "Elŝutoj",
"tvseries": "Televidaj serioj",
"series": "Televidaj serioj",
"movies": "Filmoj",
"queue": "Vico",
"queue_hint": "Vico kaj elŝutoj perdiĝos ĉe aplikaĵa rekomenco",
"no_items_in_queue": "Neniuj eroj en vico",
"no_downloaded_items": "Neniuj elŝutitaj eroj",
"delete_all_movies_button": "Forigi ĉiujn Filmojn",
"delete_all_tvseries_button": "Forigi ĉiujn Televidajn Seriojn",
"delete_all_series_button": "Forigi ĉiujn Televidajn Seriojn",
"delete_all_button": "Forigi ĉion",
"active_download": "Aktiva elŝuto",
"no_active_downloads": "Neniuj aktivaj elŝutoj",
@@ -253,8 +253,8 @@
"you_are_not_allowed_to_download_files": "Vi ne rajtas elŝuti dosierojn.",
"deleted_all_movies_successfully": "Sukcese forigis ĉiujn filmojn!",
"failed_to_delete_all_movies": "Malsukcesis forigi ĉiujn filmojn",
"deleted_all_tvseries_successfully": "Sukcese forigis ĉiujn Televidajn Seriojn!",
"failed_to_delete_all_tvseries": "Malsukcesis forigi ĉiujn Televidajn Seriojn",
"deleted_all_series_successfully": "Sukcese forigis ĉiujn Televidajn Seriojn!",
"failed_to_delete_all_series": "Malsukcesis forigi ĉiujn Televidajn Seriojn",
"download_cancelled": "Elŝuto nuligita",
"could_not_cancel_download": "Ne povis nuligi elŝuton",
"download_completed": "Elŝuto finita",

View File

@@ -516,7 +516,7 @@
},
"downloads": {
"downloads_title": "Descargas",
"tvseries": "Series",
"series": "Series",
"movies": "Películas",
"queue": "Cola",
"other_media": "Otros medios",
@@ -524,7 +524,7 @@
"no_items_in_queue": "No hay ítems en la cola",
"no_downloaded_items": "No hay ítems descargados",
"delete_all_movies_button": "Eliminar todas las películas",
"delete_all_tvseries_button": "Eliminar todas las series",
"delete_all_series_button": "Eliminar todas las series",
"delete_all_button": "Eliminar todo",
"delete_all_other_media_button": "Eliminar otros medios",
"active_download": "Descarga activa",
@@ -542,8 +542,8 @@
"you_are_not_allowed_to_download_files": "No tienes permiso para descargar archivos.",
"deleted_all_movies_successfully": "¡Todas las películas eliminadas con éxito!",
"failed_to_delete_all_movies": "Error al eliminar todas las películas",
"deleted_all_tvseries_successfully": "¡Todas las series eliminadas con éxito!",
"failed_to_delete_all_tvseries": "Error al eliminar todas las series",
"deleted_all_series_successfully": "¡Todas las series eliminadas con éxito!",
"failed_to_delete_all_series": "Error al eliminar todas las series",
"deleted_media_successfully": "¡Otros medios eliminados con éxito!",
"failed_to_delete_media": "Error al eliminar otros medios",
"download_deleted": "Descarga eliminada",

View File

@@ -516,7 +516,7 @@
},
"downloads": {
"downloads_title": "Lataukset",
"tvseries": "TV-sarjat",
"series": "TV-sarjat",
"movies": "Elokuvat",
"queue": "Jonot",
"other_media": "Muu media",
@@ -524,7 +524,7 @@
"no_items_in_queue": "Ei kohteita jonossa",
"no_downloaded_items": "Ei ladattuja kohteita",
"delete_all_movies_button": "Poista kaikki elokuvat",
"delete_all_tvseries_button": "Poista kaikki TV-sarjat",
"delete_all_series_button": "Poista kaikki TV-sarjat",
"delete_all_button": "Poista kaikki",
"delete_all_other_media_button": "Poista muu media",
"active_download": "Aktiivinen lataus",
@@ -542,8 +542,8 @@
"you_are_not_allowed_to_download_files": "Sinulla ei ole lupaa ladata tiedostoja.",
"deleted_all_movies_successfully": "Kaikki elokuvat poistettu onnistuneesti!",
"failed_to_delete_all_movies": "Kaikkien elokuvien poistaminen epäonnistui",
"deleted_all_tvseries_successfully": "Kaikki TV-sarjat poistettu onnistuneesti!",
"failed_to_delete_all_tvseries": "Kaikkien TV-sarjojen poistaminen epäonnistui",
"deleted_all_series_successfully": "Kaikki TV-sarjat poistettu onnistuneesti!",
"failed_to_delete_all_series": "Kaikkien TV-sarjojen poistaminen epäonnistui",
"deleted_media_successfully": "Muu media poistettu onnistuneesti!",
"failed_to_delete_media": "Muiden medioiden poistaminen epäonnistui",
"download_deleted": "Lataus Poistettu",

View File

@@ -516,7 +516,7 @@
},
"downloads": {
"downloads_title": "Téléchargements",
"tvseries": "Séries",
"series": "Séries",
"movies": "Films",
"queue": "File d'attente",
"other_media": "Autres médias",
@@ -524,7 +524,7 @@
"no_items_in_queue": "Aucun téléchargement de média dans la file d'attente",
"no_downloaded_items": "Aucun média téléchargé",
"delete_all_movies_button": "Supprimer tous les films",
"delete_all_tvseries_button": "Supprimer toutes les séries",
"delete_all_series_button": "Supprimer toutes les séries",
"delete_all_button": "Supprimer tous les médias",
"delete_all_other_media_button": "Supprimer un autre média",
"active_download": "Téléchargement actif",
@@ -542,8 +542,8 @@
"you_are_not_allowed_to_download_files": "Vous n'êtes pas autorisé à télécharger des fichiers.",
"deleted_all_movies_successfully": "Tous les films ont été supprimés avec succès!",
"failed_to_delete_all_movies": "Échec de la suppression de tous les films",
"deleted_all_tvseries_successfully": "Toutes les séries ont été supprimées avec succès!",
"failed_to_delete_all_tvseries": "Échec de la suppression de toutes les séries",
"deleted_all_series_successfully": "Toutes les séries ont été supprimées avec succès!",
"failed_to_delete_all_series": "Échec de la suppression de toutes les séries",
"deleted_media_successfully": "Les autres médias ont été supprimés avec succès !",
"failed_to_delete_media": "Échec de la suppression d'un autre média",
"download_deleted": "Téléchargement supprimé",

View File

@@ -516,7 +516,7 @@
},
"downloads": {
"downloads_title": "הורדות",
"tvseries": "סדרות",
"series": "סדרות",
"movies": "סרטים",
"queue": "תוֹר",
"other_media": "תוכן אחר",
@@ -524,7 +524,7 @@
"no_items_in_queue": "אין פרטים בתור",
"no_downloaded_items": "אין פריטים שהורדו",
"delete_all_movies_button": "מחק את כל הסרטים",
"delete_all_tvseries_button": "מחק את כל הסדרות",
"delete_all_series_button": "מחק את כל הסדרות",
"delete_all_button": "מחק הכל",
"delete_all_other_media_button": "מחק שאר תוכן",
"active_download": "הורדה פעילה",
@@ -542,8 +542,8 @@
"you_are_not_allowed_to_download_files": "אתה לא מורשה להוריד קבצים.",
"deleted_all_movies_successfully": "כל הסרטים נמחקו בהצלחה!",
"failed_to_delete_all_movies": "נכשל במחיקת כל הסרטים",
"deleted_all_tvseries_successfully": "כל הסדרות נמחקו בהצלחה!",
"failed_to_delete_all_tvseries": "נכשל במחיקת כל הסדרות",
"deleted_all_series_successfully": "כל הסדרות נמחקו בהצלחה!",
"failed_to_delete_all_series": "נכשל במחיקת כל הסדרות",
"deleted_media_successfully": "כל שאר התוכן נמחק בהצלחה!",
"failed_to_delete_media": "נכשל במחיקת שאר התוכן",
"download_deleted": "ההורדה נמחקה",

View File

@@ -516,7 +516,7 @@
},
"downloads": {
"downloads_title": "Letöltések",
"tvseries": "Sorozatok",
"series": "Sorozatok",
"movies": "Filmek",
"queue": "Sor",
"other_media": "Other media",
@@ -524,7 +524,7 @@
"no_items_in_queue": "Nincs Elem a Sorban",
"no_downloaded_items": "Nincsenek Letöltött Elemek",
"delete_all_movies_button": "Összes Film Törlése",
"delete_all_tvseries_button": "Összes Sorozat Törlése",
"delete_all_series_button": "Összes Sorozat Törlése",
"delete_all_button": "Összes Törlése",
"delete_all_other_media_button": "Delete other media",
"active_download": "Aktív Letöltés",
@@ -542,8 +542,8 @@
"you_are_not_allowed_to_download_files": "Nem engedélyezett a fájlok letöltése.",
"deleted_all_movies_successfully": "Az Összes Film Sikeresen Törölve!",
"failed_to_delete_all_movies": "Nem Sikerült Törölni Az Összes Filmet",
"deleted_all_tvseries_successfully": "Az Összes Sorozat Sikeresen Törölve!",
"failed_to_delete_all_tvseries": "Nem Sikerült Törölni Az Összes Sorozatot",
"deleted_all_series_successfully": "Az Összes Sorozat Sikeresen Törölve!",
"failed_to_delete_all_series": "Nem Sikerült Törölni Az Összes Sorozatot",
"deleted_media_successfully": "Deleted other media Successfully!",
"failed_to_delete_media": "Failed to Delete other media",
"download_deleted": "Letöltés Törölve",

View File

@@ -516,7 +516,7 @@
},
"downloads": {
"downloads_title": "Scaricati",
"tvseries": "Serie TV",
"series": "Serie TV",
"movies": "Film",
"queue": "Coda",
"other_media": "Altri supporti",
@@ -524,7 +524,7 @@
"no_items_in_queue": "Nessun elemento in coda",
"no_downloaded_items": "Nessun elemento scaricato",
"delete_all_movies_button": "Cancella tutti i film",
"delete_all_tvseries_button": "Cancella tutte le serie TV",
"delete_all_series_button": "Cancella tutte le serie TV",
"delete_all_button": "Cancella tutti",
"delete_all_other_media_button": "Elimina altri supporti",
"active_download": "Scaricamento in corso",
@@ -542,8 +542,8 @@
"you_are_not_allowed_to_download_files": "Non è consentito scaricare file.",
"deleted_all_movies_successfully": "Cancellati tutti i film con successo!",
"failed_to_delete_all_movies": "Impossibile eliminare tutti i film",
"deleted_all_tvseries_successfully": "Eliminate tutte le serie TV con successo!",
"failed_to_delete_all_tvseries": "Impossibile eliminare tutte le serie TV",
"deleted_all_series_successfully": "Eliminate tutte le serie TV con successo!",
"failed_to_delete_all_series": "Impossibile eliminare tutte le serie TV",
"deleted_media_successfully": "Eliminato altri supporti con successo!",
"failed_to_delete_media": "Impossibile eliminare altri media",
"download_deleted": "Download Eliminato",

View File

@@ -516,7 +516,7 @@
},
"downloads": {
"downloads_title": "ダウンロード",
"tvseries": "TVシリーズ",
"series": "TVシリーズ",
"movies": "映画",
"queue": "キュー",
"other_media": "その他のメディア",
@@ -524,7 +524,7 @@
"no_items_in_queue": "キューにアイテムがありません",
"no_downloaded_items": "ダウンロードしたアイテムはありません",
"delete_all_movies_button": "すべての映画を削除",
"delete_all_tvseries_button": "すべてのシリーズを削除",
"delete_all_series_button": "すべてのシリーズを削除",
"delete_all_button": "すべて削除",
"delete_all_other_media_button": "他のメディアを削除する",
"active_download": "アクティブなダウンロード",
@@ -542,8 +542,8 @@
"you_are_not_allowed_to_download_files": "ファイルをダウンロードする権限がありません。",
"deleted_all_movies_successfully": "すべての映画を正常に削除しました!",
"failed_to_delete_all_movies": "すべての映画を削除できませんでした",
"deleted_all_tvseries_successfully": "すべてのシリーズを正常に削除しました!",
"failed_to_delete_all_tvseries": "すべてのシリーズを削除できませんでした",
"deleted_all_series_successfully": "すべてのシリーズを正常に削除しました!",
"failed_to_delete_all_series": "すべてのシリーズを削除できませんでした",
"deleted_media_successfully": "他のメディアを削除しました!",
"failed_to_delete_media": "他のメディアの削除に失敗しました",
"download_deleted": "ダウンロードが削除されました",

View File

@@ -516,7 +516,7 @@
},
"downloads": {
"downloads_title": "Downloads",
"tvseries": "TV-Series",
"series": "TV-Series",
"movies": "Movies",
"queue": "Queue",
"other_media": "Other media",
@@ -524,7 +524,7 @@
"no_items_in_queue": "No Items in Queue",
"no_downloaded_items": "No Downloaded Items",
"delete_all_movies_button": "Delete All Movies",
"delete_all_tvseries_button": "Delete All TV-Series",
"delete_all_series_button": "Delete All TV-Series",
"delete_all_button": "Delete All",
"delete_all_other_media_button": "Delete other media",
"active_download": "Active Download",
@@ -542,8 +542,8 @@
"you_are_not_allowed_to_download_files": "You are not allowed to download files.",
"deleted_all_movies_successfully": "Deleted All Movies Successfully!",
"failed_to_delete_all_movies": "Failed to Delete All Movies",
"deleted_all_tvseries_successfully": "Deleted All TV-Series Successfully!",
"failed_to_delete_all_tvseries": "Failed to Delete All TV-Series",
"deleted_all_series_successfully": "Deleted All TV-Series Successfully!",
"failed_to_delete_all_series": "Failed to Delete All TV-Series",
"deleted_media_successfully": "Deleted other media Successfully!",
"failed_to_delete_media": "Failed to Delete other media",
"download_deleted": "Download Deleted",

View File

@@ -229,14 +229,14 @@
},
"downloads": {
"downloads_title": "Nedlastinger",
"tvseries": "TV-serier",
"series": "TV-serier",
"movies": "Filmer",
"queue": "Kø",
"queue_hint": "Kø og nedlastinger vil gå tapt ved omstart av appen",
"no_items_in_queue": "Ingen elementer i køen",
"no_downloaded_items": "Ingen nedlastede elementer",
"delete_all_movies_button": "Slett alle filmer",
"delete_all_tvseries_button": "Slett alle TV-serier",
"delete_all_series_button": "Slett alle TV-serier",
"delete_all_button": "Slett alt",
"active_download": "Aktiv nedlasting",
"no_active_downloads": "Ingen aktive nedlastinger",
@@ -253,8 +253,8 @@
"you_are_not_allowed_to_download_files": "Du har ikke tillatelse til å laste ned filer.",
"deleted_all_movies_successfully": "Alle filmer ble slettet!",
"failed_to_delete_all_movies": "Kunne ikke slette alle filmer",
"deleted_all_tvseries_successfully": "Alle TV-serier ble slettet!",
"failed_to_delete_all_tvseries": "Kunne ikke slette alle TV-serier",
"deleted_all_series_successfully": "Alle TV-serier ble slettet!",
"failed_to_delete_all_series": "Kunne ikke slette alle TV-serier",
"download_cancelled": "Nedlasting avbrutt",
"could_not_cancel_download": "Kunne ikke avbryte nedlastingen",
"download_completed": "Nedlasting fullført",

View File

@@ -516,7 +516,7 @@
},
"downloads": {
"downloads_title": "Downloads",
"tvseries": "Series",
"series": "Series",
"movies": "Films",
"queue": "Wachtrij",
"other_media": "Andere media",
@@ -524,7 +524,7 @@
"no_items_in_queue": "Geen items in wachtrij",
"no_downloaded_items": "Geen gedownloade items",
"delete_all_movies_button": "Verwijder alle films",
"delete_all_tvseries_button": "Verwijder alle Series",
"delete_all_series_button": "Verwijder alle Series",
"delete_all_button": "Verwijder alles",
"delete_all_other_media_button": "Andere media verwijderen",
"active_download": "Actieve download",
@@ -542,8 +542,8 @@
"you_are_not_allowed_to_download_files": "Je mag geen bestanden downloaden.",
"deleted_all_movies_successfully": "Alle films succesvol verwijderd!",
"failed_to_delete_all_movies": "Alle films zijn niet verwijderd",
"deleted_all_tvseries_successfully": "Alle series succesvol verwijderd!",
"failed_to_delete_all_tvseries": "Alle series zijn niet verwijderd",
"deleted_all_series_successfully": "Alle series succesvol verwijderd!",
"failed_to_delete_all_series": "Alle series zijn niet verwijderd",
"deleted_media_successfully": "Andere media succesvol verwijderd!",
"failed_to_delete_media": "Verwijderen van andere media mislukt",
"download_deleted": "Download verwijderd",

View File

@@ -229,14 +229,14 @@
},
"downloads": {
"downloads_title": "Nedlastingar",
"tvseries": "TV-seriar",
"series": "TV-seriar",
"movies": "Filmar",
"queue": "Kø",
"queue_hint": "Kø og nedlastingar vil gå tapt ved omstart av appen",
"no_items_in_queue": "Ingen element i køen",
"no_downloaded_items": "Ingen nedlasta element",
"delete_all_movies_button": "Slett alle filmar",
"delete_all_tvseries_button": "Slett alle TV-seriar",
"delete_all_series_button": "Slett alle TV-seriar",
"delete_all_button": "Slett alt",
"active_download": "Aktiv nedlasting",
"no_active_downloads": "Ingen aktive nedlastingar",
@@ -253,8 +253,8 @@
"you_are_not_allowed_to_download_files": "Du har ikkje løyve til å lasta ned filer.",
"deleted_all_movies_successfully": "Alle filmar vart sletta!",
"failed_to_delete_all_movies": "Kunne ikkje sletta alle filmar",
"deleted_all_tvseries_successfully": "Alle TV-seriar vart sletta!",
"failed_to_delete_all_tvseries": "Kunne ikkje sletta alle TV-seriar",
"deleted_all_series_successfully": "Alle TV-seriar vart sletta!",
"failed_to_delete_all_series": "Kunne ikkje sletta alle TV-seriar",
"download_cancelled": "Nedlasting avbroten",
"could_not_cancel_download": "Kunne ikkje avbryta nedlastinga",
"download_completed": "Nedlasting fullført",

View File

@@ -516,7 +516,7 @@
},
"downloads": {
"downloads_title": "Nedlastinger",
"tvseries": "TV-Serier",
"series": "TV-Serier",
"movies": "Filmer",
"queue": "Kø",
"other_media": "Andre medier",
@@ -524,7 +524,7 @@
"no_items_in_queue": "Ingen elementer i køen",
"no_downloaded_items": "Ingen nedlastede elementer",
"delete_all_movies_button": "Slett alle filmer",
"delete_all_tvseries_button": "Slett alle TV-Serier",
"delete_all_series_button": "Slett alle TV-Serier",
"delete_all_button": "Slett alle",
"delete_all_other_media_button": "Slett andre media",
"active_download": "Aktiv nedlasting",
@@ -542,8 +542,8 @@
"you_are_not_allowed_to_download_files": "Du har ikke lov til å laste ned filer.",
"deleted_all_movies_successfully": "Slettet alle filmer vellykket!",
"failed_to_delete_all_movies": "Kunne ikke slette alle filmer",
"deleted_all_tvseries_successfully": "Alle TV-Serier ble slettet!",
"failed_to_delete_all_tvseries": "Kunne ikke slette alle TV-Serier",
"deleted_all_series_successfully": "Alle TV-Serier ble slettet!",
"failed_to_delete_all_series": "Kunne ikke slette alle TV-Serier",
"deleted_media_successfully": "Slettet andre media vellykket!",
"failed_to_delete_media": "Kunne ikke slette andre medier",
"download_deleted": "Nedlasting slettet",

View File

@@ -516,7 +516,7 @@
},
"downloads": {
"downloads_title": "Pobrane",
"tvseries": "Seriale",
"series": "Seriale",
"movies": "Filmy",
"queue": "Kolejka",
"other_media": "Inne media",
@@ -524,7 +524,7 @@
"no_items_in_queue": "Brak elementów w kolejce",
"no_downloaded_items": "Brak pobranych elementów",
"delete_all_movies_button": "Usuń wszystkie filmy",
"delete_all_tvseries_button": "Usuń wszystkie seriale",
"delete_all_series_button": "Usuń wszystkie seriale",
"delete_all_button": "Usuń wszystko",
"delete_all_other_media_button": "Usuń inne media",
"active_download": "Aktywne pobieranie",
@@ -542,8 +542,8 @@
"you_are_not_allowed_to_download_files": "Nie masz uprawnień do pobierania plików.",
"deleted_all_movies_successfully": "Wszystkie filmy zostały pomyślnie usunięte!",
"failed_to_delete_all_movies": "Nie udało się usunąć wszystkich filmów",
"deleted_all_tvseries_successfully": "Wszystkie seriale zostały pomyślnie usunięte!",
"failed_to_delete_all_tvseries": "Nie udało się usunąć wszystkich seriali",
"deleted_all_series_successfully": "Wszystkie seriale zostały pomyślnie usunięte!",
"failed_to_delete_all_series": "Nie udało się usunąć wszystkich seriali",
"deleted_media_successfully": "Pomyślnie usunięto inne media!",
"failed_to_delete_media": "Nie udało się usunąć innych mediów",
"download_deleted": "Pobieranie usunięte",

View File

@@ -227,14 +227,14 @@
},
"downloads": {
"downloads_title": "Downloads",
"tvseries": "TV/Séries",
"series": "TV/Séries",
"movies": "Filmes",
"queue": "Fila",
"queue_hint": "A fila e os downloads serão perdidos ao reiniciar o aplicativo",
"no_items_in_queue": "Nenhum item na fila",
"no_downloaded_items": "Nenhum item baixado",
"delete_all_movies_button": "Remover todos os filmes",
"delete_all_tvseries_button": "Remover todos as TV/Séries",
"delete_all_series_button": "Remover todos as TV/Séries",
"delete_all_button": "Remover tudo",
"active_download": "Downloads ativos",
"no_active_downloads": "Nenhum download ativo",
@@ -251,8 +251,8 @@
"you_are_not_allowed_to_download_files": "Você não tem permissão para baixar arquivos.",
"deleted_all_movies_successfully": "Todos os filmes foram removidos com sucesso!",
"failed_to_delete_all_movies": "Falha ao remover todos os filmes",
"deleted_all_tvseries_successfully": "Todos as TV/Séries foram removidos com sucesso!",
"failed_to_delete_all_tvseries": "Falha ao remover todos as TV/Séries",
"deleted_all_series_successfully": "Todos as TV/Séries foram removidos com sucesso!",
"failed_to_delete_all_series": "Falha ao remover todos as TV/Séries",
"download_cancelled": "Download cancelado",
"could_not_cancel_download": "Não foi possível cancelar o download",
"download_completed": "Download completo",

View File

@@ -516,7 +516,7 @@
},
"downloads": {
"downloads_title": "Downloads",
"tvseries": "TV-Séries",
"series": "TV-Séries",
"movies": "Filmes",
"queue": "Fila",
"other_media": "Outras mídias",
@@ -524,7 +524,7 @@
"no_items_in_queue": "Nenhum item na fila",
"no_downloaded_items": "Nenhum item baixado",
"delete_all_movies_button": "Excluir todos os filmes",
"delete_all_tvseries_button": "Excluir todas as séries",
"delete_all_series_button": "Excluir todas as séries",
"delete_all_button": "Excluir todos os",
"delete_all_other_media_button": "Excluir outras mídias",
"active_download": "Download ativo",
@@ -542,8 +542,8 @@
"you_are_not_allowed_to_download_files": "Você não tem permissão para baixar arquivos.",
"deleted_all_movies_successfully": "Todos os filmes excluídos com sucesso!",
"failed_to_delete_all_movies": "Falha ao excluir todos os filmes",
"deleted_all_tvseries_successfully": "Todas as TV-Series excluídas com sucesso!",
"failed_to_delete_all_tvseries": "Falha ao excluir todas as séries",
"deleted_all_series_successfully": "Todas as TV-Series excluídas com sucesso!",
"failed_to_delete_all_series": "Falha ao excluir todas as séries",
"deleted_media_successfully": "Outras mídias excluídas com sucesso!",
"failed_to_delete_media": "Falha ao excluir outras mídias",
"download_deleted": "Download Excluído",

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