Compare commits

...

66 Commits

Author SHA1 Message Date
Uruk
a4e47e5cb7 feat(casting): improve track selection and session handling
implements automatic initialization of audio and subtitle tracks based on server-provided defaults. ensures subtitle selection persists correctly during stream reloads by resolving track indices more reliably.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

All player-related strings (error messages, playback controls, download prompts) are now accessible as common translations, enabling their use throughout the application without namespace-specific imports.
2026-05-21 00:47:31 +02:00
Uruk
88b79920bf feat: add i18n support for skip button text
- Add player.skip_* translation keys for all 5 segment types
- Enable proper localization of skip button text
- Addresses GitHub Copilot review comment
2026-05-21 00:47:31 +02:00
Uruk
203c6d59b0 refactor: address GitHub Copilot review comments
- Remove unnecessary currentSegment from skipSegment dependency array
- Remove redundant wrappedSeek wrapper (ref guard prevents issues)
- Document 200ms setTimeout delay for seek operations
- Improve code clarity and reduce unnecessary re-renders
2026-05-21 00:47:31 +02:00
Uruk
bb491d4e86 refactor: apply CodeRabbit suggestions for segment skip feature
- Add missing segment types (recap, commercial, preview) to JobStatus
- Consolidate duplicate useMemo blocks with factory function
- Improve code maintainability and consistency
2026-05-21 00:47:31 +02:00
Uruk
0b6639fc4e feat: add comprehensive segment skip with all 5 types and settings submenu
- Add SegmentSkipMode type ('none', 'ask', 'auto') in settings.ts
- Create 5 segment skip settings: skipIntro, skipOutro, skipRecap, skipCommercial, skipPreview
- Update segments.ts to fetch all 5 segment types from Jellyfin MediaSegments API (10.11+)
- Create unified useSegmentSkipper hook supporting all segment types with 3 modes
- Update video player Controls.tsx with priority system (Commercial > Recap > Intro > Preview > Outro)
- Add dynamic skip button text in BottomControls.tsx
- Create dedicated settings submenu at settings/segment-skip/page.tsx
- Simplify PlaybackControlsSettings.tsx with navigation to submenu
- Extend DownloadedItem interface with all segment types for offline support
- Add 13+ translation keys for segment skip UI
2026-05-21 00:47:31 +02:00
Uruk
71d922beeb fix(chromecast): resolve TypeScript errors and improve type safety
- Fix deviceName property to use friendlyName
- Update disconnect to use stop() instead of endSession()
- Fix null handling in getPosterUrl and useTrickplay
- Remove unused variables and imports
- Add proper null checks in segment skipping
- Disable auto-skip until settings are available
2026-05-21 00:47:31 +02:00
Uruk
5e60b6c2f8 feat(chromecast): add new player UI with mini player, hooks, and utilities
- Create ChromecastMiniPlayer component (bottom bar navigation)
- Create chromecast-player modal route with full UI
- Add useChromecastPlayer hook (playback controls & state)
- Add useChromecastSegments hook (intro/credits/segments)
- Add chromecast options (constants & config)
- Add chromecast helpers (time formatting, quality checks)
- Implement swipe-down gesture to dismiss
- Add Netflix-style buffering indicator
- Add progress tracking with trickplay support
- Add next episode countdown
- Ready for segments integration from autoskip branch
2026-05-21 00:47:31 +02:00
Uruk
da70541c8e fix: add Chromecast video progress tracking 2026-05-21 00:47:31 +02:00
renovate[bot]
f79cf1925d chore(deps): Update dependency @react-native-tvos/config-tv to v0.1.6 (#1430) 2026-05-21 00:18:29 +02:00
renovate[bot]
8bb0d845a2 chore(deps): Update dependency lint-staged to v17 (#1570) 2026-05-20 23:29:39 +02:00
renovate[bot]
c7cd8217c9 chore(deps): Update dependency react-i18next to v16.5.8 (#1500)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
2026-05-20 23:03:35 +02:00
renovate[bot]
235ba1473f chore(deps): Update dependency lint-staged to v16.4.0 (#1507) 2026-05-20 22:40:20 +02:00
renovate[bot]
284a4e3d41 chore(deps): Update dependency expo-doctor to v1.18.22 (#1506) 2026-05-20 19:10:36 +02:00
renovate[bot]
1fd3574520 chore(deps): Update actions/github-script action to v9 (#1568) 2026-05-20 18:36:38 +02:00
renovate[bot]
f1188c090a chore(deps): Update peter-evans/create-pull-request action to v8.1.1 (#1566) 2026-05-20 17:37:53 +02:00
renovate[bot]
1321a5c000 chore(deps): Update actions/dependency-review-action action to v5 (#1567) 2026-05-20 17:05:10 +02:00
renovate[bot]
52bc5e912d chore(deps): Update actions/dependency-review-action action to v4.9.0 (#1502) 2026-05-20 15:28:34 +02:00
renovate[bot]
7bccafc476 chore(deps): Update actions/dependency-review-action action to v4.8.3 (#1499)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
2026-05-20 14:49:24 +02:00
renovate[bot]
8df61838d4 chore(deps): Update actions/cache action to v5.0.5 (#1429) 2026-05-20 13:43:17 +02:00
renovate[bot]
55776d887f chore(deps): Update maxim-lobanov/setup-xcode digest to ed7a3b1 (#1498) 2026-05-20 13:17:20 +02:00
renovate[bot]
2e7079cb5a chore(deps): Update github/codeql-action action to v4.35.5 (#1508) 2026-05-20 12:54:50 +02:00
renovate[bot]
428455f6a6 chore(deps): Update actions/setup-node action to v6.4.0 (#1503)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
2026-05-20 01:08:17 +02:00
renovate[bot]
8c749cdc4d chore(deps): Update oven-sh/setup-bun action to v2.2.0 (#1509) 2026-05-20 00:44:45 +02:00
renovate[bot]
7ed0c00ce7 chore(deps): Update actions/upload-artifact action to v7 (#1510) 2026-05-20 00:24:58 +02:00
renovate[bot]
222ae69644 chore(deps): Update marocchino/sticky-pull-request-comment action to v3 (#1512) 2026-05-20 00:03:36 +02:00
renovate[bot]
fec8df37f7 chore(deps): Pin dependencies (#1537) 2026-05-19 23:40:40 +02:00
renovate[bot]
0e0e722e1c chore(deps): Update crowdin/github-action action to v2.16.2 (#1504)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
2026-05-19 19:58:56 +02:00
Théo FORTIN
2ce810c191 fix(player/ios): Disable subtitles embedding for iOS simulator (#1544)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
2026-04-24 10:44:30 +02:00
Alex Collado
564a593a3a fix: Close modal when back button is pressed on Android (#1487)
Some checks failed
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
2026-03-12 13:45:17 +01:00
Gallyam Biktashev
479e23f001 fix(i18n): Update transalation for Russian lang (#1427)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-21 18:51:01 +01:00
github-actions[bot]
019f863b3e feat: New Crowdin Translations (#1346)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-02-19 07:45:11 +01:00
renovate[bot]
54d8693999 chore(deps): Update crowdin/github-action action to v2.14.1 (#1453)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-19 07:44:30 +01:00
quang-tran
8c0cbffd98 fix(logs): change the mimeType and UTI to the proper types for log ex… (#1424) 2026-02-19 07:39:29 +01:00
Alex
9f9d949891 feat: Re-add native apple controls for mpv (#1411) 2026-02-19 07:38:01 +01:00
58 changed files with 7121 additions and 1887 deletions

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

@@ -26,7 +26,7 @@ jobs:
steps:
- name: 🔍 Get PR and Artifacts
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
script: |
// Check if we're running from a fork (more precise detection)

View File

@@ -41,12 +41,12 @@ jobs:
show-progress: false
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
- name: 💾 Cache Bun dependencies
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
@@ -60,7 +60,7 @@ jobs:
bun run submodule-reload
- name: 💾 Cache Gradle global
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/.gradle/caches
@@ -73,7 +73,7 @@ jobs:
run: bun run prebuild
- name: 💾 Cache project Gradle (.gradle)
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: android/.gradle
key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
@@ -88,7 +88,7 @@ jobs:
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
- name: 📤 Upload APK artifact
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: streamyfin-android-phone-apk-${{ env.DATE_TAG }}
path: |
@@ -124,12 +124,12 @@ jobs:
show-progress: false
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
- name: 💾 Cache Bun dependencies
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
@@ -143,7 +143,7 @@ jobs:
bun run submodule-reload
- name: 💾 Cache Gradle global
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/.gradle/caches
@@ -156,7 +156,7 @@ jobs:
run: bun run prebuild:tv
- name: 💾 Cache project Gradle (.gradle)
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: android/.gradle
key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
@@ -171,7 +171,7 @@ jobs:
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
- name: 📤 Upload APK artifact
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: streamyfin-android-tv-apk-${{ env.DATE_TAG }}
path: |
@@ -195,12 +195,12 @@ jobs:
show-progress: false
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
- name: 💾 Cache Bun dependencies
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
@@ -216,12 +216,12 @@ jobs:
run: bun run prebuild
- name: 🔧 Setup Xcode
uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
with:
xcode-version: "26.2"
- name: 🏗️ Setup EAS
uses: expo/expo-github-action@main
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main
with:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
@@ -236,7 +236,7 @@ jobs:
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
- name: 📤 Upload IPA artifact
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: streamyfin-ios-phone-ipa-${{ env.DATE_TAG }}
path: build-*.ipa
@@ -259,12 +259,12 @@ jobs:
show-progress: false
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
- name: 💾 Cache Bun dependencies
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
@@ -280,7 +280,7 @@ jobs:
run: bun run prebuild
- name: 🔧 Setup Xcode
uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
with:
xcode-version: "26.2"
@@ -293,7 +293,7 @@ jobs:
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
- name: 📤 Upload IPA artifact
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: streamyfin-ios-phone-unsigned-ipa-${{ env.DATE_TAG }}
path: build/*.ipa

View File

@@ -27,12 +27,12 @@ jobs:
fetch-depth: 0
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
- name: 💾 Cache Bun dependencies
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/.bun/install/cache

View File

@@ -27,13 +27,13 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: 🏁 Initialize CodeQL
uses: github/codeql-action/init@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0
uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
with:
languages: ${{ matrix.language }}
queries: +security-extended,security-and-quality
- name: 🛠️ Autobuild
uses: github/codeql-action/autobuild@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0
uses: github/codeql-action/autobuild@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
- name: 🧪 Perform CodeQL Analysis
uses: github/codeql-action/analyze@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0
uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5

View File

@@ -28,7 +28,7 @@ jobs:
fetch-depth: 0
- name: 🌐 Sync Translations with Crowdin
uses: crowdin/github-action@b4b468cffefb50bdd99dd83e5d2eaeb63c880380 # v2.14.0
uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2.16.2
with:
upload_sources: true
upload_translations: true

View File

@@ -25,7 +25,7 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2.9.4
- uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
if: always() && (steps.lint_pr_title.outputs.error_message != null)
with:
header: pr-title-lint-error
@@ -39,7 +39,7 @@ jobs:
```
- if: ${{ steps.lint_pr_title.outputs.error_message == null }}
uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2.9.4
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
with:
header: pr-title-lint-error
delete: true
@@ -57,7 +57,7 @@ jobs:
fetch-depth: 0
- name: Dependency Review
uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4.8.2
uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
with:
fail-on-severity: high
base-ref: ${{ github.event.pull_request.base.sha || 'develop' }}
@@ -76,7 +76,7 @@ jobs:
fetch-depth: 0
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
@@ -107,12 +107,12 @@ jobs:
fetch-depth: 0
- name: "🟢 Setup Node.js"
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '24.x'
- name: "🍞 Setup Bun"
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest

View File

@@ -21,14 +21,14 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: "🟢 Setup Node.js"
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '24.x'
cache: 'npm'
- name: 🔍 Extract minor version from app.json
id: minor
uses: actions/github-script@main
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # main
with:
result-encoding: string
script: |
@@ -54,7 +54,7 @@ jobs:
dry_run: no-push
- name: 📬 Commit and create pull request
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
with:
add-paths: .github/ISSUE_TEMPLATE/bug_report.yml
branch: ci-update-bug-report

View File

@@ -61,7 +61,10 @@ export default function Page() {
setLoading(true);
try {
logsFile.write(JSON.stringify(filteredLogs));
await Sharing.shareAsync(logsFile.uri, { mimeType: "txt", UTI: "txt" });
await Sharing.shareAsync(logsFile.uri, {
mimeType: "text/plain",
UTI: "public.plain-text",
});
} catch (e: any) {
writeErrorLog("Something went wrong attempting to export", e);
} finally {

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

@@ -11,6 +11,7 @@ import { withLayoutContext } from "expo-router";
import { useTranslation } from "react-i18next";
import { Platform, View } from "react-native";
import { SystemBars } from "react-native-edge-to-edge";
import { CastingMiniPlayer } from "@/components/casting/CastingMiniPlayer";
import { MiniPlayerBar } from "@/components/music/MiniPlayerBar";
import { MusicPlaybackEngine } from "@/components/music/MusicPlaybackEngine";
import { Colors } from "@/constants/Colors";
@@ -118,6 +119,7 @@ export default function TabLayout() {
}}
/>
</NativeTabs>
<CastingMiniPlayer />
<MiniPlayerBar />
<MusicPlaybackEngine />
</View>

File diff suppressed because it is too large Load Diff

View File

@@ -48,6 +48,7 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import {
getMpvAudioId,
@@ -504,6 +505,31 @@ export default function page() {
return ticksToSeconds(getInitialPlaybackTicks());
}, [getInitialPlaybackTicks]);
/** Prepare metadata for iOS native media controls (Control Center, Lock Screen) */
const nowPlayingMetadata = useMemo(() => {
if (!item || !api) return undefined;
const artworkUri = getPrimaryImageUrl({
api,
item,
quality: 90,
width: 500,
});
return {
title: item.Name || "",
artist:
item.Type === "Episode"
? item.SeriesName || ""
: item.AlbumArtist || "",
albumTitle:
item.Type === "Episode" && item.SeasonName
? item.SeasonName
: undefined,
artworkUri: artworkUri || undefined,
};
}, [item, api]);
/** Build video source config for MPV */
const videoSource = useMemo<MpvVideoSource | undefined>(() => {
if (!stream?.url) return undefined;
@@ -932,6 +958,7 @@ export default function page() {
ref={videoRef}
source={videoSource}
style={{ width: "100%", height: "100%" }}
nowPlayingMetadata={nowPlayingMetadata}
onProgress={onProgress}
onPlaybackStateChange={onPlaybackStateChanged}
onLoad={() => setIsVideoLoaded(true)}
@@ -939,7 +966,7 @@ export default function page() {
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);
}}

165
bun.lock
View File

@@ -1,6 +1,5 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "streamyfin",
@@ -57,7 +56,7 @@
"patch-package": "^8.0.0",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-i18next": "16.5.4",
"react-i18next": "16.5.8",
"react-native": "0.81.5",
"react-native-awesome-slider": "^2.9.0",
"react-native-bottom-tabs": "1.1.0",
@@ -98,15 +97,15 @@
"@babel/core": "7.28.6",
"@biomejs/biome": "2.3.11",
"@react-native-community/cli": "20.1.1",
"@react-native-tvos/config-tv": "0.1.4",
"@react-native-tvos/config-tv": "0.1.6",
"@types/jest": "29.5.14",
"@types/lodash": "4.17.23",
"@types/react": "19.1.17",
"@types/react-test-renderer": "19.1.0",
"cross-env": "10.1.0",
"expo-doctor": "1.17.14",
"expo-doctor": "1.18.22",
"husky": "9.1.7",
"lint-staged": "16.2.7",
"lint-staged": "17.0.5",
"react-test-renderer": "19.2.3",
"typescript": "5.9.3",
},
@@ -404,7 +403,7 @@
"@isaacs/brace-expansion": ["@isaacs/brace-expansion@5.0.0", "", { "dependencies": { "@isaacs/balanced-match": "^4.0.1" } }, "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA=="],
"@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
"@isaacs/cliui": ["@isaacs/cliui@9.0.0", "", {}, "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg=="],
"@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="],
@@ -538,7 +537,7 @@
"@react-native-community/netinfo": ["@react-native-community/netinfo@11.4.1", "", { "peerDependencies": { "react-native": ">=0.59" } }, "sha512-B0BYAkghz3Q2V09BF88RA601XursIEA111tnc2JOaN7axJWmNefmfjZqw/KdSxKZp7CZUuPpjBmz/WCR9uaHYg=="],
"@react-native-tvos/config-tv": ["@react-native-tvos/config-tv@0.1.4", "", { "dependencies": { "getenv": "^1.0.0" }, "peerDependencies": { "expo": ">=52.0.0" } }, "sha512-xfVDqSFjEUsb+xcMk0hE2Z/M6QZH0QzAJOSQZwo7W/ZRaLrd+xFQnx0LaXqt3kxlR3P7wskKHByDP/FSoUZnbA=="],
"@react-native-tvos/config-tv": ["@react-native-tvos/config-tv@0.1.6", "", { "dependencies": { "getenv": "^1.0.0", "glob": "^11.0.0" }, "peerDependencies": { "expo": ">=52.0.0" } }, "sha512-VxMSIcro+U1EVb64pYShZsc+uE3HNGhfHppoUhTyGwx9ELQkhWvReRTOI4gpb/qeRWEcT+UbUc9Gd9Zlwm572w=="],
"@react-native/assets-registry": ["@react-native/assets-registry@0.81.5", "", {}, "sha512-705B6x/5Kxm1RKRvSv0ADYWm5JOnoiQ1ufW7h8uu2E6G9Of/eE6hP/Ivw3U5jI16ERqZxiKQwk34VJbB0niX9w=="],
@@ -828,7 +827,7 @@
"cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="],
"cli-truncate": ["cli-truncate@5.1.1", "", { "dependencies": { "slice-ansi": "^7.1.0", "string-width": "^8.0.0" } }, "sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A=="],
"cli-truncate": ["cli-truncate@5.2.0", "", { "dependencies": { "slice-ansi": "^8.0.0", "string-width": "^8.2.0" } }, "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw=="],
"client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
@@ -844,7 +843,7 @@
"color-string": ["color-string@2.1.2", "", { "dependencies": { "color-name": "^2.0.0" } }, "sha512-RxmjYxbWemV9gKu4zPgiZagUxbH3RQpEIO77XoSSX0ivgABDZ+h8Zuash/EMFLTI4N9QgFPOJ6JQpPZKFxa+dA=="],
"colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="],
"colorette": ["colorette@1.4.0", "", {}, "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g=="],
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
@@ -990,7 +989,7 @@
"event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="],
"eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="],
"eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="],
"events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="],
@@ -1030,7 +1029,7 @@
"expo-device": ["expo-device@8.0.10", "", { "dependencies": { "ua-parser-js": "^0.7.33" }, "peerDependencies": { "expo": "*" } }, "sha512-jd5BxjaF7382JkDMaC+P04aXXknB2UhWaVx5WiQKA05ugm/8GH5uaz9P9ckWdMKZGQVVEOC8MHaUADoT26KmFA=="],
"expo-doctor": ["expo-doctor@1.17.14", "", { "bin": { "expo-doctor": "build/index.js" } }, "sha512-+UsXFP5ZTVobDuGS5Du8aKU6O6s2sa49QOdGHdzP8UEjQKH8gPb59uw6hxEQmo6YtVboLwQd13QEdcSolBMvLw=="],
"expo-doctor": ["expo-doctor@1.18.22", "", { "bin": { "expo-doctor": "build/index.js" } }, "sha512-AEGwceidWxyYpWEfIf3XnUvc+FbI3OjjyBaXctuoZg10x9An+utrdRf6go/3UFRAG5EkpMOWgUT0j1TKcYDsSQ=="],
"expo-file-system": ["expo-file-system@19.0.21", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-s3DlrDdiscBHtab/6W1osrjGL+C2bvoInPJD7sOwmxfJ5Woynv2oc+Fz1/xVXaE/V7HE/+xrHC/H45tu6lZzzg=="],
@@ -1148,7 +1147,7 @@
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
"get-east-asian-width": ["get-east-asian-width@1.4.0", "", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="],
"get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
@@ -1164,7 +1163,7 @@
"gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="],
"glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
"glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="],
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
@@ -1276,7 +1275,7 @@
"istanbul-lib-instrument": ["istanbul-lib-instrument@5.2.1", "", { "dependencies": { "@babel/core": "^7.12.3", "@babel/parser": "^7.14.7", "@istanbuljs/schema": "^0.1.2", "istanbul-lib-coverage": "^3.2.0", "semver": "^6.3.0" } }, "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg=="],
"jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
"jackspeak": ["jackspeak@4.2.3", "", { "dependencies": { "@isaacs/cliui": "^9.0.0" } }, "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg=="],
"jest-diff": ["jest-diff@29.7.0", "", { "dependencies": { "chalk": "^4.0.0", "diff-sequences": "^29.6.3", "jest-get-type": "^29.6.3", "pretty-format": "^29.7.0" } }, "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw=="],
@@ -1370,9 +1369,9 @@
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
"lint-staged": ["lint-staged@16.2.7", "", { "dependencies": { "commander": "^14.0.2", "listr2": "^9.0.5", "micromatch": "^4.0.8", "nano-spawn": "^2.0.0", "pidtree": "^0.6.0", "string-argv": "^0.3.2", "yaml": "^2.8.1" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-lDIj4RnYmK7/kXMya+qJsmkRFkGolciXjrsZ6PC25GdTfWOAWetR0ZbsNXRAj1EHHImRSalc+whZFg56F5DVow=="],
"lint-staged": ["lint-staged@17.0.5", "", { "dependencies": { "listr2": "^10.2.1", "picomatch": "^4.0.4", "string-argv": "^0.3.2", "tinyexec": "^1.1.2" }, "optionalDependencies": { "yaml": "^2.8.4" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-d12yC+/e8RhBjZtaxZn71FyrgU/P5e+uAPifhCLwdosQZP/zamSdKRWDC30ocVIbzDKiFG1McHc/LUgB92GIPw=="],
"listr2": ["listr2@9.0.5", "", { "dependencies": { "cli-truncate": "^5.0.0", "colorette": "^2.0.20", "eventemitter3": "^5.0.1", "log-update": "^6.1.0", "rfdc": "^1.4.1", "wrap-ansi": "^9.0.0" } }, "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g=="],
"listr2": ["listr2@10.2.1", "", { "dependencies": { "cli-truncate": "^5.2.0", "eventemitter3": "^5.0.4", "log-update": "^6.1.0", "rfdc": "^1.4.1", "wrap-ansi": "^10.0.0" } }, "sha512-7I5knELsJKTUjXG+A6BkKAiGkW1i25fNa/xlUl9hFtk15WbE9jndA89xu5FzQKrY5llajE1hfZZFMILXkDHk/Q=="],
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
@@ -1448,7 +1447,7 @@
"mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="],
"minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
"minimatch": ["minimatch@10.1.1", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ=="],
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
@@ -1462,8 +1461,6 @@
"mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
"nano-spawn": ["nano-spawn@2.0.0", "", {}, "sha512-tacvGzUY5o2D8CBh2rrwxyNojUsZNU2zjNTzKQrkgGJQTbGAfArVWXSKMBokBeeg6C7OLRGUEyoFlYbfeWQIqw=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"nativewind": ["nativewind@2.0.11", "", { "dependencies": { "@babel/generator": "^7.18.7", "@babel/helper-module-imports": "7.18.6", "@babel/types": "7.19.0", "css-mediaquery": "^0.1.2", "css-to-react-native": "^3.0.0", "micromatch": "^4.0.5", "postcss": "^8.4.12", "postcss-calc": "^8.2.4", "postcss-color-functional-notation": "^4.2.2", "postcss-css-variables": "^0.18.0", "postcss-nested": "^5.0.6", "react-is": "^18.1.0", "use-sync-external-store": "^1.1.0" }, "peerDependencies": { "tailwindcss": "~3" } }, "sha512-qCEXUwKW21RYJ33KRAJl3zXq2bCq82WoI564fI21D/TiqhfmstZOqPN53RF8qK1NDK6PGl56b2xaTxgObEePEg=="],
@@ -1558,9 +1555,7 @@
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"pidtree": ["pidtree@0.6.0", "", { "bin": { "pidtree": "bin/pidtree.js" } }, "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g=="],
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"pify": ["pify@2.3.0", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="],
@@ -1640,7 +1635,7 @@
"react-freeze": ["react-freeze@1.0.4", "", { "peerDependencies": { "react": ">=17.0.0" } }, "sha512-r4F0Sec0BLxWicc7HEyo2x3/2icUTrRmDjaaRyzzn+7aDyFZliszMDOgLVwSnQnYENOlL1o569Ze2HZefk8clA=="],
"react-i18next": ["react-i18next@16.5.4", "", { "dependencies": { "@babel/runtime": "^7.28.4", "html-parse-stringify": "^3.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 25.6.2", "react": ">= 16.8.0", "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-6yj+dcfMncEC21QPhOTsW8mOSO+pzFmT6uvU7XXdvM/Cp38zJkmTeMeKmTrmCMD5ToT79FmiE/mRWiYWcJYW4g=="],
"react-i18next": ["react-i18next@16.5.8", "", { "dependencies": { "@babel/runtime": "^7.28.4", "html-parse-stringify": "^3.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 25.6.2", "react": ">= 16.8.0", "react-dom": "*", "react-native": "*", "typescript": "^5" }, "optionalPeers": ["react-dom", "react-native", "typescript"] }, "sha512-2ABeHHlakxVY+LSirD+OiERxFL6+zip0PaHo979bgwzeHg27Sqc82xxXWIrSFmfWX0ZkrvXMHwhsi/NGUf5VQg=="],
"react-is": ["react-is@19.2.3", "", {}, "sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA=="],
@@ -1696,7 +1691,7 @@
"react-native-text-ticker": ["react-native-text-ticker@1.15.0", "", {}, "sha512-d/uK+PIOhsYMy1r8h825iq/nADiHsabz3WMbRJSnkpQYn+K9aykUAXRRhu8ZbTAzk4CgnUWajJEFxS5ZDygsdg=="],
"react-native-track-player": ["react-native-track-player@github:lovegaoshi/react-native-track-player#003afd0", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-windows": "*", "shaka-player": "^4.7.9" }, "optionalPeers": ["react-native-windows", "shaka-player"] }, "lovegaoshi-react-native-track-player-003afd0"],
"react-native-track-player": ["react-native-track-player@github:lovegaoshi/react-native-track-player#003afd0", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-windows": "*", "shaka-player": "^4.7.9" }, "optionalPeers": ["react-native-windows", "shaka-player"] }, "lovegaoshi-react-native-track-player-003afd0", "sha512-HR7BaMDMBhQ4xAy5XeQEkT0fBosWw2x9+X2QOD4buocxuX03D770LaRKm5rgQ/qDzchr92KW7+d8fISI14fRLA=="],
"react-native-udp": ["react-native-udp@4.1.7", "", { "dependencies": { "buffer": "^5.6.0", "events": "^3.1.0" } }, "sha512-NUE3zewu61NCdSsLlj+l0ad6qojcVEZPT4hVG/x6DU9U4iCzwtfZSASh9vm7teAcVzLkdD+cO3411LHshAi/wA=="],
@@ -1826,7 +1821,7 @@
"slash": ["slash@2.0.0", "", {}, "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A=="],
"slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="],
"slice-ansi": ["slice-ansi@8.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "is-fullwidth-code-point": "^5.1.0" } }, "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg=="],
"slugify": ["slugify@1.6.6", "", {}, "sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw=="],
@@ -1910,6 +1905,8 @@
"tinycolor2": ["tinycolor2@1.6.0", "", {}, "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw=="],
"tinyexec": ["tinyexec@1.1.2", "", {}, "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"tmp": ["tmp@0.2.5", "", {}, "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow=="],
@@ -2014,7 +2011,7 @@
"wonka": ["wonka@6.3.5", "", {}, "sha512-SSil+ecw6B4/Dm7Pf2sAshKQ5hWFvfyGlfPbEd6A14dOH6VDjrmbY86u6nZvy9omGwwIPFR8V41+of1EezgoUw=="],
"wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
"wrap-ansi": ["wrap-ansi@10.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "string-width": "^8.2.0", "strip-ansi": "^7.1.2" } }, "sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ=="],
"wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
@@ -2034,7 +2031,7 @@
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"yaml": ["yaml@2.8.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw=="],
"yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
"yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
@@ -2118,6 +2115,8 @@
"@expo/cli/glob": ["glob@13.0.0", "", { "dependencies": { "minimatch": "^10.1.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA=="],
"@expo/cli/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
"@expo/cli/ora": ["ora@3.4.0", "", { "dependencies": { "chalk": "^2.4.2", "cli-cursor": "^2.1.0", "cli-spinners": "^2.0.0", "log-symbols": "^2.2.0", "strip-ansi": "^5.2.0", "wcwidth": "^1.0.1" } }, "sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg=="],
"@expo/cli/picomatch": ["picomatch@3.0.1", "", {}, "sha512-I3EurrIQMlRc9IaAZnqRR044Phh2DXY+55o7uJ0V+hYZAcQYSuFWsc9q5PvyDHUSCe1Qxn/iBz+78s86zWnGag=="],
@@ -2154,6 +2153,8 @@
"@expo/fingerprint/glob": ["glob@13.0.0", "", { "dependencies": { "minimatch": "^10.1.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA=="],
"@expo/fingerprint/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
"@expo/fingerprint/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
"@expo/image-utils/getenv": ["getenv@2.0.0", "", {}, "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ=="],
@@ -2172,6 +2173,8 @@
"@expo/metro-config/glob": ["glob@13.0.0", "", { "dependencies": { "minimatch": "^10.1.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA=="],
"@expo/metro-config/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
"@expo/metro-config/postcss": ["postcss@8.4.49", "", { "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA=="],
"@expo/package-manager/ora": ["ora@3.4.0", "", { "dependencies": { "chalk": "^2.4.2", "cli-cursor": "^2.1.0", "cli-spinners": "^2.0.0", "log-symbols": "^2.2.0", "strip-ansi": "^5.2.0", "wcwidth": "^1.0.1" } }, "sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg=="],
@@ -2180,12 +2183,6 @@
"@expo/xcpretty/@babel/code-frame": ["@babel/code-frame@7.10.4", "", { "dependencies": { "@babel/highlight": "^7.10.4" } }, "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg=="],
"@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
"@isaacs/cliui/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
"@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
"@istanbuljs/load-nyc-config/camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="],
"@istanbuljs/load-nyc-config/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
@@ -2208,6 +2205,8 @@
"@react-native-community/cli-doctor/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
"@react-native-community/cli-doctor/yaml": ["yaml@2.8.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw=="],
"@react-native-community/cli-server-api/open": ["open@6.4.0", "", { "dependencies": { "is-wsl": "^1.1.0" } }, "sha512-IFenVPgF70fSm1keSd2iDBIDIBZkroLeuffXq+wKTzTJlBpesFWojV9lb8mzOfaAzM1sr7HQHuO0vtV0zYekGg=="],
"@react-native-community/cli-tools/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
@@ -2222,6 +2221,8 @@
"@react-native/codegen/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="],
"@react-native/codegen/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
"@react-native/community-cli-plugin/metro": ["metro@0.83.2", "", { "dependencies": { "@babel/code-frame": "^7.24.7", "@babel/core": "^7.25.2", "@babel/generator": "^7.25.0", "@babel/parser": "^7.25.3", "@babel/template": "^7.25.0", "@babel/traverse": "^7.25.3", "@babel/types": "^7.25.2", "accepts": "^1.3.7", "chalk": "^4.0.0", "ci-info": "^2.0.0", "connect": "^3.6.5", "debug": "^4.4.0", "error-stack-parser": "^2.0.6", "flow-enums-runtime": "^0.0.6", "graceful-fs": "^4.2.4", "hermes-parser": "0.32.0", "image-size": "^1.0.2", "invariant": "^2.2.4", "jest-worker": "^29.7.0", "jsc-safe-url": "^0.2.2", "lodash.throttle": "^4.1.1", "metro-babel-transformer": "0.83.2", "metro-cache": "0.83.2", "metro-cache-key": "0.83.2", "metro-config": "0.83.2", "metro-core": "0.83.2", "metro-file-map": "0.83.2", "metro-resolver": "0.83.2", "metro-runtime": "0.83.2", "metro-source-map": "0.83.2", "metro-symbolicate": "0.83.2", "metro-transform-plugins": "0.83.2", "metro-transform-worker": "0.83.2", "mime-types": "^2.1.27", "nullthrows": "^1.1.1", "serialize-error": "^2.1.0", "source-map": "^0.5.6", "throat": "^5.0.0", "ws": "^7.5.10", "yargs": "^17.6.2" }, "bin": { "metro": "src/cli.js" } }, "sha512-HQgs9H1FyVbRptNSMy/ImchTTE5vS2MSqLoOo7hbDoBq6hPPZokwJvBMwrYSxdjQZmLXz2JFZtdvS+ZfgTc9yw=="],
"@react-native/community-cli-plugin/metro-config": ["metro-config@0.83.2", "", { "dependencies": { "connect": "^3.6.5", "flow-enums-runtime": "^0.0.6", "jest-validate": "^29.7.0", "metro": "0.83.2", "metro-cache": "0.83.2", "metro-core": "0.83.2", "metro-runtime": "0.83.2", "yaml": "^2.6.1" } }, "sha512-1FjCcdBe3e3D08gSSiU9u3Vtxd7alGH3x/DNFqWDFf5NouX4kLgbVloDDClr1UrLz62c0fHh2Vfr9ecmrOZp+g=="],
@@ -2260,12 +2261,12 @@
"accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
"ansi-fragments/colorette": ["colorette@1.4.0", "", {}, "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g=="],
"ansi-fragments/slice-ansi": ["slice-ansi@2.1.0", "", { "dependencies": { "ansi-styles": "^3.2.0", "astral-regex": "^1.0.0", "is-fullwidth-code-point": "^2.0.0" } }, "sha512-Qu+VC3EwYLldKa1fCxuuvULvSJOKEgk9pi8dZeCVK7TqBfUNTH4sFkk4joj8afVSfAYgJoSOetjx9QWOJ5mYoQ=="],
"ansi-fragments/strip-ansi": ["strip-ansi@5.2.0", "", { "dependencies": { "ansi-regex": "^4.1.0" } }, "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA=="],
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"babel-jest/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="],
"babel-plugin-jest-hoist/@babel/template": ["@babel/template@7.27.2", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/parser": "^7.27.2", "@babel/types": "^7.27.1" } }, "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw=="],
@@ -2286,7 +2287,7 @@
"chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"cli-truncate/string-width": ["string-width@8.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.0", "strip-ansi": "^7.1.0" } }, "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg=="],
"cli-truncate/string-width": ["string-width@8.2.1", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA=="],
"cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
@@ -2322,8 +2323,6 @@
"foreground-child/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
"hoist-non-react-statics/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"hosted-git-info/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
@@ -2340,16 +2339,20 @@
"jest-message-util/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="],
"jest-util/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
"lighthouse-logger/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"lint-staged/commander": ["commander@14.0.2", "", {}, "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ=="],
"log-update/cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="],
"log-update/slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="],
"log-update/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
"log-update/wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
"logkitty/yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="],
"metro/@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
@@ -2376,6 +2379,8 @@
"metro-babel-transformer/hermes-parser": ["hermes-parser@0.32.0", "", { "dependencies": { "hermes-estree": "0.32.0" } }, "sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw=="],
"metro-config/yaml": ["yaml@2.8.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw=="],
"metro-source-map/@babel/traverse": ["@babel/traverse@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/types": "^7.28.5", "debug": "^4.3.1" } }, "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ=="],
"metro-source-map/@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="],
@@ -2396,6 +2401,8 @@
"metro-transform-worker/@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="],
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"nativewind/@babel/generator": ["@babel/generator@7.28.5", "", { "dependencies": { "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ=="],
"nativewind/@babel/types": ["@babel/types@7.19.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.18.10", "@babel/helper-validator-identifier": "^7.18.6", "to-fast-properties": "^2.0.0" } }, "sha512-YuGopBq3ke25BVSiS6fgF49Ul9gH1x70Bcr6bqRLjWCkcX8Hre1/5+z+IiWOIerRMSSEfGZVB9z9kyq7wVs9YA=="],
@@ -2412,12 +2419,16 @@
"patch-package/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="],
"patch-package/yaml": ["yaml@2.8.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw=="],
"path-scurry/lru-cache": ["lru-cache@11.2.4", "", {}, "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg=="],
"postcss-css-variables/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="],
"postcss-load-config/lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
"postcss-load-config/yaml": ["yaml@2.8.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw=="],
"pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
"prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
@@ -2428,6 +2439,8 @@
"react-native/commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="],
"react-native/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
"react-native/scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="],
"react-native/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
@@ -2442,8 +2455,12 @@
"readable-web-to-node-stream/readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="],
"readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"requireg/resolve": ["resolve@1.7.1", "", { "dependencies": { "path-parse": "^1.0.5" } }, "sha512-c7rwLofp8g1U+h1KNyHL/jicrKg1Ek4q+Lr33AL65uZTinUZHe30D5HlyN5V9NW0JX1D5dXQ4jqW5l7Sy/kGfw=="],
"rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
"send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"send/mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="],
@@ -2472,6 +2489,8 @@
"terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
"test-exclude/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
"test-exclude/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
"tinyglobby/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
@@ -2480,7 +2499,7 @@
"wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
"wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
"wrap-ansi/string-width": ["string-width@8.2.1", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA=="],
"wrap-ansi/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
@@ -2640,10 +2659,6 @@
"@expo/cli/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"@expo/config-plugins/glob/minimatch": ["minimatch@10.1.1", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ=="],
"@expo/config/glob/minimatch": ["minimatch@10.1.1", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ=="],
"@expo/config/sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
"@expo/fingerprint/glob/minimatch": ["minimatch@10.1.1", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ=="],
@@ -2676,12 +2691,6 @@
"@expo/package-manager/ora/strip-ansi": ["strip-ansi@5.2.0", "", { "dependencies": { "ansi-regex": "^4.1.0" } }, "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA=="],
"@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
"@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
"@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
"@istanbuljs/load-nyc-config/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
"@istanbuljs/load-nyc-config/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
@@ -2756,6 +2765,8 @@
"@react-native/codegen/@babel/parser/@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="],
"@react-native/codegen/glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
"@react-native/community-cli-plugin/metro/@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
"@react-native/community-cli-plugin/metro/@babel/core": ["@babel/core@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.28.3", "@babel/helpers": "^7.28.4", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw=="],
@@ -2800,6 +2811,8 @@
"@react-native/community-cli-plugin/metro-config/metro-runtime": ["metro-runtime@0.83.2", "", { "dependencies": { "@babel/runtime": "^7.25.0", "flow-enums-runtime": "^0.0.6" } }, "sha512-nnsPtgRvFbNKwemqs0FuyFDzXLl+ezuFsUXDbX8o0SXOfsOPijqiQrf3kuafO1Zx1aUWf4NOrKJMAQP5EEHg9A=="],
"@react-native/community-cli-plugin/metro-config/yaml": ["yaml@2.8.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw=="],
"@react-native/community-cli-plugin/metro-core/metro-resolver": ["metro-resolver@0.83.2", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-Yf5mjyuiRE/Y+KvqfsZxrbHDA15NZxyfg8pIk0qg47LfAJhpMVEX+36e6ZRBq7KVBqy6VDX5Sq55iHGM4xSm7Q=="],
"@react-navigation/bottom-tabs/color/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
@@ -2860,8 +2873,6 @@
"finalhandler/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"glob/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
"istanbul-lib-instrument/@babel/core/@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
"istanbul-lib-instrument/@babel/core/@babel/generator": ["@babel/generator@7.28.5", "", { "dependencies": { "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ=="],
@@ -2884,8 +2895,16 @@
"log-update/cli-cursor/restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="],
"log-update/slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
"log-update/slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="],
"log-update/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
"log-update/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
"log-update/wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
"logkitty/yargs/cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="],
"logkitty/yargs/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
@@ -2980,8 +2999,12 @@
"patch-package/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
"react-native/glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
"readable-web-to-node-stream/readable-stream/buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="],
"rimraf/glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
"send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"serve-static/send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
@@ -2990,6 +3013,12 @@
"serve-static/send/mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="],
"slice-ansi/is-fullwidth-code-point/get-east-asian-width": ["get-east-asian-width@1.4.0", "", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="],
"sucrase/glob/jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
"sucrase/glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
"sucrase/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
"terminal-link/ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="],
@@ -2998,8 +3027,6 @@
"wrap-ansi-cjs/ansi-styles/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"wrap-ansi/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
"wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
"@babel/highlight/chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="],
@@ -3082,6 +3109,8 @@
"@react-native/codegen/@babel/core/@babel/helper-module-transforms/@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="],
"@react-native/codegen/glob/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
"@react-native/community-cli-plugin/metro/@babel/core/@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.27.2", "", { "dependencies": { "@babel/compat-data": "^7.27.2", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ=="],
"@react-native/community-cli-plugin/metro/@babel/core/@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.3", "", { "dependencies": { "@babel/helper-module-imports": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1", "@babel/traverse": "^7.28.3" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw=="],
@@ -3130,8 +3159,6 @@
"expo-manifests/@expo/config/@expo/config-plugins/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="],
"expo-manifests/@expo/config/glob/minimatch": ["minimatch@10.1.1", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ=="],
"expo-manifests/@expo/config/sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
"istanbul-lib-instrument/@babel/core/@babel/helper-compilation-targets/@babel/compat-data": ["@babel/compat-data@7.28.5", "", {}, "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA=="],
@@ -3142,6 +3169,12 @@
"log-update/cli-cursor/restore-cursor/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"log-update/slice-ansi/is-fullwidth-code-point/get-east-asian-width": ["get-east-asian-width@1.4.0", "", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="],
"log-update/wrap-ansi/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
"log-update/wrap-ansi/string-width/get-east-asian-width": ["get-east-asian-width@1.4.0", "", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="],
"logkitty/yargs/cliui/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
"logkitty/yargs/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
@@ -3164,8 +3197,14 @@
"metro/@babel/core/@babel/helper-module-transforms/@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="],
"react-native/glob/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
"rimraf/glob/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
"serve-static/send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"sucrase/glob/jackspeak/@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
"sucrase/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"wrap-ansi-cjs/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
@@ -3200,6 +3239,12 @@
"logkitty/yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
"sucrase/glob/jackspeak/@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
"sucrase/glob/jackspeak/@isaacs/cliui/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
"sucrase/glob/jackspeak/@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
"@expo/cli/ora/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="],
"@expo/cli/ora/cli-cursor/restore-cursor/onetime/mimic-fn": ["mimic-fn@1.2.0", "", {}, "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ=="],
@@ -3212,6 +3257,12 @@
"logkitty/yargs/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
"sucrase/glob/jackspeak/@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
"sucrase/glob/jackspeak/@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
"sucrase/glob/jackspeak/@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
"logkitty/yargs/cliui/wrap-ansi/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
}
}

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

@@ -47,6 +47,7 @@ interface PlatformDropdownProps {
open?: boolean;
onOpenChange?: (open: boolean) => void;
onOptionSelect?: (value?: any) => void;
disabled?: boolean;
expoUIConfig?: {
hostStyle?: any;
};
@@ -197,6 +198,7 @@ const PlatformDropdownComponent = ({
onOpenChange: controlledOnOpenChange,
onOptionSelect,
expoUIConfig,
disabled,
bottomSheetConfig,
}: PlatformDropdownProps) => {
const { showModal, hideModal, isVisible } = useGlobalModal();
@@ -231,6 +233,13 @@ const PlatformDropdownComponent = ({
}, [isVisible, controlledOpen, controlledOnOpenChange]);
if (Platform.OS === "ios") {
if (disabled) {
return (
<View style={{ opacity: 0.5 }} pointerEvents='none'>
{trigger || <Text className='text-white'>Open Menu</Text>}
</View>
);
}
return (
<Host style={expoUIConfig?.hostStyle}>
<ContextMenu>
@@ -353,8 +362,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={disabled}
>
<View style={disabled ? { opacity: 0.5 } : undefined}>
{trigger || <Text className='text-white'>Open Menu</Text>}
</View>
</TouchableOpacity>
);
};

View File

@@ -8,7 +8,7 @@ import { useTranslation } from "react-i18next";
import { Alert, Platform, TouchableOpacity, View } from "react-native";
import CastContext, {
CastButton,
MediaStreamType,
MediaPlayerState,
PlayServicesState,
useMediaStatus,
useRemoteMediaClient,
@@ -32,8 +32,7 @@ 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 { buildCastMediaInfo } from "@/utils/casting/mediaInfo";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { chromecast } from "@/utils/profiles/chromecast";
import { chromecasth265 } from "@/utils/profiles/chromecasth265";
@@ -111,7 +110,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 +123,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:
@@ -175,8 +183,6 @@ export const PlayButton: React.FC<Props> = ({
subtitleStreamIndex: selectedOptions.subtitleIndex,
});
console.log("URL: ", data?.url, enableH265);
if (!data?.url) {
console.warn("No URL returned from getStreamUrl", data);
Alert.alert(
@@ -186,74 +192,16 @@ export const PlayButton: React.FC<Props> = ({
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,
})!,
},
],
},
},
mediaInfo: buildCastMediaInfo({
item,
streamUrl: data.url,
api,
}),
startTime: startTimeSeconds,
})
.then(() => {
@@ -261,10 +209,13 @@ export const PlayButton: React.FC<Props> = ({
if (isOpeningCurrentlyPlayingMedia) {
return;
}
CastContext.showExpandedControls();
router.push("/casting-player");
})
.catch((err) => {
console.error("[PlayButton] loadMedia failed:", err);
});
} catch (e) {
console.log(e);
console.error("[PlayButton] Cast error:", e);
}
}
});

View File

@@ -0,0 +1,457 @@
/**
* Unified Casting Mini Player
* Works with all supported casting protocols
*/
import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { Image } from "expo-image";
import { router } from "expo-router";
import { useAtomValue } from "jotai";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { Dimensions, 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 { Text } from "@/components/common/Text";
import { useTrickplay } from "@/hooks/useTrickplay";
import { apiAtom } from "@/providers/JellyfinProvider";
import {
formatTime,
formatTrickplayTime,
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 insets = useSafeAreaInsets();
const castDevice = useCastDevice();
const mediaStatus = useMediaStatus();
const remoteMediaClient = useRemoteMediaClient();
const currentItem = useMemo(() => {
return mediaStatus?.mediaInfo?.customData as BaseItemDto | undefined;
}, [mediaStatus?.mediaInfo?.customData]);
// 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 [scrubPercentage, setScrubPercentage] = useState(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 });
// Track scrub percentage for bubble positioning
if (duration > 0) {
setScrubPercentage(value / duration);
}
}}
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={() => {
// Calculate bubble position with edge clamping
const screenWidth = Dimensions.get("window").width;
const sliderPadding = 8;
const thumbWidth = 10; // matches thumbWidth prop on Slider
const sliderWidth = screenWidth - sliderPadding * 2;
// Adjust thumb position to account for thumb width affecting travel range
const effectiveTrackWidth = sliderWidth - thumbWidth;
const thumbPosition =
thumbWidth / 2 + scrubPercentage * effectiveTrackWidth;
if (!trickPlayUrl || !trickplayInfo) {
// Show simple time bubble when no trickplay
const timeBubbleWidth = 70;
const minLeft = -thumbPosition;
const maxLeft = sliderWidth - thumbPosition - timeBubbleWidth;
const centeredLeft = -timeBubbleWidth / 2;
const clampedLeft = Math.max(
minLeft,
Math.min(maxLeft, centeredLeft),
);
return (
<View
style={{
position: "absolute",
bottom: 12,
left: clampedLeft,
backgroundColor: protocolColor,
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 4,
}}
>
<Text
style={{ color: "#fff", fontSize: 11, fontWeight: "600" }}
>
{formatTrickplayTime(trickplayTime)}
</Text>
</View>
);
}
const { x, y, url } = trickPlayUrl;
const tileWidth = 140; // Smaller preview for mini player
const tileHeight = tileWidth / (trickplayInfo.aspectRatio ?? 1.78);
// Calculate clamped position for trickplay preview
const minLeft = -thumbPosition;
const maxLeft = sliderWidth - thumbPosition - tileWidth;
const centeredLeft = -tileWidth / 2;
const clampedLeft = Math.max(
minLeft,
Math.min(maxLeft, centeredLeft),
);
return (
<View
style={{
position: "absolute",
bottom: 12,
left: clampedLeft,
width: tileWidth,
alignItems: "center",
}}
>
{/* Trickplay image preview */}
<View
style={{
width: tileWidth,
height: tileHeight,
borderRadius: 6,
overflow: "hidden",
backgroundColor: "#1a1a1a",
}}
>
<Image
cachePolicy='memory-disk'
style={{
width: tileWidth * (trickplayInfo.data?.TileWidth ?? 1),
height:
(tileWidth / (trickplayInfo.aspectRatio ?? 1.78)) *
(trickplayInfo.data?.TileHeight ?? 1),
transform: [
{ translateX: -x * tileWidth },
{ translateY: -y * tileHeight },
],
}}
source={{ uri: url }}
contentFit='cover'
/>
</View>
{/* Time overlay */}
<View
style={{
position: "absolute",
bottom: 2,
left: 2,
backgroundColor: "rgba(0, 0, 0, 0.7)",
paddingHorizontal: 4,
paddingVertical: 1,
borderRadius: 3,
}}
>
<Text
style={{ color: "#fff", fontSize: 10, fontWeight: "600" }}
>
{formatTrickplayTime(trickplayTime)}
</Text>
</View>
</View>
);
}}
sliderHeight={3}
thumbWidth={10}
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>
{/* 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,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,351 @@
/**
* 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,
backgroundColor: isCurrentEpisode ? "#a855f7" : "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,347 @@
/**
* Chromecast Settings Menu
* Allows users to configure audio, subtitles, quality, and playback speed
*/
import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
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,
MediaSource,
SubtitleTrack,
} from "@/utils/casting/types";
interface ChromecastSettingsMenuProps {
visible: boolean;
onClose: () => void;
item: BaseItemDto;
mediaSources: MediaSource[];
selectedMediaSource: MediaSource | null;
onMediaSourceChange: (source: MediaSource) => void;
audioTracks: AudioTrack[];
selectedAudioTrack: AudioTrack | null;
onAudioTrackChange: (track: AudioTrack) => void;
subtitleTracks: SubtitleTrack[];
selectedSubtitleTrack: SubtitleTrack | null;
onSubtitleTrackChange: (track: SubtitleTrack | null) => void;
playbackSpeed: number;
onPlaybackSpeedChange: (speed: number) => void;
}
const PLAYBACK_SPEEDS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2];
export const ChromecastSettingsMenu: React.FC<ChromecastSettingsMenuProps> = ({
visible,
onClose,
item: _item, // Reserved for future use (technical info display)
mediaSources,
selectedMediaSource,
onMediaSourceChange,
audioTracks,
selectedAudioTrack,
onAudioTrackChange,
subtitleTracks,
selectedSubtitleTrack,
onSubtitleTrackChange,
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>
);
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()}
>
{/* Header */}
<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>
{/* Quality/Media Source - only show when sources available */}
{mediaSources.length > 0 &&
renderSectionHeader(
t("casting_player.quality"),
"film-outline",
"quality",
)}
{mediaSources.length > 0 && expandedSection === "quality" && (
<View style={{ paddingVertical: 8 }}>
{mediaSources.map((source) => (
<Pressable
key={source.id}
onPress={() => {
onMediaSourceChange(source);
setExpandedSection(null);
}}
style={{
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
padding: 16,
backgroundColor:
selectedMediaSource?.id === source.id
? "#2a2a2a"
: "transparent",
}}
>
<View>
<Text style={{ color: "white", fontSize: 15 }}>
{source.name}
</Text>
{source.bitrate && (
<Text
style={{ color: "#999", fontSize: 13, marginTop: 2 }}
>
{Math.round(source.bitrate / 1000000)} Mbps
</Text>
)}
</View>
{selectedMediaSource?.id === source.id && (
<Ionicons name='checkmark' size={20} color='#a855f7' />
)}
</Pressable>
))}
</View>
)}
{/* Audio Tracks - only show if 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) => (
<Pressable
key={track.index}
onPress={() => {
onAudioTrackChange(track);
setExpandedSection(null);
}}
style={{
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
padding: 16,
backgroundColor:
selectedAudioTrack?.index === track.index
? "#2a2a2a"
: "transparent",
}}
>
<View>
<Text style={{ color: "white", fontSize: 15 }}>
{track.displayTitle ||
track.language ||
t("casting_player.unknown")}
</Text>
{track.codec && (
<Text
style={{ color: "#999", fontSize: 13, marginTop: 2 }}
>
{track.codec.toUpperCase()}
</Text>
)}
</View>
{selectedAudioTrack?.index === track.index && (
<Ionicons name='checkmark' size={20} color='#a855f7' />
)}
</Pressable>
))}
</View>
)}
{/* Subtitle Tracks - only show if subtitles available */}
{subtitleTracks.length > 0 &&
renderSectionHeader(
t("casting_player.subtitles"),
"text",
"subtitles",
)}
{subtitleTracks.length > 0 && expandedSection === "subtitles" && (
<View style={{ paddingVertical: 8 }}>
<Pressable
onPress={() => {
onSubtitleTrackChange(null);
setExpandedSection(null);
}}
style={{
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
padding: 16,
backgroundColor:
selectedSubtitleTrack === null
? "#2a2a2a"
: "transparent",
}}
>
<Text style={{ color: "white", fontSize: 15 }}>
{t("casting_player.none")}
</Text>
{selectedSubtitleTrack === null && (
<Ionicons name='checkmark' size={20} color='#a855f7' />
)}
</Pressable>
{subtitleTracks.map((track) => (
<Pressable
key={track.index}
onPress={() => {
onSubtitleTrackChange(track);
setExpandedSection(null);
}}
style={{
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
padding: 16,
backgroundColor:
selectedSubtitleTrack?.index === track.index
? "#2a2a2a"
: "transparent",
}}
>
<View>
<Text style={{ color: "white", fontSize: 15 }}>
{track.displayTitle ||
track.language ||
t("casting_player.unknown")}
</Text>
{(track.codec || track.isForced) && (
<Text
style={{ color: "#999", fontSize: 13, marginTop: 2 }}
>
{track.codec ? track.codec.toUpperCase() : ""}
{track.isForced && `${t("casting_player.forced")}`}
</Text>
)}
</View>
{selectedSubtitleTrack?.index === track.index && (
<Ionicons name='checkmark' size={20} color='#a855f7' />
)}
</Pressable>
))}
</View>
)}
{/* Playback Speed */}
{renderSectionHeader(
t("casting_player.playback_speed"),
"speedometer",
"speed",
)}
{expandedSection === "speed" && (
<View style={{ paddingVertical: 8 }}>
{PLAYBACK_SPEEDS.map((speed) => (
<Pressable
key={speed}
onPress={() => {
onPlaybackSpeedChange(speed);
setExpandedSection(null);
}}
style={{
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
padding: 16,
backgroundColor:
Math.abs(playbackSpeed - speed) < 0.01
? "#2a2a2a"
: "transparent",
}}
>
<Text style={{ color: "white", fontSize: 15 }}>
{speed === 1 ? t("casting_player.normal") : `${speed}x`}
</Text>
{Math.abs(playbackSpeed - speed) < 0.01 && (
<Ionicons name='checkmark' size={20} color='#a855f7' />
)}
</Pressable>
))}
</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

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

@@ -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();
@@ -248,6 +250,15 @@ 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>
</ListGroup>
</DisabledSetting>
);

View File

@@ -19,7 +19,9 @@ interface BottomControlsProps {
currentTime: number;
remainingTime: number;
showSkipButton: boolean;
skipButtonText: string;
showSkipCreditButton: boolean;
skipCreditButtonText: string;
hasContentAfterCredits: boolean;
skipIntro: () => void;
skipCredit: () => void;
@@ -67,7 +69,9 @@ export const BottomControls: FC<BottomControlsProps> = ({
currentTime,
remainingTime,
showSkipButton,
skipButtonText,
showSkipCreditButton,
skipCreditButtonText,
hasContentAfterCredits,
skipIntro,
skipCredit,
@@ -136,7 +140,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
@@ -146,7 +150,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
showSkipCreditButton && (hasContentAfterCredits || !nextItem)
}
onPress={skipCredit}
buttonText='Skip Credits'
buttonText={skipCreditButtonText}
/>
{settings.autoPlayNextEpisode !== false &&
(settings.maxAutoPlayEpisodeCount.value === -1 ||

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";
@@ -42,6 +50,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,6 +121,24 @@ export const Controls: FC<Props> = ({
const [episodeView, setEpisodeView] = useState(false);
const [showAudioSlider, setShowAudioSlider] = 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({
item,
@@ -300,27 +329,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) => {
@@ -534,7 +661,9 @@ export const Controls: FC<Props> = ({
currentTime={currentTime}
remainingTime={remainingTime}
showSkipButton={showSkipButton}
skipButtonText={skipButtonText}
showSkipCreditButton={showSkipCreditButton}
skipCreditButtonText={skipCreditButtonText}
hasContentAfterCredits={hasContentAfterCredits}
skipIntro={skipIntro}
skipCredit={skipCredit}

View File

@@ -120,13 +120,7 @@ const formatTranscodeReason = (reason: string): string => {
};
export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
({
showControls,
visible,
getTechnicalInfo,
playMethod,
transcodeReasons,
}) => {
({ visible, getTechnicalInfo, playMethod, transcodeReasons }) => {
const { settings } = useSettings();
const insets = useSafeAreaInsets();
const [info, setInfo] = useState<TechnicalInfo | null>(null);

393
hooks/useCasting.ts Normal file
View File

@@ -0,0 +1,393 @@
/**
* 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;
});
},
[],
);
// 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:
activeProtocol === "chromecast" ? "DirectStream" : "DirectPlay",
VolumeLevel: Math.floor(currentState.volume * 100),
IsMuted: currentState.volume === 0,
PlaySessionId: mediaStatus?.mediaInfo?.contentId,
},
})
.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:
activeProtocol === "chromecast" ? "DirectStream" : "DirectPlay",
VolumeLevel: Math.floor(s.volume * 100),
IsMuted: s.volume === 0,
PlaySessionId: mediaStatus?.mediaInfo?.contentId,
},
})
.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,
mediaStatus?.mediaInfo?.contentId,
]);
// 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

@@ -1,109 +0,0 @@
import { Api } from "@jellyfin/sdk";
import { useCallback, useEffect, useState } from "react";
import { DownloadedItem } from "@/providers/Downloads/types";
import { useSegments } from "@/utils/segments";
import { msToSeconds, secondsToMs } from "@/utils/time";
import { useHaptic } from "./useHaptic";
/**
* Custom hook to handle skipping credits in a media player.
* The player reports time values in milliseconds.
*/
export const useCreditSkipper = (
itemId: string,
currentTime: number,
seek: (ms: number) => void,
play: () => void,
isOffline = false,
api: Api | null = null,
downloadedFiles: DownloadedItem[] | undefined = undefined,
totalDuration?: number,
) => {
const [showSkipCreditButton, setShowSkipCreditButton] = useState(false);
const lightHapticFeedback = useHaptic("light");
// Convert ms to seconds for comparison with timestamps
const currentTimeSeconds = msToSeconds(currentTime);
const totalDurationInSeconds =
totalDuration != null ? msToSeconds(totalDuration) : undefined;
// Regular function (not useCallback) to match useIntroSkipper pattern
const wrappedSeek = (seconds: number) => {
seek(secondsToMs(seconds));
};
const { data: segments } = useSegments(
itemId,
isOffline,
downloadedFiles,
api,
);
const creditTimestamps = segments?.creditSegments?.[0];
// Determine if there's content after credits (credits don't extend to video end)
// Use a 5-second buffer to account for timing discrepancies
const hasContentAfterCredits = (() => {
if (
!creditTimestamps ||
totalDurationInSeconds == null ||
!Number.isFinite(totalDurationInSeconds)
) {
return false;
}
const creditsEndToVideoEnd =
totalDurationInSeconds - creditTimestamps.endTime;
// If credits end more than 5 seconds before video ends, there's content after
return creditsEndToVideoEnd > 5;
})();
useEffect(() => {
if (creditTimestamps) {
const shouldShow =
currentTimeSeconds > creditTimestamps.startTime &&
currentTimeSeconds < creditTimestamps.endTime;
setShowSkipCreditButton(shouldShow);
} else {
// Reset button state when no credit timestamps exist
if (showSkipCreditButton) {
setShowSkipCreditButton(false);
}
}
}, [creditTimestamps, currentTimeSeconds, showSkipCreditButton]);
const skipCredit = useCallback(() => {
if (!creditTimestamps) return;
try {
lightHapticFeedback();
// Calculate the target seek position
let seekTarget = creditTimestamps.endTime;
// If we have total duration, ensure we don't seek past the end of the video.
// Some media sources report credit end times that exceed the actual video duration,
// which causes the player to pause/stop when seeking past the end.
// Leave a small buffer (2 seconds) to trigger the natural end-of-video flow
// (next episode countdown, etc.) instead of an abrupt pause.
if (totalDurationInSeconds && seekTarget >= totalDurationInSeconds) {
seekTarget = Math.max(0, totalDurationInSeconds - 2);
}
wrappedSeek(seekTarget);
setTimeout(() => {
play();
}, 200);
} catch (error) {
console.error("[CREDIT_SKIPPER] Error skipping credit", error);
}
}, [
creditTimestamps,
lightHapticFeedback,
wrappedSeek,
play,
totalDurationInSeconds,
]);
return { showSkipCreditButton, skipCredit, hasContentAfterCredits };
};

View File

@@ -1,68 +0,0 @@
import { Api } from "@jellyfin/sdk";
import { useCallback, useEffect, useState } from "react";
import { DownloadedItem } from "@/providers/Downloads/types";
import { useSegments } from "@/utils/segments";
import { msToSeconds, secondsToMs } from "@/utils/time";
import { useHaptic } from "./useHaptic";
/**
* Custom hook to handle skipping intros in a media player.
* MPV player uses milliseconds for time.
*
* @param {number} currentTime - The current playback time in milliseconds.
*/
export const useIntroSkipper = (
itemId: string,
currentTime: number,
seek: (ms: number) => void,
play: () => void,
isOffline = false,
api: Api | null = null,
downloadedFiles: DownloadedItem[] | undefined = undefined,
) => {
const [showSkipButton, setShowSkipButton] = useState(false);
// Convert ms to seconds for comparison with timestamps
const currentTimeSeconds = msToSeconds(currentTime);
const lightHapticFeedback = useHaptic("light");
const wrappedSeek = (seconds: number) => {
seek(secondsToMs(seconds));
};
const { data: segments } = useSegments(
itemId,
isOffline,
downloadedFiles,
api,
);
const introTimestamps = segments?.introSegments?.[0];
useEffect(() => {
if (introTimestamps) {
const shouldShow =
currentTimeSeconds > introTimestamps.startTime &&
currentTimeSeconds < introTimestamps.endTime;
setShowSkipButton(shouldShow);
} else {
if (showSkipButton) {
setShowSkipButton(false);
}
}
}, [introTimestamps, currentTimeSeconds, showSkipButton]);
const skipIntro = useCallback(() => {
if (!introTimestamps) return;
try {
lightHapticFeedback();
wrappedSeek(introTimestamps.endTime);
setTimeout(() => {
play();
}, 200);
} catch (error) {
console.error("[INTRO_SKIPPER] Error skipping intro", error);
}
}, [introTimestamps, lightHapticFeedback, wrappedSeek, play]);
return { showSkipButton, skipIntro };
};

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

@@ -43,6 +43,12 @@ class MpvPlayerModule : Module() {
view.loadVideo(config)
}
// Now Playing metadata for media controls (iOS-only, no-op on Android)
// Android handles media session differently via MediaSessionCompat
Prop("nowPlayingMetadata") { _: MpvPlayerView, _: Map<String, String>? ->
// No-op on Android - media session integration would require MediaSessionCompat
}
// Async function to play video
AsyncFunction("play") { view: MpvPlayerView ->
view.play()

View File

@@ -10,6 +10,7 @@ protocol MPVLayerRendererDelegate: AnyObject {
func renderer(_ renderer: MPVLayerRenderer, didChangeLoading isLoading: Bool)
func renderer(_ renderer: MPVLayerRenderer, didBecomeReadyToSeek: Bool)
func renderer(_ renderer: MPVLayerRenderer, didBecomeTracksReady: Bool)
func renderer(_ renderer: MPVLayerRenderer, didSelectAudioOutput audioOutput: String)
}
/// MPV player using vo_avfoundation for video output.
@@ -170,7 +171,11 @@ final class MPVLayerRenderer {
// Enable composite OSD mode - renders subtitles directly onto video frames using GPU
// This is better for PiP as subtitles are baked into the video
// NOTE: Must be set BEFORE the #if targetEnvironment check or tvOS will freeze on player exit
#if targetEnvironment(simulator)
checkError(mpv_set_option_string(handle, "avfoundation-composite-osd", "no"))
#else
checkError(mpv_set_option_string(handle, "avfoundation-composite-osd", "yes"))
#endif
// Hardware decoding with VideoToolbox
// On simulator, use software decoding since VideoToolbox is not available
@@ -347,7 +352,8 @@ final class MPVLayerRenderer {
("pause", MPV_FORMAT_FLAG),
("track-list/count", MPV_FORMAT_INT64),
("paused-for-cache", MPV_FORMAT_FLAG),
("demuxer-cache-duration", MPV_FORMAT_DOUBLE)
("demuxer-cache-duration", MPV_FORMAT_DOUBLE),
("current-ao", MPV_FORMAT_STRING)
]
for (name, format) in properties {
mpv_observe_property(handle, 0, name, format)
@@ -552,6 +558,15 @@ final class MPVLayerRenderer {
self.delegate?.renderer(self, didBecomeTracksReady: true)
}
}
case "current-ao":
// Audio output is now active - notify delegate
if let aoName = getStringProperty(handle: handle, name: name) {
print("[MPV] 🔊 Audio output selected: \(aoName)")
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.delegate?.renderer(self, didSelectAudioOutput: aoName)
}
}
default:
break
}

View File

@@ -0,0 +1,188 @@
import Foundation
import MediaPlayer
import UIKit
import AVFoundation
/// Simple manager for Now Playing info and remote commands.
/// Stores all state internally and updates Now Playing when ready.
class MPVNowPlayingManager {
static let shared = MPVNowPlayingManager()
// State
private var title: String?
private var artist: String?
private var albumTitle: String?
private var cachedArtwork: MPMediaItemArtwork?
private var duration: TimeInterval = 0
private var position: TimeInterval = 0
private var isPlaying: Bool = false
private var isCommandsSetup = false
private var artworkTask: URLSessionDataTask?
private init() {}
// MARK: - Audio Session
func activateAudioSession() {
do {
let session = AVAudioSession.sharedInstance()
try session.setCategory(.playback, mode: .moviePlayback)
try session.setActive(true)
print("[NowPlaying] Audio session activated")
} catch {
print("[NowPlaying] Audio session error: \(error)")
}
}
func deactivateAudioSession() {
do {
try AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
print("[NowPlaying] Audio session deactivated")
} catch {
print("[NowPlaying] Deactivation error: \(error)")
}
}
// MARK: - Remote Commands
func setupRemoteCommands(
playHandler: @escaping () -> Void,
pauseHandler: @escaping () -> Void,
toggleHandler: @escaping () -> Void,
seekHandler: @escaping (TimeInterval) -> Void,
skipForward: @escaping (TimeInterval) -> Void,
skipBackward: @escaping (TimeInterval) -> Void
) {
guard !isCommandsSetup else { return }
isCommandsSetup = true
DispatchQueue.main.async {
UIApplication.shared.beginReceivingRemoteControlEvents()
}
let cc = MPRemoteCommandCenter.shared()
cc.playCommand.isEnabled = true
cc.playCommand.addTarget { _ in playHandler(); return .success }
cc.pauseCommand.isEnabled = true
cc.pauseCommand.addTarget { _ in pauseHandler(); return .success }
cc.togglePlayPauseCommand.isEnabled = true
cc.togglePlayPauseCommand.addTarget { _ in toggleHandler(); return .success }
cc.skipForwardCommand.isEnabled = true
cc.skipForwardCommand.preferredIntervals = [15]
cc.skipForwardCommand.addTarget { e in
if let ev = e as? MPSkipIntervalCommandEvent { skipForward(ev.interval) }
return .success
}
cc.skipBackwardCommand.isEnabled = true
cc.skipBackwardCommand.preferredIntervals = [15]
cc.skipBackwardCommand.addTarget { e in
if let ev = e as? MPSkipIntervalCommandEvent { skipBackward(ev.interval) }
return .success
}
cc.changePlaybackPositionCommand.isEnabled = true
cc.changePlaybackPositionCommand.addTarget { e in
if let ev = e as? MPChangePlaybackPositionCommandEvent { seekHandler(ev.positionTime) }
return .success
}
print("[NowPlaying] Remote commands ready")
}
func cleanupRemoteCommands() {
guard isCommandsSetup else { return }
let cc = MPRemoteCommandCenter.shared()
cc.playCommand.removeTarget(nil)
cc.pauseCommand.removeTarget(nil)
cc.togglePlayPauseCommand.removeTarget(nil)
cc.skipForwardCommand.removeTarget(nil)
cc.skipBackwardCommand.removeTarget(nil)
cc.changePlaybackPositionCommand.removeTarget(nil)
DispatchQueue.main.async {
UIApplication.shared.endReceivingRemoteControlEvents()
}
isCommandsSetup = false
print("[NowPlaying] Remote commands cleaned up")
}
// MARK: - State Updates (call these whenever data changes)
/// Set metadata (title, artist, artwork URL)
func setMetadata(title: String?, artist: String?, albumTitle: String?, artworkUrl: String?) {
self.title = title
self.artist = artist
self.albumTitle = albumTitle
print("[NowPlaying] Metadata: \(title ?? "nil")")
// Load artwork async
artworkTask?.cancel()
if let urlString = artworkUrl, let url = URL(string: urlString) {
artworkTask = URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in
if let data = data, let image = UIImage(data: data) {
self?.cachedArtwork = MPMediaItemArtwork(boundsSize: image.size) { _ in image }
print("[NowPlaying] Artwork loaded")
DispatchQueue.main.async { self?.refresh() }
}
}
artworkTask?.resume()
}
refresh()
}
/// Update playback state (position, duration, playing)
func updatePlayback(position: TimeInterval, duration: TimeInterval, isPlaying: Bool) {
self.position = position
self.duration = duration
self.isPlaying = isPlaying
refresh()
}
/// Clear everything
func clear() {
artworkTask?.cancel()
title = nil
artist = nil
albumTitle = nil
cachedArtwork = nil
duration = 0
position = 0
isPlaying = false
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
print("[NowPlaying] Cleared")
}
// MARK: - Private
/// Refresh Now Playing info if we have enough data
private func refresh() {
guard duration > 0 else {
print("[NowPlaying] refresh skipped - duration is 0")
return
}
var info: [String: Any] = [
MPMediaItemPropertyPlaybackDuration: duration,
MPNowPlayingInfoPropertyElapsedPlaybackTime: position,
MPNowPlayingInfoPropertyPlaybackRate: isPlaying ? 1.0 : 0.0
]
if let title { info[MPMediaItemPropertyTitle] = title }
if let artist { info[MPMediaItemPropertyArtist] = artist }
if let albumTitle { info[MPMediaItemPropertyAlbumTitle] = albumTitle }
if let cachedArtwork { info[MPMediaItemPropertyArtwork] = cachedArtwork }
MPNowPlayingInfoCenter.default().nowPlayingInfo = info
print("[NowPlaying] ✅ Set info: title=\(title ?? "nil"), dur=\(Int(duration))s, pos=\(Int(position))s, rate=\(isPlaying ? 1.0 : 0.0)")
}
}

View File

@@ -43,6 +43,21 @@ public class MpvPlayerModule: Module {
view.loadVideo(config: config)
}
// Now Playing metadata for iOS Control Center and Lock Screen
Prop("nowPlayingMetadata") { (view: MpvPlayerView, metadata: [String: Any]?) in
guard let metadata = metadata else { return }
// Convert Any values to String, filtering out nil/null values
var stringMetadata: [String: String] = [:]
for (key, value) in metadata {
if let stringValue = value as? String {
stringMetadata[key] = stringValue
}
}
if !stringMetadata.isEmpty {
view.setNowPlayingMetadata(stringMetadata)
}
}
// Async function to play video
AsyncFunction("play") { (view: MpvPlayerView) in
view.play()

View File

@@ -1,6 +1,7 @@
import AVFoundation
import CoreMedia
import ExpoModulesCore
import MediaPlayer
import UIKit
/// Configuration for loading a video
@@ -41,7 +42,6 @@ class MpvPlayerView: ExpoView {
private var renderer: MPVLayerRenderer?
private var videoContainer: UIView!
private var pipController: PiPController?
let onLoad = EventDispatcher()
let onPlaybackStateChange = EventDispatcher()
let onProgress = EventDispatcher()
@@ -53,11 +53,14 @@ class MpvPlayerView: ExpoView {
private var cachedDuration: Double = 0
private var intendedPlayState: Bool = false
private var _isZoomedToFill: Bool = false
// Reference to now playing manager
private let nowPlayingManager = MPVNowPlayingManager.shared
required init(appContext: AppContext? = nil) {
super.init(appContext: appContext)
setupNotifications()
setupView()
// Note: Decoder reset is handled automatically via KVO in MPVLayerRenderer
}
private func setupView() {
@@ -109,6 +112,77 @@ class MpvPlayerView: ExpoView {
CATransaction.commit()
}
// MARK: - Audio Session & Notifications
private func setupNotifications() {
// Handle audio session interruptions (e.g., incoming calls, other apps playing audio)
NotificationCenter.default.addObserver(
self, selector: #selector(handleAudioSessionInterruption),
name: AVAudioSession.interruptionNotification, object: nil)
}
@objc func handleAudioSessionInterruption(_ notification: Notification) {
guard let userInfo = notification.userInfo,
let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt,
let type = AVAudioSession.InterruptionType(rawValue: typeValue) else {
return
}
switch type {
case .began:
// Interruption began - pause the video
print("[MPV] Audio session interrupted - pausing video")
self.pause()
case .ended:
// Interruption ended - check if we should resume
if let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt {
let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
if options.contains(.shouldResume) {
print("[MPV] Audio session interruption ended - can resume")
// Don't auto-resume - let user manually resume playback
} else {
print("[MPV] Audio session interruption ended - should not resume")
}
}
@unknown default:
break
}
}
private func setupRemoteCommands() {
nowPlayingManager.setupRemoteCommands(
playHandler: { [weak self] in self?.play() },
pauseHandler: { [weak self] in self?.pause() },
toggleHandler: { [weak self] in
guard let self else { return }
if self.intendedPlayState { self.pause() } else { self.play() }
},
seekHandler: { [weak self] time in self?.seekTo(position: time) },
skipForward: { [weak self] interval in self?.seekBy(offset: interval) },
skipBackward: { [weak self] interval in self?.seekBy(offset: -interval) }
)
}
// MARK: - Now Playing Info
func setNowPlayingMetadata(_ metadata: [String: String]) {
print("[MPV] setNowPlayingMetadata: \(metadata["title"] ?? "nil")")
nowPlayingManager.setMetadata(
title: metadata["title"],
artist: metadata["artist"],
albumTitle: metadata["albumTitle"],
artworkUrl: metadata["artworkUri"]
)
}
private func clearNowPlayingInfo() {
nowPlayingManager.cleanupRemoteCommands()
nowPlayingManager.deactivateAudioSession()
nowPlayingManager.clear()
}
func loadVideo(config: VideoLoadConfig) {
// Skip reload if same URL is already playing
if currentURL == config.url {
@@ -149,6 +223,7 @@ class MpvPlayerView: ExpoView {
func play() {
intendedPlayState = true
setupRemoteCommands()
renderer?.play()
pipController?.setPlaybackRate(1.0)
pipController?.updatePlaybackState()
@@ -162,10 +237,17 @@ class MpvPlayerView: ExpoView {
}
func seekTo(position: Double) {
// Update cached position and Now Playing immediately for smooth Control Center feedback
cachedPosition = position
syncNowPlaying(isPlaying: !isPaused())
renderer?.seek(to: position)
}
func seekBy(offset: Double) {
// Update cached position and Now Playing immediately for smooth Control Center feedback
let newPosition = max(0, min(cachedPosition + offset, cachedDuration))
cachedPosition = newPosition
syncNowPlaying(isPlaying: !isPaused())
renderer?.seek(by: offset)
}
@@ -292,23 +374,32 @@ class MpvPlayerView: ExpoView {
pipController?.stopPictureInPicture()
renderer?.stop()
displayLayer.removeFromSuperlayer()
clearNowPlayingInfo()
NotificationCenter.default.removeObserver(self)
}
}
// MARK: - MPVLayerRendererDelegate
extension MpvPlayerView: MPVLayerRendererDelegate {
// MARK: - Single location for Now Playing updates
private func syncNowPlaying(isPlaying: Bool) {
print("[MPV] syncNowPlaying: pos=\(Int(cachedPosition))s, dur=\(Int(cachedDuration))s, playing=\(isPlaying)")
nowPlayingManager.updatePlayback(position: cachedPosition, duration: cachedDuration, isPlaying: isPlaying)
}
func renderer(_: MPVLayerRenderer, didUpdatePosition position: Double, duration: Double, cacheSeconds: Double) {
cachedPosition = position
cachedDuration = duration
DispatchQueue.main.async { [weak self] in
guard let self else { return }
// Update PiP current time for progress bar
if self.pipController?.isPictureInPictureActive == true {
self.pipController?.setCurrentTimeFromSeconds(position, duration: duration)
}
self.onProgress([
"position": position,
"duration": duration,
@@ -321,12 +412,10 @@ extension MpvPlayerView: MPVLayerRendererDelegate {
func renderer(_: MPVLayerRenderer, didChangePause isPaused: Bool) {
DispatchQueue.main.async { [weak self] in
guard let self else { return }
// Don't update intendedPlayState here - it's only set by user actions (play/pause)
// This prevents PiP UI flicker during seeking
// Sync timebase rate with actual playback state
print("[MPV] didChangePause: isPaused=\(isPaused), cachedDuration=\(self.cachedDuration)")
self.pipController?.setPlaybackRate(isPaused ? 0.0 : 1.0)
self.syncNowPlaying(isPlaying: !isPaused)
self.onPlaybackStateChange([
"isPaused": isPaused,
"isPlaying": !isPaused,
@@ -358,6 +447,13 @@ extension MpvPlayerView: MPVLayerRendererDelegate {
self.onTracksReady([:])
}
}
func renderer(_: MPVLayerRenderer, didSelectAudioOutput audioOutput: String) {
// Audio output is now active - this is the right time to activate audio session and set Now Playing
print("[MPV] Audio output ready (\(audioOutput)), activating audio session and syncing Now Playing")
nowPlayingManager.activateAudioSession()
syncNowPlaying(isPlaying: !isPaused())
}
}
// MARK: - PiPControllerDelegate

View File

@@ -25,6 +25,13 @@ export type OnErrorEventPayload = {
export type OnTracksReadyEventPayload = Record<string, never>;
export type NowPlayingMetadata = {
title?: string;
artist?: string;
albumTitle?: string;
artworkUri?: string;
};
export type MpvPlayerModuleEvents = {
onChange: (params: ChangeEventPayload) => void;
};
@@ -48,6 +55,8 @@ export type VideoSource = {
export type MpvPlayerViewProps = {
source?: VideoSource;
style?: StyleProp<ViewStyle>;
/** Metadata for iOS Control Center and Lock Screen now playing info */
nowPlayingMetadata?: NowPlayingMetadata;
onLoad?: (event: { nativeEvent: OnLoadEventPayload }) => void;
onPlaybackStateChange?: (event: {
nativeEvent: OnPlaybackStateChangePayload;

View File

@@ -77,7 +77,7 @@
"patch-package": "^8.0.0",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-i18next": "16.5.4",
"react-i18next": "16.5.8",
"react-native": "0.81.5",
"react-native-awesome-slider": "^2.9.0",
"react-native-bottom-tabs": "1.1.0",
@@ -118,15 +118,15 @@
"@babel/core": "7.28.6",
"@biomejs/biome": "2.3.11",
"@react-native-community/cli": "20.1.1",
"@react-native-tvos/config-tv": "0.1.4",
"@react-native-tvos/config-tv": "0.1.6",
"@types/jest": "29.5.14",
"@types/lodash": "4.17.23",
"@types/react": "19.1.17",
"@types/react-test-renderer": "19.1.0",
"cross-env": "10.1.0",
"expo-doctor": "1.17.14",
"expo-doctor": "1.18.22",
"husky": "9.1.7",
"lint-staged": "16.2.7",
"lint-staged": "17.0.5",
"react-test-renderer": "19.2.3",
"typescript": "5.9.3"
},

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

@@ -5,10 +5,13 @@ import {
type ReactNode,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { BackHandler, Platform } from "react-native";
interface ModalOptions {
enableDynamicSizing?: boolean;
snapPoints?: (string | number)[];
@@ -73,6 +76,25 @@ export const GlobalModalProvider: React.FC<GlobalModalProviderProps> = ({
});
}, []);
useEffect(() => {
if (Platform.OS !== "android") return;
const onBackPress = () => {
if (isVisible) {
hideModal();
return true;
}
return false;
};
const subscription = BackHandler.addEventListener(
"hardwareBackPress",
onBackPress,
);
return () => subscription.remove();
}, [isVisible, hideModal]);
const value = {
showModal,
hideModal,

View File

@@ -7,7 +7,7 @@
"username_placeholder": "Benutzername",
"password_placeholder": "Passwort",
"login_button": "Anmelden",
"quick_connect": "Schnellverbindung",
"quick_connect": "Quick Connect",
"enter_code_to_login": "Gib den Code {{code}} ein, um dich anzumelden",
"failed_to_initiate_quick_connect": "Fehler beim Initiieren der Schnellverbindung",
"got_it": "Verstanden",
@@ -30,48 +30,48 @@
"connect_button": "Verbinden",
"previous_servers": "Vorherige Server",
"clear_button": "Löschen",
"swipe_to_remove": "Swipe to remove",
"swipe_to_remove": "Wischen, um zu entfernen",
"search_for_local_servers": "Nach lokalen Servern suchen",
"searching": "Suche...",
"servers": "Server",
"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": "Gespeichert",
"session_expired": "Sitzung abgelaufen",
"please_login_again": "Ihre Sitzung ist abgelaufen. Bitte erneut anmelden.",
"remove_saved_login": "Gespeicherte Zugangsdaten entfernen",
"remove_saved_login_description": "Hiermit werden ihre gespeicherten Zugangsdaten für diesen Server entfernt. Sie müssen sich dann erneut anmelden.",
"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."
},
"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": "Konto speichern",
"save_for_later": "Dieses Konto speichern",
"security_option": "Sicherheitseinstellung",
"no_protection": "Keine",
"no_protection_desc": "Schnellanmeldung ohne Authentifizierung",
"pin_code": "PIN",
"pin_code_desc": "4-stellige PIN bei Konto-Wechsel erforderlich",
"password": "Passwort wiederholen",
"password_desc": "Passwort bei Konto-Wechsel erforderlich",
"save_button": "Speichern",
"cancel_button": "Abbrechen"
},
"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 eingeben",
"enter_pin_for": "PIN für {{username}} eingeben",
"enter_4_digits": "4 Ziffern eingeben",
"invalid_pin": "Ungültige PIN",
"setup_pin": "PIN festlegen",
"confirm_pin": "PIN bestätigen",
"pins_dont_match": "PIN stimmt nicht überein",
"forgot_pin": "PIN vergessen?",
"forgot_pin_desc": "Ihre gespeicherten Zugangsdaten werden entfernt"
},
"password": {
"enter_password": "Enter Password",
"enter_password_for": "Enter password for {{username}}",
"invalid_password": "Invalid password"
"enter_password": "Passwort eingeben",
"enter_password_for": "Passwort für {{username}} eingeben",
"invalid_password": "Ungültiges Passwort"
},
"home": {
"checking_server_connection": "Überprüfe Serververbindung...",
@@ -87,7 +87,7 @@
"error_message": "Etwas ist schiefgelaufen.\nBitte melde dich ab und wieder an.",
"continue_watching": "Weiterschauen",
"next_up": "Als nächstes",
"continue_and_next_up": "Continue & Next Up",
"continue_and_next_up": "\"Weiterschauen\" und \"Als Nächstes\"",
"recently_added_in": "Kürzlich hinzugefügt in {{libraryName}}",
"suggested_movies": "Empfohlene Filme",
"suggested_episodes": "Empfohlene Episoden",
@@ -120,36 +120,36 @@
},
"appearance": {
"title": "Aussehen",
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
"hide_remote_session_button": "Hide Remote Session Button"
"merge_next_up_continue_watching": "\"Weiterschauen\" und \"Als Nächstes\" kombinieren",
"hide_remote_session_button": "Button für Remote-Sitzung ausblenden"
},
"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": "Netzwerk",
"local_network": "Lokales Netzwerk",
"auto_switch_enabled": "Zuhause automatisch wechseln",
"auto_switch_description": "Im WLAN Zuhause automatisch zu lokaler URL wechseln",
"local_url": "Lokale URL",
"local_url_hint": "Lokale Server-URL eingeben (zB. 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",
"home_wifi_networks": "Private WLAN-Netze",
"add_current_network": "{{ssid}} hinzufügen",
"not_connected_to_wifi": "Nicht mit WLAN verbunden",
"no_networks_configured": "Keine Netzwerke konfiguriert",
"add_network_hint": "Füge dein privates WLAN-Netz hinzu um automatischen Wechsel zu aktivieren",
"current_wifi": "Aktuelles WLAN-Netz",
"using_url": "Verwendet",
"local": "Lokale URL",
"remote": "Remote URL",
"not_connected": "Not connected",
"current_server": "Current Server",
"not_connected": "Nicht verbunden",
"current_server": "Aktueller 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."
"active_url": "Aktive URL",
"not_configured": "Nicht konfiguriert",
"network_added": "Netzwerk hinzugefügt",
"network_already_added": "Netzwerk bereits hinzugefügt",
"no_wifi_connected": "Nicht mit WLAN verbunden",
"permission_denied": "Standortberechtigung nicht verfügbar",
"permission_denied_explanation": "Standortberechtigung ist nötig um WLAN-Netze für den automatischen Wechsel zu erkennen. Bitte in den Einstellungen aktivieren."
},
"user_info": {
"user_info_title": "Benutzerinformationen",
@@ -159,82 +159,82 @@
"app_version": "App-Version"
},
"quick_connect": {
"quick_connect_title": "Schnellverbindung",
"authorize_button": "Schnellverbindung autorisieren",
"enter_the_quick_connect_code": "Gib den Schnellverbindungscode ein...",
"success": "Erfolg",
"quick_connect_autorized": "Schnellverbindung autorisiert",
"quick_connect_title": "Quick Connect",
"authorize_button": "Quick Connect autorisieren",
"enter_the_quick_connect_code": "Quick Connect-Code eingeben...",
"success": "Erfolgreich verbunden",
"quick_connect_autorized": "Quick Connect autorisiert",
"error": "Fehler",
"invalid_code": "Ungültiger Code",
"authorize": "Autorisieren"
},
"media_controls": {
"media_controls_title": "Mediensteuerung",
"forward_skip_length": "Vorspulzeit",
"rewind_length": "Rückspulzeit",
"forward_skip_length": "Vorspullänge",
"rewind_length": "Rückspullänge",
"seconds_unit": "s"
},
"gesture_controls": {
"gesture_controls_title": "Gestensteuerung",
"horizontal_swipe_skip": "Horizontales Wischen zum Überspringen",
"horizontal_swipe_skip_description": "Wische links/rechts, wenn Steuerelemente ausgeblendet werden um zu überspringen",
"left_side_brightness": "Helligkeitskontrolle der linken Seite",
"left_side_brightness_description": "Wischen Sie auf der linken Seite nach oben/runter, um die Helligkeit anzupassen",
"right_side_volume": "Lautstärkeregelung der rechten Seite",
"right_side_volume_description": "Auf der rechten Seite nach oben/unten wischen, um Lautstärke anzupassen",
"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"
"horizontal_swipe_skip": "Horizontal Wischen zum Überspringen",
"horizontal_swipe_skip_description": "Wische links/rechts, wenn Steuerelemente ausgeblendet sind um zu überspringen",
"left_side_brightness": "Helligkeitsregler Links",
"left_side_brightness_description": "Links nach oben/unten wischen um Helligkeit anzupassen",
"right_side_volume": "Lautstärkeregler Rechts",
"right_side_volume_description": "Rechts nach oben/unten wischen um Lautstärke anzupassen",
"hide_volume_slider": "Lautstärkeregler ausblenden",
"hide_volume_slider_description": "Lautstärkeregler im Videoplayer ausblenden",
"hide_brightness_slider": "Helligkeitsregler ausblenden",
"hide_brightness_slider_description": "Helligkeitsregler im Videoplayer ausblenden"
},
"audio": {
"audio_title": "Audio",
"set_audio_track": "Audiospur aus dem vorherigen Element festlegen",
"set_audio_track": "Audiospur aus dem vorherigen Element übernehmen",
"audio_language": "Audio-Sprache",
"audio_hint": "Wähl die Standardsprache für Audio aus.",
"audio_hint": "Standardsprache für Audio auswählen.",
"none": "Keine",
"language": "Sprache",
"transcode_mode": {
"title": "Audio Transcoding",
"description": "Controls how surround audio (7.1, TrueHD, DTS-HD) is handled",
"title": "Audio-Transcoding",
"description": "Legt fest, wie Surround-Audio (7.1, TrueHD, DTS-HD) behandelt wird",
"auto": "Auto",
"stereo": "Force Stereo",
"5_1": "Allow 5.1",
"stereo": "Stereo erzwingen",
"5_1": "5.1 erlauben",
"passthrough": "Passthrough"
}
},
"subtitles": {
"subtitle_title": "Untertitel",
"subtitle_hint": "Konfigurier die Untertitel-Präferenzen.",
"subtitle_hint": "Untertitel-Erscheinungsbild und Verhalten konfigurieren.",
"subtitle_language": "Untertitel-Sprache",
"subtitle_mode": "Untertitel-Modus",
"set_subtitle_track": "Untertitel-Spur aus dem vorherigen Element festlegen",
"set_subtitle_track": "Untertitel-Spur aus dem vorherigen Element übernehmen",
"subtitle_size": "Untertitel-Größe",
"none": "Keine",
"language": "Sprache",
"loading": "Lädt",
"modes": {
"Default": "Standard",
"Smart": "Intelligent",
"Smart": "Smart",
"Always": "Immer",
"None": "Keine",
"OnlyForced": "Nur erzwungen"
"OnlyForced": "Nur erzwungene"
},
"text_color": "Textfarbe",
"background_color": "Hintergrundfarbe",
"outline_color": "Konturfarbe",
"outline_thickness": "Umriss Dicke",
"outline_thickness": "Konturdicke",
"background_opacity": "Hintergrundtransparenz",
"outline_opacity": "Kontur-Deckkraft",
"bold_text": "Bold Text",
"outline_opacity": "Konturtransparenz",
"bold_text": "Fettgedruckter Text",
"colors": {
"Black": "Schwarz",
"Gray": "Grau",
"Silver": "Silber",
"White": "Weiß",
"Maroon": "Marotte",
"Maroon": "Rotbraun",
"Red": "Rot",
"Fuchsia": "Fuchsia",
"Fuchsia": "Magenta",
"Yellow": "Gelb",
"Olive": "Olivgrün",
"Green": "Grün",
@@ -251,29 +251,29 @@
"Normal": "Normal",
"Thick": "Dick"
},
"subtitle_color": "Subtitle Color",
"subtitle_background_color": "Background Color",
"subtitle_font": "Subtitle Font",
"ksplayer_title": "KSPlayer Settings",
"subtitle_color": "Untertitelfarbe",
"subtitle_background_color": "Hintergrundfarbe",
"subtitle_font": "Untertitel-Schriftart",
"ksplayer_title": "KSPlayer Einstellungen",
"hardware_decode": "Hardware Decoding",
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues."
"hardware_decode_description": "Hardwarebeschleunigung für Video Decoding verwenden. Deaktivieren wenn Wiedergabeprobleme auftreten."
},
"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 Untertitel-Einstellungen",
"hint": "Anpassen des Untertitel-Erscheinungsbildes für VLC. Änderungen werden bei der nächsten Wiedergabe übernommen.",
"text_color": "Schriftfarbe",
"background_color": "Hintergrundfarbe",
"background_opacity": "Hintergrundtransparenz",
"outline_color": "Konturfarbe",
"outline_opacity": "Konturtransparenz",
"outline_thickness": "Konturdicke",
"bold": "Fettgedruckter Text",
"margin": "Unterer Abstand"
},
"video_player": {
"title": "Video Player",
"video_player": "Video Player",
"video_player_description": "Choose which video player to use on iOS.",
"title": "Videoplayer",
"video_player": "Videoplayer",
"video_player_description": "Videoplayer auf iOS auswählen.",
"ksplayer": "KSPlayer",
"vlc": "VLC"
},
@@ -282,7 +282,7 @@
"video_orientation": "Videoausrichtung",
"orientation": "Ausrichtung",
"orientations": {
"DEFAULT": "Standard",
"DEFAULT": "Geräteausrichtung folgen",
"ALL": "Alle",
"PORTRAIT": "Hochformat",
"PORTRAIT_UP": "Hochformat oben",
@@ -294,54 +294,54 @@
"UNKNOWN": "Unbekannt"
},
"safe_area_in_controls": "Sicherer Bereich in den Steuerungen",
"video_player": "Video player",
"video_player": "Videoplayer",
"video_players": {
"VLC_3": "VLC 3",
"VLC_4": "VLC 4 (Experimentell + PiP)"
},
"show_custom_menu_links": "Benutzerdefinierte Menülinks anzeigen",
"show_large_home_carousel": "Zeige Großes Heimkarussell (Beta)",
"show_large_home_carousel": "Zeige große Startseiten-Übersicht (Beta)",
"hide_libraries": "Bibliotheken ausblenden",
"select_liraries_you_want_to_hide": "Wähl die Bibliotheken aus, die du im Bibliothekstab und auf der Startseite ausblenden möchtest.",
"select_liraries_you_want_to_hide": "Bibliotheken auswählen die aus dem Bibliothekstab und der Startseite ausgeblendet werden sollen.",
"disable_haptic_feedback": "Haptisches Feedback deaktivieren",
"default_quality": "Standardqualität",
"default_playback_speed": "Default Playback Speed",
"auto_play_next_episode": "Auto-play Next Episode",
"max_auto_play_episode_count": "Max. automatische Wiedergabe Episodenanzahl",
"default_playback_speed": "Standard-Wiedergabegeschwindigkeit",
"auto_play_next_episode": "Automatisch nächste Episode abspielen",
"max_auto_play_episode_count": "Maximale automatisch abzuspielende Episodenanzahl",
"disabled": "Deaktiviert"
},
"downloads": {
"downloads_title": "Downloads"
},
"music": {
"title": "Music",
"playback_title": "Playback",
"playback_description": "Configure how music is played.",
"prefer_downloaded": "Prefer Downloaded Songs",
"title": "Musik",
"playback_title": "Wiedergabe",
"playback_description": "Konfigurieren, wie Musik abgespielt wird.",
"prefer_downloaded": "Bevorzuge heruntergeladene Titel",
"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"
"caching_description": "Automatisches Caching anstehender Titel für bessere Wiedergabe.",
"lookahead_enabled": "Look-Ahead Caching aktivieren",
"lookahead_count": "Titel vorher in den Cache laden",
"max_cache_size": "Maximale Cache-Größe"
},
"plugins": {
"plugins_title": "Erweiterungen",
"plugins_title": "Plugins",
"jellyseerr": {
"jellyseerr_warning": "Diese integration ist in einer frühen Entwicklungsphase. Erwarte Veränderungen.",
"server_url": "Server Adresse",
"server_url_hint": "Beispiel: http(s)://your-host.url\n(Portnummer hinzufügen, falls erforderlich)",
"server_url_placeholder": "Jellyseerr URL...",
"jellyseerr_warning": "Diese Integration ist in einer frühen Entwicklungsphase und kann jederzeit geändert werden.",
"server_url": "Server URL",
"server_url_hint": "Beispiel: http(s)://your-host.url\n(Port hinzufügen, falls erforderlich)",
"server_url_placeholder": "Seerr URL",
"password": "Passwort",
"password_placeholder": "Passwort für Jellyfin Benutzer {{username}} eingeben",
"login_button": "Anmelden",
"total_media_requests": "Gesamtanfragen",
"movie_quota_limit": "Film-Anfragelimit",
"movie_quota_days": "Film-Anfragetage",
"tv_quota_limit": "TV-Anfragelimit",
"tv_quota_days": "TV-Anfragetage",
"reset_jellyseerr_config_button": "Setze Jellyseerr-Konfiguration zurück",
"movie_quota_days": "Film-Anfragetagelimit",
"tv_quota_limit": "Serien-Anfragelimit",
"tv_quota_days": "Serien-Anfragetagelimit",
"reset_jellyseerr_config_button": "Seerr-Konfiguration zurücksetzen",
"unlimited": "Unlimitiert",
"plus_n_more": "+{{n}} more",
"plus_n_more": "+{{n}} weitere",
"order_by": {
"DEFAULT": "Standard",
"VOTE_COUNT_AND_AVERAGE": "Stimmenanzahl und Durchschnitt",
@@ -352,71 +352,71 @@
"enable_marlin_search": "Aktiviere Marlin Search",
"url": "URL",
"server_url_placeholder": "http(s)://domain.org:port",
"marlin_search_hint": "Gib die URL für den Marlin Server ein. Die URL sollte http oder https enthalten und optional den Port.",
"marlin_search_hint": "URL für den Marlin Server eingeben. Die URL sollte http oder https enthalten und optional den Port.",
"read_more_about_marlin": "Erfahre mehr über Marlin.",
"save_button": "Speichern",
"toasts": {
"saved": "Gespeichert",
"refreshed": "Settings refreshed from server"
"refreshed": "Einstellungen vom Server aktualisiert"
},
"refresh_from_server": "Refresh Settings from Server"
"refresh_from_server": "Einstellungen vom Server aktualisieren"
},
"streamystats": {
"enable_streamystats": "Enable Streamystats",
"disable_streamystats": "Disable Streamystats",
"enable_search": "Use for Search",
"enable_streamystats": "Streamystats aktivieren",
"disable_streamystats": "Streamystats deaktivieren",
"enable_search": "Zum Suchen verwenden",
"url": "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",
"streamystats_search_hint": "URL für den Streamystats-Server eingeben.",
"read_more_about_streamystats": "Mehr über Streamystats erfahren.",
"save_button": "Speichern",
"save": "Gespeichert",
"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",
"home_sections_title": "Startseitenbereiche",
"enable_movie_recommendations": "Filmempfehlungen",
"enable_series_recommendations": "Serienempfehlungen",
"enable_promoted_watchlists": "Empfohlene Merklisten",
"hide_watchlists_tab": "Merklisten-Tab ausblenden",
"home_sections_hint": "Zeige personalisierte Empfehlungen und empfohlene Merklisten von Streamystats auf der Startseite.",
"recommended_movies": "Empfohlene Filme",
"recommended_series": "Empfohlene Serien",
"toasts": {
"saved": "Saved",
"refreshed": "Settings refreshed from server",
"disabled": "Streamystats disabled"
"saved": "Gespeichert",
"refreshed": "Einstellungen vom Server aktualisiert",
"disabled": "Streamystats deaktiviert"
},
"refresh_from_server": "Refresh Settings from Server"
"refresh_from_server": "Einstellungen vom Server aktualisieren"
},
"kefinTweaks": {
"watchlist_enabler": "Enable our Watchlist integration",
"watchlist_button": "Toggle Watchlist integration"
"watchlist_enabler": "Merklisten-Integration aktivieren",
"watchlist_button": "Merklisten-Integration umschalten"
}
},
"storage": {
"storage_title": "Speicher",
"app_usage": "App {{usedSpace}}%",
"device_usage": "Gerät {{availableSpace}}%",
"size_used": "{{used}} von {{total}} benutzt",
"delete_all_downloaded_files": "Alle Downloads löschen",
"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"
"size_used": "{{used}} von {{total}} genutzt",
"delete_all_downloaded_files": "Alle heruntergeladenen Dateien löschen",
"music_cache_title": "Musik-Cache",
"music_cache_description": "Beim Anhören Titel automatisch in den Cache laden um bessere Wiedergabe und Offline-Wiedergabe zu ermöglichen",
"enable_music_cache": "Musik-Cache aktivieren",
"clear_music_cache": "Musik-Cache leeren",
"music_cache_size": "{{size}} gechached",
"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"
},
"intro": {
"title": "Intro ",
"show_intro": "Show intro",
"reset_intro": "Reset intro"
"title": "Einführung",
"show_intro": "Einführung anzeigen",
"reset_intro": "Einführung zurücksetzen"
},
"logs": {
"logs_title": "Logs",
"export_logs": "Export logs",
"click_for_more_info": "Click for more info",
"export_logs": "Logs exportieren",
"click_for_more_info": "Für mehr Informationen klicken",
"level": "Level",
"no_logs_available": "Keine Logs verfügbar",
"delete_all_logs": "Alle Logs löschen"
@@ -438,21 +438,21 @@
},
"downloads": {
"downloads_title": "Downloads",
"tvseries": "TV-Serien",
"tvseries": "Serien",
"movies": "Filme",
"queue": "Warteschlange",
"other_media": "Andere Medien",
"queue_hint": "Warteschlange und aktive Downloads gehen verloren bei App-Neustart",
"queue_hint": "Warteschlange und aktive Downloads gehen verloren wenn die App neu gestartet wird",
"no_items_in_queue": "Keine Elemente in der Warteschlange",
"no_downloaded_items": "Keine heruntergeladenen Elemente",
"delete_all_movies_button": "Alle Filme löschen",
"delete_all_tvseries_button": "Alle TV-Serien löschen",
"delete_all_tvseries_button": "Alle Serien löschen",
"delete_all_button": "Alles löschen",
"delete_all_other_media_button": "Andere Medien löschen",
"delete_all_other_media_button": "Alle anderen Medien löschen",
"active_download": "Aktiver Download",
"no_active_downloads": "Keine aktiven Downloads",
"active_downloads": "Aktive Downloads",
"new_app_version_requires_re_download": "Die neue App-Version erfordert das erneute Herunterladen.",
"new_app_version_requires_re_download": "Neue App-Version erfordert erneutes Herunterladen",
"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",
@@ -463,8 +463,8 @@
"you_are_not_allowed_to_download_files": "Du hast keine Berechtigung, Dateien herunterzuladen",
"deleted_all_movies_successfully": "Alle Filme erfolgreich gelöscht!",
"failed_to_delete_all_movies": "Fehler beim Löschen aller Filme",
"deleted_all_tvseries_successfully": "Alle TV-Serien erfolgreich gelöscht!",
"failed_to_delete_all_tvseries": "Fehler beim Löschen aller TV-Serien",
"deleted_all_tvseries_successfully": "Alle Serien erfolgreich gelöscht!",
"failed_to_delete_all_tvseries": "Fehler beim Löschen aller Serien",
"deleted_media_successfully": "Andere Medien erfolgreich gelöscht!",
"failed_to_delete_media": "Fehler beim Löschen anderer Medien",
"download_deleted": "Download gelöscht",
@@ -486,7 +486,7 @@
"all_files_folders_and_jobs_deleted_successfully": "Alle Dateien, Ordner und Jobs erfolgreich gelöscht",
"failed_to_clean_cache_directory": "Fehler beim Bereinigen des Cache-Verzeichnisses",
"could_not_get_download_url_for_item": "Download-URL für {{itemName}} konnte nicht geladen werden",
"go_to_downloads": "Gehe zu den Downloads",
"go_to_downloads": "Zu Downloads gehen",
"file_deleted": "{{item}} gelöscht"
}
}
@@ -499,18 +499,18 @@
"subtitle": "Untertitel",
"play": "Abspielen",
"none": "Keine",
"track": "Track",
"cancel": "Cancel",
"delete": "Delete",
"track": "Spur",
"cancel": "Abbrechen",
"delete": "Löschen",
"ok": "OK",
"remove": "Remove",
"next": "Next",
"back": "Back",
"continue": "Continue",
"verifying": "Verifying..."
"remove": "Entfernen",
"next": "Weiter",
"back": "Zurück",
"continue": "Fortsetzen",
"verifying": "Verifiziere..."
},
"search": {
"search": "Suche...",
"search": "Suchen...",
"x_items": "{{count}} Elemente",
"library": "Bibliothek",
"discover": "Entdecken",
@@ -521,33 +521,33 @@
"episodes": "Episoden",
"collections": "Sammlungen",
"actors": "Schauspieler",
"artists": "Artists",
"albums": "Albums",
"songs": "Songs",
"artists": "Künstler",
"albums": "Alben",
"songs": "Titel",
"playlists": "Playlists",
"request_movies": "Film anfragen",
"request_series": "Serie anfragen",
"recently_added": "Kürzlich hinzugefügt",
"recent_requests": "Kürzlich angefragt",
"plex_watchlist": "Plex Watchlist",
"trending": "In den Trends",
"plex_watchlist": "Plex Merkliste",
"trending": "Beliebt",
"popular_movies": "Beliebte Filme",
"movie_genres": "Film-Genres",
"upcoming_movies": "Kommende Filme",
"studios": "Studios",
"popular_tv": "Beliebte TV-Serien",
"tv_genres": "TV-Serien-Genres",
"upcoming_tv": "Kommende TV-Serien",
"networks": "Netzwerke",
"popular_tv": "Beliebte Serien",
"tv_genres": "Serien-Genres",
"upcoming_tv": "Kommende Serien",
"networks": "Sender",
"tmdb_movie_keyword": "TMDB Film-Schlüsselwort",
"tmdb_movie_genre": "TMDB Film-Genre",
"tmdb_tv_keyword": "TMDB TV-Serien-Schlüsselwort",
"tmdb_tv_genre": "TMDB TV-Serien-Genre",
"tmdb_tv_keyword": "TMDB Serien-Schlüsselwort",
"tmdb_tv_genre": "TMDB Serien-Genre",
"tmdb_search": "TMDB Suche",
"tmdb_studio": "TMDB Studio",
"tmdb_network": "TMDB Netzwerk",
"tmdb_movie_streaming_services": "TMDB Film-Streaming-Dienste",
"tmdb_tv_streaming_services": "TMDB TV-Serien-Streaming-Dienste"
"tmdb_tv_streaming_services": "TMDB Serien-Streaming-Dienste"
},
"library": {
"no_results": "Keine Ergebnisse",
@@ -572,7 +572,7 @@
"genres": "Genres",
"years": "Jahre",
"sort_by": "Sortieren nach",
"filter_by": "Filter By",
"filter_by": "Filtern nach",
"sort_order": "Sortierreihenfolge",
"tags": "Tags"
}
@@ -585,7 +585,7 @@
"boxsets": "Boxsets",
"playlists": "Wiedergabelisten",
"noDataTitle": "Noch keine Favoriten",
"noData": "Markiere Elemente als Favoriten, damit sie hier für einen schnellen Zugriff angezeigt werden."
"noData": "Elemente als Favoriten markieren, um sie hier anzuzeigen."
},
"custom_links": {
"no_links": "Keine Links"
@@ -593,7 +593,7 @@
"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. Überprüf die Logs in den Einstellungen.",
"an_error_occured_while_playing_the_video": "Ein Fehler ist beim Abspielen des Videos aufgetreten. Logs in den Einstellungen überprüfen.",
"client_error": "Client-Fehler",
"could_not_create_stream_for_chromecast": "Konnte keinen Stream für Chromecast erstellen",
"message_from_server": "Nachricht vom Server: {{message}}",
@@ -602,17 +602,17 @@
"audio_tracks": "Audiospuren:",
"playback_state": "Wiedergabestatus:",
"index": "Index:",
"continue_watching": "Weiterschauen",
"continue_watching": "Fortsetzen",
"go_back": "Zurück",
"downloaded_file_title": "Diese Datei wurde heruntergeladen",
"downloaded_file_message": "Möchten Sie die heruntergeladene Datei abspielen?",
"downloaded_file_title": "Diese Datei wurde bereits heruntergeladen",
"downloaded_file_message": "Heruntergeladene Datei abspielen?",
"downloaded_file_yes": "Ja",
"downloaded_file_no": "Nein",
"downloaded_file_cancel": "Abbrechen"
},
"item_card": {
"next_up": "Als Nächstes",
"no_items_to_display": "Keine Elemente zum Anzeigen",
"no_items_to_display": "Keine Elemente",
"cast_and_crew": "Besetzung und Crew",
"series": "Serien",
"seasons": "Staffeln",
@@ -630,7 +630,7 @@
"subtitles": "Untertitel",
"show_more": "Mehr anzeigen",
"show_less": "Weniger anzeigen",
"appeared_in": "Erschienen in",
"appeared_in": "Erschien in",
"could_not_load_item": "Konnte Element nicht laden",
"none": "Keine",
"download": {
@@ -639,13 +639,13 @@
"download_episode": "Episode herunterladen",
"download_movie": "Film herunterladen",
"download_x_item": "{{item_count}} Elemente herunterladen",
"download_unwatched_only": "Nur unbeobachtete",
"download_unwatched_only": "Nur Ungesehene",
"download_button": "Herunterladen"
}
},
"live_tv": {
"next": "Nächster",
"previous": "Vorheriger",
"next": "Nächste",
"previous": "Vorherige",
"coming_soon": "Demnächst",
"on_now": "Jetzt",
"shows": "Serien",
@@ -658,10 +658,10 @@
"confirm": "Bestätigen",
"cancel": "Abbrechen",
"yes": "Ja",
"whats_wrong": "Hast du Probleme?",
"issue_type": "Fehlerart",
"select_an_issue": "Wähle einen Fehlerart aus",
"types": "Arten",
"whats_wrong": "Was stimmt nicht?",
"issue_type": "Art des Problems",
"select_an_issue": "Wähle die Art des Problems aus",
"types": "Problem-Arten",
"describe_the_issue": "(optional) Beschreibe das Problem",
"submit_button": "Absenden",
"report_issue_button": "Fehler melden",
@@ -671,7 +671,7 @@
"cast": "Besetzung",
"details": "Details",
"status": "Status",
"original_title": "Original Titel",
"original_title": "Originaltitel",
"series_type": "Serien Typ",
"release_dates": "Veröffentlichungsdaten",
"first_air_date": "Erstausstrahlungsdatum",
@@ -687,10 +687,10 @@
"request_as": "Anfragen als",
"tags": "Tags",
"quality_profile": "Qualitätsprofil",
"root_folder": "Root-Ordner",
"season_all": "Season (all)",
"root_folder": "Stammverzeichnis",
"season_all": "Staffeln (alle)",
"season_number": "Staffel {{season_number}}",
"number_episodes": "{{episode_number}} Folgen",
"number_episodes": "{{episode_number}} Episoden",
"born": "Geboren",
"appearances": "Auftritte",
"approve": "Genehmigen",
@@ -698,9 +698,9 @@
"requested_by": "Angefragt von {{user}}",
"unknown_user": "Unbekannter Nutzer",
"toasts": {
"jellyseer_does_not_meet_requirements": "Jellyseerr Server erfüllt nicht die Anforderungsversion. Bitte aktualisiere deinen Jellyseerr Server auf mindestens 2.0.0",
"jellyseerr_test_failed": "Jellyseerr-Test fehlgeschlagen. Bitte versuche es erneut.",
"failed_to_test_jellyseerr_server_url": "Fehler beim Testen der Jellyseerr-Server-URL",
"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.",
"failed_to_test_jellyseerr_server_url": "Fehler beim Test der Seerr-Server-URL",
"issue_submitted": "Problem eingereicht!",
"requested_item": "{{item}} angefragt!",
"you_dont_have_permission_to_request": "Du hast keine Berechtigung Anfragen zu stellen",
@@ -715,131 +715,131 @@
"home": "Startseite",
"search": "Suche",
"library": "Bibliothek",
"custom_links": "Benutzerdefinierte Links",
"custom_links": "Links",
"favorites": "Favoriten"
},
"music": {
"title": "Music",
"title": "Musik",
"tabs": {
"suggestions": "Suggestions",
"albums": "Albums",
"artists": "Artists",
"suggestions": "Vorschläge",
"albums": "Alben",
"artists": "Künstler",
"playlists": "Playlists",
"tracks": "tracks"
"tracks": "Titel"
},
"filters": {
"all": "All"
"all": "Alle"
},
"recently_added": "Recently Added",
"recently_played": "Recently Played",
"frequently_played": "Frequently Played",
"explore": "Explore",
"top_tracks": "Top Tracks",
"play": "Play",
"recently_added": "Kürzlich hinzugefügt",
"recently_played": "Vor kurzem gehört",
"frequently_played": "Oft gehört",
"explore": "Entdecken",
"top_tracks": "Top-Titel",
"play": "Abspielen",
"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",
"play_top_tracks": "Top-Tracks abspielen",
"no_suggestions": "Keine Vorschläge verfügbar",
"no_albums": "Keine Alben gefunden",
"no_artists": "Keine Künstler gefunden",
"no_playlists": "Keine Playlists gefunden",
"album_not_found": "Album nicht gefunden",
"artist_not_found": "Künstler nicht gefunden",
"playlist_not_found": "Playlist nicht gefunden",
"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": "Als Nächstes wiedergeben",
"add_to_queue": "Zur Warteschlange hinzufügen",
"add_to_playlist": "Zur Playlist hinzufügen",
"download": "Herunterladen",
"downloaded": "Heruntergeladen",
"downloading": "Wird heruntergeladen...",
"cached": "Gecached",
"delete_download": "Download löschen",
"delete_cache": "Aus dem Cache löschen",
"go_to_artist": "Zum Künstler gehen",
"go_to_album": "Zum Album gehen",
"add_to_favorites": "Zu Favoriten hinzufügen",
"remove_from_favorites": "Aus Favoriten entfernen",
"remove_from_playlist": "Aus Playlist entfernen"
},
"playlists": {
"create_playlist": "Create Playlist",
"create_playlist": "Playlist erstellen",
"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"
"enter_name": "Playlist Name eingeben",
"create": "Erstellen",
"search_playlists": "Playlisten durchsuchen...",
"added_to": "Zu {{name}} hinzugefügt",
"added": "Zur Playlist hinzugefügt",
"removed_from": "Aus {{name}} entfernt",
"removed": "Aus Playlist entfernt",
"created": "Playlist erstellt",
"create_new": "Neue Playlist erstellen",
"failed_to_add": "Fehler beim Hinzufügen zur Playlist",
"failed_to_remove": "Fehler beim Entfernen aus der Playlist",
"failed_to_create": "Fehler beim Erstellen der Playlist",
"delete_playlist": "Playlist löschen",
"delete_confirm": "Bist Du sicher, dass Du \"{{name}}\" löschen möchtest? Das kann nicht rückgängig gemacht werden.",
"deleted": "Playlist gelöscht",
"failed_to_delete": "Fehler beim Löschen der Playlist"
},
"sort": {
"title": "Sort By",
"alphabetical": "Alphabetical",
"date_created": "Date Created"
"title": "Sortieren nach",
"alphabetical": "Alphabetisch",
"date_created": "Erstellungsdatum"
}
},
"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",
"title": "Merklisten",
"my_watchlists": "Meine Merklisten",
"public_watchlists": "Öffentliche Merklisten",
"create_title": "Merkliste erstellen",
"edit_title": "Merkliste bearbeiten",
"create_button": "Merkliste erstellen",
"save_button": "Änderungen speichern",
"delete_button": "Löschen",
"remove_button": "Entfernen",
"cancel_button": "Abbrechen",
"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"
"name_placeholder": "Merklistenname eingeben",
"description_label": "Beschreibung",
"description_placeholder": "Beschreibung eingeben (optional)",
"is_public_label": "Öffentliche Merkliste",
"is_public_description": "Anderen erlauben diese Merkliste anzusehen",
"allowed_type_label": "Inhaltstyp",
"sort_order_label": "Standard-Sortierreihenfolge",
"empty_title": "Keine Merklisten",
"empty_description": "Erstelle deine erste Merkliste um deine Medien zu organisieren",
"empty_watchlist": "Diese Merkliste ist leer",
"empty_watchlist_hint": "Füge Elemente aus deiner Bibliothek zu dieser Merkliste hinzu",
"not_configured_title": "Streamystats nicht konfiguriert",
"not_configured_description": "Streamystats in den Einstellungen konfigurieren, um Merklisten zu verwenden",
"go_to_settings": "Gehe zu Einstellungen",
"add_to_watchlist": "Zur Merkliste hinzufügen",
"remove_from_watchlist": "Von Merkliste entfernen",
"select_watchlist": "Merkliste auswählen",
"create_new": "Neue Merkliste erstellen",
"item": "Element",
"items": "Elemente",
"public": "Öffentlich",
"private": "Privat",
"you": "Du",
"by_owner": "Von einem anderen Benutzer",
"not_found": "Merkliste nicht gefunden",
"delete_confirm_title": "Merkliste löschen",
"delete_confirm_message": "Bist Du sicher, dass Du \"{{name}}\" löschen möchtest? Das kann nicht rückgängig gemacht werden.",
"remove_item_title": "Von Merkliste entfernen",
"remove_item_message": "\"{{name}}\" von dieser Merkliste entfernen?",
"loading": "Lade Merklisten...",
"no_compatible_watchlists": "Keine kompatiblen Merklisten",
"create_one_first": "Erstelle eine Merkliste, welche diesen Inhaltstyp akzeptiert"
},
"playback_speed": {
"title": "Playback Speed",
"apply_to": "Apply To",
"speed": "Speed",
"title": "Wiedergabegeschwindigkeit",
"apply_to": "Anwenden auf",
"speed": "Geschwindigkeit",
"scope": {
"media": "This media only",
"show": "This show",
"all": "All media (default)"
"media": "Nur hier",
"show": "Nur diese Serie",
"all": "Alle (Standard)"
}
}
}

View File

@@ -24,6 +24,82 @@
"too_old_server_text": "Unsupported Jellyfin Server Discovered",
"too_old_server_description": "Please update Jellyfin to the latest version"
},
"player": {
"skip_intro": "Skip Intro",
"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"
},
"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",
"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",
@@ -308,6 +384,21 @@
"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",
"disabled": "Disabled"
},
"downloads": {
@@ -590,26 +681,6 @@
"custom_links": {
"no_links": "No Links"
},
"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.",
"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"
},
"item_card": {
"next_up": "Next Up",
"no_items_to_display": "No Items to Display",

View File

@@ -39,39 +39,39 @@
"please_login_again": "Su sesión guardada ha caducado. Por favor, inicie sesión de nuevo.",
"remove_saved_login": "Eliminar inicio de sesión guardado",
"remove_saved_login_description": "Esto eliminará tus credenciales guardadas para este servidor. Tendrás que volver a introducir tu nombre de usuario y contraseña la próxima vez.",
"accounts_count": "{{count}} accounts",
"select_account": "Select Account",
"add_account": "Add Account",
"remove_account_description": "This will remove the saved credentials for {{username}}."
"accounts_count": "{{count}} cuentas",
"select_account": "Seleccione una cuenta",
"add_account": "Añadir cuenta",
"remove_account_description": "Esto eliminará las credenciales guardadas para {{username}}."
},
"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": "Guardar Cuenta",
"save_for_later": "Guardar esta cuenta",
"security_option": "Opciones de seguridad",
"no_protection": "Sin Protección",
"no_protection_desc": "Inicio de sesión rápido sin autenticación",
"pin_code": "Código PIN",
"pin_code_desc": "PIN de 4 dígitos requerido al cambiar",
"password": "Vuelva a introducir la contraseña",
"password_desc": "Contraseña requerida al cambiar",
"save_button": "Guardar",
"cancel_button": "Cancelar"
},
"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": "Introduce el PIN",
"enter_pin_for": "Introduzca el PIN para {{username}}",
"enter_4_digits": "Introduce 4 dígitos",
"invalid_pin": "PIN inválido",
"setup_pin": "Configurar PIN",
"confirm_pin": "Confirmar PIN",
"pins_dont_match": "Los códigos PIN no coinciden",
"forgot_pin": "¿Olvidó el PIN?",
"forgot_pin_desc": "Sus credenciales guardadas serán eliminadas"
},
"password": {
"enter_password": "Enter Password",
"enter_password_for": "Enter password for {{username}}",
"invalid_password": "Invalid password"
"enter_password": "Introduzca la contraseña",
"enter_password_for": "Introduzca la contraseña para {{username}}",
"invalid_password": "Contraseña inválida"
},
"home": {
"checking_server_connection": "Comprobando conexión con el servidor...",
@@ -124,32 +124,32 @@
"hide_remote_session_button": "Ocultar botón de sesión remota"
},
"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": "Cadena",
"local_network": "Red local",
"auto_switch_enabled": "Cambiar automáticamente en casa",
"auto_switch_description": "Cambiar automáticamente a la URL local cuando se conecta a la WiFi de casa",
"local_url": "URL local",
"local_url_hint": "Introduzca la dirección de su servidor local (por ejemplo, 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": "Redes WiFi domésticas",
"add_current_network": "Añadir \"{{ssid}}\"",
"not_connected_to_wifi": "No está conectado a WiFi",
"no_networks_configured": "No hay redes configuradas",
"add_network_hint": "Añade tu red WiFi doméstica para activar el cambio automático",
"current_wifi": "WiFi actual",
"using_url": "Utilizando",
"local": "URL local",
"remote": "URL Remota",
"not_connected": "Sin conexión",
"current_server": "Servidor actual",
"remote_url": "URL Remota",
"active_url": "URL Activa",
"not_configured": "Sin configurar",
"network_added": "Red añadida",
"network_already_added": "Red ya añadida",
"no_wifi_connected": "Sin conexión a WiFi",
"permission_denied": "Permiso de ubicación denegado",
"permission_denied_explanation": "Se necesita el permiso de ubicación para detectar la red WiFi para cambiar automáticamente. Por favor, actívala en Configuración."
},
"user_info": {
"user_info_title": "Información de usuario",
@@ -195,12 +195,12 @@
"none": "Ninguno",
"language": "Idioma",
"transcode_mode": {
"title": "Audio Transcoding",
"description": "Controls how surround audio (7.1, TrueHD, DTS-HD) is handled",
"title": "Transcodificación de audio",
"description": "Controla cómo el audio envolvente (7.1, TrueHD, DTS-HD) es manejado",
"auto": "Auto",
"stereo": "Force Stereo",
"5_1": "Allow 5.1",
"passthrough": "Passthrough"
"stereo": "Forzar salida estéreo",
"5_1": "Permitir 5.1",
"passthrough": "Directo"
}
},
"subtitles": {
@@ -259,16 +259,16 @@
"hardware_decode_description": "Utilizar la aceleración de hardware para la decodificación de vídeo. Deshabilite si experimenta problemas de reproducción."
},
"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": "Configuración de subtítulos VLC",
"hint": "Personalizar la apariencia de los subtítulos para el reproductor VLC. Los cambios tendrán efecto en la próxima reproducción.",
"text_color": "Color del texto",
"background_color": "Color del fondo",
"background_opacity": "Opacidad del fondo",
"outline_color": "Color del contorno",
"outline_opacity": "Opacidad del contorno",
"outline_thickness": "Grosor del contorno",
"bold": "Texto en negrita",
"margin": "Margen inferior"
},
"video_player": {
"title": "Reproductor de vídeo",
@@ -300,13 +300,13 @@
"VLC_4": "VLC 4 (Experimental + PiP)"
},
"show_custom_menu_links": "Mostrar enlaces de menú personalizados",
"show_large_home_carousel": "Show Large Home Carousel (beta)",
"show_large_home_carousel": "Mostrar carrusel del menú principal grande (beta)",
"hide_libraries": "Ocultar bibliotecas",
"select_liraries_you_want_to_hide": "Selecciona las bibliotecas que quieres ocultar de la pestaña Bibliotecas y de Inicio.",
"disable_haptic_feedback": "Desactivar feedback háptico",
"default_quality": "Calidad por defecto",
"default_playback_speed": "Velocidad de reproducción predeterminada",
"auto_play_next_episode": "Auto-play Next Episode",
"auto_play_next_episode": "Reproducir automáticamente el siguiente episodio",
"max_auto_play_episode_count": "Máximo número de episodios de Auto Play",
"disabled": "Deshabilitado"
},
@@ -317,10 +317,10 @@
"title": "Música",
"playback_title": "Reproducir",
"playback_description": "Configurar cómo se reproduce la música.",
"prefer_downloaded": "Prefer Downloaded Songs",
"prefer_downloaded": "Preferir las canciones descargadas",
"caching_title": "Almacenando en caché",
"caching_description": "Automatically cache upcoming tracks for smoother playback.",
"lookahead_enabled": "Enable Look-Ahead Caching",
"caching_description": "Cachear automáticamente las próximas canciones para una reproducción más suave.",
"lookahead_enabled": "Activar el look-Ahead Cache",
"lookahead_count": "",
"max_cache_size": "Tamaño máximo del caché"
},
@@ -399,7 +399,7 @@
"size_used": "{{used}} de {{total}} usado",
"delete_all_downloaded_files": "Eliminar todos los archivos descargados",
"music_cache_title": "Caché de música",
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
"music_cache_description": "Cachear automáticamente las canciones mientras escuchas una reproducción más suave y soporte sin conexión",
"enable_music_cache": "Activar Caché de Música",
"clear_music_cache": "Borrar Caché de Música",
"music_cache_size": "Caché {{Tamaño}}",
@@ -504,10 +504,10 @@
"delete": "Borrar",
"ok": "Aceptar",
"remove": "Eliminar",
"next": "Next",
"back": "Back",
"continue": "Continue",
"verifying": "Verifying..."
"next": "Siguiente",
"back": "Atrás",
"continue": "Continuar",
"verifying": "Verificando..."
},
"search": {
"search": "Buscar...",
@@ -753,8 +753,8 @@
"downloaded": "Descargado",
"downloading": "Descargando...",
"cached": "En caché",
"delete_download": "Delete Download",
"delete_cache": "Remove from Cache",
"delete_download": "Eliminar descarga",
"delete_cache": "Borrar del caché",
"go_to_artist": "Ir al artista",
"go_to_album": "Ir al álbum",
"add_to_favorites": "Añadir a Favoritos",

View File

@@ -305,8 +305,8 @@
"select_liraries_you_want_to_hide": "Sélectionnez les bibliothèques que vous souhaitez masquer dans l'onglet Bibliothèque et les sections de la page d'accueil.",
"disable_haptic_feedback": "Désactiver le retour haptique",
"default_quality": "Qualité par défaut",
"default_playback_speed": "Default Playback Speed",
"auto_play_next_episode": "Auto-play Next Episode",
"default_playback_speed": "Vitesse de lecture par défaut",
"auto_play_next_episode": "Lecture automatique de l'épisode suivant",
"max_auto_play_episode_count": "Nombre d'épisodes en lecture automatique max",
"disabled": "Désactivé"
},
@@ -314,15 +314,15 @@
"downloads_title": "Téléchargements"
},
"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": "Musique",
"playback_title": "Lecture",
"playback_description": "Configurer le mode de lecture de la musique.",
"prefer_downloaded": "Supprimer toutes les musiques téléchargées",
"caching_title": "Mise en cache",
"caching_description": "Mettre automatiquement en cache les pistes à venir pour une lecture plus fluide.",
"lookahead_enabled": "Activer la mise en cache guidée",
"lookahead_count": "Pistes à pré-mettre en cache",
"max_cache_size": "Taille max de cache"
},
"plugins": {
"plugins_title": "Plugins",
@@ -357,19 +357,19 @@
"save_button": "Enregistrer",
"toasts": {
"saved": "Enregistré",
"refreshed": "Settings refreshed from server"
"refreshed": "Paramètres actualisés depuis le serveur"
},
"refresh_from_server": "Refresh Settings from Server"
"refresh_from_server": "Rafraîchir les paramètres depuis le serveur"
},
"streamystats": {
"enable_streamystats": "Enable Streamystats",
"disable_streamystats": "Disable Streamystats",
"enable_search": "Use for Search",
"enable_streamystats": "Activer Streamystats",
"disable_streamystats": "Désactiver Streamystats",
"enable_search": "Utiliser pour la recherche",
"url": "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",
"streamystats_search_hint": "Entrez l'URL de votre serveur Streamystats. L'URL doit inclure http ou https et éventuellement le port.",
"read_more_about_streamystats": "En savoir plus sur Streamystats.",
"save_button": "Enregistrer",
"save": "Enregistrer",
"features_title": "Fonctionnalités",
"home_sections_title": "Sections de la page d´accueil",
@@ -572,7 +572,7 @@
"genres": "Genres",
"years": "Années",
"sort_by": "Trier par",
"filter_by": "Filter By",
"filter_by": "Filtrer par",
"sort_order": "Ordre de tri",
"tags": "Tags"
}
@@ -719,127 +719,127 @@
"favorites": "Favoris"
},
"music": {
"title": "Music",
"title": "Musique",
"tabs": {
"suggestions": "Suggestions",
"albums": "Albums",
"artists": "Artists",
"artists": "Artistes",
"playlists": "Playlists",
"tracks": "tracks"
"tracks": "morceaux"
},
"filters": {
"all": "All"
"all": "Toutes"
},
"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": "Ajoutés récemment",
"recently_played": "Récemment joué",
"frequently_played": "Fréquemment joué",
"explore": "Explorez",
"top_tracks": "Top chansons",
"play": "Lecture",
"shuffle": "Aléatoire",
"play_top_tracks": "Jouer les pistes les plus populaires",
"no_suggestions": "Pas de suggestion disponible",
"no_albums": "Pas d'albums trouvés",
"no_artists": "Pas d'artistes trouvé",
"no_playlists": "Pas de playlists trouvées",
"album_not_found": "Album introuvable",
"artist_not_found": "Artiste introuvable",
"playlist_not_found": "Playlist introuvable",
"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": "Lecture suivante",
"add_to_queue": "Ajouter à la file d'attente",
"add_to_playlist": "Ajouter à la playlist",
"download": "Télécharger",
"downloaded": "Téléchargé",
"downloading": "Téléchargement en cours...",
"cached": "En cache",
"delete_download": "Supprimer un téléchargement",
"delete_cache": "Supprimer du cache",
"go_to_artist": "Voir l'artiste",
"go_to_album": "Aller à lalbum",
"add_to_favorites": "Ajouter aux favoris",
"remove_from_favorites": "Retirer des favoris",
"remove_from_playlist": "Retirer de la 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": "Créer une Playlist",
"playlist_name": "Nom de la Playlist",
"enter_name": "Entrer le nom de la playlist",
"create": "Créer",
"search_playlists": "Rechercher des playlists...",
"added_to": "Ajouté à {{name}}",
"added": "Ajouté à la playlist",
"removed_from": "Retiré de {{name}}",
"removed": "Retiré de la playlist",
"created": "Playlist créée",
"create_new": "Créer une nouvelle playlist",
"failed_to_add": "Échec de l'ajout à la playlist",
"failed_to_remove": "Échec de la suppression de la playlist",
"failed_to_create": "Échec de la suppression de la playlist",
"delete_playlist": "Supprimer la playlist",
"delete_confirm": "Êtes-vous sûr de vouloir supprimer « {{ name }} » ? Cette action est irréversible.",
"deleted": "Playlist supprimée",
"failed_to_delete": "Échec de la suppression de la playlist"
},
"sort": {
"title": "Sort By",
"alphabetical": "Alphabetical",
"date_created": "Date Created"
"title": "Trier par",
"alphabetical": "Ordre alphabétique",
"date_created": "Date de création"
}
},
"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",
"public_watchlists": "Watchlist publique",
"create_title": "Créer une Watchlist",
"edit_title": "Modifier la Watchlist",
"create_button": "Créer une Watchlist",
"save_button": "Enregistrer les modifications",
"delete_button": "Supprimer",
"remove_button": "Retirer",
"cancel_button": "Annuler",
"name_label": "Nom",
"name_placeholder": "Entrer le nom de la playlist",
"description_label": "Description",
"description_placeholder": "Enter description (optional)",
"description_placeholder": "Entrez la description (facultatif)",
"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"
"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",
"empty_title": "Pas de Watchlists",
"empty_description": "Créez votre première liste de suivi pour commencer à organiser vos médias",
"empty_watchlist": "Cette liste de suivi est vide",
"empty_watchlist_hint": "Ajouter des éléments de votre bibliothèque à cette liste de suivi",
"not_configured_title": "Streamystats non configuré",
"not_configured_description": "Configurer Streamystats dans les paramètres pour utiliser les listes de suivi",
"go_to_settings": "Accédez aux Paramètres",
"add_to_watchlist": "Ajouter à la Watchlist",
"remove_from_watchlist": "Retirer de la Watchlist",
"select_watchlist": "Sélectionner la liste de suivi",
"create_new": "Créer une Watchlist",
"item": "médias",
"items": "élément",
"public": "Publique",
"private": "Privée",
"you": "Vous-même",
"by_owner": "Par un autre utilisateur",
"not_found": "Playlist introuvable",
"delete_confirm_title": "Supprimer la Watchlist",
"delete_confirm_message": "Tous les médias (par défaut)",
"remove_item_title": "Retirer de la Watchlist",
"remove_item_message": "Retirer «{{name}}» de cette liste de suivi?",
"loading": "Chargement des listes de suivi...",
"no_compatible_watchlists": "Aucune liste de suivi compatible",
"create_one_first": "Créer une liste de suivi qui accepte ce type de contenu"
},
"playback_speed": {
"title": "Playback Speed",
"apply_to": "Apply To",
"speed": "Speed",
"title": "Vitesse de lecture",
"apply_to": "Appliquer à",
"speed": "Vitesse",
"scope": {
"media": "This media only",
"show": "This show",
"all": "All media (default)"
"media": "Ce média uniquement",
"show": "Cette série",
"all": "Tous les médias (par défaut)"
}
}
}

View File

@@ -34,9 +34,9 @@
"search_for_local_servers": "Ricerca dei server locali",
"searching": "Cercando...",
"servers": "Server",
"saved": "Saved",
"saved": "Salvato",
"session_expired": "Session Expired",
"please_login_again": "Your saved session has expired. Please log in again.",
"please_login_again": "La tua sessione è scaduta. Si prega di eseguire nuovamente l'accesso.",
"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",
@@ -125,7 +125,7 @@
},
"network": {
"title": "Network",
"local_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",
@@ -137,7 +137,7 @@
"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",
"using_url": "Sta utilizzando",
"local": "Local URL",
"remote": "Remote URL",
"not_connected": "Not connected",

View File

@@ -30,7 +30,7 @@
"connect_button": "Verbinden",
"previous_servers": "vorige servers",
"clear_button": "Wissen",
"swipe_to_remove": "Swipe to remove",
"swipe_to_remove": "Swipe om te verwijderen.",
"search_for_local_servers": "Zoek naar lokale servers",
"searching": "Zoeken...",
"servers": "Servers",
@@ -40,38 +40,38 @@
"remove_saved_login": "Opgeslagen login verwijderen",
"remove_saved_login_description": "Hiermee worden uw opgeslagen gegevens voor deze server verwijderd. U moet uw gebruikersnaam en wachtwoord de volgende keer opnieuw invoeren.",
"accounts_count": "{{count}} accounts",
"select_account": "Select Account",
"add_account": "Add Account",
"remove_account_description": "This will remove the saved credentials for {{username}}."
"select_account": "Account selecteren",
"add_account": "Account toevoegen",
"remove_account_description": "Hiermee worden de opgeslagen inloggegevens voor {{username}} verwijderd."
},
"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": "Account opslaan",
"save_for_later": "Dit account opslaan",
"security_option": "Beveiligingsopties",
"no_protection": "Geen beveiliging",
"no_protection_desc": "Snelle login zonder authenticatie",
"pin_code": "Pincode",
"pin_code_desc": "4-cijferige pincode vereist bij wisselen",
"password": "Wachtwoord opnieuw invoeren",
"password_desc": "Wachtwoord vereist bij wisselen",
"save_button": "Opslaan",
"cancel_button": "Annuleren"
},
"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": "Pincode invoeren",
"enter_pin_for": "Pincode voor {{username}} invoeren",
"enter_4_digits": "Voer 6 cijfers in",
"invalid_pin": "Ongeldige pincode",
"setup_pin": "Pincode instellen",
"confirm_pin": "Pincode bevestigen",
"pins_dont_match": "Pincodes komen niet overeen",
"forgot_pin": "Pincode vergeten?",
"forgot_pin_desc": "Je opgeslagen inloggegevens worden verwijderd"
},
"password": {
"enter_password": "Enter Password",
"enter_password_for": "Enter password for {{username}}",
"invalid_password": "Invalid password"
"enter_password": "Voer wachtwoord in",
"enter_password_for": "Voer wachtwoord voor {{username}} in",
"invalid_password": "Ongeldig wachtwoord"
},
"home": {
"checking_server_connection": "Serververbinding controleren...",
@@ -84,7 +84,7 @@
"server_unreachable": "Server onbereikbaar",
"server_unreachable_message": "Kon de server niet bereiken.\nControleer uw netwerkverbinding.",
"oops": "Oeps!",
"error_message": "Er ging iets fout\nGelieve af en aan te melden.",
"error_message": "Er ging iets fout\nProbeer opnieuw in te loggen.",
"continue_watching": "Verder Kijken",
"next_up": "Volgende",
"continue_and_next_up": "Doorgaan & Volgende",
@@ -124,32 +124,32 @@
"hide_remote_session_button": "Verberg Knop voor Externe Sessie"
},
"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": "Netwerk",
"local_network": "Lokaal netwerk",
"auto_switch_enabled": "Automatisch wisselen wanneer thuis",
"auto_switch_description": "Automatisch wisselen naar lokale URL wanneer verbonden met thuisnetwerk",
"local_url": "Lokale URL",
"local_url_hint": "Voer uw lokale serveradres in (bijv. 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": "Wi-Fi netwerken",
"add_current_network": "Voeg \"{{ssid}} \" toe",
"not_connected_to_wifi": "Niet verbonden met Wi-Fi",
"no_networks_configured": "Geen netwerken geconfigureerd",
"add_network_hint": "Voeg je thuisnetwerk toe om automatisch wisselen in te schakelen",
"current_wifi": "Huidige Wi-Fi",
"using_url": "Gebruik makend van",
"local": "Lokale URL",
"remote": "Externe URL",
"not_connected": "Niet verbonden",
"current_server": "Huidige Server",
"remote_url": "Externe URL",
"active_url": "Actieve URL",
"not_configured": "Niet geconfigureerd",
"network_added": "Netwerk toegevoegd",
"network_already_added": "Netwerk reeds toegevoegd",
"no_wifi_connected": "Niet verbonden met Wi-Fi",
"permission_denied": "Locatie toestemming geweigerd",
"permission_denied_explanation": "Locatie permissie is vereist om Wifi-netwerk te kunnen detecteren voor automatisch wisselen. Schakel het in via Instellingen."
},
"user_info": {
"user_info_title": "Gebruiker Info",
@@ -195,11 +195,11 @@
"none": "Geen",
"language": "Taal",
"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",
"title": "Audio-transcoding",
"description": "Bepaalt hoe surround audio (7.1, TrueHD, DTS-HD) wordt behandeld",
"auto": "Automatisch",
"stereo": "Stereo forceren",
"5_1": "5.1 toestaan",
"passthrough": "Passthrough"
}
},
@@ -231,7 +231,7 @@
"Black": "Zwart",
"Gray": "Grijs",
"Silver": "Zilver",
"White": "wit",
"White": "Wit",
"Maroon": "Kastanjebruin",
"Red": "Rood",
"Fuchsia": "Fuchsia",
@@ -259,14 +259,14 @@
"hardware_decode_description": "Gebruik hardware acceleratie voor video-decodering. Uitschakelen als u problemen met afspelen ondervindt."
},
"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",
"title": "VLC ondertitel instellingen",
"hint": "Aanpassen van ondertiteling voor VLC-speler. Wijzigingen worden toegepast bij het afspelen.",
"text_color": "Tekstkleur",
"background_color": "Achtergrondkleur",
"background_opacity": "Doorzichtigheid achtergrond",
"outline_color": "Kleur omlijning",
"outline_opacity": "Omtrek opaciteit",
"outline_thickness": "Omtrek dikte",
"bold": "Bold Text",
"margin": "Bottom Margin"
},
@@ -306,7 +306,7 @@
"disable_haptic_feedback": "Haptische feedback uitschakelen",
"default_quality": "Standaard kwaliteit",
"default_playback_speed": "Standaard Afspeelsnelheid",
"auto_play_next_episode": "Auto-play Next Episode",
"auto_play_next_episode": "Volgende aflevering automatisch afspelen",
"max_auto_play_episode_count": "Max Automatisch Aflevering Aantal",
"disabled": "Uitgeschakeld"
},
@@ -378,12 +378,12 @@
"enable_promoted_watchlists": "Gepromote Kijklijst",
"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",
"recommended_movies": "Aanbevolen films",
"recommended_series": "Aanbevolen serie",
"toasts": {
"saved": "Saved",
"saved": "Opgeslagen",
"refreshed": "Settings refreshed from server",
"disabled": "Streamystats disabled"
"disabled": "Streamystats uitgeschakeld"
},
"refresh_from_server": "Refresh Settings from Server"
},
@@ -402,24 +402,24 @@
"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",
"music_cache_size": "{{size}} gecached",
"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"
},
"intro": {
"title": "Intro",
"show_intro": "Toon intro",
"reset_intro": "intro opnieuw instellen"
"reset_intro": "Reset Intro"
},
"logs": {
"logs_title": "Logboek",
"export_logs": "Export logs",
"click_for_more_info": "Click for more info",
"click_for_more_info": "Klik voor meer info",
"level": "Niveau",
"no_logs_available": "Geen logs beschikbaar",
"delete_all_logs": "Verwijder alle logs"
"delete_all_logs": "Alle logs verwijderen"
},
"languages": {
"title": "Talen",
@@ -500,14 +500,14 @@
"play": "Afspelen",
"none": "Geen",
"track": "Spoor",
"cancel": "Cancel",
"delete": "Delete",
"ok": "OK",
"remove": "Remove",
"next": "Next",
"back": "Back",
"continue": "Continue",
"verifying": "Verifying..."
"cancel": "Annuleren",
"delete": "Verwijderen",
"ok": "O",
"remove": "Verwijderen",
"next": "Volgende",
"back": "Terug",
"continue": "Doorgaan",
"verifying": "Verifiëren..."
},
"search": {
"search": "Zoek...",
@@ -521,10 +521,10 @@
"episodes": "Afleveringen",
"collections": "Collecties",
"actors": "Acteurs",
"artists": "Artists",
"artists": "Artiesten",
"albums": "Albums",
"songs": "Songs",
"playlists": "Playlists",
"songs": "Nummers",
"playlists": "Afspeellijsten",
"request_movies": "Vraag films aan",
"request_series": "Vraag series aan",
"recently_added": "Recent Toegevoegd",
@@ -572,7 +572,7 @@
"genres": "Genres",
"years": "Jaren",
"sort_by": "Sorteren op",
"filter_by": "Filter By",
"filter_by": "Filteren op",
"sort_order": "Sorteer volgorde",
"tags": "Labels"
}
@@ -719,127 +719,127 @@
"favorites": "Favorieten"
},
"music": {
"title": "Music",
"title": "Muziek",
"tabs": {
"suggestions": "Suggestions",
"suggestions": "Suggesties",
"albums": "Albums",
"artists": "Artists",
"playlists": "Playlists",
"tracks": "tracks"
"artists": "Artiesten",
"playlists": "Afspeellijsten",
"tracks": "Nummers"
},
"filters": {
"all": "All"
"all": "Alle"
},
"recently_added": "Recently Added",
"recently_played": "Recently Played",
"frequently_played": "Frequently Played",
"explore": "Explore",
"recently_added": "Recent toegevoegd",
"recently_played": "Onlangs afgespeeld",
"frequently_played": "Vaak afgespeeld",
"explore": "Ontdek",
"top_tracks": "Top Tracks",
"play": "Play",
"play": "Afspelen",
"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",
"no_suggestions": "Geen suggesties beschikbaar",
"no_albums": "Geen albums gevonden",
"no_artists": "Geen artiesten gevonden",
"no_playlists": "Geen afspeellijsten gevonden",
"album_not_found": "Album niet gevonden",
"artist_not_found": "Artiest niet gevonden",
"playlist_not_found": "Afspeellijst niet gevonden",
"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": "Speel volgende af",
"add_to_queue": "Toevoegen aan wachtrij",
"add_to_playlist": "Voeg toe aan afspeellijst",
"download": "Downloaden",
"downloaded": "Gedownload",
"downloading": "Downloaden...",
"cached": "Gecached",
"delete_download": "Download verwijderen",
"delete_cache": "Verwijderen uit cache",
"go_to_artist": "Ga naar artiest",
"go_to_album": "Ga naar album",
"add_to_favorites": "Toevoegen aan favorieten",
"remove_from_favorites": "Verwijderen uit favorieten",
"remove_from_playlist": "Verwijder uit afspeellijst"
},
"playlists": {
"create_playlist": "Create Playlist",
"playlist_name": "Playlist Name",
"create_playlist": "Afspeellijst aanmaken",
"playlist_name": "Afspeellijst naam",
"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",
"create": "Aanmaken",
"search_playlists": "Playlist zoeken...",
"added_to": "Toegevoegd aan {{name}}",
"added": "Toegevoegd aan playlist",
"removed_from": "Verwijderd uit {{name}}",
"removed": "Verwijderd uit 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"
"failed_to_remove": "Verwijderen uit afspeellijst is mislukt",
"failed_to_create": "Het maken van de afspeellijst is mislukt",
"delete_playlist": "Afspeellijst verwijderen",
"delete_confirm": "Weet u zeker dat u \"{{name}}\"wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.",
"deleted": "Afspeellijst verwijderd.",
"failed_to_delete": "Verwijderen van afspeellijst mislukt"
},
"sort": {
"title": "Sort By",
"alphabetical": "Alphabetical",
"date_created": "Date Created"
"title": "Sorteren op",
"alphabetical": "Alfabetisch",
"date_created": "Aanmaakdatum"
}
},
"watchlists": {
"title": "Watchlists",
"my_watchlists": "My Watchlists",
"title": "Watchlist",
"my_watchlists": "Mijn 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",
"save_button": "Wijzigingen opslaan",
"delete_button": "Verwijder",
"remove_button": "Verwijderen",
"cancel_button": "Annuleren",
"name_label": "Naam",
"name_placeholder": "Voer naam van kijklijst in",
"description_label": "Beschrijving",
"description_placeholder": "Voer beschrijving in (optioneel)",
"is_public_label": "Openbare Kijklijst",
"is_public_description": "Sta anderen toe om deze kijklijst te bekijken",
"allowed_type_label": "Inhoudstype",
"sort_order_label": "Standaard Sortering",
"empty_title": "Geen Kijklijsten",
"empty_description": "Maak je eerste kijklijst om je media te organiseren",
"empty_watchlist": "Deze watchlist is leeg",
"empty_watchlist_hint": "Voeg items uit je bibliotheek toe aan deze kijklijst",
"not_configured_title": "Streamystats niet geconfigureerd",
"not_configured_description": "Configureer Streamystats in instellingen om kijklijsten te gebruiken",
"go_to_settings": "Ga naar Instellingen",
"add_to_watchlist": "Voeg toe aan kijklijst",
"remove_from_watchlist": "Verwijder van kijklijst",
"select_watchlist": "Selecteer kijklijst",
"create_new": "Nieuwe kijklijst aanmaken",
"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"
"public": "Publiek",
"private": "Privé",
"you": "Jij",
"by_owner": "Door een andere gebruiker",
"not_found": "Kijklijst niet gevonden",
"delete_confirm_title": "Verwijder kijklijst",
"delete_confirm_message": "Weet u zeker dat u \"{{name}}\"wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.",
"remove_item_title": "Verwijder van watchlist",
"remove_item_message": "Verwijder \"{{name}}\" uit deze watchlist?",
"loading": "Laden van watchlists...",
"no_compatible_watchlists": "Geen compatibele watchlist",
"create_one_first": "Maak een watchlist aan die dit inhoudstype accepteert"
},
"playback_speed": {
"title": "Playback Speed",
"apply_to": "Apply To",
"speed": "Speed",
"title": "Afspeelsnelheid",
"apply_to": "Toepassen op",
"speed": "Snelheid",
"scope": {
"media": "This media only",
"show": "This show",
"all": "All media (default)"
"media": "Alleen deze media",
"show": "Deze serie",
"all": "Alle media (standaard)"
}
}
}

View File

@@ -34,44 +34,44 @@
"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}} будут удалены."
},
"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": "Введите 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": "Проверка соединения с сервером...",
@@ -82,12 +82,12 @@
"go_to_downloads": "В загрузки",
"retry": "Повторить",
"server_unreachable": "Сервер недоступен",
"server_unreachable_message": "Could not reach the server.\nPlease check your network connection.",
"server_unreachable_message": "Не удалось соединиться с сервером.\nПожалуйста, проверьте настройки сети.",
"oops": "Упс!",
"error_message": "Что-то пошло не так.\nПожалуйста выйдите и зайдите снова.",
"continue_watching": "Продолжить просмотр",
"next_up": "Следующее",
"continue_and_next_up": "Continue & Next Up",
"continue_watching": "Продолжить",
"next_up": "Далее",
"continue_and_next_up": "Продолжить и Далее",
"recently_added_in": "Недавно добавлено в {{libraryName}}",
"suggested_movies": "Предложенные фильмы",
"suggested_episodes": "Предложенные серии",
@@ -110,46 +110,46 @@
"settings_title": "Настройки",
"log_out_button": "Выйти",
"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": "Скрыть кнопку «Удалённый сеанс»"
},
"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": "Локальный URL",
"local_url_hint": "Введите локальный URL вашего сервера (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": "Локальный",
"remote": "Внешний",
"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": "Разрешение на доступ к местоположению обязательно для обнаружения WiFi сети при автоматическом переключении. Пожалуйста, включите его в настройках."
},
"user_info": {
"user_info_title": "Информация о пользователе",
@@ -170,22 +170,22 @@
},
"media_controls": {
"media_controls_title": "Медиа-контроль",
"forward_skip_length": "Длина пропуска вперед",
"rewind_length": "Длина перемотки",
"forward_skip_length": "Шаг перемотки вперёд",
"rewind_length": "Шаг перемотки назад",
"seconds_unit": "c"
},
"gesture_controls": {
"gesture_controls_title": "Управление жестами",
"horizontal_swipe_skip": "Горизонтальный свайп, чтобы пропустить",
"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",
"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,17 +195,17 @@
"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": {
"subtitle_title": "Субтитры",
"subtitle_hint": "Настроить субтитры.",
"subtitle_hint": "Настройки отображения субтитров",
"subtitle_language": "Язык субтитров",
"subtitle_mode": "Режим субтитров",
"set_subtitle_track": "Устанавливать субтитры из предыдущего элемента",
@@ -226,24 +226,24 @@
"outline_thickness": "Толщина контура",
"background_opacity": "Прозрачность фона",
"outline_opacity": "Прозрачность контура",
"bold_text": "Bold Text",
"bold_text": "Жирный",
"colors": {
"Black": "Черный",
"Gray": "Серый",
"Silver": "Серебряный",
"Silver": "Серебристый",
"White": "Белый",
"Maroon": "Марун",
"Maroon": "Бордовый",
"Red": "Красный",
"Fuchsia": "Fuchsia",
"Fuchsia": "Пурпурный",
"Yellow": "Жёлтый",
"Olive": "Олив",
"Olive": "Оливковый",
"Green": "Зелёный",
"Teal": "Бирюзовый",
"Lime": "Лаймовый",
"Purple": "Фиолетовый",
"Navy": "Тёмно-синий",
"Blue": "Синий",
"Aqua": "Акваа"
"Aqua": "Голубой"
},
"thickness": {
"None": "Отсутствует",
@@ -251,29 +251,29 @@
"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": "Использовать аппаратное ускорение для декодирования видео. Выключите, если наблюдаете проблемы с воспроизведением."
},
"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"
},
@@ -294,19 +294,19 @@
"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_large_home_carousel": "Show Large Home Carousel (beta)",
"show_large_home_carousel": "Показывать большую карусель (beta)",
"hide_libraries": "Скрыть библиотеки",
"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 +314,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 +357,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",
"enable_streamystats": "Включить Streamystats",
"disable_streamystats": "Выключить Streamystats",
"enable_search": "Использовать в поиске",
"url": "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": "Введите URL вашего сервера Streamystats. URL должен включать 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,18 +398,18 @@
"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": "Скачанные песни удалены"
},
"intro": {
"title": "Intro",
"title": "Вступление",
"show_intro": "Показать вступление",
"reset_intro": "Сбросить вступление"
},
@@ -441,24 +441,24 @@
"tvseries": "Сериалы",
"movies": "Фильмы",
"queue": "Очередь",
"other_media": "Другие медиа",
"queue_hint": "Очередь и загрузки будут удалены при перезагрузке приложения",
"other_media": "Прочие файлы",
"queue_hint": "Очередь очистится после перезапуска",
"no_items_in_queue": "Нет элементов в очереди",
"no_downloaded_items": "Нет загруженых предметов",
"no_downloaded_items": "Нет загруженных файлов",
"delete_all_movies_button": "Удалить все фильмы",
"delete_all_tvseries_button": "Удалить все сериалы",
"delete_all_button": "Удалить все",
"delete_all_other_media_button": "Удалить другой материал",
"active_download": "Активно загружается",
"delete_all_other_media_button": "Удалить прочие файлы",
"active_download": "Загружается",
"no_active_downloads": "Нет активных загрузок",
"active_downloads": "Активные загрузки",
"active_downloads": "Активные",
"new_app_version_requires_re_download": "Новая версия приложения требует повторной загрузки",
"new_app_version_requires_re_download_description": "Новая версия приложения требует повторной загрузки. Пожалуйста удалите всё и попробуйте заново.",
"back": "Назад",
"delete": "Удалить",
"something_went_wrong": "Что-то пошло не так",
"could_not_get_stream_url_from_jellyfin": "Не удалось получить ссылку трансляции из Jellyfin",
"eta": "ETA {{eta}}",
"eta": "Осталось {{eta}}",
"toasts": {
"you_are_not_allowed_to_download_files": "Нет разрешения на скачивание файлов.",
"deleted_all_movies_successfully": "Все фильмы были успешно удалены!",
@@ -467,64 +467,64 @@
"failed_to_delete_all_tvseries": "Возникла ошибка при удалении всех сериалов",
"deleted_media_successfully": "Другие носители успешно удалены!",
"failed_to_delete_media": "Не удалось удалить другой файл",
"download_deleted": "Загрузка удалена",
"download_deleted": "Удалено",
"download_cancelled": "Загрузка отменена",
"could_not_delete_download": "Не удалось удалить загрузку",
"download_paused": "Загрузка приостановлена",
"download_paused": "На паузе",
"could_not_pause_download": "Не удалось приостановить загрузку",
"download_resumed": "Загрузка возобновлена",
"download_resumed": "Продолжено",
"could_not_resume_download": "Не удалось продолжить загрузку",
"download_completed": "Загрузка завершена",
"download_failed": "Download Failed",
"download_completed": "Завершено",
"download_failed": "Не удалось загрузить",
"download_failed_for_item": "Загрузка {{item}} провалилась с ошибкой: {{error}}",
"download_completed_for_item": "{{item}} успешно загружен",
"download_started_for_item": "Загрузка началась для {{item}}",
"failed_to_start_download": "Не удалось начать загрузку",
"item_already_downloading": "{{item}} is already downloading",
"all_files_deleted": "All Downloads Deleted Successfully",
"files_deleted_by_type": "{{count}} {{type}} deleted",
"item_already_downloading": "{{item}} уже загружается",
"all_files_deleted": "Все загрузки удалены",
"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}}",
"go_to_downloads": "В загрузки",
"file_deleted": "{{item}} deleted"
"file_deleted": "{{item}} удалён"
}
}
},
"common": {
"select": "Выбрать",
"no_trailer_available": "Прицеп недоступен",
"no_trailer_available": "Трейлер недоступен",
"video": "Видео",
"audio": "Звук",
"subtitle": "Субтитры",
"play": "Играть",
"none": "None",
"track": "Track",
"cancel": "Cancel",
"delete": "Delete",
"ok": "OK",
"remove": "Remove",
"next": "Next",
"back": "Back",
"continue": "Continue",
"verifying": "Verifying..."
"play": "Воспроизвести",
"none": "Отсутствует",
"track": "Трек",
"cancel": "Отмена",
"delete": "Удалить",
"ok": "ОК",
"remove": "Удалить",
"next": "Вперед",
"back": "Назад",
"continue": "Продолжить",
"verifying": "Проверка..."
},
"search": {
"search": "Поиск...",
"x_items": "{{count}} предметов",
"x_items": "{{count}} элементов",
"library": "Библиотека",
"discover": "Найти новое",
"no_results": "Нет результатов",
"no_results_found_for": "Не было результатов при поиске",
"no_results": "Ничего не найдено",
"no_results_found_for": "Ничего не найдено по запросу",
"movies": "Фильмы",
"series": "Сериалы",
"episodes": "Серии",
"collections": "Коллекции",
"actors": "Актеры",
"artists": "Artists",
"albums": "Albums",
"songs": "Songs",
"playlists": "Playlists",
"artists": "Артисты",
"albums": "Альбомы",
"songs": "Песни",
"playlists": "Плейлисты",
"request_movies": "Запросить фильмы",
"request_series": "Запросить сериалы",
"recently_added": "Недавно добавлено",
@@ -553,7 +553,7 @@
"no_results": "Нет результатов",
"no_libraries_found": "Библиотеки не найдены",
"item_types": {
"movies": "фильмы",
"movies": "Фильмы",
"series": "Сериалы",
"boxsets": "Коллекции",
"items": "элементы"
@@ -571,9 +571,9 @@
"filters": {
"genres": "Жанры",
"years": "Года",
"sort_by": "Сортировать по",
"filter_by": "Filter By",
"sort_order": "Порядок сортировки",
"sort_by": "Сортировка",
"filter_by": "Фильтр",
"sort_order": "Порядок",
"tags": "Тэги"
}
},
@@ -604,14 +604,14 @@
"index": "Индекс:",
"continue_watching": "Продолжить просмотр",
"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_title": "Этот файл уже скачан",
"downloaded_file_message": "Хотите воспроизвести скачанный файл?",
"downloaded_file_yes": "Да",
"downloaded_file_no": "Нет",
"downloaded_file_cancel": "Отмена"
},
"item_card": {
"next_up": "Следующее",
"next_up": "Далее",
"no_items_to_display": "Нет элементов для отображения",
"cast_and_crew": "Актеры и съемочная группа",
"series": "Серии",
@@ -644,7 +644,7 @@
}
},
"live_tv": {
"next": "Следующая",
"next": "Далее",
"previous": "Предыдущая",
"coming_soon": "Скоро",
"on_now": "Сейчас в эфире",
@@ -675,7 +675,7 @@
"series_type": "Тип сериала",
"release_dates": "Дата релиза",
"first_air_date": "Первая дата выхода в эфир",
"next_air_date": "Следующая дата выхода в эфир",
"next_air_date": "Ближайшая дата выхода в эфир",
"revenue": "Прибыль",
"budget": "Бюджет",
"original_language": "Оригинальный язык",
@@ -693,10 +693,10 @@
"number_episodes": "{{episode_number}} серий",
"born": "Рожден",
"appearances": "Появления",
"approve": "Approve",
"decline": "Decline",
"requested_by": "Requested by {{user}}",
"unknown_user": "Unknown User",
"approve": "Одобрить",
"decline": "Отклонить",
"requested_by": "Запрошено {{user}}",
"unknown_user": "Неизвестный пользователь",
"toasts": {
"jellyseer_does_not_meet_requirements": "Сервер Jellyseerr не соответствует минимальным требованиям версии! Пожалуйста, обновите до версии не ниже 2.0.0",
"jellyseerr_test_failed": "Тест Jellyseerr не пройден. Попробуйте еще раз.",
@@ -705,141 +705,141 @@
"requested_item": "Запрошено {{item}}!",
"you_dont_have_permission_to_request": "У вас нет разрешения на запрос!",
"something_went_wrong_requesting_media": "Что-то пошло не так при запросе медиафайлов!",
"request_approved": "Request Approved!",
"request_declined": "Request Declined!",
"failed_to_approve_request": "Failed to Approve Request",
"failed_to_decline_request": "Failed to Decline Request"
"request_approved": "Запрос одобрен!",
"request_declined": "Запрос отклонён!",
"failed_to_approve_request": "Не удалось одобрить запрос",
"failed_to_decline_request": "Не удалось отклонить запрос"
}
},
"tabs": {
"home": "Дом",
"home": "Главная",
"search": "Поиск",
"library": "Библиотека",
"custom_links": "Кастомные ссылки",
"custom_links": "Ссылки",
"favorites": "Избранное"
},
"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": "Ко всем файлам (по умолчанию)"
}
}
}

View File

@@ -7,88 +7,88 @@
"username_placeholder": "Kullanıcı adı",
"password_placeholder": "Şifre",
"login_button": "Giriş yap",
"quick_connect": "Hızlı Bağlantı",
"quick_connect": "Hızlı Bağlan",
"enter_code_to_login": "Giriş yapmak için {{code}} kodunu girin",
"failed_to_initiate_quick_connect": "Quick Connect başlatılamadı",
"failed_to_initiate_quick_connect": "Hızlı Bağlan başlatılamadı",
"got_it": "Anlaşıldı",
"connection_failed": "Bağlantı başarısız",
"could_not_connect_to_server": "Sunucuya bağlanılamadı. Lütfen URL'yi ve ağ bağlantınızı kontrol edin",
"could_not_connect_to_server": "Sunucuya bağlanılamadı. Lütfen URL'yi ve ağ bağlantınızı kontrol edin.",
"an_unexpected_error_occured": "Beklenmedik bir hata oluştu",
"change_server": "Sunucuyu değiştir",
"change_server": "Sunucu değiştir",
"invalid_username_or_password": "Geçersiz kullanıcı adı veya şifre",
"user_does_not_have_permission_to_log_in": "Kullanıcının giriş yapma izni yok",
"server_is_taking_too_long_to_respond_try_again_later": "Sunucu yanıt vermekte çok uzun sürüyor, lütfen tekrar deneyin",
"server_received_too_many_requests_try_again_later": "Sunucu çok fazla istek aldı, lütfen tekrar deneyin.",
"server_is_taking_too_long_to_respond_try_again_later": "Sunucunun yanıt vermesi çok uzun sürüyor, lütfen daha sonra tekrar deneyin",
"server_received_too_many_requests_try_again_later": "Sunucu çok fazla istek aldı, lütfen daha sonra tekrar deneyin.",
"there_is_a_server_error": "Sunucu hatası var",
"an_unexpected_error_occured_did_you_enter_the_correct_url": "Beklenmedik bir hata oluştu. Sunucu URL'sini doğru girdiğinizden emin oldunuz mu?",
"too_old_server_text": "Unsupported Jellyfin Server Discovered",
"too_old_server_description": "Please update Jellyfin to the latest version"
"an_unexpected_error_occured_did_you_enter_the_correct_url": "Beklenmedik bir hata oluştu. Sunucu URL'sini doğru girdiğinizden emin misiniz?",
"too_old_server_text": "Desteklenmeyen Jellyfin Sunucu sürümü bulundu.",
"too_old_server_description": "Lütfen Jellyfin'i en son sürüme güncelleyin."
},
"server": {
"enter_url_to_jellyfin_server": "Jellyfin sunucunusun URL'sini girin",
"enter_url_to_jellyfin_server": "Jellyfin sunucunusun URL adresini girin",
"server_url_placeholder": "http(s)://sunucunuz.com",
"connect_button": "Bağlan",
"previous_servers": "Önceki sunucular",
"clear_button": "Temizle",
"swipe_to_remove": "Swipe to remove",
"swipe_to_remove": "Kaldırmak için kaydırın",
"search_for_local_servers": "Yerel sunucuları ara",
"searching": "Aranıyor...",
"servers": "Sunucular",
"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": "Kaydedildi",
"session_expired": "Oturum süresi doldu",
"please_login_again": "Kaydedilmiş oturumunuzun süresi doldu. Lütfen tekrar giriş yapın.",
"remove_saved_login": "Kayıtlı oturumu kaldır",
"remove_saved_login_description": "Bu sunucu için kaydedilmiş kimlik bilgileriniz kaldırılacaktır. Bir sonraki sefere kullanıcı adı ve şifrenizi yeniden girmeniz gerekecek.",
"accounts_count": "{{count}} hesap",
"select_account": "Hesap Seç",
"add_account": "Hesap Ekle",
"remove_account_description": "{{username}} için kayıtlı bilgiler kaldırılacaktır."
},
"save_account": {
"title": "Save Account",
"save_for_later": "Save this account",
"security_option": "Security Option",
"title": "Hesabı Kaydet",
"save_for_later": "Bu hesabı kaydet",
"security_option": "Güvenlik Seçeneği",
"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"
"no_protection_desc": "Kimlik doğrulamasız hızlı giriş",
"pin_code": "PIN kodu",
"pin_code_desc": "Geçiş yaparken 4 haneli PIN kodu gereklidir",
"password": "Şifrenizi tekrar girin ",
"password_desc": "Geçiş yaparken şifre gereklidir",
"save_button": "Kaydet",
"cancel_button": "Vazgeç"
},
"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 kodunu girin",
"enter_pin_for": "{{username}} için PIN kodunu girin",
"enter_4_digits": "4 hane girin",
"invalid_pin": "Geçersiz PIN kodu",
"setup_pin": "PIN kodunu ayarla",
"confirm_pin": "PIN kodunu onayla",
"pins_dont_match": "PIN kodları eşleşmiyor",
"forgot_pin": "PIN kodunu mu unuttunuz?",
"forgot_pin_desc": "Kayıtlı bilgileriniz kaldırılacaktır"
},
"password": {
"enter_password": "Enter Password",
"enter_password_for": "Enter password for {{username}}",
"invalid_password": "Invalid password"
"enter_password": "Şifrenizi girin",
"enter_password_for": "{{username}} için şifrenizi girin",
"invalid_password": "Geçersiz şifre"
},
"home": {
"checking_server_connection": "Checking server connection...",
"checking_server_connection": "Sunucu bağlantısı kontrol ediliyor...",
"no_internet": "İnternet Yok",
"no_items": "Öge Yok",
"no_internet_message": "Endişelenmeyin, hala\ndownloaded içerik izleyebilirsiniz.",
"checking_server_connection_message": "Checking connection to server",
"go_to_downloads": "İndirmelere Git",
"retry": "Retry",
"server_unreachable": "Server Unreachable",
"server_unreachable_message": "Could not reach the server.\nPlease check your network connection.",
"no_internet_message": "Endişelenmeyin, indirilmiş içerikleri izleyebilirsiniz.",
"checking_server_connection_message": "Sunucuya bağlantı kontrol ediliyor",
"go_to_downloads": "İndirilenlere git",
"retry": "Tekrar dene",
"server_unreachable": "Sunucuya ulaşılamıyor",
"server_unreachable_message": "Sunucuya bağlanılamadı. Lütfen ağ bağlantınızı kontrol edin.",
"oops": "Hups!",
"error_message": "Bir şeyler ters gitti.\nLütfen çıkış yapın ve tekrar giriş yapın.",
"error_message": "Bir şeyler ters gitti.\nLütfen çıkış yapıp tekrar giriş yapın.",
"continue_watching": "İzlemeye Devam Et",
"next_up": "Sonraki",
"continue_and_next_up": "Continue & Next Up",
"recently_added_in": "{{libraryName}}'de Yakınlarda Eklendi",
"continue_and_next_up": "İzlemeye Devam Et & Sıradakiler",
"recently_added_in": "{{libraryName}} Kütüphanesine Son Eklenenler",
"suggested_movies": "Önerilen Filmler",
"suggested_episodes": "Önerilen Bölümler",
"intro": {
@@ -110,52 +110,52 @@
"settings_title": "Ayarlar",
"log_out_button": ıkış Yap",
"categories": {
"title": "Categories"
"title": "Kategoriler"
},
"playback_controls": {
"title": "Playback & Controls"
"title": "Oynatma & Kontroller"
},
"audio_subtitles": {
"title": "Audio & Subtitles"
"title": "Ses & Altyazılar"
},
"appearance": {
"title": "Appearance",
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
"hide_remote_session_button": "Hide Remote Session Button"
"title": "Görünüm",
"merge_next_up_continue_watching": "İzlemeye Devam Et & Sıradakiler'i birleştir",
"hide_remote_session_button": "Uzak Oturum Butonunu Gizle"
},
"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": "Yerel Ağ",
"auto_switch_enabled": "Evdeyken otomatik geçiş yap",
"auto_switch_description": "Ev WiFi'sine bağlanınca otomatik olarak yerek URL adresine geçiş yap",
"local_url": "Yerel URL Adresi",
"local_url_hint": "Yerel sunucu adresinizi girin (http://192.168.1.100:8096, gibi)",
"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": "Ev WiFi ağları",
"add_current_network": "\"{{ssid}}\"'yi ekle",
"not_connected_to_wifi": "WiFi'a bağlı değil",
"no_networks_configured": "Herhangi bir ağ ayarlanmadı",
"add_network_hint": "Otomatik geçişi etkinleştirmek için ev WiFi'nizi ekleyin",
"current_wifi": "Şu anki WiFi",
"using_url": "Kullanılıyor",
"local": "Yerel URL Adresi",
"remote": "Uzak URL Adresi",
"not_connected": "Bağlı değil",
"current_server": "Geçerli Sunucu",
"remote_url": "Uzak URL Adresi",
"active_url": "Aktif URL Adresi",
"not_configured": "Yapılandırılmamış",
"network_added": "Ağ eklendi",
"network_already_added": "Ağ zaten eklendi",
"no_wifi_connected": "WiFi'a bağlı değil",
"permission_denied": "Konum izni reddedildi",
"permission_denied_explanation": "Otomatik geçiş yapabilmek için WiFi ağını algılayabilmek için konum izni gereklidir. Lütfen Ayarlarda etkinleştirin."
},
"user_info": {
"user_info_title": "Kullanıcı Bilgisi",
"user": "Kullanıcı",
"server": "Sunucu",
"token": "Token",
"token": "Erişim Anahtarı",
"app_version": "Uygulama Sürümü"
},
"quick_connect": {
@@ -172,20 +172,20 @@
"media_controls_title": "Medya Kontrolleri",
"forward_skip_length": "İleri Sarma Uzunluğu",
"rewind_length": "Geri Sarma Uzunluğu",
"seconds_unit": "s"
"seconds_unit": "sn"
},
"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",
"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"
"gesture_controls_title": "Hareketle Kontrol",
"horizontal_swipe_skip": "Atlamak için yatay kaydırma",
"horizontal_swipe_skip_description": "Kontroller gizliyken sola/sağa kaydırarak atlama",
"left_side_brightness": "Sol Taraf Parlaklık Kontrolü",
"left_side_brightness_description": "Sol tarafta aşağı/yukarı kaydırarak parlaklık ayarı",
"right_side_volume": "Sağ Taraf Ses Kontrolü",
"right_side_volume_description": "Sağ tarafta aşağı/yukarı kaydırarak ses ayarı",
"hide_volume_slider": "Ses Ayarını Gizle",
"hide_volume_slider_description": "Video oynatıcıda ses ayarını gizle",
"hide_brightness_slider": "Parlaklık Ayarını Gizle",
"hide_brightness_slider_description": "Video oynatıcıda parlaklık ayarını gizle"
},
"audio": {
"audio_title": "Ses",
@@ -195,12 +195,12 @@
"none": "Yok",
"language": "Dil",
"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": "Ses Kod Dönüştürmesi",
"description": "Surround sesin (7.1, TrueHD, DTS-HD) nasıl işleneceğini kontrol eder.",
"auto": "Oto",
"stereo": "Stereo'ya zorla",
"5_1": "5.1'e izin ver",
"passthrough": "Doğrudan geçiş"
}
},
"subtitles": {
@@ -220,60 +220,60 @@
"None": "Yok",
"OnlyForced": "Sadece Zorunlu"
},
"text_color": "Text Color",
"background_color": "Background Color",
"outline_color": "Outline Color",
"outline_thickness": "Outline Thickness",
"background_opacity": "Background Opacity",
"outline_opacity": "Outline Opacity",
"bold_text": "Bold Text",
"text_color": "Metin Rengi",
"background_color": "Arkaplan Rengi",
"outline_color": "Kenarlık Rengi",
"outline_thickness": "Kenarlık kalınlığı",
"background_opacity": "Arkaplan Opaklığı",
"outline_opacity": "Kenarlık Opaklığı",
"bold_text": "Kalın Metin",
"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": "Siyah",
"Gray": "Gri",
"Silver": "Gümüş",
"White": "Beyaz",
"Maroon": "Kestane",
"Red": "Kırmızı",
"Fuchsia": "Fuşya",
"Yellow": "Sarı",
"Olive": "Zeytin yeşili",
"Green": "Yeşil",
"Teal": "Deniz mavisi",
"Lime": "Limon",
"Purple": "Mor",
"Navy": "Lacivert",
"Blue": "Mavi",
"Aqua": "Açık Mavi"
},
"thickness": {
"None": "Hiçbiri",
"Thin": "Thin",
"Thin": "İnce",
"Normal": "Normal",
"Thick": "Thick"
"Thick": "Kalın"
},
"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": "Altyazı Rengi",
"subtitle_background_color": "Arkaplan Rengi",
"subtitle_font": "Altyazı Yazı Tipi",
"ksplayer_title": "KSPlayer Ayarları",
"hardware_decode": "Donanımsal Kod Çözme",
"hardware_decode_description": "Video kod çözme için donanımsal hızlandırma kullan. Oynatma sorunları yaşıyorsanız devre dışı bırakın."
},
"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 Altyazı Ayarları",
"hint": "VLC oynatıcı için altyazı görünümünü değiştirin. Değişiklikler bir sonraki oynatmada etkili olacak.",
"text_color": "Metin Rengi",
"background_color": "Arkaplan Rengi",
"background_opacity": "Arkaplan Opaklığı",
"outline_color": "Kenarlık Rengi",
"outline_opacity": "Kenarlık Opaklığı",
"outline_thickness": "Kenarlık Kalınlığı",
"bold": "Kalın Metin",
"margin": "Alt Kenar Boşluğu"
},
"video_player": {
"title": "Video Player",
"video_player": "Video Player",
"video_player_description": "Choose which video player to use on iOS.",
"title": "Video oynatıcısı",
"video_player": "Video oynatıcısı",
"video_player_description": "iOS'da hangi video oynatıcının kullanılacağını seçin.",
"ksplayer": "KSPlayer",
"vlc": "VLC"
},
@@ -297,7 +297,7 @@
"video_player": "Video player",
"video_players": {
"VLC_3": "VLC 3",
"VLC_4": "VLC 4 (Experimental + PiP)"
"VLC_4": "VLC 4 (Deneysel + PiP)"
},
"show_custom_menu_links": "Özel Menü Bağlantılarını Göster",
"show_large_home_carousel": "Show Large Home Carousel (beta)",
@@ -305,24 +305,24 @@
"select_liraries_you_want_to_hide": "Kütüphane sekmesinden ve ana sayfa bölümlerinden gizlemek istediğiniz kütüphaneleri seçin.",
"disable_haptic_feedback": "Dokunsal Geri Bildirimi Devre Dışı Bırak",
"default_quality": "Varsayılan kalite",
"default_playback_speed": "Default Playback Speed",
"auto_play_next_episode": "Auto-play Next Episode",
"max_auto_play_episode_count": "Max Auto Play Episode Count",
"default_playback_speed": "Varsayılan Oynatma Hızı",
"auto_play_next_episode": "Otomatik Sonraki Bölümü Oynat",
"max_auto_play_episode_count": "En Fazla Otomatik Oynatılacak Bölüm Sayısı",
"disabled": "Devre dışı"
},
"downloads": {
"downloads_title": "İndirmeler"
},
"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.",
"title": "Müzik",
"playback_title": "Oynatma",
"playback_description": "Müziğin nasıl çalınacağını ayarlayın.",
"prefer_downloaded": "İndirilmiş Şarkıları Tercih Et",
"caching_title": "Önbellekleme",
"caching_description": "Akıcı oynatım için gelecek şarkıları otomatik önbelleğe al.",
"lookahead_enabled": "Enable Look-Ahead Caching",
"lookahead_count": "Tracks to Pre-cache",
"max_cache_size": "Max Cache Size"
"lookahead_count": "Önden Önbelleklenecek Parça Sayısı",
"max_cache_size": "Maksimum Önbellek Boyutu"
},
"plugins": {
"plugins_title": "Eklentiler",
@@ -345,7 +345,7 @@
"order_by": {
"DEFAULT": "Varsayılan",
"VOTE_COUNT_AND_AVERAGE": "Vote count and average",
"POPULARITY": "Popularity"
"POPULARITY": "Popülerlik"
}
},
"marlin_search": {
@@ -357,35 +357,35 @@
"save_button": "Kaydet",
"toasts": {
"saved": "Kaydedildi",
"refreshed": "Settings refreshed from server"
"refreshed": "Ayarlar sunucudan yeniden alındı"
},
"refresh_from_server": "Refresh Settings from Server"
"refresh_from_server": "Ayarları Sunucudan Yeniden Al"
},
"streamystats": {
"enable_streamystats": "Enable Streamystats",
"disable_streamystats": "Disable Streamystats",
"enable_search": "Use for Search",
"url": "URL",
"enable_streamystats": "Streamystats'ı Etkinleştir",
"disable_streamystats": "Streamystats'ı Devre Dışı Bırak",
"enable_search": "Arama için kullan",
"url": "URL Adresi",
"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",
"streamystats_search_hint": "Streamystats sunucu URL'sini girin. URL, http veya https içermeli ve isteğe bağlı olarak portu içerebilir.",
"read_more_about_streamystats": "Streamystats hakkında daha fazla bilgi.",
"save_button": "Kaydet",
"save": "Kaydet",
"features_title": "Özellikler",
"home_sections_title": "Home Sections",
"enable_movie_recommendations": "Movie Recommendations",
"enable_series_recommendations": "Series Recommendations",
"enable_movie_recommendations": "Film Önerileri",
"enable_series_recommendations": "Dizi Önerileri",
"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",
"recommended_movies": "Önerilen Filmler",
"recommended_series": "Önerilen Diziler",
"toasts": {
"saved": "Saved",
"refreshed": "Settings refreshed from server",
"disabled": "Streamystats disabled"
"saved": "Kaydedildi",
"refreshed": "Ayarlar sunucudan yeniden alındı",
"disabled": "Streamystats devre dışı"
},
"refresh_from_server": "Refresh Settings from Server"
"refresh_from_server": "Ayarları Sunucudan Yeniden Al"
},
"kefinTweaks": {
"watchlist_enabler": "Enable our Watchlist integration",
@@ -398,18 +398,18 @@
"device_usage": "Cihaz {{availableSpace}}%",
"size_used": "{{used}} / {{total}} kullanıldı",
"delete_all_downloaded_files": "Tüm indirilen dosyaları sil",
"music_cache_title": "Music Cache",
"music_cache_title": "Müzik Ön Belleği",
"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"
"enable_music_cache": "Müzik Ön Belleğini Etkinleştir",
"clear_music_cache": "Müzik Ön Belleğini Temizle",
"music_cache_size": "{{size}} ön belleklendi",
"music_cache_cleared": "Müzik ön belleği temizlendi",
"delete_all_downloaded_songs": "Tüm İndirilen Müzikleri Sil",
"downloaded_songs_size": "{{size}} indirildi",
"downloaded_songs_deleted": "İndirilen müzikler silindi"
},
"intro": {
"title": "Intro",
"title": "Giriş",
"show_intro": "Tanıtımı Göster",
"reset_intro": "Tanıtımı Sıfırla"
},
@@ -417,7 +417,7 @@
"logs_title": "Günlükler",
"export_logs": "Export logs",
"click_for_more_info": "Click for more info",
"level": "Level",
"level": "Düzey",
"no_logs_available": "Günlükler mevcut değil",
"delete_all_logs": "Tüm günlükleri sil"
},
@@ -433,22 +433,22 @@
}
},
"sessions": {
"title": "Sessions",
"no_active_sessions": "No Active Sessions"
"title": "Oturumlar",
"no_active_sessions": "Aktif Oturum Yok"
},
"downloads": {
"downloads_title": "İndirilenler",
"tvseries": "Diziler",
"movies": "Filmler",
"queue": "Sıra",
"other_media": "Other media",
"other_media": "Diğer medya",
"queue_hint": "Sıra ve indirmeler uygulama yeniden başlatıldığında kaybolacaktır",
"no_items_in_queue": "Sırada öğe yok",
"no_downloaded_items": "İndirilen öğe yok",
"delete_all_movies_button": "Tüm Filmleri Sil",
"delete_all_tvseries_button": "Tüm Dizileri Sil",
"delete_all_button": "Tümünü Sil",
"delete_all_other_media_button": "Delete other media",
"delete_all_other_media_button": "Diğer medyayı sil",
"active_download": "Aktif indirme",
"no_active_downloads": "Aktif indirme yok",
"active_downloads": "Aktif indirmeler",
@@ -465,49 +465,49 @@
"failed_to_delete_all_movies": "Filmler silinemedi",
"deleted_all_tvseries_successfully": "Tüm diziler başarıyla silindi!",
"failed_to_delete_all_tvseries": "Diziler silinemedi",
"deleted_media_successfully": "Deleted other media Successfully!",
"deleted_media_successfully": "Diğer medya başarıyla silindi!",
"failed_to_delete_media": "Failed to Delete other media",
"download_deleted": "Download Deleted",
"download_deleted": "İndirme silindi",
"download_cancelled": "İndirme iptal edildi",
"could_not_delete_download": "Could Not Delete Download",
"download_paused": "Download Paused",
"could_not_pause_download": "Could Not Pause Download",
"download_resumed": "Download Resumed",
"could_not_resume_download": "Could Not Resume Download",
"could_not_delete_download": "İndirme Silinemedi",
"download_paused": "İndirme Duraklatıldı",
"could_not_pause_download": "İndirme Duraklatılamadı",
"download_resumed": "İndirme Devam Ediyor",
"could_not_resume_download": "İndirme Devam Ettirilemedi",
"download_completed": "İndirme tamamlandı",
"download_failed": "Download Failed",
"download_failed": "İndirme başarısız oldu",
"download_failed_for_item": "{{item}} için indirme başarısız oldu - {{error}}",
"download_completed_for_item": "{{item}} için indirme tamamlandı",
"download_started_for_item": "Download Started for {{item}}",
"failed_to_start_download": "Failed to start download",
"item_already_downloading": "{{item}} is already downloading",
"all_files_deleted": "All Downloads Deleted Successfully",
"files_deleted_by_type": "{{count}} {{type}} deleted",
"download_started_for_item": "{{item}} için indirme başladı",
"failed_to_start_download": "İndirme başlatılamadı",
"item_already_downloading": "{{item}} zaten indiriliyor",
"all_files_deleted": "Bütün indirilenler başarıyla silindi",
"files_deleted_by_type": "{{count}} {{type}} silindi",
"all_files_folders_and_jobs_deleted_successfully": "Tüm dosyalar, klasörler ve işler başarıyla silindi",
"failed_to_clean_cache_directory": "Failed to clean cache directory",
"could_not_get_download_url_for_item": "Could not get download URL for {{itemName}}",
"failed_to_clean_cache_directory": "Önbellek dizini temizlenemedi",
"could_not_get_download_url_for_item": "{{itemName}} için indirme URL'si alınamadı",
"go_to_downloads": "İndirmelere git",
"file_deleted": "{{item}} deleted"
"file_deleted": "{{item}} silindi"
}
}
},
"common": {
"select": "Select",
"no_trailer_available": "No trailer available",
"select": "Seç",
"no_trailer_available": "Fragman mevcut değil",
"video": "Video",
"audio": "Ses",
"subtitle": "Altyazı",
"play": "Play",
"none": "None",
"track": "Track",
"cancel": "Cancel",
"delete": "Delete",
"ok": "OK",
"remove": "Remove",
"next": "Next",
"back": "Back",
"continue": "Continue",
"verifying": "Verifying..."
"play": "Oynat",
"none": "Hiçbiri",
"track": "Parça",
"cancel": "Vazgeç",
"delete": "Sil",
"ok": "Tamam",
"remove": "Kaldır",
"next": "Sonraki",
"back": "Geri",
"continue": "Devam",
"verifying": "Doğrulanıyor..."
},
"search": {
"search": "Ara...",
@@ -521,10 +521,10 @@
"episodes": "Bölümler",
"collections": "Koleksiyonlar",
"actors": "Oyuncular",
"artists": "Artists",
"albums": "Albums",
"songs": "Songs",
"playlists": "Playlists",
"artists": "Sanatçılar",
"albums": "Albümler",
"songs": "Şarkılar",
"playlists": "Çalma listeleri",
"request_movies": "Film Talep Et",
"request_series": "Dizi Talep Et",
"recently_added": "Son Eklenenler",
@@ -572,7 +572,7 @@
"genres": "Türler",
"years": "Yıllar",
"sort_by": "Sırala",
"filter_by": "Filter By",
"filter_by": "Filtrele",
"sort_order": "Sıralama düzeni",
"tags": "Etiketler"
}
@@ -604,11 +604,11 @@
"index": "İndeks:",
"continue_watching": "İzlemeye devam et",
"go_back": "Geri",
"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_title": "Bu dosya indirilmiş",
"downloaded_file_message": "İndirilmiş dosyayı oynatmak ister misiniz?",
"downloaded_file_yes": "Evet",
"downloaded_file_no": "Hayır",
"downloaded_file_cancel": "Vazgeç"
},
"item_card": {
"next_up": "Sıradaki",
@@ -624,7 +624,7 @@
"no_similar_items_found": "Benzer öge bulunamadı",
"video": "Video",
"more_details": "Daha fazla detay",
"media_options": "Media Options",
"media_options": "Medya Seçenekleri",
"quality": "Kalite",
"audio": "Ses",
"subtitles": "Altyazı",
@@ -639,7 +639,7 @@
"download_episode": "Bölümü indir",
"download_movie": "Filmi indir",
"download_x_item": "{{item_count}} tane ögeyi indir",
"download_unwatched_only": "Unwatched Only",
"download_unwatched_only": "Yalnızca İzlenmemişler",
"download_button": "İndir"
}
},
@@ -693,10 +693,10 @@
"number_episodes": "Bölüm {{episode_number}}",
"born": "Doğum",
"appearances": "Görünmeler",
"approve": "Approve",
"decline": "Decline",
"requested_by": "Requested by {{user}}",
"unknown_user": "Unknown User",
"approve": "Onayla",
"decline": "Reddet",
"requested_by": "{{user}} tarafından istendi",
"unknown_user": "Bilinmeyen Kullanıcı",
"toasts": {
"jellyseer_does_not_meet_requirements": "Jellyseerr sunucusu minimum sürüm gereksinimlerini karşılamıyor! Lütfen en az 2.0.0 sürümüne güncelleyin",
"jellyseerr_test_failed": "Jellyseerr testi başarısız oldu. Lütfen tekrar deneyin.",
@@ -705,10 +705,10 @@
"requested_item": "{{item}} talep edildi!",
"you_dont_have_permission_to_request": "İstek göndermeye izniniz yok!",
"something_went_wrong_requesting_media": "Medya talep edilirken bir şeyler ters gitti!",
"request_approved": "Request Approved!",
"request_declined": "Request Declined!",
"failed_to_approve_request": "Failed to Approve Request",
"failed_to_decline_request": "Failed to Decline Request"
"request_approved": "İstek Onaylandı!",
"request_declined": "İstek Reddedildi!",
"failed_to_approve_request": "İsteği Onaylama Başarısız Oldu",
"failed_to_decline_request": "İsteği Reddetme Başarısız Oldu"
}
},
"tabs": {
@@ -719,127 +719,127 @@
"favorites": "Favoriler"
},
"music": {
"title": "Music",
"title": "Müzik",
"tabs": {
"suggestions": "Suggestions",
"albums": "Albums",
"artists": "Artists",
"playlists": "Playlists",
"tracks": "tracks"
"suggestions": "Öneriler",
"albums": "Albümler",
"artists": "Sanatçılar",
"playlists": "Çalma listeleri",
"tracks": "parçalar"
},
"filters": {
"all": "All"
"all": "Tümü"
},
"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": "Son Eklenenler",
"recently_played": "Son Oynatılanlar",
"frequently_played": "Sık Oynatılanlar",
"explore": "Keşfet",
"top_tracks": "En Popülar Parçalar",
"play": "Oynat",
"shuffle": "Karıştır",
"play_top_tracks": "En Çok Oynatılan Parçaları Oynat",
"no_suggestions": "Öneri mevcut değil",
"no_albums": "Hiç albüm bulunamadı",
"no_artists": "Hiç sanatçı bulunamadı",
"no_playlists": "Hiç çalma listesi bulunamadı",
"album_not_found": "Albüm bulunamadı",
"artist_not_found": "Sanatçı bulunamadı",
"playlist_not_found": "Çalma listesi bulunamadı",
"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": "Sıradakini Çal",
"add_to_queue": "Sıraya Ekle",
"add_to_playlist": "Çalma listesine ekle",
"download": "İndir",
"downloaded": "İndirildi",
"downloading": "İndiriliyor...",
"cached": "Önbellekte",
"delete_download": "İndirmeyi Sil",
"delete_cache": "Ön bellekten kaldır",
"go_to_artist": "Sanatçıya Git",
"go_to_album": "Albüme Git",
"add_to_favorites": "Favorilere Ekle",
"remove_from_favorites": "Favorilerden Kaldır",
"remove_from_playlist": "Çalma Listesinden Kaldır"
},
"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": "Çalma Listesi Oluştur",
"playlist_name": "Çalma Listesi Adı",
"enter_name": "Çalma listesi adı girin",
"create": "Oluştur",
"search_playlists": "Çalma listelerini ara...",
"added_to": "Şu çalma listesine eklendi: {{name}}",
"added": "Çalma listesine eklendi",
"removed_from": "Şu çalma listesinden kaldırıldı: {{name}}",
"removed": "Çalma listesinden kaldır",
"created": "Çalma listesi oluşturuldu",
"create_new": "Yeni Çalma Listesi Oluştur",
"failed_to_add": "Çalma listesine eklenemedi",
"failed_to_remove": "Çalma listesinden kaldırılamadı",
"failed_to_create": "Çalma listesi oluşturulamadı",
"delete_playlist": "Çalma Listesini Sil",
"delete_confirm": "\"{{name}}\" adlı çalma listesini silmek istediğinize emin misiniz? Bu işlem geri alınamaz.",
"deleted": "Çalma listesi silindi",
"failed_to_delete": "Çalma listesi oluşturulamadı"
},
"sort": {
"title": "Sort By",
"alphabetical": "Alphabetical",
"date_created": "Date Created"
"title": "Sırala",
"alphabetical": "Alfabetik",
"date_created": "Oluşturulma Tarihi"
}
},
"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",
"title": "İzleme listeleri",
"my_watchlists": "İzleme listelerim",
"public_watchlists": "Herkese açık izleme listeleri",
"create_title": "İzleme listesi oluştur",
"edit_title": "İzleme listesini düzenle",
"create_button": "İzleme listesi oluştur",
"save_button": "Değişiklikleri Kaydet",
"delete_button": "Sil",
"remove_button": "Kaldır",
"cancel_button": "Vazgeç",
"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",
"name_placeholder": "İzleme listesi adını girin",
"description_label": "ıklama",
"description_placeholder": "ıklama girin (isteğe bağlı)",
"is_public_label": "Herkese açık izleme listesi",
"is_public_description": "Başkalarının da bu izleme listesini görmesine izin ver",
"allowed_type_label": "İçerik Türü",
"sort_order_label": "Varsayılan Sıralama",
"empty_title": "İzleme listesi yok",
"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",
"empty_watchlist": "Bu izleme listesi boş",
"empty_watchlist_hint": "Kütüphanenizdeki nesneleri bu izleme listesine ekleyin",
"not_configured_title": "Streamystats ayarlanmamış",
"not_configured_description": "İzleme listelerini kullanmak için ayarlardan Streamystats'ı ayarlayın",
"go_to_settings": "Ayarlara git",
"add_to_watchlist": "İzleme Listesine Ekle",
"remove_from_watchlist": "İzleme Listesinden Kaldır",
"select_watchlist": "İzleme Listesi Seç",
"create_new": "Yeni İzleme Listesi Oluştur",
"item": "öğe",
"items": "öğeler",
"public": "Herkese Açık",
"private": "Özel",
"you": "Siz",
"by_owner": "Başka kullanıcı tarafından",
"not_found": "İzleme listesi bulunamadı",
"delete_confirm_title": "İzleme listesini sil",
"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"
"remove_item_title": "İzleme Listesinden Kaldır",
"remove_item_message": "{{name}} bu izleme listesinden kaldırılsın mı?",
"loading": "İzleme listeleri yükleniyor...",
"no_compatible_watchlists": "Uyumlu izleme listesi yok",
"create_one_first": "Bu içerik türünü kabul eden bir izleme listesi oluşturun"
},
"playback_speed": {
"title": "Playback Speed",
"apply_to": "Apply To",
"speed": "Speed",
"title": "Oynatma Hızı",
"apply_to": "Şuna Uygula",
"speed": "Hız",
"scope": {
"media": "This media only",
"show": "This show",
"all": "All media (default)"
"media": "Yalnızca bu medyada",
"show": "Bu dizide",
"all": "Bütün medyalarda (varsayılan)"
}
}
}

