Compare commits

..

146 Commits

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

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

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

Typecheck passes; bun.lock regenerated against merged package.json.
2026-06-01 23:14:35 +02:00
Fredrik Burmester
46bd2a784e fix(tv): keep focus on search field instead of jumping to results grid (#1637)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
2026-06-01 21:50:52 +02:00
Fredrik Burmester
0a36fdfbec fix: icon alignment library header
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
2026-06-01 19:55:55 +02:00
Fredrik Burmester
45d1f752d6 fix: header left button icon alignment 2026-06-01 19:46:07 +02:00
lance chant
54ee507209 fix: fixing the time variable (#1638)
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-06-01 15:22:39 +02:00
lance chant
338fb9713b fix: qr code scanning not working ios (#1619)
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-06-01 12:38:54 +02:00
lance chant
939fd2512d fix: max episodes count (#1554)
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-06-01 12:38:34 +02:00
github-actions[bot]
32c99de874 feat: New Crowdin Translations (#1460)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-06-01 10:33:17 +02:00
Fredrik Burmester
c232e433bf fix(ci): grant actions:read to github-release job (#1634) 2026-06-01 10:31:05 +02:00
Fredrik Burmester
07e2faff07 fix(tv): only add user-management entitlement for tvOS builds (#1633) 2026-06-01 10:28:21 +02:00
Fredrik Burmester
8507699cdd feat(eas): force bun on EAS via custom build configs + 5-build release workflow (#1632) 2026-06-01 10:24:52 +02:00
Gauvain
21fb056586 fix(i18n): make two hardcoded titles translatable (#1627) 2026-06-01 09:46:27 +02:00
Gauvain
1d79b513f3 fix(item): dedupe top people sections by id (#1623) 2026-06-01 09:37:45 +02:00
Gauvain
863dffd944 fix(chapters): keep landscape when opening chapter list on iOS (#1624) 2026-06-01 09:37:35 +02:00
lance chant
6aa0868bfd fix: fixed a runtime issue for android (#1628)
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-06-01 09:35:19 +02:00
Felix Schneider
6b7ee0514f feat(i18n): add new translations for action sheet options (#1475)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
Co-authored-by: lance chant <13349722+lancechant@users.noreply.github.com>
Co-authored-by: Gauvain <contact@uruk.dev>
2026-05-31 23:45:45 +02:00
Fredrik Burmester
c663bd0413 fix(jellyseerr): correct RequestModal ref type to fix typecheck
advancedReqModalRef was typed as BottomSheetModal but RequestModal's
forwardRef expects BottomSheetModalMethods, causing a TS2322 error
that broke the Security & Quality Gate typecheck on develop.
2026-05-31 22:10:15 +02:00
Alex
52e6f56220 fix(auth): clear stored user on logout to prevent empty home on relaunch (#1622) 2026-05-31 21:52:41 +02:00
Uruk
58f0877cfe fix(chapters): use bookmarks icon to disambiguate from episode list 2026-05-24 17:34:10 +02:00
Uruk
2c2a7137d3 fix(autoplay): make Cancel stop the timer and fix stale cast capture state 2026-05-23 23:36:38 +02:00
Uruk
56e350891d feat(casting): mount the autoplay watcher and countdown overlay 2026-05-23 23:27:33 +02:00
Uruk
d9e25135c4 feat(casting): add cast autoplay watcher hook 2026-05-23 23:23:54 +02:00
Uruk
84246e9dde feat(casting): add cast autoplay countdown atom 2026-05-23 23:20:08 +02:00
Uruk
57cfa5ce78 feat(casting): extract reusable next-episode helpers 2026-05-23 23:18:55 +02:00
Uruk
0ba3d19550 feat(autoplay): use AutoplayCountdown overlay in the native player 2026-05-23 23:14:33 +02:00
Uruk
58e2418120 feat(autoplay): expose countdown durations in playback settings 2026-05-23 23:13:08 +02:00
Uruk
6c00a0348a feat(autoplay): add shared AutoplayCountdown overlay 2026-05-22 16:41:53 +02:00
Uruk
276ba1e4c5 feat(autoplay): add configurable countdown duration settings 2026-05-22 15:14:51 +02:00
Uruk
41ab4de833 fix(chapters): thinner ticks, light-grey colour on the cast bar 2026-05-22 14:30:23 +02:00
Uruk
abe4981126 chore(casting): remove DEBUG_TOUCH_ZONES overlay 2026-05-22 14:25:08 +02:00
Uruk
a9d8f753d4 fix(chapters): size chapter ticks to the slider track 2026-05-22 14:22:36 +02:00
Uruk
ee5c9ae19f fix(chapters): nudge the chapter button left of the skip controls 2026-05-22 14:13:15 +02:00
Uruk
d661a9ff7a fix(chapters): address review comments - null starts, ticksToMs, a11y, memoize 2026-05-22 12:33:57 +02:00
Uruk
4939d05e69 fix(playback): register a stable proxy controller to break a render loop 2026-05-22 12:25:32 +02:00
Uruk
7201002dd5 fix(chapters): sort chapter list entries, localize strings, fix tick keys 2026-05-22 12:10:36 +02:00
Uruk
03d2917ca0 feat(casting): chapter list button in the cast player 2026-05-22 11:59:25 +02:00
Uruk
74315a8b94 feat(casting): chapter ticks on the cast progress bar 2026-05-22 11:56:00 +02:00
Uruk
53c4f317cc feat(chapters): chapter ticks and list in the native player 2026-05-22 11:54:33 +02:00
Uruk
335a373034 feat(chapters): add ChapterList modal 2026-05-22 11:54:32 +02:00
Uruk
55595bea9b feat(chapters): add ChapterTicks slider overlay 2026-05-22 11:54:32 +02:00
Uruk
0cf6630af9 feat(chapters): add pure chapter helpers 2026-05-22 11:54:31 +02:00
Uruk
41f6116ba8 docs(casting): add autoplay+countdown design (deferred pending chapters) 2026-05-22 11:31:57 +02:00
Uruk
1e3311fea9 fix(casting): trickplay bubble positioning and mini-player preview
Position the trickplay/scrub bubble above the progress bar and let the
slider own horizontal placement (bubbleMaxWidth/bubbleWidth = tile width)
so the preview tracks the cursor and is clamped at the track edges. Wire
the mini-player trickplay to the fetched full item and size its tile/thumb.
2026-05-22 11:05:10 +02:00
Uruk
e400378684 docs(casting): mark UX player sub-project done in handoff 2026-05-22 10:07:19 +02:00
Uruk
21c0fb4b6c feat(casting): add DEBUG_TOUCH_ZONES overlay for hit-area calibration 2026-05-22 10:02:17 +02:00
Uruk
b9e87e51cc feat(casting): mini-player trickplay fix and stop button 2026-05-22 09:57:05 +02:00
Uruk
c3a9b451b6 fix(casting): clamp trickplay bubble via slider bubbleWidth 2026-05-22 09:55:23 +02:00
Uruk
418bd506c0 feat(casting): add shared CastTrickplayBubble component 2026-05-22 09:47:36 +02:00
Uruk
b0e92d8689 docs(casting): add player UX implementation plan 2026-05-22 09:34:38 +02:00
Uruk
4ae656818c docs(casting): add player UX (trickplay/bubble/mini-player) design spec
Fix trickplay bubble truncation via bubbleWidth, extract a shared
CastTrickplayBubble, lighten the time display, add a mini-player stop
button, and add a DEBUG_TOUCH_ZONES overlay for hand-calibrating panHitSlop.
2026-05-22 09:31:15 +02:00
Uruk
99527e1fae feat(casting): full-width labelled stop button for movies 2026-05-22 07:57:58 +02:00
Uruk
1ca6e0853b docs(casting): record player feature ideas and touch-zone note in handoff 2026-05-22 02:46:43 +02:00
Uruk
f99ce8210c feat(casting): show stop button when playing a movie 2026-05-22 02:45:20 +02:00
Uruk
674e252641 refactor: remove duplicate BitRateSheet, use shared BitrateSelector 2026-05-22 02:44:25 +02:00
Uruk
119b7ad937 refactor(casting): drop unused liveProgress export 2026-05-22 02:43:52 +02:00
Uruk
788a3b7cfd docs(casting): add chromecast refactor handoff & resume document
Captures the full state of the A/B/C/D sub-projects and the #1367 prep:
commit ranges, verification status, pending queue, key decisions, and
how to resume the work in a later session.
2026-05-22 02:32:36 +02:00
Uruk
8b94f491e4 fix(playback): dispatch each remote command once; stabilise controllers 2026-05-22 02:30:29 +02:00
Uruk
e9f61a2f7c fix(casting): guard against stale currentItem during episode load 2026-05-22 02:24:13 +02:00
Uruk
6ca1f63877 feat(casting): hide episode buttons when no adjacent episode 2026-05-22 02:22:03 +02:00
Uruk
0cc3a8469d fix(casting): report the real PlayMethod to Jellyfin 2026-05-22 02:20:51 +02:00
Uruk
b38064e2da feat(music): register music PlaybackController 2026-05-22 02:19:13 +02:00
Uruk
5b823a8efd feat(player): register native-video PlaybackController 2026-05-22 02:17:30 +02:00
Uruk
750caba038 feat(casting): register cast PlaybackController for remote control 2026-05-22 02:11:20 +02:00
Uruk
d3ee6c8239 feat(playback): handle remote-control messages over WebSocket 2026-05-22 02:07:10 +02:00
Uruk
7e2ef0f2da feat(playback): add useRemoteControl dispatch hook 2026-05-22 02:06:12 +02:00
Uruk
ca2e657eac feat(playback): add pure remote-command mapper 2026-05-22 02:05:10 +02:00
Uruk
288b390e5b feat(playback): add PlaybackController contract and registry 2026-05-22 02:03:36 +02:00
Uruk
c04924fe9e docs(casting): add session reporting & remote control plan
10-task plan for sub-project D: PlaybackController registry, pure
remote-command mapper, useRemoteControl dispatch, per-player
registration, PlayMethod fix, conditional episode buttons, loadEpisode
race fix.
2026-05-22 02:02:17 +02:00
Uruk
525a6b39fa docs(casting): add session reporting & remote control design spec
Design for sub-project D: correct PlayMethod reporting, conditional
episode buttons, loadEpisode race fix, and app-wide Jellyfin remote
control (Playstate/GeneralCommand) routed via a PlaybackController.
2026-05-22 01:58:30 +02:00
Uruk
1ea7f0f491 refactor(casting): extract useCastPlayerProgress hook 2026-05-22 01:32:21 +02:00
Uruk
79c2829444 refactor(casting): extract useCastDismissGesture hook 2026-05-22 01:22:21 +02:00
Uruk
87e0b0006b refactor(casting): extract useCastEpisodes hook 2026-05-22 01:18:08 +02:00
Uruk
3c71c08591 refactor(casting): extract useCastPlayerItem hook 2026-05-22 01:15:29 +02:00
Uruk
9f4f0fa7d1 refactor(casting): extract CastPlayerTransportControls 2026-05-22 01:10:30 +02:00
Uruk
0d922b75d6 refactor(casting): extract CastPlayerProgressBar 2026-05-22 01:08:04 +02:00
Uruk
0ee1d43d16 refactor(casting): extract CastPlayerEpisodeControls 2026-05-22 01:02:37 +02:00
Uruk
ec49d03cf1 refactor(casting): extract CastPlayerPoster 2026-05-22 00:59:57 +02:00
Uruk
02df2477d8 refactor(casting): extract CastPlayerHeader and CastPlayerTitle 2026-05-22 00:57:55 +02:00
Uruk
8c9506c7b5 docs(casting): add casting-player split implementation plan
10-task plan for sub-project C: extract 6 presentational components +
4 hooks from casting-player.tsx, leaving a thin orchestrator. Purely
mechanical, behaviour-neutral.

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

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

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

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 23:23:19 +02:00
Uruk
73214f5d45 fix(casting): apply conservative bitrate cap on downgrade retry
The status-2100 downgrade retry overrode only the device profile's
MaxStreamingBitrate. Jellyfin uses the explicit getStreamUrl maxBitrate
request param as the effective ceiling, so a quality-menu bitrate would
survive the retry. Clamp options.maxBitrate to the fallback cap too.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 02:36:25 +02:00
Uruk
5cfd110ad5 docs(casting): add Chromecast cast test matrix 2026-05-21 02:33:12 +02:00
Uruk
6e63afc61a feat(casting): replace H265 toggle with Chromecast profile selector 2026-05-21 02:32:18 +02:00
Uruk
bcf6b705e1 refactor(casting): route all cast loads through loadCastMedia
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 02:27:20 +02:00
Uruk
fb8c649f6f feat(casting): add unified loadCastMedia with downgrade-on-failure 2026-05-21 02:20:06 +02:00
Uruk
6ecadecb87 feat(casting): add chromecastProfile and chromecastMaxBitrate settings 2026-05-21 02:17:59 +02:00
Uruk
e3f105691b feat(casting): add Chromecast device profile builder 2026-05-21 02:16:29 +02:00
Uruk
bcfa8c6d63 feat(casting): add Chromecast capability detection 2026-05-21 02:13:31 +02:00
Uruk
17450e3811 docs(casting): add device profiles implementation plan
7-task TDD plan for sub-project A: capability detection, profile builder,
unified loadCastMedia, call-site rewiring, settings UI, test matrix.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

All player-related strings (error messages, playback controls, download prompts) are now accessible as common translations, enabling their use throughout the application without namespace-specific imports.
2026-05-21 00:47:31 +02:00
Uruk
88b79920bf feat: add i18n support for skip button text
- Add player.skip_* translation keys for all 5 segment types
- Enable proper localization of skip button text
- Addresses GitHub Copilot review comment
2026-05-21 00:47:31 +02:00
Uruk
203c6d59b0 refactor: address GitHub Copilot review comments
- Remove unnecessary currentSegment from skipSegment dependency array
- Remove redundant wrappedSeek wrapper (ref guard prevents issues)
- Document 200ms setTimeout delay for seek operations
- Improve code clarity and reduce unnecessary re-renders
2026-05-21 00:47:31 +02:00
Uruk
bb491d4e86 refactor: apply CodeRabbit suggestions for segment skip feature
- Add missing segment types (recap, commercial, preview) to JobStatus
- Consolidate duplicate useMemo blocks with factory function
- Improve code maintainability and consistency
2026-05-21 00:47:31 +02:00
Uruk
0b6639fc4e feat: add comprehensive segment skip with all 5 types and settings submenu
- Add SegmentSkipMode type ('none', 'ask', 'auto') in settings.ts
- Create 5 segment skip settings: skipIntro, skipOutro, skipRecap, skipCommercial, skipPreview
- Update segments.ts to fetch all 5 segment types from Jellyfin MediaSegments API (10.11+)
- Create unified useSegmentSkipper hook supporting all segment types with 3 modes
- Update video player Controls.tsx with priority system (Commercial > Recap > Intro > Preview > Outro)
- Add dynamic skip button text in BottomControls.tsx
- Create dedicated settings submenu at settings/segment-skip/page.tsx
- Simplify PlaybackControlsSettings.tsx with navigation to submenu
- Extend DownloadedItem interface with all segment types for offline support
- Add 13+ translation keys for segment skip UI
2026-05-21 00:47:31 +02:00
Uruk
71d922beeb fix(chromecast): resolve TypeScript errors and improve type safety
- Fix deviceName property to use friendlyName
- Update disconnect to use stop() instead of endSession()
- Fix null handling in getPosterUrl and useTrickplay
- Remove unused variables and imports
- Add proper null checks in segment skipping
- Disable auto-skip until settings are available
2026-05-21 00:47:31 +02:00
Uruk
5e60b6c2f8 feat(chromecast): add new player UI with mini player, hooks, and utilities
- Create ChromecastMiniPlayer component (bottom bar navigation)
- Create chromecast-player modal route with full UI
- Add useChromecastPlayer hook (playback controls & state)
- Add useChromecastSegments hook (intro/credits/segments)
- Add chromecast options (constants & config)
- Add chromecast helpers (time formatting, quality checks)
- Implement swipe-down gesture to dismiss
- Add Netflix-style buffering indicator
- Add progress tracking with trickplay support
- Add next episode countdown
- Ready for segments integration from autoskip branch
2026-05-21 00:47:31 +02:00
Uruk
da70541c8e fix: add Chromecast video progress tracking 2026-05-21 00:47:31 +02:00
126 changed files with 14534 additions and 2885 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,14 @@
name: 🚀 Release (EAS Build + Submit)
name: 🚀 Release (EAS build + submit)
# Cloud EAS build + auto-submit for iOS, tvOS and Android on merge to main.
# A manual approval gate (the `production` GitHub Environment) pauses the run
# before any build/submit starts. Configure required reviewers on that
# environment in repo Settings → Environments → production.
# On merge to main (gated by the `production` GitHub Environment approval),
# build all targets on EAS in parallel via custom bun build configs:
# 1. iOS phone → App Store (auto-submit)
# 2. tvOS → App Store (auto-submit)
# 3. Android AAB → Google Play (auto-submit)
# 4. Android phone APK→ downloadable artifact
# 5. Android TV APK → downloadable artifact
# Note: EAS queues builds based on your plan's concurrency; parallel jobs
# here just submit them — EAS may still run them serially.
concurrency:
group: release-${{ github.ref }}
@@ -23,7 +28,7 @@ jobs:
- name: ✅ Release approved
run: echo "Release approved for ${{ github.sha }}"
release:
build:
name: 🚀 ${{ matrix.name }}
needs: approve
runs-on: ubuntu-24.04
@@ -36,12 +41,25 @@ jobs:
- name: 🍎 iOS
platform: ios
profile: production
submit: true
- name: 📺 tvOS
platform: ios
profile: production_tv
- name: 🤖 Android
submit: true
- name: 🤖 Android AAB
platform: android
profile: production
submit: true
- name: 🤖 Android APK
platform: android
profile: production-apk
submit: false
artifact_name: streamyfin-android-phone-apk
- name: 📺 Android TV APK
platform: android
profile: production-apk-tv
submit: false
artifact_name: streamyfin-android-tv-apk
steps:
- name: 📥 Checkout code
@@ -76,10 +94,8 @@ jobs:
token: ${{ secrets.EXPO_TOKEN }}
eas-cache: true
# tvOS uses local credentials (EAS can't manage tvOS provisioning
# remotely, including the TopShelf extension target). Restore the
# gitignored credentials.json + cert + profiles from secrets so the
# cloud build can sign with `credentialsSource: local`.
# tvOS uses credentialsSource: local — restore the gitignored
# credentials.json + cert + provisioning profiles from secrets.
- name: 🔐 Restore tvOS signing credentials
if: matrix.profile == 'production_tv'
env:
@@ -94,10 +110,14 @@ jobs:
echo "$TVOS_APP_PROFILE_BASE64" | base64 -d > profiles/Streamyfin_tvOS_App_Store.mobileprovision
echo "$TVOS_TOPSHELF_PROFILE_BASE64" | base64 -d > profiles/Streamyfin_TopShelf_tvOS_App_Store.mobileprovision
# iOS + tvOS submit upload to App Store Connect with an ASC API key.
# EAS reads it from EXPO_ASC_API_KEY_PATH / EXPO_ASC_KEY_ID /
# EXPO_ASC_ISSUER_ID (set on the build step below). Write the .p8,
# tolerating either raw-PEM or base64-encoded secret content.
# Android Play submit needs the Google Play service account JSON.
- name: 🔐 Restore Google Play service account
if: matrix.platform == 'android' && matrix.submit
env:
GOOGLE_SERVICE_ACCOUNT_KEY: ${{ secrets.GOOGLE_SERVICE_ACCOUNT_KEY }}
run: printf '%s' "$GOOGLE_SERVICE_ACCOUNT_KEY" > google-service-account.json
# App Store Connect API key for iOS/tvOS submit (raw-PEM or base64).
- name: 🔐 Restore App Store Connect API key
if: matrix.platform == 'ios'
env:
@@ -109,18 +129,11 @@ jobs:
printf '%s' "$APPLE_KEY_CONTENT" | base64 -d > "$RUNNER_TEMP/asc_api_key.p8"
fi
# Android submit needs a Google Play service account JSON. eas.json's
# submit.production.android.serviceAccountKeyPath points at this file.
- name: 🔐 Restore Google Play service account
if: matrix.platform == 'android'
env:
GOOGLE_SERVICE_ACCOUNT_KEY: ${{ secrets.GOOGLE_SERVICE_ACCOUNT_KEY }}
run: printf '%s' "$GOOGLE_SERVICE_ACCOUNT_KEY" > google-service-account.json
# ── Submit builds: cloud build + auto-submit to the store ──
- name: 🚀 Build & submit (${{ matrix.name }})
if: matrix.submit
env:
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
# Consumed by eas submit for iOS/tvOS; ignored for Android.
EXPO_ASC_API_KEY_PATH: ${{ runner.temp }}/asc_api_key.p8
EXPO_ASC_KEY_ID: ${{ secrets.APPLE_KEY_ID }}
EXPO_ASC_ISSUER_ID: ${{ secrets.APPLE_KEY_ISSUER_ID }}
@@ -129,4 +142,75 @@ jobs:
--platform ${{ matrix.platform }} \
--profile ${{ matrix.profile }} \
--auto-submit \
--non-interactive
--non-interactive \
--wait
# ── Artifact builds: cloud build, then download + upload the APK ──
- name: 🏗️ Build artifact (${{ matrix.name }})
if: ${{ !matrix.submit }}
env:
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
run: |
eas build \
--platform ${{ matrix.platform }} \
--profile ${{ matrix.profile }} \
--non-interactive \
--wait \
--json > build-result.json
URL=$(node -e "const b=require('./build-result.json'); const x=Array.isArray(b)?b[0]:b; console.log(x.artifacts.applicationArchiveUrl)")
echo "Downloading artifact: $URL"
curl -fL "$URL" -o "${{ matrix.artifact_name }}.apk"
- name: 📤 Upload APK artifact (${{ matrix.name }})
if: ${{ !matrix.submit }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ${{ matrix.artifact_name }}
path: ${{ matrix.artifact_name }}.apk
retention-days: 14
# Draft a GitHub Release with the two APKs attached. The tag comes from the
# merged-in app version (app.json → expo.version), NOT the auto-incremented
# build number — so cutting a release is a deliberate version bump via PR.
github-release:
name: 📦 Draft GitHub Release
needs: build
if: ${{ !cancelled() }}
runs-on: ubuntu-24.04
permissions:
contents: write
actions: read # required for `gh run download` to list/fetch this run's artifacts
steps:
- name: 📥 Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
show-progress: false
- name: 📦 Download APK artifacts from this run
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
mkdir -p apks
gh run download ${{ github.run_id }} --name streamyfin-android-phone-apk --dir apks
gh run download ${{ github.run_id }} --name streamyfin-android-tv-apk --dir apks
ls -la apks
- name: 📝 Draft release (tag = app.json version, not auto-bumped)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
VERSION=$(node -e "console.log(require('./app.json').expo.version)")
TAG="v$VERSION"
echo "Release tag from merged app version: $TAG"
if gh release view "$TAG" >/dev/null 2>&1; then
echo "Release $TAG exists — updating APK assets"
gh release upload "$TAG" apks/*.apk --clobber
else
echo "Creating draft release $TAG"
gh release create "$TAG" \
--draft \
--generate-notes \
--title "$TAG" \
apks/*.apk
fi

3
.gitignore vendored
View File

@@ -76,6 +76,9 @@ modules/background-downloader/android/build/*
# ios:unsigned-build Artifacts
build/
# but keep EAS custom build configs (the generic build/ rule above matches .eas/build/)
!.eas/build/
!.eas/build/**
.claude/
.agents/skills/**
skills-lock.json

View File

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

View File

@@ -114,7 +114,7 @@ export default function StreamystatsPage() {
};
const handleRefreshFromServer = useCallback(async () => {
const newPluginSettings = await refreshStreamyfinPluginSettings(true);
const newPluginSettings = await refreshStreamyfinPluginSettings();
// Update local state with new values
const newUrl = newPluginSettings?.streamyStatsServerUrl?.value || "";
setUrl(newUrl);

View File

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

View File

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

View File

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

View File

@@ -11,6 +11,8 @@ import type {
import { useTranslation } from "react-i18next";
import { Platform, View } from "react-native";
import { SystemBars } from "react-native-edge-to-edge";
import { CastAutoplayWatcher } from "@/components/casting/CastAutoplayWatcher";
import { CastingMiniPlayer } from "@/components/casting/CastingMiniPlayer";
import { Colors } from "@/constants/Colors";
import { useTVHomeBackHandler } from "@/hooks/useTVBackHandler";
import { useSettings } from "@/utils/atoms/settings";
@@ -139,6 +141,8 @@ export default function TabLayout() {
}}
/>
</NativeTabs>
<CastingMiniPlayer />
<CastAutoplayWatcher />
<MiniPlayerBar />
<MusicPlaybackEngine />
</View>

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -1,13 +1,14 @@
import { Ionicons } from "@expo/vector-icons";
import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
import React, { useEffect } from "react";
import { Platform, StyleSheet, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import React, { useEffect, useState } from "react";
import {
MeasuredTriggerHost,
OptionGroupCard,
ToggleSwitch,
} from "@/components/common/dropdownShared";
type LayoutChangeEvent,
Platform,
StyleSheet,
TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { useGlobalModal } from "@/providers/GlobalModalProvider";
@@ -15,7 +16,7 @@ import { useGlobalModal } from "@/providers/GlobalModalProvider";
// A static top-level import evaluates requireNativeModule('ExpoUI') at module
// load and crashes the entire route tree on tvOS (expo-router requires every
// route file). Load it lazily and only off-TV; TV never renders these.
const { Button, Menu } = Platform.isTV
const { Button, Host, Menu } = Platform.isTV
? ({} as typeof import("@expo/ui/swift-ui"))
: require("@expo/ui/swift-ui");
const { disabled } = Platform.isTV
@@ -62,6 +63,7 @@ interface PlatformDropdownProps {
open?: boolean;
onOpenChange?: (open: boolean) => void;
onOptionSelect?: (value?: any) => void;
disabled?: boolean;
expoUIConfig?: {
hostStyle?: any;
};
@@ -71,6 +73,16 @@ interface PlatformDropdownProps {
};
}
const ToggleSwitch: React.FC<{ value: boolean }> = ({ value }) => (
<View
className={`w-12 h-7 rounded-full ${value ? "bg-purple-600" : "bg-neutral-600"} flex-row items-center`}
>
<View
className={`w-5 h-5 rounded-full bg-white shadow-md transform transition-transform ${value ? "translate-x-6" : "translate-x-1"}`}
/>
</View>
);
const OptionItem: React.FC<{ option: Option; isLast?: boolean }> = ({
option,
isLast,
@@ -110,15 +122,28 @@ const OptionItem: React.FC<{ option: Option; isLast?: boolean }> = ({
};
const OptionGroupComponent: React.FC<{ group: OptionGroup }> = ({ group }) => (
<OptionGroupCard title={group.title}>
{group.options.map((option, index) => (
<OptionItem
key={index}
option={option}
isLast={index === group.options.length - 1}
/>
))}
</OptionGroupCard>
<View className='mb-6'>
{group.title && (
<Text className='text-lg font-semibold mb-3 text-neutral-300'>
{group.title}
</Text>
)}
<View
style={{
borderRadius: 12,
overflow: "hidden",
}}
className='bg-neutral-800 rounded-xl overflow-hidden'
>
{group.options.map((option, index) => (
<OptionItem
key={index}
option={option}
isLast={index === group.options.length - 1}
/>
))}
</View>
</View>
);
const BottomSheetContent: React.FC<{
@@ -189,10 +214,31 @@ const PlatformDropdownComponent = ({
onOpenChange: controlledOnOpenChange,
onOptionSelect,
expoUIConfig,
// Aliased to avoid shadowing the module-level `disabled` SwiftUI modifier
// (from @expo/ui/swift-ui/modifiers) used by the iOS <Menu> renderer below.
disabled: isDisabled,
bottomSheetConfig,
}: PlatformDropdownProps) => {
const { showModal, hideModal, isVisible } = useGlobalModal();
// @expo/ui's <Host> (SDK 55) fills its available space by default, and
// `matchContents` doesn't help here: it reports the native Menu's size via
// setStyleSize and overrides any explicit size. Instead we measure the
// trigger's intrinsic size in plain RN (off-layout) and pin it on the Host.
const [triggerSize, setTriggerSize] = useState<{
width: number;
height: number;
} | null>(null);
const handleMeasureTrigger = (e: LayoutChangeEvent) => {
const { width, height } = e.nativeEvent.layout;
setTriggerSize((prev) =>
prev && prev.width === width && prev.height === height
? prev
: { width, height },
);
};
// Handle controlled open state for Android
useEffect(() => {
if (Platform.OS === "android" && controlledOpen === true) {
@@ -223,42 +269,89 @@ const PlatformDropdownComponent = ({
}, [isVisible, controlledOpen, controlledOnOpenChange]);
if (Platform.OS === "ios" && !Platform.isTV) {
if (isDisabled) {
return (
<View style={{ opacity: 0.5 }} pointerEvents='none'>
{trigger || <Text className='text-white'>Open Menu</Text>}
</View>
);
}
// Pin the wrapper to the measured trigger size. @expo/ui's <Host> (SDK 55)
// fills its parent and reports its own size via setStyleSize, so it can't
// size itself to content. If the wrapper has no size, the Host's `flex: 1`
// height depends on the parent while the parent depends on the Host — a
// circular dependency that collapses to 0 for any selector nested more than
// one level deep (so only the first, shallowest dropdown stays visible).
// Giving the wrapper the measured size breaks the cycle; the Host then
// fills a concrete box.
return (
<MeasuredTriggerHost
trigger={trigger}
hostStyle={expoUIConfig?.hostStyle}
>
<Menu label={trigger}>
{groups.flatMap((group, groupIndex) => {
// Check if this group has radio options
const radioOptions = group.options.filter(
(opt) => opt.type === "radio",
) as RadioOption[];
const toggleOptions = group.options.filter(
(opt) => opt.type === "toggle",
) as ToggleOption[];
const actionOptions = group.options.filter(
(opt) => opt.type === "action",
) as ActionOption[];
<View style={triggerSize ?? { opacity: 0 }}>
{/* Hidden measurer: lays the trigger out off-flow to capture its
intrinsic size. Absolutely positioned WITHOUT right/bottom so it
sizes to the trigger's content rather than to its parent. */}
<View
style={{ position: "absolute", top: 0, left: 0, opacity: 0 }}
pointerEvents='none'
aria-hidden
onLayout={handleMeasureTrigger}
>
{trigger}
</View>
<Host style={[StyleSheet.absoluteFill, expoUIConfig?.hostStyle as any]}>
<Menu label={trigger}>
{groups.flatMap((group, groupIndex) => {
// Check if this group has radio options
const radioOptions = group.options.filter(
(opt) => opt.type === "radio",
) as RadioOption[];
const toggleOptions = group.options.filter(
(opt) => opt.type === "toggle",
) as ToggleOption[];
const actionOptions = group.options.filter(
(opt) => opt.type === "action",
) as ActionOption[];
const items = [];
const items = [];
// Group radio options under a submenu ONLY if there's a title
// Otherwise render as individual buttons
if (radioOptions.length > 0) {
if (group.title) {
// Use a nested Menu as a submenu for grouped options. This
// reads as "Title: Selected" and expands to the choices on
// tap, keeping the nested look while staying a dropdown.
// (Menu opens on a single tap and nests cleanly; ContextMenu
// would require a long-press and read as a context menu.)
const selectedOption = radioOptions.find((opt) => opt.selected);
const displayTitle = selectedOption
? `${group.title}: ${selectedOption.label}`
: group.title;
items.push(
<Menu key={`submenu-${groupIndex}`} label={displayTitle}>
{radioOptions.map((option, optionIndex) => (
// Group radio options under a submenu ONLY if there's a title
// Otherwise render as individual buttons
if (radioOptions.length > 0) {
if (group.title) {
// Use a nested Menu as a submenu for grouped options. This
// reads as "Title: Selected" and expands to the choices on
// tap, keeping the nested look while staying a dropdown.
// (Menu opens on a single tap and nests cleanly; ContextMenu
// would require a long-press and read as a context menu.)
const selectedOption = radioOptions.find(
(opt) => opt.selected,
);
const displayTitle = selectedOption
? `${group.title}: ${selectedOption.label}`
: group.title;
items.push(
<Menu key={`submenu-${groupIndex}`} label={displayTitle}>
{radioOptions.map((option, optionIndex) => (
<Button
key={`radio-${groupIndex}-${optionIndex}`}
label={option.label}
systemImage={
option.selected ? "checkmark.circle.fill" : "circle"
}
modifiers={
option.disabled ? [disabled(true)] : undefined
}
onPress={() => {
option.onPress();
onOptionSelect?.(option.value);
}}
/>
))}
</Menu>,
);
} else {
// Render radio options as direct buttons
radioOptions.forEach((option, optionIndex) => {
items.push(
<Button
key={`radio-${groupIndex}-${optionIndex}`}
label={option.label}
@@ -272,67 +365,49 @@ const PlatformDropdownComponent = ({
option.onPress();
onOptionSelect?.(option.value);
}}
/>
))}
</Menu>,
);
} else {
// Render radio options as direct buttons
radioOptions.forEach((option, optionIndex) => {
items.push(
<Button
key={`radio-${groupIndex}-${optionIndex}`}
label={option.label}
systemImage={
option.selected ? "checkmark.circle.fill" : "circle"
}
modifiers={option.disabled ? [disabled(true)] : undefined}
onPress={() => {
option.onPress();
onOptionSelect?.(option.value);
}}
/>,
);
});
/>,
);
});
}
}
}
// Add Buttons for toggle options
toggleOptions.forEach((option, optionIndex) => {
items.push(
<Button
key={`toggle-${groupIndex}-${optionIndex}`}
label={option.label}
systemImage={
option.value ? "checkmark.circle.fill" : "circle"
}
modifiers={option.disabled ? [disabled(true)] : undefined}
onPress={() => {
option.onToggle();
onOptionSelect?.(option.value);
}}
/>,
);
});
// Add Buttons for toggle options
toggleOptions.forEach((option, optionIndex) => {
items.push(
<Button
key={`toggle-${groupIndex}-${optionIndex}`}
label={option.label}
systemImage={
option.value ? "checkmark.circle.fill" : "circle"
}
modifiers={option.disabled ? [disabled(true)] : undefined}
onPress={() => {
option.onToggle();
onOptionSelect?.(option.value);
}}
/>,
);
});
// Add Buttons for action options (no icon)
actionOptions.forEach((option, optionIndex) => {
items.push(
<Button
key={`action-${groupIndex}-${optionIndex}`}
label={option.label}
modifiers={option.disabled ? [disabled(true)] : undefined}
onPress={() => {
option.onPress();
}}
/>,
);
});
// Add Buttons for action options (no icon)
actionOptions.forEach((option, optionIndex) => {
items.push(
<Button
key={`action-${groupIndex}-${optionIndex}`}
label={option.label}
modifiers={option.disabled ? [disabled(true)] : undefined}
onPress={() => {
option.onPress();
}}
/>,
);
});
return items;
})}
</Menu>
</MeasuredTriggerHost>
return items;
})}
</Menu>
</Host>
</View>
);
}
@@ -353,8 +428,14 @@ const PlatformDropdownComponent = ({
};
return (
<TouchableOpacity onPress={handlePress} activeOpacity={0.7}>
{trigger || <Text className='text-white'>Open Menu</Text>}
<TouchableOpacity
onPress={handlePress}
activeOpacity={0.7}
disabled={isDisabled}
>
<View style={isDisabled ? { opacity: 0.5 } : undefined}>
{trigger || <Text className='text-white'>Open Menu</Text>}
</View>
</TouchableOpacity>
);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -74,6 +74,9 @@ function ChapterListComponent({
transparent
animationType='slide'
onRequestClose={onClose}
// iOS defaults <Modal> to portrait-only; without this it rotates the app
// back to portrait when opened from the landscape player. Android ignores it.
supportedOrientations={["portrait", "landscape"]}
>
<Pressable onPress={onClose} style={styles.backdrop}>
<Pressable onPress={(e) => e.stopPropagation()} style={styles.sheet}>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -133,7 +133,6 @@ const HomeMobile = () => {
onPress={() => {
router.push("/(auth)/downloads");
}}
className='ml-1.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather

View File

@@ -37,7 +37,20 @@ export const ItemPeopleSections: React.FC<Props> = ({ item, ...props }) => {
return { ...item, People: people } as BaseItemDto;
}, [item, people]);
const topPeople = useMemo(() => people.slice(0, 3), [people]);
// Jellyfin can list the same person several times (e.g. an actor also
// credited as writer). Dedupe by Id so the same actor section isn't rendered
// twice and we still surface 3 distinct people.
const topPeople = useMemo(() => {
const seen = new Set<string>();
const unique: BaseItemPerson[] = [];
for (const person of people) {
if (!person.Id || seen.has(person.Id)) continue;
seen.add(person.Id);
unique.push(person);
if (unique.length >= 3) break;
}
return unique;
}, [people]);
const renderActorSection = useCallback(
(person: BaseItemPerson, idx: number, total: number) => {
@@ -47,7 +60,7 @@ export const ItemPeopleSections: React.FC<Props> = ({ item, ...props }) => {
return (
<MoreMoviesWithActor
key={person.Id}
key={`${person.Id}-${idx}`}
currentItem={item}
actorId={person.Id}
actorName={person.Name}

View File

@@ -1,6 +1,6 @@
import { t } from "i18next";
import React, { useCallback, useState } from "react";
import { ScrollView, View } from "react-native";
import { Platform, ScrollView, View } from "react-native";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { useScaledTVTypography } from "@/constants/TVTypography";
@@ -107,7 +107,7 @@ export const TVAddServerForm: React.FC<TVAddServerFormProps> = ({
</View>
{/* Pair with Phone */}
{onStartPairing && (
{Platform.OS !== "ios" && onStartPairing && (
<View>
<Button
onPress={onStartPairing}

View File

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

View File

@@ -401,10 +401,6 @@ export const TVJellyseerrSearchResults: React.FC<
}) => {
const { t } = useTranslation();
const hasMovies = movieResults && movieResults.length > 0;
const hasTv = tvResults && tvResults.length > 0;
const hasPersons = personResults && personResults.length > 0;
if (loading) {
return null;
}
@@ -431,22 +427,26 @@ export const TVJellyseerrSearchResults: React.FC<
return (
<View>
{/* No section requests `hasTVPreferredFocus`: the native search field
keeps focus while typing, otherwise the first result would re-grab
focus on every keystroke as results re-render. The user navigates
down to the grid manually. */}
<TVJellyseerrMovieSection
title={t("search.request_movies")}
items={movieResults}
isFirstSection={hasMovies}
isFirstSection={false}
onItemPress={onMoviePress}
/>
<TVJellyseerrTvSection
title={t("search.request_series")}
items={tvResults}
isFirstSection={!hasMovies && hasTv}
isFirstSection={false}
onItemPress={onTvPress}
/>
<TVJellyseerrPersonSection
title={t("search.actors")}
items={personResults}
isFirstSection={!hasMovies && !hasTv && hasPersons}
isFirstSection={false}
onItemPress={onPersonPress}
/>
</View>

View File

@@ -235,10 +235,13 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
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,
marginHorizontal: HORIZONTAL_PADDING,
height: SEARCH_AREA_HEIGHT,
}}
>
@@ -280,13 +283,17 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
{/* Library Search Results */}
{isLibraryMode && !loading && (
<View style={{ gap: SECTION_GAP }}>
{sections.map((section, index) => (
{sections.map((section) => (
<TVSearchSection
key={section.key}
title={section.title}
items={section.items!}
orientation={section.orientation || "vertical"}
isFirstSection={index === 0}
// Never auto-focus a result. The native search field owns focus
// while typing; `hasTVPreferredFocus` here would re-grab focus on
// every keystroke as results re-render. User navigates down to the
// grid manually.
isFirstSection={false}
onItemPress={onItemPress}
onItemLongPress={onItemLongPress}
imageUrlGetter={

View File

@@ -297,12 +297,12 @@ export const TVSearchSection: React.FC<TVSearchSectionProps> = ({
removeClippedSubviews={false}
getItemLayout={getItemLayout}
style={{ overflow: "visible" }}
contentInset={{
left: edgePadding,
right: edgePadding,
}}
contentOffset={{ x: -edgePadding, y: 0 }}
// Edge padding via contentContainerStyle, NOT contentInset+contentOffset.
// contentOffset only applies on initial mount; since this FlatList is
// reused across searches (stable key), a second search left the inset
// without the offset and the grid snapped flush to the left edge.
contentContainerStyle={{
paddingHorizontal: edgePadding,
paddingVertical: SCALE_PADDING,
}}
/>

View File

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

View File

@@ -229,7 +229,7 @@ export const LibraryOptionsSheet: React.FC<Props> = ({
/>
</OptionGroup>
<OptionGroup title='Options'>
<OptionGroup title={t("library.options.options_title")}>
<ToggleItem
label={t("library.options.show_titles")}
value={settings.showTitles}

View File

@@ -196,7 +196,10 @@ export const OtherSettings: React.FC = () => {
}
/>
</ListItem>
<ListItem title={t("home.settings.other.max_auto_play_episode_count")}>
<ListItem
title={t("home.settings.other.max_auto_play_episode_count")}
disabled={pluginSettings?.maxAutoPlayEpisodeCount?.locked}
>
<PlatformDropdown
groups={autoPlayEpisodeOptions}
trigger={

View File

@@ -8,6 +8,7 @@ import { BITRATES } from "@/components/BitrateSelector";
import { PlatformDropdown } from "@/components/PlatformDropdown";
import { PLAYBACK_SPEEDS } from "@/components/PlaybackSpeedSelector";
import DisabledSetting from "@/components/settings/DisabledSetting";
import useRouter from "@/hooks/useAppRouter";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings";
import { Text } from "../common/Text";
@@ -15,6 +16,7 @@ import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
export const PlaybackControlsSettings: React.FC = () => {
const router = useRouter();
const { settings, updateSettings, pluginSettings } = useSettings();
const { t } = useTranslation();
@@ -96,6 +98,48 @@ export const PlaybackControlsSettings: React.FC = () => {
[settings?.maxAutoPlayEpisodeCount?.key, t, updateSettings],
);
// Clamp persisted value to the 5-60s bounds so the dropdown always shows a
// valid selection even if an out-of-range value was stored previously.
const autoplayCountdown = Math.min(
60,
Math.max(5, settings?.autoplayCountdownSeconds ?? 15),
);
const castAutoplayCountdown = Math.min(
60,
Math.max(5, settings?.castAutoplayCountdownSeconds ?? 30),
);
const autoplayCountdownOptions = useMemo(
() => [
{
options: AUTOPLAY_COUNTDOWN_SECONDS.map((seconds) => ({
type: "radio" as const,
label: String(seconds),
value: String(seconds),
selected: seconds === autoplayCountdown,
onPress: () => updateSettings({ autoplayCountdownSeconds: seconds }),
})),
},
],
[autoplayCountdown, updateSettings],
);
const castAutoplayCountdownOptions = useMemo(
() => [
{
options: AUTOPLAY_COUNTDOWN_SECONDS.map((seconds) => ({
type: "radio" as const,
label: String(seconds),
value: String(seconds),
selected: seconds === castAutoplayCountdown,
onPress: () =>
updateSettings({ castAutoplayCountdownSeconds: seconds }),
})),
},
],
[castAutoplayCountdown, updateSettings],
);
const playbackSpeedOptions = useMemo(
() => [
{
@@ -229,7 +273,10 @@ export const PlaybackControlsSettings: React.FC = () => {
<ListItem
title={t("home.settings.other.max_auto_play_episode_count")}
disabled={!settings.autoPlayNextEpisode}
disabled={
!settings.autoPlayNextEpisode ||
pluginSettings?.maxAutoPlayEpisodeCount?.locked
}
>
<PlatformDropdown
groups={autoPlayEpisodeOptions}
@@ -248,6 +295,57 @@ export const PlaybackControlsSettings: React.FC = () => {
title={t("home.settings.other.max_auto_play_episode_count")}
/>
</ListItem>
{/* Media Segment Skip Settings */}
<ListItem
title={t("home.settings.other.segment_skip_settings")}
subtitle={t("home.settings.other.segment_skip_settings_description")}
onPress={() => router.push("/settings/segment-skip/page")}
>
<Ionicons name='chevron-forward' size={20} color='#8E8D91' />
</ListItem>
<ListItem
title={t("home.settings.other.autoplay_countdown_seconds")}
disabled={!settings.autoPlayNextEpisode}
>
<PlatformDropdown
groups={autoplayCountdownOptions}
trigger={
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>{autoplayCountdown}</Text>
<Ionicons
name='chevron-expand-sharp'
size={18}
color='#5A5960'
/>
</View>
}
title={t("home.settings.other.autoplay_countdown_seconds")}
/>
</ListItem>
<ListItem
title={t("home.settings.other.cast_autoplay_countdown_seconds")}
disabled={!settings.autoPlayNextEpisode}
>
<PlatformDropdown
groups={castAutoplayCountdownOptions}
trigger={
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>
{castAutoplayCountdown}
</Text>
<Ionicons
name='chevron-expand-sharp'
size={18}
color='#5A5960'
/>
</View>
}
title={t("home.settings.other.cast_autoplay_countdown_seconds")}
/>
</ListItem>
</ListGroup>
</DisabledSetting>
);
@@ -268,3 +366,6 @@ const AUTOPLAY_EPISODES_COUNT = (
{ key: "6", value: 6 },
{ key: "7", value: 7 },
];
// Selectable next-episode countdown durations, bounded to 5-60 seconds.
const AUTOPLAY_COUNTDOWN_SECONDS: number[] = [5, 10, 15, 20, 30, 45, 60];

View File

@@ -1,9 +1,10 @@
import { Ionicons } from "@expo/vector-icons";
import type { Api } from "@jellyfin/sdk";
import type {
BaseItemDto,
ChapterInfo,
} from "@jellyfin/sdk/lib/generated-client";
import { type FC, useMemo, useState } from "react";
import { type FC, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Pressable, View } from "react-native";
import { Slider } from "react-native-awesome-slider";
@@ -12,9 +13,10 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ChapterList } from "@/components/chapters/ChapterList";
import { ChapterTicks } from "@/components/chapters/ChapterTicks";
import { Text } from "@/components/common/Text";
import { AutoplayCountdown } from "@/components/player/AutoplayCountdown";
import { useSettings } from "@/utils/atoms/settings";
import { chapterMarkers, chapterNameAt } from "@/utils/chapters";
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import SkipButton from "./SkipButton";
import { TimeDisplay } from "./TimeDisplay";
import { TrickplayBubble } from "./TrickplayBubble";
@@ -35,11 +37,14 @@ interface BottomControlsProps {
currentTime: number;
remainingTime: number;
showSkipButton: boolean;
skipButtonText: string;
showSkipCreditButton: boolean;
skipCreditButtonText: string;
hasContentAfterCredits: boolean;
skipIntro: () => void;
skipCredit: () => void;
nextItem?: BaseItemDto | null;
api?: Api | null;
handleNextEpisodeAutoPlay: () => void;
handleNextEpisodeManual: () => void;
handleControlsInteraction: () => void;
@@ -90,11 +95,14 @@ export const BottomControls: FC<BottomControlsProps> = ({
currentTime,
remainingTime,
showSkipButton,
skipButtonText,
showSkipCreditButton,
skipCreditButtonText,
hasContentAfterCredits,
skipIntro,
skipCredit,
nextItem,
api,
handleNextEpisodeAutoPlay,
handleNextEpisodeManual,
handleControlsInteraction,
@@ -125,6 +133,83 @@ export const BottomControls: FC<BottomControlsProps> = ({
);
const hasChapters = chapterMarkerList.length > 1;
// Autoplay overlay: shown under the same condition the old countdown button used.
const autoplayAllowed =
settings.autoPlayNextEpisode !== false &&
(settings.maxAutoPlayEpisodeCount.value === -1 ||
settings.autoPlayEpisodeCount < settings.maxAutoPlayEpisodeCount.value);
const showNextEpisodeCountdown =
autoplayAllowed &&
(!nextItem
? false
: // Show during credits if no content after, OR near end of video
(showSkipCreditButton && !hasContentAfterCredits) ||
remainingTime < 10000);
const [secondsRemaining, setSecondsRemaining] = useState(
settings.autoplayCountdownSeconds,
);
const [autoplayCancelled, setAutoplayCancelled] = useState(false);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Keep a stable ref to the autoplay handler so the timer effect does not
// restart when the handler identity changes.
const autoPlayHandlerRef = useRef(handleNextEpisodeAutoPlay);
autoPlayHandlerRef.current = handleNextEpisodeAutoPlay;
useEffect(() => {
if (!showNextEpisodeCountdown || autoplayCancelled) {
// Either the show-condition flipped off OR the user cancelled.
// In both cases, stop the running timer immediately so autoplay
// can't fire after Cancel was pressed.
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
// Only reset cancellation + seconds when the show-condition itself
// flipped off — a fresh credits/end-of-video window then starts a
// brand-new countdown. If we got here because autoplayCancelled
// just flipped true, keep it true so the countdown stays stopped.
if (!showNextEpisodeCountdown) {
setAutoplayCancelled(false);
setSecondsRemaining(settings.autoplayCountdownSeconds);
}
return;
}
setSecondsRemaining(settings.autoplayCountdownSeconds);
intervalRef.current = setInterval(() => {
setSecondsRemaining((prev) => {
if (prev <= 1) {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
autoPlayHandlerRef.current();
return 0;
}
return prev - 1;
});
}, 1000);
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
}, [
showNextEpisodeCountdown,
autoplayCancelled,
settings.autoplayCountdownSeconds,
]);
const nextEpisodePosterUrl = useMemo(
() =>
nextItem ? getPrimaryImageUrl({ api, item: nextItem, width: 200 }) : null,
[api, nextItem],
);
// Current chapter name for the always-visible header label (live playback).
const currentChapterName = useMemo(
() => (hasChapters ? chapterNameAt(currentTime, chapters) : null),
@@ -202,7 +287,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
<SkipButton
showButton={showSkipButton}
onPress={skipIntro}
buttonText='Skip Intro'
buttonText={skipButtonText}
/>
{/* Smart Skip Credits behavior:
- Show "Skip Credits" if there's content after credits OR no next episode
@@ -212,24 +297,17 @@ export const BottomControls: FC<BottomControlsProps> = ({
showSkipCreditButton && (hasContentAfterCredits || !nextItem)
}
onPress={skipCredit}
buttonText='Skip Credits'
buttonText={skipCreditButtonText}
/>
{settings.autoPlayNextEpisode !== false &&
(settings.maxAutoPlayEpisodeCount.value === -1 ||
settings.autoPlayEpisodeCount <
settings.maxAutoPlayEpisodeCount.value) && (
<NextEpisodeCountDownButton
show={
!nextItem
? false
: // Show during credits if no content after, OR near end of video
(showSkipCreditButton && !hasContentAfterCredits) ||
remainingTime < 10000
}
onFinish={handleNextEpisodeAutoPlay}
onPress={handleNextEpisodeManual}
/>
)}
{showNextEpisodeCountdown && !autoplayCancelled && nextItem && (
<AutoplayCountdown
nextEpisode={nextItem}
posterUrl={nextEpisodePosterUrl}
secondsRemaining={secondsRemaining}
onPlayNow={handleNextEpisodeManual}
onCancel={() => setAutoplayCancelled(true)}
/>
)}
</View>
</View>
<View

View File

@@ -4,7 +4,15 @@ import type {
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client";
import { useLocalSearchParams } from "expo-router";
import { type FC, useCallback, useEffect, useState } from "react";
import {
type FC,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { StyleSheet, useWindowDimensions, View } from "react-native";
import Animated, {
Easing,
@@ -16,17 +24,17 @@ import Animated, {
} from "react-native-reanimated";
import ContinueWatchingOverlay from "@/components/video-player/controls/ContinueWatchingOverlay";
import useRouter from "@/hooks/useAppRouter";
import { useCreditSkipper } from "@/hooks/useCreditSkipper";
import { useHaptic } from "@/hooks/useHaptic";
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
import { useSegmentSkipper } from "@/hooks/useSegmentSkipper";
import { useTrickplay } from "@/hooks/useTrickplay";
import type { TechnicalInfo } from "@/modules/mpv-player";
import { DownloadedItem } from "@/providers/Downloads/types";
import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
import { ticksToMs } from "@/utils/time";
import { useSegments } from "@/utils/segments";
import { msToSeconds, ticksToMs } from "@/utils/time";
import { BottomControls } from "./BottomControls";
import { CenterControls } from "./CenterControls";
import { CONTROLS_CONSTANTS } from "./constants";
@@ -43,6 +51,9 @@ import { useControlsTimeout } from "./useControlsTimeout";
import { PlaybackSpeedScope } from "./utils/playback-speed-settings";
import { type AspectRatio } from "./VideoScalingModeSelector";
// No-op function to avoid creating new references on every render
const noop = () => {};
interface Props {
item: BaseItemDto;
isPlaying: boolean;
@@ -110,10 +121,24 @@ export const Controls: FC<Props> = ({
const [episodeView, setEpisodeView] = useState(false);
const [showAudioSlider, setShowAudioSlider] = useState(false);
// Pause the controls auto-hide while the player settings popover is open so
// it can't be dismissed out from under the user (notably the iOS popover,
// which lives inside the controls and closes when they fade out).
const [settingsMenuOpen, setSettingsMenuOpen] = useState(false);
// Ref to track pending play timeout for cleanup and cancellation
const playTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Mutable ref tracking isPlaying to avoid stale closures in seekMs timeout
const playingRef = useRef(isPlaying);
useEffect(() => {
playingRef.current = isPlaying;
}, [isPlaying]);
// Clean up timeout on unmount
useEffect(() => {
return () => {
if (playTimeoutRef.current) {
clearTimeout(playTimeoutRef.current);
}
};
}, []);
const { height: screenHeight, width: screenWidth } = useWindowDimensions();
const { previousItem, nextItem } = usePlaybackManager({
@@ -321,27 +346,125 @@ export const Controls: FC<Props> = ({
subtitleIndex: string;
}>();
const { showSkipButton, skipIntro } = useIntroSkipper(
item.Id!,
currentTime,
seek,
play,
// Fetch all segments for the current item
const { data: segments } = useSegments(
item.Id ?? "",
offline,
api,
downloadedFiles,
api,
);
const { showSkipCreditButton, skipCredit, hasContentAfterCredits } =
useCreditSkipper(
item.Id!,
currentTime,
seek,
play,
offline,
api,
downloadedFiles,
maxMs,
);
// Convert milliseconds to seconds for segment comparison
const currentTimeSeconds = msToSeconds(currentTime);
const maxSeconds = maxMs ? msToSeconds(maxMs) : undefined;
// Wrapper to convert segment skip from seconds to milliseconds
// Includes 200ms delay to allow seek operation to complete before resuming playback
const seekMs = useCallback(
(timeInSeconds: number) => {
// Cancel any pending play call to avoid race conditions
if (playTimeoutRef.current) {
clearTimeout(playTimeoutRef.current);
}
seek(timeInSeconds * 1000);
// Brief delay ensures the seek operation completes before resuming playback
// Without this, playback may resume from the old position
// Read latest isPlaying from ref to avoid stale closure
playTimeoutRef.current = setTimeout(() => {
if (playingRef.current) {
play();
}
playTimeoutRef.current = null;
}, 200);
},
[seek, play],
);
// Use unified segment skipper for all segment types
const introSkipper = useSegmentSkipper({
segments: segments?.introSegments || [],
segmentType: "Intro",
currentTime: currentTimeSeconds,
seek: seekMs,
isPaused: !isPlaying,
});
const outroSkipper = useSegmentSkipper({
segments: segments?.creditSegments || [],
segmentType: "Outro",
currentTime: currentTimeSeconds,
totalDuration: maxSeconds,
seek: seekMs,
isPaused: !isPlaying,
});
const recapSkipper = useSegmentSkipper({
segments: segments?.recapSegments || [],
segmentType: "Recap",
currentTime: currentTimeSeconds,
seek: seekMs,
isPaused: !isPlaying,
});
const commercialSkipper = useSegmentSkipper({
segments: segments?.commercialSegments || [],
segmentType: "Commercial",
currentTime: currentTimeSeconds,
seek: seekMs,
isPaused: !isPlaying,
});
const previewSkipper = useSegmentSkipper({
segments: segments?.previewSegments || [],
segmentType: "Preview",
currentTime: currentTimeSeconds,
seek: seekMs,
isPaused: !isPlaying,
});
// Determine which segment button to show (priority order)
// Commercial > Recap > Intro > Preview > Outro
const activeSegment = useMemo(() => {
if (commercialSkipper.currentSegment)
return { type: "Commercial", ...commercialSkipper };
if (recapSkipper.currentSegment) return { type: "Recap", ...recapSkipper };
if (introSkipper.currentSegment) return { type: "Intro", ...introSkipper };
if (previewSkipper.currentSegment)
return { type: "Preview", ...previewSkipper };
if (outroSkipper.currentSegment) return { type: "Outro", ...outroSkipper };
return null;
}, [
commercialSkipper.currentSegment,
recapSkipper.currentSegment,
introSkipper.currentSegment,
previewSkipper.currentSegment,
outroSkipper.currentSegment,
commercialSkipper,
recapSkipper,
introSkipper,
previewSkipper,
outroSkipper,
]);
// Legacy compatibility: map to old variable names
const showSkipButton = !!(
activeSegment &&
["Intro", "Recap", "Commercial", "Preview"].includes(activeSegment.type)
);
const skipIntro = activeSegment?.skipSegment || noop;
const showSkipCreditButton = activeSegment?.type === "Outro";
const skipCredit = outroSkipper.skipSegment || noop;
const hasContentAfterCredits =
outroSkipper.currentSegment && maxSeconds
? outroSkipper.currentSegment.endTime < maxSeconds
: false;
// Get button text based on segment type using i18n
const { t } = useTranslation();
const skipButtonText = activeSegment
? t(`player.skip_${activeSegment.type.toLowerCase()}`)
: t("player.skip_intro");
const skipCreditButtonText = t("player.skip_outro");
const goToItemCommon = useCallback(
(item: BaseItemDto) => {
@@ -471,7 +594,7 @@ export const Controls: FC<Props> = ({
episodeView,
onHideControls: hideControls,
timeout: CONTROLS_CONSTANTS.TIMEOUT,
disabled: settingsMenuOpen,
disabled: true,
});
const switchOnEpisodeMode = useCallback(() => {
@@ -532,7 +655,6 @@ export const Controls: FC<Props> = ({
setPlaybackSpeed={setPlaybackSpeed}
showTechnicalInfo={showTechnicalInfo}
onToggleTechnicalInfo={onToggleTechnicalInfo}
onSettingsMenuOpenChange={setSettingsMenuOpen}
/>
</Animated.View>
<Animated.View
@@ -569,11 +691,14 @@ export const Controls: FC<Props> = ({
currentTime={currentTime}
remainingTime={remainingTime}
showSkipButton={showSkipButton}
skipButtonText={skipButtonText}
showSkipCreditButton={showSkipCreditButton}
skipCreditButtonText={skipCreditButtonText}
hasContentAfterCredits={hasContentAfterCredits}
skipIntro={skipIntro}
skipCredit={skipCredit}
nextItem={nextItem}
api={api}
handleNextEpisodeAutoPlay={handleNextEpisodeAutoPlay}
handleNextEpisodeManual={handleNextEpisodeManual}
handleControlsInteraction={handleControlsInteraction}

View File

@@ -1254,7 +1254,7 @@ export const Controls: FC<Props> = ({
<Text
style={[styles.endsAtText, { fontSize: typography.callout }]}
>
{t("player.ends_at")} {getFinishTime()}
{t("player.ends_at", { time: getFinishTime() })}
</Text>
</View>
)}
@@ -1448,7 +1448,7 @@ export const Controls: FC<Props> = ({
<Text
style={[styles.endsAtText, { fontSize: typography.callout }]}
>
{t("player.ends_at")} {getFinishTime()}
{t("player.ends_at", { time: getFinishTime() })}
</Text>
</View>
)}

View File

@@ -37,9 +37,6 @@ interface HeaderControlsProps {
// Technical info props
showTechnicalInfo?: boolean;
onToggleTechnicalInfo?: () => void;
// Notifies the parent when the settings popover opens/closes so it can pause
// the controls auto-hide while the menu is up.
onSettingsMenuOpenChange?: (open: boolean) => void;
}
export const HeaderControls: FC<HeaderControlsProps> = ({
@@ -60,7 +57,6 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
setPlaybackSpeed,
showTechnicalInfo = false,
onToggleTechnicalInfo,
onSettingsMenuOpenChange,
}) => {
const { settings } = useSettings();
const router = useRouter();
@@ -121,7 +117,6 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
setPlaybackSpeed={setPlaybackSpeed}
showTechnicalInfo={showTechnicalInfo}
onToggleTechnicalInfo={onToggleTechnicalInfo}
onOpenChange={onSettingsMenuOpenChange}
/>
</View>
)}

View File

@@ -1,96 +0,0 @@
import type React from "react";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import {
TouchableOpacity,
type TouchableOpacityProps,
View,
} from "react-native";
import Animated, {
cancelAnimation,
Easing,
runOnJS,
useAnimatedStyle,
useSharedValue,
withTiming,
} from "react-native-reanimated";
import { Text } from "@/components/common/Text";
import { Colors } from "@/constants/Colors";
interface NextEpisodeCountDownButtonProps extends TouchableOpacityProps {
onFinish?: () => void;
onPress?: () => void;
show: boolean;
}
const NextEpisodeCountDownButton: React.FC<NextEpisodeCountDownButtonProps> = ({
onFinish,
onPress,
show,
...props
}) => {
const progress = useSharedValue(0);
useEffect(() => {
if (show) {
progress.value = 0;
progress.value = withTiming(
1,
{
duration: 10000, // 10 seconds
easing: Easing.linear,
},
(finished) => {
if (finished && onFinish) {
runOnJS(onFinish)();
}
},
);
// Cancel animation on unmount to prevent onFinish from firing after exit
return () => {
cancelAnimation(progress);
};
}
}, [show, onFinish]);
const animatedStyle = useAnimatedStyle(() => {
return {
position: "absolute",
left: 0,
top: 0,
bottom: 0,
width: `${progress.value * 100}%`,
backgroundColor: Colors.primary,
};
});
const handlePress = () => {
if (onPress) {
onPress();
}
};
const { t } = useTranslation();
if (!show) {
return null;
}
return (
<TouchableOpacity
className='w-32 overflow-hidden rounded-md bg-black/60 border border-neutral-900'
{...props}
onPress={handlePress}
>
<Animated.View style={animatedStyle} />
<View className='px-3 py-3'>
<Text numberOfLines={1} className='text-center font-bold'>
{t("player.next_episode")}
</Text>
</View>
</TouchableOpacity>
);
};
export default NextEpisodeCountDownButton;

View File

@@ -3,6 +3,10 @@ import { useLocalSearchParams } from "expo-router";
import { useCallback, useMemo, useRef } from "react";
import { Platform, View } from "react-native";
import { BITRATES } from "@/components/BitrateSelector";
import {
type OptionGroup,
PlatformDropdown,
} from "@/components/PlatformDropdown";
import { PLAYBACK_SPEEDS } from "@/components/PlaybackSpeedSelector";
import useRouter from "@/hooks/useAppRouter";
import { useOfflineMode } from "@/providers/OfflineModeProvider";
@@ -10,18 +14,26 @@ import { useSettings } from "@/utils/atoms/settings";
import { usePlayerContext } from "../contexts/PlayerContext";
import { useVideoContext } from "../contexts/VideoContext";
import { PlaybackSpeedScope } from "../utils/playback-speed-settings";
import {
type OptionGroup,
PlayerSettingsPopover,
} from "./PlayerSettingsPopover";
// Subtitle scale presets (direct multiplier values)
const SUBTITLE_SCALE_PRESETS = [
{ label: "0.1x", value: 0.1 },
{ label: "0.25x", value: 0.25 },
{ label: "0.5x", value: 0.5 },
{ label: "0.75x", value: 0.75 },
{ label: "1.0x", value: 1.0 },
{ label: "1.25x", value: 1.25 },
{ label: "1.5x", value: 1.5 },
{ label: "2.0x", value: 2.0 },
{ label: "2.5x", value: 2.5 },
{ label: "3.0x", value: 3.0 },
] as const;
interface DropdownViewProps {
playbackSpeed?: number;
setPlaybackSpeed?: (speed: number, scope: PlaybackSpeedScope) => void;
showTechnicalInfo?: boolean;
onToggleTechnicalInfo?: () => void;
/** Forwarded to the popover so the player can pause auto-hide while it's open. */
onOpenChange?: (open: boolean) => void;
}
const DropdownView = ({
@@ -29,7 +41,6 @@ const DropdownView = ({
setPlaybackSpeed,
showTechnicalInfo = false,
onToggleTechnicalInfo,
onOpenChange,
}: DropdownViewProps) => {
const { subtitleTracks, audioTracks } = useVideoContext();
const { item, mediaSource } = usePlayerContext();
@@ -91,7 +102,6 @@ const DropdownView = ({
if (!isOffline) {
groups.push({
title: "Quality",
icon: "gauge.with.dots.needle.50percent",
options:
BITRATES?.map((bitrate) => ({
type: "radio" as const,
@@ -103,41 +113,29 @@ const DropdownView = ({
});
}
// Subtitles section. iOS: tap the `...` opens a SwiftUI Popover with the
// section header "SUBTITLES" + a Track row (Menu) + a Size row (native
// Slider). Android: same shape in a bottom-sheet — tap the "Track" row to
// expand the list inline, Size shows a Material 3 Slider.
// Subtitle Section
if (subtitleTracks && subtitleTracks.length > 0) {
groups.push({
title: "Subtitles",
options: [
{
type: "subgroup" as const,
label: "Track",
icon: "captions.bubble",
options: subtitleTracks.map((sub) => ({
type: "radio" as const,
label: sub.name,
value: sub.index.toString(),
selected: subtitleIndex === sub.index.toString(),
onPress: () => sub.setTrack(),
})),
},
{
type: "slider" as const,
label: "Size",
icon: "textformat.size",
value: Math.round((settings.mpvSubtitleScale ?? 1.0) * 10) / 10,
step: 0.1,
min: 0.1,
max: 3.0,
format: (v: number) => `${v.toFixed(1)}x`,
onValueChange: (value: number) =>
updateSettings({
mpvSubtitleScale: Math.round(value * 10) / 10,
}),
},
],
options: subtitleTracks.map((sub) => ({
type: "radio" as const,
label: sub.name,
value: sub.index.toString(),
selected: subtitleIndex === sub.index.toString(),
onPress: () => sub.setTrack(),
})),
});
// Subtitle Scale Section
groups.push({
title: "Subtitle Scale",
options: SUBTITLE_SCALE_PRESETS.map((preset) => ({
type: "radio" as const,
label: preset.label,
value: preset.value.toString(),
selected: (settings.mpvSubtitleScale ?? 1.0) === preset.value,
onPress: () => updateSettings({ mpvSubtitleScale: preset.value }),
})),
});
}
@@ -145,7 +143,6 @@ const DropdownView = ({
if (audioTracks && audioTracks.length > 0) {
groups.push({
title: "Audio",
icon: "speaker.wave.2",
options: audioTracks.map((track) => ({
type: "radio" as const,
label: track.name,
@@ -160,7 +157,6 @@ const DropdownView = ({
if (setPlaybackSpeed) {
groups.push({
title: "Speed",
icon: "speedometer",
options: PLAYBACK_SPEEDS.map((speed) => ({
type: "radio" as const,
label: speed.label,
@@ -180,7 +176,6 @@ const DropdownView = ({
label: showTechnicalInfo
? "Hide Technical Info"
: "Show Technical Info",
icon: "info.circle",
onPress: onToggleTechnicalInfo,
},
],
@@ -221,12 +216,11 @@ const DropdownView = ({
if (Platform.isTV) return null;
return (
<PlayerSettingsPopover
<PlatformDropdown
title='Playback Options'
groups={optionGroups}
trigger={trigger}
expoUIConfig={{}}
onOpenChange={onOpenChange}
bottomSheetConfig={{
enablePanDownToClose: true,
}}

View File

@@ -1,930 +0,0 @@
import { Ionicons } from "@expo/vector-icons";
import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
import React, { useEffect, useState } from "react";
import { Platform, StyleSheet, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import {
MeasuredTriggerHost,
OptionGroupCard,
ToggleSwitch,
} from "@/components/common/dropdownShared";
import { Text } from "@/components/common/Text";
import type {
ActionOption as BaseActionOption,
RadioOption as BaseRadioOption,
ToggleOption as BaseToggleOption,
} from "@/components/PlatformDropdown";
import { useGlobalModal } from "@/providers/GlobalModalProvider";
// Player-only popover/sheet. Shares no rendering with `PlatformDropdown`:
// that component is used by ~20 callers (settings, season pickers,
// bitrate/audio/subtitle selectors, …) and must keep its small native
// Menu look. This one targets the in-player `...` button and is allowed to
// (a) host a real slider, (b) wear the Swift-mock visual style, and
// (c) carry SF Symbol icons per row.
//
// Common boilerplate (trigger measurement, ToggleSwitch, Android option-card
// shell) lives in @/components/common/dropdownShared and is reused with
// PlatformDropdown.
//
// @expo/ui's SwiftUI native module (ExpoUI) does not exist in tvOS builds.
// A static top-level import evaluates requireNativeModule('ExpoUI') at module
// load and crashes the entire route tree on tvOS (expo-router requires every
// route file). Load it lazily and only off-TV; TV never renders these.
const {
Button,
HStack,
Image: SwiftImage,
Menu,
Popover,
Rectangle: SwiftRectangle,
Slider: SwiftSlider,
Spacer,
Stepper,
Text: SwiftText,
Toggle: SwiftToggle,
VStack,
} = Platform.isTV
? ({} as typeof import("@expo/ui/swift-ui"))
: require("@expo/ui/swift-ui");
const {
buttonStyle,
disabled,
font,
foregroundStyle,
frame,
opacity,
padding,
tint,
} = Platform.isTV
? ({} as typeof import("@expo/ui/swift-ui/modifiers"))
: require("@expo/ui/swift-ui/modifiers");
// Android-side Material 3 slider. Lives in @expo/ui/community/slider and is a
// drop-in for react-native-community/slider on Android (and SwiftUI Slider on
// iOS, but we use the swift-ui Slider directly inside the popover instead).
const { Slider: CommunitySlider } = Platform.isTV
? ({} as typeof import("@expo/ui/community/slider"))
: require("@expo/ui/community/slider");
// ---------------------------------------------------------------------------
// Option model
// ---------------------------------------------------------------------------
// Reuses PlatformDropdown's three base option types (so the 20+ shared callers
// and the player popover stay in sync on shape), then adds:
// - `icon?: string` on every variant — SF Symbol shown in the iOS popover
// - Slider / Stepper / Subgroup variants for the player's extra controls
type WithIcon = { icon?: string };
export type RadioOption<T = any> = BaseRadioOption<T> & WithIcon;
export type ToggleOption = BaseToggleOption & WithIcon;
export type ActionOption = BaseActionOption & WithIcon;
export type StepperOption = {
type: "stepper";
label: string;
value: number;
step: number;
min: number;
max: number;
onValueChange: (value: number) => void;
/** Optional value formatter for the displayed number. */
format?: (value: number) => string;
disabled?: boolean;
} & WithIcon;
export type SliderOption = {
type: "slider";
label: string;
value: number;
step: number;
min: number;
max: number;
onValueChange: (value: number) => void;
/** Optional value formatter for the displayed number. */
format?: (value: number) => string;
disabled?: boolean;
} & WithIcon;
/**
* A row that itself opens a nested dropdown. On iOS this renders as a
* SwiftUI `Menu` inside the popover (label = subgroup name, value =
* currently-selected child); on Android the row expands inline to show its
* options when tapped (and collapses again on a second tap).
*/
export type SubgroupOption = {
type: "subgroup";
label: string;
options: Option[];
disabled?: boolean;
} & WithIcon;
export type Option =
| RadioOption
| ToggleOption
| ActionOption
| StepperOption
| SliderOption
| SubgroupOption;
export type OptionGroup = {
title?: string;
options: Option[];
/**
* Optional SF Symbol used for the group's row in the iOS popover when the
* entire group is compressed to a single Menu (e.g. radio-only groups).
*/
icon?: string;
};
interface PlayerSettingsPopoverProps {
trigger?: React.ReactNode;
title?: string;
groups: OptionGroup[];
open?: boolean;
onOpenChange?: (open: boolean) => void;
onOptionSelect?: (value?: any) => void;
expoUIConfig?: {
hostStyle?: any;
};
bottomSheetConfig?: {
enableDynamicSizing?: boolean;
enablePanDownToClose?: boolean;
};
}
// ---------------------------------------------------------------------------
// Android bottom-sheet renderers
// ---------------------------------------------------------------------------
const StepperControl: React.FC<{
option: StepperOption;
}> = ({ option }) => {
const display = option.format
? option.format(option.value)
: option.value.toString();
const canDecrement = option.value > option.min;
const canIncrement = option.value < option.max;
const decrement = () => {
if (option.disabled) return;
const next = Math.max(option.min, option.value - option.step);
if (next !== option.value) option.onValueChange(next);
};
const increment = () => {
if (option.disabled) return;
const next = Math.min(option.max, option.value + option.step);
if (next !== option.value) option.onValueChange(next);
};
return (
<View className='flex flex-row items-center'>
<TouchableOpacity
onPress={decrement}
disabled={!canDecrement || option.disabled}
className={`w-8 h-8 bg-neutral-700 rounded-l-lg flex items-center justify-center ${!canDecrement || option.disabled ? "opacity-40" : ""}`}
>
<Text className='text-white'>-</Text>
</TouchableOpacity>
<View className='h-8 px-3 bg-neutral-700 flex items-center justify-center'>
<Text className='text-white'>{display}</Text>
</View>
<TouchableOpacity
onPress={increment}
disabled={!canIncrement || option.disabled}
className={`w-8 h-8 bg-neutral-700 rounded-r-lg flex items-center justify-center ${!canIncrement || option.disabled ? "opacity-40" : ""}`}
>
<Text className='text-white'>+</Text>
</TouchableOpacity>
</View>
);
};
/**
* Android: full-width Material 3 slider inside the bottom sheet, with a
* label/value row above the track. The slider lives below the touch target so
* dragging it doesn't accidentally collapse the sheet.
*/
const SliderControl: React.FC<{
option: SliderOption;
}> = ({ option }) => {
const display = option.format
? option.format(option.value)
: option.value.toString();
return (
<View className='flex-1 px-4 py-3'>
<View className='flex flex-row items-center justify-between mb-2'>
<Text className='text-white'>{option.label}</Text>
<Text className='text-neutral-400'>{display}</Text>
</View>
<CommunitySlider
value={option.value}
minimumValue={option.min}
maximumValue={option.max}
step={option.step}
onValueChange={option.onValueChange}
disabled={option.disabled}
style={{ width: "100%", height: 40 }}
/>
</View>
);
};
const OptionItem: React.FC<{ option: Option; isLast?: boolean }> = ({
option,
isLast,
}) => {
const [expanded, setExpanded] = useState(false);
const isToggle = option.type === "toggle";
const isAction = option.type === "action";
const isStepper = option.type === "stepper";
const isSlider = option.type === "slider";
const isSubgroup = option.type === "subgroup";
if (isSlider) {
return (
<>
<SliderControl option={option} />
{!isLast && (
<View
style={{ height: StyleSheet.hairlineWidth }}
className='bg-neutral-700 mx-4'
/>
)}
</>
);
}
const handlePress = isToggle
? option.onToggle
: isSubgroup
? () => setExpanded((v) => !v)
: isStepper
? undefined
: (option as RadioOption | ActionOption).onPress;
const selectedChild = isSubgroup
? (option.options.find(
(o): o is RadioOption => o.type === "radio" && o.selected,
) ?? undefined)
: undefined;
return (
<>
<TouchableOpacity
onPress={handlePress}
disabled={option.disabled || isStepper}
activeOpacity={isStepper ? 1 : 0.2}
className={`px-4 py-3 flex flex-row items-center justify-between ${option.disabled ? "opacity-50" : ""}`}
>
<Text className='flex-1 text-white'>{option.label}</Text>
{isToggle ? (
<ToggleSwitch value={option.value} />
) : isStepper ? (
<StepperControl option={option} />
) : isSubgroup ? (
<View className='flex flex-row items-center'>
{selectedChild && (
<Text className='text-neutral-400 mr-2'>
{selectedChild.label}
</Text>
)}
<Ionicons
name={expanded ? "chevron-up" : "chevron-down"}
size={20}
color='#9ca3af'
/>
</View>
) : isAction ? null : (option as RadioOption).selected ? (
<Ionicons name='checkmark-circle' size={24} color='#9333ea' />
) : (
<Ionicons name='ellipse-outline' size={24} color='#6b7280' />
)}
</TouchableOpacity>
{isSubgroup && expanded && (
<View className='pl-4 bg-neutral-900'>
{option.options.map((child, childIndex) => (
<OptionItem
key={childIndex}
option={child}
isLast={childIndex === option.options.length - 1}
/>
))}
</View>
)}
{!isLast && (
<View
style={{ height: StyleSheet.hairlineWidth }}
className='bg-neutral-700 mx-4'
/>
)}
</>
);
};
const OptionGroupComponent: React.FC<{ group: OptionGroup }> = ({ group }) => (
<OptionGroupCard title={group.title}>
{group.options.map((option, index) => (
<OptionItem
key={index}
option={option}
isLast={index === group.options.length - 1}
/>
))}
</OptionGroupCard>
);
const BottomSheetContent: React.FC<{
title?: string;
groups: OptionGroup[];
onOptionSelect?: (value?: any) => void;
onClose?: () => void;
}> = ({ title, groups, onOptionSelect, onClose }) => {
const insets = useSafeAreaInsets();
// Recursively wrap options so radio/action presses also call
// onOptionSelect/onClose, including options nested inside subgroups.
const wrapOption = (option: Option): Option => {
if (option.type === "radio") {
return {
...option,
onPress: () => {
option.onPress();
onOptionSelect?.(option.value);
onClose?.();
},
};
}
if (option.type === "toggle") {
return {
...option,
onToggle: () => {
option.onToggle();
onOptionSelect?.(option.value);
},
};
}
if (option.type === "action") {
return {
...option,
onPress: () => {
option.onPress();
onClose?.();
},
};
}
if (option.type === "subgroup") {
return { ...option, options: option.options.map(wrapOption) };
}
return option;
};
const wrappedGroups = groups.map((group) => ({
...group,
options: group.options.map(wrapOption),
}));
return (
<BottomSheetScrollView
className='px-4 pb-8 pt-2'
style={{
paddingLeft: Math.max(16, insets.left),
paddingRight: Math.max(16, insets.right),
}}
>
{title && <Text className='font-bold text-2xl mb-6'>{title}</Text>}
{wrappedGroups.map((group, index) => (
<OptionGroupComponent key={index} group={group} />
))}
</BottomSheetScrollView>
);
};
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
const PlayerSettingsPopoverComponent = ({
trigger,
title,
groups,
open: controlledOpen,
onOpenChange: controlledOnOpenChange,
onOptionSelect,
expoUIConfig,
bottomSheetConfig,
}: PlayerSettingsPopoverProps) => {
const { showModal, hideModal, isVisible } = useGlobalModal();
// Android: controlled open routes through the global bottom-sheet modal.
useEffect(() => {
if (Platform.OS === "android" && controlledOpen === true) {
showModal(
<BottomSheetContent
title={title}
groups={groups}
onOptionSelect={onOptionSelect}
onClose={() => {
hideModal();
controlledOnOpenChange?.(false);
}}
/>,
{
snapPoints: ["90%"],
enablePanDownToClose: bottomSheetConfig?.enablePanDownToClose ?? true,
},
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [controlledOpen]);
useEffect(() => {
if (Platform.OS === "android" && controlledOpen === true && !isVisible) {
controlledOnOpenChange?.(false);
}
}, [isVisible, controlledOpen, controlledOnOpenChange]);
// Internal open state for the iOS popover. Synced both ways with
// `controlledOpen` when controlled.
const [iosOpen, setIosOpen] = useState(false);
useEffect(() => {
if (Platform.OS === "ios" && controlledOpen !== undefined) {
setIosOpen(controlledOpen);
}
}, [controlledOpen]);
const handleIosOpenChange = (value: boolean) => {
setIosOpen(value);
controlledOnOpenChange?.(value);
};
if (Platform.OS === "ios" && !Platform.isTV) {
const closePopover = () => handleIosOpenChange(false);
// ---- Swift-mock styled popover body ----
// Mirrors the reference Swift `PlayerSettingsViewController` design:
// - small-caps section headers with a hairline rule to the trailing edge
// - 44pt rows with leading SF Symbol, 15pt title, trailing value + glyph
// - real native Slider rows for slider options
// Radio-only titled groups (Quality/Audio/Speed) are compressed to a
// single Menu row whose label is a styled HStack — tapping opens the
// selection menu without changing the panel's height.
type IconName = string | undefined;
const MENU_CHEVRON = "chevron.up.chevron.down" as const;
const TERTIARY = {
type: "hierarchical" as const,
style: "tertiary" as const,
};
const SECONDARY = {
type: "hierarchical" as const,
style: "secondary" as const,
};
/** 24pt-wide leading icon slot. Renders a transparent placeholder when
* no icon is set so titles stay aligned across rows. */
const renderIcon = (icon: IconName) => (
<SwiftImage
systemName={(icon ?? "circle") as any}
size={18}
modifiers={[
frame({ width: 24, alignment: "leading" }),
foregroundStyle(SECONDARY),
...(icon ? [] : [opacity(0)]),
]}
/>
);
/** Small-caps section header + thin separator that fills the row width. */
const renderSectionHeader = (sectionTitle: string, key: string) => (
<HStack
key={key}
spacing={10}
alignment='center'
modifiers={[frame({ height: 28 })]}
>
<SwiftText
modifiers={[
font({ size: 11, weight: "semibold" }),
foregroundStyle(TERTIARY),
]}
>
{sectionTitle.toUpperCase()}
</SwiftText>
<SwiftRectangle
modifiers={[frame({ height: 1 }), foregroundStyle(TERTIARY)]}
/>
</HStack>
);
/** Bare hairline used to close out a multi-row titled section. */
const renderDivider = (key: string) => (
<SwiftRectangle
key={key}
modifiers={[
frame({ height: 1 }),
foregroundStyle(TERTIARY),
padding({ vertical: 2 }),
]}
/>
);
/** Render menu-safe children (radio/action) inside a SwiftUI Menu. */
const renderMenuChild = (option: Option, key: string): any => {
if (option.type === "radio") {
return (
<Button
key={key}
label={option.label}
systemImage={
(option.selected ? "checkmark.circle.fill" : "circle") as any
}
modifiers={option.disabled ? [disabled(true)] : undefined}
onPress={() => {
option.onPress();
onOptionSelect?.(option.value);
closePopover();
}}
/>
);
}
if (option.type === "action") {
return (
<Button
key={key}
label={option.label}
modifiers={option.disabled ? [disabled(true)] : undefined}
onPress={() => {
option.onPress();
closePopover();
}}
/>
);
}
return null;
};
/** Row that opens a SwiftUI Menu on tap. Used for compressed radio
* groups and for subgroup options inside a multi-row section. */
const renderMenuRow = ({
key,
icon,
title: rowTitle,
valueLabel,
children,
}: {
key: string;
icon: IconName;
title: string;
valueLabel?: string;
children: any;
}) => (
<Menu
key={key}
label={
<HStack
spacing={10}
alignment='center'
modifiers={[frame({ height: 44 })]}
>
{renderIcon(icon)}
<SwiftText modifiers={[font({ size: 15 })]}>{rowTitle}</SwiftText>
<Spacer />
{valueLabel ? (
<SwiftText
modifiers={[font({ size: 13 }), foregroundStyle(SECONDARY)]}
>
{valueLabel}
</SwiftText>
) : null}
<SwiftImage
systemName={MENU_CHEVRON as any}
size={12}
modifiers={[foregroundStyle(TERTIARY)]}
/>
</HStack>
}
>
{children}
</Menu>
);
const renderSliderRow = (option: SliderOption, key: string) => {
const display = option.format
? option.format(option.value)
: option.value.toString();
return (
<HStack
key={key}
spacing={10}
alignment='center'
modifiers={[frame({ height: 44 })]}
>
{renderIcon(option.icon)}
<SwiftText
modifiers={[
font({ size: 15 }),
frame({ width: 64, alignment: "leading" }),
]}
>
{option.label}
</SwiftText>
<SwiftSlider
value={option.value}
min={option.min}
max={option.max}
step={option.step}
modifiers={option.disabled ? [disabled(true)] : undefined}
onValueChange={option.onValueChange}
/>
<SwiftText
modifiers={[
font({ size: 13, design: "monospaced" }),
foregroundStyle(SECONDARY),
frame({ width: 44, alignment: "trailing" }),
]}
>
{display}
</SwiftText>
</HStack>
);
};
const renderStepperRow = (option: StepperOption, key: string) => {
const display = option.format
? option.format(option.value)
: option.value.toString();
return (
<HStack
key={key}
spacing={10}
alignment='center'
modifiers={[frame({ height: 44 })]}
>
{renderIcon(option.icon)}
<SwiftText modifiers={[font({ size: 15 })]}>{option.label}</SwiftText>
<Spacer />
<Stepper
label={display}
value={option.value}
step={option.step}
min={option.min}
max={option.max}
modifiers={option.disabled ? [disabled(true)] : undefined}
onValueChange={option.onValueChange}
/>
</HStack>
);
};
const renderToggleRow = (option: ToggleOption, key: string) => (
<HStack
key={key}
spacing={10}
alignment='center'
modifiers={[frame({ height: 44 })]}
>
{renderIcon(option.icon)}
<SwiftText modifiers={[font({ size: 15 })]}>{option.label}</SwiftText>
<Spacer />
<SwiftToggle
label=''
value={option.value}
modifiers={option.disabled ? [disabled(true)] : undefined}
onValueChange={() => {
option.onToggle();
onOptionSelect?.(option.value);
}}
/>
</HStack>
);
const renderActionRow = (option: ActionOption, key: string) => (
<Button
key={key}
modifiers={[
buttonStyle("plain"),
...(option.disabled ? [disabled(true)] : []),
]}
onPress={() => {
option.onPress();
closePopover();
}}
>
<HStack
spacing={10}
alignment='center'
modifiers={[frame({ height: 44 })]}
>
{renderIcon(option.icon)}
<SwiftText modifiers={[font({ size: 15 })]}>{option.label}</SwiftText>
<Spacer />
</HStack>
</Button>
);
/** Render one Option as its own row inside a mixed (non-compressed)
* section. */
const renderOptionRow = (option: Option, key: string): any => {
if (option.type === "slider") return renderSliderRow(option, key);
if (option.type === "stepper") return renderStepperRow(option, key);
if (option.type === "toggle") return renderToggleRow(option, key);
if (option.type === "action") return renderActionRow(option, key);
if (option.type === "subgroup") {
const selectedChild = option.options.find(
(o): o is RadioOption => o.type === "radio" && o.selected,
);
return renderMenuRow({
key,
icon: option.icon,
title: option.label,
valueLabel: selectedChild?.label,
children: option.options.map((child, idx) =>
renderMenuChild(child, `${key}-c${idx}`),
),
});
}
if (option.type === "radio") {
return (
<Button
key={key}
modifiers={[
buttonStyle("plain"),
...(option.disabled ? [disabled(true)] : []),
]}
onPress={() => {
option.onPress();
onOptionSelect?.(option.value);
closePopover();
}}
>
<HStack
spacing={10}
alignment='center'
modifiers={[frame({ height: 44 })]}
>
{renderIcon(option.icon)}
<SwiftText modifiers={[font({ size: 15 })]}>
{option.label}
</SwiftText>
<Spacer />
{option.selected ? (
<SwiftImage
systemName={"checkmark" as any}
size={14}
modifiers={[foregroundStyle(SECONDARY)]}
/>
) : null}
</HStack>
</Button>
);
}
return null;
};
/**
* Render an entire OptionGroup.
* - Titled group with only radio (or radio + action) options →
* compressed to a single Menu row.
* - Titled group containing slider/toggle/stepper/subgroup →
* section header + individual rows.
* - Untitled group → individual rows, no header.
*/
const renderGroup = (group: OptionGroup, groupIndex: number): any[] => {
if (group.options.length === 0) return [];
const onlyMenuSafe = group.options.every(
(o) => o.type === "radio" || o.type === "action",
);
if (group.title && onlyMenuSafe) {
const selectedRadio = group.options.find(
(o): o is RadioOption => o.type === "radio" && o.selected,
);
return [
renderMenuRow({
key: `group-${groupIndex}`,
icon: group.icon,
title: group.title,
valueLabel: selectedRadio?.label,
children: group.options.map((opt, idx) =>
renderMenuChild(opt, `g${groupIndex}-c${idx}`),
),
}),
];
}
const rows: any[] = [];
if (group.title) {
rows.push(renderSectionHeader(group.title, `header-${groupIndex}`));
}
group.options.forEach((opt, idx) => {
rows.push(renderOptionRow(opt, `g${groupIndex}-o${idx}`));
});
return rows;
};
return (
<MeasuredTriggerHost
trigger={trigger}
hostStyle={expoUIConfig?.hostStyle}
>
<Popover
isPresented={iosOpen}
onIsPresentedChange={handleIosOpenChange}
arrowEdge='top'
>
<Popover.Trigger>
{/* Wrap the RN trigger view in a SwiftUI Button so tap handling
is captured at the SwiftUI layer (matches the codebase
pattern in SearchTabButtons.tsx). */}
<Button
modifiers={[buttonStyle("plain")]}
onPress={() => handleIosOpenChange(true)}
>
{trigger}
</Button>
</Popover.Trigger>
<Popover.Content>
{/* Bare VStack — no Form/List chrome — so the panel reads as
the Swift mock's floating glass card. The popover itself
supplies the material background; we just stack rows
inside. Width pinned to ~320pt; height >= 480pt. */}
<VStack
spacing={0}
alignment='leading'
modifiers={[
padding({ horizontal: 18, top: 12, bottom: 12 }),
frame({
minWidth: 300,
idealWidth: 320,
maxWidth: 360,
minHeight: 480,
idealHeight: 520,
}),
// Tint cascades to all child controls — Slider track, Menu
// checkmark, Stepper ± buttons, Toggle — so one modifier
// paints the whole popover white instead of system blue.
tint("white"),
]}
>
{groups.flatMap((group, groupIndex) => {
const rows = renderGroup(group, groupIndex);
if (rows.length === 0) return [];
// After a multi-row titled section (Subtitles), append a
// bare hairline divider so it's clearly separated from
// the next group below.
const isMultiRow =
!!group.title &&
!group.options.every(
(o) => o.type === "radio" || o.type === "action",
);
const hasNext = groupIndex < groups.length - 1;
return isMultiRow && hasNext
? [...rows, renderDivider(`footer-${groupIndex}`)]
: rows;
})}
</VStack>
</Popover.Content>
</Popover>
</MeasuredTriggerHost>
);
}
// Android: open the bottom sheet directly on press (uncontrolled mode).
const handlePress = () => {
showModal(
<BottomSheetContent
title={title}
groups={groups}
onOptionSelect={onOptionSelect}
onClose={hideModal}
/>,
{
snapPoints: ["90%"],
enablePanDownToClose: bottomSheetConfig?.enablePanDownToClose ?? true,
},
);
};
return (
<TouchableOpacity onPress={handlePress} activeOpacity={0.7}>
{trigger || <Text className='text-white'>Open Menu</Text>}
</TouchableOpacity>
);
};
// Memoize to prevent unnecessary re-renders when parent re-renders.
export const PlayerSettingsPopover = React.memo(
PlayerSettingsPopoverComponent,
(prevProps, nextProps) =>
prevProps.title === nextProps.title &&
prevProps.open === nextProps.open &&
prevProps.groups === nextProps.groups &&
prevProps.trigger === nextProps.trigger,
);

View File

@@ -0,0 +1,39 @@
# Chromecast Cast Test Matrix
Manual verification for the device-profile work. Run each row by casting the
matching media from the app to a physical Chromecast and recording the result.
**Test device:** ___________________ (model name as reported by the app)
**App build / commit:** ___________________
**Date:** ___________________
## How to run
1. Pick a library item matching the row's codec / audio / container.
2. Cast it. Note whether it direct-plays or transcodes (server logs show
`Video is being transcoded` vs `Video is being direct played`).
3. Record the load result: OK / 2100 / infinite-loading / other.
## Matrix
| # | Video codec | Audio | Container | Approx bitrate | Direct/Transcode | Result | Notes |
|---|---|---|---|---|---|---|---|
| 1 | H.264 1080p | AAC stereo | MP4 | ~4 Mb/s | | | |
| 2 | H.264 1080p | AAC stereo | MKV | ~8 Mb/s | | | |
| 3 | H.264 1080p | AAC 5.1 | MKV | ~8 Mb/s | | | |
| 4 | H.264 1080p | AC3 5.1 | MKV | ~10 Mb/s | | | |
| 5 | H.264 1080p | DTS 5.1 | MKV | ~12 Mb/s | | | |
| 6 | H.264 1080p | TrueHD 7.1 | MKV | ~20 Mb/s | | | |
| 7 | H.264 1080p | AAC stereo | MP4 | ~16 Mb/s | | | |
| 8 | H.264 1080p | AAC stereo | MP4 | source-max | | | |
| 9 | HEVC 8-bit | AAC stereo | MKV | ~10 Mb/s | | | |
| 10 | HEVC 10-bit | AAC stereo | MKV | ~15 Mb/s | | | |
## Outcome
- Highest video bitrate that loads reliably on the test device: ___________
-> update `CONSERVATIVE_CAPABILITIES.maxVideoBitrate` in
`utils/casting/capabilities.ts` accordingly.
- Confirmed cause of issue #1423 (<= 2 Mb/s): ___________
- Confirmed cause of the 5.1 crash (#1085): ___________
- Cases where downgrade-on-failure retry rescued playback: ___________

View File

@@ -56,7 +56,11 @@
"environment": "production",
"autoIncrement": true,
"android": {
"image": "latest"
"image": "latest",
"config": "android-production.yml"
},
"ios": {
"config": "ios-production.yml"
}
},
"production-apk": {
@@ -65,7 +69,8 @@
"autoIncrement": true,
"android": {
"buildType": "apk",
"image": "latest"
"image": "latest",
"config": "android-production-apk.yml"
}
},
"production-apk-tv": {
@@ -74,7 +79,8 @@
"autoIncrement": true,
"android": {
"buildType": "apk",
"image": "latest"
"image": "latest",
"config": "android-production-tv.yml"
},
"env": {
"EXPO_TV": "1"
@@ -88,7 +94,8 @@
"EXPO_TV": "1"
},
"ios": {
"credentialsSource": "local"
"credentialsSource": "local",
"config": "ios-production.yml"
}
}
},

431
hooks/useCastAutoplay.ts Normal file
View File

@@ -0,0 +1,431 @@
/**
* Cast autoplay watcher.
*
* Always-mounted hook: subscribes to the Chromecast `mediaStatus`, captures the
* currently-playing episode while playback is active, and on either
* (a) playback entering the Outro segment (when `skipOutro !== "auto"`), or
* (b) `IDLE + FINISHED` (hard end of media),
* starts a cancellable countdown via `castAutoplayAtom` and ultimately loads
* the next episode on the cast.
*
* The countdown atom is driven here; the casting-player overlay reads it.
* Cancellation (overlay's Cancel button) sets the atom to `null` externally;
* the watcher reacts by clearing its interval and refusing to retrigger for
* the same item.
*/
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
import { useAtom, useAtomValue } from "jotai";
import { useEffect, useRef, useState } from "react";
import {
MediaPlayerIdleReason,
MediaPlayerState,
useCastDevice,
useMediaStatus,
useRemoteMediaClient,
} from "react-native-google-cast";
import { toast } from "sonner-native";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { castAutoplayAtom } from "@/utils/atoms/castAutoplay";
import { useSettings } from "@/utils/atoms/settings";
import { loadCastMedia } from "@/utils/casting/castLoad";
import { fetchSeriesEpisodes, findNextEpisode } from "@/utils/casting/episodes";
import { useSegments } from "@/utils/segments";
/**
* Cached next-episode resolution, keyed by the captured (seriesId, currentEpisodeId)
* pair so the network calls are not repeated on every `mediaStatus` tick.
*/
interface NextEpisodeCache {
seriesId: string;
currentEpisodeId: string;
nextEpisode: BaseItemDto | null;
}
export interface ShouldStartCountdownParams {
playerState: MediaPlayerState | undefined;
idleReason: MediaPlayerIdleReason | undefined;
currentPositionMs: number;
outroStartMs: number | null;
outroEndMs: number | null;
skipOutro: string;
alreadyTriggered: boolean;
}
/**
* Pure decision helper: should the countdown start *right now*?
* Exported for testability.
*/
export const shouldStartCountdown = ({
playerState,
idleReason,
currentPositionMs,
outroStartMs,
outroEndMs,
skipOutro,
alreadyTriggered,
}: ShouldStartCountdownParams): boolean => {
if (alreadyTriggered) return false;
// (b) hard end of media — fires regardless of segment availability.
if (
playerState === MediaPlayerState.IDLE &&
idleReason === MediaPlayerIdleReason.FINISHED
) {
return true;
}
// (a) playback inside Outro segment, and Outro is not already auto-skipped.
if (
skipOutro !== "auto" &&
outroStartMs != null &&
outroEndMs != null &&
currentPositionMs >= outroStartMs &&
currentPositionMs < outroEndMs
) {
return true;
}
return false;
};
export const useCastAutoplay = (): void => {
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const { settings, updateSettings } = useSettings();
const mediaStatus = useMediaStatus();
const remoteMediaClient = useRemoteMediaClient();
const castDevice = useCastDevice();
const [autoplayState, setAutoplayState] = useAtom(castAutoplayAtom);
// Continuously captured currently-playing item (full BaseItemDto, fetched
// from Jellyfin). `mediaInfo` clears at IDLE so we must capture as it plays.
const capturedItemRef = useRef<BaseItemDto | null>(null);
const capturedItemIdRef = useRef<string | null>(null);
// State mirror of the captured item id so downstream effects/hooks re-run
// *after* the async getItem resolves — depending on `contentId` directly
// would fire them before the ref is populated and they'd read stale data.
const [capturedItemId, setCapturedItemId] = useState<string | null>(null);
// Cached next-episode resolution per (seriesId, currentEpisodeId).
const nextEpisodeCacheRef = useRef<NextEpisodeCache | null>(null);
// Last item id we triggered a countdown for. Reset when captured item changes
// so the same finished episode does not retrigger.
const triggeredForItemIdRef = useRef<string | null>(null);
// Countdown interval handle.
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Track whether the atom transitioned to null while a countdown is running —
// that means the overlay cancelled, so we must not retrigger for this item.
const autoplayStateRef = useRef(autoplayState);
autoplayStateRef.current = autoplayState;
// Latest settings snapshot reachable from the interval / load callback
// without re-creating the interval on every settings change.
const settingsRef = useRef(settings);
settingsRef.current = settings;
const updateSettingsRef = useRef(updateSettings);
updateSettingsRef.current = updateSettings;
const apiRef = useRef(api);
apiRef.current = api;
const userRef = useRef(user);
userRef.current = user;
const remoteMediaClientRef = useRef(remoteMediaClient);
remoteMediaClientRef.current = remoteMediaClient;
const castDeviceRef = useRef(castDevice);
castDeviceRef.current = castDevice;
const contentId = mediaStatus?.mediaInfo?.contentId ?? null;
// --- 1. Capture the currently-playing item, full BaseItemDto. ---
useEffect(() => {
if (!contentId || !api || !user?.Id) {
// No active content: clear all captured state so downstream effects /
// useSegments stop using a stale previous-item id.
capturedItemRef.current = null;
capturedItemIdRef.current = null;
setCapturedItemId(null);
return;
}
// If the captured id changed, reset the trigger guard immediately — the
// user moved to another episode, and that new episode should be eligible.
if (capturedItemIdRef.current !== contentId) {
triggeredForItemIdRef.current = null;
}
let cancelled = false;
const controller = new AbortController();
(async () => {
try {
const res = await getUserLibraryApi(api).getItem(
{ itemId: contentId, userId: user.Id! },
{ signal: controller.signal },
);
if (cancelled) return;
capturedItemRef.current = res.data;
capturedItemIdRef.current = contentId;
// Publish the captured id as state *after* the ref is set, so the
// next-episode-resolve effect (keyed on this state) sees a populated
// ref by the time it runs.
setCapturedItemId(contentId);
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError")
return;
// Non-fatal: keep whatever we last captured.
console.error("[useCastAutoplay] Failed to fetch item:", error);
}
})();
return () => {
cancelled = true;
controller.abort();
};
}, [contentId, api, user?.Id]);
// --- 2. Resolve next episode (cached per series+episode). ---
// This effect runs whenever the captured item id changes; the cache key
// prevents refetching on every mediaStatus tick.
useEffect(() => {
const item = capturedItemRef.current;
if (!item || !api || !user) return;
if (item.Type !== "Episode") {
nextEpisodeCacheRef.current = null;
return;
}
const seriesId = item.SeriesId;
const currentEpisodeId = item.Id;
if (!seriesId || !currentEpisodeId) {
nextEpisodeCacheRef.current = null;
return;
}
const cached = nextEpisodeCacheRef.current;
if (
cached &&
cached.seriesId === seriesId &&
cached.currentEpisodeId === currentEpisodeId
) {
return;
}
let cancelled = false;
(async () => {
try {
const episodes = await fetchSeriesEpisodes(api, user, seriesId);
if (cancelled) return;
nextEpisodeCacheRef.current = {
seriesId,
currentEpisodeId,
nextEpisode: findNextEpisode(episodes, currentEpisodeId),
};
} catch (error) {
console.error(
"[useCastAutoplay] Failed to resolve next episode:",
error,
);
}
})();
return () => {
cancelled = true;
};
// Depend on the *state* mirror of the captured id rather than `contentId`
// directly: `contentId` flips synchronously on the new episode, but
// `capturedItemRef.current` is only populated after the async getItem
// resolves. Keying on `capturedItemId` (set right after the ref write)
// guarantees the ref points at the new item by the time we read it here.
}, [capturedItemId, api, user]);
// --- 3. Media segments for the captured item (Outro). ---
// Matches `useChromecastSegments`: cast playback is online, no downloaded
// files context to thread through.
const { data: segmentData } = useSegments(
capturedItemId ?? "",
false,
undefined,
api,
);
const outroSegment = segmentData?.creditSegments?.[0] ?? null;
const outroStartMs = outroSegment ? outroSegment.startTime * 1000 : null;
const outroEndMs = outroSegment ? outroSegment.endTime * 1000 : null;
// --- 4. Trigger detection. ---
useEffect(() => {
// Master gate: setting must allow autoplay, and a countdown must not be
// already running. The atom drives the countdown; an active atom means
// we already triggered (possibly via overlay's Play now).
if (!settings.autoPlayNextEpisode) return;
if (autoplayState !== null) return;
const maxValue = settings.maxAutoPlayEpisodeCount.value;
if (maxValue !== -1 && settings.autoPlayEpisodeCount >= maxValue) return;
const capturedItem = capturedItemRef.current;
const capturedItemId = capturedItemIdRef.current;
if (!capturedItem || !capturedItemId) return;
if (capturedItem.Type !== "Episode") return;
const cached = nextEpisodeCacheRef.current;
if (
!cached ||
cached.currentEpisodeId !== capturedItemId ||
!cached.nextEpisode
) {
return;
}
const nextEpisode = cached.nextEpisode;
const currentPositionMs = (mediaStatus?.streamPosition ?? 0) * 1000;
const should = shouldStartCountdown({
playerState: mediaStatus?.playerState as MediaPlayerState | undefined,
idleReason: mediaStatus?.idleReason as MediaPlayerIdleReason | undefined,
currentPositionMs,
outroStartMs,
outroEndMs,
skipOutro: settings.skipOutro,
alreadyTriggered: triggeredForItemIdRef.current === capturedItemId,
});
if (!should) return;
triggeredForItemIdRef.current = capturedItemId;
setAutoplayState({
nextEpisode,
secondsRemaining: settings.castAutoplayCountdownSeconds,
});
// The countdown interval is started by the effect below (reacts to the
// atom transitioning to non-null), so this effect stays pure-decide.
}, [
mediaStatus?.playerState,
mediaStatus?.idleReason,
mediaStatus?.streamPosition,
outroStartMs,
outroEndMs,
settings.autoPlayNextEpisode,
settings.autoPlayEpisodeCount,
settings.maxAutoPlayEpisodeCount,
settings.castAutoplayCountdownSeconds,
settings.skipOutro,
autoplayState,
setAutoplayState,
]);
// --- 5. Run countdown interval whenever atom is non-null. ---
// Starting/stopping is driven by the atom value, so an external Cancel
// (overlay) that sets the atom to null naturally tears the interval down.
useEffect(() => {
if (autoplayState === null) {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
return;
}
// Only start an interval if one is not already running.
if (intervalRef.current) return;
intervalRef.current = setInterval(() => {
// Read latest atom value from ref to decide what to do next.
const current = autoplayStateRef.current;
if (current === null) {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
return;
}
const next = current.secondsRemaining - 1;
if (next > 0) {
setAutoplayState({ ...current, secondsRemaining: next });
return;
}
// Time's up — load the next episode and clear.
// Snapshot what we need; clear the interval and atom synchronously to
// avoid double-fire.
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
const episodeToLoad = current.nextEpisode;
setAutoplayState(null);
const apiLocal = apiRef.current;
const userLocal = userRef.current;
const clientLocal = remoteMediaClientRef.current;
const deviceLocal = castDeviceRef.current;
const settingsLocal = settingsRef.current;
if (!apiLocal || !userLocal?.Id || !clientLocal || !episodeToLoad?.Id) {
return;
}
// Mirror `useCastEpisodes.loadEpisode` exactly — same arguments,
// same start-position derivation.
(async () => {
try {
const startPositionMs =
(episodeToLoad.UserData?.PlaybackPositionTicks ?? 0) / 10000;
const result = await loadCastMedia({
client: clientLocal,
device: deviceLocal,
api: apiLocal,
item: episodeToLoad,
userId: userLocal.Id!,
profileMode: settingsLocal.chromecastProfile,
maxBitrateSetting: settingsLocal.chromecastMaxBitrate,
options: { startPositionMs },
});
if (!result.ok) {
console.error(
"[useCastAutoplay] Failed to load next episode:",
result.error,
);
return;
}
// Read the freshest count at the moment of the write — the
// overlay's "Play now" can reset this to 0 in parallel, and using
// a snapshot taken before the await would clobber that reset.
updateSettingsRef.current({
autoPlayEpisodeCount: settingsRef.current.autoPlayEpisodeCount + 1,
});
toast("Playing next episode");
} catch (error) {
console.error(
"[useCastAutoplay] Failed to load next episode:",
error,
);
}
})();
}, 1000);
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
}, [autoplayState, setAutoplayState]);
// --- 6. Final unmount cleanup is covered by the interval effect's
// return; nothing else to do here.
};
export default useCastAutoplay;

View File

@@ -0,0 +1,69 @@
import type { ImperativeRouter } from "expo-router";
import { useCallback } from "react";
import { Gesture } from "react-native-gesture-handler";
import {
runOnJS,
useAnimatedStyle,
useSharedValue,
withSpring,
} from "react-native-reanimated";
interface UseCastDismissGestureParams {
router: ImperativeRouter;
}
/**
* Swipe-down-to-dismiss gesture cluster for the casting player modal.
* Owns the `translateY`/`context` shared values, the pan gesture, the animated
* style, and the `dismissModal` callback (also invoked by the header button).
*/
export function useCastDismissGesture({ router }: UseCastDismissGestureParams) {
// Swipe down to dismiss gesture
const translateY = useSharedValue(0);
const context = useSharedValue({ y: 0 });
const dismissModal = useCallback(() => {
// Navigate immediately without animation to prevent crashes
if (router.canGoBack()) {
router.back();
} else {
router.replace("/(auth)/(tabs)/(home)/");
}
}, [router]);
const panGesture = Gesture.Pan()
.onStart(() => {
context.value = { y: translateY.value };
})
.onUpdate((event) => {
// Only allow downward swipes from top of screen
if (event.translationY > 0) {
translateY.value = context.value.y + event.translationY;
}
})
.onEnd((event) => {
// Dismiss if swiped down more than 150px or fast swipe
if (event.translationY > 150 || event.velocityY > 600) {
// Animate down and dismiss
translateY.value = withSpring(
1000,
{
damping: 20,
stiffness: 90,
},
() => {
runOnJS(dismissModal)();
},
);
} else {
// Spring back to position
translateY.value = withSpring(0);
}
});
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ translateY: translateY.value }],
}));
return { panGesture, animatedStyle, dismissModal };
}

156
hooks/useCastEpisodes.ts Normal file
View File

@@ -0,0 +1,156 @@
import type { Api } from "@jellyfin/sdk";
import type { BaseItemDto, UserDto } from "@jellyfin/sdk/lib/generated-client";
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
import { useCallback, useEffect, useState } from "react";
import type { Device, RemoteMediaClient } from "react-native-google-cast";
import type { Settings } from "@/utils/atoms/settings";
import { loadCastMedia } from "@/utils/casting/castLoad";
import { fetchSeriesEpisodes, findNextEpisode } from "@/utils/casting/episodes";
interface UseCastEpisodesParams {
api: Api | null;
user: UserDto | null;
currentItem: BaseItemDto | null;
remoteMediaClient: RemoteMediaClient | null;
castDevice: Device | null;
settings: Settings;
}
interface UseCastEpisodesResult {
episodes: BaseItemDto[];
nextEpisode: BaseItemDto | null;
seasonData: BaseItemDto | null;
loadEpisode: (episode: BaseItemDto) => Promise<void>;
/**
* Id of the episode currently being loaded onto the cast device, or null
* when no load is pending. The cast `customData` (and thus `currentItem`)
* lags behind the load, so consumers use this to detect the stale window
* between a `loadEpisode` call and the cast reporting the new episode.
*/
loadingEpisodeId: string | null;
}
export function useCastEpisodes({
api,
user,
currentItem,
remoteMediaClient,
castDevice,
settings,
}: UseCastEpisodesParams): UseCastEpisodesResult {
const [episodes, setEpisodes] = useState<BaseItemDto[]>([]);
const [nextEpisode, setNextEpisode] = useState<BaseItemDto | null>(null);
const [seasonData, setSeasonData] = useState<BaseItemDto | null>(null);
// Target episode id while a load is in flight; cleared once it resolves.
const [loadingEpisodeId, setLoadingEpisodeId] = useState<string | null>(null);
// Load a different episode on the Chromecast
const loadEpisode = useCallback(
async (episode: BaseItemDto) => {
if (!api || !user?.Id || !episode.Id || !remoteMediaClient) return;
setLoadingEpisodeId(episode.Id);
try {
const startPositionMs =
(episode.UserData?.PlaybackPositionTicks ?? 0) / 10000;
const result = await loadCastMedia({
client: remoteMediaClient,
device: castDevice,
api,
item: episode,
userId: user.Id,
profileMode: settings.chromecastProfile,
maxBitrateSetting: settings.chromecastMaxBitrate,
options: { startPositionMs },
});
if (!result.ok) {
console.error(
"[Casting Player] Failed to load episode:",
result.error,
);
return;
}
} catch (error) {
console.error("[Casting Player] Failed to load episode:", error);
} finally {
// Clear regardless of outcome: on success `currentItem` catches up via
// customData; on failure the stale guard must not stay stuck.
setLoadingEpisodeId(null);
}
},
[
api,
user?.Id,
remoteMediaClient,
castDevice,
settings.chromecastProfile,
settings.chromecastMaxBitrate,
],
);
// Fetch season data for season poster
useEffect(() => {
if (
currentItem?.Type !== "Episode" ||
!currentItem.SeasonId ||
!api ||
!user?.Id
)
return;
const fetchSeasonData = async () => {
try {
const userLibraryApi = getUserLibraryApi(api);
const response = await userLibraryApi.getItem({
itemId: currentItem.SeasonId!,
userId: user.Id!,
});
setSeasonData(response.data);
} catch (error) {
console.error("[Casting Player] Failed to fetch season data:", error);
setSeasonData(null);
}
};
fetchSeasonData();
}, [currentItem?.Type, currentItem?.SeasonId, api, user?.Id]);
// Fetch episodes for TV shows
useEffect(() => {
if (
currentItem?.Type !== "Episode" ||
!currentItem.SeriesId ||
!api ||
!user
)
return;
const fetchEpisodes = async () => {
try {
// Fetch ALL episodes from ALL seasons (no season filter).
const episodeList = await fetchSeriesEpisodes(
api,
user,
currentItem.SeriesId!,
);
setEpisodes(episodeList);
setNextEpisode(findNextEpisode(episodeList, currentItem.Id));
} catch (error) {
console.error("Failed to fetch episodes:", error);
}
};
fetchEpisodes();
}, [
currentItem?.Type,
currentItem?.SeriesId,
currentItem?.SeasonId,
currentItem?.Id,
api,
user,
]);
return { episodes, nextEpisode, seasonData, loadEpisode, loadingEpisodeId };
}

View File

@@ -0,0 +1,94 @@
import type { Api } from "@jellyfin/sdk";
import type { BaseItemDto, UserDto } from "@jellyfin/sdk/lib/generated-client";
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
import { useEffect, useMemo, useState } from "react";
import type { MediaStatus } from "react-native-google-cast";
interface UseCastPlayerItemParams {
api: Api | null;
user: UserDto | null;
mediaStatus: MediaStatus | null;
}
interface UseCastPlayerItemResult {
fetchedItem: BaseItemDto | null;
currentItem: BaseItemDto | null;
}
export function useCastPlayerItem({
api,
user,
mediaStatus,
}: UseCastPlayerItemParams): UseCastPlayerItemResult {
// Fetch full item data from Jellyfin by ID
const [fetchedItem, setFetchedItem] = useState<BaseItemDto | null>(null);
useEffect(() => {
const controller = new AbortController();
const fetchItemData = async () => {
const itemId = mediaStatus?.mediaInfo?.contentId;
if (!itemId || !api || !user?.Id) return;
try {
const res = await getUserLibraryApi(api).getItem(
{ itemId, userId: user.Id },
{ signal: controller.signal },
);
if (!controller.signal.aborted) {
setFetchedItem(res.data);
}
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError")
return;
console.error("[Casting Player] Failed to fetch item:", error);
}
};
fetchItemData();
return () => controller.abort();
}, [mediaStatus?.mediaInfo?.contentId, api, user?.Id]);
// Extract item from customData, or use fetched item, or create a minimal fallback
const currentItem = useMemo(() => {
// Priority 1: Use fetched item from API (most reliable)
if (fetchedItem) {
return fetchedItem;
}
// Priority 2: Try customData from mediaStatus
const customData = mediaStatus?.mediaInfo?.customData as BaseItemDto | null;
if (
customData?.Type &&
(customData.ImageTags || customData.MediaSources || customData.Id)
) {
// Use customData if it has a real Type AND meaningful metadata
// (rules out placeholder objects that lack image tags, media sources, or an ID)
return customData;
}
// Priority 3: Create minimal fallback while loading
if (mediaStatus?.mediaInfo) {
const { contentId, metadata } = mediaStatus.mediaInfo;
// Derive type from metadata if available, otherwise omit to avoid
// misrepresenting episodes as movies
let metadataType: string | undefined;
if (metadata?.type === "movie") {
metadataType = "Movie";
} else if (metadata?.type === "tvShow") {
metadataType = "Episode";
}
return {
Id: contentId,
Name: metadata?.title || "Unknown",
...(metadataType ? { Type: metadataType } : {}),
ServerId: "",
} as BaseItemDto;
}
return null;
}, [fetchedItem, mediaStatus?.mediaInfo]);
return { fetchedItem, currentItem };
}

View File

@@ -0,0 +1,148 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { type RefObject, useEffect, useRef, useState } from "react";
import { MediaPlayerState, type MediaStatus } from "react-native-google-cast";
import { type SharedValue, useSharedValue } from "react-native-reanimated";
import { useTrickplay } from "@/hooks/useTrickplay";
interface TrickplayTime {
hours: number;
minutes: number;
seconds: number;
}
interface UseCastPlayerProgressParams {
/** Raw Chromecast media status, or null when no session. */
mediaStatus: MediaStatus | null;
/** Full item fetched from Jellyfin, used to derive trickplay data. */
fetchedItem: BaseItemDto | null;
/** Total media duration, in seconds. */
duration: number;
}
type TrickplayReturn = ReturnType<typeof useTrickplay>;
interface UseCastPlayerProgressResult {
/** Shared value tracking the slider progress, in milliseconds. */
sliderProgress: SharedValue<number>;
/** Shared value for the slider minimum, in milliseconds. */
sliderMin: SharedValue<number>;
/** Shared value for the slider maximum, in milliseconds. */
sliderMax: SharedValue<number>;
/** Mutable ref flag set true while the user is scrubbing. */
isScrubbing: RefObject<boolean>;
/** Trickplay time display state for the bubble. */
trickplayTime: TrickplayTime;
/** Updates the trickplay time display state. */
setTrickplayTime: (time: TrickplayTime) => void;
/** Current playback progress, in seconds (live-updating). */
progress: number;
/** Last stable playback position (seconds), for resuming across reloads. */
resumePositionRef: RefObject<number>;
/** Current trickplay image URL/coordinates, or null. */
trickPlayUrl: TrickplayReturn["trickPlayUrl"];
/** Computes the trickplay URL for a given progress in ticks. */
calculateTrickplayUrl: TrickplayReturn["calculateTrickplayUrl"];
/** Parsed trickplay metadata, or null. */
trickplayInfo: TrickplayReturn["trickplayInfo"];
}
/**
* Progress/slider/trickplay cluster for the casting player.
* Owns the slider shared values, scrub state, live-progress interpolation,
* resume-position tracking, and trickplay preview.
*/
export function useCastPlayerProgress({
mediaStatus,
fetchedItem,
duration,
}: UseCastPlayerProgressParams): UseCastPlayerProgressResult {
// Shared values for progress slider (must be initialized before any early returns)
const sliderProgress = useSharedValue(0);
const sliderMin = useSharedValue(0);
const sliderMax = useSharedValue(100);
const isScrubbing = useRef(false);
// Trickplay time display
const [trickplayTime, setTrickplayTime] = useState({
hours: 0,
minutes: 0,
seconds: 0,
});
// Live progress tracking - update every second
const [liveProgress, setLiveProgress] = useState(0);
const lastSyncPositionRef = useRef(0);
const lastSyncTimestampRef = useRef(Date.now());
// Last stable playback position (seconds), for resuming across reloads.
const resumePositionRef = useRef(0);
useEffect(() => {
// Sync refs whenever mediaStatus provides a new position
if (mediaStatus?.streamPosition !== undefined) {
lastSyncPositionRef.current = mediaStatus.streamPosition;
lastSyncTimestampRef.current = Date.now();
setLiveProgress(mediaStatus.streamPosition);
}
// Update every second when playing, deriving from last sync point
const interval = setInterval(() => {
if (
mediaStatus?.playerState === MediaPlayerState.PLAYING &&
mediaStatus?.streamPosition !== undefined
) {
const elapsed = (Date.now() - lastSyncTimestampRef.current) / 1000;
setLiveProgress(lastSyncPositionRef.current + elapsed);
} else if (mediaStatus?.streamPosition !== undefined) {
// Sync with actual position when paused/buffering
setLiveProgress(mediaStatus.streamPosition);
}
}, 1000);
return () => clearInterval(interval);
}, [mediaStatus?.playerState, mediaStatus?.streamPosition]);
// Track the last stable position so a reload mid-switch resumes correctly.
useEffect(() => {
const pos = mediaStatus?.streamPosition ?? 0;
if (mediaStatus?.playerState === MediaPlayerState.PLAYING && pos > 0) {
resumePositionRef.current = pos;
}
}, [mediaStatus?.streamPosition, mediaStatus?.playerState]);
// Derive state from raw Chromecast hooks
const progress = liveProgress; // Use live-updating progress
// Trickplay for seeking preview - use fetched item with full data
const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } = useTrickplay(
fetchedItem ?? null,
);
// Update slider max when duration changes
useEffect(() => {
if (duration > 0) {
sliderMax.value = duration * 1000; // Convert to milliseconds
}
}, [duration, sliderMax]);
// Update slider progress when not scrubbing
useEffect(() => {
if (!isScrubbing.current && progress > 0) {
sliderProgress.value = progress * 1000; // Convert to milliseconds
}
}, [progress, sliderProgress]);
return {
sliderProgress,
sliderMin,
sliderMax,
isScrubbing,
trickplayTime,
setTrickplayTime,
progress,
resumePositionRef,
trickPlayUrl,
calculateTrickplayUrl,
trickplayInfo,
};
}

75
hooks/useCastSelection.ts Normal file
View File

@@ -0,0 +1,75 @@
/**
* Source of truth for the active cast track / quality / version selection.
*
* Truth = the CastSelection echoed back in the cast media customData. A local
* `pending` selection is shown optimistically while a reload re-transcodes, then
* cleared once the cast reports it (reconciled) or the reload fails.
*/
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useCallback, useEffect, useState } from "react";
import type { MediaStatus } from "react-native-google-cast";
import { resolveSelection, selectionsEqual } from "@/utils/casting/selection";
import type { CastSelection } from "@/utils/casting/types";
interface UseCastSelectionParams {
currentItem: BaseItemDto | null;
mediaStatus: MediaStatus | null | undefined;
/** Reload the cast stream with the given selection. Resolves true on success. */
reload: (selection: CastSelection) => Promise<boolean>;
}
interface UseCastSelectionResult {
/** Effective selection: optimistic pending, else cast truth, else default. */
currentSelection: CastSelection | null;
/** Merge a partial selection, show it optimistically, and reload the stream. */
applySelection: (partial: Partial<CastSelection>) => void;
}
export const useCastSelection = ({
currentItem,
mediaStatus,
reload,
}: UseCastSelectionParams): UseCastSelectionResult => {
const [pending, setPending] = useState<CastSelection | null>(null);
// Truth: the selection the cast reports as loaded, via customData.
const truth =
(
mediaStatus?.mediaInfo?.customData as
| { selection?: CastSelection }
| undefined
)?.selection ?? null;
const currentSelection: CastSelection | null =
pending ??
truth ??
(currentItem ? resolveSelection(currentItem, {}) : null);
// A new media item invalidates any pending selection from the previous one.
// biome-ignore lint/correctness/useExhaustiveDependencies: keyed on item id only
useEffect(() => {
setPending(null);
}, [currentItem?.Id]);
// Reconcile: once the cast reports the pending selection as loaded, clear it.
useEffect(() => {
if (pending && truth && selectionsEqual(pending, truth)) {
setPending(null);
}
}, [pending, truth]);
const applySelection = useCallback(
(partial: Partial<CastSelection>) => {
if (!currentSelection) return;
const next: CastSelection = { ...currentSelection, ...partial };
setPending(next);
reload(next).then((ok) => {
if (!ok) setPending(null);
});
},
[currentSelection, reload],
);
return { currentSelection, applySelection };
};

407
hooks/useCasting.ts Normal file
View File

@@ -0,0 +1,407 @@
/**
* Unified Casting Hook
* Protocol-agnostic casting interface - currently supports Chromecast
* Architecture allows for future protocol integrations
*/
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api";
import { useAtomValue } from "jotai";
import { useCallback, useEffect, useRef, useState } from "react";
import {
CastState,
useCastDevice,
useCastState,
useMediaStatus,
useRemoteMediaClient,
} from "react-native-google-cast";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import type { CastPlayerState, CastProtocol } from "@/utils/casting/types";
import { DEFAULT_CAST_STATE } from "@/utils/casting/types";
/**
* Unified hook for managing casting
* Extensible architecture supporting multiple protocols
*/
export const useCasting = (item: BaseItemDto | null) => {
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
// Chromecast hooks
const client = useRemoteMediaClient();
const castDevice = useCastDevice();
const castState = useCastState();
const mediaStatus = useMediaStatus();
// Local state
const [state, setState] = useState<CastPlayerState>(DEFAULT_CAST_STATE);
const lastReportedProgressRef = useRef(0);
const volumeDebounceRef = useRef<NodeJS.Timeout | null>(null);
const hasReportedStartRef = useRef<string | null>(null); // Track which item we reported start for
const stateRef = useRef<CastPlayerState>(DEFAULT_CAST_STATE); // Ref for progress reporting without deps
// Helper to update both state and ref
const updateState = useCallback(
(updater: (prev: CastPlayerState) => CastPlayerState) => {
setState((prev) => {
const next = updater(prev);
stateRef.current = next;
return next;
});
},
[],
);
// Real Jellyfin PlaySessionId, embedded in customData by buildCastMediaInfo.
const playSessionId =
(
mediaStatus?.mediaInfo?.customData as
| { playSessionId?: string }
| undefined
)?.playSessionId ?? mediaStatus?.mediaInfo?.contentId;
const playMethod =
(
mediaStatus?.mediaInfo?.customData as
| { playMethod?: "Transcode" | "DirectPlay" }
| undefined
)?.playMethod ?? "Transcode";
// Detect which protocol is active - use CastState for reliable detection
const chromecastConnected = castState === CastState.CONNECTED;
// Future: Add detection for other protocols here
const activeProtocol: CastProtocol | null = chromecastConnected
? "chromecast"
: null;
const isConnected = chromecastConnected;
// Update current device
useEffect(() => {
if (chromecastConnected && castDevice) {
updateState((prev) => ({
...prev,
isConnected: true,
protocol: "chromecast",
currentDevice: {
id: castDevice.deviceId,
name: castDevice.friendlyName || castDevice.deviceId,
protocol: "chromecast",
},
}));
} else {
updateState((prev) => ({
...prev,
isConnected: false,
protocol: null,
currentDevice: null,
}));
}
// Future: Add device detection for other protocols
}, [chromecastConnected, castDevice]);
// Chromecast: Update playback state
useEffect(() => {
if (activeProtocol === "chromecast" && mediaStatus) {
updateState((prev) => ({
...prev,
isPlaying: mediaStatus.playerState === "playing",
progress: (mediaStatus.streamPosition || 0) * 1000,
duration: (mediaStatus.mediaInfo?.streamDuration || 0) * 1000,
isBuffering: mediaStatus.playerState === "buffering",
}));
}
}, [mediaStatus, activeProtocol, updateState]);
// Chromecast: Sync volume from mediaStatus
useEffect(() => {
if (activeProtocol !== "chromecast") return;
// Sync from mediaStatus when available
if (mediaStatus?.volume !== undefined) {
updateState((prev) => ({
...prev,
volume: mediaStatus.volume,
}));
}
}, [mediaStatus?.volume, activeProtocol, updateState]);
// Progress reporting to Jellyfin (matches native player behavior)
// Uses stateRef to read current progress/volume without adding them as deps
useEffect(() => {
if (!isConnected || !item?.Id || !user?.Id || !api) return;
const playStateApi = getPlaystateApi(api);
// Report playback start when media begins (only once per item)
// Don't require progress > 0 — playback can legitimately start at position 0
const currentState = stateRef.current;
const isPlaybackActive =
currentState.isPlaying ||
mediaStatus?.playerState === "playing" ||
currentState.progress > 0;
if (hasReportedStartRef.current !== item.Id && isPlaybackActive) {
// Set synchronously before async call to prevent race condition duplicates
hasReportedStartRef.current = item.Id || null;
playStateApi
.reportPlaybackStart({
playbackStartInfo: {
ItemId: item.Id,
PositionTicks: Math.floor(currentState.progress * 10000),
PlayMethod: playMethod,
VolumeLevel: Math.floor(currentState.volume * 100),
IsMuted: currentState.volume === 0,
PlaySessionId: playSessionId,
},
})
.catch((error) => {
// Revert on failure so it can be retried
hasReportedStartRef.current = null;
console.error("[useCasting] Failed to report playback start:", error);
});
}
const reportProgress = () => {
const s = stateRef.current;
// Don't report if no meaningful progress or if buffering
if (s.progress <= 0 || s.isBuffering) return;
const progressMs = Math.floor(s.progress);
const progressTicks = progressMs * 10000; // Convert ms to ticks
const progressSeconds = Math.floor(progressMs / 1000);
// When paused, always report to keep server in sync
// When playing, skip if progress hasn't changed significantly (less than 3 seconds)
if (
s.isPlaying &&
Math.abs(progressSeconds - lastReportedProgressRef.current) < 3
) {
return;
}
lastReportedProgressRef.current = progressSeconds;
playStateApi
.reportPlaybackProgress({
playbackProgressInfo: {
ItemId: item.Id,
PositionTicks: progressTicks,
IsPaused: !s.isPlaying,
PlayMethod: playMethod,
VolumeLevel: Math.floor(s.volume * 100),
IsMuted: s.volume === 0,
PlaySessionId: playSessionId,
},
})
.catch((error) => {
console.error("[useCasting] Failed to report progress:", error);
});
};
// Report progress on a fixed interval, reading latest state from ref
const interval = setInterval(reportProgress, 10000);
return () => clearInterval(interval);
}, [
api,
item?.Id,
user?.Id,
isConnected,
activeProtocol,
playSessionId,
playMethod,
]);
// Play/Pause controls
const play = useCallback(async () => {
if (activeProtocol === "chromecast") {
// Check if there's an active media session
if (!client || !mediaStatus?.mediaInfo) {
console.warn(
"[useCasting] Cannot play - no active media session. Media needs to be loaded first.",
);
return;
}
try {
await client.play();
} catch (error) {
console.error("[useCasting] Error playing:", error);
throw error;
}
}
// Future: Add play control for other protocols
}, [client, mediaStatus, activeProtocol]);
const pause = useCallback(async () => {
if (activeProtocol === "chromecast") {
try {
await client?.pause();
} catch (error) {
console.error("[useCasting] Error pausing:", error);
throw error;
}
}
// Future: Add pause control for other protocols
}, [client, activeProtocol]);
const togglePlayPause = useCallback(async () => {
if (state.isPlaying) {
await pause();
} else {
await play();
}
}, [state.isPlaying, play, pause]);
// Seek controls
const seek = useCallback(
async (positionMs: number) => {
// Validate position
if (positionMs < 0 || !Number.isFinite(positionMs)) {
console.error("[useCasting] Invalid seek position (ms):", positionMs);
return;
}
const positionSeconds = positionMs / 1000;
// Additional validation for Chromecast
if (activeProtocol === "chromecast") {
// state.duration is in ms, positionSeconds is in seconds - compare in same unit
// Only clamp when duration is known (> 0) to avoid forcing seeks to 0
const durationSeconds = state.duration / 1000;
if (durationSeconds > 0 && positionSeconds > durationSeconds) {
console.warn(
"[useCasting] Seek position exceeds duration, clamping:",
positionSeconds,
"->",
durationSeconds,
);
await client?.seek({ position: durationSeconds });
return;
}
await client?.seek({ position: positionSeconds });
}
// Future: Add seek control for other protocols
},
[client, activeProtocol, state.duration],
);
const skipForward = useCallback(
async (seconds = 10) => {
const newPosition = state.progress + seconds * 1000;
await seek(Math.min(newPosition, state.duration));
},
[state.progress, state.duration, seek],
);
const skipBackward = useCallback(
async (seconds = 10) => {
const newPosition = state.progress - seconds * 1000;
await seek(Math.max(newPosition, 0));
},
[state.progress, seek],
);
// Stop and disconnect
const stop = useCallback(
async (onStopComplete?: () => void) => {
try {
if (activeProtocol === "chromecast") {
await client?.stop();
}
// Future: Add stop control for other protocols
// Report stop to Jellyfin
if (api && item?.Id && user?.Id) {
const playStateApi = getPlaystateApi(api);
await playStateApi.reportPlaybackStopped({
playbackStopInfo: {
ItemId: item.Id,
PositionTicks: stateRef.current.progress * 10000,
},
});
}
} catch (error) {
console.error("[useCasting] Error during stop:", error);
} finally {
hasReportedStartRef.current = null;
setState(DEFAULT_CAST_STATE);
stateRef.current = DEFAULT_CAST_STATE;
// Call callback after stop completes (e.g., to navigate away)
if (onStopComplete) {
onStopComplete();
}
}
},
[client, api, item?.Id, user?.Id, activeProtocol],
);
// Volume control (debounced to reduce API calls)
const setVolume = useCallback(
(volume: number) => {
const clampedVolume = Math.max(0, Math.min(1, volume));
// Update UI immediately
updateState((prev) => ({ ...prev, volume: clampedVolume }));
// Debounce API call
if (volumeDebounceRef.current) {
clearTimeout(volumeDebounceRef.current);
}
volumeDebounceRef.current = setTimeout(async () => {
if (activeProtocol === "chromecast" && client && isConnected) {
// Use setStreamVolume for media stream volume (0.0 - 1.0)
// Physical volume buttons are handled automatically by the framework
await client.setStreamVolume(clampedVolume).catch(() => {
// Ignore errors - session might have ended
});
}
// Future: Add volume control for other protocols
}, 300);
},
[client, activeProtocol, isConnected],
);
// Cleanup
useEffect(() => {
return () => {
if (volumeDebounceRef.current) {
clearTimeout(volumeDebounceRef.current);
}
};
}, []);
return {
// State
isConnected,
protocol: activeProtocol,
isPlaying: state.isPlaying,
isBuffering: state.isBuffering,
currentItem: item,
currentDevice: state.currentDevice,
progress: state.progress,
duration: state.duration,
volume: state.volume,
// Availability - derived from actual cast state
isChromecastAvailable:
castState === CastState.CONNECTED ||
castState === CastState.CONNECTING ||
castState === CastState.NOT_CONNECTED,
// Raw clients (for advanced operations)
remoteMediaClient: client,
// Controls
play,
pause,
togglePlayPause,
seek,
skipForward,
skipBackward,
stop,
setVolume,
};
};

View File

@@ -109,30 +109,35 @@ export const usePlaybackManager = ({
staleTime: 0,
});
/**
* Derive prev/next from the current item's real position in the adjacent
* list rather than from the array length. `getEpisodes({ adjacentTo })` does
* not guarantee a fixed [prev, current, next] shape — at the first/last
* episode it can still return the current item as the first/last entry — so
* length-based indexing wrongly surfaces the current episode as "previous".
*/
const currentIndex = useMemo(
() => adjacentItems?.findIndex((e) => e.Id === item?.Id) ?? -1,
[adjacentItems, item],
);
/** A neighbour is only navigable if it has an actual media file (not a
* "Virtual"/missing episode placeholder, e.g. an absent Special). */
const isNavigable = (episode?: BaseItemDto | null): episode is BaseItemDto =>
!!episode && episode.Id !== item?.Id && episode.LocationType !== "Virtual";
const previousItem = useMemo(() => {
if (!adjacentItems || adjacentItems.length <= 1) {
return null;
}
if (adjacentItems.length === 2) {
return adjacentItems[0].Id === item?.Id ? null : adjacentItems[0];
}
return adjacentItems[0];
}, [adjacentItems, item]);
if (!adjacentItems || currentIndex <= 0) return null;
const candidate = adjacentItems[currentIndex - 1];
return isNavigable(candidate) ? candidate : null;
}, [adjacentItems, currentIndex, item]);
/** The next item in the series */
const nextItem = useMemo(() => {
if (!adjacentItems || adjacentItems.length <= 1) {
return null;
}
if (adjacentItems.length === 2) {
return adjacentItems[1].Id === item?.Id ? null : adjacentItems[1];
}
return adjacentItems[2];
}, [adjacentItems, item]);
if (!adjacentItems || currentIndex < 0) return null;
const candidate = adjacentItems[currentIndex + 1];
return isNavigable(candidate) ? candidate : null;
}, [adjacentItems, currentIndex, item]);
/**
* Reports playback progress.

64
hooks/useRemoteControl.ts Normal file
View File

@@ -0,0 +1,64 @@
/**
* Dispatches Jellyfin remote-control WebSocket messages to the active
* PlaybackController. DisplayMessage is shown as an in-app toast and needs no
* controller.
*/
import { useAtomValue } from "jotai";
import { useEffect, useRef } from "react";
import { toast } from "sonner-native";
import { activePlaybackControllerAtom } from "@/utils/playback/playbackController";
import {
mapRemoteCommand,
type RemoteWsMessage,
} from "@/utils/playback/remoteCommands";
/** Handle one remote-control message (call it whenever a new WS message arrives). */
export const useRemoteControl = (lastMessage: RemoteWsMessage | null): void => {
const controller = useAtomValue(activePlaybackControllerAtom);
const handledRef = useRef<RemoteWsMessage | null>(null);
useEffect(() => {
if (!lastMessage || lastMessage === handledRef.current) return;
handledRef.current = lastMessage;
const action = mapRemoteCommand(lastMessage);
if (!action) return;
if (action.kind === "displayMessage") {
toast(action.text);
return;
}
if (!controller) return;
switch (action.kind) {
case "playPause":
controller.playPause();
break;
case "pause":
controller.pause();
break;
case "unpause":
controller.unpause();
break;
case "stop":
controller.stop();
break;
case "seek":
controller.seek(action.positionMs);
break;
case "next":
controller.next();
break;
case "previous":
controller.previous();
break;
case "setVolume":
controller.setVolume(action.level);
break;
case "toggleMute":
controller.toggleMute();
break;
}
}, [lastMessage, controller]);
};

113
hooks/useSegmentSkipper.ts Normal file
View File

@@ -0,0 +1,113 @@
import { useCallback, useEffect, useRef } from "react";
import { MediaTimeSegment } from "@/providers/Downloads/types";
import { useSettings } from "@/utils/atoms/settings";
import { useHaptic } from "./useHaptic";
type SegmentType = "Intro" | "Outro" | "Recap" | "Commercial" | "Preview";
interface UseSegmentSkipperProps {
segments: MediaTimeSegment[];
segmentType: SegmentType;
currentTime: number;
totalDuration?: number;
seek: (time: number) => void;
isPaused: boolean;
}
interface UseSegmentSkipperReturn {
currentSegment: MediaTimeSegment | null;
skipSegment: (notifyOrUseHaptics?: boolean) => void;
}
/**
* Generic hook to handle all media segment types (intro, outro, recap, commercial, preview)
* Supports three modes: 'none' (disabled), 'ask' (show button), 'auto' (auto-skip)
*/
export const useSegmentSkipper = ({
segments,
segmentType,
currentTime,
totalDuration,
seek,
isPaused,
}: UseSegmentSkipperProps): UseSegmentSkipperReturn => {
const { settings } = useSettings();
const haptic = useHaptic();
const autoSkipTriggeredRef = useRef<string | null>(null);
// Get skip mode based on segment type
const skipMode = (() => {
switch (segmentType) {
case "Intro":
return settings.skipIntro;
case "Outro":
return settings.skipOutro;
case "Recap":
return settings.skipRecap;
case "Commercial":
return settings.skipCommercial;
case "Preview":
return settings.skipPreview;
default:
return "none";
}
})();
// Find current segment
const currentSegment =
segments.find(
(segment) =>
currentTime >= segment.startTime && currentTime < segment.endTime,
) || null;
// Skip function with optional haptic feedback
const skipSegment = useCallback(
(notifyOrUseHaptics = true) => {
if (!currentSegment || skipMode === "none") return;
// For Outro segments, prevent seeking past the end
if (
segmentType === "Outro" &&
totalDuration != null &&
Number.isFinite(totalDuration)
) {
const seekTime = Math.min(currentSegment.endTime, totalDuration);
seek(seekTime);
} else {
seek(currentSegment.endTime);
}
// Only trigger haptic feedback if explicitly requested (manual skip)
if (notifyOrUseHaptics) {
haptic();
}
},
[currentSegment, segmentType, totalDuration, seek, haptic, skipMode],
);
// Auto-skip logic when mode is 'auto'
useEffect(() => {
if (skipMode !== "auto" || isPaused) {
return;
}
// Track segment identity to avoid re-triggering on pause/unpause
const segmentId = currentSegment
? `${currentSegment.startTime}-${currentSegment.endTime}`
: null;
if (currentSegment && autoSkipTriggeredRef.current !== segmentId) {
autoSkipTriggeredRef.current = segmentId;
skipSegment(false); // Don't trigger haptics for auto-skip
}
if (!currentSegment) {
autoSkipTriggeredRef.current = null;
}
}, [currentSegment, skipMode, isPaused, skipSegment]);
// Return null segment if skip mode is 'none'
return {
currentSegment: skipMode === "none" ? null : currentSegment,
skipSegment,
};
};

View File

@@ -17,20 +17,24 @@ interface TrickplayUrl {
}
/** Hook to handle trickplay logic for a given item. */
export const useTrickplay = (item: BaseItemDto) => {
export const useTrickplay = (item: BaseItemDto | null) => {
const { getDownloadedItemById } = useDownload();
const [trickPlayUrl, setTrickPlayUrl] = useState<TrickplayUrl | null>(null);
const lastCalculationTime = useRef(0);
const throttleDelay = 200;
const isOffline = useGlobalSearchParams().offline === "true";
const trickplayInfo = useMemo(() => getTrickplayInfo(item), [item]);
const trickplayInfo = useMemo(
() => (item ? getTrickplayInfo(item) : null),
[item],
);
/** Generates the trickplay URL for the given item and sheet index.
* We change between offline and online trickplay URLs depending on the state of the app. */
const getTrickplayUrl = useCallback(
(item: BaseItemDto, sheetIndex: number) => {
if (!item.Id) return null;
// If we are offline, we can use the downloaded item's trickplay data path
const downloadedItem = getDownloadedItemById(item.Id!);
const downloadedItem = getDownloadedItemById(item.Id);
if (isOffline && downloadedItem?.trickPlayData?.path) {
return `${downloadedItem.trickPlayData.path}${sheetIndex}.jpg`;
}
@@ -45,7 +49,7 @@ export const useTrickplay = (item: BaseItemDto) => {
const now = Date.now();
if (
!trickplayInfo ||
!item.Id ||
!item?.Id ||
now - lastCalculationTime.current < throttleDelay
)
return;
@@ -62,7 +66,7 @@ export const useTrickplay = (item: BaseItemDto) => {
/** Prefetches all the trickplay images for the item, limiting concurrency to avoid I/O spikes. */
const prefetchAllTrickplayImages = useCallback(async () => {
if (!trickplayInfo || !item.Id) return;
if (!trickplayInfo || !item?.Id) return;
const maxConcurrent = 4;
const total = trickplayInfo.totalImageSheets;
const urls: string[] = [];

View File

@@ -1,46 +1,20 @@
plugins {
id 'com.android.library'
id 'kotlin-android'
}
apply plugin: 'expo-module-gradle-plugin'
group = 'expo.modules.backgrounddownloader'
version = '1.0.0'
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
def kotlinVersion = findProperty('android.kotlinVersion') ?: '1.9.25'
apply from: expoModulesCorePlugin
applyKotlinExpoModulesCorePlugin()
useDefaultAndroidSdkVersions()
useCoreDependencies()
useExpoPublishing()
expoModule {
canBePublished false
}
android {
namespace "expo.modules.backgrounddownloader"
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
lintOptions {
abortOnError false
defaultConfig {
versionCode 1
versionName "1.0.0"
}
}
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion"
implementation "com.squareup.okhttp3:okhttp:4.12.0"
}
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach {
kotlinOptions {
jvmTarget = "17"
}
}

View File

@@ -1,11 +1,13 @@
import { useTranslation } from "react-i18next";
import { MpvPlayerViewProps } from "./MpvPlayer.types";
export default function MpvPlayerView(props: MpvPlayerViewProps) {
const url = props.source?.url ?? "";
const { t } = useTranslation();
return (
<div>
<iframe
title='MPV Player'
title={t("player.mpv_player_title")}
style={{ flex: 1 }}
src={url}
onLoad={() => props.onLoad?.({ nativeEvent: { url } })}

View File

@@ -4,9 +4,16 @@ const { withEntitlementsPlist } = require("expo/config-plugins");
* Expo config plugin to add User Management entitlement for tvOS profile linking
*/
const withTVUserManagement = (config) => {
// Only add for tvOS builds. The entitlement is restricted by Apple and must
// be present in the provisioning profile, so injecting it into mobile builds
// breaks signing ("Entitlement ... not found and could not be included in
// profile"). The entitlement is only needed for tvOS
// TVUserManager.currentUserIdentifier.
if (process.env.EXPO_TV !== "1") {
return config;
}
return withEntitlementsPlist(config, (config) => {
// Only add for tvOS builds (check if building for TV)
// The entitlement is needed for TVUserManager.currentUserIdentifier to work
config.modResults["com.apple.developer.user-management"] = [
"runs-as-current-user",
];

View File

@@ -32,12 +32,6 @@ export interface MediaTimeSegment {
text: string;
}
export interface Segment {
startTime: number;
endTime: number;
text: string;
}
/** Represents a single downloaded media item with all necessary metadata for offline playback. */
export interface DownloadedItem {
/** The Jellyfin item DTO. */
@@ -56,6 +50,12 @@ export interface DownloadedItem {
introSegments?: MediaTimeSegment[];
/** The credit segments for the item. */
creditSegments?: MediaTimeSegment[];
/** The recap segments for the item. */
recapSegments?: MediaTimeSegment[];
/** The commercial segments for the item. */
commercialSegments?: MediaTimeSegment[];
/** The preview segments for the item. */
previewSegments?: MediaTimeSegment[];
/** The user data for the item. */
userData: UserData;
}
@@ -144,6 +144,12 @@ export type JobStatus = {
introSegments?: MediaTimeSegment[];
/** Pre-downloaded credit segments (optional) - downloaded before video starts */
creditSegments?: MediaTimeSegment[];
/** Pre-downloaded recap segments (optional) - downloaded before video starts */
recapSegments?: MediaTimeSegment[];
/** Pre-downloaded commercial segments (optional) - downloaded before video starts */
commercialSegments?: MediaTimeSegment[];
/** Pre-downloaded preview segments (optional) - downloaded before video starts */
previewSegments?: MediaTimeSegment[];
/** The audio stream index selected for this download */
audioStreamIndex?: number;
/** The subtitle stream index selected for this download */

View File

@@ -69,6 +69,13 @@ const initialApi = (() => {
const initialUser = (() => {
try {
// Only return a stored user if we also have a token. Otherwise the
// user atom would be populated while the api atom is null (e.g. after
// a logout that left stale user JSON in storage), which causes
// useProtectedRoute to keep us inside the (auth) group instead of
// redirecting to /login.
const token = storage.getString("token");
if (!token) return null;
const userStr = storage.getString("user");
if (userStr) {
return JSON.parse(userStr) as UserDto;
@@ -402,6 +409,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
);
storage.remove("token");
storage.remove("user");
clearTVDiscoverySafely();
setUser(null);
setApi(null);

View File

@@ -28,6 +28,10 @@ import { useNetworkStatus } from "@/providers/NetworkStatusProvider";
import { settingsAtom } from "@/utils/atoms/settings";
import { getAudioStreamUrl } from "@/utils/jellyfin/audio/getAudioStreamUrl";
import { storage } from "@/utils/mmkv";
import {
type PlaybackController,
useRegisterPlaybackController,
} from "@/utils/playback/playbackController";
// Conditionally import TrackPlayer only on non-TV platforms
// This prevents the native module from being loaded on TV where it doesn't exist
@@ -1621,6 +1625,43 @@ const MobileMusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
settings?.audioLookaheadCount,
]);
// App-wide remote-control surface: wraps the existing music controls so
// remote commands can target whatever player is currently active.
const isMusicActive = state.currentTrack !== null;
const playbackController = useMemo<PlaybackController>(
() => ({
playPause: () => {
togglePlayPause();
},
pause: () => {
pause();
},
unpause: () => {
resume();
},
stop: () => {
stop();
},
// TrackPlayer works in seconds; the controller contract is milliseconds.
seek: (positionMs: number) => {
seek(positionMs / 1000);
},
next: () => {
next();
},
previous: () => {
previous();
},
// The music player exposes no volume API — keep these as no-ops.
setVolume: () => {},
toggleMute: () => {},
}),
[togglePlayPause, pause, resume, stop, seek, next, previous],
);
useRegisterPlaybackController(playbackController, isMusicActive);
const value = useMemo(
() => ({
...state,

View File

@@ -13,6 +13,7 @@ import {
import { AppState, type AppStateStatus } from "react-native";
import useRouter from "@/hooks/useAppRouter";
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
import { useRemoteControl } from "@/hooks/useRemoteControl";
import { apiAtom, getOrSetDeviceId } from "@/providers/JellyfinProvider";
import { useNetworkStatus } from "@/providers/NetworkStatusProvider";
@@ -54,6 +55,8 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
const [ws, setWs] = useState<WebSocket | null>(null);
const [isConnected, setIsConnected] = useState(false);
const [lastMessage, setLastMessage] = useState<WebSocketMessage | null>(null);
// Route Jellyfin remote-control messages to the active player.
useRemoteControl(lastMessage);
const router = useRouter();
const queryClient = useNetworkAwareQueryClient();
const deviceId = useMemo(() => {
@@ -219,7 +222,14 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
IconUrl:
"https://raw.githubusercontent.com/retardgerman/streamyfinweb/refs/heads/main/public/assets/images/icon_new_withoutBackground.png",
PlayableMediaTypes: ["Audio", "Video"],
SupportedCommands: ["Play"],
SupportedCommands: [
"Play",
"DisplayMessage",
"SetVolume",
"ToggleMute",
"Mute",
"Unmute",
],
SupportsMediaControl: true,
SupportsPersistentIdentifier: true,
},

View File

@@ -4,6 +4,9 @@
"error_title": "خطأ",
"login_title": "تسجيل الدخول",
"login_to_title": "تسجيل الدخول إلى",
"select_user": "Select a user to log in",
"add_user_to_login": "Add a user to log in",
"add_user": "Add User",
"username_placeholder": "اسم المستخدم",
"password_placeholder": "كلمة المرور",
"login_button": "تسجيل الدخول",
@@ -30,48 +33,54 @@
"connect_button": "اتصل",
"previous_servers": "الخوادم السابقة",
"clear_button": "مسح",
"swipe_to_remove": "Swipe to remove",
"swipe_to_remove": "مرر للإزالة",
"search_for_local_servers": "البحث عن الخوادم المحلية",
"searching": "جاري البحث...",
"servers": "الخوادم",
"saved": "Saved",
"session_expired": "Session Expired",
"please_login_again": "Your saved session has expired. Please log in again.",
"remove_saved_login": "Remove Saved Login",
"remove_saved_login_description": "This will remove your saved credentials for this server. You'll need to enter your username and password again next time.",
"accounts_count": "{{count}} accounts",
"select_account": "Select Account",
"add_account": "Add Account",
"remove_account_description": "This will remove the saved credentials for {{username}}."
"saved": "تم الحفظ",
"session_expired": "انتهت الجلسة",
"please_login_again": "انتهت مدة صلاحية جلستك. الرجاء تسجيل الدخول مرة أخرى.",
"remove_saved_login": "إزالة تسجيل دخول محفوظ",
"remove_saved_login_description": "سيؤدي هذا إلى إزالة بيانات تسجيل الدخول الخاص بك المحفوظة لهذا الخادم. ستحتاج إلى إدخال اسم المستخدم وكلمة المرور مرة أخرى في المرة القادمة.",
"accounts_count": "الحسابات {{count}}",
"select_account": "اختر الحساب",
"add_account": "إضافة حساب",
"remove_account_description": "سيؤدي هذا إلى إزالة بيانات تسجيل الدخول لـ {{username}}.",
"remove_server": "Remove Server",
"remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
"select_your_server": "Select Your Server",
"add_server_to_get_started": "Add a server to get started",
"add_server": "Add Server",
"change_server": "Change Server"
},
"save_account": {
"title": "Save Account",
"save_for_later": "Save this account",
"security_option": "Security Option",
"no_protection": "No protection",
"no_protection_desc": "Quick login without authentication",
"pin_code": "PIN code",
"pin_code_desc": "4-digit PIN required when switching",
"password": "Re-enter password",
"password_desc": "Password required when switching",
"save_button": "Save",
"cancel_button": "Cancel"
"title": "حفظ الحساب",
"save_for_later": "حفظ هذا الحساب",
"security_option": "‮خيارات الأمان",
"no_protection": "بدون حماية",
"no_protection_desc": "تسجيل دخول سريع بدون مصادقة",
"pin_code": "رمز PIN",
"pin_code_desc": "رمز PIN مكون من 4 أرقام مطلوب عند التبديل",
"password": "أعد إدخال كلمة المرور",
"password_desc": "كلمة المرور مطلوبة عند التبديل",
"save_button": "حفظ",
"cancel_button": "إلغاء"
},
"pin": {
"enter_pin": "Enter PIN",
"enter_pin_for": "Enter PIN for {{username}}",
"enter_4_digits": "Enter 4 digits",
"invalid_pin": "Invalid PIN",
"setup_pin": "Set Up PIN",
"confirm_pin": "Confirm PIN",
"pins_dont_match": "PINs don't match",
"forgot_pin": "Forgot PIN?",
"forgot_pin_desc": "Your saved credentials will be removed"
"enter_pin": "‏أدخل رمز PIN",
"enter_pin_for": "أدخل رمز PIN لـ {{username}}",
"enter_4_digits": "ادخل 4 أرقام",
"invalid_pin": "PIN غير صالح",
"setup_pin": "تعيين رمز PIN",
"confirm_pin": "تأكيد رمز PIN",
"pins_dont_match": "رموز PIN غير متطابقة",
"forgot_pin": "نسيت رمز PIN؟",
"forgot_pin_desc": "سيتم إزالة بيانات تسجيل الدخول المحفوظة الخاصة بك"
},
"password": {
"enter_password": "Enter Password",
"enter_password_for": "Enter password for {{username}}",
"invalid_password": "Invalid password"
"enter_password": "أدخل كلمة المرور",
"enter_password_for": "أدخل كلمة المرور لـ {{username}}",
"invalid_password": "كلمة المرور غير صحيحة"
},
"home": {
"checking_server_connection": "التحقق من اتصال الخادم...",
@@ -86,8 +95,9 @@
"oops": "عفوًا!",
"error_message": "حدث خطأ ما.\nيرجى تسجيل الخروج ثم الدخول مرة أخرى.",
"continue_watching": "متابعة المشاهدة",
"continue": "Continue",
"next_up": "التالي",
"continue_and_next_up": "Continue & Next Up",
"continue_and_next_up": "تابع و التالي",
"recently_added_in": "أضيف مؤخراً في {{libraryName}}",
"suggested_movies": "أفلام مقترحة",
"suggested_episodes": "حلقات مقترحة",
@@ -109,6 +119,12 @@
"settings": {
"settings_title": "الإعدادات",
"log_out_button": "تسجيل الخروج",
"switch_user": {
"title": "Switch User",
"account": "Account",
"switch_user": "Switch User on This Server",
"current": "current"
},
"categories": {
"title": "الأقسام"
},
@@ -120,36 +136,45 @@
},
"appearance": {
"title": "المظهر",
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
"hide_remote_session_button": "Hide Remote Session Button"
"merge_next_up_continue_watching": "دمج تابع المشاهدة والتالي",
"hide_remote_session_button": "إخفاء زر جلسة البث عن بُعد",
"show_home_backdrop": "Dynamic Home Backdrop",
"show_hero_carousel": "Hero Carousel",
"show_series_poster_on_episode": "Show Series Poster on Episodes",
"theme_music": "Theme Music",
"display_size": "Display Size",
"display_size_small": "Small",
"display_size_default": "Default",
"display_size_large": "Large",
"display_size_extra_large": "Extra Large"
},
"network": {
"title": "Network",
"local_network": "Local Network",
"auto_switch_enabled": "Auto-switch when at home",
"auto_switch_description": "Automatically switch to local URL when connected to home WiFi",
"local_url": "Local URL",
"local_url_hint": "Enter your local server address (e.g., http://192.168.1.100:8096)",
"title": "الشبكة",
"local_network": "الشبكة المحلية",
"auto_switch_enabled": "التبديل التلقائي عند المنزل",
"auto_switch_description": "التبديل تلقائياً إلى رابط URL محلي عند الاتصال بشبكة WiFi المنزلية",
"local_url": "رابط محلي",
"local_url_hint": "أدخل عنوان الخادم المحلي الخاص بك (على سبيل المثال http://192.168.1.100:8096)",
"local_url_placeholder": "http://192.168.1.100:8096",
"home_wifi_networks": "Home WiFi Networks",
"add_current_network": "Add \"{{ssid}}\"",
"not_connected_to_wifi": "Not connected to WiFi",
"no_networks_configured": "No networks configured",
"add_network_hint": "Add your home WiFi network to enable auto-switching",
"current_wifi": "Current WiFi",
"using_url": "Using",
"local": "Local URL",
"remote": "Remote URL",
"not_connected": "Not connected",
"current_server": "Current Server",
"remote_url": "Remote URL",
"active_url": "Active URL",
"not_configured": "Not configured",
"network_added": "Network added",
"network_already_added": "Network already added",
"no_wifi_connected": "Not connected to WiFi",
"permission_denied": "Location permission denied",
"permission_denied_explanation": "Location permission is required to detect WiFi network for auto-switching. Please enable it in Settings."
"home_wifi_networks": "شبكات WiFi المنزل",
"add_current_network": "إضافة \"{{ssid}}\"",
"not_connected_to_wifi": "غير متصل بشبكة WiFi",
"no_networks_configured": "لا توجد شبكات مكونة",
"add_network_hint": "إضافة شبكة WiFi المنزلية الخاصة بك لتمكين التبديل التلقائي",
"current_wifi": "شبكة WiFi الحالية",
"using_url": "استخدام",
"local": "رابط محلي",
"remote": "الـ URL الخارجي",
"not_connected": "غير متصل",
"current_server": "الخادم الحالي",
"remote_url": "الـ URL الخارجي",
"active_url": "الرابط النشط",
"not_configured": "لم يتم تكوينه",
"network_added": "تمت إضافة الشبكة",
"network_already_added": "الشبكة مضافة مسبقاً",
"no_wifi_connected": "غير متصل بشبكة WiFi",
"permission_denied": "تم رفض إذن الوصول إلى الموقع",
"permission_denied_explanation": "يتطلب التعرف على شبكة WiFi للتبديل التلقائي الحصول على إذن الوصول إلى الموقع. يرجى تفعيله من الإعدادات."
},
"user_info": {
"user_info_title": "معلومات المستخدم",
@@ -174,6 +199,22 @@
"rewind_length": "مدة الترجيع",
"seconds_unit": "ث"
},
"buffer": {
"title": "Buffer Settings",
"cache_mode": "Cache Mode",
"cache_auto": "Auto",
"cache_yes": "Enabled",
"cache_no": "Disabled",
"buffer_duration": "Buffer Duration",
"max_cache_size": "Max Cache Size",
"max_backward_cache": "Max Backward Cache"
},
"vo_driver": {
"title": "Video Output",
"vo_mode": "VO Driver",
"gpu_next": "gpu-next (Recommended)",
"gpu": "gpu"
},
"gesture_controls": {
"gesture_controls_title": "التحكم بالإيماءات",
"horizontal_swipe_skip": "السحب الأفقي للتخطي",
@@ -182,10 +223,10 @@
"left_side_brightness_description": "اسحب لأعلى/لأسفل على الجانب الأيسر لضبط السطوع",
"right_side_volume": "التحكم في مستوى الصوت من الجانب الأيمن",
"right_side_volume_description": "اسحب لأعلى/لأسفل على الجانب الأيمن لضبط مستوى الصوت",
"hide_volume_slider": "Hide Volume Slider",
"hide_volume_slider_description": "Hide the volume slider in the video player",
"hide_brightness_slider": "Hide Brightness Slider",
"hide_brightness_slider_description": "Hide the brightness slider in the video player"
"hide_volume_slider": "إخفاء شريط مستوى الصوت",
"hide_volume_slider_description": "إخفاء شريط التحكم في مستوى الصوت في مشغل الفيديو",
"hide_brightness_slider": "إخفاء شريط السطوع",
"hide_brightness_slider_description": "إخفاء شريط التحكم في السطوع في مشغل الفيديو"
},
"audio": {
"audio_title": "الصوت",
@@ -195,12 +236,12 @@
"none": "لا شيء",
"language": "اللغة",
"transcode_mode": {
"title": "Audio Transcoding",
"description": "Controls how surround audio (7.1, TrueHD, DTS-HD) is handled",
"auto": "Auto",
"stereo": "Force Stereo",
"5_1": "Allow 5.1",
"passthrough": "Passthrough"
"title": "تحويل ترميز الصوت",
"description": "يتحكم في كيفية التعامل مع الصوت المحيطي (7.1، TrueHD، DTS-HD)",
"auto": "تلقائي",
"stereo": "إجبار تشغيل ستيريو",
"5_1": "السماح بـ 5.1",
"passthrough": "تمرير الصوت"
}
},
"subtitles": {
@@ -251,29 +292,45 @@
"Normal": "عادي",
"Thick": "سميك"
},
"subtitle_color": "Subtitle Color",
"subtitle_background_color": "Background Color",
"subtitle_font": "Subtitle Font",
"ksplayer_title": "KSPlayer Settings",
"hardware_decode": "Hardware Decoding",
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues."
"subtitle_color": "لون الترجمة",
"subtitle_background_color": "لون الخلفية",
"subtitle_font": "خط الترجمة",
"ksplayer_title": "إعدادات KSPlayer",
"hardware_decode": "فك الترميز بواسطة الجهاز",
"hardware_decode_description": "استخدم تسريع العتاد لفك ترميز الفيديو. قم بتعطيله إذا واجهت مشكلات في التشغيل.",
"opensubtitles_title": "OpenSubtitles",
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
"opensubtitles_api_key": "API Key",
"opensubtitles_api_key_placeholder": "Enter API key...",
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
"mpv_subtitle_scale": "Subtitle Scale",
"mpv_subtitle_margin_y": "Vertical Margin",
"mpv_subtitle_align_x": "Horizontal Align",
"mpv_subtitle_align_y": "Vertical Align",
"align": {
"left": "Left",
"center": "Center",
"right": "Right",
"top": "Top",
"bottom": "Bottom"
}
},
"vlc_subtitles": {
"title": "VLC Subtitle Settings",
"hint": "Customize subtitle appearance for VLC player. Changes take effect on next playback.",
"text_color": "Text Color",
"background_color": "Background Color",
"background_opacity": "Background Opacity",
"outline_color": "Outline Color",
"outline_opacity": "Outline Opacity",
"outline_thickness": "Outline Thickness",
"bold": "Bold Text",
"margin": "Bottom Margin"
"title": "إعدادات ترجمة VLC",
"hint": "تخصيص مظهر الترجمة لمشغل VLC. تصبح التغييرات سارية المفعول عند التشغيل التالي.",
"text_color": "لون النص",
"background_color": "لون الخلفية",
"background_opacity": "شفافية الخلفية",
"outline_color": "لون إطار الخط",
"outline_opacity": "شفافية إطار الخط",
"outline_thickness": "سمك إطار الخط",
"bold": "خط عريض",
"margin": "الهامش السفلي"
},
"video_player": {
"title": "Video Player",
"video_player": "Video Player",
"video_player_description": "Choose which video player to use on iOS.",
"title": "مشغل الفيديو",
"video_player": "مشغل الفيديو",
"video_player_description": "اختر مشغل الفيديو الذي سيتم استخدامه على نظام iOS.",
"ksplayer": "KSPlayer",
"vlc": "VLC"
},
@@ -305,8 +362,8 @@
"select_liraries_you_want_to_hide": "اختر المكتبات التي تريد إخفاءها من تبويب المكتبة وأقسام الصفحة الرئيسية.",
"disable_haptic_feedback": "تعطيل ردود الفعل اللمسية",
"default_quality": "الجودة الافتراضية",
"default_playback_speed": "Default Playback Speed",
"auto_play_next_episode": "Auto-play Next Episode",
"default_playback_speed": "سرعة التشغيل الافتراضية",
"auto_play_next_episode": "تشغيل الحلقة التالية تلقائياً",
"max_auto_play_episode_count": "الحد الأقصى لعدد الحلقات التي يتم تشغيلها تلقائيًا",
"disabled": "معطل"
},
@@ -314,15 +371,15 @@
"downloads_title": "التنزيلات"
},
"music": {
"title": "Music",
"playback_title": "Playback",
"playback_description": "Configure how music is played.",
"prefer_downloaded": "Prefer Downloaded Songs",
"caching_title": "Caching",
"caching_description": "Automatically cache upcoming tracks for smoother playback.",
"lookahead_enabled": "Enable Look-Ahead Caching",
"lookahead_count": "Tracks to Pre-cache",
"max_cache_size": "Max Cache Size"
"title": "الموسيقى",
"playback_title": "التشغيل",
"playback_description": "ضبط كيفية تشغيل الموسيقى.",
"prefer_downloaded": "تفضيل الأغاني التي تم تنزيلها",
"caching_title": "التخزين المؤقت",
"caching_description": "تخزين الأغاني التالية مؤقتاً تلقائياً لضمان تشغيل أكثر سلاسة.",
"lookahead_enabled": "تفعيل التخزين المؤقت الاستباقي",
"lookahead_count": "عدد الأغاني المراد تخزينها مسبقاً",
"max_cache_size": "الحد الأقصى لحجم التخزين المؤقت"
},
"plugins": {
"plugins_title": "الإضافات",
@@ -357,39 +414,39 @@
"save_button": "حفظ",
"toasts": {
"saved": "تم الحفظ",
"refreshed": "Settings refreshed from server"
"refreshed": "تم تحديث الإعدادات من الخادم"
},
"refresh_from_server": "Refresh Settings from Server"
"refresh_from_server": "تحديث الإعدادات من الخادم"
},
"streamystats": {
"enable_streamystats": "Enable Streamystats",
"disable_streamystats": "Disable Streamystats",
"enable_search": "Use for Search",
"url": "URL",
"enable_streamystats": "تفعيل Streamystats",
"disable_streamystats": "تعطيل Streamystats",
"enable_search": "استخدم للبحث",
"url": "الرابط",
"server_url_placeholder": "http(s)://streamystats.example.com",
"streamystats_search_hint": "Enter the URL for your Streamystats server. The URL should include http or https and optionally the port.",
"read_more_about_streamystats": "Read More About Streamystats.",
"save_button": "Save",
"save": "Save",
"features_title": "Features",
"home_sections_title": "Home Sections",
"enable_movie_recommendations": "Movie Recommendations",
"enable_series_recommendations": "Series Recommendations",
"enable_promoted_watchlists": "Promoted Watchlists",
"hide_watchlists_tab": "Hide Watchlists Tab",
"home_sections_hint": "Show personalized recommendations and promoted watchlists from Streamystats on the home page.",
"recommended_movies": "Recommended Movies",
"recommended_series": "Recommended Series",
"streamystats_search_hint": "أدخل رابط خادم Streamystats الخاص بك. يجب أن يتضمن الرابط البروتوكول http أو https مع رقم المنفذ اختيارياً.",
"read_more_about_streamystats": "اقرأ المزيد عن Streamystats.",
"save_button": "حفظ",
"save": "حفظ",
"features_title": "المميزات",
"home_sections_title": "أقسام الرئيسية",
"enable_movie_recommendations": "توصيات الأفلام",
"enable_series_recommendations": "توصيات المسلسلات",
"enable_promoted_watchlists": "قوائم مشاهدة مختارة",
"hide_watchlists_tab": "إخفاء تبويب قوائم المشاهدة",
"home_sections_hint": "إظهار التوصيات المخصصة وقوائم المشاهدة المختارة من Streamystats في الصفحة الرئيسية.",
"recommended_movies": "أفلام موصى بها",
"recommended_series": "مسلسلات موصى بها",
"toasts": {
"saved": "Saved",
"refreshed": "Settings refreshed from server",
"disabled": "Streamystats disabled"
"saved": "تم الحفظ",
"refreshed": "تم تحديث الإعدادات من الخادم",
"disabled": "تم تعطيل Streamystats"
},
"refresh_from_server": "Refresh Settings from Server"
"refresh_from_server": "تحديث الإعدادات من الخادم"
},
"kefinTweaks": {
"watchlist_enabler": "Enable our Watchlist integration",
"watchlist_button": "Toggle Watchlist integration"
"watchlist_enabler": "تفعيل الربط مع قائمة المشاهدة الخاصة بنا",
"watchlist_button": "تبديل حالة ربط قائمة المشاهدة"
}
},
"storage": {
@@ -398,15 +455,21 @@
"device_usage": "الجهاز {{availableSpace}}%",
"size_used": "تم استخدام {{used}} من {{total}}",
"delete_all_downloaded_files": "حذف جميع الملفات التي تم تنزيلها",
"music_cache_title": "Music Cache",
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
"enable_music_cache": "Enable Music Cache",
"clear_music_cache": "Clear Music Cache",
"music_cache_size": "{{size}} cached",
"music_cache_cleared": "Music cache cleared",
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
"downloaded_songs_size": "{{size}} downloaded",
"downloaded_songs_deleted": "Downloaded songs deleted"
"music_cache_title": "التخزين المؤقت للموسيقى",
"music_cache_description": "تخزين الأغاني تلقائياً أثناء الاستماع لضمان تشغيل أكثر سلاسة ودعم الاستماع بدون اتصال",
"enable_music_cache": "تمكين التخزين المؤقت للموسيقى",
"clear_music_cache": "مسح التخزين المؤقت للموسيقى",
"music_cache_size": "تم تخزين {{size}} مؤقتاً",
"music_cache_cleared": "تم مسح التخزين المؤقت للموسيقى",
"delete_all_downloaded_songs": "حذف جميع الأغاني التي تم تنزيلها",
"downloaded_songs_size": "تم تنزيل {{size}}",
"downloaded_songs_deleted": "تم حذف الأغاني التي تم تنزيلها",
"clear_all_cache": "Clear All Cache",
"clear_all_cache_confirm": "Clear All Cache?",
"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.",
"clear_all_cache_success": "Cache Cleared",
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
},
"intro": {
"title": "المقدمة",
@@ -430,6 +493,21 @@
"error_deleting_files": "خطأ في حذف الملفات",
"background_downloads_enabled": "تم تفعيل التنزيلات في الخلفية",
"background_downloads_disabled": "تم تعطيل التنزيلات في الخلفية"
},
"security": {
"title": "Security",
"inactivity_timeout": {
"title": "Inactivity Timeout",
"description": "Auto logout after inactivity",
"disabled": "Disabled",
"1_minute": "1 minute",
"5_minutes": "5 minutes",
"15_minutes": "15 minutes",
"30_minutes": "30 minutes",
"1_hour": "1 hour",
"4_hours": "4 hours",
"24_hours": "24 hours"
}
}
},
"sessions": {
@@ -456,6 +534,7 @@
"new_app_version_requires_re_download_description": "يتطلب التحديث الجديد تنزيل المحتوى مرة أخرى. يرجى إزالة كل المحتوى الذي تم تنزيله والمحاولة مرة أخرى.",
"back": "رجوع",
"delete": "حذف",
"delete_download": "Delete Download",
"something_went_wrong": "حدث خطأ ما",
"could_not_get_stream_url_from_jellyfin": "تعذر الحصول على رابط البث من Jellyfin",
"eta": "الوقت المتبقي {{eta}}",
@@ -492,22 +571,28 @@
}
},
"common": {
"no_results": "No Results",
"select": "اختر",
"no_trailer_available": "لا يوجد مقطع دعائي متوفر",
"video": "فيديو",
"audio": "الصوت",
"subtitle": "الترجمة",
"play": "تشغيل",
"mark_as_played": "Mark as Played",
"mark_as_not_played": "Mark as not Played",
"none": "لا شيء",
"track": "Track",
"cancel": "Cancel",
"delete": "Delete",
"ok": "OK",
"remove": "Remove",
"next": "Next",
"back": "Back",
"continue": "Continue",
"verifying": "Verifying..."
"track": "أغنية",
"cancel": "إلغاء",
"stop": "Stop",
"delete": "حذف",
"ok": "حسناً",
"remove": "إزالة",
"next": "التالي",
"back": "رجوع",
"continue": "متابعة",
"verifying": "جارٍ التحقق...",
"login": "Login",
"refresh": "Refresh"
},
"search": {
"search": "بحث...",
@@ -521,10 +606,10 @@
"episodes": "حلقات",
"collections": "مجموعات",
"actors": "ممثلون",
"artists": "Artists",
"albums": "Albums",
"songs": "Songs",
"playlists": "Playlists",
"artists": "الفنانون",
"albums": "الألبومات",
"songs": "الأغاني",
"playlists": "قوائم التشغيل",
"request_movies": "طلب أفلام",
"request_series": "طلب مسلسلات",
"recently_added": "أضيف مؤخرًا",
@@ -556,6 +641,7 @@
"movies": "أفلام",
"series": "مسلسلات",
"boxsets": "مجموعات",
"playlists": "Playlists",
"items": "عناصر"
},
"options": {
@@ -566,15 +652,20 @@
"poster": "ملصق",
"cover": "غلاف",
"show_titles": "إظهار العناوين",
"show_stats": "إظهار الإحصائيات"
"show_stats": "إظهار الإحصائيات",
"options_title": "Options"
},
"filters": {
"genres": "الأنواع",
"years": "السنوات",
"sort_by": "ترتيب حسب",
"filter_by": "Filter By",
"filter_by": "تصفية حسب",
"sort_order": "اتجاه الترتيب",
"tags": "الوسوم"
"tags": "الوسوم",
"all": "All",
"reset": "Reset",
"asc": "Ascending",
"desc": "Descending"
}
},
"favorites": {
@@ -591,6 +682,8 @@
"no_links": "لا توجد روابط"
},
"player": {
"live": "LIVE",
"mpv_player_title": "MPV Player",
"error": "خطأ",
"failed_to_get_stream_url": "فشل في الحصول على رابط البث",
"an_error_occured_while_playing_the_video": "حدث خطأ أثناء تشغيل الفيديو. تحقق من السجلات في الإعدادات.",
@@ -604,11 +697,39 @@
"index": "الفِهْرِس:",
"continue_watching": "متابعة المشاهدة",
"go_back": "رجوع",
"downloaded_file_title": "You have this file downloaded",
"downloaded_file_title": "تم تنزيل هذا الملف",
"downloaded_file_message": "هل تريد تشغيل الملف الذي تم تنزيله؟",
"downloaded_file_yes": "نعم",
"downloaded_file_no": "لا",
"downloaded_file_cancel": "Cancel"
"downloaded_file_cancel": "إلغاء",
"swipe_down_settings": "Swipe down for settings",
"ends_at": "Ends at {{time}}",
"search_subtitles": "Search Subtitles",
"subtitle_tracks": "Tracks",
"subtitle_search": "Search & Download",
"download": "Download",
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
"using_jellyfin_server": "Using Jellyfin Server",
"language": "Language",
"results": "Results",
"searching": "Searching...",
"search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found",
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
"settings": "Settings",
"skip_intro": "Skip Intro",
"skip_credits": "Skip Credits",
"stopPlayback": "Stop Playback",
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
"stopPlayingConfirm": "Are you sure you want to stop playback?",
"downloaded": "Downloaded"
},
"chapters": {
"title": "Chapters",
"chapter_number": "Chapter {{number}}",
"open": "Open chapters",
"close": "Close chapters"
},
"item_card": {
"next_up": "التالي",
@@ -617,6 +738,11 @@
"series": "مسلسلات",
"seasons": "مواسم",
"season": "موسم",
"from_this_series": "From This Series",
"more_from_this_season": "More from this Season",
"view_series": "View Series",
"view_season": "View Season",
"select_season": "Select Season",
"no_episodes_for_this_season": "لا توجد حلقات لهذا الموسم",
"overview": "نظرة عامة",
"more_with": "المزيد مع {{name}}",
@@ -624,13 +750,24 @@
"no_similar_items_found": "لم يتم العثور على عناصر مشابهة",
"video": "فيديو",
"more_details": "المزيد من التفاصيل",
"media_options": "Media Options",
"media_options": "خيارات الوسائط",
"quality": "الجودة",
"audio": "الصوت",
"subtitles": "الترجمة",
"subtitles": {
"label": "Subtitle",
"none": "None",
"tracks": "Tracks"
},
"show_more": "عرض المزيد",
"show_less": "عرض أقل",
"left": "left",
"more_info": "More Info",
"director": "Director",
"cast": "Cast",
"technical_details": "Technical Details",
"appeared_in": "ظهر في",
"movies": "Movies",
"shows": "Shows",
"could_not_load_item": "تعذر تحميل العنصر",
"none": "لا شيء",
"download": {
@@ -641,7 +778,13 @@
"download_x_item": "تنزيل {{item_count}} عناصر",
"download_unwatched_only": "غير المشاهدة فقط",
"download_button": "تنزيل"
}
},
"mark_played": "Mark as Watched",
"mark_unplayed": "Mark as Unwatched",
"resume_playback": "Resume Playback",
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
"play_from_start": "Play from Start",
"continue_from": "Continue from {{time}}"
},
"live_tv": {
"next": "التالي",
@@ -652,7 +795,18 @@
"movies": "أفلام",
"sports": "رياضة",
"for_kids": "للأطفال",
"news": "أخبار"
"news": "أخبار",
"page_of": "Page {{current}} of {{total}}",
"no_programs": "No programs available",
"no_channels": "No channels available",
"tabs": {
"programs": "Programs",
"guide": "Guide",
"channels": "Channels",
"recordings": "Recordings",
"schedule": "Schedule",
"series": "Series"
}
},
"jellyseerr": {
"confirm": "تأكيد",
@@ -697,6 +851,12 @@
"decline": "رفض",
"requested_by": "مطلوب من {{user}}",
"unknown_user": "مستخدم غير معروف",
"select": "Select",
"request_all": "Request All",
"request_seasons": "Request Seasons",
"select_seasons": "Select Seasons",
"request_selected": "Request Selected",
"n_selected": "{{count}} selected",
"toasts": {
"jellyseer_does_not_meet_requirements": "خادم Seerr لا يستوفي الحد الأدنى للإصدار المطلوب! يرجى التحديث إلى إصدار 2.0.0 على الأقل",
"jellyseerr_test_failed": "فشل اختبار Seerr. يرجى المحاولة مرة أخرى.",
@@ -716,130 +876,162 @@
"search": "بحث",
"library": "المكتبة",
"custom_links": "روابط مخصصة",
"favorites": "المفضلة"
"favorites": "المفضلة",
"settings": "Settings"
},
"music": {
"title": "Music",
"title": "الموسيقى",
"tabs": {
"suggestions": "Suggestions",
"albums": "Albums",
"artists": "Artists",
"playlists": "Playlists",
"tracks": "tracks"
"suggestions": "الإقتراحات",
"albums": "الألبومات",
"artists": "الفنانون",
"playlists": "قوائم التشغيل",
"tracks": "الأغاني"
},
"filters": {
"all": "All"
"all": "الكل"
},
"recently_added": "Recently Added",
"recently_played": "Recently Played",
"frequently_played": "Frequently Played",
"explore": "Explore",
"top_tracks": "Top Tracks",
"play": "Play",
"shuffle": "Shuffle",
"play_top_tracks": "Play Top Tracks",
"no_suggestions": "No suggestions available",
"no_albums": "No albums found",
"no_artists": "No artists found",
"no_playlists": "No playlists found",
"album_not_found": "Album not found",
"artist_not_found": "Artist not found",
"playlist_not_found": "Playlist not found",
"recently_added": "أضيف مؤخرًا",
"recently_played": "تم تشغيله مؤخرًا",
"frequently_played": "الأكثر تشغيلاً",
"explore": "اكتشف",
"top_tracks": "أفضل الأغاني",
"play": "تشغيل",
"shuffle": "ترتيب عشوائي",
"play_top_tracks": "تشغيل أفضل الأغاني",
"no_suggestions": "لا توجد مقترحات متاحة",
"no_albums": "لا توجد ألبومات",
"no_artists": "لا يوجد فنانون",
"no_playlists": "لا توجد قوائم تشغيل",
"album_not_found": "الألبوم غير موجود",
"artist_not_found": "الفنان غير موجود",
"playlist_not_found": "قائمة التشغيل غير موجودة",
"track_options": {
"play_next": "Play Next",
"add_to_queue": "Add to Queue",
"add_to_playlist": "Add to Playlist",
"download": "Download",
"downloaded": "Downloaded",
"downloading": "Downloading...",
"cached": "Cached",
"delete_download": "Delete Download",
"delete_cache": "Remove from Cache",
"go_to_artist": "Go to Artist",
"go_to_album": "Go to Album",
"add_to_favorites": "Add to Favorites",
"remove_from_favorites": "Remove from Favorites",
"remove_from_playlist": "Remove from Playlist"
"play_next": "تشغيل التالي",
"add_to_queue": "إضافة إلى قائمة الانتظار",
"add_to_playlist": "أضف إلى قائمة التشغيل",
"download": "تنزيل",
"downloaded": "تم التنزيل",
"downloading": "جارٍ التنزيل...",
"cached": "تم التخزين مؤقتاً",
"delete_download": "حذف ملف التنزيل",
"delete_cache": "إزالة من التخزين المؤقت",
"go_to_artist": "انتقال إلى الفنان",
"go_to_album": "انتقال إلى الألبوم",
"add_to_favorites": "إضافة إلى المفضلة",
"remove_from_favorites": "إزالة من المفضلة",
"remove_from_playlist": "إزالة من قائمة التشغيل"
},
"playlists": {
"create_playlist": "Create Playlist",
"playlist_name": "Playlist Name",
"enter_name": "Enter playlist name",
"create": "Create",
"search_playlists": "Search playlists...",
"added_to": "Added to {{name}}",
"added": "Added to playlist",
"removed_from": "Removed from {{name}}",
"removed": "Removed from playlist",
"created": "Playlist created",
"create_new": "Create New Playlist",
"failed_to_add": "Failed to add to playlist",
"failed_to_remove": "Failed to remove from playlist",
"failed_to_create": "Failed to create playlist",
"delete_playlist": "Delete Playlist",
"delete_confirm": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
"deleted": "Playlist deleted",
"failed_to_delete": "Failed to delete playlist"
"create_playlist": "إنشاء قائمة التشغيل",
"playlist_name": "اسم قائمة التشغيل",
"enter_name": "أدخل اسم قائمة التشغيل",
"create": "إنشاء",
"search_playlists": "البحث عن قوائم التشغيل...",
"added_to": "تمت الإضافة إلى {{name}}",
"added": "تمت الإضافة إلى قائمة التشغيل",
"removed_from": "تمت الإزالة من {{name}}",
"removed": "تمت الازالة من قائمة التشغيل",
"created": "تم إنشاء قائمة التشغيل",
"create_new": "إنشاء قائمة تشغيل جديدة",
"failed_to_add": "فشلت الإضافة إلى قائمة التشغيل",
"failed_to_remove": "فشلت الإزالة من قائمة التشغيل",
"failed_to_create": "فشل إنشاء قائمة التشغيل",
"delete_playlist": "حذف قائمة التشغيل",
"delete_confirm": "هل أنت متأكد من رغبتك في حذف {{name}}؟ لا يمكن التراجع عن هذا الإجراء.",
"deleted": "تم حذف قائمة التشغيل",
"failed_to_delete": "فشل إنشاء قائمة التشغيل"
},
"sort": {
"title": "Sort By",
"alphabetical": "Alphabetical",
"date_created": "Date Created"
"title": "ترتيب حسب",
"alphabetical": "أبجدي",
"date_created": "تاريخ الإنشاء"
}
},
"watchlists": {
"title": "Watchlists",
"my_watchlists": "My Watchlists",
"public_watchlists": "Public Watchlists",
"create_title": "Create Watchlist",
"edit_title": "Edit Watchlist",
"create_button": "Create Watchlist",
"save_button": "Save Changes",
"delete_button": "Delete",
"remove_button": "Remove",
"cancel_button": "Cancel",
"name_label": "Name",
"name_placeholder": "Enter watchlist name",
"description_label": "Description",
"description_placeholder": "Enter description (optional)",
"is_public_label": "Public Watchlist",
"is_public_description": "Allow others to view this watchlist",
"allowed_type_label": "Content Type",
"sort_order_label": "Default Sort Order",
"empty_title": "No Watchlists",
"empty_description": "Create your first watchlist to start organizing your media",
"empty_watchlist": "This watchlist is empty",
"empty_watchlist_hint": "Add items from your library to this watchlist",
"not_configured_title": "Streamystats Not Configured",
"not_configured_description": "Configure Streamystats in settings to use watchlists",
"go_to_settings": "Go to Settings",
"add_to_watchlist": "Add to Watchlist",
"remove_from_watchlist": "Remove from Watchlist",
"select_watchlist": "Select Watchlist",
"create_new": "Create New Watchlist",
"item": "item",
"items": "items",
"public": "Public",
"private": "Private",
"you": "You",
"by_owner": "By another user",
"not_found": "Watchlist not found",
"delete_confirm_title": "Delete Watchlist",
"delete_confirm_message": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
"remove_item_title": "Remove from Watchlist",
"remove_item_message": "Remove \"{{name}}\" from this watchlist?",
"loading": "Loading watchlists...",
"no_compatible_watchlists": "No compatible watchlists",
"create_one_first": "Create a watchlist that accepts this content type"
"title": "قوائم المشاهدة",
"my_watchlists": "قوائم المشاهدة الخاصة بي",
"public_watchlists": "قوائم مشاهدة عامة",
"create_title": "إنشاء قائمة مشاهدة",
"edit_title": "تعديل قائمة المشاهدة",
"create_button": "إنشاء قائمة مشاهدة",
"save_button": "حفظ التغييرات",
"delete_button": "حذف",
"remove_button": "إزالة",
"cancel_button": "إلغاء",
"name_label": "الاسم",
"name_placeholder": "أدخل اسم قائمة المشاهدة",
"description_label": "الوصف",
"description_placeholder": "أدخل الوصف (اختياري)",
"is_public_label": "قائمة مشاهدة عامة",
"is_public_description": "السماح للآخرين بعرض قائمة المشاهدة هذه",
"allowed_type_label": "نوع المحتوى",
"sort_order_label": "الترتيب الافتراضي",
"empty_title": "لا توجد قوائم مشاهدة",
"empty_description": "قم بإنشاء أول قائمة مشاهدة لبدء تنظيم الوسائط الخاصة بك",
"empty_watchlist": "قائمة المشاهدة هذه فارغة",
"empty_watchlist_hint": "إضافة عناصر من مكتبتك إلى قائمة المشاهدة هذه",
"not_configured_title": "لم يتم ضبط Streamystats",
"not_configured_description": "اضبط Streamystats في الإعدادات لاستخدام قوائم المشاهدة",
"go_to_settings": "الذهاب إلى الإعدادات",
"add_to_watchlist": "إضافة إلى قائمة المشاهدة",
"remove_from_watchlist": "إزالة من قائمة المشاهدة",
"select_watchlist": "تحديد قائمة المشاهدة",
"create_new": "إنشاء قائمة مشاهدة جديدة",
"item": "عنصر",
"items": "عناصر",
"public": "عامة",
"private": "خاصة",
"you": "أنت",
"by_owner": "بواسطة مستخدم آخر",
"not_found": "قائمة المشاهدة غير موجودة",
"delete_confirm_title": "حذف قائمة المشاهدة",
"delete_confirm_message": "هل أنت متأكد من رغبتك في حذف \"{{name}}\"؟ لا يمكن التراجع عن هذا الإجراء.",
"remove_item_title": "إزالة من قائمة المشاهدة",
"remove_item_message": "إزالة \"{{name}}\" من قائمة المشاهدة هذه؟",
"loading": "تحميل قوائم المشاهدة...",
"no_compatible_watchlists": "لا توجد قوائم مشاهدة متوافقة",
"create_one_first": "إنشاء قائمة مشاهدة تقبل نوع المحتوى هذا"
},
"playback_speed": {
"title": "Playback Speed",
"apply_to": "Apply To",
"speed": "Speed",
"title": "سرعة التشغيل",
"apply_to": "تطبيق على",
"speed": "السرعة",
"scope": {
"media": "This media only",
"show": "This show",
"all": "All media (default)"
"media": "الوسائط هذه فقط",
"show": "هذا المسلسل",
"all": "جميع الوسائط (الافتراضي)"
}
},
"companion_login": {
"title": "Pair with TV",
"align_qr": "Align the QR code within the frame",
"enter_code_manually": "Enter code manually",
"pairing_enter_credentials": "Enter credentials for TV",
"pairing_code_label": "Pairing code",
"server": "Server",
"authorize_button": "Authorize",
"authorizing": "Authorizing...",
"scan_again": "Scan Again",
"done": "Done",
"success_title": "Authorization Sent",
"pairing_tv_connecting": "The TV is connecting to your account",
"error_title": "Authorization Failed",
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
"error_generic": "Something went wrong. Please try again.",
"error_permission_denied": "Camera permission is required to scan QR codes.",
"login_as": "Log in as {{username}}?",
"on_server": "on {{server}}",
"use_different_user": "Use a different user",
"open_settings": "Open Settings"
},
"pairing": {
"pair_with_phone": "Pair with Phone",
"pair_with_phone_title": "Login TV",
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
"waiting_for_phone": "Waiting for phone...",
"scan_with_phone": "Scan with the Streamyfin app on your phone",
"logging_in": "Logging in...",
"logging_in_description": "Connecting to your server"
}
}

View File

@@ -4,6 +4,9 @@
"error_title": "Error",
"login_title": "Inicia sessió",
"login_to_title": "Inicia sessió a",
"select_user": "Select a user to log in",
"add_user_to_login": "Add a user to log in",
"add_user": "Add User",
"username_placeholder": "Nom d'usuari",
"password_placeholder": "Contrasenya",
"login_button": "Inicia sessió",
@@ -42,7 +45,13 @@
"accounts_count": "{{count}} accounts",
"select_account": "Select Account",
"add_account": "Add Account",
"remove_account_description": "This will remove the saved credentials for {{username}}."
"remove_account_description": "This will remove the saved credentials for {{username}}.",
"remove_server": "Remove Server",
"remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
"select_your_server": "Select Your Server",
"add_server_to_get_started": "Add a server to get started",
"add_server": "Add Server",
"change_server": "Change Server"
},
"save_account": {
"title": "Save Account",
@@ -86,6 +95,7 @@
"oops": "Oops!",
"error_message": "Alguna cosa ha anat malament.\nTanqueu la sessió i torneu-la a iniciar.",
"continue_watching": "Continua veient",
"continue": "Continue",
"next_up": "A continuació",
"continue_and_next_up": "Continue & Next Up",
"recently_added_in": "Afegit recentment a {{libraryName}}",
@@ -109,6 +119,12 @@
"settings": {
"settings_title": "Configuració",
"log_out_button": "Tanca sessió",
"switch_user": {
"title": "Switch User",
"account": "Account",
"switch_user": "Switch User on This Server",
"current": "current"
},
"categories": {
"title": "Categories"
},
@@ -121,7 +137,16 @@
"appearance": {
"title": "Appearance",
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
"hide_remote_session_button": "Hide Remote Session Button"
"hide_remote_session_button": "Hide Remote Session Button",
"show_home_backdrop": "Dynamic Home Backdrop",
"show_hero_carousel": "Hero Carousel",
"show_series_poster_on_episode": "Show Series Poster on Episodes",
"theme_music": "Theme Music",
"display_size": "Display Size",
"display_size_small": "Small",
"display_size_default": "Default",
"display_size_large": "Large",
"display_size_extra_large": "Extra Large"
},
"network": {
"title": "Network",
@@ -174,6 +199,22 @@
"rewind_length": "Durada del rebobinat",
"seconds_unit": "s"
},
"buffer": {
"title": "Buffer Settings",
"cache_mode": "Cache Mode",
"cache_auto": "Auto",
"cache_yes": "Enabled",
"cache_no": "Disabled",
"buffer_duration": "Buffer Duration",
"max_cache_size": "Max Cache Size",
"max_backward_cache": "Max Backward Cache"
},
"vo_driver": {
"title": "Video Output",
"vo_mode": "VO Driver",
"gpu_next": "gpu-next (Recommended)",
"gpu": "gpu"
},
"gesture_controls": {
"gesture_controls_title": "Gesture Controls",
"horizontal_swipe_skip": "Horizontal Swipe to Skip",
@@ -256,7 +297,23 @@
"subtitle_font": "Subtitle Font",
"ksplayer_title": "KSPlayer Settings",
"hardware_decode": "Hardware Decoding",
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues."
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues.",
"opensubtitles_title": "OpenSubtitles",
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
"opensubtitles_api_key": "API Key",
"opensubtitles_api_key_placeholder": "Enter API key...",
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
"mpv_subtitle_scale": "Subtitle Scale",
"mpv_subtitle_margin_y": "Vertical Margin",
"mpv_subtitle_align_x": "Horizontal Align",
"mpv_subtitle_align_y": "Vertical Align",
"align": {
"left": "Left",
"center": "Center",
"right": "Right",
"top": "Top",
"bottom": "Bottom"
}
},
"vlc_subtitles": {
"title": "VLC Subtitle Settings",
@@ -406,7 +463,13 @@
"music_cache_cleared": "Music cache cleared",
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
"downloaded_songs_size": "{{size}} downloaded",
"downloaded_songs_deleted": "Downloaded songs deleted"
"downloaded_songs_deleted": "Downloaded songs deleted",
"clear_all_cache": "Clear All Cache",
"clear_all_cache_confirm": "Clear All Cache?",
"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.",
"clear_all_cache_success": "Cache Cleared",
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
},
"intro": {
"title": "Intro",
@@ -430,6 +493,21 @@
"error_deleting_files": "Error en suprimir fitxers",
"background_downloads_enabled": "Descàrregues en segon pla activades",
"background_downloads_disabled": "Descàrregues en segon pla desactivades"
},
"security": {
"title": "Security",
"inactivity_timeout": {
"title": "Inactivity Timeout",
"description": "Auto logout after inactivity",
"disabled": "Disabled",
"1_minute": "1 minute",
"5_minutes": "5 minutes",
"15_minutes": "15 minutes",
"30_minutes": "30 minutes",
"1_hour": "1 hour",
"4_hours": "4 hours",
"24_hours": "24 hours"
}
}
},
"sessions": {
@@ -456,6 +534,7 @@
"new_app_version_requires_re_download_description": "L'actualització nova requereix que el contingut es torni a descarregar. Suprimiu tot el contingut descarregat i torneu-ho a provar.",
"back": "Enrere",
"delete": "Suprimeix",
"delete_download": "Delete Download",
"something_went_wrong": "Alguna cosa ha anat malament",
"could_not_get_stream_url_from_jellyfin": "No s'ha pogut obtenir l'URL del flux de Jellyfin",
"eta": "ETA {{eta}}",
@@ -492,22 +571,28 @@
}
},
"common": {
"no_results": "No Results",
"select": "Select",
"no_trailer_available": "No trailer available",
"video": "Vídeo",
"audio": "Àudio",
"subtitle": "Subtítols",
"play": "Play",
"mark_as_played": "Mark as Played",
"mark_as_not_played": "Mark as not Played",
"none": "None",
"track": "Track",
"cancel": "Cancel",
"stop": "Stop",
"delete": "Delete",
"ok": "OK",
"remove": "Remove",
"next": "Next",
"back": "Back",
"continue": "Continue",
"verifying": "Verifying..."
"verifying": "Verifying...",
"login": "Login",
"refresh": "Refresh"
},
"search": {
"search": "Cerca...",
@@ -556,6 +641,7 @@
"movies": "pel·lícules",
"series": "sèries",
"boxsets": "col·leccions",
"playlists": "Playlists",
"items": "elements"
},
"options": {
@@ -566,7 +652,8 @@
"poster": "Cartell",
"cover": "Coberta",
"show_titles": "Mostrar títols",
"show_stats": "Mostrar estadístiques"
"show_stats": "Mostrar estadístiques",
"options_title": "Options"
},
"filters": {
"genres": "Gèneres",
@@ -574,7 +661,11 @@
"sort_by": "Ordenar per",
"filter_by": "Filter By",
"sort_order": "Ordre",
"tags": "Etiquetes"
"tags": "Etiquetes",
"all": "All",
"reset": "Reset",
"asc": "Ascending",
"desc": "Descending"
}
},
"favorites": {
@@ -591,6 +682,8 @@
"no_links": "No hi ha enllaços"
},
"player": {
"live": "LIVE",
"mpv_player_title": "MPV Player",
"error": "Error",
"failed_to_get_stream_url": "No s'ha pogut obtenir l'URL del flux",
"an_error_occured_while_playing_the_video": "S'ha produït un error en reproduir el vídeo. Consulteu els registres a la configuració.",
@@ -608,7 +701,35 @@
"downloaded_file_message": "Do you want to play the downloaded file?",
"downloaded_file_yes": "Yes",
"downloaded_file_no": "No",
"downloaded_file_cancel": "Cancel"
"downloaded_file_cancel": "Cancel",
"swipe_down_settings": "Swipe down for settings",
"ends_at": "Ends at {{time}}",
"search_subtitles": "Search Subtitles",
"subtitle_tracks": "Tracks",
"subtitle_search": "Search & Download",
"download": "Download",
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
"using_jellyfin_server": "Using Jellyfin Server",
"language": "Language",
"results": "Results",
"searching": "Searching...",
"search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found",
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
"settings": "Settings",
"skip_intro": "Skip Intro",
"skip_credits": "Skip Credits",
"stopPlayback": "Stop Playback",
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
"stopPlayingConfirm": "Are you sure you want to stop playback?",
"downloaded": "Downloaded"
},
"chapters": {
"title": "Chapters",
"chapter_number": "Chapter {{number}}",
"open": "Open chapters",
"close": "Close chapters"
},
"item_card": {
"next_up": "A continuació",
@@ -617,6 +738,11 @@
"series": "Sèries",
"seasons": "Temporades",
"season": "Temporada",
"from_this_series": "From This Series",
"more_from_this_season": "More from this Season",
"view_series": "View Series",
"view_season": "View Season",
"select_season": "Select Season",
"no_episodes_for_this_season": "No hi ha episodis per a aquesta temporada",
"overview": "Descripció general",
"more_with": "Més amb {{name}}",
@@ -627,10 +753,21 @@
"media_options": "Media Options",
"quality": "Qualitat",
"audio": "Àudio",
"subtitles": "Subtítols",
"subtitles": {
"label": "Subtitle",
"none": "None",
"tracks": "Tracks"
},
"show_more": "Mostra més",
"show_less": "Mostra menys",
"left": "left",
"more_info": "More Info",
"director": "Director",
"cast": "Cast",
"technical_details": "Technical Details",
"appeared_in": "Va aparèixer a",
"movies": "Movies",
"shows": "Shows",
"could_not_load_item": "No s'ha pogut carregar l'element",
"none": "Cap",
"download": {
@@ -641,7 +778,13 @@
"download_x_item": "Descarrega {{item_count}} elements",
"download_unwatched_only": "Unwatched Only",
"download_button": "Descarrega"
}
},
"mark_played": "Mark as Watched",
"mark_unplayed": "Mark as Unwatched",
"resume_playback": "Resume Playback",
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
"play_from_start": "Play from Start",
"continue_from": "Continue from {{time}}"
},
"live_tv": {
"next": "Següent",
@@ -652,7 +795,18 @@
"movies": "Pel·lícules",
"sports": "Esports",
"for_kids": "Infantil",
"news": "Notícies"
"news": "Notícies",
"page_of": "Page {{current}} of {{total}}",
"no_programs": "No programs available",
"no_channels": "No channels available",
"tabs": {
"programs": "Programs",
"guide": "Guide",
"channels": "Channels",
"recordings": "Recordings",
"schedule": "Schedule",
"series": "Series"
}
},
"jellyseerr": {
"confirm": "Confirma",
@@ -697,6 +851,12 @@
"decline": "Decline",
"requested_by": "Requested by {{user}}",
"unknown_user": "Unknown User",
"select": "Select",
"request_all": "Request All",
"request_seasons": "Request Seasons",
"select_seasons": "Select Seasons",
"request_selected": "Request Selected",
"n_selected": "{{count}} selected",
"toasts": {
"jellyseer_does_not_meet_requirements": "El servidor Jellyseerr no compleix els requisits mínims de versió! Actualitzeu-lo almenys a la versió 2.0.0",
"jellyseerr_test_failed": "Ha fallat la prova de Jellyseerr. Torneu-ho a provar.",
@@ -716,7 +876,8 @@
"search": "Cercar",
"library": "Biblioteca",
"custom_links": "Enllaços personalitzats",
"favorites": "Preferits"
"favorites": "Preferits",
"settings": "Settings"
},
"music": {
"title": "Music",
@@ -841,5 +1002,36 @@
"show": "This show",
"all": "All media (default)"
}
},
"companion_login": {
"title": "Pair with TV",
"align_qr": "Align the QR code within the frame",
"enter_code_manually": "Enter code manually",
"pairing_enter_credentials": "Enter credentials for TV",
"pairing_code_label": "Pairing code",
"server": "Server",
"authorize_button": "Authorize",
"authorizing": "Authorizing...",
"scan_again": "Scan Again",
"done": "Done",
"success_title": "Authorization Sent",
"pairing_tv_connecting": "The TV is connecting to your account",
"error_title": "Authorization Failed",
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
"error_generic": "Something went wrong. Please try again.",
"error_permission_denied": "Camera permission is required to scan QR codes.",
"login_as": "Log in as {{username}}?",
"on_server": "on {{server}}",
"use_different_user": "Use a different user",
"open_settings": "Open Settings"
},
"pairing": {
"pair_with_phone": "Pair with Phone",
"pair_with_phone_title": "Login TV",
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
"waiting_for_phone": "Waiting for phone...",
"scan_with_phone": "Scan with the Streamyfin app on your phone",
"logging_in": "Logging in...",
"logging_in_description": "Connecting to your server"
}
}

View File

@@ -4,6 +4,9 @@
"error_title": "Chyba",
"login_title": "Přihlásit se",
"login_to_title": "Přihlásit se do",
"select_user": "Select a user to log in",
"add_user_to_login": "Add a user to log in",
"add_user": "Add User",
"username_placeholder": "Uživatelské jméno",
"password_placeholder": "Heslo",
"login_button": "Přihlásit se",
@@ -42,7 +45,13 @@
"accounts_count": "{{count}} accounts",
"select_account": "Select Account",
"add_account": "Add Account",
"remove_account_description": "This will remove the saved credentials for {{username}}."
"remove_account_description": "This will remove the saved credentials for {{username}}.",
"remove_server": "Remove Server",
"remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
"select_your_server": "Select Your Server",
"add_server_to_get_started": "Add a server to get started",
"add_server": "Add Server",
"change_server": "Change Server"
},
"save_account": {
"title": "Save Account",
@@ -86,6 +95,7 @@
"oops": "Jejda!",
"error_message": "Něco se pokazilo.\nOdhlaste se a znovu se prosím.",
"continue_watching": "Pokračovat ve sledování",
"continue": "Continue",
"next_up": "Další nahoru",
"continue_and_next_up": "Continue & Next Up",
"recently_added_in": "Nedávno přidané v {{libraryName}}",
@@ -109,6 +119,12 @@
"settings": {
"settings_title": "Nastavení",
"log_out_button": "Odhlásit se",
"switch_user": {
"title": "Switch User",
"account": "Account",
"switch_user": "Switch User on This Server",
"current": "current"
},
"categories": {
"title": "Categories"
},
@@ -121,7 +137,16 @@
"appearance": {
"title": "Appearance",
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
"hide_remote_session_button": "Hide Remote Session Button"
"hide_remote_session_button": "Hide Remote Session Button",
"show_home_backdrop": "Dynamic Home Backdrop",
"show_hero_carousel": "Hero Carousel",
"show_series_poster_on_episode": "Show Series Poster on Episodes",
"theme_music": "Theme Music",
"display_size": "Display Size",
"display_size_small": "Small",
"display_size_default": "Default",
"display_size_large": "Large",
"display_size_extra_large": "Extra Large"
},
"network": {
"title": "Network",
@@ -174,6 +199,22 @@
"rewind_length": "Délka zpětného větru",
"seconds_unit": "s"
},
"buffer": {
"title": "Buffer Settings",
"cache_mode": "Cache Mode",
"cache_auto": "Auto",
"cache_yes": "Enabled",
"cache_no": "Disabled",
"buffer_duration": "Buffer Duration",
"max_cache_size": "Max Cache Size",
"max_backward_cache": "Max Backward Cache"
},
"vo_driver": {
"title": "Video Output",
"vo_mode": "VO Driver",
"gpu_next": "gpu-next (Recommended)",
"gpu": "gpu"
},
"gesture_controls": {
"gesture_controls_title": "Ovládání gest",
"horizontal_swipe_skip": "Horizontální přejetím přeskočit",
@@ -256,7 +297,23 @@
"subtitle_font": "Subtitle Font",
"ksplayer_title": "KSPlayer Settings",
"hardware_decode": "Hardware Decoding",
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues."
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues.",
"opensubtitles_title": "OpenSubtitles",
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
"opensubtitles_api_key": "API Key",
"opensubtitles_api_key_placeholder": "Enter API key...",
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
"mpv_subtitle_scale": "Subtitle Scale",
"mpv_subtitle_margin_y": "Vertical Margin",
"mpv_subtitle_align_x": "Horizontal Align",
"mpv_subtitle_align_y": "Vertical Align",
"align": {
"left": "Left",
"center": "Center",
"right": "Right",
"top": "Top",
"bottom": "Bottom"
}
},
"vlc_subtitles": {
"title": "VLC Subtitle Settings",
@@ -406,7 +463,13 @@
"music_cache_cleared": "Music cache cleared",
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
"downloaded_songs_size": "{{size}} downloaded",
"downloaded_songs_deleted": "Downloaded songs deleted"
"downloaded_songs_deleted": "Downloaded songs deleted",
"clear_all_cache": "Clear All Cache",
"clear_all_cache_confirm": "Clear All Cache?",
"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.",
"clear_all_cache_success": "Cache Cleared",
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
},
"intro": {
"title": "Intro",
@@ -430,6 +493,21 @@
"error_deleting_files": "Chyba při mazání souborů",
"background_downloads_enabled": "Stahování na pozadí povoleno",
"background_downloads_disabled": "Stahování na pozadí zakázáno"
},
"security": {
"title": "Security",
"inactivity_timeout": {
"title": "Inactivity Timeout",
"description": "Auto logout after inactivity",
"disabled": "Disabled",
"1_minute": "1 minute",
"5_minutes": "5 minutes",
"15_minutes": "15 minutes",
"30_minutes": "30 minutes",
"1_hour": "1 hour",
"4_hours": "4 hours",
"24_hours": "24 hours"
}
}
},
"sessions": {
@@ -456,6 +534,7 @@
"new_app_version_requires_re_download_description": "Nová aktualizace vyžaduje opětovné stažení obsahu. Odstraňte prosím veškerý stažený obsah a zkuste to znovu.",
"back": "Zpět",
"delete": "Vymazat",
"delete_download": "Delete Download",
"something_went_wrong": "Něco se pokazilo",
"could_not_get_stream_url_from_jellyfin": "Nelze získat URL streamu z Jellyfin",
"eta": "ETA {{eta}}",
@@ -492,22 +571,28 @@
}
},
"common": {
"no_results": "No Results",
"select": "Vybrat",
"no_trailer_available": "Přípojné vozidlo není k dispozici",
"video": "Video",
"audio": "Zvuk",
"subtitle": "Podtitulek",
"play": "Hrát",
"mark_as_played": "Mark as Played",
"mark_as_not_played": "Mark as not Played",
"none": "None",
"track": "Track",
"cancel": "Cancel",
"stop": "Stop",
"delete": "Delete",
"ok": "OK",
"remove": "Remove",
"next": "Next",
"back": "Back",
"continue": "Continue",
"verifying": "Verifying..."
"verifying": "Verifying...",
"login": "Login",
"refresh": "Refresh"
},
"search": {
"search": "Hledat...",
@@ -556,6 +641,7 @@
"movies": "Filmy",
"series": "Série",
"boxsets": "Sada boxů",
"playlists": "Playlists",
"items": "Položky"
},
"options": {
@@ -566,7 +652,8 @@
"poster": "Plakát",
"cover": "Kryt",
"show_titles": "Zobrazit názvy",
"show_stats": "Zobrazit statistiky"
"show_stats": "Zobrazit statistiky",
"options_title": "Options"
},
"filters": {
"genres": "Genres",
@@ -574,7 +661,11 @@
"sort_by": "Seřadit podle",
"filter_by": "Filter By",
"sort_order": "Řazení",
"tags": "Štítky"
"tags": "Štítky",
"all": "All",
"reset": "Reset",
"asc": "Ascending",
"desc": "Descending"
}
},
"favorites": {
@@ -591,6 +682,8 @@
"no_links": "Žádné odkazy"
},
"player": {
"live": "LIVE",
"mpv_player_title": "MPV Player",
"error": "Chyba",
"failed_to_get_stream_url": "Nepodařilo se získat URL streamu",
"an_error_occured_while_playing_the_video": "Při přehrávání videa došlo k chybě. Zkontrolujte logy v nastavení.",
@@ -608,7 +701,35 @@
"downloaded_file_message": "Do you want to play the downloaded file?",
"downloaded_file_yes": "Yes",
"downloaded_file_no": "No",
"downloaded_file_cancel": "Cancel"
"downloaded_file_cancel": "Cancel",
"swipe_down_settings": "Swipe down for settings",
"ends_at": "Ends at {{time}}",
"search_subtitles": "Search Subtitles",
"subtitle_tracks": "Tracks",
"subtitle_search": "Search & Download",
"download": "Download",
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
"using_jellyfin_server": "Using Jellyfin Server",
"language": "Language",
"results": "Results",
"searching": "Searching...",
"search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found",
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
"settings": "Settings",
"skip_intro": "Skip Intro",
"skip_credits": "Skip Credits",
"stopPlayback": "Stop Playback",
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
"stopPlayingConfirm": "Are you sure you want to stop playback?",
"downloaded": "Downloaded"
},
"chapters": {
"title": "Chapters",
"chapter_number": "Chapter {{number}}",
"open": "Open chapters",
"close": "Close chapters"
},
"item_card": {
"next_up": "Další nahoru",
@@ -617,6 +738,11 @@
"series": "Série",
"seasons": "Série",
"season": "Sezóna",
"from_this_series": "From This Series",
"more_from_this_season": "More from this Season",
"view_series": "View Series",
"view_season": "View Season",
"select_season": "Select Season",
"no_episodes_for_this_season": "Žádné epizody pro tuto sezónu",
"overview": "Přehled",
"more_with": "Více s {{name}}",
@@ -627,10 +753,21 @@
"media_options": "Media Options",
"quality": "Kvalita",
"audio": "Zvuk",
"subtitles": "Podtitulek",
"subtitles": {
"label": "Subtitle",
"none": "None",
"tracks": "Tracks"
},
"show_more": "Zobrazit více",
"show_less": "Zobrazit méně",
"left": "left",
"more_info": "More Info",
"director": "Director",
"cast": "Cast",
"technical_details": "Technical Details",
"appeared_in": "Zobrazeno v",
"movies": "Movies",
"shows": "Shows",
"could_not_load_item": "Nelze načíst položku",
"none": "Nic",
"download": {
@@ -641,7 +778,13 @@
"download_x_item": "Stáhnout položky {{item_count}}",
"download_unwatched_only": "Pouze nezhlédnuté",
"download_button": "Stáhnout"
}
},
"mark_played": "Mark as Watched",
"mark_unplayed": "Mark as Unwatched",
"resume_playback": "Resume Playback",
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
"play_from_start": "Play from Start",
"continue_from": "Continue from {{time}}"
},
"live_tv": {
"next": "Další",
@@ -652,7 +795,18 @@
"movies": "Filmy",
"sports": "Sporty",
"for_kids": "Pro děti",
"news": "Novinky"
"news": "Novinky",
"page_of": "Page {{current}} of {{total}}",
"no_programs": "No programs available",
"no_channels": "No channels available",
"tabs": {
"programs": "Programs",
"guide": "Guide",
"channels": "Channels",
"recordings": "Recordings",
"schedule": "Schedule",
"series": "Series"
}
},
"jellyseerr": {
"confirm": "Potvrdit",
@@ -697,6 +851,12 @@
"decline": "Decline",
"requested_by": "Requested by {{user}}",
"unknown_user": "Unknown User",
"select": "Select",
"request_all": "Request All",
"request_seasons": "Request Seasons",
"select_seasons": "Select Seasons",
"request_selected": "Request Selected",
"n_selected": "{{count}} selected",
"toasts": {
"jellyseer_does_not_meet_requirements": "Seerr server nesplňuje minimální požadavky na verzi! Aktualizujte prosím alespoň na 2.0.0",
"jellyseerr_test_failed": "Seerr test se nezdařil. Zkuste to prosím znovu.",
@@ -716,7 +876,8 @@
"search": "Hledat",
"library": "Knihovna",
"custom_links": "Vlastní odkazy",
"favorites": "Oblíbené"
"favorites": "Oblíbené",
"settings": "Settings"
},
"music": {
"title": "Music",
@@ -841,5 +1002,36 @@
"show": "This show",
"all": "All media (default)"
}
},
"companion_login": {
"title": "Pair with TV",
"align_qr": "Align the QR code within the frame",
"enter_code_manually": "Enter code manually",
"pairing_enter_credentials": "Enter credentials for TV",
"pairing_code_label": "Pairing code",
"server": "Server",
"authorize_button": "Authorize",
"authorizing": "Authorizing...",
"scan_again": "Scan Again",
"done": "Done",
"success_title": "Authorization Sent",
"pairing_tv_connecting": "The TV is connecting to your account",
"error_title": "Authorization Failed",
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
"error_generic": "Something went wrong. Please try again.",
"error_permission_denied": "Camera permission is required to scan QR codes.",
"login_as": "Log in as {{username}}?",
"on_server": "on {{server}}",
"use_different_user": "Use a different user",
"open_settings": "Open Settings"
},
"pairing": {
"pair_with_phone": "Pair with Phone",
"pair_with_phone_title": "Login TV",
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
"waiting_for_phone": "Waiting for phone...",
"scan_with_phone": "Scan with the Streamyfin app on your phone",
"logging_in": "Logging in...",
"logging_in_description": "Connecting to your server"
}
}

View File

@@ -4,6 +4,9 @@
"error_title": "Fejl",
"login_title": "Log ind",
"login_to_title": "Log ind på",
"select_user": "Select a user to log in",
"add_user_to_login": "Add a user to log in",
"add_user": "Add User",
"username_placeholder": "Brugernavn",
"password_placeholder": "Adgangskode",
"login_button": "Log ind",
@@ -42,7 +45,13 @@
"accounts_count": "{{count}} accounts",
"select_account": "Select Account",
"add_account": "Add Account",
"remove_account_description": "This will remove the saved credentials for {{username}}."
"remove_account_description": "This will remove the saved credentials for {{username}}.",
"remove_server": "Remove Server",
"remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
"select_your_server": "Select Your Server",
"add_server_to_get_started": "Add a server to get started",
"add_server": "Add Server",
"change_server": "Change Server"
},
"save_account": {
"title": "Save Account",
@@ -86,6 +95,7 @@
"oops": "Ups!",
"error_message": "Noget gik galt.\nLog venligst ud og ind igen.",
"continue_watching": "Fortsæt med at se",
"continue": "Continue",
"next_up": "Næste",
"continue_and_next_up": "Continue & Next Up",
"recently_added_in": "Senest tilføjet i {{libraryName}}",
@@ -109,6 +119,12 @@
"settings": {
"settings_title": "Indstillinger",
"log_out_button": "Log ud",
"switch_user": {
"title": "Switch User",
"account": "Account",
"switch_user": "Switch User on This Server",
"current": "current"
},
"categories": {
"title": "Categories"
},
@@ -121,7 +137,16 @@
"appearance": {
"title": "Appearance",
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
"hide_remote_session_button": "Hide Remote Session Button"
"hide_remote_session_button": "Hide Remote Session Button",
"show_home_backdrop": "Dynamic Home Backdrop",
"show_hero_carousel": "Hero Carousel",
"show_series_poster_on_episode": "Show Series Poster on Episodes",
"theme_music": "Theme Music",
"display_size": "Display Size",
"display_size_small": "Small",
"display_size_default": "Default",
"display_size_large": "Large",
"display_size_extra_large": "Extra Large"
},
"network": {
"title": "Network",
@@ -174,6 +199,22 @@
"rewind_length": "Spol tilbage længde",
"seconds_unit": "s"
},
"buffer": {
"title": "Buffer Settings",
"cache_mode": "Cache Mode",
"cache_auto": "Auto",
"cache_yes": "Enabled",
"cache_no": "Disabled",
"buffer_duration": "Buffer Duration",
"max_cache_size": "Max Cache Size",
"max_backward_cache": "Max Backward Cache"
},
"vo_driver": {
"title": "Video Output",
"vo_mode": "VO Driver",
"gpu_next": "gpu-next (Recommended)",
"gpu": "gpu"
},
"gesture_controls": {
"gesture_controls_title": "Bevægelsesstyring",
"horizontal_swipe_skip": "Vandret Stryg for at springe over",
@@ -256,7 +297,23 @@
"subtitle_font": "Subtitle Font",
"ksplayer_title": "KSPlayer Settings",
"hardware_decode": "Hardware Decoding",
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues."
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues.",
"opensubtitles_title": "OpenSubtitles",
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
"opensubtitles_api_key": "API Key",
"opensubtitles_api_key_placeholder": "Enter API key...",
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
"mpv_subtitle_scale": "Subtitle Scale",
"mpv_subtitle_margin_y": "Vertical Margin",
"mpv_subtitle_align_x": "Horizontal Align",
"mpv_subtitle_align_y": "Vertical Align",
"align": {
"left": "Left",
"center": "Center",
"right": "Right",
"top": "Top",
"bottom": "Bottom"
}
},
"vlc_subtitles": {
"title": "VLC Subtitle Settings",
@@ -406,7 +463,13 @@
"music_cache_cleared": "Music cache cleared",
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
"downloaded_songs_size": "{{size}} downloaded",
"downloaded_songs_deleted": "Downloaded songs deleted"
"downloaded_songs_deleted": "Downloaded songs deleted",
"clear_all_cache": "Clear All Cache",
"clear_all_cache_confirm": "Clear All Cache?",
"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.",
"clear_all_cache_success": "Cache Cleared",
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
},
"intro": {
"title": "Intro",
@@ -430,6 +493,21 @@
"error_deleting_files": "Fejl ved sletning af filer",
"background_downloads_enabled": "Baggrundsdownloads aktiveret",
"background_downloads_disabled": "Baggrundsdownloads deaktiveret"
},
"security": {
"title": "Security",
"inactivity_timeout": {
"title": "Inactivity Timeout",
"description": "Auto logout after inactivity",
"disabled": "Disabled",
"1_minute": "1 minute",
"5_minutes": "5 minutes",
"15_minutes": "15 minutes",
"30_minutes": "30 minutes",
"1_hour": "1 hour",
"4_hours": "4 hours",
"24_hours": "24 hours"
}
}
},
"sessions": {
@@ -456,6 +534,7 @@
"new_app_version_requires_re_download_description": "Den nye opdatering kræver, at indhold downloades igen. Fjern venligst alt downloadet indhold og prøv igen.",
"back": "Tilbage",
"delete": "Slet",
"delete_download": "Delete Download",
"something_went_wrong": "Noget gik galt",
"could_not_get_stream_url_from_jellyfin": "Kunne ikke hente stream URL'en fra Jellyfin",
"eta": "ETA {{eta}}",
@@ -492,22 +571,28 @@
}
},
"common": {
"no_results": "No Results",
"select": "Vælg",
"no_trailer_available": "Intet påhængskøretøj tilgængeligt",
"video": "Video",
"audio": "Lyd",
"subtitle": "Undertekster",
"play": "Afspil",
"mark_as_played": "Mark as Played",
"mark_as_not_played": "Mark as not Played",
"none": "None",
"track": "Track",
"cancel": "Cancel",
"stop": "Stop",
"delete": "Delete",
"ok": "OK",
"remove": "Remove",
"next": "Next",
"back": "Back",
"continue": "Continue",
"verifying": "Verifying..."
"verifying": "Verifying...",
"login": "Login",
"refresh": "Refresh"
},
"search": {
"search": "Søg...",
@@ -556,6 +641,7 @@
"movies": "film",
"series": "serier",
"boxsets": "box sæt",
"playlists": "Playlists",
"items": "elementer"
},
"options": {
@@ -566,7 +652,8 @@
"poster": "Plakat",
"cover": "Omslag",
"show_titles": "Vis titler",
"show_stats": "Vis statistik"
"show_stats": "Vis statistik",
"options_title": "Options"
},
"filters": {
"genres": "Genrer",
@@ -574,7 +661,11 @@
"sort_by": "Sortér efter",
"filter_by": "Filter By",
"sort_order": "Sorteringsrækkefølge",
"tags": "Mærker"
"tags": "Mærker",
"all": "All",
"reset": "Reset",
"asc": "Ascending",
"desc": "Descending"
}
},
"favorites": {
@@ -591,6 +682,8 @@
"no_links": "Ingen links"
},
"player": {
"live": "LIVE",
"mpv_player_title": "MPV Player",
"error": "Fejl",
"failed_to_get_stream_url": "Kunne ikke hente stream URL'en",
"an_error_occured_while_playing_the_video": "Der opstod en fejl under afspilning af videoen. Tjek logfilerne i indstillinger.",
@@ -608,7 +701,35 @@
"downloaded_file_message": "Do you want to play the downloaded file?",
"downloaded_file_yes": "Yes",
"downloaded_file_no": "No",
"downloaded_file_cancel": "Cancel"
"downloaded_file_cancel": "Cancel",
"swipe_down_settings": "Swipe down for settings",
"ends_at": "Ends at {{time}}",
"search_subtitles": "Search Subtitles",
"subtitle_tracks": "Tracks",
"subtitle_search": "Search & Download",
"download": "Download",
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
"using_jellyfin_server": "Using Jellyfin Server",
"language": "Language",
"results": "Results",
"searching": "Searching...",
"search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found",
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
"settings": "Settings",
"skip_intro": "Skip Intro",
"skip_credits": "Skip Credits",
"stopPlayback": "Stop Playback",
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
"stopPlayingConfirm": "Are you sure you want to stop playback?",
"downloaded": "Downloaded"
},
"chapters": {
"title": "Chapters",
"chapter_number": "Chapter {{number}}",
"open": "Open chapters",
"close": "Close chapters"
},
"item_card": {
"next_up": "Næste",
@@ -617,6 +738,11 @@
"series": "Serier",
"seasons": "Sæsoner",
"season": "Sæson",
"from_this_series": "From This Series",
"more_from_this_season": "More from this Season",
"view_series": "View Series",
"view_season": "View Season",
"select_season": "Select Season",
"no_episodes_for_this_season": "Ingen episoder for denne sæson",
"overview": "Oversigt",
"more_with": "Mere med {{name}}",
@@ -627,10 +753,21 @@
"media_options": "Media Options",
"quality": "Kvalitet",
"audio": "Lyd",
"subtitles": "Undertekster",
"subtitles": {
"label": "Subtitle",
"none": "None",
"tracks": "Tracks"
},
"show_more": "Vis mere",
"show_less": "Vis mindre",
"left": "left",
"more_info": "More Info",
"director": "Director",
"cast": "Cast",
"technical_details": "Technical Details",
"appeared_in": "Medvirket i",
"movies": "Movies",
"shows": "Shows",
"could_not_load_item": "Kunne ikke indlæse elementet",
"none": "Ingen",
"download": {
@@ -641,7 +778,13 @@
"download_x_item": "Download {{item_count}} elementer",
"download_unwatched_only": "Kun Usete",
"download_button": "Hent"
}
},
"mark_played": "Mark as Watched",
"mark_unplayed": "Mark as Unwatched",
"resume_playback": "Resume Playback",
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
"play_from_start": "Play from Start",
"continue_from": "Continue from {{time}}"
},
"live_tv": {
"next": "Næste",
@@ -652,7 +795,18 @@
"movies": "Film",
"sports": "Sport",
"for_kids": "For børn",
"news": "Nyheder"
"news": "Nyheder",
"page_of": "Page {{current}} of {{total}}",
"no_programs": "No programs available",
"no_channels": "No channels available",
"tabs": {
"programs": "Programs",
"guide": "Guide",
"channels": "Channels",
"recordings": "Recordings",
"schedule": "Schedule",
"series": "Series"
}
},
"jellyseerr": {
"confirm": "Bekræft",
@@ -697,6 +851,12 @@
"decline": "Decline",
"requested_by": "Requested by {{user}}",
"unknown_user": "Unknown User",
"select": "Select",
"request_all": "Request All",
"request_seasons": "Request Seasons",
"select_seasons": "Select Seasons",
"request_selected": "Request Selected",
"n_selected": "{{count}} selected",
"toasts": {
"jellyseer_does_not_meet_requirements": "Jellyseerr serveren opfylder ikke minimumskravene! Opdater venligst til mindst 2.0.0",
"jellyseerr_test_failed": "Jellyseerr test mislykkedes. Prøv venligst igen.",
@@ -716,7 +876,8 @@
"search": "Søg",
"library": "Bibliotek",
"custom_links": "Tilpassede links",
"favorites": "Favoritter"
"favorites": "Favoritter",
"settings": "Settings"
},
"music": {
"title": "Music",
@@ -841,5 +1002,36 @@
"show": "This show",
"all": "All media (default)"
}
},
"companion_login": {
"title": "Pair with TV",
"align_qr": "Align the QR code within the frame",
"enter_code_manually": "Enter code manually",
"pairing_enter_credentials": "Enter credentials for TV",
"pairing_code_label": "Pairing code",
"server": "Server",
"authorize_button": "Authorize",
"authorizing": "Authorizing...",
"scan_again": "Scan Again",
"done": "Done",
"success_title": "Authorization Sent",
"pairing_tv_connecting": "The TV is connecting to your account",
"error_title": "Authorization Failed",
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
"error_generic": "Something went wrong. Please try again.",
"error_permission_denied": "Camera permission is required to scan QR codes.",
"login_as": "Log in as {{username}}?",
"on_server": "on {{server}}",
"use_different_user": "Use a different user",
"open_settings": "Open Settings"
},
"pairing": {
"pair_with_phone": "Pair with Phone",
"pair_with_phone_title": "Login TV",
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
"waiting_for_phone": "Waiting for phone...",
"scan_with_phone": "Scan with the Streamyfin app on your phone",
"logging_in": "Logging in...",
"logging_in_description": "Connecting to your server"
}
}

View File

@@ -4,6 +4,9 @@
"error_title": "Fehler",
"login_title": "Anmelden",
"login_to_title": "Anmelden bei",
"select_user": "Select a user to log in",
"add_user_to_login": "Add a user to log in",
"add_user": "Add User",
"username_placeholder": "Benutzername",
"password_placeholder": "Passwort",
"login_button": "Anmelden",
@@ -42,7 +45,13 @@
"accounts_count": "{{count}} Konten",
"select_account": "Konto auswählen",
"add_account": "Konto hinzufügen",
"remove_account_description": "Hiermit werden die gespeicherten Zugangsdaten für {{username}} entfernt."
"remove_account_description": "Hiermit werden die gespeicherten Zugangsdaten für {{username}} entfernt.",
"remove_server": "Remove Server",
"remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
"select_your_server": "Select Your Server",
"add_server_to_get_started": "Add a server to get started",
"add_server": "Add Server",
"change_server": "Change Server"
},
"save_account": {
"title": "Konto speichern",
@@ -86,6 +95,7 @@
"oops": "Ups!",
"error_message": "Etwas ist schiefgelaufen.\nBitte melde dich ab und wieder an.",
"continue_watching": "Weiterschauen",
"continue": "Continue",
"next_up": "Als nächstes",
"continue_and_next_up": "\"Weiterschauen\" und \"Als Nächstes\"",
"recently_added_in": "Kürzlich hinzugefügt in {{libraryName}}",
@@ -109,6 +119,12 @@
"settings": {
"settings_title": "Einstellungen",
"log_out_button": "Abmelden",
"switch_user": {
"title": "Switch User",
"account": "Account",
"switch_user": "Switch User on This Server",
"current": "current"
},
"categories": {
"title": "Kategorien"
},
@@ -121,7 +137,16 @@
"appearance": {
"title": "Aussehen",
"merge_next_up_continue_watching": "\"Weiterschauen\" und \"Als Nächstes\" kombinieren",
"hide_remote_session_button": "Button für Remote-Sitzung ausblenden"
"hide_remote_session_button": "Button für Remote-Sitzung ausblenden",
"show_home_backdrop": "Dynamic Home Backdrop",
"show_hero_carousel": "Hero Carousel",
"show_series_poster_on_episode": "Show Series Poster on Episodes",
"theme_music": "Theme Music",
"display_size": "Display Size",
"display_size_small": "Small",
"display_size_default": "Default",
"display_size_large": "Large",
"display_size_extra_large": "Extra Large"
},
"network": {
"title": "Netzwerk",
@@ -174,6 +199,22 @@
"rewind_length": "Rückspullänge",
"seconds_unit": "s"
},
"buffer": {
"title": "Buffer Settings",
"cache_mode": "Cache Mode",
"cache_auto": "Auto",
"cache_yes": "Enabled",
"cache_no": "Disabled",
"buffer_duration": "Buffer Duration",
"max_cache_size": "Max Cache Size",
"max_backward_cache": "Max Backward Cache"
},
"vo_driver": {
"title": "Video Output",
"vo_mode": "VO Driver",
"gpu_next": "gpu-next (Recommended)",
"gpu": "gpu"
},
"gesture_controls": {
"gesture_controls_title": "Gestensteuerung",
"horizontal_swipe_skip": "Horizontal Wischen zum Überspringen",
@@ -256,7 +297,23 @@
"subtitle_font": "Untertitel-Schriftart",
"ksplayer_title": "KSPlayer Einstellungen",
"hardware_decode": "Hardware Decoding",
"hardware_decode_description": "Hardwarebeschleunigung für Video Decoding verwenden. Deaktivieren wenn Wiedergabeprobleme auftreten."
"hardware_decode_description": "Hardwarebeschleunigung für Video Decoding verwenden. Deaktivieren wenn Wiedergabeprobleme auftreten.",
"opensubtitles_title": "OpenSubtitles",
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
"opensubtitles_api_key": "API Key",
"opensubtitles_api_key_placeholder": "Enter API key...",
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
"mpv_subtitle_scale": "Subtitle Scale",
"mpv_subtitle_margin_y": "Vertical Margin",
"mpv_subtitle_align_x": "Horizontal Align",
"mpv_subtitle_align_y": "Vertical Align",
"align": {
"left": "Left",
"center": "Center",
"right": "Right",
"top": "Top",
"bottom": "Bottom"
}
},
"vlc_subtitles": {
"title": "VLC Untertitel-Einstellungen",
@@ -406,7 +463,13 @@
"music_cache_cleared": "Musik-Cache geleert",
"delete_all_downloaded_songs": "Alle heruntergeladenen Titel löschen",
"downloaded_songs_size": "{{size}} heruntergeladen",
"downloaded_songs_deleted": "Heruntergeladene Titel gelöscht"
"downloaded_songs_deleted": "Heruntergeladene Titel gelöscht",
"clear_all_cache": "Clear All Cache",
"clear_all_cache_confirm": "Clear All Cache?",
"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.",
"clear_all_cache_success": "Cache Cleared",
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
},
"intro": {
"title": "Einführung",
@@ -430,6 +493,21 @@
"error_deleting_files": "Fehler beim Löschen von Dateien",
"background_downloads_enabled": "Hintergrunddownloads aktiviert",
"background_downloads_disabled": "Hintergrunddownloads deaktiviert"
},
"security": {
"title": "Security",
"inactivity_timeout": {
"title": "Inactivity Timeout",
"description": "Auto logout after inactivity",
"disabled": "Disabled",
"1_minute": "1 minute",
"5_minutes": "5 minutes",
"15_minutes": "15 minutes",
"30_minutes": "30 minutes",
"1_hour": "1 hour",
"4_hours": "4 hours",
"24_hours": "24 hours"
}
}
},
"sessions": {
@@ -456,6 +534,7 @@
"new_app_version_requires_re_download_description": "Die neue App-Version erfordert das erneute Herunterladen von Filmen und Serien. Bitte lösche alle heruntergeladenen Elemente und starte den Download erneut.",
"back": "Zurück",
"delete": "Löschen",
"delete_download": "Download löschen",
"something_went_wrong": "Etwas ist schiefgelaufen",
"could_not_get_stream_url_from_jellyfin": "Konnte keine Stream-URL von Jellyfin erhalten",
"eta": "ETA {{eta}}",
@@ -492,22 +571,28 @@
}
},
"common": {
"no_results": "No Results",
"select": "Auswählen",
"no_trailer_available": "Kein Trailer verfügbar",
"video": "Video",
"audio": "Audio",
"subtitle": "Untertitel",
"play": "Abspielen",
"mark_as_played": "Als gesehen markieren",
"mark_as_not_played": "Als ungesehen markieren",
"none": "Keine",
"track": "Spur",
"cancel": "Abbrechen",
"stop": "Stop",
"delete": "Löschen",
"ok": "OK",
"remove": "Entfernen",
"next": "Weiter",
"back": "Zurück",
"continue": "Fortsetzen",
"verifying": "Verifiziere..."
"verifying": "Verifiziere...",
"login": "Login",
"refresh": "Refresh"
},
"search": {
"search": "Suchen...",
@@ -556,6 +641,7 @@
"movies": "Filme",
"series": "Serien",
"boxsets": "Boxsets",
"playlists": "Playlists",
"items": "Elemente"
},
"options": {
@@ -566,7 +652,8 @@
"poster": "Poster",
"cover": "Cover",
"show_titles": "Titel anzeigen",
"show_stats": "Statistiken anzeigen"
"show_stats": "Statistiken anzeigen",
"options_title": "Options"
},
"filters": {
"genres": "Genres",
@@ -574,7 +661,11 @@
"sort_by": "Sortieren nach",
"filter_by": "Filtern nach",
"sort_order": "Sortierreihenfolge",
"tags": "Tags"
"tags": "Tags",
"all": "All",
"reset": "Reset",
"asc": "Ascending",
"desc": "Descending"
}
},
"favorites": {
@@ -591,6 +682,8 @@
"no_links": "Keine Links"
},
"player": {
"live": "LIVE",
"mpv_player_title": "MPV Player",
"error": "Fehler",
"failed_to_get_stream_url": "Fehler beim Abrufen der Stream-URL",
"an_error_occured_while_playing_the_video": "Ein Fehler ist beim Abspielen des Videos aufgetreten. Logs in den Einstellungen überprüfen.",
@@ -609,7 +702,34 @@
"downloaded_file_yes": "Ja",
"downloaded_file_no": "Nein",
"downloaded_file_cancel": "Abbrechen",
"ends_at": "Endet um {{time}}"
"swipe_down_settings": "Swipe down for settings",
"ends_at": "Endet um {{time}}",
"search_subtitles": "Search Subtitles",
"subtitle_tracks": "Tracks",
"subtitle_search": "Search & Download",
"download": "Download",
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
"using_jellyfin_server": "Using Jellyfin Server",
"language": "Language",
"results": "Results",
"searching": "Searching...",
"search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found",
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
"settings": "Settings",
"skip_intro": "Skip Intro",
"skip_credits": "Skip Credits",
"stopPlayback": "Stop Playback",
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
"stopPlayingConfirm": "Are you sure you want to stop playback?",
"downloaded": "Downloaded"
},
"chapters": {
"title": "Chapters",
"chapter_number": "Chapter {{number}}",
"open": "Open chapters",
"close": "Close chapters"
},
"item_card": {
"next_up": "Als Nächstes",
@@ -618,6 +738,11 @@
"series": "Serien",
"seasons": "Staffeln",
"season": "Staffel",
"from_this_series": "From This Series",
"more_from_this_season": "More from this Season",
"view_series": "View Series",
"view_season": "View Season",
"select_season": "Select Season",
"no_episodes_for_this_season": "Keine Episoden für diese Staffel",
"overview": "Überblick",
"more_with": "Mehr mit {{name}}",
@@ -628,10 +753,21 @@
"media_options": "Medienoptionen",
"quality": "Qualität",
"audio": "Audio",
"subtitles": "Untertitel",
"subtitles": {
"label": "Subtitle",
"none": "None",
"tracks": "Tracks"
},
"show_more": "Mehr anzeigen",
"show_less": "Weniger anzeigen",
"left": "left",
"more_info": "More Info",
"director": "Director",
"cast": "Cast",
"technical_details": "Technical Details",
"appeared_in": "Erschien in",
"movies": "Movies",
"shows": "Shows",
"could_not_load_item": "Konnte Element nicht laden",
"none": "Keine",
"download": {
@@ -642,7 +778,13 @@
"download_x_item": "{{item_count}} Elemente herunterladen",
"download_unwatched_only": "Nur Ungesehene",
"download_button": "Herunterladen"
}
},
"mark_played": "Mark as Watched",
"mark_unplayed": "Mark as Unwatched",
"resume_playback": "Resume Playback",
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
"play_from_start": "Play from Start",
"continue_from": "Continue from {{time}}"
},
"live_tv": {
"next": "Nächste",
@@ -653,7 +795,18 @@
"movies": "Filme",
"sports": "Sport",
"for_kids": "Für Kinder",
"news": "Nachrichten"
"news": "Nachrichten",
"page_of": "Page {{current}} of {{total}}",
"no_programs": "No programs available",
"no_channels": "No channels available",
"tabs": {
"programs": "Programs",
"guide": "Guide",
"channels": "Channels",
"recordings": "Recordings",
"schedule": "Schedule",
"series": "Series"
}
},
"jellyseerr": {
"confirm": "Bestätigen",
@@ -698,6 +851,12 @@
"decline": "Ablehnen",
"requested_by": "Angefragt von {{user}}",
"unknown_user": "Unbekannter Nutzer",
"select": "Select",
"request_all": "Request All",
"request_seasons": "Request Seasons",
"select_seasons": "Select Seasons",
"request_selected": "Request Selected",
"n_selected": "{{count}} selected",
"toasts": {
"jellyseer_does_not_meet_requirements": "Seerr-Server erfüllt nicht die minimalen Versionsanforderungen. Bitte den Seerr-Server auf mindestens 2.0.0 aktualisieren.",
"jellyseerr_test_failed": "Seerr-Test fehlgeschlagen. Bitte erneut versuchen.",
@@ -717,7 +876,8 @@
"search": "Suche",
"library": "Bibliothek",
"custom_links": "Links",
"favorites": "Favoriten"
"favorites": "Favoriten",
"settings": "Settings"
},
"music": {
"title": "Musik",
@@ -842,5 +1002,36 @@
"show": "Nur diese Serie",
"all": "Alle (Standard)"
}
},
"companion_login": {
"title": "Pair with TV",
"align_qr": "Align the QR code within the frame",
"enter_code_manually": "Enter code manually",
"pairing_enter_credentials": "Enter credentials for TV",
"pairing_code_label": "Pairing code",
"server": "Server",
"authorize_button": "Authorize",
"authorizing": "Authorizing...",
"scan_again": "Scan Again",
"done": "Done",
"success_title": "Authorization Sent",
"pairing_tv_connecting": "The TV is connecting to your account",
"error_title": "Authorization Failed",
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
"error_generic": "Something went wrong. Please try again.",
"error_permission_denied": "Camera permission is required to scan QR codes.",
"login_as": "Log in as {{username}}?",
"on_server": "on {{server}}",
"use_different_user": "Use a different user",
"open_settings": "Open Settings"
},
"pairing": {
"pair_with_phone": "Pair with Phone",
"pair_with_phone_title": "Login TV",
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
"waiting_for_phone": "Waiting for phone...",
"scan_with_phone": "Scan with the Streamyfin app on your phone",
"logging_in": "Logging in...",
"logging_in_description": "Connecting to your server"
}
}

View File

@@ -4,6 +4,9 @@
"error_title": "Σφάλμα",
"login_title": "Σύνδεση",
"login_to_title": "Συνδεθείτε στο",
"select_user": "Select a user to log in",
"add_user_to_login": "Add a user to log in",
"add_user": "Add User",
"username_placeholder": "Όνομα Χρήστη",
"password_placeholder": "Κωδικός",
"login_button": "Σύνδεση",
@@ -42,7 +45,13 @@
"accounts_count": "{{count}} accounts",
"select_account": "Select Account",
"add_account": "Add Account",
"remove_account_description": "This will remove the saved credentials for {{username}}."
"remove_account_description": "This will remove the saved credentials for {{username}}.",
"remove_server": "Remove Server",
"remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
"select_your_server": "Select Your Server",
"add_server_to_get_started": "Add a server to get started",
"add_server": "Add Server",
"change_server": "Change Server"
},
"save_account": {
"title": "Save Account",
@@ -86,6 +95,7 @@
"oops": "Ωχ!",
"error_message": "Something went wrong.\nPlease log out and in again.",
"continue_watching": "Συνέχεια Παρακολούθησης",
"continue": "Continue",
"next_up": "Επόμενο Επάνω",
"continue_and_next_up": "Continue & Next Up",
"recently_added_in": "Προστέθηκε πρόσφατα στο {{libraryName}}",
@@ -109,6 +119,12 @@
"settings": {
"settings_title": "Ρυθμίσεις",
"log_out_button": "Αποσύνδεση",
"switch_user": {
"title": "Switch User",
"account": "Account",
"switch_user": "Switch User on This Server",
"current": "current"
},
"categories": {
"title": "Categories"
},
@@ -121,7 +137,16 @@
"appearance": {
"title": "Appearance",
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
"hide_remote_session_button": "Hide Remote Session Button"
"hide_remote_session_button": "Hide Remote Session Button",
"show_home_backdrop": "Dynamic Home Backdrop",
"show_hero_carousel": "Hero Carousel",
"show_series_poster_on_episode": "Show Series Poster on Episodes",
"theme_music": "Theme Music",
"display_size": "Display Size",
"display_size_small": "Small",
"display_size_default": "Default",
"display_size_large": "Large",
"display_size_extra_large": "Extra Large"
},
"network": {
"title": "Network",
@@ -174,6 +199,22 @@
"rewind_length": "Επαναφορά Μήκους",
"seconds_unit": "ίνα"
},
"buffer": {
"title": "Buffer Settings",
"cache_mode": "Cache Mode",
"cache_auto": "Auto",
"cache_yes": "Enabled",
"cache_no": "Disabled",
"buffer_duration": "Buffer Duration",
"max_cache_size": "Max Cache Size",
"max_backward_cache": "Max Backward Cache"
},
"vo_driver": {
"title": "Video Output",
"vo_mode": "VO Driver",
"gpu_next": "gpu-next (Recommended)",
"gpu": "gpu"
},
"gesture_controls": {
"gesture_controls_title": "Έλεγχοι Χειρονομιών",
"horizontal_swipe_skip": "Οριζόντια σάρωση για παράλειψη",
@@ -256,7 +297,23 @@
"subtitle_font": "Subtitle Font",
"ksplayer_title": "KSPlayer Settings",
"hardware_decode": "Hardware Decoding",
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues."
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues.",
"opensubtitles_title": "OpenSubtitles",
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
"opensubtitles_api_key": "API Key",
"opensubtitles_api_key_placeholder": "Enter API key...",
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
"mpv_subtitle_scale": "Subtitle Scale",
"mpv_subtitle_margin_y": "Vertical Margin",
"mpv_subtitle_align_x": "Horizontal Align",
"mpv_subtitle_align_y": "Vertical Align",
"align": {
"left": "Left",
"center": "Center",
"right": "Right",
"top": "Top",
"bottom": "Bottom"
}
},
"vlc_subtitles": {
"title": "VLC Subtitle Settings",
@@ -406,7 +463,13 @@
"music_cache_cleared": "Music cache cleared",
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
"downloaded_songs_size": "{{size}} downloaded",
"downloaded_songs_deleted": "Downloaded songs deleted"
"downloaded_songs_deleted": "Downloaded songs deleted",
"clear_all_cache": "Clear All Cache",
"clear_all_cache_confirm": "Clear All Cache?",
"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.",
"clear_all_cache_success": "Cache Cleared",
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
},
"intro": {
"title": "Intro",
@@ -430,6 +493,21 @@
"error_deleting_files": "Σφάλμα Διαγραφής Αρχείων",
"background_downloads_enabled": "Οι λήψεις στο παρασκήνιο ενεργοποιήθηκαν",
"background_downloads_disabled": "Οι λήψεις παρασκηνίου απενεργοποιήθηκαν"
},
"security": {
"title": "Security",
"inactivity_timeout": {
"title": "Inactivity Timeout",
"description": "Auto logout after inactivity",
"disabled": "Disabled",
"1_minute": "1 minute",
"5_minutes": "5 minutes",
"15_minutes": "15 minutes",
"30_minutes": "30 minutes",
"1_hour": "1 hour",
"4_hours": "4 hours",
"24_hours": "24 hours"
}
}
},
"sessions": {
@@ -456,6 +534,7 @@
"new_app_version_requires_re_download_description": "Η νέα ενημέρωση απαιτεί λήψη περιεχομένου ξανά. Καταργήστε όλο το κατεβασμένο περιεχόμενο και προσπαθήστε ξανά.",
"back": "Πίσω",
"delete": "Διαγραφή",
"delete_download": "Delete Download",
"something_went_wrong": "Κάτι Πήγε Λάθος",
"could_not_get_stream_url_from_jellyfin": "Αδυναμία λήψης του URL ροής από το Jellyfin",
"eta": "ETA {{eta}}",
@@ -492,22 +571,28 @@
}
},
"common": {
"no_results": "No Results",
"select": "Επιλογή",
"no_trailer_available": "Δεν υπάρχει διαθέσιμο ρυμουλκούμενο",
"video": "Βίντεο",
"audio": "Ήχος",
"subtitle": "Υπότιτλος",
"play": "Αναπαραγωγή",
"mark_as_played": "Mark as Played",
"mark_as_not_played": "Mark as not Played",
"none": "None",
"track": "Track",
"cancel": "Cancel",
"stop": "Stop",
"delete": "Delete",
"ok": "OK",
"remove": "Remove",
"next": "Next",
"back": "Back",
"continue": "Continue",
"verifying": "Verifying..."
"verifying": "Verifying...",
"login": "Login",
"refresh": "Refresh"
},
"search": {
"search": "Αναζήτηση...",
@@ -556,6 +641,7 @@
"movies": "Ταινίες",
"series": "Σειρά",
"boxsets": "Σύνολα Πλαισίων",
"playlists": "Playlists",
"items": "Στοιχεία"
},
"options": {
@@ -566,7 +652,8 @@
"poster": "Αφίσα",
"cover": "Εξώφυλλο",
"show_titles": "Εμφάνιση Τίτλων",
"show_stats": "Εμφάνιση Στατιστικών"
"show_stats": "Εμφάνιση Στατιστικών",
"options_title": "Options"
},
"filters": {
"genres": "Genres",
@@ -574,7 +661,11 @@
"sort_by": "Ταξινόμηση Κατά",
"filter_by": "Filter By",
"sort_order": "Σειρά Ταξινόμησης",
"tags": "Ετικέτες"
"tags": "Ετικέτες",
"all": "All",
"reset": "Reset",
"asc": "Ascending",
"desc": "Descending"
}
},
"favorites": {
@@ -591,6 +682,8 @@
"no_links": "Δεν Υπάρχουν Σύνδεσμοι"
},
"player": {
"live": "LIVE",
"mpv_player_title": "MPV Player",
"error": "Σφάλμα",
"failed_to_get_stream_url": "Αποτυχία λήψης του URL ροής",
"an_error_occured_while_playing_the_video": "Παρουσιάστηκε σφάλμα κατά την αναπαραγωγή του βίντεο. Ελέγξτε τα αρχεία καταγραφής στις ρυθμίσεις.",
@@ -608,7 +701,35 @@
"downloaded_file_message": "Do you want to play the downloaded file?",
"downloaded_file_yes": "Yes",
"downloaded_file_no": "No",
"downloaded_file_cancel": "Cancel"
"downloaded_file_cancel": "Cancel",
"swipe_down_settings": "Swipe down for settings",
"ends_at": "Ends at {{time}}",
"search_subtitles": "Search Subtitles",
"subtitle_tracks": "Tracks",
"subtitle_search": "Search & Download",
"download": "Download",
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
"using_jellyfin_server": "Using Jellyfin Server",
"language": "Language",
"results": "Results",
"searching": "Searching...",
"search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found",
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
"settings": "Settings",
"skip_intro": "Skip Intro",
"skip_credits": "Skip Credits",
"stopPlayback": "Stop Playback",
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
"stopPlayingConfirm": "Are you sure you want to stop playback?",
"downloaded": "Downloaded"
},
"chapters": {
"title": "Chapters",
"chapter_number": "Chapter {{number}}",
"open": "Open chapters",
"close": "Close chapters"
},
"item_card": {
"next_up": "Επόμενο Επάνω",
@@ -617,6 +738,11 @@
"series": "Σειρά",
"seasons": "Περίοδοι",
"season": "Σεζόν",
"from_this_series": "From This Series",
"more_from_this_season": "More from this Season",
"view_series": "View Series",
"view_season": "View Season",
"select_season": "Select Season",
"no_episodes_for_this_season": "Δεν υπάρχουν επεισόδια για αυτή τη σεζόν",
"overview": "Επισκόπηση",
"more_with": "More with {{name}}",
@@ -627,10 +753,21 @@
"media_options": "Media Options",
"quality": "Ποιότητα",
"audio": "Ήχος",
"subtitles": "Υπότιτλος",
"subtitles": {
"label": "Subtitle",
"none": "None",
"tracks": "Tracks"
},
"show_more": "Εμφάνιση Περισσότερων",
"show_less": "Εμφάνιση Λιγότερων",
"left": "left",
"more_info": "More Info",
"director": "Director",
"cast": "Cast",
"technical_details": "Technical Details",
"appeared_in": "Εμφανίστηκε Σε",
"movies": "Movies",
"shows": "Shows",
"could_not_load_item": "Αδύνατη Η Φόρτωση Του Στοιχείου",
"none": "Κανένα",
"download": {
@@ -641,7 +778,13 @@
"download_x_item": "Λήψη Αντικειμένων {{item_count}}",
"download_unwatched_only": "Μόνο Χωρίς Παρακολούθηση",
"download_button": "Λήψη"
}
},
"mark_played": "Mark as Watched",
"mark_unplayed": "Mark as Unwatched",
"resume_playback": "Resume Playback",
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
"play_from_start": "Play from Start",
"continue_from": "Continue from {{time}}"
},
"live_tv": {
"next": "Επόμενο",
@@ -652,7 +795,18 @@
"movies": "Ταινίες",
"sports": "Αθλητισμός",
"for_kids": "Για Παιδιά",
"news": "Νέα"
"news": "Νέα",
"page_of": "Page {{current}} of {{total}}",
"no_programs": "No programs available",
"no_channels": "No channels available",
"tabs": {
"programs": "Programs",
"guide": "Guide",
"channels": "Channels",
"recordings": "Recordings",
"schedule": "Schedule",
"series": "Series"
}
},
"jellyseerr": {
"confirm": "Επιβεβαίωση",
@@ -697,6 +851,12 @@
"decline": "Decline",
"requested_by": "Requested by {{user}}",
"unknown_user": "Unknown User",
"select": "Select",
"request_all": "Request All",
"request_seasons": "Request Seasons",
"select_seasons": "Select Seasons",
"request_selected": "Request Selected",
"n_selected": "{{count}} selected",
"toasts": {
"jellyseer_does_not_meet_requirements": "Ο διακομιστής Seerr δεν πληροί τις ελάχιστες απαιτήσεις έκδοσης! Παρακαλούμε ενημερώστε τουλάχιστον σε 2.0.0",
"jellyseerr_test_failed": "Ο έλεγχος Seerr απέτυχε. Παρακαλώ προσπαθήστε ξανά.",
@@ -716,7 +876,8 @@
"search": "Αναζήτηση",
"library": "Βιβλιοθήκη",
"custom_links": "Προσαρμοσμένοι Σύνδεσμοι",
"favorites": "Αγαπημένα"
"favorites": "Αγαπημένα",
"settings": "Settings"
},
"music": {
"title": "Music",
@@ -841,5 +1002,36 @@
"show": "This show",
"all": "All media (default)"
}
},
"companion_login": {
"title": "Pair with TV",
"align_qr": "Align the QR code within the frame",
"enter_code_manually": "Enter code manually",
"pairing_enter_credentials": "Enter credentials for TV",
"pairing_code_label": "Pairing code",
"server": "Server",
"authorize_button": "Authorize",
"authorizing": "Authorizing...",
"scan_again": "Scan Again",
"done": "Done",
"success_title": "Authorization Sent",
"pairing_tv_connecting": "The TV is connecting to your account",
"error_title": "Authorization Failed",
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
"error_generic": "Something went wrong. Please try again.",
"error_permission_denied": "Camera permission is required to scan QR codes.",
"login_as": "Log in as {{username}}?",
"on_server": "on {{server}}",
"use_different_user": "Use a different user",
"open_settings": "Open Settings"
},
"pairing": {
"pair_with_phone": "Pair with Phone",
"pair_with_phone_title": "Login TV",
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
"waiting_for_phone": "Waiting for phone...",
"scan_with_phone": "Scan with the Streamyfin app on your phone",
"logging_in": "Logging in...",
"logging_in_description": "Connecting to your server"
}
}

View File

@@ -27,6 +27,112 @@
"too_old_server_text": "Unsupported Jellyfin Server Discovered",
"too_old_server_description": "Please update Jellyfin to the latest version"
},
"player": {
"skip_intro": "Skip Intro",
"live": "LIVE",
"mpv_player_title": "MPV Player",
"an_error_occured_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
"swipe_down_settings": "Swipe down for settings",
"ends_at": "Ends at {{time}}",
"search_subtitles": "Search Subtitles",
"subtitle_tracks": "Tracks",
"subtitle_search": "Search & Download",
"download": "Download",
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
"using_jellyfin_server": "Using Jellyfin Server",
"language": "Language",
"results": "Results",
"searching": "Searching...",
"search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found",
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
"settings": "Settings",
"skip_credits": "Skip Credits",
"stopPlayback": "Stop Playback",
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
"stopPlayingConfirm": "Are you sure you want to stop playback?",
"downloaded": "Downloaded",
"skip_outro": "Skip Outro",
"skip_recap": "Skip Recap",
"skip_commercial": "Skip Commercial",
"skip_preview": "Skip Preview",
"error": "Error",
"failed_to_get_stream_url": "Failed to get the stream URL",
"an_error_occurred_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
"client_error": "Client Error",
"could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast",
"message_from_server": "Message from Server: {{message}}",
"next_episode": "Next Episode",
"refresh_tracks": "Refresh Tracks",
"audio_tracks": "Audio Tracks:",
"playback_state": "Playback State:",
"index": "Index:",
"continue_watching": "Continue Watching",
"go_back": "Go Back",
"downloaded_file_title": "You have this file downloaded",
"downloaded_file_message": "Do you want to play the downloaded file?",
"downloaded_file_yes": "Yes",
"downloaded_file_no": "No",
"downloaded_file_cancel": "Cancel",
"up_next": "Up next",
"next_episode_in": "Next episode in {{seconds}}s",
"play_now": "Play now",
"cancel": "Cancel"
},
"casting_player": {
"buffering": "Buffering...",
"changing_audio": "Changing audio...",
"changing_subtitles": "Changing subtitles...",
"season_episode_format": "Season {{season}} • Episode {{episode}}",
"connecting": "Connecting to Chromecast...",
"unknown_device": "Unknown Device",
"ending_at": "Ending at {{time}}",
"unknown": "Unknown",
"connected": "Connected",
"volume": "Volume",
"muted": "Muted",
"disconnect": "Disconnect",
"stop_casting": "Stop Casting",
"disconnecting": "Disconnecting...",
"chromecast": "Chromecast",
"device_name": "Device Name",
"playback_settings": "Playback Settings",
"version": "Version",
"stop": "Stop",
"quality": "Quality",
"audio": "Audio",
"subtitles": "Subtitles",
"none": "None",
"playback_speed": "Playback Speed",
"normal": "Normal",
"episodes": "Episodes",
"season": "Season {{number}}",
"minutes_short": "min",
"episode_label": "Episode {{number}}",
"forced": "Forced",
"device": "Device",
"cancel": "Cancel",
"connection_quality": {
"excellent": "Excellent",
"good": "Good",
"fair": "Fair",
"poor": "Poor",
"disconnected": "Disconnected"
},
"error_title": "Chromecast Error",
"error_description": "Something went wrong with the cast session",
"retry": "Try Again",
"critical_error_title": "Multiple Errors Detected",
"critical_error_description": "Chromecast encountered multiple errors. Please disconnect and try casting again.",
"track_changed": "Track changed successfully",
"audio_track_changed": "Audio track changed",
"subtitle_track_changed": "Subtitle track changed",
"seeking": "Seeking...",
"seeking_error": "Failed to seek",
"load_failed": "Failed to load media",
"load_retry": "Retrying media load..."
},
"server": {
"enter_url_to_jellyfin_server": "Enter the URL to your Jellyfin server",
"server_url_placeholder": "http(s)://your-server.com",
@@ -365,6 +471,23 @@
"default_playback_speed": "Default Playback Speed",
"auto_play_next_episode": "Auto-play Next Episode",
"max_auto_play_episode_count": "Max Auto Play Episode Count",
"segment_skip_settings": "Segment Skip Settings",
"segment_skip_settings_description": "Configure skip behavior for intros, credits, and other segments",
"skip_intro": "Skip Intro",
"skip_intro_description": "Action when intro segment is detected",
"skip_outro": "Skip Outro/Credits",
"skip_outro_description": "Action when outro/credits segment is detected",
"skip_recap": "Skip Recap",
"skip_recap_description": "Action when recap segment is detected",
"skip_commercial": "Skip Commercial",
"skip_commercial_description": "Action when commercial segment is detected",
"skip_preview": "Skip Preview",
"skip_preview_description": "Action when preview segment is detected",
"segment_skip_none": "None",
"segment_skip_ask": "Show Skip Button",
"segment_skip_auto": "Auto Skip",
"autoplay_countdown_seconds": "Player countdown (seconds)",
"cast_autoplay_countdown_seconds": "Chromecast countdown (seconds)",
"disabled": "Disabled"
},
"downloads": {
@@ -534,6 +657,7 @@
"new_app_version_requires_re_download_description": "The new update requires content to be downloaded again. Please remove all downloaded content and try again.",
"back": "Back",
"delete": "Delete",
"delete_download": "Delete Download",
"something_went_wrong": "Something Went Wrong",
"could_not_get_stream_url_from_jellyfin": "Could not get the stream URL from Jellyfin",
"eta": "ETA {{eta}}",
@@ -577,6 +701,8 @@
"audio": "Audio",
"subtitle": "Subtitle",
"play": "Play",
"mark_as_played": "Mark as Played",
"mark_as_not_played": "Mark as not Played",
"none": "None",
"track": "Track",
"cancel": "Cancel",
@@ -649,7 +775,8 @@
"poster": "Poster",
"cover": "Cover",
"show_titles": "Show Titles",
"show_stats": "Show Stats"
"show_stats": "Show Stats",
"options_title": "Options"
},
"filters": {
"genres": "Genres",
@@ -677,49 +804,6 @@
"custom_links": {
"no_links": "No Links"
},
"player": {
"live": "LIVE",
"error": "Error",
"failed_to_get_stream_url": "Failed to get the stream URL",
"an_error_occured_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
"client_error": "Client Error",
"could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast",
"message_from_server": "Message from Server: {{message}}",
"next_episode": "Next Episode",
"refresh_tracks": "Refresh Tracks",
"audio_tracks": "Audio Tracks:",
"playback_state": "Playback State:",
"index": "Index:",
"continue_watching": "Continue Watching",
"go_back": "Go Back",
"downloaded_file_title": "You have this file downloaded",
"downloaded_file_message": "Do you want to play the downloaded file?",
"downloaded_file_yes": "Yes",
"downloaded_file_no": "No",
"downloaded_file_cancel": "Cancel",
"swipe_down_settings": "Swipe down for settings",
"ends_at": "Ends at {{time}}",
"search_subtitles": "Search Subtitles",
"subtitle_tracks": "Tracks",
"subtitle_search": "Search & Download",
"download": "Download",
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
"using_jellyfin_server": "Using Jellyfin Server",
"language": "Language",
"results": "Results",
"searching": "Searching...",
"search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found",
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
"settings": "Settings",
"skip_intro": "Skip Intro",
"skip_credits": "Skip Credits",
"stopPlayback": "Stop Playback",
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
"stopPlayingConfirm": "Are you sure you want to stop playback?",
"downloaded": "Downloaded"
},
"chapters": {
"title": "Chapters",
"chapter_number": "Chapter {{number}}",

View File

@@ -4,6 +4,9 @@
"error_title": "Error",
"login_title": "Iniciar sesión",
"login_to_title": "Iniciar sesión en",
"select_user": "Select a user to log in",
"add_user_to_login": "Add a user to log in",
"add_user": "Add User",
"username_placeholder": "Nombre de usuario",
"password_placeholder": "Contraseña",
"login_button": "Iniciar sesión",
@@ -42,7 +45,13 @@
"accounts_count": "{{count}} cuentas",
"select_account": "Seleccione una cuenta",
"add_account": "Añadir cuenta",
"remove_account_description": "Esto eliminará las credenciales guardadas para {{username}}."
"remove_account_description": "Esto eliminará las credenciales guardadas para {{username}}.",
"remove_server": "Remove Server",
"remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
"select_your_server": "Select Your Server",
"add_server_to_get_started": "Add a server to get started",
"add_server": "Add Server",
"change_server": "Change Server"
},
"save_account": {
"title": "Guardar Cuenta",
@@ -86,6 +95,7 @@
"oops": "¡Vaya!",
"error_message": "Algo ha salido mal.\nPor favor, cierra la sesión y vuelve a iniciar.",
"continue_watching": "Seguir viendo",
"continue": "Continue",
"next_up": "A continuación",
"continue_and_next_up": "Continuar y siguiente",
"recently_added_in": "Recientemente añadido en {{libraryName}}",
@@ -109,6 +119,12 @@
"settings": {
"settings_title": "Configuración",
"log_out_button": "Cerrar sesión",
"switch_user": {
"title": "Switch User",
"account": "Account",
"switch_user": "Switch User on This Server",
"current": "current"
},
"categories": {
"title": "Categorías"
},
@@ -121,7 +137,16 @@
"appearance": {
"title": "Apariencia",
"merge_next_up_continue_watching": "Fusionar continuar viendo y siguiente",
"hide_remote_session_button": "Ocultar botón de sesión remota"
"hide_remote_session_button": "Ocultar botón de sesión remota",
"show_home_backdrop": "Dynamic Home Backdrop",
"show_hero_carousel": "Hero Carousel",
"show_series_poster_on_episode": "Show Series Poster on Episodes",
"theme_music": "Theme Music",
"display_size": "Display Size",
"display_size_small": "Small",
"display_size_default": "Default",
"display_size_large": "Large",
"display_size_extra_large": "Extra Large"
},
"network": {
"title": "Cadena",
@@ -174,6 +199,22 @@
"rewind_length": "Longitud de retroceso",
"seconds_unit": "s"
},
"buffer": {
"title": "Buffer Settings",
"cache_mode": "Cache Mode",
"cache_auto": "Auto",
"cache_yes": "Enabled",
"cache_no": "Disabled",
"buffer_duration": "Buffer Duration",
"max_cache_size": "Max Cache Size",
"max_backward_cache": "Max Backward Cache"
},
"vo_driver": {
"title": "Video Output",
"vo_mode": "VO Driver",
"gpu_next": "gpu-next (Recommended)",
"gpu": "gpu"
},
"gesture_controls": {
"gesture_controls_title": "Controles de gestos",
"horizontal_swipe_skip": "Deslizar horizontal para omitir",
@@ -256,7 +297,23 @@
"subtitle_font": "Fuente de los subtítulos",
"ksplayer_title": "Ajustes de KSPlayer",
"hardware_decode": "Decodificación de hardware",
"hardware_decode_description": "Utilizar la aceleración de hardware para la decodificación de vídeo. Deshabilite si experimenta problemas de reproducción."
"hardware_decode_description": "Utilizar la aceleración de hardware para la decodificación de vídeo. Deshabilite si experimenta problemas de reproducción.",
"opensubtitles_title": "OpenSubtitles",
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
"opensubtitles_api_key": "API Key",
"opensubtitles_api_key_placeholder": "Enter API key...",
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
"mpv_subtitle_scale": "Subtitle Scale",
"mpv_subtitle_margin_y": "Vertical Margin",
"mpv_subtitle_align_x": "Horizontal Align",
"mpv_subtitle_align_y": "Vertical Align",
"align": {
"left": "Left",
"center": "Center",
"right": "Right",
"top": "Top",
"bottom": "Bottom"
}
},
"vlc_subtitles": {
"title": "Configuración de subtítulos VLC",
@@ -406,7 +463,13 @@
"music_cache_cleared": "Caché de música eliminado",
"delete_all_downloaded_songs": "Eliminar todas las descargas",
"downloaded_songs_size": "{{tamaño}} descargado",
"downloaded_songs_deleted": "Canciones descargadas eliminadas"
"downloaded_songs_deleted": "Canciones descargadas eliminadas",
"clear_all_cache": "Clear All Cache",
"clear_all_cache_confirm": "Clear All Cache?",
"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.",
"clear_all_cache_success": "Cache Cleared",
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
},
"intro": {
"title": "Intro",
@@ -430,6 +493,21 @@
"error_deleting_files": "Error al eliminar archivos",
"background_downloads_enabled": "Descargas en segundo plano habilitadas",
"background_downloads_disabled": "Descargas en segundo plano deshabilitadas"
},
"security": {
"title": "Security",
"inactivity_timeout": {
"title": "Inactivity Timeout",
"description": "Auto logout after inactivity",
"disabled": "Disabled",
"1_minute": "1 minute",
"5_minutes": "5 minutes",
"15_minutes": "15 minutes",
"30_minutes": "30 minutes",
"1_hour": "1 hour",
"4_hours": "4 hours",
"24_hours": "24 hours"
}
}
},
"sessions": {
@@ -456,6 +534,7 @@
"new_app_version_requires_re_download_description": "La nueva actualización requiere volver a descargar el contenido. Por favor, elimina todo el código descargado y vuélvelo a intentar.",
"back": "Atrás",
"delete": "Borrar",
"delete_download": "Delete Download",
"something_went_wrong": "Algo ha salido mal",
"could_not_get_stream_url_from_jellyfin": "No se pudo obtener la URL del stream de Jellyfin",
"eta": "{{eta}} restante",
@@ -492,22 +571,28 @@
}
},
"common": {
"no_results": "No Results",
"select": "Seleccionar",
"no_trailer_available": "No hay tráiler disponible",
"video": "Vídeo",
"audio": "Audio",
"subtitle": "Subtítulos",
"play": "Jugar",
"mark_as_played": "Mark as Played",
"mark_as_not_played": "Mark as not Played",
"none": "Nada",
"track": "Pista",
"cancel": "Cancelar",
"stop": "Stop",
"delete": "Borrar",
"ok": "Aceptar",
"remove": "Eliminar",
"next": "Siguiente",
"back": "Atrás",
"continue": "Continuar",
"verifying": "Verificando..."
"verifying": "Verificando...",
"login": "Login",
"refresh": "Refresh"
},
"search": {
"search": "Buscar...",
@@ -556,6 +641,7 @@
"movies": "Películas",
"series": "Series",
"boxsets": "Colecciones",
"playlists": "Playlists",
"items": "Elementos"
},
"options": {
@@ -566,7 +652,8 @@
"poster": "Póster",
"cover": "Portada",
"show_titles": "Mostrar títulos",
"show_stats": "Mostrar estadísticas"
"show_stats": "Mostrar estadísticas",
"options_title": "Options"
},
"filters": {
"genres": "Géneros",
@@ -574,7 +661,11 @@
"sort_by": "Ordenar por",
"filter_by": "Filtrar por",
"sort_order": "Ordenar",
"tags": "Etiquetas"
"tags": "Etiquetas",
"all": "All",
"reset": "Reset",
"asc": "Ascending",
"desc": "Descending"
}
},
"favorites": {
@@ -591,6 +682,8 @@
"no_links": "Sin enlaces"
},
"player": {
"live": "LIVE",
"mpv_player_title": "MPV Player",
"error": "Error",
"failed_to_get_stream_url": "Error al obtener la URL del Steam",
"an_error_occured_while_playing_the_video": "Ha ocurrido un error al reproducir el vídeo. Comprueba los registros en la configuración.",
@@ -608,7 +701,35 @@
"downloaded_file_message": "¿Quieres reproducir el archivo descargado?",
"downloaded_file_yes": "Sí",
"downloaded_file_no": "No",
"downloaded_file_cancel": "Cancelar"
"downloaded_file_cancel": "Cancelar",
"swipe_down_settings": "Swipe down for settings",
"ends_at": "Ends at {{time}}",
"search_subtitles": "Search Subtitles",
"subtitle_tracks": "Tracks",
"subtitle_search": "Search & Download",
"download": "Download",
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
"using_jellyfin_server": "Using Jellyfin Server",
"language": "Language",
"results": "Results",
"searching": "Searching...",
"search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found",
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
"settings": "Settings",
"skip_intro": "Skip Intro",
"skip_credits": "Skip Credits",
"stopPlayback": "Stop Playback",
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
"stopPlayingConfirm": "Are you sure you want to stop playback?",
"downloaded": "Downloaded"
},
"chapters": {
"title": "Chapters",
"chapter_number": "Chapter {{number}}",
"open": "Open chapters",
"close": "Close chapters"
},
"item_card": {
"next_up": "A continuación",
@@ -617,6 +738,11 @@
"series": "Series",
"seasons": "Temporadas",
"season": "Temporada",
"from_this_series": "From This Series",
"more_from_this_season": "More from this Season",
"view_series": "View Series",
"view_season": "View Season",
"select_season": "Select Season",
"no_episodes_for_this_season": "No hay episodios para esta temporada",
"overview": "Resumen",
"more_with": "Más con {{name}}",
@@ -627,10 +753,21 @@
"media_options": "Opciones de medios",
"quality": "Calidad",
"audio": "Audio",
"subtitles": "Subtítulos",
"subtitles": {
"label": "Subtitle",
"none": "None",
"tracks": "Tracks"
},
"show_more": "Mostrar más",
"show_less": "Mostrar menos",
"left": "left",
"more_info": "More Info",
"director": "Director",
"cast": "Cast",
"technical_details": "Technical Details",
"appeared_in": "Apareció en",
"movies": "Movies",
"shows": "Shows",
"could_not_load_item": "No se pudo cargar el ítem",
"none": "Ninguno",
"download": {
@@ -641,7 +778,13 @@
"download_x_item": "Descargar {{item_count}} ítems",
"download_unwatched_only": "No visto",
"download_button": "Descargar"
}
},
"mark_played": "Mark as Watched",
"mark_unplayed": "Mark as Unwatched",
"resume_playback": "Resume Playback",
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
"play_from_start": "Play from Start",
"continue_from": "Continue from {{time}}"
},
"live_tv": {
"next": "Siguiente",
@@ -652,7 +795,18 @@
"movies": "Películas",
"sports": "Deportes",
"for_kids": "Para niños",
"news": "Noticias"
"news": "Noticias",
"page_of": "Page {{current}} of {{total}}",
"no_programs": "No programs available",
"no_channels": "No channels available",
"tabs": {
"programs": "Programs",
"guide": "Guide",
"channels": "Channels",
"recordings": "Recordings",
"schedule": "Schedule",
"series": "Series"
}
},
"jellyseerr": {
"confirm": "Confirmar",
@@ -697,6 +851,12 @@
"decline": "Rechazar",
"requested_by": "Solicitado por {{user}}",
"unknown_user": "Usuario desconocido",
"select": "Select",
"request_all": "Request All",
"request_seasons": "Request Seasons",
"select_seasons": "Select Seasons",
"request_selected": "Request Selected",
"n_selected": "{{count}} selected",
"toasts": {
"jellyseer_does_not_meet_requirements": "¡Jellyseer no cumple con los requisitos! Por favor, actualízalo al menos a la versión 2.0.0.",
"jellyseerr_test_failed": "La prueba de Jellyseerr ha fallado. Por favor inténtalo de nuevo.",
@@ -716,7 +876,8 @@
"search": "Buscar",
"library": "Bibliotecas",
"custom_links": "Enlaces personalizados",
"favorites": "Favoritos"
"favorites": "Favoritos",
"settings": "Settings"
},
"music": {
"title": "Música",
@@ -841,5 +1002,36 @@
"show": "Este programa",
"all": "Todos los medios (por defecto)"
}
},
"companion_login": {
"title": "Pair with TV",
"align_qr": "Align the QR code within the frame",
"enter_code_manually": "Enter code manually",
"pairing_enter_credentials": "Enter credentials for TV",
"pairing_code_label": "Pairing code",
"server": "Server",
"authorize_button": "Authorize",
"authorizing": "Authorizing...",
"scan_again": "Scan Again",
"done": "Done",
"success_title": "Authorization Sent",
"pairing_tv_connecting": "The TV is connecting to your account",
"error_title": "Authorization Failed",
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
"error_generic": "Something went wrong. Please try again.",
"error_permission_denied": "Camera permission is required to scan QR codes.",
"login_as": "Log in as {{username}}?",
"on_server": "on {{server}}",
"use_different_user": "Use a different user",
"open_settings": "Open Settings"
},
"pairing": {
"pair_with_phone": "Pair with Phone",
"pair_with_phone_title": "Login TV",
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
"waiting_for_phone": "Waiting for phone...",
"scan_with_phone": "Scan with the Streamyfin app on your phone",
"logging_in": "Logging in...",
"logging_in_description": "Connecting to your server"
}
}

View File

@@ -4,6 +4,9 @@
"error_title": "Virhe",
"login_title": "Kirjaudu sisään",
"login_to_title": "Kirjaudu sisään palveluun",
"select_user": "Select a user to log in",
"add_user_to_login": "Add a user to log in",
"add_user": "Add User",
"username_placeholder": "Käyttäjätunnus",
"password_placeholder": "Salasana",
"login_button": "Kirjaudu sisään",
@@ -42,7 +45,13 @@
"accounts_count": "{{count}} accounts",
"select_account": "Select Account",
"add_account": "Add Account",
"remove_account_description": "This will remove the saved credentials for {{username}}."
"remove_account_description": "This will remove the saved credentials for {{username}}.",
"remove_server": "Remove Server",
"remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
"select_your_server": "Select Your Server",
"add_server_to_get_started": "Add a server to get started",
"add_server": "Add Server",
"change_server": "Change Server"
},
"save_account": {
"title": "Save Account",
@@ -86,6 +95,7 @@
"oops": "Ups!",
"error_message": "Jotain meni pieleen.\nKirjaudu ulos ja takaisin sisään.",
"continue_watching": "Jatka katsomista",
"continue": "Continue",
"next_up": "Seuraavaksi",
"continue_and_next_up": "Continue & Next Up",
"recently_added_in": "Äskettäin lisätty {{libraryName}}-kirjastoon",
@@ -109,6 +119,12 @@
"settings": {
"settings_title": "Asetukset",
"log_out_button": "Kirjaudu ulos",
"switch_user": {
"title": "Switch User",
"account": "Account",
"switch_user": "Switch User on This Server",
"current": "current"
},
"categories": {
"title": "Kategoriat"
},
@@ -121,7 +137,16 @@
"appearance": {
"title": "Ulkoasu",
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
"hide_remote_session_button": "Hide Remote Session Button"
"hide_remote_session_button": "Hide Remote Session Button",
"show_home_backdrop": "Dynamic Home Backdrop",
"show_hero_carousel": "Hero Carousel",
"show_series_poster_on_episode": "Show Series Poster on Episodes",
"theme_music": "Theme Music",
"display_size": "Display Size",
"display_size_small": "Small",
"display_size_default": "Default",
"display_size_large": "Large",
"display_size_extra_large": "Extra Large"
},
"network": {
"title": "Network",
@@ -174,6 +199,22 @@
"rewind_length": "Taaksepäin hyppäämisen pituus",
"seconds_unit": "s"
},
"buffer": {
"title": "Buffer Settings",
"cache_mode": "Cache Mode",
"cache_auto": "Auto",
"cache_yes": "Enabled",
"cache_no": "Disabled",
"buffer_duration": "Buffer Duration",
"max_cache_size": "Max Cache Size",
"max_backward_cache": "Max Backward Cache"
},
"vo_driver": {
"title": "Video Output",
"vo_mode": "VO Driver",
"gpu_next": "gpu-next (Recommended)",
"gpu": "gpu"
},
"gesture_controls": {
"gesture_controls_title": "Ele Ohjaus",
"horizontal_swipe_skip": "Ohita vaakatasossa pyyhkäisemällä",
@@ -256,7 +297,23 @@
"subtitle_font": "Subtitle Font",
"ksplayer_title": "KSPlayer Settings",
"hardware_decode": "Hardware Decoding",
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues."
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues.",
"opensubtitles_title": "OpenSubtitles",
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
"opensubtitles_api_key": "API Key",
"opensubtitles_api_key_placeholder": "Enter API key...",
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
"mpv_subtitle_scale": "Subtitle Scale",
"mpv_subtitle_margin_y": "Vertical Margin",
"mpv_subtitle_align_x": "Horizontal Align",
"mpv_subtitle_align_y": "Vertical Align",
"align": {
"left": "Left",
"center": "Center",
"right": "Right",
"top": "Top",
"bottom": "Bottom"
}
},
"vlc_subtitles": {
"title": "VLC Subtitle Settings",
@@ -406,7 +463,13 @@
"music_cache_cleared": "Music cache cleared",
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
"downloaded_songs_size": "{{size}} downloaded",
"downloaded_songs_deleted": "Downloaded songs deleted"
"downloaded_songs_deleted": "Downloaded songs deleted",
"clear_all_cache": "Clear All Cache",
"clear_all_cache_confirm": "Clear All Cache?",
"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.",
"clear_all_cache_success": "Cache Cleared",
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
},
"intro": {
"title": "Esittely",
@@ -430,6 +493,21 @@
"error_deleting_files": "Virhe tiedostojen poistamisessa",
"background_downloads_enabled": "Taustalataukset käytössä",
"background_downloads_disabled": "Taustalataukset pois käytöstä"
},
"security": {
"title": "Security",
"inactivity_timeout": {
"title": "Inactivity Timeout",
"description": "Auto logout after inactivity",
"disabled": "Disabled",
"1_minute": "1 minute",
"5_minutes": "5 minutes",
"15_minutes": "15 minutes",
"30_minutes": "30 minutes",
"1_hour": "1 hour",
"4_hours": "4 hours",
"24_hours": "24 hours"
}
}
},
"sessions": {
@@ -456,6 +534,7 @@
"new_app_version_requires_re_download_description": "Uusi päivitys vaatii sisällön lataamista uudelleen. Poista kaikki ladattu sisältö ja yritä uudelleen.",
"back": "Takaisin",
"delete": "Poista",
"delete_download": "Delete Download",
"something_went_wrong": "Jotain meni pieleen",
"could_not_get_stream_url_from_jellyfin": "Ei voitu saada suoratoiston URL:ia Jellyfinilta",
"eta": "Arvio {{eta}}",
@@ -492,22 +571,28 @@
}
},
"common": {
"no_results": "No Results",
"select": "Valitse",
"no_trailer_available": "Perävaunua ei saatavilla",
"video": "Video",
"audio": "Ääni",
"subtitle": "Tekstitys",
"play": "Toista",
"mark_as_played": "Mark as Played",
"mark_as_not_played": "Mark as not Played",
"none": "Ei mitään",
"track": "Track",
"cancel": "Cancel",
"stop": "Stop",
"delete": "Delete",
"ok": "OK",
"remove": "Remove",
"next": "Next",
"back": "Back",
"continue": "Continue",
"verifying": "Verifying..."
"verifying": "Verifying...",
"login": "Login",
"refresh": "Refresh"
},
"search": {
"search": "Haku...",
@@ -556,6 +641,7 @@
"movies": "elokuvat",
"series": "sarjat",
"boxsets": "bokset",
"playlists": "Playlists",
"items": "kohteet"
},
"options": {
@@ -566,7 +652,8 @@
"poster": "Juliste",
"cover": "Kansi",
"show_titles": "Näytä otsikot",
"show_stats": "Näytä tilastot"
"show_stats": "Näytä tilastot",
"options_title": "Options"
},
"filters": {
"genres": "Genret",
@@ -574,7 +661,11 @@
"sort_by": "Lajittele",
"filter_by": "Filter By",
"sort_order": "Lajittelujärjestys",
"tags": "Tunnisteet"
"tags": "Tunnisteet",
"all": "All",
"reset": "Reset",
"asc": "Ascending",
"desc": "Descending"
}
},
"favorites": {
@@ -591,6 +682,8 @@
"no_links": "Ei Linkkejä"
},
"player": {
"live": "LIVE",
"mpv_player_title": "MPV Player",
"error": "Virhe",
"failed_to_get_stream_url": "Lähetyksen URL-osoitteen haku epäonnistui",
"an_error_occured_while_playing_the_video": "Videon toiston yhteydessä tapahtui virhe. Tarkista lokit asetuksista.",
@@ -608,7 +701,35 @@
"downloaded_file_message": "Haluatko toistaa ladatun tiedoston?",
"downloaded_file_yes": "Kyllä",
"downloaded_file_no": "Ei",
"downloaded_file_cancel": "Peruuta"
"downloaded_file_cancel": "Peruuta",
"swipe_down_settings": "Swipe down for settings",
"ends_at": "Ends at {{time}}",
"search_subtitles": "Search Subtitles",
"subtitle_tracks": "Tracks",
"subtitle_search": "Search & Download",
"download": "Download",
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
"using_jellyfin_server": "Using Jellyfin Server",
"language": "Language",
"results": "Results",
"searching": "Searching...",
"search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found",
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
"settings": "Settings",
"skip_intro": "Skip Intro",
"skip_credits": "Skip Credits",
"stopPlayback": "Stop Playback",
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
"stopPlayingConfirm": "Are you sure you want to stop playback?",
"downloaded": "Downloaded"
},
"chapters": {
"title": "Chapters",
"chapter_number": "Chapter {{number}}",
"open": "Open chapters",
"close": "Close chapters"
},
"item_card": {
"next_up": "Seuraavaksi",
@@ -617,6 +738,11 @@
"series": "Sarjat",
"seasons": "Kaudet",
"season": "Kausi",
"from_this_series": "From This Series",
"more_from_this_season": "More from this Season",
"view_series": "View Series",
"view_season": "View Season",
"select_season": "Select Season",
"no_episodes_for_this_season": "Ei jaksoja tälle kaudelle",
"overview": "Yleiskatsaus",
"more_with": "Enemmän {{name}} kanssa",
@@ -627,10 +753,21 @@
"media_options": "Media-asetukset",
"quality": "Laatu",
"audio": "Ääni",
"subtitles": "Tekstitys",
"subtitles": {
"label": "Subtitle",
"none": "None",
"tracks": "Tracks"
},
"show_more": "Näytä Lisää",
"show_less": "Näytä Vähemmän",
"left": "left",
"more_info": "More Info",
"director": "Director",
"cast": "Cast",
"technical_details": "Technical Details",
"appeared_in": "Esiintyy Sisään",
"movies": "Movies",
"shows": "Shows",
"could_not_load_item": "Kohdetta Ei Voitu Ladata",
"none": "Ei mitään",
"download": {
@@ -641,7 +778,13 @@
"download_x_item": "Lataa {{item_count}} Kohteita",
"download_unwatched_only": "Vain Katsomattomat",
"download_button": "Lataa"
}
},
"mark_played": "Mark as Watched",
"mark_unplayed": "Mark as Unwatched",
"resume_playback": "Resume Playback",
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
"play_from_start": "Play from Start",
"continue_from": "Continue from {{time}}"
},
"live_tv": {
"next": "Seuraava",
@@ -652,7 +795,18 @@
"movies": "Elokuvat",
"sports": "Urheilu",
"for_kids": "Lapsille",
"news": "Uutiset"
"news": "Uutiset",
"page_of": "Page {{current}} of {{total}}",
"no_programs": "No programs available",
"no_channels": "No channels available",
"tabs": {
"programs": "Programs",
"guide": "Guide",
"channels": "Channels",
"recordings": "Recordings",
"schedule": "Schedule",
"series": "Series"
}
},
"jellyseerr": {
"confirm": "Vahvista",
@@ -697,6 +851,12 @@
"decline": "Hylkää",
"requested_by": "Käyttäjän {{user}} pyynnöstä",
"unknown_user": "Tuntematon käyttäjä",
"select": "Select",
"request_all": "Request All",
"request_seasons": "Request Seasons",
"select_seasons": "Select Seasons",
"request_selected": "Request Selected",
"n_selected": "{{count}} selected",
"toasts": {
"jellyseer_does_not_meet_requirements": "Jellyseerr-palvelin ei täytä vähimmäisversiovaatimuksia! Päivitä vähintään versioon 2.0.0",
"jellyseerr_test_failed": "Jellyseerr-testi epäonnistui. Yritä uudelleen.",
@@ -716,7 +876,8 @@
"search": "Haku",
"library": "Kirjasto",
"custom_links": "Mukautetut linkit",
"favorites": "Suosikit"
"favorites": "Suosikit",
"settings": "Settings"
},
"music": {
"title": "Music",
@@ -841,5 +1002,36 @@
"show": "This show",
"all": "All media (default)"
}
},
"companion_login": {
"title": "Pair with TV",
"align_qr": "Align the QR code within the frame",
"enter_code_manually": "Enter code manually",
"pairing_enter_credentials": "Enter credentials for TV",
"pairing_code_label": "Pairing code",
"server": "Server",
"authorize_button": "Authorize",
"authorizing": "Authorizing...",
"scan_again": "Scan Again",
"done": "Done",
"success_title": "Authorization Sent",
"pairing_tv_connecting": "The TV is connecting to your account",
"error_title": "Authorization Failed",
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
"error_generic": "Something went wrong. Please try again.",
"error_permission_denied": "Camera permission is required to scan QR codes.",
"login_as": "Log in as {{username}}?",
"on_server": "on {{server}}",
"use_different_user": "Use a different user",
"open_settings": "Open Settings"
},
"pairing": {
"pair_with_phone": "Pair with Phone",
"pair_with_phone_title": "Login TV",
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
"waiting_for_phone": "Waiting for phone...",
"scan_with_phone": "Scan with the Streamyfin app on your phone",
"logging_in": "Logging in...",
"logging_in_description": "Connecting to your server"
}
}

View File

@@ -4,6 +4,9 @@
"error_title": "Erreur",
"login_title": "Se connecter",
"login_to_title": "Se connecter à",
"select_user": "Select a user to log in",
"add_user_to_login": "Add a user to log in",
"add_user": "Add User",
"username_placeholder": "Nom d'utilisateur",
"password_placeholder": "Mot de passe",
"login_button": "Se connecter",
@@ -42,7 +45,13 @@
"accounts_count": "Comptes {{count}}",
"select_account": "Sélectionnez un compte",
"add_account": "Ajouter un compte",
"remove_account_description": "Cela supprimera les identifiants enregistrés pour {{username}}."
"remove_account_description": "Cela supprimera les identifiants enregistrés pour {{username}}.",
"remove_server": "Remove Server",
"remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
"select_your_server": "Select Your Server",
"add_server_to_get_started": "Add a server to get started",
"add_server": "Add Server",
"change_server": "Change Server"
},
"save_account": {
"title": "Sauvegarder le compte",
@@ -86,6 +95,7 @@
"oops": "Oups!",
"error_message": "Quelque chose s'est mal passé.\nVeuillez vous reconnecter à nouveau.",
"continue_watching": "Continuer à regarder",
"continue": "Continue",
"next_up": "À suivre",
"continue_and_next_up": "Continuer de regarder et à suivre",
"recently_added_in": "Ajoutés récemment dans {{libraryName}}",
@@ -109,6 +119,12 @@
"settings": {
"settings_title": "Paramètres",
"log_out_button": "Déconnexion",
"switch_user": {
"title": "Switch User",
"account": "Account",
"switch_user": "Switch User on This Server",
"current": "current"
},
"categories": {
"title": "Catégories"
},
@@ -121,7 +137,16 @@
"appearance": {
"title": "Apparence",
"merge_next_up_continue_watching": "Fusionner, continuer à regarder et à suivre",
"hide_remote_session_button": "Masquer le bouton de session distante"
"hide_remote_session_button": "Masquer le bouton de session distante",
"show_home_backdrop": "Dynamic Home Backdrop",
"show_hero_carousel": "Hero Carousel",
"show_series_poster_on_episode": "Show Series Poster on Episodes",
"theme_music": "Theme Music",
"display_size": "Display Size",
"display_size_small": "Small",
"display_size_default": "Default",
"display_size_large": "Large",
"display_size_extra_large": "Extra Large"
},
"network": {
"title": "Réseau",
@@ -174,6 +199,22 @@
"rewind_length": "Durée de retour en arrière",
"seconds_unit": "s"
},
"buffer": {
"title": "Buffer Settings",
"cache_mode": "Cache Mode",
"cache_auto": "Auto",
"cache_yes": "Enabled",
"cache_no": "Disabled",
"buffer_duration": "Buffer Duration",
"max_cache_size": "Max Cache Size",
"max_backward_cache": "Max Backward Cache"
},
"vo_driver": {
"title": "Video Output",
"vo_mode": "VO Driver",
"gpu_next": "gpu-next (Recommended)",
"gpu": "gpu"
},
"gesture_controls": {
"gesture_controls_title": "Commandes gestuelles",
"horizontal_swipe_skip": "Glisser horizontalement pour passer",
@@ -256,7 +297,23 @@
"subtitle_font": "Police des sous-titres",
"ksplayer_title": "Paramètres de KSPlayer",
"hardware_decode": "Décodage matériel",
"hardware_decode_description": "Utilisez laccélération matérielle pour le décodage vidéo. Désactivez si vous rencontrez des problèmes de lecture."
"hardware_decode_description": "Utilisez laccélération matérielle pour le décodage vidéo. Désactivez si vous rencontrez des problèmes de lecture.",
"opensubtitles_title": "OpenSubtitles",
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
"opensubtitles_api_key": "API Key",
"opensubtitles_api_key_placeholder": "Enter API key...",
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
"mpv_subtitle_scale": "Subtitle Scale",
"mpv_subtitle_margin_y": "Vertical Margin",
"mpv_subtitle_align_x": "Horizontal Align",
"mpv_subtitle_align_y": "Vertical Align",
"align": {
"left": "Left",
"center": "Center",
"right": "Right",
"top": "Top",
"bottom": "Bottom"
}
},
"vlc_subtitles": {
"title": "Paramètres des sous-titres VLC",
@@ -406,7 +463,13 @@
"music_cache_cleared": "Cache de musique effacé",
"delete_all_downloaded_songs": "Supprimer toutes les musiques téléchargées",
"downloaded_songs_size": "{{size}} téléchargé",
"downloaded_songs_deleted": "Chansons téléchargées supprimées"
"downloaded_songs_deleted": "Chansons téléchargées supprimées",
"clear_all_cache": "Clear All Cache",
"clear_all_cache_confirm": "Clear All Cache?",
"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.",
"clear_all_cache_success": "Cache Cleared",
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
},
"intro": {
"title": "Introduction",
@@ -430,6 +493,21 @@
"error_deleting_files": "Erreur lors de la suppression des fichiers",
"background_downloads_enabled": "Téléchargements en arrière-plan activés",
"background_downloads_disabled": "Téléchargements en arrière-plan désactivés"
},
"security": {
"title": "Security",
"inactivity_timeout": {
"title": "Inactivity Timeout",
"description": "Auto logout after inactivity",
"disabled": "Disabled",
"1_minute": "1 minute",
"5_minutes": "5 minutes",
"15_minutes": "15 minutes",
"30_minutes": "30 minutes",
"1_hour": "1 hour",
"4_hours": "4 hours",
"24_hours": "24 hours"
}
}
},
"sessions": {
@@ -456,6 +534,7 @@
"new_app_version_requires_re_download_description": "La nouvelle mise à jour nécessite que le contenu soit téléchargé à nouveau. Veuillez supprimer tout le contenu téléchargé et réessayer.",
"back": "Retour",
"delete": "Supprimer",
"delete_download": "Delete Download",
"something_went_wrong": "Quelque chose s'est mal passé",
"could_not_get_stream_url_from_jellyfin": "Impossible d'obtenir l'URL du flux depuis Jellyfin",
"eta": "ETA {{eta}}",
@@ -492,22 +571,28 @@
}
},
"common": {
"no_results": "No Results",
"select": "Sélectionner",
"no_trailer_available": "Aucune bande-annonce disponible",
"video": "Vidéo",
"audio": "Audio",
"subtitle": "Sous-titres",
"play": "Lecture",
"mark_as_played": "Mark as Played",
"mark_as_not_played": "Mark as not Played",
"none": "Aucun",
"track": "Suivre",
"cancel": "Annuler",
"stop": "Stop",
"delete": "Supprimer",
"ok": "Ok",
"remove": "Retirer",
"next": "Suivant",
"back": "Précédent",
"continue": "Continuer",
"verifying": "Vérification..."
"verifying": "Vérification...",
"login": "Login",
"refresh": "Refresh"
},
"search": {
"search": "Rechercher...",
@@ -556,6 +641,7 @@
"movies": "Films",
"series": "Séries",
"boxsets": "Coffrets ",
"playlists": "Playlists",
"items": "Médias"
},
"options": {
@@ -566,7 +652,8 @@
"poster": "Affiche",
"cover": "Couverture",
"show_titles": "Afficher les titres",
"show_stats": "Afficher les statistiques"
"show_stats": "Afficher les statistiques",
"options_title": "Options"
},
"filters": {
"genres": "Genres",
@@ -574,7 +661,11 @@
"sort_by": "Trier par",
"filter_by": "Filtrer par",
"sort_order": "Ordre de tri",
"tags": "Tags"
"tags": "Tags",
"all": "All",
"reset": "Reset",
"asc": "Ascending",
"desc": "Descending"
}
},
"favorites": {
@@ -591,6 +682,8 @@
"no_links": "Aucuns liens"
},
"player": {
"live": "LIVE",
"mpv_player_title": "MPV Player",
"error": "Erreur",
"failed_to_get_stream_url": "Échec de l'obtention de l'URL du flux",
"an_error_occured_while_playing_the_video": "Une erreur sest produite lors de la lecture de la vidéo. Vérifiez les journaux dans les paramètres.",
@@ -608,7 +701,35 @@
"downloaded_file_message": "Voulez-vous lire le fichier téléchargé ?",
"downloaded_file_yes": "Oui",
"downloaded_file_no": "Non",
"downloaded_file_cancel": "Annuler"
"downloaded_file_cancel": "Annuler",
"swipe_down_settings": "Swipe down for settings",
"ends_at": "Ends at {{time}}",
"search_subtitles": "Search Subtitles",
"subtitle_tracks": "Tracks",
"subtitle_search": "Search & Download",
"download": "Download",
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
"using_jellyfin_server": "Using Jellyfin Server",
"language": "Language",
"results": "Results",
"searching": "Searching...",
"search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found",
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
"settings": "Settings",
"skip_intro": "Skip Intro",
"skip_credits": "Skip Credits",
"stopPlayback": "Stop Playback",
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
"stopPlayingConfirm": "Are you sure you want to stop playback?",
"downloaded": "Downloaded"
},
"chapters": {
"title": "Chapters",
"chapter_number": "Chapter {{number}}",
"open": "Open chapters",
"close": "Close chapters"
},
"item_card": {
"next_up": "À suivre",
@@ -617,6 +738,11 @@
"series": "Séries",
"seasons": "Saisons",
"season": "Saison",
"from_this_series": "From This Series",
"more_from_this_season": "More from this Season",
"view_series": "View Series",
"view_season": "View Season",
"select_season": "Select Season",
"no_episodes_for_this_season": "Aucun épisode pour cette saison",
"overview": "Aperçu",
"more_with": "Plus avec {{name}}",
@@ -627,10 +753,21 @@
"media_options": "Options média",
"quality": "Qualité",
"audio": "Audio",
"subtitles": "Sous-titres",
"subtitles": {
"label": "Subtitle",
"none": "None",
"tracks": "Tracks"
},
"show_more": "Afficher plus",
"show_less": "Afficher moins",
"left": "left",
"more_info": "More Info",
"director": "Director",
"cast": "Cast",
"technical_details": "Technical Details",
"appeared_in": "Apparu dans",
"movies": "Movies",
"shows": "Shows",
"could_not_load_item": "Impossible de charger le média",
"none": "Aucun",
"download": {
@@ -641,7 +778,13 @@
"download_x_item": "Télécharger {{item_count}} médias",
"download_unwatched_only": "Non visionné uniquement",
"download_button": "Télécharger"
}
},
"mark_played": "Mark as Watched",
"mark_unplayed": "Mark as Unwatched",
"resume_playback": "Resume Playback",
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
"play_from_start": "Play from Start",
"continue_from": "Continue from {{time}}"
},
"live_tv": {
"next": "Suivant",
@@ -652,7 +795,18 @@
"movies": "Films",
"sports": "Sports",
"for_kids": "Pour enfants",
"news": "Actualités"
"news": "Actualités",
"page_of": "Page {{current}} of {{total}}",
"no_programs": "No programs available",
"no_channels": "No channels available",
"tabs": {
"programs": "Programs",
"guide": "Guide",
"channels": "Channels",
"recordings": "Recordings",
"schedule": "Schedule",
"series": "Series"
}
},
"jellyseerr": {
"confirm": "Confirmer",
@@ -697,6 +851,12 @@
"decline": "Refuser",
"requested_by": "Demandé par {{user}}",
"unknown_user": "Utilisateur inconnu",
"select": "Select",
"request_all": "Request All",
"request_seasons": "Request Seasons",
"select_seasons": "Select Seasons",
"request_selected": "Request Selected",
"n_selected": "{{count}} selected",
"toasts": {
"jellyseer_does_not_meet_requirements": "Seerr ne répond pas aux exigences! Veuillez mettre à jour au moins vers la version 2.0.0.",
"jellyseerr_test_failed": "Le test Seerr a échoué. Veuillez réessayer.",
@@ -716,7 +876,8 @@
"search": "Recherche",
"library": "Bibliothèque",
"custom_links": "Liens personnalisés",
"favorites": "Favoris"
"favorites": "Favoris",
"settings": "Settings"
},
"music": {
"title": "Musique",
@@ -788,8 +949,8 @@
}
},
"watchlists": {
"title": "Watchlists",
"my_watchlists": "My Watchlists",
"title": "Listes de lecture",
"my_watchlists": "Mes listes de lecture",
"public_watchlists": "Watchlist publique",
"create_title": "Créer une Watchlist",
"edit_title": "Modifier la Watchlist",
@@ -802,7 +963,7 @@
"name_placeholder": "Entrer le nom de la playlist",
"description_label": "Description",
"description_placeholder": "Entrez la description (facultatif)",
"is_public_label": "Public Watchlist",
"is_public_label": "Liste de lecture Publique",
"is_public_description": "Autoriser d'autres personnes à voir cette liste de suivi",
"allowed_type_label": "Type de contenu",
"sort_order_label": "Ordre de tri par défaut",
@@ -841,5 +1002,36 @@
"show": "Cette série",
"all": "Tous les médias (par défaut)"
}
},
"companion_login": {
"title": "Pair with TV",
"align_qr": "Align the QR code within the frame",
"enter_code_manually": "Enter code manually",
"pairing_enter_credentials": "Enter credentials for TV",
"pairing_code_label": "Pairing code",
"server": "Server",
"authorize_button": "Authorize",
"authorizing": "Authorizing...",
"scan_again": "Scan Again",
"done": "Done",
"success_title": "Authorization Sent",
"pairing_tv_connecting": "The TV is connecting to your account",
"error_title": "Authorization Failed",
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
"error_generic": "Something went wrong. Please try again.",
"error_permission_denied": "Camera permission is required to scan QR codes.",
"login_as": "Log in as {{username}}?",
"on_server": "on {{server}}",
"use_different_user": "Use a different user",
"open_settings": "Open Settings"
},
"pairing": {
"pair_with_phone": "Pair with Phone",
"pair_with_phone_title": "Login TV",
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
"waiting_for_phone": "Waiting for phone...",
"scan_with_phone": "Scan with the Streamyfin app on your phone",
"logging_in": "Logging in...",
"logging_in_description": "Connecting to your server"
}
}

View File

@@ -4,6 +4,9 @@
"error_title": "שגיאה",
"login_title": "התחבר",
"login_to_title": "התחבר אל",
"select_user": "Select a user to log in",
"add_user_to_login": "Add a user to log in",
"add_user": "Add User",
"username_placeholder": "שם משתמש",
"password_placeholder": "סיסמה",
"login_button": "התחבר",
@@ -39,10 +42,16 @@
"please_login_again": "Your saved session has expired. Please log in again.",
"remove_saved_login": "Remove Saved Login",
"remove_saved_login_description": "This will remove your saved credentials for this server. You'll need to enter your username and password again next time.",
"accounts_count": "{{count}} accounts",
"accounts_count": "{{count}} חשבונות",
"select_account": "Select Account",
"add_account": "Add Account",
"remove_account_description": "This will remove the saved credentials for {{username}}."
"remove_account_description": "This will remove the saved credentials for {{username}}.",
"remove_server": "Remove Server",
"remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
"select_your_server": "Select Your Server",
"add_server_to_get_started": "Add a server to get started",
"add_server": "Add Server",
"change_server": "Change Server"
},
"save_account": {
"title": "Save Account",
@@ -86,6 +95,7 @@
"oops": "אופס!",
"error_message": "קרתה תקלה. אנא התנתק והתחבר מחדש.",
"continue_watching": "המשך לצפות",
"continue": "Continue",
"next_up": "הבא בתור",
"continue_and_next_up": "Continue & Next Up",
"recently_added_in": "התווסף לאחרונה ב-{{libraryName}}",
@@ -109,19 +119,34 @@
"settings": {
"settings_title": "הגדרות",
"log_out_button": "התנתק",
"switch_user": {
"title": "Switch User",
"account": "Account",
"switch_user": "Switch User on This Server",
"current": "current"
},
"categories": {
"title": "Categories"
"title": "קטגוריות"
},
"playback_controls": {
"title": "Playback & Controls"
},
"audio_subtitles": {
"title": "Audio & Subtitles"
"title": "שמע וכתוביות"
},
"appearance": {
"title": "Appearance",
"title": "מראה",
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
"hide_remote_session_button": "Hide Remote Session Button"
"hide_remote_session_button": "Hide Remote Session Button",
"show_home_backdrop": "Dynamic Home Backdrop",
"show_hero_carousel": "Hero Carousel",
"show_series_poster_on_episode": "Show Series Poster on Episodes",
"theme_music": "Theme Music",
"display_size": "Display Size",
"display_size_small": "Small",
"display_size_default": "Default",
"display_size_large": "Large",
"display_size_extra_large": "Extra Large"
},
"network": {
"title": "Network",
@@ -174,6 +199,22 @@
"rewind_length": "אורך הזזה אחורה",
"seconds_unit": "שנ'"
},
"buffer": {
"title": "Buffer Settings",
"cache_mode": "Cache Mode",
"cache_auto": "Auto",
"cache_yes": "Enabled",
"cache_no": "Disabled",
"buffer_duration": "Buffer Duration",
"max_cache_size": "Max Cache Size",
"max_backward_cache": "Max Backward Cache"
},
"vo_driver": {
"title": "Video Output",
"vo_mode": "VO Driver",
"gpu_next": "gpu-next (Recommended)",
"gpu": "gpu"
},
"gesture_controls": {
"gesture_controls_title": "פקדי מחוות",
"horizontal_swipe_skip": "החלקה אופקית לדילוג",
@@ -188,7 +229,7 @@
"hide_brightness_slider_description": "Hide the brightness slider in the video player"
},
"audio": {
"audio_title": "אודיו",
"audio_title": "שמע",
"set_audio_track": "בחר רצועת שמע מהפריט הקודם",
"audio_language": "שפת שמע",
"audio_hint": "בחר שפת שמע אוטומטית.",
@@ -256,7 +297,23 @@
"subtitle_font": "Subtitle Font",
"ksplayer_title": "KSPlayer Settings",
"hardware_decode": "Hardware Decoding",
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues."
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues.",
"opensubtitles_title": "OpenSubtitles",
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
"opensubtitles_api_key": "API Key",
"opensubtitles_api_key_placeholder": "Enter API key...",
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
"mpv_subtitle_scale": "Subtitle Scale",
"mpv_subtitle_margin_y": "Vertical Margin",
"mpv_subtitle_align_x": "Horizontal Align",
"mpv_subtitle_align_y": "Vertical Align",
"align": {
"left": "Left",
"center": "Center",
"right": "Right",
"top": "Top",
"bottom": "Bottom"
}
},
"vlc_subtitles": {
"title": "VLC Subtitle Settings",
@@ -271,8 +328,8 @@
"margin": "Bottom Margin"
},
"video_player": {
"title": "Video Player",
"video_player": "Video Player",
"title": "נגן וידאו",
"video_player": "נגן וידאו",
"video_player_description": "Choose which video player to use on iOS.",
"ksplayer": "KSPlayer",
"vlc": "VLC"
@@ -314,7 +371,7 @@
"downloads_title": "הורדות"
},
"music": {
"title": "Music",
"title": "מוזיקה",
"playback_title": "Playback",
"playback_description": "Configure how music is played.",
"prefer_downloaded": "Prefer Downloaded Songs",
@@ -406,10 +463,16 @@
"music_cache_cleared": "Music cache cleared",
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
"downloaded_songs_size": "{{size}} downloaded",
"downloaded_songs_deleted": "Downloaded songs deleted"
"downloaded_songs_deleted": "Downloaded songs deleted",
"clear_all_cache": "Clear All Cache",
"clear_all_cache_confirm": "Clear All Cache?",
"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.",
"clear_all_cache_success": "Cache Cleared",
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
},
"intro": {
"title": "Intro",
"title": "הקדמה",
"show_intro": "הצג פתיח",
"reset_intro": "אפס פתיח"
},
@@ -430,6 +493,21 @@
"error_deleting_files": "שגיאה במחיקת קבצים",
"background_downloads_enabled": "הורדה ברקע מופעלת",
"background_downloads_disabled": "הורדה ברקע כבויה"
},
"security": {
"title": "Security",
"inactivity_timeout": {
"title": "Inactivity Timeout",
"description": "Auto logout after inactivity",
"disabled": "Disabled",
"1_minute": "1 minute",
"5_minutes": "5 minutes",
"15_minutes": "15 minutes",
"30_minutes": "30 minutes",
"1_hour": "1 hour",
"4_hours": "4 hours",
"24_hours": "24 hours"
}
}
},
"sessions": {
@@ -456,6 +534,7 @@
"new_app_version_requires_re_download_description": "הגרסה החדשה דורשת לתוכן לרדת מחדש. אנא מחק את כל התוכן שכבר הורדת ונסה שוב.",
"back": "חזרה",
"delete": "מחק",
"delete_download": "Delete Download",
"something_went_wrong": "משהו השתבש",
"could_not_get_stream_url_from_jellyfin": "לא היה ניתן להגיע לקישור הזרם מהשרת Jellyfin",
"eta": "זמן משוער {{eta}}",
@@ -492,22 +571,28 @@
}
},
"common": {
"no_results": "No Results",
"select": "בחר",
"no_trailer_available": "אין טריילר זמין",
"video": "וידאו",
"audio": "אודיו",
"audio": "שמע",
"subtitle": "כתובית",
"play": "נגן",
"mark_as_played": "Mark as Played",
"mark_as_not_played": "Mark as not Played",
"none": "ללא",
"track": "Track",
"cancel": "Cancel",
"stop": "Stop",
"delete": "Delete",
"ok": "OK",
"remove": "Remove",
"next": "Next",
"back": "Back",
"continue": "Continue",
"verifying": "Verifying..."
"verifying": "Verifying...",
"login": "Login",
"refresh": "Refresh"
},
"search": {
"search": "חפש...",
@@ -521,9 +606,9 @@
"episodes": "פרקים",
"collections": "אוספים",
"actors": "שחקנים",
"artists": "Artists",
"albums": "Albums",
"songs": "Songs",
"artists": "אומנים",
"albums": "אלבומים",
"songs": "שירים",
"playlists": "Playlists",
"request_movies": "סרטים מבוקשים",
"request_series": "סדרות מבוקשים",
@@ -556,6 +641,7 @@
"movies": "סרטים",
"series": "סדרות",
"boxsets": "אוסף",
"playlists": "Playlists",
"items": "פריטים"
},
"options": {
@@ -566,7 +652,8 @@
"poster": "פוסטר",
"cover": "עטיפה",
"show_titles": "הצג כותרות",
"show_stats": "הצג סטטיסטיקה"
"show_stats": "הצג סטטיסטיקה",
"options_title": "Options"
},
"filters": {
"genres": "סגנונות",
@@ -574,7 +661,11 @@
"sort_by": "מיין לפי",
"filter_by": "Filter By",
"sort_order": "סדר מיון",
"tags": "תגים"
"tags": "תגים",
"all": "All",
"reset": "Reset",
"asc": "Ascending",
"desc": "Descending"
}
},
"favorites": {
@@ -591,6 +682,8 @@
"no_links": "אין קישורים"
},
"player": {
"live": "LIVE",
"mpv_player_title": "MPV Player",
"error": "שגיאה",
"failed_to_get_stream_url": "נכשל בהשגת קישור הזרם",
"an_error_occured_while_playing_the_video": "קרתה תקלה במהלך הניגון של הקובץ. בדוק את הלוגים בהגדרות.",
@@ -606,9 +699,37 @@
"go_back": "חזור",
"downloaded_file_title": "You have this file downloaded",
"downloaded_file_message": "Do you want to play the downloaded file?",
"downloaded_file_yes": "Yes",
"downloaded_file_no": "No",
"downloaded_file_cancel": "Cancel"
"downloaded_file_yes": "כן",
"downloaded_file_no": "לא",
"downloaded_file_cancel": "Cancel",
"swipe_down_settings": "Swipe down for settings",
"ends_at": "Ends at {{time}}",
"search_subtitles": "Search Subtitles",
"subtitle_tracks": "Tracks",
"subtitle_search": "Search & Download",
"download": "Download",
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
"using_jellyfin_server": "Using Jellyfin Server",
"language": "Language",
"results": "Results",
"searching": "Searching...",
"search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found",
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
"settings": "Settings",
"skip_intro": "Skip Intro",
"skip_credits": "Skip Credits",
"stopPlayback": "Stop Playback",
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
"stopPlayingConfirm": "Are you sure you want to stop playback?",
"downloaded": "Downloaded"
},
"chapters": {
"title": "Chapters",
"chapter_number": "Chapter {{number}}",
"open": "Open chapters",
"close": "Close chapters"
},
"item_card": {
"next_up": "הבא בתור",
@@ -617,6 +738,11 @@
"series": "סדרות",
"seasons": "עונות",
"season": "עונה",
"from_this_series": "From This Series",
"more_from_this_season": "More from this Season",
"view_series": "View Series",
"view_season": "View Season",
"select_season": "Select Season",
"no_episodes_for_this_season": "אין פרקים בעונה זו",
"overview": "סקירה",
"more_with": "עוד עם {{name}}",
@@ -626,11 +752,22 @@
"more_details": "פרטים נוספים",
"media_options": "Media Options",
"quality": "איכות",
"audio": "אודיו",
"subtitles": "כתובית",
"audio": "שמע",
"subtitles": {
"label": "Subtitle",
"none": "None",
"tracks": "Tracks"
},
"show_more": "הצג עוד",
"show_less": "הצג פחות",
"left": "left",
"more_info": "More Info",
"director": "Director",
"cast": "Cast",
"technical_details": "Technical Details",
"appeared_in": "הופיע ב-",
"movies": "Movies",
"shows": "Shows",
"could_not_load_item": "נכשל בטעינת פריט",
"none": "ללא",
"download": {
@@ -641,7 +778,13 @@
"download_x_item": "הורד {{item_count}} פריטים",
"download_unwatched_only": "רק שלא נצפו",
"download_button": "הורד"
}
},
"mark_played": "Mark as Watched",
"mark_unplayed": "Mark as Unwatched",
"resume_playback": "Resume Playback",
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
"play_from_start": "Play from Start",
"continue_from": "Continue from {{time}}"
},
"live_tv": {
"next": "הבא",
@@ -652,7 +795,18 @@
"movies": "סרטים",
"sports": "ספורט",
"for_kids": "לילדים",
"news": "חדשות"
"news": "חדשות",
"page_of": "Page {{current}} of {{total}}",
"no_programs": "No programs available",
"no_channels": "No channels available",
"tabs": {
"programs": "Programs",
"guide": "Guide",
"channels": "Channels",
"recordings": "Recordings",
"schedule": "Schedule",
"series": "Series"
}
},
"jellyseerr": {
"confirm": "אשר",
@@ -697,6 +851,12 @@
"decline": "Decline",
"requested_by": "Requested by {{user}}",
"unknown_user": "Unknown User",
"select": "Select",
"request_all": "Request All",
"request_seasons": "Request Seasons",
"select_seasons": "Select Seasons",
"request_selected": "Request Selected",
"n_selected": "{{count}} selected",
"toasts": {
"jellyseer_does_not_meet_requirements": "שרת ה-Seerr לא תואם את הגרסה המינימלית הנרדת! אנא עדכן לפחות לגרסה 2.0.0",
"jellyseerr_test_failed": "בדיקת ה-Seerr נכשלה. אנא נסה שוב.",
@@ -716,13 +876,14 @@
"search": "חיפוש",
"library": "ספריה",
"custom_links": "קישורים מותאמים אישית",
"favorites": "מועדפים"
"favorites": "מועדפים",
"settings": "Settings"
},
"music": {
"title": "Music",
"title": "מוזיקה",
"tabs": {
"suggestions": "Suggestions",
"albums": "Albums",
"albums": "אלבומים",
"artists": "Artists",
"playlists": "Playlists",
"tracks": "tracks"
@@ -798,9 +959,9 @@
"delete_button": "Delete",
"remove_button": "Remove",
"cancel_button": "Cancel",
"name_label": "Name",
"name_label": "שם",
"name_placeholder": "Enter watchlist name",
"description_label": "Description",
"description_label": "תיאור",
"description_placeholder": "Enter description (optional)",
"is_public_label": "Public Watchlist",
"is_public_description": "Allow others to view this watchlist",
@@ -817,10 +978,10 @@
"remove_from_watchlist": "Remove from Watchlist",
"select_watchlist": "Select Watchlist",
"create_new": "Create New Watchlist",
"item": "item",
"items": "items",
"public": "Public",
"private": "Private",
"item": "פריט",
"items": "פריטים",
"public": "ציבורי",
"private": "פרטי",
"you": "You",
"by_owner": "By another user",
"not_found": "Watchlist not found",
@@ -835,11 +996,42 @@
"playback_speed": {
"title": "Playback Speed",
"apply_to": "Apply To",
"speed": "Speed",
"speed": "מהירות",
"scope": {
"media": "This media only",
"show": "This show",
"all": "All media (default)"
}
},
"companion_login": {
"title": "Pair with TV",
"align_qr": "Align the QR code within the frame",
"enter_code_manually": "Enter code manually",
"pairing_enter_credentials": "Enter credentials for TV",
"pairing_code_label": "Pairing code",
"server": "Server",
"authorize_button": "Authorize",
"authorizing": "Authorizing...",
"scan_again": "Scan Again",
"done": "Done",
"success_title": "Authorization Sent",
"pairing_tv_connecting": "The TV is connecting to your account",
"error_title": "Authorization Failed",
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
"error_generic": "Something went wrong. Please try again.",
"error_permission_denied": "Camera permission is required to scan QR codes.",
"login_as": "Log in as {{username}}?",
"on_server": "on {{server}}",
"use_different_user": "Use a different user",
"open_settings": "Open Settings"
},
"pairing": {
"pair_with_phone": "Pair with Phone",
"pair_with_phone_title": "Login TV",
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
"waiting_for_phone": "Waiting for phone...",
"scan_with_phone": "Scan with the Streamyfin app on your phone",
"logging_in": "Logging in...",
"logging_in_description": "Connecting to your server"
}
}

View File

@@ -4,6 +4,9 @@
"error_title": "Hiba",
"login_title": "Bejelentkezés",
"login_to_title": "Bejelentkezés ide",
"select_user": "Select a user to log in",
"add_user_to_login": "Add a user to log in",
"add_user": "Add User",
"username_placeholder": "Felhasználónév",
"password_placeholder": "Jelszó",
"login_button": "Bejelentkezés",
@@ -42,7 +45,13 @@
"accounts_count": "{{count}} accounts",
"select_account": "Select Account",
"add_account": "Add Account",
"remove_account_description": "This will remove the saved credentials for {{username}}."
"remove_account_description": "This will remove the saved credentials for {{username}}.",
"remove_server": "Remove Server",
"remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
"select_your_server": "Select Your Server",
"add_server_to_get_started": "Add a server to get started",
"add_server": "Add Server",
"change_server": "Change Server"
},
"save_account": {
"title": "Save Account",
@@ -86,6 +95,7 @@
"oops": "Hoppá!",
"error_message": "Valami nem stimmel.\nKérjük, jelentkezz ki, majd újra be.",
"continue_watching": "Nézd Tovább",
"continue": "Continue",
"next_up": "Következő",
"continue_and_next_up": "Continue & Next Up",
"recently_added_in": "Új a(z) {{libraryName}} könyvtárban",
@@ -109,6 +119,12 @@
"settings": {
"settings_title": "Beállítások",
"log_out_button": "Kijelentkezés",
"switch_user": {
"title": "Switch User",
"account": "Account",
"switch_user": "Switch User on This Server",
"current": "current"
},
"categories": {
"title": "Categories"
},
@@ -121,7 +137,16 @@
"appearance": {
"title": "Appearance",
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
"hide_remote_session_button": "Hide Remote Session Button"
"hide_remote_session_button": "Hide Remote Session Button",
"show_home_backdrop": "Dynamic Home Backdrop",
"show_hero_carousel": "Hero Carousel",
"show_series_poster_on_episode": "Show Series Poster on Episodes",
"theme_music": "Theme Music",
"display_size": "Display Size",
"display_size_small": "Small",
"display_size_default": "Default",
"display_size_large": "Large",
"display_size_extra_large": "Extra Large"
},
"network": {
"title": "Network",
@@ -174,6 +199,22 @@
"rewind_length": "Visszatekerés Hossza",
"seconds_unit": "mp"
},
"buffer": {
"title": "Buffer Settings",
"cache_mode": "Cache Mode",
"cache_auto": "Auto",
"cache_yes": "Enabled",
"cache_no": "Disabled",
"buffer_duration": "Buffer Duration",
"max_cache_size": "Max Cache Size",
"max_backward_cache": "Max Backward Cache"
},
"vo_driver": {
"title": "Video Output",
"vo_mode": "VO Driver",
"gpu_next": "gpu-next (Recommended)",
"gpu": "gpu"
},
"gesture_controls": {
"gesture_controls_title": "Gesztusvezérlés",
"horizontal_swipe_skip": "Vízszintes Húzás Ugráshoz",
@@ -256,7 +297,23 @@
"subtitle_font": "Subtitle Font",
"ksplayer_title": "KSPlayer Settings",
"hardware_decode": "Hardware Decoding",
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues."
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues.",
"opensubtitles_title": "OpenSubtitles",
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
"opensubtitles_api_key": "API Key",
"opensubtitles_api_key_placeholder": "Enter API key...",
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
"mpv_subtitle_scale": "Subtitle Scale",
"mpv_subtitle_margin_y": "Vertical Margin",
"mpv_subtitle_align_x": "Horizontal Align",
"mpv_subtitle_align_y": "Vertical Align",
"align": {
"left": "Left",
"center": "Center",
"right": "Right",
"top": "Top",
"bottom": "Bottom"
}
},
"vlc_subtitles": {
"title": "VLC Subtitle Settings",
@@ -406,7 +463,13 @@
"music_cache_cleared": "Music cache cleared",
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
"downloaded_songs_size": "{{size}} downloaded",
"downloaded_songs_deleted": "Downloaded songs deleted"
"downloaded_songs_deleted": "Downloaded songs deleted",
"clear_all_cache": "Clear All Cache",
"clear_all_cache_confirm": "Clear All Cache?",
"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.",
"clear_all_cache_success": "Cache Cleared",
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
},
"intro": {
"title": "Intro",
@@ -430,6 +493,21 @@
"error_deleting_files": "Hiba a Fájlok Törlésekor",
"background_downloads_enabled": "Background downloads enabled",
"background_downloads_disabled": "Background downloads disabled"
},
"security": {
"title": "Security",
"inactivity_timeout": {
"title": "Inactivity Timeout",
"description": "Auto logout after inactivity",
"disabled": "Disabled",
"1_minute": "1 minute",
"5_minutes": "5 minutes",
"15_minutes": "15 minutes",
"30_minutes": "30 minutes",
"1_hour": "1 hour",
"4_hours": "4 hours",
"24_hours": "24 hours"
}
}
},
"sessions": {
@@ -456,6 +534,7 @@
"new_app_version_requires_re_download_description": "Az új frissítéshez az összes tartalmat újra le kell tölteni. Kérjük, töröld az összes letöltött tartalmat, majd próbáld újra.",
"back": "Vissza",
"delete": "Törlés",
"delete_download": "Delete Download",
"something_went_wrong": "Hiba Történt",
"could_not_get_stream_url_from_jellyfin": "Nem sikerült lekérni a stream URL-t a Jellyfinből",
"eta": "Várható Idő: {{eta}}",
@@ -492,22 +571,28 @@
}
},
"common": {
"no_results": "No Results",
"select": "Select",
"no_trailer_available": "No trailer available",
"video": "Videó",
"audio": "Hang",
"subtitle": "Felirat",
"play": "Play",
"mark_as_played": "Mark as Played",
"mark_as_not_played": "Mark as not Played",
"none": "None",
"track": "Track",
"cancel": "Cancel",
"stop": "Stop",
"delete": "Delete",
"ok": "OK",
"remove": "Remove",
"next": "Next",
"back": "Back",
"continue": "Continue",
"verifying": "Verifying..."
"verifying": "Verifying...",
"login": "Login",
"refresh": "Refresh"
},
"search": {
"search": "Keresés...",
@@ -556,6 +641,7 @@
"movies": "Filmek",
"series": "Sorozatok",
"boxsets": "Gyűjtemények",
"playlists": "Playlists",
"items": "Elemek"
},
"options": {
@@ -566,7 +652,8 @@
"poster": "Poszter",
"cover": "Borító",
"show_titles": "Címek Megjelenítése",
"show_stats": "Statisztikák Megjelenítése"
"show_stats": "Statisztikák Megjelenítése",
"options_title": "Options"
},
"filters": {
"genres": "Műfajok",
@@ -574,7 +661,11 @@
"sort_by": "Rendezés",
"filter_by": "Filter By",
"sort_order": "Rendezés Iránya",
"tags": "Címkék"
"tags": "Címkék",
"all": "All",
"reset": "Reset",
"asc": "Ascending",
"desc": "Descending"
}
},
"favorites": {
@@ -591,6 +682,8 @@
"no_links": "Nincsenek Linkek"
},
"player": {
"live": "LIVE",
"mpv_player_title": "MPV Player",
"error": "Hiba",
"failed_to_get_stream_url": "Nem sikerült lekérni a stream URL-t",
"an_error_occured_while_playing_the_video": "Hiba történt a videó lejátszása közben. Ellenőrizd a naplókat a beállításokban.",
@@ -608,7 +701,35 @@
"downloaded_file_message": "Do you want to play the downloaded file?",
"downloaded_file_yes": "Yes",
"downloaded_file_no": "No",
"downloaded_file_cancel": "Cancel"
"downloaded_file_cancel": "Cancel",
"swipe_down_settings": "Swipe down for settings",
"ends_at": "Ends at {{time}}",
"search_subtitles": "Search Subtitles",
"subtitle_tracks": "Tracks",
"subtitle_search": "Search & Download",
"download": "Download",
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
"using_jellyfin_server": "Using Jellyfin Server",
"language": "Language",
"results": "Results",
"searching": "Searching...",
"search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found",
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
"settings": "Settings",
"skip_intro": "Skip Intro",
"skip_credits": "Skip Credits",
"stopPlayback": "Stop Playback",
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
"stopPlayingConfirm": "Are you sure you want to stop playback?",
"downloaded": "Downloaded"
},
"chapters": {
"title": "Chapters",
"chapter_number": "Chapter {{number}}",
"open": "Open chapters",
"close": "Close chapters"
},
"item_card": {
"next_up": "Következő",
@@ -617,6 +738,11 @@
"series": "Sorozat",
"seasons": "Évadok",
"season": "Évad",
"from_this_series": "From This Series",
"more_from_this_season": "More from this Season",
"view_series": "View Series",
"view_season": "View Season",
"select_season": "Select Season",
"no_episodes_for_this_season": "Ehhez az évadhoz nincs epizód",
"overview": "Áttekintés",
"more_with": "További {{name}} Alkotások",
@@ -627,10 +753,21 @@
"media_options": "Media Options",
"quality": "Minőség",
"audio": "Hang",
"subtitles": "Felirat",
"subtitles": {
"label": "Subtitle",
"none": "None",
"tracks": "Tracks"
},
"show_more": "Több Megjelenítése",
"show_less": "Kevesebb Megjelenítése",
"left": "left",
"more_info": "More Info",
"director": "Director",
"cast": "Cast",
"technical_details": "Technical Details",
"appeared_in": "Megjelent:",
"movies": "Movies",
"shows": "Shows",
"could_not_load_item": "Nem Sikerült Betölteni az Elemet",
"none": "Nincs",
"download": {
@@ -641,7 +778,13 @@
"download_x_item": "{{item_count}} Elem Letöltése",
"download_unwatched_only": "Csak Nem Megtekintett",
"download_button": "Letöltés"
}
},
"mark_played": "Mark as Watched",
"mark_unplayed": "Mark as Unwatched",
"resume_playback": "Resume Playback",
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
"play_from_start": "Play from Start",
"continue_from": "Continue from {{time}}"
},
"live_tv": {
"next": "Következő",
@@ -652,7 +795,18 @@
"movies": "Filmek",
"sports": "Sport",
"for_kids": "Gyerekeknek",
"news": "Hírek"
"news": "Hírek",
"page_of": "Page {{current}} of {{total}}",
"no_programs": "No programs available",
"no_channels": "No channels available",
"tabs": {
"programs": "Programs",
"guide": "Guide",
"channels": "Channels",
"recordings": "Recordings",
"schedule": "Schedule",
"series": "Series"
}
},
"jellyseerr": {
"confirm": "Megerősítés",
@@ -697,6 +851,12 @@
"decline": "Decline",
"requested_by": "Requested by {{user}}",
"unknown_user": "Unknown User",
"select": "Select",
"request_all": "Request All",
"request_seasons": "Request Seasons",
"select_seasons": "Select Seasons",
"request_selected": "Request Selected",
"n_selected": "{{count}} selected",
"toasts": {
"jellyseer_does_not_meet_requirements": "A Jellyseerr szerver nem felel meg a minimum verziókövetelményeknek! Kérlek frissítsd legalább 2.0.0-ra.",
"jellyseerr_test_failed": "A Jellyseerr teszt sikertelen. Próbáld újra.",
@@ -716,7 +876,8 @@
"search": "Keresés",
"library": "Könyvtár",
"custom_links": "Egyéni Linkek",
"favorites": "Kedvencek"
"favorites": "Kedvencek",
"settings": "Settings"
},
"music": {
"title": "Music",
@@ -841,5 +1002,36 @@
"show": "This show",
"all": "All media (default)"
}
},
"companion_login": {
"title": "Pair with TV",
"align_qr": "Align the QR code within the frame",
"enter_code_manually": "Enter code manually",
"pairing_enter_credentials": "Enter credentials for TV",
"pairing_code_label": "Pairing code",
"server": "Server",
"authorize_button": "Authorize",
"authorizing": "Authorizing...",
"scan_again": "Scan Again",
"done": "Done",
"success_title": "Authorization Sent",
"pairing_tv_connecting": "The TV is connecting to your account",
"error_title": "Authorization Failed",
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
"error_generic": "Something went wrong. Please try again.",
"error_permission_denied": "Camera permission is required to scan QR codes.",
"login_as": "Log in as {{username}}?",
"on_server": "on {{server}}",
"use_different_user": "Use a different user",
"open_settings": "Open Settings"
},
"pairing": {
"pair_with_phone": "Pair with Phone",
"pair_with_phone_title": "Login TV",
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
"waiting_for_phone": "Waiting for phone...",
"scan_with_phone": "Scan with the Streamyfin app on your phone",
"logging_in": "Logging in...",
"logging_in_description": "Connecting to your server"
}
}

View File

@@ -4,6 +4,9 @@
"error_title": "Errore",
"login_title": "Accesso",
"login_to_title": "Accedi a",
"select_user": "Select a user to log in",
"add_user_to_login": "Add a user to log in",
"add_user": "Add User",
"username_placeholder": "Nome utente",
"password_placeholder": "Password",
"login_button": "Accedi",
@@ -42,7 +45,13 @@
"accounts_count": "{{count}} accounts",
"select_account": "Select Account",
"add_account": "Add Account",
"remove_account_description": "This will remove the saved credentials for {{username}}."
"remove_account_description": "This will remove the saved credentials for {{username}}.",
"remove_server": "Remove Server",
"remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
"select_your_server": "Select Your Server",
"add_server_to_get_started": "Add a server to get started",
"add_server": "Add Server",
"change_server": "Change Server"
},
"save_account": {
"title": "Save Account",
@@ -86,6 +95,7 @@
"oops": "Ops!",
"error_message": "Qualcosa è andato storto. \nEffetturare il logout e riaccedere.",
"continue_watching": "Continua a guardare",
"continue": "Continue",
"next_up": "Prossimo",
"continue_and_next_up": "Continue & Next Up",
"recently_added_in": "Aggiunti di recente a {{libraryName}}",
@@ -109,6 +119,12 @@
"settings": {
"settings_title": "Impostazioni",
"log_out_button": "Esci",
"switch_user": {
"title": "Switch User",
"account": "Account",
"switch_user": "Switch User on This Server",
"current": "current"
},
"categories": {
"title": "Categorie"
},
@@ -121,7 +137,16 @@
"appearance": {
"title": "Aspetto",
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
"hide_remote_session_button": "Hide Remote Session Button"
"hide_remote_session_button": "Hide Remote Session Button",
"show_home_backdrop": "Dynamic Home Backdrop",
"show_hero_carousel": "Hero Carousel",
"show_series_poster_on_episode": "Show Series Poster on Episodes",
"theme_music": "Theme Music",
"display_size": "Display Size",
"display_size_small": "Small",
"display_size_default": "Default",
"display_size_large": "Large",
"display_size_extra_large": "Extra Large"
},
"network": {
"title": "Network",
@@ -136,7 +161,7 @@
"not_connected_to_wifi": "Not connected to WiFi",
"no_networks_configured": "No networks configured",
"add_network_hint": "Add your home WiFi network to enable auto-switching",
"current_wifi": "Current WiFi",
"current_wifi": "WiFi Attuale",
"using_url": "Sta utilizzando",
"local": "Local URL",
"remote": "Remote URL",
@@ -174,6 +199,22 @@
"rewind_length": "Lunghezza del riavvolgimento",
"seconds_unit": "secondi"
},
"buffer": {
"title": "Buffer Settings",
"cache_mode": "Cache Mode",
"cache_auto": "Auto",
"cache_yes": "Enabled",
"cache_no": "Disabled",
"buffer_duration": "Buffer Duration",
"max_cache_size": "Max Cache Size",
"max_backward_cache": "Max Backward Cache"
},
"vo_driver": {
"title": "Video Output",
"vo_mode": "VO Driver",
"gpu_next": "gpu-next (Recommended)",
"gpu": "gpu"
},
"gesture_controls": {
"gesture_controls_title": "Controlli Gesture",
"horizontal_swipe_skip": "Scorrimento orizzontale per saltare",
@@ -256,7 +297,23 @@
"subtitle_font": "Subtitle Font",
"ksplayer_title": "KSPlayer Settings",
"hardware_decode": "Hardware Decoding",
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues."
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues.",
"opensubtitles_title": "OpenSubtitles",
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
"opensubtitles_api_key": "API Key",
"opensubtitles_api_key_placeholder": "Enter API key...",
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
"mpv_subtitle_scale": "Subtitle Scale",
"mpv_subtitle_margin_y": "Vertical Margin",
"mpv_subtitle_align_x": "Horizontal Align",
"mpv_subtitle_align_y": "Vertical Align",
"align": {
"left": "Left",
"center": "Center",
"right": "Right",
"top": "Top",
"bottom": "Bottom"
}
},
"vlc_subtitles": {
"title": "VLC Subtitle Settings",
@@ -406,7 +463,13 @@
"music_cache_cleared": "Music cache cleared",
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
"downloaded_songs_size": "{{size}} downloaded",
"downloaded_songs_deleted": "Downloaded songs deleted"
"downloaded_songs_deleted": "Downloaded songs deleted",
"clear_all_cache": "Clear All Cache",
"clear_all_cache_confirm": "Clear All Cache?",
"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.",
"clear_all_cache_success": "Cache Cleared",
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
},
"intro": {
"title": "Intro",
@@ -430,6 +493,21 @@
"error_deleting_files": "Errore nella cancellazione dei file",
"background_downloads_enabled": "Scaricamento in background abilitato",
"background_downloads_disabled": "Scaricamento in background disabilitato"
},
"security": {
"title": "Security",
"inactivity_timeout": {
"title": "Inactivity Timeout",
"description": "Auto logout after inactivity",
"disabled": "Disabled",
"1_minute": "1 minute",
"5_minutes": "5 minutes",
"15_minutes": "15 minutes",
"30_minutes": "30 minutes",
"1_hour": "1 hour",
"4_hours": "4 hours",
"24_hours": "24 hours"
}
}
},
"sessions": {
@@ -456,6 +534,7 @@
"new_app_version_requires_re_download_description": "Il nuovo aggiornamento richiede di scaricare nuovamente i contenuti. Rimuovere tutti i contenuti scaricati e riprovare.",
"back": "Indietro",
"delete": "Cancella",
"delete_download": "Delete Download",
"something_went_wrong": "Qualcosa è andato storto",
"could_not_get_stream_url_from_jellyfin": "Impossibile ottenere l'URL del flusso da Jellyfin",
"eta": "Tempo stimato di completamento {{eta}}",
@@ -492,22 +571,28 @@
}
},
"common": {
"no_results": "No Results",
"select": "Seleziona",
"no_trailer_available": "Nessun trailer disponibile",
"video": "Video",
"audio": "Audio",
"subtitle": "Sottotitoli",
"play": "Gioca",
"mark_as_played": "Mark as Played",
"mark_as_not_played": "Mark as not Played",
"none": "Nulla",
"track": "Traccia",
"cancel": "Cancel",
"stop": "Stop",
"delete": "Delete",
"ok": "OK",
"remove": "Remove",
"next": "Next",
"back": "Back",
"continue": "Continue",
"verifying": "Verifying..."
"verifying": "Verifying...",
"login": "Login",
"refresh": "Refresh"
},
"search": {
"search": "Cerca...",
@@ -556,6 +641,7 @@
"movies": "film",
"series": "serie TV",
"boxsets": "cofanetti",
"playlists": "Playlists",
"items": "elementi"
},
"options": {
@@ -566,7 +652,8 @@
"poster": "Poster",
"cover": "Copertina",
"show_titles": "Mostra titoli",
"show_stats": "Mostra statistiche"
"show_stats": "Mostra statistiche",
"options_title": "Options"
},
"filters": {
"genres": "Generi",
@@ -574,7 +661,11 @@
"sort_by": "Ordina per",
"filter_by": "Filter By",
"sort_order": "Criterio di ordinamento",
"tags": "Tag"
"tags": "Tag",
"all": "All",
"reset": "Reset",
"asc": "Ascending",
"desc": "Descending"
}
},
"favorites": {
@@ -591,6 +682,8 @@
"no_links": "Nessun link"
},
"player": {
"live": "LIVE",
"mpv_player_title": "MPV Player",
"error": "Errore",
"failed_to_get_stream_url": "Impossibile ottenere l'URL dello stream",
"an_error_occured_while_playing_the_video": "Si è verificato un errore durante la riproduzione del video. Controllare i log nelle impostazioni.",
@@ -608,7 +701,35 @@
"downloaded_file_message": "Do you want to play the downloaded file?",
"downloaded_file_yes": "Yes",
"downloaded_file_no": "No",
"downloaded_file_cancel": "Cancel"
"downloaded_file_cancel": "Cancel",
"swipe_down_settings": "Swipe down for settings",
"ends_at": "Ends at {{time}}",
"search_subtitles": "Search Subtitles",
"subtitle_tracks": "Tracks",
"subtitle_search": "Search & Download",
"download": "Download",
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
"using_jellyfin_server": "Using Jellyfin Server",
"language": "Language",
"results": "Results",
"searching": "Searching...",
"search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found",
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
"settings": "Settings",
"skip_intro": "Skip Intro",
"skip_credits": "Skip Credits",
"stopPlayback": "Stop Playback",
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
"stopPlayingConfirm": "Are you sure you want to stop playback?",
"downloaded": "Downloaded"
},
"chapters": {
"title": "Chapters",
"chapter_number": "Chapter {{number}}",
"open": "Open chapters",
"close": "Close chapters"
},
"item_card": {
"next_up": "Il prossimo",
@@ -617,6 +738,11 @@
"series": "Serie",
"seasons": "Stagioni",
"season": "Stagione",
"from_this_series": "From This Series",
"more_from_this_season": "More from this Season",
"view_series": "View Series",
"view_season": "View Season",
"select_season": "Select Season",
"no_episodes_for_this_season": "Nessun episodio per questa stagione",
"overview": "Panoramica",
"more_with": "Altri con {{name}}",
@@ -627,10 +753,21 @@
"media_options": "Opzioni Media",
"quality": "Qualità",
"audio": "Audio",
"subtitles": "Sottotitoli",
"subtitles": {
"label": "Subtitle",
"none": "None",
"tracks": "Tracks"
},
"show_more": "Mostra di più",
"show_less": "Mostra di meno",
"left": "left",
"more_info": "More Info",
"director": "Director",
"cast": "Cast",
"technical_details": "Technical Details",
"appeared_in": "Apparso in",
"movies": "Movies",
"shows": "Shows",
"could_not_load_item": "Impossibile caricare l'elemento",
"none": "Nessuno",
"download": {
@@ -641,7 +778,13 @@
"download_x_item": "Scarica {{item_count}} elementi",
"download_unwatched_only": "Solo Non Visti",
"download_button": "Scarica"
}
},
"mark_played": "Mark as Watched",
"mark_unplayed": "Mark as Unwatched",
"resume_playback": "Resume Playback",
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
"play_from_start": "Play from Start",
"continue_from": "Continue from {{time}}"
},
"live_tv": {
"next": "Prossimo",
@@ -652,7 +795,18 @@
"movies": "Film",
"sports": "Sport",
"for_kids": "Per Bambini",
"news": "Notiziari"
"news": "Notiziari",
"page_of": "Page {{current}} of {{total}}",
"no_programs": "No programs available",
"no_channels": "No channels available",
"tabs": {
"programs": "Programs",
"guide": "Guide",
"channels": "Channels",
"recordings": "Recordings",
"schedule": "Schedule",
"series": "Series"
}
},
"jellyseerr": {
"confirm": "Conferma",
@@ -697,6 +851,12 @@
"decline": "Rifiuta",
"requested_by": "Richiesto da {{user}}",
"unknown_user": "Utente Sconosciuto",
"select": "Select",
"request_all": "Request All",
"request_seasons": "Request Seasons",
"select_seasons": "Select Seasons",
"request_selected": "Request Selected",
"n_selected": "{{count}} selected",
"toasts": {
"jellyseer_does_not_meet_requirements": "Il server Jellyseerr non soddisfa i requisiti minimi di versione! Aggiornare almeno alla versione 2.0.0.",
"jellyseerr_test_failed": "Il test di Jellyseerr non è riuscito. Riprovare.",
@@ -716,7 +876,8 @@
"search": "Cerca",
"library": "Libreria",
"custom_links": "Collegamenti personalizzati",
"favorites": "Preferiti"
"favorites": "Preferiti",
"settings": "Settings"
},
"music": {
"title": "Music",
@@ -841,5 +1002,36 @@
"show": "This show",
"all": "All media (default)"
}
},
"companion_login": {
"title": "Pair with TV",
"align_qr": "Align the QR code within the frame",
"enter_code_manually": "Enter code manually",
"pairing_enter_credentials": "Enter credentials for TV",
"pairing_code_label": "Pairing code",
"server": "Server",
"authorize_button": "Authorize",
"authorizing": "Authorizing...",
"scan_again": "Scan Again",
"done": "Done",
"success_title": "Authorization Sent",
"pairing_tv_connecting": "The TV is connecting to your account",
"error_title": "Authorization Failed",
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
"error_generic": "Something went wrong. Please try again.",
"error_permission_denied": "Camera permission is required to scan QR codes.",
"login_as": "Log in as {{username}}?",
"on_server": "on {{server}}",
"use_different_user": "Use a different user",
"open_settings": "Open Settings"
},
"pairing": {
"pair_with_phone": "Pair with Phone",
"pair_with_phone_title": "Login TV",
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
"waiting_for_phone": "Waiting for phone...",
"scan_with_phone": "Scan with the Streamyfin app on your phone",
"logging_in": "Logging in...",
"logging_in_description": "Connecting to your server"
}
}

View File

@@ -4,6 +4,9 @@
"error_title": "エラー",
"login_title": "ログイン",
"login_to_title": "ログイン先",
"select_user": "Select a user to log in",
"add_user_to_login": "Add a user to log in",
"add_user": "Add User",
"username_placeholder": "ユーザー名",
"password_placeholder": "パスワード",
"login_button": "ログイン",
@@ -42,7 +45,13 @@
"accounts_count": "{{count}} accounts",
"select_account": "Select Account",
"add_account": "Add Account",
"remove_account_description": "This will remove the saved credentials for {{username}}."
"remove_account_description": "This will remove the saved credentials for {{username}}.",
"remove_server": "Remove Server",
"remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
"select_your_server": "Select Your Server",
"add_server_to_get_started": "Add a server to get started",
"add_server": "Add Server",
"change_server": "Change Server"
},
"save_account": {
"title": "Save Account",
@@ -86,6 +95,7 @@
"oops": "おっと!",
"error_message": "何か問題が発生しました。\nログアウトして再度ログインしてください。",
"continue_watching": "続きを見る",
"continue": "Continue",
"next_up": "次の動画",
"continue_and_next_up": "Continue & Next Up",
"recently_added_in": "{{libraryName}}に最近追加された",
@@ -109,6 +119,12 @@
"settings": {
"settings_title": "設定",
"log_out_button": "ログアウト",
"switch_user": {
"title": "Switch User",
"account": "Account",
"switch_user": "Switch User on This Server",
"current": "current"
},
"categories": {
"title": "カテゴリ"
},
@@ -121,7 +137,16 @@
"appearance": {
"title": "Appearance",
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
"hide_remote_session_button": "Hide Remote Session Button"
"hide_remote_session_button": "Hide Remote Session Button",
"show_home_backdrop": "Dynamic Home Backdrop",
"show_hero_carousel": "Hero Carousel",
"show_series_poster_on_episode": "Show Series Poster on Episodes",
"theme_music": "Theme Music",
"display_size": "Display Size",
"display_size_small": "Small",
"display_size_default": "Default",
"display_size_large": "Large",
"display_size_extra_large": "Extra Large"
},
"network": {
"title": "Network",
@@ -174,6 +199,22 @@
"rewind_length": "巻き戻しの長さ",
"seconds_unit": "s"
},
"buffer": {
"title": "Buffer Settings",
"cache_mode": "Cache Mode",
"cache_auto": "Auto",
"cache_yes": "Enabled",
"cache_no": "Disabled",
"buffer_duration": "Buffer Duration",
"max_cache_size": "Max Cache Size",
"max_backward_cache": "Max Backward Cache"
},
"vo_driver": {
"title": "Video Output",
"vo_mode": "VO Driver",
"gpu_next": "gpu-next (Recommended)",
"gpu": "gpu"
},
"gesture_controls": {
"gesture_controls_title": "ジェスチャーコントロール",
"horizontal_swipe_skip": "水平方向にスワイプしてスキップ",
@@ -256,7 +297,23 @@
"subtitle_font": "Subtitle Font",
"ksplayer_title": "KSPlayer Settings",
"hardware_decode": "Hardware Decoding",
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues."
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues.",
"opensubtitles_title": "OpenSubtitles",
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
"opensubtitles_api_key": "API Key",
"opensubtitles_api_key_placeholder": "Enter API key...",
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
"mpv_subtitle_scale": "Subtitle Scale",
"mpv_subtitle_margin_y": "Vertical Margin",
"mpv_subtitle_align_x": "Horizontal Align",
"mpv_subtitle_align_y": "Vertical Align",
"align": {
"left": "Left",
"center": "Center",
"right": "Right",
"top": "Top",
"bottom": "Bottom"
}
},
"vlc_subtitles": {
"title": "VLC Subtitle Settings",
@@ -406,7 +463,13 @@
"music_cache_cleared": "Music cache cleared",
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
"downloaded_songs_size": "{{size}} downloaded",
"downloaded_songs_deleted": "Downloaded songs deleted"
"downloaded_songs_deleted": "Downloaded songs deleted",
"clear_all_cache": "Clear All Cache",
"clear_all_cache_confirm": "Clear All Cache?",
"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.",
"clear_all_cache_success": "Cache Cleared",
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
},
"intro": {
"title": "イントロ",
@@ -430,6 +493,21 @@
"error_deleting_files": "ファイルの削除エラー",
"background_downloads_enabled": "バックグラウンドでのダウンロードは有効です",
"background_downloads_disabled": "バックグラウンドでのダウンロードは無効です"
},
"security": {
"title": "Security",
"inactivity_timeout": {
"title": "Inactivity Timeout",
"description": "Auto logout after inactivity",
"disabled": "Disabled",
"1_minute": "1 minute",
"5_minutes": "5 minutes",
"15_minutes": "15 minutes",
"30_minutes": "30 minutes",
"1_hour": "1 hour",
"4_hours": "4 hours",
"24_hours": "24 hours"
}
}
},
"sessions": {
@@ -456,6 +534,7 @@
"new_app_version_requires_re_download_description": "新しいアップデートではコンテンツを再度ダウンロードする必要があります。ダウンロードしたコンテンツをすべて削除してもう一度お試しください。",
"back": "戻る",
"delete": "削除",
"delete_download": "Delete Download",
"something_went_wrong": "問題が発生しました",
"could_not_get_stream_url_from_jellyfin": "JellyfinからストリームURLを取得できませんでした",
"eta": "ETA {{eta}}",
@@ -492,22 +571,28 @@
}
},
"common": {
"no_results": "No Results",
"select": "選択",
"no_trailer_available": "トレーラーがありません",
"video": "映像",
"audio": "音声",
"subtitle": "字幕",
"play": "再生",
"mark_as_played": "Mark as Played",
"mark_as_not_played": "Mark as not Played",
"none": "None",
"track": "Track",
"cancel": "Cancel",
"stop": "Stop",
"delete": "Delete",
"ok": "OK",
"remove": "Remove",
"next": "Next",
"back": "Back",
"continue": "Continue",
"verifying": "Verifying..."
"verifying": "Verifying...",
"login": "Login",
"refresh": "Refresh"
},
"search": {
"search": "検索...",
@@ -556,6 +641,7 @@
"movies": "映画",
"series": "シリーズ",
"boxsets": "ボックスセット",
"playlists": "Playlists",
"items": "アイテム"
},
"options": {
@@ -566,7 +652,8 @@
"poster": "ポスター",
"cover": "カバー",
"show_titles": "タイトルの表示",
"show_stats": "統計を表示"
"show_stats": "統計を表示",
"options_title": "Options"
},
"filters": {
"genres": "ジャンル",
@@ -574,7 +661,11 @@
"sort_by": "ソート",
"filter_by": "Filter By",
"sort_order": "ソート順",
"tags": "タグ"
"tags": "タグ",
"all": "All",
"reset": "Reset",
"asc": "Ascending",
"desc": "Descending"
}
},
"favorites": {
@@ -591,6 +682,8 @@
"no_links": "リンクがありません"
},
"player": {
"live": "LIVE",
"mpv_player_title": "MPV Player",
"error": "エラー",
"failed_to_get_stream_url": "ストリームURLを取得できませんでした",
"an_error_occured_while_playing_the_video": "動画の再生中にエラーが発生しました。設定でログを確認してください。",
@@ -608,7 +701,35 @@
"downloaded_file_message": "Do you want to play the downloaded file?",
"downloaded_file_yes": "Yes",
"downloaded_file_no": "No",
"downloaded_file_cancel": "Cancel"
"downloaded_file_cancel": "Cancel",
"swipe_down_settings": "Swipe down for settings",
"ends_at": "Ends at {{time}}",
"search_subtitles": "Search Subtitles",
"subtitle_tracks": "Tracks",
"subtitle_search": "Search & Download",
"download": "Download",
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
"using_jellyfin_server": "Using Jellyfin Server",
"language": "Language",
"results": "Results",
"searching": "Searching...",
"search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found",
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
"settings": "Settings",
"skip_intro": "Skip Intro",
"skip_credits": "Skip Credits",
"stopPlayback": "Stop Playback",
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
"stopPlayingConfirm": "Are you sure you want to stop playback?",
"downloaded": "Downloaded"
},
"chapters": {
"title": "Chapters",
"chapter_number": "Chapter {{number}}",
"open": "Open chapters",
"close": "Close chapters"
},
"item_card": {
"next_up": "次",
@@ -617,6 +738,11 @@
"series": "シリーズ",
"seasons": "シーズン",
"season": "シーズン",
"from_this_series": "From This Series",
"more_from_this_season": "More from this Season",
"view_series": "View Series",
"view_season": "View Season",
"select_season": "Select Season",
"no_episodes_for_this_season": "このシーズンのエピソードはありません",
"overview": "ストーリー",
"more_with": "{{name}}の詳細",
@@ -627,10 +753,21 @@
"media_options": "Media Options",
"quality": "画質",
"audio": "音声",
"subtitles": "字幕",
"subtitles": {
"label": "Subtitle",
"none": "None",
"tracks": "Tracks"
},
"show_more": "もっと見る",
"show_less": "少なく表示",
"left": "left",
"more_info": "More Info",
"director": "Director",
"cast": "Cast",
"technical_details": "Technical Details",
"appeared_in": "出演作品",
"movies": "Movies",
"shows": "Shows",
"could_not_load_item": "アイテムを読み込めませんでした",
"none": "なし",
"download": {
@@ -641,7 +778,13 @@
"download_x_item": "{{item_count}}のアイテムをダウンロード",
"download_unwatched_only": "未視聴のみ",
"download_button": "ダウンロード"
}
},
"mark_played": "Mark as Watched",
"mark_unplayed": "Mark as Unwatched",
"resume_playback": "Resume Playback",
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
"play_from_start": "Play from Start",
"continue_from": "Continue from {{time}}"
},
"live_tv": {
"next": "次",
@@ -652,7 +795,18 @@
"movies": "映画",
"sports": "スポーツ",
"for_kids": "子供向け",
"news": "ニュース"
"news": "ニュース",
"page_of": "Page {{current}} of {{total}}",
"no_programs": "No programs available",
"no_channels": "No channels available",
"tabs": {
"programs": "Programs",
"guide": "Guide",
"channels": "Channels",
"recordings": "Recordings",
"schedule": "Schedule",
"series": "Series"
}
},
"jellyseerr": {
"confirm": "確認",
@@ -697,6 +851,12 @@
"decline": "Decline",
"requested_by": "Requested by {{user}}",
"unknown_user": "Unknown User",
"select": "Select",
"request_all": "Request All",
"request_seasons": "Request Seasons",
"select_seasons": "Select Seasons",
"request_selected": "Request Selected",
"n_selected": "{{count}} selected",
"toasts": {
"jellyseer_does_not_meet_requirements": "Jellyseerrサーバーは最小バージョン要件を満たしていません。少なくとも 2.0.0 に更新してください。",
"jellyseerr_test_failed": "Jellyseerrテストに失敗しました。もう一度お試しください。",
@@ -716,7 +876,8 @@
"search": "検索",
"library": "ライブラリ",
"custom_links": "カスタムリンク",
"favorites": "お気に入り"
"favorites": "お気に入り",
"settings": "Settings"
},
"music": {
"title": "Music",
@@ -841,5 +1002,36 @@
"show": "This show",
"all": "All media (default)"
}
},
"companion_login": {
"title": "Pair with TV",
"align_qr": "Align the QR code within the frame",
"enter_code_manually": "Enter code manually",
"pairing_enter_credentials": "Enter credentials for TV",
"pairing_code_label": "Pairing code",
"server": "Server",
"authorize_button": "Authorize",
"authorizing": "Authorizing...",
"scan_again": "Scan Again",
"done": "Done",
"success_title": "Authorization Sent",
"pairing_tv_connecting": "The TV is connecting to your account",
"error_title": "Authorization Failed",
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
"error_generic": "Something went wrong. Please try again.",
"error_permission_denied": "Camera permission is required to scan QR codes.",
"login_as": "Log in as {{username}}?",
"on_server": "on {{server}}",
"use_different_user": "Use a different user",
"open_settings": "Open Settings"
},
"pairing": {
"pair_with_phone": "Pair with Phone",
"pair_with_phone_title": "Login TV",
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
"waiting_for_phone": "Waiting for phone...",
"scan_with_phone": "Scan with the Streamyfin app on your phone",
"logging_in": "Logging in...",
"logging_in_description": "Connecting to your server"
}
}

View File

@@ -1,187 +1,228 @@
{
"login": {
"username_required": "Username Is Required",
"error_title": "Error",
"login_title": "Log In",
"login_to_title": "Log in to",
"username_placeholder": "Username",
"password_placeholder": "Password",
"login_button": "Log In",
"quick_connect": "Quick Connect",
"enter_code_to_login": "Enter code {{code}} to login",
"failed_to_initiate_quick_connect": "Failed to initiate Quick Connect",
"got_it": "Got It",
"connection_failed": "Connection Failed",
"could_not_connect_to_server": "Could not connect to the server. Please check the URL and your network connection.",
"an_unexpected_error_occured": "An Unexpected Error Occurred",
"change_server": "Change Server",
"invalid_username_or_password": "Invalid Username or Password",
"user_does_not_have_permission_to_log_in": "User does not have permission to log in",
"server_is_taking_too_long_to_respond_try_again_later": "Server is taking too long to respond, try again later",
"server_received_too_many_requests_try_again_later": "Server received too many requests, try again later.",
"there_is_a_server_error": "There is a server error",
"an_unexpected_error_occured_did_you_enter_the_correct_url": "An unexpected error occurred. Did you enter the server URL correctly?",
"username_required": "사용자 이름이 필요합니다",
"error_title": "오류",
"login_title": "로그인",
"login_to_title": "다음 서비스에 연결 중",
"select_user": "Select a user to log in",
"add_user_to_login": "Add a user to log in",
"add_user": "Add User",
"username_placeholder": "사용자 이름",
"password_placeholder": "비밀번호",
"login_button": "로그인",
"quick_connect": "퀵 커넥트",
"enter_code_to_login": "로그인 하기 위해 코드{{code}}를 입력하세요",
"failed_to_initiate_quick_connect": "Quick Connect 연결을 시작하는 데 실패했습니다",
"got_it": "성공",
"connection_failed": "연결 실패",
"could_not_connect_to_server": "서버에 연결되지 않았습니다. URL과 네트워크 상태를 확인하세요.",
"an_unexpected_error_occured": "예기치 않은 오류가 발생했습니다",
"change_server": "서버 변경",
"invalid_username_or_password": "잘못된 아이디 혹은 비밀번호입니다",
"user_does_not_have_permission_to_log_in": "로그인 하기 위한 권한이 없습니다",
"server_is_taking_too_long_to_respond_try_again_later": "서버 응답이 너무 느립니다. 나중에 다시 시도하세요",
"server_received_too_many_requests_try_again_later": "서버가 너무 많은 요청을 받았습니다. 나중에 다시 시도하세요.",
"there_is_a_server_error": "서버 에러",
"an_unexpected_error_occured_did_you_enter_the_correct_url": "예기치 않은 오류가 발생했습니다. 서버 URL을 올바르게 입력하셨습니까?",
"too_old_server_text": "Unsupported Jellyfin Server Discovered",
"too_old_server_description": "Please update Jellyfin to the latest version"
},
"server": {
"enter_url_to_jellyfin_server": "Enter the URL to your Jellyfin server",
"server_url_placeholder": "http(s)://your-server.com",
"connect_button": "Connect",
"previous_servers": "Previous Servers",
"clear_button": "Clear all",
"swipe_to_remove": "Swipe to remove",
"search_for_local_servers": "Search for Local Servers",
"searching": "Searching...",
"servers": "Servers",
"saved": "Saved",
"session_expired": "Session Expired",
"please_login_again": "Your saved session has expired. Please log in again.",
"remove_saved_login": "Remove Saved Login",
"remove_saved_login_description": "This will remove your saved credentials for this server. You'll need to enter your username and password again next time.",
"accounts_count": "{{count}} accounts",
"select_account": "Select Account",
"add_account": "Add Account",
"remove_account_description": "This will remove the saved credentials for {{username}}."
"connect_button": "연결",
"previous_servers": "이전 서버",
"clear_button": "모두 지우기",
"swipe_to_remove": "스와이프해서 지우기",
"search_for_local_servers": "로컬 서버 찾기",
"searching": "찾는 중...",
"servers": "서버",
"saved": "저장됨",
"session_expired": "세션 만료됨",
"please_login_again": "사용자 세션이 만료되었습니다. 다시 로그인하십시오.",
"remove_saved_login": "저장된 로그인 정보 삭제",
"remove_saved_login_description": "해당 서버에 저장된 자격 증명이 삭제됩니다. 다음에 접속할 때는 사용자 이름과 비밀번호를 다시 입력해야 합니다.",
"accounts_count": "{{count}} 계정",
"select_account": "계정 선택",
"add_account": "계정 추가",
"remove_account_description": "{{username}}에 저장된 자격 증명이 삭제됩니다.",
"remove_server": "Remove Server",
"remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
"select_your_server": "Select Your Server",
"add_server_to_get_started": "Add a server to get started",
"add_server": "Add Server",
"change_server": "Change Server"
},
"save_account": {
"title": "Save Account",
"save_for_later": "Save this account",
"security_option": "Security Option",
"no_protection": "No protection",
"no_protection_desc": "Quick login without authentication",
"pin_code": "PIN code",
"pin_code_desc": "4-digit PIN required when switching",
"password": "Re-enter password",
"password_desc": "Password required when switching",
"save_button": "Save",
"cancel_button": "Cancel"
"title": "계정 저장",
"save_for_later": "이 계정 저장",
"security_option": "보안 설정",
"no_protection": "보안 없음",
"no_protection_desc": "인증 없이 빠른 로그인",
"pin_code": "PIN 코드",
"pin_code_desc": "전환하려면 4자리 PIN 필요함",
"password": "암호 확인",
"password_desc": "전환하려면 비밀번호 필요함",
"save_button": "저장",
"cancel_button": "취소"
},
"pin": {
"enter_pin": "Enter PIN",
"enter_pin_for": "Enter PIN for {{username}}",
"enter_4_digits": "Enter 4 digits",
"invalid_pin": "Invalid PIN",
"setup_pin": "Set Up PIN",
"confirm_pin": "Confirm PIN",
"pins_dont_match": "PINs don't match",
"forgot_pin": "Forgot PIN?",
"forgot_pin_desc": "Your saved credentials will be removed"
"enter_pin": "PIN 입력",
"enter_pin_for": "{{username}} PIN 입력",
"enter_4_digits": "4자리 입력",
"invalid_pin": "잘못된 PIN",
"setup_pin": "PIN 설정",
"confirm_pin": "PIN 확인",
"pins_dont_match": "PIN이 일치하지 않습니다",
"forgot_pin": "PIN을 잊으셨나요?",
"forgot_pin_desc": "저장된 계정 정보가 삭제됩니다"
},
"password": {
"enter_password": "Enter Password",
"enter_password_for": "Enter password for {{username}}",
"invalid_password": "Invalid password"
"enter_password": "비밀번호 입력",
"enter_password_for": "{{username}}의 비밀번호 입력",
"invalid_password": "잘못된 비밀번호"
},
"home": {
"checking_server_connection": "Checking server connection...",
"no_internet": "No Internet",
"no_items": "No Items",
"no_internet_message": "No worries, you can still watch\ndownloaded content.",
"checking_server_connection": "서버 연결 체크중...",
"no_internet": "인터넷에 연결되지 않음",
"no_items": "항목 없음",
"no_internet_message": "걱정마세요. 다운로드 된 컨텐츠는 여전히 볼 수 있습니다.",
"checking_server_connection_message": "Checking connection to server",
"go_to_downloads": "Go to Downloads",
"retry": "Retry",
"server_unreachable": "Server Unreachable",
"server_unreachable_message": "Could not reach the server.\nPlease check your network connection.",
"oops": "Oops!",
"error_message": "Something went wrong.\nPlease log out and in again.",
"continue_watching": "Continue Watching",
"next_up": "Next Up",
"continue_and_next_up": "Continue & Next Up",
"recently_added_in": "Recently Added in {{libraryName}}",
"suggested_movies": "Suggested Movies",
"suggested_episodes": "Suggested Episodes",
"retry": "재시도",
"server_unreachable": "서버에 연결할 수 없음",
"server_unreachable_message": "서버에 연결할 수 없습니다. 네트워크 상태를 체크하세요.",
"oops": "이런!",
"error_message": "문제가 발생했습니다.\n로그아웃 후 다시 로그인해 주세요.",
"continue_watching": "이어서 보기",
"continue": "Continue",
"next_up": "다음 시청",
"continue_and_next_up": "이어서 보기 & 다음 시청",
"recently_added_in": "최근에 추가된 {{libraryName}}",
"suggested_movies": "추천 영화",
"suggested_episodes": "추천 에피소드",
"intro": {
"welcome_to_streamyfin": "Welcome to Streamyfin",
"a_free_and_open_source_client_for_jellyfin": "A Free and Open-Source Client for Jellyfin.",
"features_title": "Features",
"features_description": "Streamyfin has a bunch of features and integrates with a wide array of software which you can find in the settings menu, these include:",
"jellyseerr_feature_description": "Connect to your Seerr instance and request movies directly in the app.",
"downloads_feature_title": "Downloads",
"downloads_feature_description": "Download movies and tv-shows to view offline. Use either the default method or install the optimize server to download files in the background.",
"chromecast_feature_description": "Cast movies and tv-shows to your Chromecast devices.",
"centralised_settings_plugin_title": "Centralised Settings Plugin",
"centralised_settings_plugin_description": "Configure settings from a centralised location on your Jellyfin server. All client settings for all users will be synced automatically.",
"done_button": "Done",
"go_to_settings_button": "Go to Settings",
"read_more": "Read More"
"welcome_to_streamyfin": "스트리미핀에 오신 것을 환영합니다",
"a_free_and_open_source_client_for_jellyfin": "젤리핀을 위한 무료 오픈소스 클라이언트입니다.",
"features_title": "기능",
"features_description": "스트리미핀은 다양한 기능을 제공하며 설정 메뉴에서 확인할 수 있는 여러 소프트웨어와 통합됩니다. 이러한 소프트웨어에는 다음이 포함됩니다:",
"jellyseerr_feature_description": "Seerr 인스턴스에 연결하여 앱에서 직접 영화를 요청할 수 있습니다.",
"downloads_feature_title": "다운로드된 컨텐츠",
"downloads_feature_description": "오프라인으로 보기위해 다운로드 하세요. 기본 다운로드 방식을 사용하거나, 백그라운드에서 파일을 다운로드하는 최적화 서버를 설치할 수 있습니다.",
"chromecast_feature_description": "영화와 TV 프로그램을 Chromecast 기기로 전송하기",
"centralised_settings_plugin_title": "중앙 설정 플러그인",
"centralised_settings_plugin_description": "Jellyfin 서버의 중앙 집중식 위치에서 설정을 구성합니다. 모든 사용자의 모든 클라이언트 설정이 자동으로 동기화됩니다.",
"done_button": "확인",
"go_to_settings_button": "설정으로 이동",
"read_more": "자세히 보기"
},
"settings": {
"settings_title": "Settings",
"log_out_button": "Log Out",
"settings_title": "설정",
"log_out_button": "로그아웃",
"switch_user": {
"title": "Switch User",
"account": "Account",
"switch_user": "Switch User on This Server",
"current": "current"
},
"categories": {
"title": "Categories"
"title": "카테고리"
},
"playback_controls": {
"title": "Playback & Controls"
"title": "재생 & 컨트롤"
},
"audio_subtitles": {
"title": "Audio & Subtitles"
"title": "오디오 & 자막"
},
"appearance": {
"title": "Appearance",
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
"hide_remote_session_button": "Hide Remote Session Button"
"title": "화면 스타일",
"merge_next_up_continue_watching": "[이어보기]와 [다음 보기] 합치기",
"hide_remote_session_button": "원격 세션 버튼 숨기기",
"show_home_backdrop": "Dynamic Home Backdrop",
"show_hero_carousel": "Hero Carousel",
"show_series_poster_on_episode": "Show Series Poster on Episodes",
"theme_music": "Theme Music",
"display_size": "Display Size",
"display_size_small": "Small",
"display_size_default": "Default",
"display_size_large": "Large",
"display_size_extra_large": "Extra Large"
},
"network": {
"title": "Network",
"local_network": "Local Network",
"auto_switch_enabled": "Auto-switch when at home",
"auto_switch_description": "Automatically switch to local URL when connected to home WiFi",
"local_url": "Local URL",
"local_url_hint": "Enter your local server address (e.g., http://192.168.1.100:8096)",
"title": "네트워크",
"local_network": "로컬 네트워크",
"auto_switch_enabled": "홈 네트워크 자동 전환",
"auto_switch_description": "홈 WiFi에 연결되었을 때 로컬 URL로 자동 전환",
"local_url": "로컬 URL",
"local_url_hint": "로컬 서버 주소를 입력하세요 (e.g., http://192.168.1.100:8096)",
"local_url_placeholder": "http://192.168.1.100:8096",
"home_wifi_networks": "Home WiFi Networks",
"add_current_network": "Add \"{{ssid}}\"",
"not_connected_to_wifi": "Not connected to WiFi",
"no_networks_configured": "No networks configured",
"add_network_hint": "Add your home WiFi network to enable auto-switching",
"current_wifi": "Current WiFi",
"using_url": "Using",
"local": "Local URL",
"remote": "Remote URL",
"not_connected": "Not connected",
"current_server": "Current Server",
"remote_url": "Remote URL",
"active_url": "Active URL",
"not_configured": "Not configured",
"network_added": "Network added",
"network_already_added": "Network already added",
"no_wifi_connected": "Not connected to WiFi",
"permission_denied": "Location permission denied",
"permission_denied_explanation": "Location permission is required to detect WiFi network for auto-switching. Please enable it in Settings."
"home_wifi_networks": " WiFi 네트워크",
"add_current_network": "\"{{ssid}}\" 추가",
"not_connected_to_wifi": "WiFi에 연결되지 않음",
"no_networks_configured": "구성된 네트워크가 없습니다",
"add_network_hint": "자동 전환을 위한 홈 WiFi 추가",
"current_wifi": "현재 WiFi",
"using_url": "사용중",
"local": "로컬 URL",
"remote": "원격 URL",
"not_connected": "연결되지 않았습니다",
"current_server": "현재 서버",
"remote_url": "원격 URL",
"active_url": "현재 사용 중인 URL",
"not_configured": "설정되지 않음",
"network_added": "네트워크 추가됨",
"network_already_added": "네트워크 이미 추가됨",
"no_wifi_connected": "WiFi에 연결되지 않음",
"permission_denied": "위치 권한이 거부되었습니다",
"permission_denied_explanation": "자동 전환 Wi-Fi 네트워크를 감지하려면 위치 권한이 필요합니다. 설정에서 위치 권한을 활성화해 주세요."
},
"user_info": {
"user_info_title": "User Info",
"user": "User",
"server": "Server",
"token": "Token",
"app_version": "App Version"
"user_info_title": "사용자 정보",
"user": "사용자",
"server": "서버",
"token": "토큰",
"app_version": "앱 버전"
},
"quick_connect": {
"quick_connect_title": "Quick Connect",
"authorize_button": "Authorize Quick Connect",
"enter_the_quick_connect_code": "Enter the quick connect code...",
"success": "Success",
"quick_connect_autorized": "Quick Connect Authorized",
"error": "Error",
"invalid_code": "Invalid Code",
"authorize": "Authorize"
"quick_connect_title": "퀵 커넥트",
"authorize_button": "퀵 커넥트 승인",
"enter_the_quick_connect_code": "퀵 커넥트 코드 입력...",
"success": "성공",
"quick_connect_autorized": "퀵 커넥트 승인됨",
"error": "오류",
"invalid_code": "유효하지 않은 코드",
"authorize": "승인"
},
"media_controls": {
"media_controls_title": "Media Controls",
"forward_skip_length": "Forward Skip Length",
"rewind_length": "Rewind Length",
"seconds_unit": "s"
"media_controls_title": "미디어 컨트롤",
"forward_skip_length": "앞으로 건너뛸 시간",
"rewind_length": "뒤로 되감을 시간",
"seconds_unit": ""
},
"buffer": {
"title": "Buffer Settings",
"cache_mode": "Cache Mode",
"cache_auto": "Auto",
"cache_yes": "Enabled",
"cache_no": "Disabled",
"buffer_duration": "Buffer Duration",
"max_cache_size": "Max Cache Size",
"max_backward_cache": "Max Backward Cache"
},
"vo_driver": {
"title": "Video Output",
"vo_mode": "VO Driver",
"gpu_next": "gpu-next (Recommended)",
"gpu": "gpu"
},
"gesture_controls": {
"gesture_controls_title": "Gesture Controls",
"horizontal_swipe_skip": "Horizontal Swipe to Skip",
"horizontal_swipe_skip_description": "Swipe left/right when controls are hidden to skip",
"left_side_brightness": "Left Side Brightness Control",
"left_side_brightness_description": "Swipe up/down on left side to adjust brightness",
"right_side_volume": "Right Side Volume Control",
"right_side_volume_description": "Swipe up/down on right side to adjust volume",
"gesture_controls_title": "제스처 제어",
"horizontal_swipe_skip": "좌/우로 스와이프하여 건너뛰기",
"horizontal_swipe_skip_description": "컨트롤 숨김상태에서 좌/우로 스와이프하여 건너뛰기",
"left_side_brightness": "왼쪽 영역 밝기 조정 컨트롤",
"left_side_brightness_description": "왼쪽 영역을 위/아래 스와이프하여 밝기 조절",
"right_side_volume": "오른쪽 영역 볼륨 컨트롤",
"right_side_volume_description": "오른족 영역을 위/아래로 스와이프 하여 볼륨 조절",
"hide_volume_slider": "Hide Volume Slider",
"hide_volume_slider_description": "Hide the volume slider in the video player",
"hide_brightness_slider": "Hide Brightness Slider",
@@ -196,7 +237,7 @@
"language": "Language",
"transcode_mode": {
"title": "Audio Transcoding",
"description": "Controls how surround audio (7.1, TrueHD, DTS-HD) is handled",
"description": "서라운드 오디오(7.1, TrueHD, DTS-HD)를 어떻게 처리할지 설정합니다",
"auto": "Auto",
"stereo": "Force Stereo",
"5_1": "Allow 5.1",
@@ -228,52 +269,68 @@
"outline_opacity": "Outline Opacity",
"bold_text": "Bold Text",
"colors": {
"Black": "Black",
"Gray": "Gray",
"Silver": "Silver",
"White": "White",
"Maroon": "Maroon",
"Red": "Red",
"Fuchsia": "Fuchsia",
"Yellow": "Yellow",
"Olive": "Olive",
"Green": "Green",
"Teal": "Teal",
"Lime": "Lime",
"Purple": "Purple",
"Navy": "Navy",
"Blue": "Blue",
"Aqua": "Aqua"
"Black": "검정색",
"Gray": "회색",
"Silver": "은색",
"White": "흰색",
"Maroon": "밤색",
"Red": "빨간색",
"Fuchsia": "분홍색",
"Yellow": "노란색",
"Olive": "올리브 색",
"Green": "녹색",
"Teal": "청록색",
"Lime": "라임색",
"Purple": "보라색",
"Navy": "남색",
"Blue": "파란색",
"Aqua": "아쿠아색"
},
"thickness": {
"None": "None",
"Thin": "Thin",
"Normal": "Normal",
"Thick": "Thick"
"None": "없음",
"Thin": "얇게",
"Normal": "보통",
"Thick": "굵게"
},
"subtitle_color": "Subtitle Color",
"subtitle_background_color": "Background Color",
"subtitle_font": "Subtitle Font",
"ksplayer_title": "KSPlayer Settings",
"hardware_decode": "Hardware Decoding",
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues."
"subtitle_color": "자막 색상",
"subtitle_background_color": "배경 색상",
"subtitle_font": "자막 폰트",
"ksplayer_title": "KSPlayer 설정",
"hardware_decode": "하드웨어 디코딩",
"hardware_decode_description": "비디오 디코딩에 하드웨어 가속을 사용하십시오. 재생 문제가 발생하는 경우 비활성화하십시오.",
"opensubtitles_title": "OpenSubtitles",
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
"opensubtitles_api_key": "API Key",
"opensubtitles_api_key_placeholder": "Enter API key...",
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
"mpv_subtitle_scale": "Subtitle Scale",
"mpv_subtitle_margin_y": "Vertical Margin",
"mpv_subtitle_align_x": "Horizontal Align",
"mpv_subtitle_align_y": "Vertical Align",
"align": {
"left": "Left",
"center": "Center",
"right": "Right",
"top": "Top",
"bottom": "Bottom"
}
},
"vlc_subtitles": {
"title": "VLC Subtitle Settings",
"hint": "Customize subtitle appearance for VLC player. Changes take effect on next playback.",
"text_color": "Text Color",
"background_color": "Background Color",
"background_opacity": "Background Opacity",
"outline_color": "Outline Color",
"outline_opacity": "Outline Opacity",
"outline_thickness": "Outline Thickness",
"bold": "Bold Text",
"margin": "Bottom Margin"
"title": "VLC 자막 설정",
"hint": "VLC 플레이어의 자막 표시 방식을 설정하세요. 변경 사항은 다음 재생 시 적용됩니다.",
"text_color": "글자색",
"background_color": "배경 색상",
"background_opacity": "배경 투명도",
"outline_color": "외곽선 색상",
"outline_opacity": "외곽선 투명도",
"outline_thickness": "외곽선 굵기",
"bold": "굵은 글씨",
"margin": "아래쪽 여백"
},
"video_player": {
"title": "Video Player",
"video_player": "Video Player",
"video_player_description": "Choose which video player to use on iOS.",
"title": "비디오 플레이어",
"video_player": "비디오 플레이어",
"video_player_description": "iOS 사용자는 비디오 플레이어를 선택하세요.",
"ksplayer": "KSPlayer",
"vlc": "VLC"
},
@@ -288,20 +345,20 @@
"PORTRAIT_UP": "Portrait Up",
"PORTRAIT_DOWN": "Portrait Down",
"LANDSCAPE": "Landscape",
"LANDSCAPE_LEFT": "Landscape Left",
"LANDSCAPE_RIGHT": "Landscape Right",
"LANDSCAPE_LEFT": "왼쪽 가로 모드",
"LANDSCAPE_RIGHT": "오른쪽 가로 모드",
"OTHER": "Other",
"UNKNOWN": "Unknown"
},
"safe_area_in_controls": "Safe Area in Controls",
"safe_area_in_controls": "컨트롤 안전 영역",
"video_player": "Video Player",
"video_players": {
"VLC_3": "VLC 3",
"VLC_4": "VLC 4 (Experimental + PiP)"
},
"show_custom_menu_links": "Show Custom Menu Links",
"show_large_home_carousel": "Show Large Home Carousel (beta)",
"hide_libraries": "Hide Libraries",
"show_custom_menu_links": "사용자 지정 메뉴 링크 표시",
"show_large_home_carousel": "대형 홈 슬라이드 배너 표시 (베타)",
"hide_libraries": "라이브러리 숨기기",
"select_liraries_you_want_to_hide": "Select the libraries you want to hide from the Library tab and home page sections.",
"disable_haptic_feedback": "Disable Haptic Feedback",
"default_quality": "Default Quality",
@@ -334,24 +391,24 @@
"password": "Password",
"password_placeholder": "Enter password for Jellyfin user {{username}}",
"login_button": "Login",
"total_media_requests": "Total Media Requests",
"movie_quota_limit": "Movie Quota Limit",
"movie_quota_days": "Movie Quota Days",
"total_media_requests": "전체 미디어 요청 수",
"movie_quota_limit": "영화 요청 한도",
"movie_quota_days": "영화 요청 제한 기간",
"tv_quota_limit": "TV Quota Limit",
"tv_quota_days": "TV Quota Days",
"reset_jellyseerr_config_button": "Reset Seerr Config",
"tv_quota_days": "TV 요청 제한 기간",
"reset_jellyseerr_config_button": "Seerr 설정 초기화",
"unlimited": "Unlimited",
"plus_n_more": "+{{n}} More",
"plus_n_more": "+{{n}}개 더",
"order_by": {
"DEFAULT": "Default",
"VOTE_COUNT_AND_AVERAGE": "Vote count and average",
"VOTE_COUNT_AND_AVERAGE": "평균 평점 및 투표 수",
"POPULARITY": "Popularity"
}
},
"marlin_search": {
"enable_marlin_search": "Enable Marlin Search",
"enable_marlin_search": "Marlin 검색 활성화",
"url": "URL",
"server_url_placeholder": "http(s)://domain.org:port",
"server_url_placeholder": "http(s)://도메인:포트",
"marlin_search_hint": "Enter the URL for the Marlin server. The URL should include http or https and optionally the port.",
"read_more_about_marlin": "Read More About Marlin.",
"save_button": "Save",
@@ -374,28 +431,28 @@
"features_title": "Features",
"home_sections_title": "Home Sections",
"enable_movie_recommendations": "Movie Recommendations",
"enable_series_recommendations": "Series Recommendations",
"enable_promoted_watchlists": "Promoted Watchlists",
"hide_watchlists_tab": "Hide Watchlists Tab",
"home_sections_hint": "Show personalized recommendations and promoted watchlists from Streamystats on the home page.",
"enable_series_recommendations": "시리즈 추천",
"enable_promoted_watchlists": "추천 관심 목록",
"hide_watchlists_tab": "관심 목록 탭 숨기기",
"home_sections_hint": "홈 페이지에서 Streamystats의 개인 맞춤 추천 및 추천 관심 목록을 표시합니다.",
"recommended_movies": "Recommended Movies",
"recommended_series": "Recommended Series",
"recommended_series": "추천 시리즈",
"toasts": {
"saved": "Saved",
"refreshed": "Settings refreshed from server",
"disabled": "Streamystats disabled"
"refreshed": "서버에서 설정을 새로고침했습니다",
"disabled": "Streamystats 비활성화됨"
},
"refresh_from_server": "Refresh Settings from Server"
"refresh_from_server": "서버에서 설정 새로고침"
},
"kefinTweaks": {
"watchlist_enabler": "Enable our Watchlist integration",
"watchlist_button": "Toggle Watchlist integration"
"watchlist_enabler": "관심 목록 통합 기능 활성화",
"watchlist_button": "관심 목록 연동 켜기/끄기"
}
},
"storage": {
"storage_title": "Storage",
"app_usage": "App {{usedSpace}}%",
"device_usage": "Device {{availableSpace}}%",
"app_usage": " {{usedSpace}}",
"device_usage": "디바이스 {{availableSpace}}%",
"size_used": "{{used}} of {{total}} Used",
"delete_all_downloaded_files": "Delete All Downloaded Files",
"music_cache_title": "Music Cache",
@@ -403,10 +460,16 @@
"enable_music_cache": "Enable Music Cache",
"clear_music_cache": "Clear Music Cache",
"music_cache_size": "{{size}} cached",
"music_cache_cleared": "Music cache cleared",
"music_cache_cleared": "음악 캐시가 삭제되었습니다",
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
"downloaded_songs_size": "{{size}} downloaded",
"downloaded_songs_deleted": "Downloaded songs deleted"
"downloaded_songs_deleted": "다운로드한 노래가 삭제되었습니다",
"clear_all_cache": "Clear All Cache",
"clear_all_cache_confirm": "Clear All Cache?",
"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.",
"clear_all_cache_success": "Cache Cleared",
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
},
"intro": {
"title": "Intro",
@@ -430,11 +493,26 @@
"error_deleting_files": "Error Deleting Files",
"background_downloads_enabled": "Background downloads enabled",
"background_downloads_disabled": "Background downloads disabled"
},
"security": {
"title": "Security",
"inactivity_timeout": {
"title": "Inactivity Timeout",
"description": "Auto logout after inactivity",
"disabled": "Disabled",
"1_minute": "1 minute",
"5_minutes": "5 minutes",
"15_minutes": "15 minutes",
"30_minutes": "30 minutes",
"1_hour": "1 hour",
"4_hours": "4 hours",
"24_hours": "24 hours"
}
}
},
"sessions": {
"title": "Sessions",
"no_active_sessions": "No Active Sessions"
"no_active_sessions": "세션 비활성화"
},
"downloads": {
"downloads_title": "Downloads",
@@ -456,6 +534,7 @@
"new_app_version_requires_re_download_description": "The new update requires content to be downloaded again. Please remove all downloaded content and try again.",
"back": "Back",
"delete": "Delete",
"delete_download": "Delete Download",
"something_went_wrong": "Something Went Wrong",
"could_not_get_stream_url_from_jellyfin": "Could not get the stream URL from Jellyfin",
"eta": "ETA {{eta}}",
@@ -492,22 +571,28 @@
}
},
"common": {
"no_results": "No Results",
"select": "Select",
"no_trailer_available": "No trailer available",
"video": "Video",
"audio": "Audio",
"subtitle": "Subtitle",
"play": "Play",
"mark_as_played": "Mark as Played",
"mark_as_not_played": "Mark as not Played",
"none": "None",
"track": "Track",
"cancel": "Cancel",
"stop": "Stop",
"delete": "Delete",
"ok": "OK",
"remove": "Remove",
"next": "Next",
"back": "Back",
"continue": "Continue",
"verifying": "Verifying..."
"verifying": "Verifying...",
"login": "Login",
"refresh": "Refresh"
},
"search": {
"search": "Search...",
@@ -556,6 +641,7 @@
"movies": "Movies",
"series": "Series",
"boxsets": "Box Sets",
"playlists": "Playlists",
"items": "Items"
},
"options": {
@@ -566,7 +652,8 @@
"poster": "Poster",
"cover": "Cover",
"show_titles": "Show Titles",
"show_stats": "Show Stats"
"show_stats": "Show Stats",
"options_title": "Options"
},
"filters": {
"genres": "Genres",
@@ -574,7 +661,11 @@
"sort_by": "Sort By",
"filter_by": "Filter By",
"sort_order": "Sort Order",
"tags": "Tags"
"tags": "Tags",
"all": "All",
"reset": "Reset",
"asc": "Ascending",
"desc": "Descending"
}
},
"favorites": {
@@ -591,6 +682,8 @@
"no_links": "No Links"
},
"player": {
"live": "LIVE",
"mpv_player_title": "MPV Player",
"error": "Error",
"failed_to_get_stream_url": "Failed to get the stream URL",
"an_error_occured_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
@@ -608,7 +701,35 @@
"downloaded_file_message": "Do you want to play the downloaded file?",
"downloaded_file_yes": "Yes",
"downloaded_file_no": "No",
"downloaded_file_cancel": "Cancel"
"downloaded_file_cancel": "Cancel",
"swipe_down_settings": "Swipe down for settings",
"ends_at": "Ends at {{time}}",
"search_subtitles": "Search Subtitles",
"subtitle_tracks": "Tracks",
"subtitle_search": "Search & Download",
"download": "Download",
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
"using_jellyfin_server": "Using Jellyfin Server",
"language": "Language",
"results": "Results",
"searching": "Searching...",
"search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found",
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
"settings": "Settings",
"skip_intro": "Skip Intro",
"skip_credits": "Skip Credits",
"stopPlayback": "Stop Playback",
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
"stopPlayingConfirm": "Are you sure you want to stop playback?",
"downloaded": "Downloaded"
},
"chapters": {
"title": "Chapters",
"chapter_number": "Chapter {{number}}",
"open": "Open chapters",
"close": "Close chapters"
},
"item_card": {
"next_up": "Next Up",
@@ -617,6 +738,11 @@
"series": "Series",
"seasons": "Seasons",
"season": "Season",
"from_this_series": "From This Series",
"more_from_this_season": "More from this Season",
"view_series": "View Series",
"view_season": "View Season",
"select_season": "Select Season",
"no_episodes_for_this_season": "No episodes for this season",
"overview": "Overview",
"more_with": "More with {{name}}",
@@ -627,10 +753,21 @@
"media_options": "Media Options",
"quality": "Quality",
"audio": "Audio",
"subtitles": "Subtitle",
"subtitles": {
"label": "Subtitle",
"none": "None",
"tracks": "Tracks"
},
"show_more": "Show More",
"show_less": "Show Less",
"left": "left",
"more_info": "More Info",
"director": "Director",
"cast": "Cast",
"technical_details": "Technical Details",
"appeared_in": "Appeared In",
"movies": "Movies",
"shows": "Shows",
"could_not_load_item": "Could Not Load Item",
"none": "None",
"download": {
@@ -641,7 +778,13 @@
"download_x_item": "Download {{item_count}} Items",
"download_unwatched_only": "Unwatched Only",
"download_button": "Download"
}
},
"mark_played": "Mark as Watched",
"mark_unplayed": "Mark as Unwatched",
"resume_playback": "Resume Playback",
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
"play_from_start": "Play from Start",
"continue_from": "Continue from {{time}}"
},
"live_tv": {
"next": "Next",
@@ -652,7 +795,18 @@
"movies": "Movies",
"sports": "Sports",
"for_kids": "For Kids",
"news": "News"
"news": "News",
"page_of": "Page {{current}} of {{total}}",
"no_programs": "No programs available",
"no_channels": "No channels available",
"tabs": {
"programs": "Programs",
"guide": "Guide",
"channels": "Channels",
"recordings": "Recordings",
"schedule": "Schedule",
"series": "Series"
}
},
"jellyseerr": {
"confirm": "Confirm",
@@ -697,6 +851,12 @@
"decline": "Decline",
"requested_by": "Requested by {{user}}",
"unknown_user": "Unknown User",
"select": "Select",
"request_all": "Request All",
"request_seasons": "Request Seasons",
"select_seasons": "Select Seasons",
"request_selected": "Request Selected",
"n_selected": "{{count}} selected",
"toasts": {
"jellyseer_does_not_meet_requirements": "Seerr server does not meet minimum version requirements! Please update to at least 2.0.0",
"jellyseerr_test_failed": "Seerr test failed. Please try again.",
@@ -716,7 +876,8 @@
"search": "Search",
"library": "Library",
"custom_links": "Custom Links",
"favorites": "Favorites"
"favorites": "Favorites",
"settings": "Settings"
},
"music": {
"title": "Music",
@@ -841,5 +1002,36 @@
"show": "This show",
"all": "All media (default)"
}
},
"companion_login": {
"title": "Pair with TV",
"align_qr": "Align the QR code within the frame",
"enter_code_manually": "Enter code manually",
"pairing_enter_credentials": "Enter credentials for TV",
"pairing_code_label": "Pairing code",
"server": "Server",
"authorize_button": "Authorize",
"authorizing": "Authorizing...",
"scan_again": "Scan Again",
"done": "Done",
"success_title": "Authorization Sent",
"pairing_tv_connecting": "The TV is connecting to your account",
"error_title": "Authorization Failed",
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
"error_generic": "Something went wrong. Please try again.",
"error_permission_denied": "Camera permission is required to scan QR codes.",
"login_as": "Log in as {{username}}?",
"on_server": "on {{server}}",
"use_different_user": "Use a different user",
"open_settings": "Open Settings"
},
"pairing": {
"pair_with_phone": "Pair with Phone",
"pair_with_phone_title": "Login TV",
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
"waiting_for_phone": "Waiting for phone...",
"scan_with_phone": "Scan with the Streamyfin app on your phone",
"logging_in": "Logging in...",
"logging_in_description": "Connecting to your server"
}
}

View File

@@ -4,6 +4,9 @@
"error_title": "Fout",
"login_title": "Aanmelden",
"login_to_title": "Aanmelden bij",
"select_user": "Select a user to log in",
"add_user_to_login": "Add a user to log in",
"add_user": "Add User",
"username_placeholder": "Gebruikersnaam",
"password_placeholder": "Wachtwoord",
"login_button": "Aanmelden",
@@ -42,7 +45,13 @@
"accounts_count": "{{count}} accounts",
"select_account": "Account selecteren",
"add_account": "Account toevoegen",
"remove_account_description": "Hiermee worden de opgeslagen inloggegevens voor {{username}} verwijderd."
"remove_account_description": "Hiermee worden de opgeslagen inloggegevens voor {{username}} verwijderd.",
"remove_server": "Remove Server",
"remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
"select_your_server": "Select Your Server",
"add_server_to_get_started": "Add a server to get started",
"add_server": "Add Server",
"change_server": "Change Server"
},
"save_account": {
"title": "Account opslaan",
@@ -86,6 +95,7 @@
"oops": "Oeps!",
"error_message": "Er ging iets fout\nProbeer opnieuw in te loggen.",
"continue_watching": "Verder Kijken",
"continue": "Continue",
"next_up": "Volgende",
"continue_and_next_up": "Doorgaan & Volgende",
"recently_added_in": "Recent toegevoegd in {{libraryName}}",
@@ -109,6 +119,12 @@
"settings": {
"settings_title": "Instellingen",
"log_out_button": "Afmelden",
"switch_user": {
"title": "Switch User",
"account": "Account",
"switch_user": "Switch User on This Server",
"current": "current"
},
"categories": {
"title": "Categorieën"
},
@@ -121,7 +137,16 @@
"appearance": {
"title": "Weergave",
"merge_next_up_continue_watching": "Doorgaan met kijken & Volgende samenvoegen",
"hide_remote_session_button": "Verberg Knop voor Externe Sessie"
"hide_remote_session_button": "Verberg Knop voor Externe Sessie",
"show_home_backdrop": "Dynamic Home Backdrop",
"show_hero_carousel": "Hero Carousel",
"show_series_poster_on_episode": "Show Series Poster on Episodes",
"theme_music": "Theme Music",
"display_size": "Display Size",
"display_size_small": "Small",
"display_size_default": "Default",
"display_size_large": "Large",
"display_size_extra_large": "Extra Large"
},
"network": {
"title": "Netwerk",
@@ -174,6 +199,22 @@
"rewind_length": "Duur terugspoelen",
"seconds_unit": "s"
},
"buffer": {
"title": "Buffer Settings",
"cache_mode": "Cache Mode",
"cache_auto": "Auto",
"cache_yes": "Enabled",
"cache_no": "Disabled",
"buffer_duration": "Buffer Duration",
"max_cache_size": "Max Cache Size",
"max_backward_cache": "Max Backward Cache"
},
"vo_driver": {
"title": "Video Output",
"vo_mode": "VO Driver",
"gpu_next": "gpu-next (Recommended)",
"gpu": "gpu"
},
"gesture_controls": {
"gesture_controls_title": "Gebaar Bediening",
"horizontal_swipe_skip": "Horizontale Swipe om over te slaan",
@@ -256,7 +297,23 @@
"subtitle_font": "Lettertype ondertitels",
"ksplayer_title": "KSPlayer Instellingen",
"hardware_decode": "Hardware Acceleratie",
"hardware_decode_description": "Gebruik hardware acceleratie voor video-decodering. Uitschakelen als u problemen met afspelen ondervindt."
"hardware_decode_description": "Gebruik hardware acceleratie voor video-decodering. Uitschakelen als u problemen met afspelen ondervindt.",
"opensubtitles_title": "OpenSubtitles",
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
"opensubtitles_api_key": "API Key",
"opensubtitles_api_key_placeholder": "Enter API key...",
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
"mpv_subtitle_scale": "Subtitle Scale",
"mpv_subtitle_margin_y": "Vertical Margin",
"mpv_subtitle_align_x": "Horizontal Align",
"mpv_subtitle_align_y": "Vertical Align",
"align": {
"left": "Left",
"center": "Center",
"right": "Right",
"top": "Top",
"bottom": "Bottom"
}
},
"vlc_subtitles": {
"title": "VLC ondertitel instellingen",
@@ -406,7 +463,13 @@
"music_cache_cleared": "Muziek cache gewist",
"delete_all_downloaded_songs": "Verwijder alle gedownloade liedjes",
"downloaded_songs_size": "{{size}} gedownload",
"downloaded_songs_deleted": "Downloaded songs deleted"
"downloaded_songs_deleted": "Downloaded songs deleted",
"clear_all_cache": "Clear All Cache",
"clear_all_cache_confirm": "Clear All Cache?",
"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.",
"clear_all_cache_success": "Cache Cleared",
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
},
"intro": {
"title": "Intro",
@@ -430,6 +493,21 @@
"error_deleting_files": "Fout bij het verwijderen van bestanden",
"background_downloads_enabled": "Downloads op de achtergrond ingeschakeld",
"background_downloads_disabled": "Downloads op de achtergrond uitgeschakeld"
},
"security": {
"title": "Security",
"inactivity_timeout": {
"title": "Inactivity Timeout",
"description": "Auto logout after inactivity",
"disabled": "Disabled",
"1_minute": "1 minute",
"5_minutes": "5 minutes",
"15_minutes": "15 minutes",
"30_minutes": "30 minutes",
"1_hour": "1 hour",
"4_hours": "4 hours",
"24_hours": "24 hours"
}
}
},
"sessions": {
@@ -456,6 +534,7 @@
"new_app_version_requires_re_download_description": "Voor de nieuwe update moet de content opnieuw worden gedownload. Verwijder alle gedownloade content en probeer het opnieuw.",
"back": "Terug",
"delete": "Verwijder",
"delete_download": "Delete Download",
"something_went_wrong": "Er ging iets mis",
"could_not_get_stream_url_from_jellyfin": "Kon de URL van de stream niet krijgen van Jellyfin",
"eta": "ETA {{eta}}",
@@ -492,22 +571,28 @@
}
},
"common": {
"no_results": "No Results",
"select": "Selecteren",
"no_trailer_available": "Geen trailer beschikbaar",
"video": "Video",
"audio": "Audio",
"subtitle": "Ondertitel",
"play": "Afspelen",
"mark_as_played": "Mark as Played",
"mark_as_not_played": "Mark as not Played",
"none": "Geen",
"track": "Spoor",
"cancel": "Annuleren",
"stop": "Stop",
"delete": "Verwijderen",
"ok": "Oké",
"remove": "Verwijderen",
"next": "Volgende",
"back": "Terug",
"continue": "Doorgaan",
"verifying": "Verifiëren..."
"verifying": "Verifiëren...",
"login": "Login",
"refresh": "Refresh"
},
"search": {
"search": "Zoek...",
@@ -556,6 +641,7 @@
"movies": "Films",
"series": "Series",
"boxsets": "Boxsets",
"playlists": "Playlists",
"items": "items"
},
"options": {
@@ -566,7 +652,8 @@
"poster": "Poster",
"cover": "Omslag",
"show_titles": "Toon titels",
"show_stats": "Toon statistieken"
"show_stats": "Toon statistieken",
"options_title": "Options"
},
"filters": {
"genres": "Genres",
@@ -574,7 +661,11 @@
"sort_by": "Sorteren op",
"filter_by": "Filteren op",
"sort_order": "Sorteer volgorde",
"tags": "Labels"
"tags": "Labels",
"all": "All",
"reset": "Reset",
"asc": "Ascending",
"desc": "Descending"
}
},
"favorites": {
@@ -591,6 +682,8 @@
"no_links": "Geen links"
},
"player": {
"live": "LIVE",
"mpv_player_title": "MPV Player",
"error": "Fout",
"failed_to_get_stream_url": "De stream-URL kon niet worden verkregen",
"an_error_occured_while_playing_the_video": "Er is een fout opgetreden tijdens het afspelen van de video. Controleer de logs in de instellingen.",
@@ -608,7 +701,35 @@
"downloaded_file_message": "Wil je het gedownloade bestand afspelen?",
"downloaded_file_yes": "Ja",
"downloaded_file_no": "Nee",
"downloaded_file_cancel": "Annuleren"
"downloaded_file_cancel": "Annuleren",
"swipe_down_settings": "Swipe down for settings",
"ends_at": "Ends at {{time}}",
"search_subtitles": "Search Subtitles",
"subtitle_tracks": "Tracks",
"subtitle_search": "Search & Download",
"download": "Download",
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
"using_jellyfin_server": "Using Jellyfin Server",
"language": "Language",
"results": "Results",
"searching": "Searching...",
"search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found",
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
"settings": "Settings",
"skip_intro": "Skip Intro",
"skip_credits": "Skip Credits",
"stopPlayback": "Stop Playback",
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
"stopPlayingConfirm": "Are you sure you want to stop playback?",
"downloaded": "Downloaded"
},
"chapters": {
"title": "Chapters",
"chapter_number": "Chapter {{number}}",
"open": "Open chapters",
"close": "Close chapters"
},
"item_card": {
"next_up": "Volgende",
@@ -617,6 +738,11 @@
"series": "Series",
"seasons": "Seizoenen",
"season": "Seizoen",
"from_this_series": "From This Series",
"more_from_this_season": "More from this Season",
"view_series": "View Series",
"view_season": "View Season",
"select_season": "Select Season",
"no_episodes_for_this_season": "Geen afleveringen voor dit seizoen",
"overview": "Overzicht",
"more_with": "Meer met {{name}}",
@@ -627,10 +753,21 @@
"media_options": "Media opties",
"quality": "Kwaliteit",
"audio": "Audio",
"subtitles": "Ondertitel",
"subtitles": {
"label": "Subtitle",
"none": "None",
"tracks": "Tracks"
},
"show_more": "Toon meer",
"show_less": "Toon minder",
"left": "left",
"more_info": "More Info",
"director": "Director",
"cast": "Cast",
"technical_details": "Technical Details",
"appeared_in": "Verschenen in",
"movies": "Movies",
"shows": "Shows",
"could_not_load_item": "Kon item niet laden",
"none": "Geen",
"download": {
@@ -641,7 +778,13 @@
"download_x_item": "Download {{item_count}} items",
"download_unwatched_only": "Alleen niet bekeken",
"download_button": "Downloaden"
}
},
"mark_played": "Mark as Watched",
"mark_unplayed": "Mark as Unwatched",
"resume_playback": "Resume Playback",
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
"play_from_start": "Play from Start",
"continue_from": "Continue from {{time}}"
},
"live_tv": {
"next": "Volgende ",
@@ -652,7 +795,18 @@
"movies": "Films",
"sports": "Sport",
"for_kids": "Voor kinderen",
"news": "Nieuws"
"news": "Nieuws",
"page_of": "Page {{current}} of {{total}}",
"no_programs": "No programs available",
"no_channels": "No channels available",
"tabs": {
"programs": "Programs",
"guide": "Guide",
"channels": "Channels",
"recordings": "Recordings",
"schedule": "Schedule",
"series": "Series"
}
},
"jellyseerr": {
"confirm": "Bevestig",
@@ -697,6 +851,12 @@
"decline": "Weigeren",
"requested_by": "Aangevraagd door {{user}}",
"unknown_user": "Onbekende gebruiker",
"select": "Select",
"request_all": "Request All",
"request_seasons": "Request Seasons",
"select_seasons": "Select Seasons",
"request_selected": "Request Selected",
"n_selected": "{{count}} selected",
"toasts": {
"jellyseer_does_not_meet_requirements": "Jellyseerr server voldoet niet aan de minimale versievereisten! Update naar minimaal 2.0.0",
"jellyseerr_test_failed": "Jellyseerr test mislukt. Probeer opnieuw.",
@@ -716,7 +876,8 @@
"search": "Zoeken",
"library": "Bibliotheek",
"custom_links": "Aangepaste links",
"favorites": "Favorieten"
"favorites": "Favorieten",
"settings": "Settings"
},
"music": {
"title": "Muziek",
@@ -841,5 +1002,36 @@
"show": "Deze serie",
"all": "Alle media (standaard)"
}
},
"companion_login": {
"title": "Pair with TV",
"align_qr": "Align the QR code within the frame",
"enter_code_manually": "Enter code manually",
"pairing_enter_credentials": "Enter credentials for TV",
"pairing_code_label": "Pairing code",
"server": "Server",
"authorize_button": "Authorize",
"authorizing": "Authorizing...",
"scan_again": "Scan Again",
"done": "Done",
"success_title": "Authorization Sent",
"pairing_tv_connecting": "The TV is connecting to your account",
"error_title": "Authorization Failed",
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
"error_generic": "Something went wrong. Please try again.",
"error_permission_denied": "Camera permission is required to scan QR codes.",
"login_as": "Log in as {{username}}?",
"on_server": "on {{server}}",
"use_different_user": "Use a different user",
"open_settings": "Open Settings"
},
"pairing": {
"pair_with_phone": "Pair with Phone",
"pair_with_phone_title": "Login TV",
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
"waiting_for_phone": "Waiting for phone...",
"scan_with_phone": "Scan with the Streamyfin app on your phone",
"logging_in": "Logging in...",
"logging_in_description": "Connecting to your server"
}
}

View File

@@ -4,6 +4,9 @@
"error_title": "Feil",
"login_title": "Logg inn",
"login_to_title": "Logg inn i",
"select_user": "Select a user to log in",
"add_user_to_login": "Add a user to log in",
"add_user": "Add User",
"username_placeholder": "Brukernavn",
"password_placeholder": "Passord",
"login_button": "Logg inn",
@@ -42,7 +45,13 @@
"accounts_count": "{{count}} accounts",
"select_account": "Select Account",
"add_account": "Add Account",
"remove_account_description": "This will remove the saved credentials for {{username}}."
"remove_account_description": "This will remove the saved credentials for {{username}}.",
"remove_server": "Remove Server",
"remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
"select_your_server": "Select Your Server",
"add_server_to_get_started": "Add a server to get started",
"add_server": "Add Server",
"change_server": "Change Server"
},
"save_account": {
"title": "Save Account",
@@ -86,6 +95,7 @@
"oops": "Oisann!",
"error_message": "Noe gikk galt.\nVennligst logg ut og inn igjen.",
"continue_watching": "Fortsett å se",
"continue": "Continue",
"next_up": "Neste opp",
"continue_and_next_up": "Continue & Next Up",
"recently_added_in": "Nylig lagt til i {{libraryName}}",
@@ -109,6 +119,12 @@
"settings": {
"settings_title": "Innstillinger",
"log_out_button": "Logg ut",
"switch_user": {
"title": "Switch User",
"account": "Account",
"switch_user": "Switch User on This Server",
"current": "current"
},
"categories": {
"title": "Categories"
},
@@ -121,7 +137,16 @@
"appearance": {
"title": "Appearance",
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
"hide_remote_session_button": "Hide Remote Session Button"
"hide_remote_session_button": "Hide Remote Session Button",
"show_home_backdrop": "Dynamic Home Backdrop",
"show_hero_carousel": "Hero Carousel",
"show_series_poster_on_episode": "Show Series Poster on Episodes",
"theme_music": "Theme Music",
"display_size": "Display Size",
"display_size_small": "Small",
"display_size_default": "Default",
"display_size_large": "Large",
"display_size_extra_large": "Extra Large"
},
"network": {
"title": "Network",
@@ -174,6 +199,22 @@
"rewind_length": "Omspar lengde",
"seconds_unit": "S"
},
"buffer": {
"title": "Buffer Settings",
"cache_mode": "Cache Mode",
"cache_auto": "Auto",
"cache_yes": "Enabled",
"cache_no": "Disabled",
"buffer_duration": "Buffer Duration",
"max_cache_size": "Max Cache Size",
"max_backward_cache": "Max Backward Cache"
},
"vo_driver": {
"title": "Video Output",
"vo_mode": "VO Driver",
"gpu_next": "gpu-next (Recommended)",
"gpu": "gpu"
},
"gesture_controls": {
"gesture_controls_title": "Gest kontroller",
"horizontal_swipe_skip": "Vannrett sveip for å hoppe over",
@@ -256,7 +297,23 @@
"subtitle_font": "Subtitle Font",
"ksplayer_title": "KSPlayer Settings",
"hardware_decode": "Hardware Decoding",
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues."
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues.",
"opensubtitles_title": "OpenSubtitles",
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
"opensubtitles_api_key": "API Key",
"opensubtitles_api_key_placeholder": "Enter API key...",
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
"mpv_subtitle_scale": "Subtitle Scale",
"mpv_subtitle_margin_y": "Vertical Margin",
"mpv_subtitle_align_x": "Horizontal Align",
"mpv_subtitle_align_y": "Vertical Align",
"align": {
"left": "Left",
"center": "Center",
"right": "Right",
"top": "Top",
"bottom": "Bottom"
}
},
"vlc_subtitles": {
"title": "VLC Subtitle Settings",
@@ -406,7 +463,13 @@
"music_cache_cleared": "Music cache cleared",
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
"downloaded_songs_size": "{{size}} downloaded",
"downloaded_songs_deleted": "Downloaded songs deleted"
"downloaded_songs_deleted": "Downloaded songs deleted",
"clear_all_cache": "Clear All Cache",
"clear_all_cache_confirm": "Clear All Cache?",
"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.",
"clear_all_cache_success": "Cache Cleared",
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
},
"intro": {
"title": "Intro",
@@ -430,6 +493,21 @@
"error_deleting_files": "Feil ved sletting av filer",
"background_downloads_enabled": "Nedlastinger av bakgrunn aktivert",
"background_downloads_disabled": "Bakgrunnsnedlastinger deaktivert"
},
"security": {
"title": "Security",
"inactivity_timeout": {
"title": "Inactivity Timeout",
"description": "Auto logout after inactivity",
"disabled": "Disabled",
"1_minute": "1 minute",
"5_minutes": "5 minutes",
"15_minutes": "15 minutes",
"30_minutes": "30 minutes",
"1_hour": "1 hour",
"4_hours": "4 hours",
"24_hours": "24 hours"
}
}
},
"sessions": {
@@ -456,6 +534,7 @@
"new_app_version_requires_re_download_description": "Den nye oppdateringen krever at innholdet lastes ned på nytt. Fjern alt nedlastet innhold og prøv på nytt.",
"back": "Tilbake",
"delete": "Slett",
"delete_download": "Delete Download",
"something_went_wrong": "Noe gikk galt",
"could_not_get_stream_url_from_jellyfin": "Kunne ikke hente stream-URL fra Jellyfin",
"eta": "ETA {{eta}}",
@@ -492,22 +571,28 @@
}
},
"common": {
"no_results": "No Results",
"select": "Velg",
"no_trailer_available": "Ingen trailer tilgjengelig",
"video": "Video",
"audio": "Lyd",
"subtitle": "Undertittel",
"play": "Spill",
"mark_as_played": "Mark as Played",
"mark_as_not_played": "Mark as not Played",
"none": "None",
"track": "Track",
"cancel": "Cancel",
"stop": "Stop",
"delete": "Delete",
"ok": "OK",
"remove": "Remove",
"next": "Next",
"back": "Back",
"continue": "Continue",
"verifying": "Verifying..."
"verifying": "Verifying...",
"login": "Login",
"refresh": "Refresh"
},
"search": {
"search": "Søk...",
@@ -556,6 +641,7 @@
"movies": "Filmer",
"series": "Serier",
"boxsets": "Boks sett",
"playlists": "Playlists",
"items": "Elementer"
},
"options": {
@@ -566,7 +652,8 @@
"poster": "Plakat",
"cover": "Omslag",
"show_titles": "Vis titler",
"show_stats": "Vis statistikk"
"show_stats": "Vis statistikk",
"options_title": "Options"
},
"filters": {
"genres": "Genres",
@@ -574,7 +661,11 @@
"sort_by": "Sorter etter",
"filter_by": "Filter By",
"sort_order": "Sorter etter",
"tags": "Tagger"
"tags": "Tagger",
"all": "All",
"reset": "Reset",
"asc": "Ascending",
"desc": "Descending"
}
},
"favorites": {
@@ -591,6 +682,8 @@
"no_links": "Ingen lenke"
},
"player": {
"live": "LIVE",
"mpv_player_title": "MPV Player",
"error": "Feil",
"failed_to_get_stream_url": "Kan ikke hente nettadressen for stream",
"an_error_occured_while_playing_the_video": "En feil oppstod under video. Sjekk loggene i innstillingene.",
@@ -608,7 +701,35 @@
"downloaded_file_message": "Do you want to play the downloaded file?",
"downloaded_file_yes": "Yes",
"downloaded_file_no": "No",
"downloaded_file_cancel": "Cancel"
"downloaded_file_cancel": "Cancel",
"swipe_down_settings": "Swipe down for settings",
"ends_at": "Ends at {{time}}",
"search_subtitles": "Search Subtitles",
"subtitle_tracks": "Tracks",
"subtitle_search": "Search & Download",
"download": "Download",
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
"using_jellyfin_server": "Using Jellyfin Server",
"language": "Language",
"results": "Results",
"searching": "Searching...",
"search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found",
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
"settings": "Settings",
"skip_intro": "Skip Intro",
"skip_credits": "Skip Credits",
"stopPlayback": "Stop Playback",
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
"stopPlayingConfirm": "Are you sure you want to stop playback?",
"downloaded": "Downloaded"
},
"chapters": {
"title": "Chapters",
"chapter_number": "Chapter {{number}}",
"open": "Open chapters",
"close": "Close chapters"
},
"item_card": {
"next_up": "Neste opp",
@@ -617,6 +738,11 @@
"series": "Serier",
"seasons": "Sesonger",
"season": "Sesong",
"from_this_series": "From This Series",
"more_from_this_season": "More from this Season",
"view_series": "View Series",
"view_season": "View Season",
"select_season": "Select Season",
"no_episodes_for_this_season": "Ingen episoder for denne sesongen",
"overview": "Oversikt",
"more_with": "Mer med {{name}}",
@@ -627,10 +753,21 @@
"media_options": "Media Options",
"quality": "Kvalitet",
"audio": "Lyd",
"subtitles": "Undertittel",
"subtitles": {
"label": "Subtitle",
"none": "None",
"tracks": "Tracks"
},
"show_more": "Vis mer",
"show_less": "Vis mindre",
"left": "left",
"more_info": "More Info",
"director": "Director",
"cast": "Cast",
"technical_details": "Technical Details",
"appeared_in": "Ble brukt i",
"movies": "Movies",
"shows": "Shows",
"could_not_load_item": "Kan ikke laste inn produkt",
"none": "Ingen",
"download": {
@@ -641,7 +778,13 @@
"download_x_item": "Last ned {{item_count}} Objekter",
"download_unwatched_only": "Bare usette",
"download_button": "Nedlasting"
}
},
"mark_played": "Mark as Watched",
"mark_unplayed": "Mark as Unwatched",
"resume_playback": "Resume Playback",
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
"play_from_start": "Play from Start",
"continue_from": "Continue from {{time}}"
},
"live_tv": {
"next": "Neste",
@@ -652,7 +795,18 @@
"movies": "Filmer",
"sports": "Sport",
"for_kids": "For barn",
"news": "Nyheter"
"news": "Nyheter",
"page_of": "Page {{current}} of {{total}}",
"no_programs": "No programs available",
"no_channels": "No channels available",
"tabs": {
"programs": "Programs",
"guide": "Guide",
"channels": "Channels",
"recordings": "Recordings",
"schedule": "Schedule",
"series": "Series"
}
},
"jellyseerr": {
"confirm": "Bekreft",
@@ -697,6 +851,12 @@
"decline": "Decline",
"requested_by": "Requested by {{user}}",
"unknown_user": "Unknown User",
"select": "Select",
"request_all": "Request All",
"request_seasons": "Request Seasons",
"select_seasons": "Select Seasons",
"request_selected": "Request Selected",
"n_selected": "{{count}} selected",
"toasts": {
"jellyseer_does_not_meet_requirements": "Seerr server oppfyller ikke minimumskravene til versjoner! Vennligst oppdater til minst 2.0.0",
"jellyseerr_test_failed": "Seerr-test mislyktes. Vennligst prøv på nytt.",
@@ -716,7 +876,8 @@
"search": "Søk",
"library": "Bibliotek",
"custom_links": "Egendefinerte lenker",
"favorites": "Favoritter"
"favorites": "Favoritter",
"settings": "Settings"
},
"music": {
"title": "Music",
@@ -841,5 +1002,36 @@
"show": "This show",
"all": "All media (default)"
}
},
"companion_login": {
"title": "Pair with TV",
"align_qr": "Align the QR code within the frame",
"enter_code_manually": "Enter code manually",
"pairing_enter_credentials": "Enter credentials for TV",
"pairing_code_label": "Pairing code",
"server": "Server",
"authorize_button": "Authorize",
"authorizing": "Authorizing...",
"scan_again": "Scan Again",
"done": "Done",
"success_title": "Authorization Sent",
"pairing_tv_connecting": "The TV is connecting to your account",
"error_title": "Authorization Failed",
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
"error_generic": "Something went wrong. Please try again.",
"error_permission_denied": "Camera permission is required to scan QR codes.",
"login_as": "Log in as {{username}}?",
"on_server": "on {{server}}",
"use_different_user": "Use a different user",
"open_settings": "Open Settings"
},
"pairing": {
"pair_with_phone": "Pair with Phone",
"pair_with_phone_title": "Login TV",
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
"waiting_for_phone": "Waiting for phone...",
"scan_with_phone": "Scan with the Streamyfin app on your phone",
"logging_in": "Logging in...",
"logging_in_description": "Connecting to your server"
}
}

View File

@@ -4,6 +4,9 @@
"error_title": "Błąd",
"login_title": "Zaloguj się",
"login_to_title": "Zaloguj się do",
"select_user": "Select a user to log in",
"add_user_to_login": "Add a user to log in",
"add_user": "Add User",
"username_placeholder": "Nazwa użytkownika",
"password_placeholder": "Hasło",
"login_button": "Zaloguj się",
@@ -42,7 +45,13 @@
"accounts_count": "{{count}} kont",
"select_account": "Wybierz konto",
"add_account": "Dodaj konto",
"remove_account_description": "Spowoduje to usunięcie zapisanych danych logowania dla {{username}}."
"remove_account_description": "Spowoduje to usunięcie zapisanych danych logowania dla {{username}}.",
"remove_server": "Remove Server",
"remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
"select_your_server": "Select Your Server",
"add_server_to_get_started": "Add a server to get started",
"add_server": "Add Server",
"change_server": "Change Server"
},
"save_account": {
"title": "Zapisz konto",
@@ -86,6 +95,7 @@
"oops": "Ups!",
"error_message": "Coś poszło nie tak.\nWyloguj się i zaloguj ponownie.",
"continue_watching": "Kontynuuj oglądanie",
"continue": "Continue",
"next_up": "Następne w kolejce",
"continue_and_next_up": "Oglądaj dalej i Następne",
"recently_added_in": "Ostatnio dodano w {{libraryName}}",
@@ -109,6 +119,12 @@
"settings": {
"settings_title": "Ustawienia",
"log_out_button": "Wyloguj się",
"switch_user": {
"title": "Switch User",
"account": "Account",
"switch_user": "Switch User on This Server",
"current": "current"
},
"categories": {
"title": "Kategorie"
},
@@ -121,7 +137,16 @@
"appearance": {
"title": "Wygląd",
"merge_next_up_continue_watching": "Połącz Oglądaj dalej i Następne",
"hide_remote_session_button": "Ukryj przycisk Zdalnej Sesji"
"hide_remote_session_button": "Ukryj przycisk Zdalnej Sesji",
"show_home_backdrop": "Dynamic Home Backdrop",
"show_hero_carousel": "Hero Carousel",
"show_series_poster_on_episode": "Show Series Poster on Episodes",
"theme_music": "Theme Music",
"display_size": "Display Size",
"display_size_small": "Small",
"display_size_default": "Default",
"display_size_large": "Large",
"display_size_extra_large": "Extra Large"
},
"network": {
"title": "Network",
@@ -174,6 +199,22 @@
"rewind_length": "Długość przewijania do tyłu",
"seconds_unit": "s"
},
"buffer": {
"title": "Buffer Settings",
"cache_mode": "Cache Mode",
"cache_auto": "Auto",
"cache_yes": "Enabled",
"cache_no": "Disabled",
"buffer_duration": "Buffer Duration",
"max_cache_size": "Max Cache Size",
"max_backward_cache": "Max Backward Cache"
},
"vo_driver": {
"title": "Video Output",
"vo_mode": "VO Driver",
"gpu_next": "gpu-next (Recommended)",
"gpu": "gpu"
},
"gesture_controls": {
"gesture_controls_title": "Sterowanie gestami",
"horizontal_swipe_skip": "Przesuń w poziomie, aby pominąć",
@@ -256,7 +297,23 @@
"subtitle_font": "Czcionka napisów",
"ksplayer_title": "Ustawienia KSPlayer",
"hardware_decode": "Dekodowanie sprzętowe",
"hardware_decode_description": "Używaj akceleracji sprzętowej dla dekodowania wideo. Wyłącz, jeśli doświadczasz problemów z odtwarzaniem."
"hardware_decode_description": "Używaj akceleracji sprzętowej dla dekodowania wideo. Wyłącz, jeśli doświadczasz problemów z odtwarzaniem.",
"opensubtitles_title": "OpenSubtitles",
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
"opensubtitles_api_key": "API Key",
"opensubtitles_api_key_placeholder": "Enter API key...",
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
"mpv_subtitle_scale": "Subtitle Scale",
"mpv_subtitle_margin_y": "Vertical Margin",
"mpv_subtitle_align_x": "Horizontal Align",
"mpv_subtitle_align_y": "Vertical Align",
"align": {
"left": "Left",
"center": "Center",
"right": "Right",
"top": "Top",
"bottom": "Bottom"
}
},
"vlc_subtitles": {
"title": "Ustawienia napisów VLC",
@@ -406,7 +463,13 @@
"music_cache_cleared": "Wyczyszczono bufor muzyki",
"delete_all_downloaded_songs": "Usuń wszystkie pobrane piosenki",
"downloaded_songs_size": "Pobrano {{size}}",
"downloaded_songs_deleted": "Usunięto pobrane piosenki"
"downloaded_songs_deleted": "Usunięto pobrane piosenki",
"clear_all_cache": "Clear All Cache",
"clear_all_cache_confirm": "Clear All Cache?",
"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.",
"clear_all_cache_success": "Cache Cleared",
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
},
"intro": {
"title": "Wstęp",
@@ -430,6 +493,21 @@
"error_deleting_files": "Błąd podczas usuwania plików",
"background_downloads_enabled": "Pobieranie w tle włączone",
"background_downloads_disabled": "Pobieranie w tle wyłączone"
},
"security": {
"title": "Security",
"inactivity_timeout": {
"title": "Inactivity Timeout",
"description": "Auto logout after inactivity",
"disabled": "Disabled",
"1_minute": "1 minute",
"5_minutes": "5 minutes",
"15_minutes": "15 minutes",
"30_minutes": "30 minutes",
"1_hour": "1 hour",
"4_hours": "4 hours",
"24_hours": "24 hours"
}
}
},
"sessions": {
@@ -456,6 +534,7 @@
"new_app_version_requires_re_download_description": "Nowa aktualizacja wymaga ponownego pobrania treści. Usuń wszystkie pobrane materiały i spróbuj ponownie.",
"back": "Wstecz",
"delete": "Usuń",
"delete_download": "Delete Download",
"something_went_wrong": "Coś poszło nie tak",
"could_not_get_stream_url_from_jellyfin": "Nie udało się pobrać adresu URL transmisji z Jellyfin",
"eta": "Szacowany czas: {{eta}}",
@@ -492,22 +571,28 @@
}
},
"common": {
"no_results": "No Results",
"select": "Wybierz",
"no_trailer_available": "Brak dostępnego zwiastunu",
"video": "Wideo",
"audio": "Dźwięk",
"subtitle": "Napisy",
"play": "Odtwórz",
"mark_as_played": "Mark as Played",
"mark_as_not_played": "Mark as not Played",
"none": "Nic",
"track": "Utwór",
"cancel": "Anuluj",
"stop": "Stop",
"delete": "Usuń",
"ok": "OK",
"remove": "Usuń",
"next": "Następne",
"back": "Poprzednie",
"continue": "Kontynuuj",
"verifying": "Weryfikacja..."
"verifying": "Weryfikacja...",
"login": "Login",
"refresh": "Refresh"
},
"search": {
"search": "Szukaj...",
@@ -556,6 +641,7 @@
"movies": "filmy",
"series": "seriale",
"boxsets": "zestawy",
"playlists": "Playlists",
"items": "elementy"
},
"options": {
@@ -566,7 +652,8 @@
"poster": "Plakat",
"cover": "Okładka",
"show_titles": "Pokaż tytuły",
"show_stats": "Pokaż statystyki"
"show_stats": "Pokaż statystyki",
"options_title": "Options"
},
"filters": {
"genres": "Gatunki",
@@ -574,7 +661,11 @@
"sort_by": "Sortuj według",
"filter_by": "Filtruj po",
"sort_order": "Kolejność sortowania",
"tags": "Tagi"
"tags": "Tagi",
"all": "All",
"reset": "Reset",
"asc": "Ascending",
"desc": "Descending"
}
},
"favorites": {
@@ -591,6 +682,8 @@
"no_links": "Brak odnośników"
},
"player": {
"live": "LIVE",
"mpv_player_title": "MPV Player",
"error": "Błąd",
"failed_to_get_stream_url": "Nie udało się pobrać adresu strumienia",
"an_error_occured_while_playing_the_video": "Wystąpił błąd podczas odtwarzania wideo. Sprawdź logi w ustawieniach.",
@@ -608,7 +701,35 @@
"downloaded_file_message": "Chcesz odtworzyć pobrany plik?",
"downloaded_file_yes": "Tak",
"downloaded_file_no": "Nie",
"downloaded_file_cancel": "Anuluj"
"downloaded_file_cancel": "Anuluj",
"swipe_down_settings": "Swipe down for settings",
"ends_at": "Ends at {{time}}",
"search_subtitles": "Search Subtitles",
"subtitle_tracks": "Tracks",
"subtitle_search": "Search & Download",
"download": "Download",
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
"using_jellyfin_server": "Using Jellyfin Server",
"language": "Language",
"results": "Results",
"searching": "Searching...",
"search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found",
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
"settings": "Settings",
"skip_intro": "Skip Intro",
"skip_credits": "Skip Credits",
"stopPlayback": "Stop Playback",
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
"stopPlayingConfirm": "Are you sure you want to stop playback?",
"downloaded": "Downloaded"
},
"chapters": {
"title": "Chapters",
"chapter_number": "Chapter {{number}}",
"open": "Open chapters",
"close": "Close chapters"
},
"item_card": {
"next_up": "Następne",
@@ -617,6 +738,11 @@
"series": "Serial",
"seasons": "Sezony",
"season": "Sezon",
"from_this_series": "From This Series",
"more_from_this_season": "More from this Season",
"view_series": "View Series",
"view_season": "View Season",
"select_season": "Select Season",
"no_episodes_for_this_season": "Brak odcinków w tym sezonie",
"overview": "Opis",
"more_with": "Więcej z {{name}}",
@@ -627,10 +753,21 @@
"media_options": "Ustawienia mediów",
"quality": "Jakość",
"audio": "Dźwięk",
"subtitles": "Napisy",
"subtitles": {
"label": "Subtitle",
"none": "None",
"tracks": "Tracks"
},
"show_more": "Pokaż więcej",
"show_less": "Pokaż mniej",
"left": "left",
"more_info": "More Info",
"director": "Director",
"cast": "Cast",
"technical_details": "Technical Details",
"appeared_in": "Wystąpił w",
"movies": "Movies",
"shows": "Shows",
"could_not_load_item": "Nie udało się wczytać elementu",
"none": "Brak",
"download": {
@@ -641,7 +778,13 @@
"download_x_item": "Pobierz {{item_count}} elementów",
"download_unwatched_only": "Tylko nieobejrzane",
"download_button": "Pobierz"
}
},
"mark_played": "Mark as Watched",
"mark_unplayed": "Mark as Unwatched",
"resume_playback": "Resume Playback",
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
"play_from_start": "Play from Start",
"continue_from": "Continue from {{time}}"
},
"live_tv": {
"next": "Następny",
@@ -652,7 +795,18 @@
"movies": "Filmy",
"sports": "Sport",
"for_kids": "Dla dzieci",
"news": "Wiadomości"
"news": "Wiadomości",
"page_of": "Page {{current}} of {{total}}",
"no_programs": "No programs available",
"no_channels": "No channels available",
"tabs": {
"programs": "Programs",
"guide": "Guide",
"channels": "Channels",
"recordings": "Recordings",
"schedule": "Schedule",
"series": "Series"
}
},
"jellyseerr": {
"confirm": "Potwierdź",
@@ -697,6 +851,12 @@
"decline": "Odrzuć",
"requested_by": "Poproszone przez {{user}}",
"unknown_user": "Nieznany użytkownik",
"select": "Select",
"request_all": "Request All",
"request_seasons": "Request Seasons",
"select_seasons": "Select Seasons",
"request_selected": "Request Selected",
"n_selected": "{{count}} selected",
"toasts": {
"jellyseer_does_not_meet_requirements": "Serwer Jellyseerr nie spełnia minimalnych wymagań wersji! Zaktualizuj go co najmniej do wersji 2.0.0",
"jellyseerr_test_failed": "Test Jellyseerr nie powiódł się. Spróbuj ponownie.",
@@ -716,7 +876,8 @@
"search": "Szukaj",
"library": "Biblioteka",
"custom_links": "Niestandardowe odnośniki",
"favorites": "Ulubione"
"favorites": "Ulubione",
"settings": "Settings"
},
"music": {
"title": "Muzyka",
@@ -841,5 +1002,36 @@
"show": "Ten odcinek",
"all": "Wszystkie media (domyślne)"
}
},
"companion_login": {
"title": "Pair with TV",
"align_qr": "Align the QR code within the frame",
"enter_code_manually": "Enter code manually",
"pairing_enter_credentials": "Enter credentials for TV",
"pairing_code_label": "Pairing code",
"server": "Server",
"authorize_button": "Authorize",
"authorizing": "Authorizing...",
"scan_again": "Scan Again",
"done": "Done",
"success_title": "Authorization Sent",
"pairing_tv_connecting": "The TV is connecting to your account",
"error_title": "Authorization Failed",
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
"error_generic": "Something went wrong. Please try again.",
"error_permission_denied": "Camera permission is required to scan QR codes.",
"login_as": "Log in as {{username}}?",
"on_server": "on {{server}}",
"use_different_user": "Use a different user",
"open_settings": "Open Settings"
},
"pairing": {
"pair_with_phone": "Pair with Phone",
"pair_with_phone_title": "Login TV",
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
"waiting_for_phone": "Waiting for phone...",
"scan_with_phone": "Scan with the Streamyfin app on your phone",
"logging_in": "Logging in...",
"logging_in_description": "Connecting to your server"
}
}

View File

@@ -4,6 +4,9 @@
"error_title": "Erro",
"login_title": "Iniciar sessão",
"login_to_title": "Iniciar sessão em",
"select_user": "Select a user to log in",
"add_user_to_login": "Add a user to log in",
"add_user": "Add User",
"username_placeholder": "Usuário",
"password_placeholder": "Palavra-passe",
"login_button": "Iniciar sessão",
@@ -42,7 +45,13 @@
"accounts_count": "{{count}} accounts",
"select_account": "Select Account",
"add_account": "Add Account",
"remove_account_description": "This will remove the saved credentials for {{username}}."
"remove_account_description": "This will remove the saved credentials for {{username}}.",
"remove_server": "Remove Server",
"remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
"select_your_server": "Select Your Server",
"add_server_to_get_started": "Add a server to get started",
"add_server": "Add Server",
"change_server": "Change Server"
},
"save_account": {
"title": "Save Account",
@@ -86,6 +95,7 @@
"oops": "Opa!",
"error_message": "Algo deu errado.\nPor favor, saia e entre novamente.",
"continue_watching": "Continuar assistindo",
"continue": "Continue",
"next_up": "A Seguir",
"continue_and_next_up": "Continuar e Próximo",
"recently_added_in": "Adicionado recentemente em {{libraryName}}",
@@ -109,6 +119,12 @@
"settings": {
"settings_title": "Confirgurações",
"log_out_button": "Encerrar Sessão",
"switch_user": {
"title": "Switch User",
"account": "Account",
"switch_user": "Switch User on This Server",
"current": "current"
},
"categories": {
"title": "Categorias"
},
@@ -121,7 +137,16 @@
"appearance": {
"title": "Aparência",
"merge_next_up_continue_watching": "Mesclar Continuar Assistindo e Próximo",
"hide_remote_session_button": "Esconder botão de sessão remota"
"hide_remote_session_button": "Esconder botão de sessão remota",
"show_home_backdrop": "Dynamic Home Backdrop",
"show_hero_carousel": "Hero Carousel",
"show_series_poster_on_episode": "Show Series Poster on Episodes",
"theme_music": "Theme Music",
"display_size": "Display Size",
"display_size_small": "Small",
"display_size_default": "Default",
"display_size_large": "Large",
"display_size_extra_large": "Extra Large"
},
"network": {
"title": "Network",
@@ -174,6 +199,22 @@
"rewind_length": "Comprimento de Retroceder",
"seconds_unit": "s"
},
"buffer": {
"title": "Buffer Settings",
"cache_mode": "Cache Mode",
"cache_auto": "Auto",
"cache_yes": "Enabled",
"cache_no": "Disabled",
"buffer_duration": "Buffer Duration",
"max_cache_size": "Max Cache Size",
"max_backward_cache": "Max Backward Cache"
},
"vo_driver": {
"title": "Video Output",
"vo_mode": "VO Driver",
"gpu_next": "gpu-next (Recommended)",
"gpu": "gpu"
},
"gesture_controls": {
"gesture_controls_title": "Controles de Gestos",
"horizontal_swipe_skip": "Deslizar horizontalmente para pular",
@@ -256,7 +297,23 @@
"subtitle_font": "Fonte da legenda",
"ksplayer_title": "Configurações do KSPlayer",
"hardware_decode": "Decodificação por hardware",
"hardware_decode_description": "Use aceleração de hardware para decodificação de vídeo. Desative se você tiver problemas de reprodução."
"hardware_decode_description": "Use aceleração de hardware para decodificação de vídeo. Desative se você tiver problemas de reprodução.",
"opensubtitles_title": "OpenSubtitles",
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
"opensubtitles_api_key": "API Key",
"opensubtitles_api_key_placeholder": "Enter API key...",
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
"mpv_subtitle_scale": "Subtitle Scale",
"mpv_subtitle_margin_y": "Vertical Margin",
"mpv_subtitle_align_x": "Horizontal Align",
"mpv_subtitle_align_y": "Vertical Align",
"align": {
"left": "Left",
"center": "Center",
"right": "Right",
"top": "Top",
"bottom": "Bottom"
}
},
"vlc_subtitles": {
"title": "VLC Subtitle Settings",
@@ -406,7 +463,13 @@
"music_cache_cleared": "Cache de música limpo",
"delete_all_downloaded_songs": "Excluir todas as músicas baixadas",
"downloaded_songs_size": "{{size}} baixado",
"downloaded_songs_deleted": "Músicas baixadas excluídas"
"downloaded_songs_deleted": "Músicas baixadas excluídas",
"clear_all_cache": "Clear All Cache",
"clear_all_cache_confirm": "Clear All Cache?",
"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.",
"clear_all_cache_success": "Cache Cleared",
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
},
"intro": {
"title": "Intro",
@@ -430,6 +493,21 @@
"error_deleting_files": "Erro ao excluir arquivos",
"background_downloads_enabled": "Downloads em segundo plano ativados",
"background_downloads_disabled": "Downloads em segundo plano desativados"
},
"security": {
"title": "Security",
"inactivity_timeout": {
"title": "Inactivity Timeout",
"description": "Auto logout after inactivity",
"disabled": "Disabled",
"1_minute": "1 minute",
"5_minutes": "5 minutes",
"15_minutes": "15 minutes",
"30_minutes": "30 minutes",
"1_hour": "1 hour",
"4_hours": "4 hours",
"24_hours": "24 hours"
}
}
},
"sessions": {
@@ -456,6 +534,7 @@
"new_app_version_requires_re_download_description": "A nova atualização requer que o conteúdo seja baixado novamente. Por favor, remova todo o conteúdo baixado e tente novamente.",
"back": "Anterior",
"delete": "excluir",
"delete_download": "Delete Download",
"something_went_wrong": "Ocorreu Um Erro",
"could_not_get_stream_url_from_jellyfin": "Não foi possível obter o URL de transmissão do Jellyfin",
"eta": "ETA {{eta}}",
@@ -492,22 +571,28 @@
}
},
"common": {
"no_results": "No Results",
"select": "Selecionar",
"no_trailer_available": "Nenhum trailer disponível",
"video": "Vídeo",
"audio": "Áudio",
"subtitle": "Legenda",
"play": "Reproduzir",
"mark_as_played": "Mark as Played",
"mark_as_not_played": "Mark as not Played",
"none": "Nenhum",
"track": "Faixa",
"cancel": "Cancelar",
"stop": "Stop",
"delete": "Apagar",
"ok": "OK",
"remove": "Remover",
"next": "Next",
"back": "Back",
"continue": "Continue",
"verifying": "Verifying..."
"verifying": "Verifying...",
"login": "Login",
"refresh": "Refresh"
},
"search": {
"search": "Buscar...",
@@ -556,6 +641,7 @@
"movies": "Filmes",
"series": "Série",
"boxsets": "Conjuntos de caixas",
"playlists": "Playlists",
"items": "itens"
},
"options": {
@@ -566,7 +652,8 @@
"poster": "Cartaz",
"cover": "Capa",
"show_titles": "Mostrar Títulos",
"show_stats": "Mostrar estatísticas"
"show_stats": "Mostrar estatísticas",
"options_title": "Options"
},
"filters": {
"genres": "Genres",
@@ -574,7 +661,11 @@
"sort_by": "Classificar por",
"filter_by": "Filtrar Por",
"sort_order": "Ordem de classificação",
"tags": "Etiquetas"
"tags": "Etiquetas",
"all": "All",
"reset": "Reset",
"asc": "Ascending",
"desc": "Descending"
}
},
"favorites": {
@@ -591,6 +682,8 @@
"no_links": "Sem links"
},
"player": {
"live": "LIVE",
"mpv_player_title": "MPV Player",
"error": "ERRO",
"failed_to_get_stream_url": "Falha ao obter a URL de transmissão",
"an_error_occured_while_playing_the_video": "Ocorreu um erro ao reproduzir o vídeo. Verifique os logs nas configurações.",
@@ -608,7 +701,35 @@
"downloaded_file_message": "Você quer reproduzir o arquivo baixado?",
"downloaded_file_yes": "SIm",
"downloaded_file_no": "Não",
"downloaded_file_cancel": "Cancelar"
"downloaded_file_cancel": "Cancelar",
"swipe_down_settings": "Swipe down for settings",
"ends_at": "Ends at {{time}}",
"search_subtitles": "Search Subtitles",
"subtitle_tracks": "Tracks",
"subtitle_search": "Search & Download",
"download": "Download",
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
"using_jellyfin_server": "Using Jellyfin Server",
"language": "Language",
"results": "Results",
"searching": "Searching...",
"search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found",
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
"settings": "Settings",
"skip_intro": "Skip Intro",
"skip_credits": "Skip Credits",
"stopPlayback": "Stop Playback",
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
"stopPlayingConfirm": "Are you sure you want to stop playback?",
"downloaded": "Downloaded"
},
"chapters": {
"title": "Chapters",
"chapter_number": "Chapter {{number}}",
"open": "Open chapters",
"close": "Close chapters"
},
"item_card": {
"next_up": "A Seguir",
@@ -617,6 +738,11 @@
"series": "Série",
"seasons": "Estações",
"season": "Temporada",
"from_this_series": "From This Series",
"more_from_this_season": "More from this Season",
"view_series": "View Series",
"view_season": "View Season",
"select_season": "Select Season",
"no_episodes_for_this_season": "Não há episódios para esta temporada",
"overview": "Geral",
"more_with": "Mais com {{name}}",
@@ -627,10 +753,21 @@
"media_options": "Opções de Mídia",
"quality": "Qualidade",
"audio": "Áudio",
"subtitles": "Legenda",
"subtitles": {
"label": "Subtitle",
"none": "None",
"tracks": "Tracks"
},
"show_more": "Mostrar mais",
"show_less": "Mostrar menos",
"left": "left",
"more_info": "More Info",
"director": "Director",
"cast": "Cast",
"technical_details": "Technical Details",
"appeared_in": "Aparece em",
"movies": "Movies",
"shows": "Shows",
"could_not_load_item": "Não foi possível carregar o item",
"none": "Nenhuma",
"download": {
@@ -641,7 +778,13 @@
"download_x_item": "Baixar itens de {{item_count}}",
"download_unwatched_only": "Apenas não assistidos",
"download_button": "BAIXAR"
}
},
"mark_played": "Mark as Watched",
"mark_unplayed": "Mark as Unwatched",
"resume_playback": "Resume Playback",
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
"play_from_start": "Play from Start",
"continue_from": "Continue from {{time}}"
},
"live_tv": {
"next": "Próximo",
@@ -652,7 +795,18 @@
"movies": "Filmes",
"sports": "Esportes",
"for_kids": "Para crianças",
"news": "Notícias"
"news": "Notícias",
"page_of": "Page {{current}} of {{total}}",
"no_programs": "No programs available",
"no_channels": "No channels available",
"tabs": {
"programs": "Programs",
"guide": "Guide",
"channels": "Channels",
"recordings": "Recordings",
"schedule": "Schedule",
"series": "Series"
}
},
"jellyseerr": {
"confirm": "Confirmar",
@@ -697,6 +851,12 @@
"decline": "Declinar",
"requested_by": "Solicitado por {{user}}",
"unknown_user": "Usuário desconhecido",
"select": "Select",
"request_all": "Request All",
"request_seasons": "Request Seasons",
"select_seasons": "Select Seasons",
"request_selected": "Request Selected",
"n_selected": "{{count}} selected",
"toasts": {
"jellyseer_does_not_meet_requirements": "O servidor do Seerr não atende ao mínimo aos requisitos de versão! Por favor, atualize para pelo menos 2.0.0",
"jellyseerr_test_failed": "Falha no teste do senhor. Por favor, tente novamente.",
@@ -716,7 +876,8 @@
"search": "Pesquisa",
"library": "Biblioteca",
"custom_links": "Links personalizados",
"favorites": "Atalhos"
"favorites": "Atalhos",
"settings": "Settings"
},
"music": {
"title": "Música",
@@ -841,5 +1002,36 @@
"show": "Esta série",
"all": "Todas as mídias (Padrão)"
}
},
"companion_login": {
"title": "Pair with TV",
"align_qr": "Align the QR code within the frame",
"enter_code_manually": "Enter code manually",
"pairing_enter_credentials": "Enter credentials for TV",
"pairing_code_label": "Pairing code",
"server": "Server",
"authorize_button": "Authorize",
"authorizing": "Authorizing...",
"scan_again": "Scan Again",
"done": "Done",
"success_title": "Authorization Sent",
"pairing_tv_connecting": "The TV is connecting to your account",
"error_title": "Authorization Failed",
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
"error_generic": "Something went wrong. Please try again.",
"error_permission_denied": "Camera permission is required to scan QR codes.",
"login_as": "Log in as {{username}}?",
"on_server": "on {{server}}",
"use_different_user": "Use a different user",
"open_settings": "Open Settings"
},
"pairing": {
"pair_with_phone": "Pair with Phone",
"pair_with_phone_title": "Login TV",
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
"waiting_for_phone": "Waiting for phone...",
"scan_with_phone": "Scan with the Streamyfin app on your phone",
"logging_in": "Logging in...",
"logging_in_description": "Connecting to your server"
}
}

View File

@@ -4,6 +4,9 @@
"error_title": "Eroare",
"login_title": "Conectare",
"login_to_title": "Conectare la",
"select_user": "Select a user to log in",
"add_user_to_login": "Add a user to log in",
"add_user": "Add User",
"username_placeholder": "Utilizator",
"password_placeholder": "Parola",
"login_button": "Conectare",
@@ -42,7 +45,13 @@
"accounts_count": "{{count}} accounts",
"select_account": "Select Account",
"add_account": "Add Account",
"remove_account_description": "This will remove the saved credentials for {{username}}."
"remove_account_description": "This will remove the saved credentials for {{username}}.",
"remove_server": "Remove Server",
"remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
"select_your_server": "Select Your Server",
"add_server_to_get_started": "Add a server to get started",
"add_server": "Add Server",
"change_server": "Change Server"
},
"save_account": {
"title": "Save Account",
@@ -86,6 +95,7 @@
"oops": "Ups!",
"error_message": "Ceva nu e bine.\nAutentificați-vă din nou.",
"continue_watching": "Continuă vizionarea",
"continue": "Continue",
"next_up": "Urmează",
"continue_and_next_up": "Continue & Next Up",
"recently_added_in": "Adăugat recent în {{libraryName}}",
@@ -109,6 +119,12 @@
"settings": {
"settings_title": "Setări",
"log_out_button": "Deconectare",
"switch_user": {
"title": "Switch User",
"account": "Account",
"switch_user": "Switch User on This Server",
"current": "current"
},
"categories": {
"title": "Categorii"
},
@@ -121,7 +137,16 @@
"appearance": {
"title": "Aspect",
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
"hide_remote_session_button": "Hide Remote Session Button"
"hide_remote_session_button": "Hide Remote Session Button",
"show_home_backdrop": "Dynamic Home Backdrop",
"show_hero_carousel": "Hero Carousel",
"show_series_poster_on_episode": "Show Series Poster on Episodes",
"theme_music": "Theme Music",
"display_size": "Display Size",
"display_size_small": "Small",
"display_size_default": "Default",
"display_size_large": "Large",
"display_size_extra_large": "Extra Large"
},
"network": {
"title": "Network",
@@ -174,6 +199,22 @@
"rewind_length": "Durata saltului înapoi",
"seconds_unit": "S"
},
"buffer": {
"title": "Buffer Settings",
"cache_mode": "Cache Mode",
"cache_auto": "Auto",
"cache_yes": "Enabled",
"cache_no": "Disabled",
"buffer_duration": "Buffer Duration",
"max_cache_size": "Max Cache Size",
"max_backward_cache": "Max Backward Cache"
},
"vo_driver": {
"title": "Video Output",
"vo_mode": "VO Driver",
"gpu_next": "gpu-next (Recommended)",
"gpu": "gpu"
},
"gesture_controls": {
"gesture_controls_title": "Controale gesturi",
"horizontal_swipe_skip": "Glisați orizontal pentru a sări",
@@ -256,7 +297,23 @@
"subtitle_font": "Subtitle Font",
"ksplayer_title": "KSPlayer Settings",
"hardware_decode": "Hardware Decoding",
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues."
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues.",
"opensubtitles_title": "OpenSubtitles",
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
"opensubtitles_api_key": "API Key",
"opensubtitles_api_key_placeholder": "Enter API key...",
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
"mpv_subtitle_scale": "Subtitle Scale",
"mpv_subtitle_margin_y": "Vertical Margin",
"mpv_subtitle_align_x": "Horizontal Align",
"mpv_subtitle_align_y": "Vertical Align",
"align": {
"left": "Left",
"center": "Center",
"right": "Right",
"top": "Top",
"bottom": "Bottom"
}
},
"vlc_subtitles": {
"title": "VLC Subtitle Settings",
@@ -406,7 +463,13 @@
"music_cache_cleared": "Music cache cleared",
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
"downloaded_songs_size": "{{size}} downloaded",
"downloaded_songs_deleted": "Downloaded songs deleted"
"downloaded_songs_deleted": "Downloaded songs deleted",
"clear_all_cache": "Clear All Cache",
"clear_all_cache_confirm": "Clear All Cache?",
"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.",
"clear_all_cache_success": "Cache Cleared",
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
},
"intro": {
"title": "Introducere",
@@ -430,6 +493,21 @@
"error_deleting_files": "Eroare la ștergerea fișierelor",
"background_downloads_enabled": "Descărcări în fundal activate",
"background_downloads_disabled": "Descărcări în fundal dezactivate"
},
"security": {
"title": "Security",
"inactivity_timeout": {
"title": "Inactivity Timeout",
"description": "Auto logout after inactivity",
"disabled": "Disabled",
"1_minute": "1 minute",
"5_minutes": "5 minutes",
"15_minutes": "15 minutes",
"30_minutes": "30 minutes",
"1_hour": "1 hour",
"4_hours": "4 hours",
"24_hours": "24 hours"
}
}
},
"sessions": {
@@ -456,6 +534,7 @@
"new_app_version_requires_re_download_description": "Noua actualizare necesită descărcarea din nou a conținutului. Vă rugăm să eliminați tot conținutul descărcat și să încercați din nou.",
"back": "Înapoi",
"delete": "Șterge",
"delete_download": "Delete Download",
"something_went_wrong": "Ceva nu a mers bine.",
"could_not_get_stream_url_from_jellyfin": "Nu s-a putut obține adresa URL a fluxului de la Jellyfin",
"eta": "Estimat {{eta}}",
@@ -492,22 +571,28 @@
}
},
"common": {
"no_results": "No Results",
"select": "Selectare",
"no_trailer_available": "Nicio remorcă disponibilă",
"video": "Video",
"audio": "Audio",
"subtitle": "Subtitrare",
"play": "Redare",
"mark_as_played": "Mark as Played",
"mark_as_not_played": "Mark as not Played",
"none": "Nimic",
"track": "Limbă audio",
"cancel": "Cancel",
"stop": "Stop",
"delete": "Delete",
"ok": "OK",
"remove": "Remove",
"next": "Next",
"back": "Back",
"continue": "Continue",
"verifying": "Verifying..."
"verifying": "Verifying...",
"login": "Login",
"refresh": "Refresh"
},
"search": {
"search": "Caută...",
@@ -556,6 +641,7 @@
"movies": "filme",
"series": "seriale",
"boxsets": "box sets",
"playlists": "Playlists",
"items": "articole"
},
"options": {
@@ -566,7 +652,8 @@
"poster": "Poster",
"cover": "Copertă",
"show_titles": "Afișează titlurile",
"show_stats": "Afișează statisticile"
"show_stats": "Afișează statisticile",
"options_title": "Options"
},
"filters": {
"genres": "Genuri",
@@ -574,7 +661,11 @@
"sort_by": "Sortează după",
"filter_by": "Filter By",
"sort_order": "Ordine de sortare",
"tags": "Taguri"
"tags": "Taguri",
"all": "All",
"reset": "Reset",
"asc": "Ascending",
"desc": "Descending"
}
},
"favorites": {
@@ -591,6 +682,8 @@
"no_links": "Niciun link"
},
"player": {
"live": "LIVE",
"mpv_player_title": "MPV Player",
"error": "Eroare",
"failed_to_get_stream_url": "Nu s-a putut obține adresa URL a fluxului",
"an_error_occured_while_playing_the_video": "A apărut o eroare la redarea videoclipului. Verificați jurnalele în setări.",
@@ -608,7 +701,35 @@
"downloaded_file_message": "Doriți să redați fișierul descărcat?",
"downloaded_file_yes": "Da",
"downloaded_file_no": "Nu",
"downloaded_file_cancel": "Anulează"
"downloaded_file_cancel": "Anulează",
"swipe_down_settings": "Swipe down for settings",
"ends_at": "Ends at {{time}}",
"search_subtitles": "Search Subtitles",
"subtitle_tracks": "Tracks",
"subtitle_search": "Search & Download",
"download": "Download",
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
"using_jellyfin_server": "Using Jellyfin Server",
"language": "Language",
"results": "Results",
"searching": "Searching...",
"search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found",
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
"settings": "Settings",
"skip_intro": "Skip Intro",
"skip_credits": "Skip Credits",
"stopPlayback": "Stop Playback",
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
"stopPlayingConfirm": "Are you sure you want to stop playback?",
"downloaded": "Downloaded"
},
"chapters": {
"title": "Chapters",
"chapter_number": "Chapter {{number}}",
"open": "Open chapters",
"close": "Close chapters"
},
"item_card": {
"next_up": "Urmează",
@@ -617,6 +738,11 @@
"series": "Seriale",
"seasons": "Sezoane",
"season": "Sezon",
"from_this_series": "From This Series",
"more_from_this_season": "More from this Season",
"view_series": "View Series",
"view_season": "View Season",
"select_season": "Select Season",
"no_episodes_for_this_season": "Niciun episod pt acest sezon",
"overview": "Prezentare generală",
"more_with": "Mai multe cu {{name}}",
@@ -627,10 +753,21 @@
"media_options": "Opțiuni Media",
"quality": "Calitate",
"audio": "Audio",
"subtitles": "Subtitrare",
"subtitles": {
"label": "Subtitle",
"none": "None",
"tracks": "Tracks"
},
"show_more": "Arată mai mult",
"show_less": "Arată mai puțin",
"left": "left",
"more_info": "More Info",
"director": "Director",
"cast": "Cast",
"technical_details": "Technical Details",
"appeared_in": "Apare în",
"movies": "Movies",
"shows": "Shows",
"could_not_load_item": "Nu s-a putut încărca elementul",
"none": "Nimic",
"download": {
@@ -641,7 +778,13 @@
"download_x_item": "Descărcați {{item_count}} articole",
"download_unwatched_only": "Numai nevizionate",
"download_button": "Descarcă"
}
},
"mark_played": "Mark as Watched",
"mark_unplayed": "Mark as Unwatched",
"resume_playback": "Resume Playback",
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
"play_from_start": "Play from Start",
"continue_from": "Continue from {{time}}"
},
"live_tv": {
"next": "Următorul",
@@ -652,7 +795,18 @@
"movies": "Filme",
"sports": "Sport",
"for_kids": "Pt copii",
"news": "Știri"
"news": "Știri",
"page_of": "Page {{current}} of {{total}}",
"no_programs": "No programs available",
"no_channels": "No channels available",
"tabs": {
"programs": "Programs",
"guide": "Guide",
"channels": "Channels",
"recordings": "Recordings",
"schedule": "Schedule",
"series": "Series"
}
},
"jellyseerr": {
"confirm": "Confirmă",
@@ -697,6 +851,12 @@
"decline": "Respinge",
"requested_by": "Solicitat de {{user}}",
"unknown_user": "Utilizator necunoscut",
"select": "Select",
"request_all": "Request All",
"request_seasons": "Request Seasons",
"select_seasons": "Select Seasons",
"request_selected": "Request Selected",
"n_selected": "{{count}} selected",
"toasts": {
"jellyseer_does_not_meet_requirements": "Serverul Jellyseerr nu îndeplinește cerințele minime de versiune! Vă rugăm să actualizați cel puțin la versiunea 2.0.0",
"jellyseerr_test_failed": "Testul Jellyseerr a eșuat. Vă rugăm să încercați din nou.",
@@ -716,7 +876,8 @@
"search": "Caută",
"library": "Bibiliotecă",
"custom_links": "Linkuri personalizate",
"favorites": "Favorite"
"favorites": "Favorite",
"settings": "Settings"
},
"music": {
"title": "Music",
@@ -841,5 +1002,36 @@
"show": "This show",
"all": "All media (default)"
}
},
"companion_login": {
"title": "Pair with TV",
"align_qr": "Align the QR code within the frame",
"enter_code_manually": "Enter code manually",
"pairing_enter_credentials": "Enter credentials for TV",
"pairing_code_label": "Pairing code",
"server": "Server",
"authorize_button": "Authorize",
"authorizing": "Authorizing...",
"scan_again": "Scan Again",
"done": "Done",
"success_title": "Authorization Sent",
"pairing_tv_connecting": "The TV is connecting to your account",
"error_title": "Authorization Failed",
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
"error_generic": "Something went wrong. Please try again.",
"error_permission_denied": "Camera permission is required to scan QR codes.",
"login_as": "Log in as {{username}}?",
"on_server": "on {{server}}",
"use_different_user": "Use a different user",
"open_settings": "Open Settings"
},
"pairing": {
"pair_with_phone": "Pair with Phone",
"pair_with_phone_title": "Login TV",
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
"waiting_for_phone": "Waiting for phone...",
"scan_with_phone": "Scan with the Streamyfin app on your phone",
"logging_in": "Logging in...",
"logging_in_description": "Connecting to your server"
}
}

View File

@@ -4,6 +4,9 @@
"error_title": "Ошибка",
"login_title": "Вход",
"login_to_title": "Вход в",
"select_user": "Select a user to log in",
"add_user_to_login": "Add a user to log in",
"add_user": "Add User",
"username_placeholder": "Имя пользователя",
"password_placeholder": "Пароль",
"login_button": "Войти",
@@ -12,25 +15,25 @@
"failed_to_initiate_quick_connect": "Не удалось инициировать быстрое подключение",
"got_it": "Принято",
"connection_failed": "Соединение не удалось",
"could_not_connect_to_server": "Не удалось подключиться к серверу. Пожалуйста проверьте URL и ваше интернет соединение.",
"could_not_connect_to_server": "Не удалось подключиться к серверу. Пожалуйста, проверьте URL и ваше интернет-соединение.",
"an_unexpected_error_occured": "Возникла непредвиденная ошибка",
"change_server": "Поменять сервер",
"invalid_username_or_password": "Неправильное имя пользователя или пароль",
"user_does_not_have_permission_to_log_in": "Пользователь не имеет прав на вход",
"server_is_taking_too_long_to_respond_try_again_later": "Сервер долго не отвечает, попробуйте позже.",
"server_is_taking_too_long_to_respond_try_again_later": "Сервер долго не отвечает, попробуйте позже",
"server_received_too_many_requests_try_again_later": "Сервер получил слишком много запросов, попробуйте позже.",
"there_is_a_server_error": "Возникла ошибка сервера",
"an_unexpected_error_occured_did_you_enter_the_correct_url": "Возникла непредвиденная ошибка. Вы правильно ввели URL?",
"too_old_server_text": "Неподдерживаемый сервер Jellyfin обнаружен",
"too_old_server_text": "Обнаружен неподдерживаемый сервер Jellyfin",
"too_old_server_description": "Пожалуйста, обновите Jellyfin до последней версии"
},
"server": {
"enter_url_to_jellyfin_server": "Укажите URL на ваш Jellyfin сервер",
"server_url_placeholder": "http(s)://your-server.com",
"connect_button": "Подключиться",
"previous_servers": "предыдущие серверы",
"previous_servers": "Предыдущие серверы",
"clear_button": "Очистить",
"swipe_to_remove": "Swipe to remove",
"swipe_to_remove": "Смахните для удаления",
"search_for_local_servers": "Поиск локальных серверов",
"searching": "Поиск...",
"servers": "Сервера",
@@ -39,10 +42,16 @@
"please_login_again": "Ваша сессия истекла. Пожалуйста, войдите снова.",
"remove_saved_login": "Удалить сохраненный аккаунт",
"remove_saved_login_description": "Ваши сохранённые данные для входа от этого сервера будут удалены. Вам придётся ввести ваши логин и пароль ещё раз.",
"accounts_count": "{{count}} аккаунтов",
"accounts_count": "Аккаунтов: {{count}}",
"select_account": "Выбрать аккаунт",
"add_account": "Добавить аккаунт",
"remove_account_description": "Данные для входа {{username}} будут удалены."
"remove_account_description": "Данные для входа {{username}} будут удалены.",
"remove_server": "Remove Server",
"remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
"select_your_server": "Select Your Server",
"add_server_to_get_started": "Add a server to get started",
"add_server": "Add Server",
"change_server": "Change Server"
},
"save_account": {
"title": "Сохранить аккаунт",
@@ -58,14 +67,14 @@
"cancel_button": "Отмена"
},
"pin": {
"enter_pin": "Введите PIN",
"enter_pin_for": "Введите PIN для {{username}}",
"enter_pin": "Введите PIN-код",
"enter_pin_for": "Введите PIN-код для {{username}}",
"enter_4_digits": "Введите 4 цифры",
"invalid_pin": "Некорректный PIN",
"setup_pin": "Установить PIN",
"confirm_pin": "Подтвердите PIN",
"invalid_pin": "Некорректный PIN-код",
"setup_pin": "Установить PIN-код",
"confirm_pin": "Подтвердите PIN-код",
"pins_dont_match": "PIN-коды не совпадают",
"forgot_pin": "Забыли PIN?",
"forgot_pin": "Забыли PIN-код?",
"forgot_pin_desc": "Ваши данные для входа будут удалены"
},
"password": {
@@ -84,8 +93,9 @@
"server_unreachable": "Сервер недоступен",
"server_unreachable_message": "Не удалось соединиться с сервером.\nПожалуйста, проверьте настройки сети.",
"oops": "Упс!",
"error_message": "Что-то пошло не так.\nПожалуйста выйдите и зайдите снова.",
"error_message": "Что-то пошло не так.\nПожалуйста, выйдите и зайдите снова.",
"continue_watching": "Продолжить",
"continue": "Continue",
"next_up": "Далее",
"continue_and_next_up": "Продолжить и Далее",
"recently_added_in": "Недавно добавлено в {{libraryName}}",
@@ -93,13 +103,13 @@
"suggested_episodes": "Предложенные серии",
"intro": {
"welcome_to_streamyfin": "Добро пожаловать в Streamyfin",
"a_free_and_open_source_client_for_jellyfin": "Бесплатный клиент для Jellyfin с открытым кодом",
"a_free_and_open_source_client_for_jellyfin": "Бесплатный клиент для Jellyfin с открытым кодом.",
"features_title": "Функции",
"features_description": "Streamyfin имеет множество функций и интегрируется с широким спектром программ, которое вы можете найти в меню настроек:",
"jellyseerr_feature_description": "Подключитесь к Jellyseerr и запрашивайте фильмы прямо в приложении.",
"downloads_feature_title": "Загрузки",
"downloads_feature_description": "Скачивайте фильмы и сериалы для просмотра без интернета. Используйте стандартный способ или установите сервер оптимизации для загрузки файлов в фоновом режиме.",
"chromecast_feature_description": "Транслируйте фильмы и сериалы на ваши устройста с поддержкой Chromecast.",
"chromecast_feature_description": "Транслируйте фильмы и сериалы на ваши устройства с поддержкой Chromecast.",
"centralised_settings_plugin_title": "Плагин для централизованной настройки",
"centralised_settings_plugin_description": "Настраивайте параметры из централизованного места на сервере Jellyfin. Все настройки клиента для всех пользователей будут синхронизированы автоматически.",
"done_button": "Готово",
@@ -109,6 +119,12 @@
"settings": {
"settings_title": "Настройки",
"log_out_button": "Выйти",
"switch_user": {
"title": "Switch User",
"account": "Account",
"switch_user": "Switch User on This Server",
"current": "current"
},
"categories": {
"title": "Категории"
},
@@ -121,7 +137,16 @@
"appearance": {
"title": "Внешний вид",
"merge_next_up_continue_watching": "Объединить «Продолжить» и «Далее»",
"hide_remote_session_button": "Скрыть кнопку «Удалённый сеанс»"
"hide_remote_session_button": "Скрыть кнопку «Удалённый сеанс»",
"show_home_backdrop": "Dynamic Home Backdrop",
"show_hero_carousel": "Hero Carousel",
"show_series_poster_on_episode": "Show Series Poster on Episodes",
"theme_music": "Theme Music",
"display_size": "Display Size",
"display_size_small": "Small",
"display_size_default": "Default",
"display_size_large": "Large",
"display_size_extra_large": "Extra Large"
},
"network": {
"title": "Сеть",
@@ -129,7 +154,7 @@
"auto_switch_enabled": "Переключаться дома автоматически",
"auto_switch_description": "Автоматически переключаться на локальный URL при присоединении к домашней WiFi сети",
"local_url": "Локальный URL",
"local_url_hint": "Введите локальный URL вашего сервера (e.g., http://192.168.1.100:8096)",
"local_url_hint": "Введите локальный URL вашего сервера (например, http://192.168.1.100:8096)",
"local_url_placeholder": "http://192.168.1.100:8096",
"home_wifi_networks": "Домашние WiFi сети",
"add_current_network": "Добавить \"{{ssid}}\"",
@@ -160,28 +185,44 @@
},
"quick_connect": {
"quick_connect_title": "Быстрое подключение",
"authorize_button": "Авторизировать через быстрое подключение",
"authorize_button": "Авторизовать через быстрое подключение",
"enter_the_quick_connect_code": "Введите код для быстрого подключения...",
"success": "Успех",
"quick_connect_autorized": "Быстрое подключение авторизовано",
"error": "Ошибка",
"invalid_code": "Неверный код",
"authorize": "Авторизировать"
"authorize": "Авторизовать"
},
"media_controls": {
"media_controls_title": "Медиа-контроль",
"media_controls_title": "Управление воспроизведением",
"forward_skip_length": "Шаг перемотки вперёд",
"rewind_length": "Шаг перемотки назад",
"seconds_unit": "c"
},
"buffer": {
"title": "Buffer Settings",
"cache_mode": "Cache Mode",
"cache_auto": "Auto",
"cache_yes": "Enabled",
"cache_no": "Disabled",
"buffer_duration": "Buffer Duration",
"max_cache_size": "Max Cache Size",
"max_backward_cache": "Max Backward Cache"
},
"vo_driver": {
"title": "Video Output",
"vo_mode": "VO Driver",
"gpu_next": "gpu-next (Recommended)",
"gpu": "gpu"
},
"gesture_controls": {
"gesture_controls_title": "Управление жестами",
"horizontal_swipe_skip": "Горизонтальный свайп для перемотки",
"horizontal_swipe_skip_description": "Проведите влево/вправо, когда элементы управления скрыты, чтобы пропустить",
"left_side_brightness": "Управление яркостью левой стороны",
"left_side_brightness_description": "Смахните вверх/вниз на левой стороне для настройки яркости",
"horizontal_swipe_skip": "Проведите влево/вправо для перемотки",
"horizontal_swipe_skip_description": "Проведите влево/вправо, когда элементы управления скрыты, чтобы перемотать",
"left_side_brightness": "Управление яркостью слева",
"left_side_brightness_description": "Проведите вверх/вниз на левой стороне для настройки яркости",
"right_side_volume": "Управление громкостью справа",
"right_side_volume_description": "Свайп вверх/вниз с правой стороны для настройки громкости",
"right_side_volume_description": "Проведите вверх/вниз с правой стороны для настройки громкости",
"hide_volume_slider": "Скрыть индикатор громкости",
"hide_volume_slider_description": "Скрывает индикатор громкости в плеере",
"hide_brightness_slider": "Скрыть индикатор яркости",
@@ -205,7 +246,7 @@
},
"subtitles": {
"subtitle_title": "Субтитры",
"subtitle_hint": "Настройки отображения субтитров",
"subtitle_hint": "Настройки отображения субтитров.",
"subtitle_language": "Язык субтитров",
"subtitle_mode": "Режим субтитров",
"set_subtitle_track": "Устанавливать субтитры из предыдущего элемента",
@@ -256,7 +297,23 @@
"subtitle_font": "Шрифт субтитров",
"ksplayer_title": "Настройки KSPlayer",
"hardware_decode": "Аппаратное декодирование",
"hardware_decode_description": "Использовать аппаратное ускорение для декодирования видео. Выключите, если наблюдаете проблемы с воспроизведением."
"hardware_decode_description": "Использовать аппаратное ускорение для декодирования видео. Выключите, если наблюдаете проблемы с воспроизведением.",
"opensubtitles_title": "OpenSubtitles",
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
"opensubtitles_api_key": "API Key",
"opensubtitles_api_key_placeholder": "Enter API key...",
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
"mpv_subtitle_scale": "Subtitle Scale",
"mpv_subtitle_margin_y": "Vertical Margin",
"mpv_subtitle_align_x": "Horizontal Align",
"mpv_subtitle_align_y": "Vertical Align",
"align": {
"left": "Left",
"center": "Center",
"right": "Right",
"top": "Top",
"bottom": "Bottom"
}
},
"vlc_subtitles": {
"title": "Настройки субтитров в VLC",
@@ -271,9 +328,9 @@
"margin": "Отступ снизу"
},
"video_player": {
"title": "Видеоплеер",
"video_player": "Видеоплеер",
"video_player_description": "Выберите видеоплеер в iOS.",
"title": "Видео плеер",
"video_player": "Видео плеер",
"video_player_description": "Выберите видео плеер в iOS.",
"ksplayer": "KSPlayer",
"vlc": "VLC"
},
@@ -294,12 +351,12 @@
"UNKNOWN": "Неизвестное"
},
"safe_area_in_controls": "Безопасная зона в элементах управления",
"video_player": "Видеоплеер",
"video_player": "Видео плеер",
"video_players": {
"VLC_3": "VLC 3",
"VLC_4": "VLC 4 (Экспериментальный + PiP)"
},
"show_custom_menu_links": "Показать ссылки кастомного меню",
"show_custom_menu_links": "Показать ссылки пользовательского меню",
"show_large_home_carousel": "Показывать большую карусель (beta)",
"hide_libraries": "Скрыть библиотеки",
"select_liraries_you_want_to_hide": "Выберите Библиотеки, которое хотите спрятать из вкладки Библиотеки и домашней страницы.",
@@ -307,7 +364,7 @@
"default_quality": "Качество по умолчанию",
"default_playback_speed": "Скорость воспроизведения по умолчанию",
"auto_play_next_episode": "Автоматически воспроизводить следующий эпизод",
"max_auto_play_episode_count": "Максимальное количество автовоспроизведения эпизодов",
"max_auto_play_episode_count": "Максимальное количество авто воспроизводимых эпизодов",
"disabled": "Отключено"
},
"downloads": {
@@ -319,9 +376,9 @@
"playback_description": "Настройте воспроизведение музыки.",
"prefer_downloaded": "Предпочитать скачанные песни",
"caching_title": "Кеширование",
"caching_description": "Автоматически предкешировать следующие треки для стабильного воспроизведения.",
"caching_description": "Автоматически кешировать следующие треки для стабильного воспроизведения.",
"lookahead_enabled": "Включить предкеширование",
"lookahead_count": "Сколько предкешировать",
"lookahead_count": "Сколько треков предкешировать",
"max_cache_size": "Максимальное число предкешированных треков"
},
"plugins": {
@@ -329,8 +386,8 @@
"jellyseerr": {
"jellyseerr_warning": "Эта интеграция находится на ранней стадии. Ожидайте изменений.",
"server_url": "URL сервера",
"server_url_hint": "Пример: http(s)://your-host.url\n(Добавьте порт если необходимо)",
"server_url_placeholder": "Jellyseerr URL...",
"server_url_hint": "Пример: http(s)://your-host.url\n(добавьте порт если необходимо)",
"server_url_placeholder": "Seerr URL...",
"password": "Пароль",
"password_placeholder": "Введите пароль для пользователя Jellyfin {{username}}",
"login_button": "Войти",
@@ -349,7 +406,7 @@
}
},
"marlin_search": {
"enable_marlin_search": "Включить Marlin Search ",
"enable_marlin_search": "Включить Marlin Search",
"url": "URL-адрес",
"server_url_placeholder": "http(s)://domain.org:port",
"marlin_search_hint": "Введите URL для Marlin сервера. URL должен включать http or https и опционально порт.",
@@ -399,14 +456,20 @@
"size_used": "{{used}} из {{total}} использовано",
"delete_all_downloaded_files": "Удалить все загруженные файлы",
"music_cache_title": "Кеш музыки",
"music_cache_description": "Автоматически прекешировать песни по мере прослушивания для плавного воспроизведения и поддержки отсутствия интернета",
"music_cache_description": "Автоматически кешировать песни по мере прослушивания для плавного воспроизведения и поддержки отсутствия интернета",
"enable_music_cache": "Кешировать музыку",
"clear_music_cache": "Очистить кеш музыки",
"music_cache_size": "{{size}} кешировано",
"music_cache_size": "Кешировано: {{size}}",
"music_cache_cleared": "Кеш музыки очищен",
"delete_all_downloaded_songs": "Удалить все скачанные песни",
"downloaded_songs_size": "{{size}} скачано",
"downloaded_songs_deleted": "Скачанные песни удалены"
"downloaded_songs_size": "Скачано: {{size}}",
"downloaded_songs_deleted": "Скачанные песни удалены",
"clear_all_cache": "Clear All Cache",
"clear_all_cache_confirm": "Clear All Cache?",
"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.",
"clear_all_cache_success": "Cache Cleared",
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
},
"intro": {
"title": "Вступление",
@@ -415,7 +478,7 @@
},
"logs": {
"logs_title": "Логи",
"export_logs": "Экспорт журналов",
"export_logs": "Сохранить логи",
"click_for_more_info": "Нажмите для получения дополнительной информации",
"level": "Уровень",
"no_logs_available": "Логи не доступны",
@@ -430,6 +493,21 @@
"error_deleting_files": "Ошибка при удалении файлов",
"background_downloads_enabled": "Фоновая загрузка включена",
"background_downloads_disabled": "Фоновая загрузка отключена"
},
"security": {
"title": "Security",
"inactivity_timeout": {
"title": "Inactivity Timeout",
"description": "Auto logout after inactivity",
"disabled": "Disabled",
"1_minute": "1 minute",
"5_minutes": "5 minutes",
"15_minutes": "15 minutes",
"30_minutes": "30 minutes",
"1_hour": "1 hour",
"4_hours": "4 hours",
"24_hours": "24 hours"
}
}
},
"sessions": {
@@ -453,9 +531,10 @@
"no_active_downloads": "Нет активных загрузок",
"active_downloads": "Активные",
"new_app_version_requires_re_download": "Новая версия приложения требует повторной загрузки",
"new_app_version_requires_re_download_description": "Новая версия приложения требует повторной загрузки. Пожалуйста удалите всё и попробуйте заново.",
"new_app_version_requires_re_download_description": "Новая версия приложения требует повторной загрузки контента. Пожалуйста, удалите весь скачанный контент и попробуйте заново.",
"back": "Назад",
"delete": "Удалить",
"delete_download": "Delete Download",
"something_went_wrong": "Что-то пошло не так",
"could_not_get_stream_url_from_jellyfin": "Не удалось получить ссылку трансляции из Jellyfin",
"eta": "Осталось {{eta}}",
@@ -465,53 +544,59 @@
"failed_to_delete_all_movies": "Возникла ошибка при удалении всех фильмов",
"deleted_all_tvseries_successfully": "Все сериалы были успешно удалены!",
"failed_to_delete_all_tvseries": "Возникла ошибка при удалении всех сериалов",
"deleted_media_successfully": "Другие носители успешно удалены!",
"failed_to_delete_media": "Не удалось удалить другой файл",
"download_deleted": "Удалено",
"deleted_media_successfully": "Остальные медиафайлы успешно удалены!",
"failed_to_delete_media": "Не удалось удалить остальные медиафайлы",
"download_deleted": "Загруженный контент удалён",
"download_cancelled": "Загрузка отменена",
"could_not_delete_download": "Не удалось удалить загрузку",
"download_paused": "На паузе",
"could_not_pause_download": "Не удалось приостановить загрузку",
"download_resumed": "Продолжено",
"could_not_resume_download": "Не удалось продолжить загрузку",
"could_not_resume_download": "Не удалось возобновить загрузку",
"download_completed": "Завершено",
"download_failed": "Не удалось загрузить",
"download_failed_for_item": "Загрузка {{item}} провалилась с ошибкой: {{error}}",
"download_completed_for_item": "{{item}} успешно загружен",
"download_started_for_item": "Загрузка началась для {{item}}",
"download_started_for_item": "Загрузка {{item}} началась",
"failed_to_start_download": "Не удалось начать загрузку",
"item_already_downloading": "{{item}} уже загружается",
"all_files_deleted": "Все загрузки удалены",
"files_deleted_by_type": "{{count}} {{type}} удалён(о)",
"files_deleted_by_type": "Удалено: {{count}} {{type}}",
"all_files_folders_and_jobs_deleted_successfully": "Все файлы, папки, и задачи были успешно удалены",
"failed_to_clean_cache_directory": "Не удалось очистить директорию кэша",
"could_not_get_download_url_for_item": "Не удалось получить URL загрузки для {{itemName}}",
"could_not_get_download_url_for_item": "Не удалось получить URL для загрузки {{itemName}}",
"go_to_downloads": "В загрузки",
"file_deleted": "{{item}} удалён"
"file_deleted": "Удалено: {{item}}"
}
}
},
"common": {
"no_results": "No Results",
"select": "Выбрать",
"no_trailer_available": "Трейлер недоступен",
"video": "Видео",
"audio": "Звук",
"subtitle": "Субтитры",
"play": "Воспроизвести",
"mark_as_played": "Mark as Played",
"mark_as_not_played": "Mark as not Played",
"none": "Отсутствует",
"track": "Трек",
"cancel": "Отмена",
"stop": "Stop",
"delete": "Удалить",
"ok": "ОК",
"remove": "Удалить",
"next": "Вперед",
"back": "Назад",
"continue": "Продолжить",
"verifying": "Проверка..."
"verifying": "Проверка...",
"login": "Login",
"refresh": "Refresh"
},
"search": {
"search": "Поиск...",
"x_items": "{{count}} элементов",
"x_items": "Элементов: {{count}}",
"library": "Библиотека",
"discover": "Найти новое",
"no_results": "Ничего не найдено",
@@ -529,14 +614,14 @@
"request_series": "Запросить сериалы",
"recently_added": "Недавно добавлено",
"recent_requests": "Недавно запрошено",
"plex_watchlist": "Список просмотра с Plex",
"plex_watchlist": "Список просмотра Plex",
"trending": "В тренде",
"popular_movies": "Популярные фильмы",
"movie_genres": "Популярные жанры",
"upcoming_movies": "Предстоящие фильмы",
"studios": "Студии",
"popular_tv": "Популярные сериалы",
"tv_genres": "жанры сериалов",
"tv_genres": "Жанры сериалов",
"upcoming_tv": "Предстоящие сериалы",
"networks": "Сети",
"tmdb_movie_keyword": "TMDB Ключевые слова фильмов",
@@ -556,7 +641,8 @@
"movies": "Фильмы",
"series": "Сериалы",
"boxsets": "Коллекции",
"items": "элементы"
"playlists": "Playlists",
"items": "Элементы"
},
"options": {
"display": "Отображать",
@@ -565,8 +651,9 @@
"image_style": "Стиль изображения",
"poster": "Постер",
"cover": "Обложка",
"show_titles": "Показывать загаловки",
"show_stats": "Показывать статистику"
"show_titles": "Показывать заголовки",
"show_stats": "Показывать статистику",
"options_title": "Options"
},
"filters": {
"genres": "Жанры",
@@ -574,7 +661,11 @@
"sort_by": "Сортировка",
"filter_by": "Фильтр",
"sort_order": "Порядок",
"tags": "Тэги"
"tags": "Теги",
"all": "All",
"reset": "Reset",
"asc": "Ascending",
"desc": "Descending"
}
},
"favorites": {
@@ -591,6 +682,8 @@
"no_links": "Нет ссылок"
},
"player": {
"live": "LIVE",
"mpv_player_title": "MPV Player",
"error": "Ошибка",
"failed_to_get_stream_url": "Не удалось получить URL потока",
"an_error_occured_while_playing_the_video": "Возникла Неожиданная ошибка во время воспроизведения. Проверьте логи в настройках.",
@@ -608,7 +701,35 @@
"downloaded_file_message": "Хотите воспроизвести скачанный файл?",
"downloaded_file_yes": "Да",
"downloaded_file_no": "Нет",
"downloaded_file_cancel": "Отмена"
"downloaded_file_cancel": "Отмена",
"swipe_down_settings": "Swipe down for settings",
"ends_at": "Ends at {{time}}",
"search_subtitles": "Search Subtitles",
"subtitle_tracks": "Tracks",
"subtitle_search": "Search & Download",
"download": "Download",
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
"using_jellyfin_server": "Using Jellyfin Server",
"language": "Language",
"results": "Results",
"searching": "Searching...",
"search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found",
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
"settings": "Settings",
"skip_intro": "Skip Intro",
"skip_credits": "Skip Credits",
"stopPlayback": "Stop Playback",
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
"stopPlayingConfirm": "Are you sure you want to stop playback?",
"downloaded": "Downloaded"
},
"chapters": {
"title": "Chapters",
"chapter_number": "Chapter {{number}}",
"open": "Open chapters",
"close": "Close chapters"
},
"item_card": {
"next_up": "Далее",
@@ -617,6 +738,11 @@
"series": "Серии",
"seasons": "Сезоны",
"season": "Сезон",
"from_this_series": "From This Series",
"more_from_this_season": "More from this Season",
"view_series": "View Series",
"view_season": "View Season",
"select_season": "Select Season",
"no_episodes_for_this_season": "В этом сезоне нет серий",
"overview": "Обзор",
"more_with": "Больше с {{name}}",
@@ -624,13 +750,24 @@
"no_similar_items_found": "Похожие элементы не найдены",
"video": "Видео",
"more_details": "Больше деталей",
"media_options": "Media Options",
"media_options": "Опции медиа",
"quality": "Качество",
"audio": "Звук",
"subtitles": "Субтитры",
"subtitles": {
"label": "Subtitle",
"none": "None",
"tracks": "Tracks"
},
"show_more": "Показать больше",
"show_less": "Показать меньше",
"left": "left",
"more_info": "More Info",
"director": "Director",
"cast": "Cast",
"technical_details": "Technical Details",
"appeared_in": "Появлялся в",
"movies": "Movies",
"shows": "Shows",
"could_not_load_item": "Не удалось загрузить элемент",
"none": "Отсутствует",
"download": {
@@ -641,7 +778,13 @@
"download_x_item": "Загрузить {{item_count}} элементов",
"download_unwatched_only": "Только непросмотренные",
"download_button": "Загрузить"
}
},
"mark_played": "Mark as Watched",
"mark_unplayed": "Mark as Unwatched",
"resume_playback": "Resume Playback",
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
"play_from_start": "Play from Start",
"continue_from": "Continue from {{time}}"
},
"live_tv": {
"next": "Далее",
@@ -652,7 +795,18 @@
"movies": "Фильмы",
"sports": "Спорт",
"for_kids": "Для детей",
"news": "Новости"
"news": "Новости",
"page_of": "Page {{current}} of {{total}}",
"no_programs": "No programs available",
"no_channels": "No channels available",
"tabs": {
"programs": "Programs",
"guide": "Guide",
"channels": "Channels",
"recordings": "Recordings",
"schedule": "Schedule",
"series": "Series"
}
},
"jellyseerr": {
"confirm": "Подтвердить",
@@ -685,26 +839,32 @@
"currently_streaming_on": "Сейчас доступно на",
"advanced": "Продвинутое",
"request_as": "Запросить как",
"tags": "Тэги",
"tags": "Теги",
"quality_profile": "Профиль качества",
"root_folder": "Корневая папка",
"season_all": "Сезон (все)",
"season_number": "Сезон {{season_number}}",
"number_episodes": "{{episode_number}} серий",
"number_episodes": "Серий: {{episode_number}}",
"born": "Рожден",
"appearances": "Появления",
"approve": "Одобрить",
"decline": "Отклонить",
"requested_by": "Запрошено {{user}}",
"unknown_user": "Неизвестный пользователь",
"select": "Select",
"request_all": "Request All",
"request_seasons": "Request Seasons",
"select_seasons": "Select Seasons",
"request_selected": "Request Selected",
"n_selected": "{{count}} selected",
"toasts": {
"jellyseer_does_not_meet_requirements": "Сервер Jellyseerr не соответствует минимальным требованиям версии! Пожалуйста, обновите до версии не ниже 2.0.0",
"jellyseerr_test_failed": "Тест Jellyseerr не пройден. Попробуйте еще раз.",
"failed_to_test_jellyseerr_server_url": "Не удалось проверить URL-адрес сервера jellyseerr",
"failed_to_test_jellyseerr_server_url": "Не удалось проверить URL-адрес сервера Seerr",
"issue_submitted": "Проблема отправлена!",
"requested_item": "Запрошено {{item}}!",
"you_dont_have_permission_to_request": "У вас нет разрешения на запрос!",
"something_went_wrong_requesting_media": "Что-то пошло не так при запросе медиафайлов!",
"something_went_wrong_requesting_media": "Что-то пошло не так при запросе медиа!",
"request_approved": "Запрос одобрен!",
"request_declined": "Запрос отклонён!",
"failed_to_approve_request": "Не удалось одобрить запрос",
@@ -716,7 +876,8 @@
"search": "Поиск",
"library": "Библиотека",
"custom_links": "Ссылки",
"favorites": "Избранное"
"favorites": "Избранное",
"settings": "Settings"
},
"music": {
"title": "Музыка",
@@ -801,7 +962,7 @@
"name_label": "Название",
"name_placeholder": "Введите название списка",
"description_label": "Описание",
"description_placeholder": "Введите описание (не обязательно)",
"description_placeholder": "Введите описание (необязательно)",
"is_public_label": "Публичный",
"is_public_description": "Разрешить остальным пользователям видеть этот список",
"allowed_type_label": "Тип контента",
@@ -841,5 +1002,36 @@
"show": "Ко всему сериалу",
"all": "Ко всем файлам (по умолчанию)"
}
},
"companion_login": {
"title": "Pair with TV",
"align_qr": "Align the QR code within the frame",
"enter_code_manually": "Enter code manually",
"pairing_enter_credentials": "Enter credentials for TV",
"pairing_code_label": "Pairing code",
"server": "Server",
"authorize_button": "Authorize",
"authorizing": "Authorizing...",
"scan_again": "Scan Again",
"done": "Done",
"success_title": "Authorization Sent",
"pairing_tv_connecting": "The TV is connecting to your account",
"error_title": "Authorization Failed",
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
"error_generic": "Something went wrong. Please try again.",
"error_permission_denied": "Camera permission is required to scan QR codes.",
"login_as": "Log in as {{username}}?",
"on_server": "on {{server}}",
"use_different_user": "Use a different user",
"open_settings": "Open Settings"
},
"pairing": {
"pair_with_phone": "Pair with Phone",
"pair_with_phone_title": "Login TV",
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
"waiting_for_phone": "Waiting for phone...",
"scan_with_phone": "Scan with the Streamyfin app on your phone",
"logging_in": "Logging in...",
"logging_in_description": "Connecting to your server"
}
}

View File

@@ -4,6 +4,9 @@
"error_title": "Fel",
"login_title": "Logga in",
"login_to_title": "Logga in till",
"select_user": "Select a user to log in",
"add_user_to_login": "Add a user to log in",
"add_user": "Add User",
"username_placeholder": "Användarnamn",
"password_placeholder": "Lösenord",
"login_button": "Logga in",
@@ -44,7 +47,11 @@
"add_account": "Lägg till konto",
"remove_account_description": "Detta kommer att ta bort de sparade uppgifterna för {{username}}.",
"remove_server": "Ta bort server",
"remove_server_description": "Detta kommer att ta bort {{server}} och alla sparade konton från din lista."
"remove_server_description": "Detta kommer att ta bort {{server}} och alla sparade konton från din lista.",
"select_your_server": "Select Your Server",
"add_server_to_get_started": "Add a server to get started",
"add_server": "Add Server",
"change_server": "Change Server"
},
"save_account": {
"title": "Spara konto",
@@ -112,6 +119,12 @@
"settings": {
"settings_title": "Inställningar",
"log_out_button": "Logga ut",
"switch_user": {
"title": "Switch User",
"account": "Account",
"switch_user": "Switch User on This Server",
"current": "current"
},
"categories": {
"title": "Kategorier"
},
@@ -128,12 +141,12 @@
"show_home_backdrop": "Dynamisk hembakgrund",
"show_hero_carousel": "Hjältekarusell",
"show_series_poster_on_episode": "Visa serieaffisch på avsnitt",
"theme_music": "Temamusik",
"display_size": "Visningsstorlek",
"display_size_small": "Liten",
"display_size_default": "Standard",
"display_size_large": "Stor",
"display_size_extra_large": "Extra stor",
"theme_music": "Temamusik"
"display_size_extra_large": "Extra stor"
},
"network": {
"title": "Nätverk",
@@ -196,6 +209,12 @@
"max_cache_size": "Max cachestorlek",
"max_backward_cache": "Max bakåtcache"
},
"vo_driver": {
"title": "Video Output",
"vo_mode": "VO Driver",
"gpu_next": "gpu-next (Recommended)",
"gpu": "gpu"
},
"gesture_controls": {
"gesture_controls_title": "Gestkontroller",
"horizontal_swipe_skip": "Horisontell Svepning för att Hoppa Fram/Bak",
@@ -371,7 +390,7 @@
"server_url_placeholder": "Seerr URL",
"password": "Lösenord",
"password_placeholder": "Ange lösenord för Jellyfin användare {{username}}",
"login_button": "Logga in",
"login_button": "Login",
"total_media_requests": "Totalt antal mediaförfrågningar",
"movie_quota_limit": "Gräns för filmkvot",
"movie_quota_days": "Filmkvot Dagar",
@@ -444,7 +463,13 @@
"music_cache_cleared": "Musikcache rensad",
"delete_all_downloaded_songs": "Ta bort alla nerladdade filer",
"downloaded_songs_size": "{{size}} nedladdad",
"downloaded_songs_deleted": "Nedladdade låtar raderade"
"downloaded_songs_deleted": "Nedladdade låtar raderade",
"clear_all_cache": "Clear All Cache",
"clear_all_cache_confirm": "Clear All Cache?",
"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.",
"clear_all_cache_success": "Cache Cleared",
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
},
"intro": {
"title": "Introduktion",
@@ -468,6 +493,21 @@
"error_deleting_files": "Fel Vid Borttagning Av Filer",
"background_downloads_enabled": "Bakgrundsnedladdningar aktiverade",
"background_downloads_disabled": "Bakgrundsnedladdningar inaktiverade"
},
"security": {
"title": "Security",
"inactivity_timeout": {
"title": "Inactivity Timeout",
"description": "Auto logout after inactivity",
"disabled": "Disabled",
"1_minute": "1 minute",
"5_minutes": "5 minutes",
"15_minutes": "15 minutes",
"30_minutes": "30 minutes",
"1_hour": "1 hour",
"4_hours": "4 hours",
"24_hours": "24 hours"
}
}
},
"sessions": {
@@ -494,6 +534,7 @@
"new_app_version_requires_re_download_description": "Den nya uppdateringen kräver att innehållet laddas ner igen. Ta bort allt nedladdat innehåll och försök igen.",
"back": "Tillbaka",
"delete": "Radera",
"delete_download": "Delete Download",
"something_went_wrong": "Något Gick Fel",
"could_not_get_stream_url_from_jellyfin": "Det gick inte att hämta strömadressen från Jellyfin",
"eta": "ETA {{eta}}",
@@ -537,6 +578,8 @@
"audio": "Ljud",
"subtitle": "Undertext",
"play": "Spela",
"mark_as_played": "Mark as Played",
"mark_as_not_played": "Mark as not Played",
"none": "Ingen",
"track": "Spår",
"cancel": "Avbryt",
@@ -549,8 +592,7 @@
"continue": "Fortsätt",
"verifying": "Verifierar...",
"login": "Logga in",
"refresh": "Uppdatera",
"seeAll": "Visa alla"
"refresh": "Uppdatera"
},
"search": {
"search": "Sök...",
@@ -610,7 +652,8 @@
"poster": "Affisch",
"cover": "Omslag",
"show_titles": "Visa Titlar",
"show_stats": "Visa Statistik"
"show_stats": "Visa Statistik",
"options_title": "Options"
},
"filters": {
"genres": "Genrer",
@@ -640,6 +683,7 @@
},
"player": {
"live": "LIVE",
"mpv_player_title": "MPV Player",
"error": "Fel",
"failed_to_get_stream_url": "Kunde inte hämta stream-URL",
"an_error_occured_while_playing_the_video": "Ett fel uppstod vid uppspelning av videon. Kontrollera loggarna i inställningarna.",
@@ -681,6 +725,12 @@
"stopPlayingConfirm": "Är du säker på att du vill stoppa uppspelningen?",
"downloaded": "Nedladdad"
},
"chapters": {
"title": "Chapters",
"chapter_number": "Chapter {{number}}",
"open": "Open chapters",
"close": "Close chapters"
},
"item_card": {
"next_up": "Näst på tur",
"no_items_to_display": "Inga Artiklar Att Visa",
@@ -793,7 +843,7 @@
"quality_profile": "Kvalitetsprofil",
"root_folder": "Rotkatalog",
"season_all": "Säsong (alla)",
"season_number": "Säsong {{season_number}}",
"season_number": "Säsong {{seasonNumber}}",
"number_episodes": "{{episode_number}} Avsnitt",
"born": "Född",
"appearances": "Framträdanden",
@@ -952,5 +1002,36 @@
"show": "Denna serie",
"all": "Alla media (standard)"
}
},
"companion_login": {
"title": "Pair with TV",
"align_qr": "Align the QR code within the frame",
"enter_code_manually": "Enter code manually",
"pairing_enter_credentials": "Enter credentials for TV",
"pairing_code_label": "Pairing code",
"server": "Server",
"authorize_button": "Authorize",
"authorizing": "Authorizing...",
"scan_again": "Scan Again",
"done": "Done",
"success_title": "Authorization Sent",
"pairing_tv_connecting": "The TV is connecting to your account",
"error_title": "Authorization Failed",
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
"error_generic": "Something went wrong. Please try again.",
"error_permission_denied": "Camera permission is required to scan QR codes.",
"login_as": "Log in as {{username}}?",
"on_server": "on {{server}}",
"use_different_user": "Use a different user",
"open_settings": "Open Settings"
},
"pairing": {
"pair_with_phone": "Pair with Phone",
"pair_with_phone_title": "Login TV",
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
"waiting_for_phone": "Waiting for phone...",
"scan_with_phone": "Scan with the Streamyfin app on your phone",
"logging_in": "Logging in...",
"logging_in_description": "Connecting to your server"
}
}

View File

@@ -4,6 +4,9 @@
"error_title": "Error",
"login_title": "Log In",
"login_to_title": "Log in to",
"select_user": "Select a user to log in",
"add_user_to_login": "Add a user to log in",
"add_user": "Add User",
"username_placeholder": "Username",
"password_placeholder": "Password",
"login_button": "Log In",
@@ -42,7 +45,13 @@
"accounts_count": "{{count}} accounts",
"select_account": "Select Account",
"add_account": "Add Account",
"remove_account_description": "This will remove the saved credentials for {{username}}."
"remove_account_description": "This will remove the saved credentials for {{username}}.",
"remove_server": "Remove Server",
"remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
"select_your_server": "Select Your Server",
"add_server_to_get_started": "Add a server to get started",
"add_server": "Add Server",
"change_server": "Change Server"
},
"save_account": {
"title": "Save Account",
@@ -86,6 +95,7 @@
"oops": "Oops!",
"error_message": "Something went wrong.\nPlease log out and in again.",
"continue_watching": "Continue Watching",
"continue": "Continue",
"next_up": "Next Up",
"continue_and_next_up": "Continue & Next Up",
"recently_added_in": "Recently Added in {{libraryName}}",
@@ -109,6 +119,12 @@
"settings": {
"settings_title": "Settings",
"log_out_button": "Log Out",
"switch_user": {
"title": "Switch User",
"account": "Account",
"switch_user": "Switch User on This Server",
"current": "current"
},
"categories": {
"title": "Categories"
},
@@ -121,7 +137,16 @@
"appearance": {
"title": "ปรับแต่งลักษณะภายนอก",
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
"hide_remote_session_button": "Hide Remote Session Button"
"hide_remote_session_button": "Hide Remote Session Button",
"show_home_backdrop": "Dynamic Home Backdrop",
"show_hero_carousel": "Hero Carousel",
"show_series_poster_on_episode": "Show Series Poster on Episodes",
"theme_music": "Theme Music",
"display_size": "Display Size",
"display_size_small": "Small",
"display_size_default": "Default",
"display_size_large": "Large",
"display_size_extra_large": "Extra Large"
},
"network": {
"title": "Network",
@@ -174,6 +199,22 @@
"rewind_length": "Rewind Length",
"seconds_unit": "s"
},
"buffer": {
"title": "Buffer Settings",
"cache_mode": "Cache Mode",
"cache_auto": "Auto",
"cache_yes": "Enabled",
"cache_no": "Disabled",
"buffer_duration": "Buffer Duration",
"max_cache_size": "Max Cache Size",
"max_backward_cache": "Max Backward Cache"
},
"vo_driver": {
"title": "Video Output",
"vo_mode": "VO Driver",
"gpu_next": "gpu-next (Recommended)",
"gpu": "gpu"
},
"gesture_controls": {
"gesture_controls_title": "Gesture Controls",
"horizontal_swipe_skip": "Horizontal Swipe to Skip",
@@ -256,7 +297,23 @@
"subtitle_font": "Subtitle Font",
"ksplayer_title": "KSPlayer Settings",
"hardware_decode": "Hardware Decoding",
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues."
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues.",
"opensubtitles_title": "OpenSubtitles",
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
"opensubtitles_api_key": "API Key",
"opensubtitles_api_key_placeholder": "Enter API key...",
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
"mpv_subtitle_scale": "Subtitle Scale",
"mpv_subtitle_margin_y": "Vertical Margin",
"mpv_subtitle_align_x": "Horizontal Align",
"mpv_subtitle_align_y": "Vertical Align",
"align": {
"left": "Left",
"center": "Center",
"right": "Right",
"top": "Top",
"bottom": "Bottom"
}
},
"vlc_subtitles": {
"title": "VLC Subtitle Settings",
@@ -406,7 +463,13 @@
"music_cache_cleared": "Music cache cleared",
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
"downloaded_songs_size": "{{size}} downloaded",
"downloaded_songs_deleted": "Downloaded songs deleted"
"downloaded_songs_deleted": "Downloaded songs deleted",
"clear_all_cache": "Clear All Cache",
"clear_all_cache_confirm": "Clear All Cache?",
"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.",
"clear_all_cache_success": "Cache Cleared",
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
},
"intro": {
"title": "Intro",
@@ -430,6 +493,21 @@
"error_deleting_files": "Error Deleting Files",
"background_downloads_enabled": "Background downloads enabled",
"background_downloads_disabled": "Background downloads disabled"
},
"security": {
"title": "Security",
"inactivity_timeout": {
"title": "Inactivity Timeout",
"description": "Auto logout after inactivity",
"disabled": "Disabled",
"1_minute": "1 minute",
"5_minutes": "5 minutes",
"15_minutes": "15 minutes",
"30_minutes": "30 minutes",
"1_hour": "1 hour",
"4_hours": "4 hours",
"24_hours": "24 hours"
}
}
},
"sessions": {
@@ -456,6 +534,7 @@
"new_app_version_requires_re_download_description": "The new update requires content to be downloaded again. Please remove all downloaded content and try again.",
"back": "Back",
"delete": "Delete",
"delete_download": "Delete Download",
"something_went_wrong": "Something Went Wrong",
"could_not_get_stream_url_from_jellyfin": "Could not get the stream URL from Jellyfin",
"eta": "ETA {{eta}}",
@@ -492,22 +571,28 @@
}
},
"common": {
"no_results": "No Results",
"select": "Select",
"no_trailer_available": "No trailer available",
"video": "Video",
"audio": "Audio",
"subtitle": "Subtitle",
"play": "Play",
"mark_as_played": "Mark as Played",
"mark_as_not_played": "Mark as not Played",
"none": "None",
"track": "Track",
"cancel": "Cancel",
"stop": "Stop",
"delete": "Delete",
"ok": "OK",
"remove": "Remove",
"next": "Next",
"back": "Back",
"continue": "Continue",
"verifying": "Verifying..."
"verifying": "Verifying...",
"login": "Login",
"refresh": "Refresh"
},
"search": {
"search": "Search...",
@@ -556,6 +641,7 @@
"movies": "Movies",
"series": "Series",
"boxsets": "Box Sets",
"playlists": "Playlists",
"items": "Items"
},
"options": {
@@ -566,7 +652,8 @@
"poster": "Poster",
"cover": "Cover",
"show_titles": "Show Titles",
"show_stats": "Show Stats"
"show_stats": "Show Stats",
"options_title": "Options"
},
"filters": {
"genres": "Genres",
@@ -574,7 +661,11 @@
"sort_by": "Sort By",
"filter_by": "Filter By",
"sort_order": "Sort Order",
"tags": "Tags"
"tags": "Tags",
"all": "All",
"reset": "Reset",
"asc": "Ascending",
"desc": "Descending"
}
},
"favorites": {
@@ -591,6 +682,8 @@
"no_links": "No Links"
},
"player": {
"live": "LIVE",
"mpv_player_title": "MPV Player",
"error": "Error",
"failed_to_get_stream_url": "Failed to get the stream URL",
"an_error_occured_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
@@ -608,7 +701,35 @@
"downloaded_file_message": "Do you want to play the downloaded file?",
"downloaded_file_yes": "Yes",
"downloaded_file_no": "No",
"downloaded_file_cancel": "Cancel"
"downloaded_file_cancel": "Cancel",
"swipe_down_settings": "Swipe down for settings",
"ends_at": "Ends at {{time}}",
"search_subtitles": "Search Subtitles",
"subtitle_tracks": "Tracks",
"subtitle_search": "Search & Download",
"download": "Download",
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
"using_jellyfin_server": "Using Jellyfin Server",
"language": "Language",
"results": "Results",
"searching": "Searching...",
"search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found",
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
"settings": "Settings",
"skip_intro": "Skip Intro",
"skip_credits": "Skip Credits",
"stopPlayback": "Stop Playback",
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
"stopPlayingConfirm": "Are you sure you want to stop playback?",
"downloaded": "Downloaded"
},
"chapters": {
"title": "Chapters",
"chapter_number": "Chapter {{number}}",
"open": "Open chapters",
"close": "Close chapters"
},
"item_card": {
"next_up": "Next Up",
@@ -617,6 +738,11 @@
"series": "Series",
"seasons": "Seasons",
"season": "Season",
"from_this_series": "From This Series",
"more_from_this_season": "More from this Season",
"view_series": "View Series",
"view_season": "View Season",
"select_season": "Select Season",
"no_episodes_for_this_season": "No episodes for this season",
"overview": "Overview",
"more_with": "More with {{name}}",
@@ -627,10 +753,21 @@
"media_options": "Media Options",
"quality": "Quality",
"audio": "Audio",
"subtitles": "Subtitle",
"subtitles": {
"label": "Subtitle",
"none": "None",
"tracks": "Tracks"
},
"show_more": "Show More",
"show_less": "Show Less",
"left": "left",
"more_info": "More Info",
"director": "Director",
"cast": "Cast",
"technical_details": "Technical Details",
"appeared_in": "Appeared In",
"movies": "Movies",
"shows": "Shows",
"could_not_load_item": "Could Not Load Item",
"none": "None",
"download": {
@@ -641,7 +778,13 @@
"download_x_item": "Download {{item_count}} Items",
"download_unwatched_only": "Unwatched Only",
"download_button": "Download"
}
},
"mark_played": "Mark as Watched",
"mark_unplayed": "Mark as Unwatched",
"resume_playback": "Resume Playback",
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
"play_from_start": "Play from Start",
"continue_from": "Continue from {{time}}"
},
"live_tv": {
"next": "Next",
@@ -652,7 +795,18 @@
"movies": "Movies",
"sports": "Sports",
"for_kids": "For Kids",
"news": "News"
"news": "News",
"page_of": "Page {{current}} of {{total}}",
"no_programs": "No programs available",
"no_channels": "No channels available",
"tabs": {
"programs": "Programs",
"guide": "Guide",
"channels": "Channels",
"recordings": "Recordings",
"schedule": "Schedule",
"series": "Series"
}
},
"jellyseerr": {
"confirm": "Confirm",
@@ -697,6 +851,12 @@
"decline": "Decline",
"requested_by": "Requested by {{user}}",
"unknown_user": "Unknown User",
"select": "Select",
"request_all": "Request All",
"request_seasons": "Request Seasons",
"select_seasons": "Select Seasons",
"request_selected": "Request Selected",
"n_selected": "{{count}} selected",
"toasts": {
"jellyseer_does_not_meet_requirements": "Seerr server does not meet minimum version requirements! Please update to at least 2.0.0",
"jellyseerr_test_failed": "Seerr test failed. Please try again.",
@@ -716,7 +876,8 @@
"search": "Search",
"library": "Library",
"custom_links": "Custom Links",
"favorites": "Favorites"
"favorites": "Favorites",
"settings": "Settings"
},
"music": {
"title": "Music",
@@ -841,5 +1002,36 @@
"show": "This show",
"all": "All media (default)"
}
},
"companion_login": {
"title": "Pair with TV",
"align_qr": "Align the QR code within the frame",
"enter_code_manually": "Enter code manually",
"pairing_enter_credentials": "Enter credentials for TV",
"pairing_code_label": "Pairing code",
"server": "Server",
"authorize_button": "Authorize",
"authorizing": "Authorizing...",
"scan_again": "Scan Again",
"done": "Done",
"success_title": "Authorization Sent",
"pairing_tv_connecting": "The TV is connecting to your account",
"error_title": "Authorization Failed",
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
"error_generic": "Something went wrong. Please try again.",
"error_permission_denied": "Camera permission is required to scan QR codes.",
"login_as": "Log in as {{username}}?",
"on_server": "on {{server}}",
"use_different_user": "Use a different user",
"open_settings": "Open Settings"
},
"pairing": {
"pair_with_phone": "Pair with Phone",
"pair_with_phone_title": "Login TV",
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
"waiting_for_phone": "Waiting for phone...",
"scan_with_phone": "Scan with the Streamyfin app on your phone",
"logging_in": "Logging in...",
"logging_in_description": "Connecting to your server"
}
}

View File

@@ -4,6 +4,9 @@
"error_title": "ghIq",
"login_title": "lut 'el",
"login_to_title": "lut 'el",
"select_user": "Select a user to log in",
"add_user_to_login": "Add a user to log in",
"add_user": "Add User",
"username_placeholder": "tlhIngan",
"password_placeholder": "ngoq De'",
"login_button": "yI'el!",
@@ -42,7 +45,13 @@
"accounts_count": "{{count}} accounts",
"select_account": "Select Account",
"add_account": "Add Account",
"remove_account_description": "This will remove the saved credentials for {{username}}."
"remove_account_description": "This will remove the saved credentials for {{username}}.",
"remove_server": "Remove Server",
"remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
"select_your_server": "Select Your Server",
"add_server_to_get_started": "Add a server to get started",
"add_server": "Add Server",
"change_server": "Change Server"
},
"save_account": {
"title": "Save Account",
@@ -86,6 +95,7 @@
"oops": "QI'ya!",
"error_message": "Doch rurbe'.\nyIQo' 'ej yI'elqa'.",
"continue_watching": "tlhol yIHaDqa'",
"continue": "Continue",
"next_up": "wej",
"continue_and_next_up": "Continue & Next Up",
"recently_added_in": "num tu'lu' {{libraryName}}",
@@ -109,6 +119,12 @@
"settings": {
"settings_title": "men",
"log_out_button": "yIQo'",
"switch_user": {
"title": "Switch User",
"account": "Account",
"switch_user": "Switch User on This Server",
"current": "current"
},
"categories": {
"title": "Categories"
},
@@ -121,7 +137,16 @@
"appearance": {
"title": "Appearance",
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
"hide_remote_session_button": "Hide Remote Session Button"
"hide_remote_session_button": "Hide Remote Session Button",
"show_home_backdrop": "Dynamic Home Backdrop",
"show_hero_carousel": "Hero Carousel",
"show_series_poster_on_episode": "Show Series Poster on Episodes",
"theme_music": "Theme Music",
"display_size": "Display Size",
"display_size_small": "Small",
"display_size_default": "Default",
"display_size_large": "Large",
"display_size_extra_large": "Extra Large"
},
"network": {
"title": "Network",
@@ -174,6 +199,22 @@
"rewind_length": "bavHom vum",
"seconds_unit": "tera' rep"
},
"buffer": {
"title": "Buffer Settings",
"cache_mode": "Cache Mode",
"cache_auto": "Auto",
"cache_yes": "Enabled",
"cache_no": "Disabled",
"buffer_duration": "Buffer Duration",
"max_cache_size": "Max Cache Size",
"max_backward_cache": "Max Backward Cache"
},
"vo_driver": {
"title": "Video Output",
"vo_mode": "VO Driver",
"gpu_next": "gpu-next (Recommended)",
"gpu": "gpu"
},
"gesture_controls": {
"gesture_controls_title": "QavwI' 'ej Qap",
"horizontal_swipe_skip": "SaS mup loSmeH",
@@ -256,7 +297,23 @@
"subtitle_font": "Subtitle Font",
"ksplayer_title": "KSPlayer Settings",
"hardware_decode": "Hardware Decoding",
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues."
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues.",
"opensubtitles_title": "OpenSubtitles",
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
"opensubtitles_api_key": "API Key",
"opensubtitles_api_key_placeholder": "Enter API key...",
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
"mpv_subtitle_scale": "Subtitle Scale",
"mpv_subtitle_margin_y": "Vertical Margin",
"mpv_subtitle_align_x": "Horizontal Align",
"mpv_subtitle_align_y": "Vertical Align",
"align": {
"left": "Left",
"center": "Center",
"right": "Right",
"top": "Top",
"bottom": "Bottom"
}
},
"vlc_subtitles": {
"title": "VLC Subtitle Settings",
@@ -406,7 +463,13 @@
"music_cache_cleared": "Music cache cleared",
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
"downloaded_songs_size": "{{size}} downloaded",
"downloaded_songs_deleted": "Downloaded songs deleted"
"downloaded_songs_deleted": "Downloaded songs deleted",
"clear_all_cache": "Clear All Cache",
"clear_all_cache_confirm": "Clear All Cache?",
"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.",
"clear_all_cache_success": "Cache Cleared",
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
},
"intro": {
"title": "Intro",
@@ -430,6 +493,21 @@
"error_deleting_files": "Qaw' ghIq",
"background_downloads_enabled": "tlhegh Qaw' chu'",
"background_downloads_disabled": "tlhegh Qaw' QIj"
},
"security": {
"title": "Security",
"inactivity_timeout": {
"title": "Inactivity Timeout",
"description": "Auto logout after inactivity",
"disabled": "Disabled",
"1_minute": "1 minute",
"5_minutes": "5 minutes",
"15_minutes": "15 minutes",
"30_minutes": "30 minutes",
"1_hour": "1 hour",
"4_hours": "4 hours",
"24_hours": "24 hours"
}
}
},
"sessions": {
@@ -456,6 +534,7 @@
"new_app_version_requires_re_download_description": "wej chu' Doch Qaw'qa' DaneH. Hoch Qaw' Doch yIQaw' 'ej yIHaDqa'.",
"back": "yIbav",
"delete": "yIQaw'",
"delete_download": "Delete Download",
"something_went_wrong": "Doch rurbe'",
"could_not_get_stream_url_from_jellyfin": "Jellyfin tlhol ret URL tu'laHbe'",
"eta": "ETA {{eta}}",
@@ -492,22 +571,28 @@
}
},
"common": {
"no_results": "No Results",
"select": "Select",
"no_trailer_available": "No trailer available",
"video": "mu'tlhegh",
"audio": "QoQ",
"subtitle": "De' chu'",
"play": "Play",
"mark_as_played": "Mark as Played",
"mark_as_not_played": "Mark as not Played",
"none": "None",
"track": "Track",
"cancel": "Cancel",
"stop": "Stop",
"delete": "Delete",
"ok": "OK",
"remove": "Remove",
"next": "Next",
"back": "Back",
"continue": "Continue",
"verifying": "Verifying..."
"verifying": "Verifying...",
"login": "Login",
"refresh": "Refresh"
},
"search": {
"search": "yISam...",
@@ -556,6 +641,7 @@
"movies": "DIS",
"series": "Hem",
"boxsets": "Hem ghom",
"playlists": "Playlists",
"items": "Doch"
},
"options": {
@@ -566,7 +652,8 @@
"poster": "nagh",
"cover": "nagh chop",
"show_titles": "pab HoS yIHoch",
"show_stats": "chIm De' yIHoch"
"show_stats": "chIm De' yIHoch",
"options_title": "Options"
},
"filters": {
"genres": "qorDu'",
@@ -574,7 +661,11 @@
"sort_by": "yIwIv",
"filter_by": "Filter By",
"sort_order": "wIv mIw",
"tags": "De'Hom"
"tags": "De'Hom",
"all": "All",
"reset": "Reset",
"asc": "Ascending",
"desc": "Descending"
}
},
"favorites": {
@@ -591,6 +682,8 @@
"no_links": "ret pagh"
},
"player": {
"live": "LIVE",
"mpv_player_title": "MPV Player",
"error": "ghIq",
"failed_to_get_stream_url": "tlhol ret URL tu'laHbe'",
"an_error_occured_while_playing_the_video": "mu'tlhegh tlholDI' ghIq. menDaq De' qon mej.",
@@ -608,7 +701,35 @@
"downloaded_file_message": "Do you want to play the downloaded file?",
"downloaded_file_yes": "Yes",
"downloaded_file_no": "No",
"downloaded_file_cancel": "Cancel"
"downloaded_file_cancel": "Cancel",
"swipe_down_settings": "Swipe down for settings",
"ends_at": "Ends at {{time}}",
"search_subtitles": "Search Subtitles",
"subtitle_tracks": "Tracks",
"subtitle_search": "Search & Download",
"download": "Download",
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
"using_jellyfin_server": "Using Jellyfin Server",
"language": "Language",
"results": "Results",
"searching": "Searching...",
"search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found",
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
"settings": "Settings",
"skip_intro": "Skip Intro",
"skip_credits": "Skip Credits",
"stopPlayback": "Stop Playback",
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
"stopPlayingConfirm": "Are you sure you want to stop playback?",
"downloaded": "Downloaded"
},
"chapters": {
"title": "Chapters",
"chapter_number": "Chapter {{number}}",
"open": "Open chapters",
"close": "Close chapters"
},
"item_card": {
"next_up": "wej",
@@ -617,6 +738,11 @@
"series": "Hem",
"seasons": "muv",
"season": "muv",
"from_this_series": "From This Series",
"more_from_this_season": "More from this Season",
"view_series": "View Series",
"view_season": "View Season",
"select_season": "Select Season",
"no_episodes_for_this_season": "muvvam HemHom pagh",
"overview": "Hoch Sov",
"more_with": "{{name}} latlh",
@@ -627,10 +753,21 @@
"media_options": "Media Options",
"quality": "luj",
"audio": "QoQ",
"subtitles": "De' chu'",
"subtitles": {
"label": "Subtitle",
"none": "None",
"tracks": "Tracks"
},
"show_more": "latlh yIHoch",
"show_less": "Hom yIHoch",
"left": "left",
"more_info": "More Info",
"director": "Director",
"cast": "Cast",
"technical_details": "Technical Details",
"appeared_in": "tlholvam",
"movies": "Movies",
"shows": "Shows",
"could_not_load_item": "Doch tlha'laHbe'",
"none": "pagh",
"download": {
@@ -641,7 +778,13 @@
"download_x_item": "{{item_count}} Doch yIQaw'",
"download_unwatched_only": "Unwatched Only",
"download_button": "yIQaw'"
}
},
"mark_played": "Mark as Watched",
"mark_unplayed": "Mark as Unwatched",
"resume_playback": "Resume Playback",
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
"play_from_start": "Play from Start",
"continue_from": "Continue from {{time}}"
},
"live_tv": {
"next": "wej",
@@ -652,7 +795,18 @@
"movies": "DIS",
"sports": "QI'",
"for_kids": "puqbeq",
"news": "De'"
"news": "De'",
"page_of": "Page {{current}} of {{total}}",
"no_programs": "No programs available",
"no_channels": "No channels available",
"tabs": {
"programs": "Programs",
"guide": "Guide",
"channels": "Channels",
"recordings": "Recordings",
"schedule": "Schedule",
"series": "Series"
}
},
"jellyseerr": {
"confirm": "yInej",
@@ -697,6 +851,12 @@
"decline": "Decline",
"requested_by": "Requested by {{user}}",
"unknown_user": "Unknown User",
"select": "Select",
"request_all": "Request All",
"request_seasons": "Request Seasons",
"select_seasons": "Select Seasons",
"request_selected": "Request Selected",
"n_selected": "{{count}} selected",
"toasts": {
"jellyseer_does_not_meet_requirements": "Jellyseerr Ho'Do' veS wej law'be'! 2.0.0 yIchu'!",
"jellyseerr_test_failed": "Jellyseerr nejlaHbe'. yIHaDqa'.",
@@ -716,7 +876,8 @@
"search": "Sam",
"library": "De'wI' bom",
"custom_links": "teqlu' ret",
"favorites": "wIv Doch"
"favorites": "wIv Doch",
"settings": "Settings"
},
"music": {
"title": "Music",
@@ -841,5 +1002,36 @@
"show": "This show",
"all": "All media (default)"
}
},
"companion_login": {
"title": "Pair with TV",
"align_qr": "Align the QR code within the frame",
"enter_code_manually": "Enter code manually",
"pairing_enter_credentials": "Enter credentials for TV",
"pairing_code_label": "Pairing code",
"server": "Server",
"authorize_button": "Authorize",
"authorizing": "Authorizing...",
"scan_again": "Scan Again",
"done": "Done",
"success_title": "Authorization Sent",
"pairing_tv_connecting": "The TV is connecting to your account",
"error_title": "Authorization Failed",
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
"error_generic": "Something went wrong. Please try again.",
"error_permission_denied": "Camera permission is required to scan QR codes.",
"login_as": "Log in as {{username}}?",
"on_server": "on {{server}}",
"use_different_user": "Use a different user",
"open_settings": "Open Settings"
},
"pairing": {
"pair_with_phone": "Pair with Phone",
"pair_with_phone_title": "Login TV",
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
"waiting_for_phone": "Waiting for phone...",
"scan_with_phone": "Scan with the Streamyfin app on your phone",
"logging_in": "Logging in...",
"logging_in_description": "Connecting to your server"
}
}

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