View File

@@ -134,6 +134,9 @@ export enum VideoPlayer {
MPV = 0,
}
// Segment skip behavior options
export type SegmentSkipMode = "none" | "ask" | "auto";
// Audio transcoding mode - controls how surround audio is handled
// This controls server-side transcoding behavior for audio streams.
// MPV decodes via FFmpeg and supports most formats, but mobile devices
@@ -181,6 +184,12 @@ export type Settings = {
maxAutoPlayEpisodeCount: MaxAutoPlayEpisodeCount;
autoPlayEpisodeCount: number;
autoPlayNextEpisode: boolean;
// Media segment skip preferences
skipIntro: SegmentSkipMode;
skipOutro: SegmentSkipMode;
skipRecap: SegmentSkipMode;
skipCommercial: SegmentSkipMode;
skipPreview: SegmentSkipMode;
// Playback speed settings
defaultPlaybackSpeed: number;
playbackSpeedPerMedia: Record<string, number>;
@@ -266,6 +275,12 @@ export const defaultValues: Settings = {
maxAutoPlayEpisodeCount: { key: "3", value: 3 },
autoPlayEpisodeCount: 0,
autoPlayNextEpisode: true,
// Media segment skip defaults
skipIntro: "ask",
skipOutro: "ask",
skipRecap: "ask",
skipCommercial: "ask",
skipPreview: "ask",
// Playback speed defaults
defaultPlaybackSpeed: 1.0,
playbackSpeedPerMedia: {},

102
utils/casting/helpers.ts Normal file
View File

@@ -0,0 +1,102 @@
/**
* Unified Casting Helper Functions
* Common utilities for casting protocols
*/
/**
* Format milliseconds to HH:MM:SS or MM:SS
*/
export const formatTime = (ms: number): string => {
const totalSeconds = Math.floor(ms / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
}
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
};
/**
* Calculate ending time based on current progress and duration.
* Uses locale-aware formatting when available.
*/
export const calculateEndingTime = (
currentMs: number,
durationMs: number,
): string => {
const remainingMs = durationMs - currentMs;
const endTime = new Date(Date.now() + remainingMs);
try {
return endTime.toLocaleTimeString(undefined, {
hour: "numeric",
minute: "2-digit",
});
} catch {
// Fallback for environments without Intl support
const hours = endTime.getHours();
const minutes = endTime.getMinutes();
const ampm = hours >= 12 ? "PM" : "AM";
const displayHours = hours % 12 || 12;
return `${displayHours}:${minutes.toString().padStart(2, "0")} ${ampm}`;
}
};
/**
* Get poster URL for item with specified dimensions
*/
export const getPosterUrl = (
baseUrl: string | undefined,
itemId: string | undefined,
tag: string | undefined,
width: number,
height: number,
): string | null => {
if (!baseUrl || !itemId) return null;
const params = new URLSearchParams({
maxWidth: width.toString(),
maxHeight: height.toString(),
quality: "90",
...(tag && { tag }),
});
return `${baseUrl}/Items/${itemId}/Images/Primary?${params.toString()}`;
};
/**
* Truncate title to max length with ellipsis
*/
export const truncateTitle = (title: string, maxLength: number): string => {
if (maxLength < 4) return title.substring(0, maxLength);
if (title.length <= maxLength) return title;
return `${title.substring(0, maxLength - 3)}...`;
};
/**
* Check if current time is within a segment
*/
export const isWithinSegment = (
currentMs: number,
segment: { start: number; end: number } | null,
): boolean => {
if (!segment) return false;
const currentSeconds = currentMs / 1000;
return currentSeconds >= segment.start && currentSeconds <= segment.end;
};
/**
* Format trickplay time from {hours, minutes, seconds} to display string.
* Produces "H:MM:SS" when hours > 0, otherwise "MM:SS".
*/
export const formatTrickplayTime = (time: {
hours: number;
minutes: number;
seconds: number;
}): string => {
const mm = String(time.minutes).padStart(2, "0");
const ss = String(time.seconds).padStart(2, "0");
return time.hours > 0 ? `${time.hours}:${mm}:${ss}` : `${mm}:${ss}`;
};

122
utils/casting/mediaInfo.ts Normal file
View File

@@ -0,0 +1,122 @@
/**
* Shared helper to build Chromecast media metadata.
* Eliminates duplication between PlayButton, casting-player reloadWithSettings, and loadEpisode.
*/
import type { Api } from "@jellyfin/sdk";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { MediaStreamType } from "react-native-google-cast";
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
/**
* Build a MediaInfo object suitable for `remoteMediaClient.loadMedia()`.
*
* NOTE on contentType: Chromecast Default Media Receiver auto-detects HLS/DASH
* from the URL. Setting contentType to "application/x-mpegurl" or "application/dash+xml"
* actually BREAKS playback on many receivers. Always use "video/mp4" unless
* you have a custom receiver that explicitly handles other MIME types.
*/
export const buildCastMediaInfo = ({
item,
streamUrl,
api,
contentType,
isLive = false,
}: {
item: BaseItemDto;
streamUrl: string;
api: Api;
/** Override MIME type. Defaults to "video/mp4" which works for all stream types on Default Media Receiver. */
contentType?: string;
/** Set true for live TV streams to use MediaStreamType.LIVE. */
isLive?: boolean;
}) => {
if (!item.Id) {
throw new Error("Missing item.Id for media load — cannot build contentId");
}
const itemId: string = item.Id;
const streamDuration = item.RunTimeTicks
? item.RunTimeTicks / 10000000
: undefined;
const buildImages = (urls: (string | null | undefined)[]) =>
urls.filter(Boolean).map((url) => ({ url: url as string }));
const buildItemMetadata = () => {
if (item.Type === "Episode") {
return {
type: "tvShow" as const,
title: item.Name || "",
episodeNumber: item.IndexNumber || 0,
seasonNumber: item.ParentIndexNumber || 0,
seriesTitle: item.SeriesName || "",
images: buildImages([
getParentBackdropImageUrl({ api, item, quality: 90, width: 2000 }),
]),
};
}
if (item.Type === "Movie") {
return {
type: "movie" as const,
title: item.Name || "",
subtitle: item.Overview || "",
images: buildImages([
getPrimaryImageUrl({ api, item, quality: 90, width: 2000 }),
]),
};
}
return {
type: "generic" as const,
title: item.Name || "",
subtitle: item.Overview || "",
images: buildImages([
getPrimaryImageUrl({ api, item, quality: 90, width: 2000 }),
]),
};
};
const metadata = buildItemMetadata();
// Build a slim customData payload with only the fields the casting-player needs.
// Sending the full BaseItemDto can exceed the Cast protocol's ~64KB message limit,
// especially for movies with many chapters, media sources, and people.
const slimCustomData: Partial<BaseItemDto> = {
Id: item.Id,
Name: item.Name,
Type: item.Type,
SeriesName: item.SeriesName,
SeriesId: item.SeriesId,
SeasonId: item.SeasonId,
IndexNumber: item.IndexNumber,
ParentIndexNumber: item.ParentIndexNumber,
ImageTags: item.ImageTags,
RunTimeTicks: item.RunTimeTicks,
Overview: item.Overview,
MediaStreams: item.MediaStreams,
MediaSources: item.MediaSources?.map((src) => ({
Id: src.Id,
Bitrate: src.Bitrate,
Container: src.Container,
Name: src.Name,
DefaultAudioStreamIndex: src.DefaultAudioStreamIndex,
DefaultSubtitleStreamIndex: src.DefaultSubtitleStreamIndex,
})),
UserData: item.UserData
? { PlaybackPositionTicks: item.UserData.PlaybackPositionTicks }
: undefined,
};
return {
contentId: itemId,
contentUrl: streamUrl,
contentType: contentType || "video/mp4",
streamType: isLive ? MediaStreamType.LIVE : MediaStreamType.BUFFERED,
streamDuration,
customData: slimCustomData,
metadata,
};
};

72
utils/casting/types.ts Normal file
View File

@@ -0,0 +1,72 @@
/**
* Unified Casting Types and Options
* Protocol-agnostic casting interface - currently supports Chromecast
* Architecture allows for future protocols (AirPlay, DLNA, etc.)
*/
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
export type CastProtocol = "chromecast";
export interface CastDevice {
id: string;
name: string;
protocol: CastProtocol;
type?: string;
}
export interface CastPlayerState {
isConnected: boolean;
isPlaying: boolean;
currentItem: BaseItemDto | null;
currentDevice: CastDevice | null;
protocol: CastProtocol | null;
progress: number;
duration: number;
volume: number;
isBuffering: boolean;
}
export interface AudioTrack {
index: number;
language: string;
codec: string;
displayTitle: string;
}
export interface SubtitleTrack {
index: number;
language: string;
codec: string;
displayTitle: string;
isForced: boolean;
}
export interface MediaSource {
id: string;
name: string;
bitrate?: number;
container: string;
}
export const CASTING_CONSTANTS = {
POSTER_WIDTH: 300,
POSTER_HEIGHT: 450,
ANIMATION_DURATION: 300,
CONTROL_HIDE_DELAY: 5000,
PROGRESS_UPDATE_INTERVAL: 1000,
SEEK_FORWARD_SECONDS: 10,
SEEK_BACKWARD_SECONDS: 10,
} as const;
export const DEFAULT_CAST_STATE: CastPlayerState = {
isConnected: false,
isPlaying: false,
currentItem: null,
currentDevice: null,
protocol: null,
progress: 0,
duration: 0,
volume: 0.5,
isBuffering: false,
};

View File

@@ -0,0 +1,11 @@
/**
* Chromecast player configuration and types
*/
export interface ChromecastSegmentData {
intro: { start: number; end: number } | null;
credits: { start: number; end: number } | null;
recap: { start: number; end: number } | null;
commercial: { start: number; end: number }[];
preview: { start: number; end: number }[];
}

View File

@@ -13,6 +13,14 @@ export const chromecast: DeviceProfile = {
{
Type: "Audio",
Codec: "aac,mp3,flac,opus,vorbis",
// Force transcode if audio has more than 2 channels (5.1, 7.1, etc)
Conditions: [
{
Condition: "LessThanEqual",
Property: "AudioChannels",
Value: "2",
},
],
},
],
ContainerProfiles: [],
@@ -84,9 +92,5 @@ export const chromecast: DeviceProfile = {
Format: "vtt",
Method: "Encode",
},
{
Format: "vtt",
Method: "Encode",
},
],
};

View File

@@ -12,7 +12,14 @@ export const chromecasth265: DeviceProfile = {
},
{
Type: "Audio",
Codec: "aac,mp3,flac,opus,vorbis",
Codec: "aac,mp3,flac,opus,vorbis", // Force transcode if audio has more than 2 channels (5.1, 7.1, etc)
Conditions: [
{
Condition: "LessThanEqual",
Property: "AudioChannels",
Value: "2",
},
],
},
],
ContainerProfiles: [],
@@ -84,9 +91,5 @@ export const chromecasth265: DeviceProfile = {
Format: "vtt",
Method: "Encode",
},
{
Format: "vtt",
Method: "Encode",
},
],
};

View File

@@ -74,10 +74,16 @@ export const getSegmentsForItem = (
): {
introSegments: MediaTimeSegment[];
creditSegments: MediaTimeSegment[];
recapSegments: MediaTimeSegment[];
commercialSegments: MediaTimeSegment[];
previewSegments: MediaTimeSegment[];
} => {
return {
introSegments: item.introSegments || [],
creditSegments: item.creditSegments || [],
recapSegments: item.recapSegments || [],
commercialSegments: item.commercialSegments || [],
previewSegments: item.previewSegments || [],
};
};
@@ -95,6 +101,9 @@ const fetchMediaSegments = async (
): Promise<{
introSegments: MediaTimeSegment[];
creditSegments: MediaTimeSegment[];
recapSegments: MediaTimeSegment[];
commercialSegments: MediaTimeSegment[];
previewSegments: MediaTimeSegment[];
} | null> => {
try {
const response = await api.axiosInstance.get<MediaSegmentsResponse>(
@@ -102,13 +111,22 @@ const fetchMediaSegments = async (
{
headers: getAuthHeaders(api),
params: {
includeSegmentTypes: ["Intro", "Outro"],
includeSegmentTypes: [
"Intro",
"Outro",
"Recap",
"Commercial",
"Preview",
],
},
},
);
const introSegments: MediaTimeSegment[] = [];
const creditSegments: MediaTimeSegment[] = [];
const recapSegments: MediaTimeSegment[] = [];
const commercialSegments: MediaTimeSegment[] = [];
const previewSegments: MediaTimeSegment[] = [];
response.data.Items.forEach((segment) => {
const timeSegment: MediaTimeSegment = {
@@ -124,13 +142,27 @@ const fetchMediaSegments = async (
case "Outro":
creditSegments.push(timeSegment);
break;
// Optionally handle other types like Recap, Commercial, Preview
case "Recap":
recapSegments.push(timeSegment);
break;
case "Commercial":
commercialSegments.push(timeSegment);
break;
case "Preview":
previewSegments.push(timeSegment);
break;
default:
break;
}
});
return { introSegments, creditSegments };
return {
introSegments,
creditSegments,
recapSegments,
commercialSegments,
previewSegments,
};
} catch (_error) {
// Return null to indicate we should try legacy endpoints
return null;
@@ -146,45 +178,47 @@ const fetchLegacySegments = async (
): Promise<{
introSegments: MediaTimeSegment[];
creditSegments: MediaTimeSegment[];
recapSegments: MediaTimeSegment[];
commercialSegments: MediaTimeSegment[];
previewSegments: MediaTimeSegment[];
}> => {
const introSegments: MediaTimeSegment[] = [];
const creditSegments: MediaTimeSegment[] = [];
try {
const [introRes, creditRes] = await Promise.allSettled([
api.axiosInstance.get<IntroTimestamps>(
`${api.basePath}/Episode/${itemId}/IntroTimestamps`,
{ headers: getAuthHeaders(api) },
),
api.axiosInstance.get<CreditTimestamps>(
`${api.basePath}/Episode/${itemId}/Timestamps`,
{ headers: getAuthHeaders(api) },
),
]);
const [introRes, creditRes] = await Promise.allSettled([
api.axiosInstance.get<IntroTimestamps>(
`${api.basePath}/Episode/${itemId}/IntroTimestamps`,
{ headers: getAuthHeaders(api) },
),
api.axiosInstance.get<CreditTimestamps>(
`${api.basePath}/Episode/${itemId}/Timestamps`,
{ headers: getAuthHeaders(api) },
),
]);
if (introRes.status === "fulfilled" && introRes.value.data.Valid) {
introSegments.push({
startTime: introRes.value.data.IntroStart,
endTime: introRes.value.data.IntroEnd,
text: "Intro",
});
}
if (
creditRes.status === "fulfilled" &&
creditRes.value.data.Credits.Valid
) {
creditSegments.push({
startTime: creditRes.value.data.Credits.Start,
endTime: creditRes.value.data.Credits.End,
text: "Credits",
});
}
} catch (error) {
console.error("Failed to fetch legacy segments", error);
if (introRes.status === "fulfilled" && introRes.value.data.Valid) {
introSegments.push({
startTime: introRes.value.data.IntroStart,
endTime: introRes.value.data.IntroEnd,
text: "Intro",
});
}
return { introSegments, creditSegments };
if (creditRes.status === "fulfilled" && creditRes.value.data.Credits.Valid) {
creditSegments.push({
startTime: creditRes.value.data.Credits.Start,
endTime: creditRes.value.data.Credits.End,
text: "Credits",
});
}
return {
introSegments,
creditSegments,
recapSegments: [],
commercialSegments: [],
previewSegments: [],
};
};
export const fetchAndParseSegments = async (
@@ -193,6 +227,9 @@ export const fetchAndParseSegments = async (
): Promise<{
introSegments: MediaTimeSegment[];
creditSegments: MediaTimeSegment[];
recapSegments: MediaTimeSegment[];
commercialSegments: MediaTimeSegment[];
previewSegments: MediaTimeSegment[];
}> => {
// Try new API first (Jellyfin 10.11+)
const newSegments = await fetchMediaSegments(itemId, api);