Compare commits

...
75 Commits
Author SHA1 Message Date
github-actions[bot] 9efdd5a17d chore: bump version to 1.27.2 2026-03-14 10:21:54 +00:00
edde746 dd11e8d3b2 fix: scroll list tiles into view on dpad focus 2026-03-14 11:20:31 +01:00
edde746 cde6e99a17 fix: dpad nav skipping episodes on single-season shows
close #691
2026-03-14 11:09:01 +01:00
edde746 045e68ca10 chore: update dart_discord_presence to 1.1.1 2026-03-14 05:41:02 +01:00
edde746 4f736c555b fix: make PlexMetadata type/key/title nullable for folder items
Plex API returns Directory entries without type field, causing
TypeError crash in fromJson. Made fields nullable and migrated
callers to use mediaType enum and displayTitle getter instead
of raw string access.
2026-03-14 05:40:14 +01:00
github-actions[bot] 642c49b1ea chore: bump version to 1.27.1 2026-03-14 04:03:34 +00:00
edde746 e13b16f13c fix(android): mpv boolean NPE on dispose 2026-03-14 05:01:39 +01:00
github-actions[bot] 1f9fa9ba4f chore: update cask to 1.27.0 2026-03-14 01:17:01 +00:00
github-actions[bot] 849ef6efe5 chore: bump version to 1.27.0 2026-03-14 00:53:01 +00:00
edde746 e968a20f4c fix(livetv): soften EPG grid program styling 2026-03-13 18:49:40 +01:00
edde746 7a6b866c28 fix(livetv): fetch boundary-spanning programs
close #686
2026-03-13 18:45:00 +01:00
edde746 6ccbaed3fd fix(livetv): always bold EPG program titles in guide grid
close #687
2026-03-13 18:20:47 +01:00
edde746 d01628a262 fix(android): use OkHttp connection pooling to prevent high-bitrate stutter
Replace DefaultHttpDataSource with OkHttpDataSource for TCP connection
reuse on buffer refill. Increase buffer sizes for devices with >=1GB RAM.
2026-03-13 17:23:06 +01:00
edde746 675ac84486 fix: use settings-style bottom sheet for browse grouping picker 2026-03-13 12:05:51 +01:00
edde746 e84dc93910 fix: prevent tab revert and overflow bleed on mobile tab switch
Skip auto-focus in touch mode to prevent ensureVisible() from fighting
TabBarView animations, and clip each tab with ClipRect to stop hub row
overflow bleeding into adjacent tabs during swipe transitions.
2026-03-13 00:09:24 +01:00
edde746 3d30ea1342 fix(macos): reduce idle CPU when video player is paused
- Remove vulkan-swap-mode mailbox (continuous GPU rendering even when paused)
- End playback activity assertion on pause, restore on play
- Fix PerformanceStatsService persistent frame callback leak
2026-03-12 23:22:58 +01:00
edde746 efd28d3ca3 docs: add AUR package to readme 2026-03-12 13:20:16 +01:00
edde746 8a28a2c398 fix: only autofocus bottom sheets in keyboard/dpad mode 2026-03-12 05:02:52 +01:00
edde746 c6fa6afa9b fix: android thread safety
- ExoPlayerPlugin: move all MPV JNI calls off main thread via runOnExecutor
- WatchNextPlugin: run ContentProvider ops on IO executor with error handling
- MpvPlayerCore: guard executor methods against dispose race (RejectedExecutionException)
- MainActivity: fix PiP null engine NPE with safe flutterEngine?.let
2026-03-12 03:34:32 +01:00
edde746 912ebc2f90 fix: cooldown resume health probes, debounce connectivity, coalesce concurrent checks
Add per-platform cooldown on resume health probes (10s mobile, 2min
desktop). Debounce connectivity listener by 2s. Coalesce concurrent
checkServerHealth and reconnectOfflineServers into single in-flight
futures.
2026-03-12 02:52:14 +01:00
edde746 f04b49e260 fix: guard mpv event handler during disposal 2026-03-12 02:44:50 +01:00
edde746 a17458ff9b fix: handle DBusServiceUnknownException on Linux without NetworkManager 2026-03-12 02:24:00 +01:00
edde746 c062dd944e feat: inline season tabs and d-pad focus improvements 2026-03-12 00:57:51 +01:00
edde746 1a4516bdd1 Merge branch 'feature/respect-season-mode' 2026-03-11 23:09:23 +01:00
edde746 5538e0627b refactor: remove SeasonDetailScreen, unify into MediaDetailScreen
Extract EpisodeCard to its own widget file. MediaDetailScreen now handles
isSeason by showing episodes directly, with proper watch state tracking,
deletion handling, and D-pad focus navigation. Hide empty overview sections.
2026-03-11 23:08:39 +01:00
edde746 7febfa9771 fix: deduplicate episode fetching and use lazy list for flattened seasons 2026-03-11 22:35:30 +01:00
Micah Morrison 160be52f02 Fix parsing setting value 2026-03-11 17:13:11 -04:00
edde746 828d57af24 fix: auto-reconnect stream after network loss during playback 2026-03-11 22:08:11 +01:00
edde746 1f7fb2201c fix: handle null ratingKey in folder browsing 2026-03-11 22:08:11 +01:00
Micah Morrison 32ae51142f Respect the Plex server library season view mode 2026-03-11 16:42:49 -04:00
edde746andGitHub 7014df7a0e Merge pull request #680 from micahmo/fix/always-on-top
Fix issue with always on top getting stuck
2026-03-11 20:50:49 +01:00
Micah Morrison cb0ed15c0c Fix issue with always on top getting stuck 2026-03-11 15:00:17 -04:00
edde746 d4e9364d29 fix: handle TogglePlayPauseEvent for macOS media key
close #679
2026-03-11 18:28:07 +01:00
edde746 4681d60850 fix: hide admin actions from non-admin users
close #677
2026-03-11 18:18:27 +01:00
edde746 78052dc07f fix: player stuck on skip credits with no next episode
close #678
2026-03-11 18:02:33 +01:00
edde746 67c66ff0c1 fix: prevent layout shift for image placeholders 2026-03-11 15:02:38 +01:00
edde746 d1bb99635a feat: configurable intro/credits marker regex patterns
Add settings to customize regex patterns for matching intro and credits
markers in chapter titles, with the current hardcoded values as defaults.
Also reclassifies real markers with non-standard type strings against the
configured patterns.
2026-03-11 15:00:24 +01:00
github-actions[bot] 69092e3656 chore: update cask to 1.26.1 2026-03-11 13:32:42 +00:00
github-actions[bot] 98138a4f35 chore: bump version to 1.26.1 2026-03-11 13:09:47 +00:00
edde746 25c7f325b6 feat: use git commit SHA for sentry release version 2026-03-11 14:09:14 +01:00
edde746 5dbaf961b7 fix: ASS subtitles broken when DV mode enabled
close #674
2026-03-11 14:04:21 +01:00
edde746 b4471fab49 fix: add D3D11 fallback for gpu-api on Windows 2026-03-11 13:54:21 +01:00
edde746 307fd354d6 fix: make overlay sheets dismissible when no descendant is focused
close #673
2026-03-11 13:30:07 +01:00
edde746 a3b5557185 fix: use stream-lavf-o instead of stream-lavf-o-append for reconnect
stream-lavf-o-append is an mpv "action" that only works as an
init option, not a runtime property — all calls returned -3.
Using stream-lavf-o as a single property set works correctly.
2026-03-11 09:40:39 +01:00
edde746 3c491be690 fix: handle mpv end-file errors with global snackbar 2026-03-11 09:29:16 +01:00
edde746 580c1d82e5 fix: stuck network check on Linux without NetworkManager
close #670
2026-03-11 08:55:25 +01:00
github-actions[bot] 4cc83006ec chore: update cask to 1.26.0 2026-03-11 04:33:26 +00:00
edde746 4954c9c569 fix: replace tunneling reopen with simple parameter toggle
Removes the full player restart cycle for tunneling changes that was
causing infinite spinning/blank screen on Amlogic devices (Onn 4K Pro).
2026-03-11 05:08:43 +01:00
edde746 8f1b40d143 fix: handle string childCount in collections 2026-03-11 05:08:43 +01:00
github-actions[bot] d72153c25a chore: bump version to 1.26.0 2026-03-11 04:08:09 +00:00
edde746 666b59c293 refactor(native-player): share player helpers 2026-03-11 01:30:11 +01:00
edde746 7543a8c55b refactor: clean up windows code 2026-03-10 22:24:14 +01:00
edde746 bfc7be2b80 fix: default to Vulkan on Windows
close #653
2026-03-10 19:42:43 +01:00
edde746 8b2c68f3ae chore: switch Windows libmpv builds to SourceForge 2026-03-10 19:08:04 +01:00
edde746 9aa019fe6f fix: ANR in detachSurface 2026-03-10 19:07:33 +01:00
edde746 23a100afdc feat: auto-reconnect VOD playback after network loss 2026-03-10 18:47:31 +01:00
edde746 af2ac2c993 fix: suppress ASS GL thread crash, update ass-media to 0.4.0 2026-03-10 06:41:43 +01:00
edde746 166e73cdaa fix: sync button focus nodes with actual action count 2026-03-10 06:08:55 +01:00
edde746 73fe9e054f fix: guard unsafe .first/.last calls to prevent StateError 2026-03-10 03:50:12 +01:00
edde746 42e066288f fix: clickable title event bubbling and movie support 2026-03-10 01:57:28 +01:00
edde746 682a04f047 feat: show per-server connection status on splash screen 2026-03-10 01:50:19 +01:00
edde746 29cb88e6e2 fix: translate remaining strings 2026-03-10 01:21:03 +01:00
edde746 6daf77c890 feat: convert settings dialogs to platform-adaptive menus
Replace AlertDialog-based selection dialogs with popup dropdowns on
desktop/TV and bottom sheets on mobile, matching the context menu
pattern. Fix popup background blending into cards by tinting surface.
2026-03-10 01:18:33 +01:00
edde746 0372840ee1 fix: localize EPG day names and reduce channel column width
close #661
2026-03-10 00:19:16 +01:00
edde746 3c51760203 feat: mouse-only clickable text on media cards 2026-03-09 23:56:24 +01:00
edde746 f3c735e3a1 fix: remove Docker bridge IP filter 2026-03-09 17:35:28 +01:00
edde746 7784cb7511 fix: TMDB icon gradient 2026-03-09 16:24:59 +01:00
edde746 82b0de9223 feat: display movie edition titles across all views 2026-03-09 16:16:37 +01:00
edde746 a6852d716f fix: handle null fields in PlexHome deserialization 2026-03-09 15:42:33 +01:00
edde746 92dfe5b41f fix: lazy Sparkle init to prevent macOS startup hang 2026-03-09 14:39:16 +01:00
edde746 16e96cdde0 fix: handle null fields in folder metadata deserialization 2026-03-09 14:10:37 +01:00
edde746 61e4bda616 fix: content strip text colors in light mode
close #659
2026-03-09 12:53:16 +01:00
edde746 e69fab87d0 fix: handle DVR tune error responses and show to user 2026-03-09 11:33:54 +01:00
edde746 a3e738773b fix: handle Metadata as list in DVR tune response 2026-03-09 10:52:57 +01:00
github-actions[bot] 425957c804 chore: update cask to 1.25.1 2026-03-09 06:39:08 +00:00
137 changed files with 5237 additions and 3713 deletions
+8 -7
View File
@@ -5,6 +5,7 @@ on:
env:
SENTRY_DART_DEFINE: ${{ github.repository == 'edde746/plezy' && '--dart-define=ENABLE_SENTRY=true' || '' }}
GIT_COMMIT_DART_DEFINE: --dart-define=GIT_COMMIT=${{ github.sha }}
jobs:
build-android:
@@ -61,7 +62,7 @@ jobs:
EOF
- name: Build APKs
run: flutter build apk --release --split-per-abi --dart-define=ENABLE_UPDATE_CHECK=true ${{ env.SENTRY_DART_DEFINE }}
run: flutter build apk --release --split-per-abi --dart-define=ENABLE_UPDATE_CHECK=true ${{ env.SENTRY_DART_DEFINE }} ${{ env.GIT_COMMIT_DART_DEFINE }}
- name: Clean up keystore
if: always()
@@ -129,7 +130,7 @@ jobs:
run: flutter pub get
- name: Build iOS (no codesign)
run: flutter build ios --release --no-codesign --dart-define=ENABLE_UPDATE_CHECK=true ${{ env.SENTRY_DART_DEFINE }}
run: flutter build ios --release --no-codesign --dart-define=ENABLE_UPDATE_CHECK=true ${{ env.SENTRY_DART_DEFINE }} ${{ env.GIT_COMMIT_DART_DEFINE }}
- name: Create IPA
run: |
@@ -187,7 +188,7 @@ jobs:
run: flutter pub get
- name: Build macOS
run: flutter build macos --release --dart-define=ENABLE_UPDATE_CHECK=true ${{ env.SENTRY_DART_DEFINE }}
run: flutter build macos --release --dart-define=ENABLE_UPDATE_CHECK=true ${{ env.SENTRY_DART_DEFINE }} ${{ env.GIT_COMMIT_DART_DEFINE }}
- name: Import Code Signing Certificate
env:
@@ -338,7 +339,7 @@ jobs:
run: flutter pub get
- name: Build Windows x64
run: flutter build windows --release --dart-define=ENABLE_UPDATE_CHECK=true ${{ env.SENTRY_DART_DEFINE }}
run: flutter build windows --release --dart-define=ENABLE_UPDATE_CHECK=true ${{ env.SENTRY_DART_DEFINE }} ${{ env.GIT_COMMIT_DART_DEFINE }}
- name: Upload x64 build
uses: actions/upload-artifact@v4
@@ -375,7 +376,7 @@ jobs:
run: flutter pub get
- name: Build Windows arm64
run: flutter build windows --release --dart-define=ENABLE_UPDATE_CHECK=true ${{ env.SENTRY_DART_DEFINE }}
run: flutter build windows --release --dart-define=ENABLE_UPDATE_CHECK=true ${{ env.SENTRY_DART_DEFINE }} ${{ env.GIT_COMMIT_DART_DEFINE }}
- name: Upload arm64 build
uses: actions/upload-artifact@v4
@@ -519,7 +520,7 @@ jobs:
run: flutter pub get
- name: Build Linux x64
run: flutter build linux --release --dart-define=ENABLE_UPDATE_CHECK=true ${{ env.SENTRY_DART_DEFINE }}
run: flutter build linux --release --dart-define=ENABLE_UPDATE_CHECK=true ${{ env.SENTRY_DART_DEFINE }} ${{ env.GIT_COMMIT_DART_DEFINE }}
env:
PKG_CONFIG_PATH: ${{ github.workspace }}/libmpv-prefix/lib/pkgconfig:${{ github.workspace }}/libmpv-prefix/lib/x86_64-linux-gnu/pkgconfig
@@ -627,7 +628,7 @@ jobs:
run: flutter pub get
- name: Build Linux arm64
run: flutter build linux --release --dart-define=ENABLE_UPDATE_CHECK=true ${{ env.SENTRY_DART_DEFINE }}
run: flutter build linux --release --dart-define=ENABLE_UPDATE_CHECK=true ${{ env.SENTRY_DART_DEFINE }} ${{ env.GIT_COMMIT_DART_DEFINE }}
env:
PKG_CONFIG_PATH: ${{ github.workspace }}/libmpv-prefix/lib/pkgconfig:${{ github.workspace }}/libmpv-prefix/lib/aarch64-linux-gnu/pkgconfig
+2 -2
View File
@@ -1,6 +1,6 @@
cask "plezy" do
version "1.25.0"
sha256 "c0ce419f06acd76207ad4d65fa3575fc8638b56fe5ce41264b0cf3aac6a3ba50"
version "1.27.0"
sha256 "39baaece57637889946ef847e678f33817c0b900ae52a626f5db69415a60fb7b"
url "https://github.com/edde746/plezy/releases/download/#{version}/plezy-macos.dmg"
name "Plezy"
+4
View File
@@ -26,6 +26,10 @@ A modern Plex client for desktop and mobile. Built with Flutter for native perfo
brew tap edde746/plezy https://github.com/edde746/plezy
brew install --cask plezy
```
- [AUR](https://aur.archlinux.org/packages/plezy-bin) (Arch Linux) - Community maintained by [@jianglai](https://github.com/jianglai):
```bash
yay -S plezy-bin
```
- **WinGet** (Windows):
```bash
winget install edde746.plezy
+7 -3
View File
@@ -138,11 +138,15 @@ dependencies {
implementation("androidx.media3:media3-ui:1.9.2")
implementation("androidx.media3:media3-common:1.9.2")
// OkHttp for connection pooling (prevents TCP reconnect stutter at high bitrates)
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("androidx.media3:media3-datasource-okhttp:1.9.2")
// FFmpeg audio decoder for unsupported codecs (ALAC, DTS, TrueHD, etc.)
implementation("org.jellyfin.media3:media3-ffmpeg-decoder:1.9.0+1")
// libass-android for ASS/SSA subtitle rendering (fork with font buffering fix)
implementation(files("libs/ass-media.aar"))
// libass-android for ASS/SSA subtitle rendering
implementation("io.github.peerless2012:ass-media:0.4.0")
// ass-kt core library (needed for AssRender.setFontScale)
implementation("io.github.peerless2012:ass-kt:0.4.0-beta01")
implementation("io.github.peerless2012:ass-kt:0.4.0")
}
Binary file not shown.
+1 -5
View File
@@ -9,11 +9,7 @@
<!-- Camera permission for QR code scanning (companion remote pairing) -->
<uses-permission android:name="android.permission.CAMERA"/>
<!-- PIP Permissions -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
<!-- Media session permissions for OS media controls -->
<!-- PIP and media session foreground service permissions -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
@@ -27,9 +27,9 @@ import java.io.File
class MainActivity : FlutterActivity() {
private val PIP_CHANNEL = "app.plezy/pip"
private val EXTERNAL_PLAYER_CHANNEL = "app.plezy/external_player"
private val THEME_CHANNEL = "app.plezy/theme"
private val PIP_CHANNEL = "com.plezy/pip"
private val EXTERNAL_PLAYER_CHANNEL = "com.plezy/external_player"
private val THEME_CHANNEL = "com.plezy/theme"
private var watchNextPlugin: WatchNextPlugin? = null
// Auto PiP state
@@ -265,11 +265,11 @@ class MainActivity : FlutterActivity() {
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean,newConfig: Configuration) {
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
MethodChannel( flutterEngine!!.dartExecutor.binaryMessenger, PIP_CHANNEL ).invokeMethod( "onPipChanged" , isInPictureInPictureMode)
// Notify ExoPlayer plugin to resize video surface for PiP
flutterEngine?.plugins?.get(ExoPlayerPlugin::class.java)?.let { plugin ->
(plugin as? ExoPlayerPlugin)?.onPipModeChanged(isInPictureInPictureMode)
flutterEngine?.let { engine ->
MethodChannel(engine.dartExecutor.binaryMessenger, PIP_CHANNEL).invokeMethod("onPipChanged", isInPictureInPictureMode)
engine.plugins.get(ExoPlayerPlugin::class.java)?.let { plugin ->
(plugin as? ExoPlayerPlugin)?.onPipModeChanged(isInPictureInPictureMode)
}
}
}
@@ -11,7 +11,7 @@ import androidx.media3.extractor.TrackOutput
/**
* ExtractorOutput wrapper that intercepts video track creation
* to insert DoviConvertingTrackOutput for DV processing.
* Shared by DoviExtractorWrapper (MP4) and DoviMatroskaExtractor (MKV).
* Shared by DoviExtractorWrapper for both MP4 and MKV containers.
*/
class DoviExtractorOutputWrapper(
private val delegate: ExtractorOutput,
@@ -1,149 +0,0 @@
package com.edde746.plezy.exoplayer
import android.util.Log
import androidx.media3.extractor.ExtractorInput
import androidx.media3.extractor.ExtractorOutput
import androidx.media3.extractor.mkv.MatroskaExtractor
import androidx.media3.extractor.text.SubtitleParser
import io.github.peerless2012.ass.media.AssHandler
/**
* MatroskaExtractor subclass that adds:
* 1. ASS subtitle support (font extraction from MKV attachments, video size reporting)
* 2. Dolby Vision Profile 7 → 8.1 conversion (RPU capture from BlockAdditions)
*
* Replaces both AssMatroskaExtractor and adds DV handling on top.
* Font extraction and video size reporting are replicated from AssMatroskaExtractor
* since that class is final and cannot be extended.
*/
class DoviMatroskaExtractor(
subtitleParserFactory: SubtitleParser.Factory,
private val assHandler: AssHandler,
private val dvMode: DvConversionMode = DvConversionMode.HEVC_STRIP,
) : MatroskaExtractor(subtitleParserFactory) {
companion object {
private const val TAG = "DoviMkvExtractor"
// Matroska element IDs for attachments
private const val ID_ATTACHMENTS = 0x1941A469
private const val ID_ATTACHED_FILE = 0x61A7
private const val ID_FILE_NAME = 0x466E
private const val ID_FILE_MIME_TYPE = 0x4660
private const val ID_FILE_DATA = 0x465C
// Track video element
private const val ID_VIDEO = 0xE0
// EBML header - hook point for output wrapping
private const val ID_EBML = 0x1A45DFA3
// MKV element type constants (matching MatroskaExtractor internals)
private const val TYPE_MASTER = 1
private const val TYPE_STRING = 3
private const val TYPE_BINARY = 4
private val FONT_MIME_TYPES = setOf(
"application/x-truetype-font",
"application/x-font-truetype",
"application/vnd.ms-opentype",
"font/sfnt",
"font/ttf",
"font/otf",
"font/collection",
)
private val extractorOutputField by lazy {
try {
MatroskaExtractor::class.java.getDeclaredField("extractorOutput").apply {
isAccessible = true
}
} catch (e: Exception) {
Log.w(TAG, "Cannot access extractorOutput field: ${e.message}")
null
}
}
}
// ASS font attachment state
private var currentAttachmentName: String? = null
private var currentAttachmentMime: String? = null
// DV conversion state
@Volatile var doviTrackOutput: DoviConvertingTrackOutput? = null
internal set
// ===== Matroska element overrides =====
override fun getElementType(id: Int): Int = when (id) {
ID_ATTACHMENTS, ID_ATTACHED_FILE -> TYPE_MASTER
ID_FILE_NAME, ID_FILE_MIME_TYPE -> TYPE_STRING
ID_FILE_DATA -> TYPE_BINARY
else -> super.getElementType(id)
}
override fun isLevel1Element(id: Int): Boolean =
super.isLevel1Element(id) || id == ID_ATTACHMENTS
override fun startMasterElement(id: Int, contentPosition: Long, contentSize: Long) {
when (id) {
ID_EBML -> {
wrapExtractorOutput()
super.startMasterElement(id, contentPosition, contentSize)
}
ID_ATTACHED_FILE -> clearAttachment()
else -> super.startMasterElement(id, contentPosition, contentSize)
}
}
override fun endMasterElement(id: Int) {
when (id) {
ID_VIDEO -> {
val track = getCurrentTrack(id)
assHandler.setVideoSize(track.width, track.height)
super.endMasterElement(id)
}
ID_ATTACHED_FILE -> clearAttachment()
else -> super.endMasterElement(id)
}
}
override fun stringElement(id: Int, value: String) {
when (id) {
ID_FILE_NAME -> currentAttachmentName = value
ID_FILE_MIME_TYPE -> currentAttachmentMime = value
else -> super.stringElement(id, value)
}
}
override fun binaryElement(id: Int, size: Int, input: ExtractorInput) {
if (id == ID_FILE_DATA) {
val mime = currentAttachmentMime
val name = currentAttachmentName
if (mime != null && name != null && mime in FONT_MIME_TYPES) {
val data = ByteArray(size)
input.readFully(data, 0, size)
assHandler.addFont(name, data)
} else {
input.skipFully(size)
}
clearAttachment()
} else {
super.binaryElement(id, size, input)
}
}
// ===== ExtractorOutput wrapping =====
private fun wrapExtractorOutput() {
val field = extractorOutputField ?: return
val output = field.get(this) as? ExtractorOutput ?: return
if (output is DoviExtractorOutputWrapper) return
field.set(this, DoviExtractorOutputWrapper(output, dvMode) { doviTrackOutput = it })
}
private fun clearAttachment() {
currentAttachmentName = null
currentAttachmentMime = null
}
}
@@ -9,7 +9,6 @@ import android.net.Uri
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import android.util.Log
import android.view.Gravity
import android.view.SurfaceView
@@ -32,6 +31,9 @@ import androidx.media3.common.VideoSize
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.analytics.AnalyticsListener
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.datasource.okhttp.OkHttpDataSource
import okhttp3.OkHttpClient
import java.util.concurrent.TimeUnit
import androidx.media3.exoplayer.DefaultLoadControl
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.mediacodec.MediaCodecSelector
@@ -55,9 +57,7 @@ import io.github.peerless2012.ass.media.parser.AssSubtitleParserFactory
import io.github.peerless2012.ass.media.type.AssRenderType
import io.github.peerless2012.ass.media.widget.AssSubtitleView
interface ExoPlayerDelegate {
fun onPropertyChange(name: String, value: Any?)
fun onEvent(name: String, data: Map<String, Any>?)
interface ExoPlayerDelegate : com.edde746.plezy.shared.PlayerDelegate {
/**
* Called when ExoPlayer encounters a format it cannot play.
@@ -75,16 +75,6 @@ interface ExoPlayerDelegate {
@OptIn(UnstableApi::class)
class ExoPlayerCore(private val activity: Activity) : Player.Listener {
private data class SubtitleStyleState(
val fontSize: Float,
val textColor: String,
val borderSize: Float,
val borderColor: String,
val bgColor: String,
val bgOpacity: Int,
val subtitlePosition: Int,
)
companion object {
private const val TAG = "ExoPlayerCore"
@@ -95,6 +85,18 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
// Codec capability caches — codec support doesn't change at runtime
private val hwAudioDecoderCache = HashMap<String, Boolean>()
private val tunneledPlaybackCache = HashMap<String, Boolean>()
private var assGlCrashHandlerInstalled = false
// Process-wide OkHttp client with connection pooling — reuses TCP connections
// on buffer refill to prevent reconnect-induced frame drops at high bitrates
private val okHttpClient: OkHttpClient by lazy {
OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(15, TimeUnit.SECONDS)
.build()
}
}
private var surfaceView: SurfaceView? = null
@@ -104,6 +106,8 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
private var overlayLayoutListener: ViewTreeObserver.OnGlobalLayoutListener? = null
private var lastVideoSize: VideoSize? = null
private var exoPlayer: ExoPlayer? = null
private var httpDataSourceFactory: OkHttpDataSource.Factory? = null
private var dataSourceFactory: DefaultDataSource.Factory? = null
private var trackSelector: DefaultTrackSelector? = null
private var tunnelingUserEnabled: Boolean = true
private var tunnelingDisabledForAudioCodec: Boolean = false
@@ -111,12 +115,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
private val tunnelingDisabledForCodec: Boolean
get() = tunnelingDisabledForAudioCodec || tunnelingDisabledForVideoCodec
private var currentTunneledPlayback: Boolean = false
private var tunnelingCorrectionInProgress: Boolean = false
private var pendingRestoredAudioTrackId: String? = null
private var pendingRestoredSubtitleTrackId: String? = null
private var lastSeekable: Boolean? = null
private var lastTunnelingCorrectionTarget: Boolean? = null
private var lastTunnelingCorrectionAtMs: Long = 0L
@Volatile private var disposing: Boolean = false
private var pendingStartPositionMs: Long = 0L
@@ -155,8 +154,6 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
private var selectedAudioTrackId: String? = null
private var selectedSubtitleTrackId: String? = null
private var selectedExternalSubtitleIndex: Int? = null
private var subtitleStyleState: SubtitleStyleState? = null
private var reapplySubtitleStyleOnNextReady: Boolean = false
private fun emitLog(level: String, prefix: String, message: String) {
when (level) {
@@ -222,7 +219,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
// DV conversion state
private var dvMode: DvConversionMode = DvConversionMode.DISABLED
private var dv7RetryAttempted = false
@Volatile private var activeDoviMkvExtractor: DoviMatroskaExtractor? = null
@Volatile private var activeDoviMkvWrapper: DoviExtractorWrapper? = null
@Volatile private var activeDoviMp4Wrapper: DoviExtractorWrapper? = null
fun initialize(bufferSizeBytes: Int? = null, tunnelingEnabled: Boolean = true): Boolean {
@@ -323,7 +320,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
trackSelector = DefaultTrackSelector(activity).apply {
setParameters(
buildUponParameters()
.setTunnelingEnabled(false)
.setTunnelingEnabled(tunnelingUserEnabled)
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false)
.setPreferredTextLanguage("en")
)
@@ -352,8 +349,9 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
}
}
// Create factories for buildWithAssSupport (like AndroidTV-FireTV)
val dataSourceFactory = DefaultDataSource.Factory(activity)
// OkHttp DataSource for TCP connection pooling — prevents reconnect stutter at high bitrates
httpDataSourceFactory = OkHttpDataSource.Factory(okHttpClient)
dataSourceFactory = DefaultDataSource.Factory(activity, httpDataSourceFactory!!)
val extractorsFactory = DefaultExtractorsFactory()
// Inline buildWithAssSupport to retain AssHandler reference for font scale control.
@@ -377,12 +375,13 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
extractorsFactory.createExtractors().map { extractor ->
when {
extractor is MatroskaExtractor -> {
val assExtractor = AssMatroskaExtractor(assParserFactory, handler)
if (doviEnabled) {
DoviMatroskaExtractor(assParserFactory, handler, currentDvMode).also {
activeDoviMkvExtractor = it
DoviExtractorWrapper(assExtractor, currentDvMode).also {
activeDoviMkvWrapper = it
}
} else {
AssMatroskaExtractor(assParserFactory, handler)
assExtractor
}
}
doviEnabled && (extractor is Mp4Extractor || extractor is FragmentedMp4Extractor) -> {
@@ -395,7 +394,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
}.toTypedArray()
}
val mediaSourceFactory = DefaultMediaSourceFactory(dataSourceFactory, wrappedExtractorsFactory)
val mediaSourceFactory = DefaultMediaSourceFactory(dataSourceFactory!!, wrappedExtractorsFactory)
.setSubtitleParserFactory(assParserFactory)
val wrappedRenderersFactory = AssRenderersFactory(handler, renderersFactory)
@@ -409,12 +408,13 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
val targetBufferBytes = if (bufferSizeBytes != null && bufferSizeBytes > 0) {
bufferSizeBytes
} else {
// Scale buffer to available memory to reduce hardware decoder pressure
// Scale buffer to available memory to reduce hardware decoder pressure.
// Larger buffers reduce oscillation frequency at high bitrates (50-100Mbps).
when {
availableMB <= 512 -> 30 * 1024 * 1024
availableMB <= 1024 -> 50 * 1024 * 1024
availableMB <= 2048 -> 60 * 1024 * 1024
else -> 130 * 1024 * 1024
availableMB <= 1024 -> 80 * 1024 * 1024
availableMB <= 2048 -> 120 * 1024 * 1024
else -> 200 * 1024 * 1024
}
}
@@ -427,7 +427,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
setBufferDurationsMs(30_000, 60_000, 2_500, 5_000)
}
}.build()
emitLog("info", "init", "Buffer: ${targetBufferBytes / 1024 / 1024}MB limit, available=${availableMB}MB, tunneling=${tunnelingUserEnabled}")
emitLog("info", "init", "Buffer: ${targetBufferBytes / 1024 / 1024}MB limit, available=${availableMB}MB, tunneling=${tunnelingUserEnabled}, dataSource=OkHttp")
exoPlayer = ExoPlayer.Builder(activity)
.setTrackSelector(trackSelector!!)
@@ -446,6 +446,23 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
// Initialize handler (registers as Player.Listener, creates Handler)
handler.init(exoPlayer!!)
// Suppress ass-media GL thread crash when EGL init partially fails (e.g. Tegra).
// AssRender.onSurfaceDestroyed() accesses uninitialized glProgram lateinit property
// during error cleanup, which is a bug in the library. The render thread dying only
// affects ASS subtitle GPU rendering; non-ASS subtitles are unaffected.
if (!assGlCrashHandlerInstalled) {
assGlCrashHandlerInstalled = true
val previousHandler = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
if (thread.name.contains("AssTexRenderThread") &&
throwable is UninitializedPropertyAccessException) {
Log.e(TAG, "ASS GL thread crash suppressed (EGL init failure)", throwable)
} else {
previousHandler?.uncaughtException(thread, throwable)
}
}
}
exoPlayer!!.addListener(this)
exoPlayer!!.addAnalyticsListener(decoderHangListener)
surfaceView?.let { exoPlayer!!.setVideoSurfaceView(it) }
@@ -585,7 +602,6 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
}
delegate?.onPropertyChange("paused-for-cache", false)
delegate?.onEvent("playback-restart", null)
applyStoredSubtitleStyleIfNeeded()
emitTrackList()
// Start frame watchdog to detect black screen (HDR tunneling issue)
@@ -634,11 +650,6 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
evaluateAudioCodecForTunneling()
evaluateVideoCodecForTunneling()
captureSelectedTrackIds(tracks)
if (maybeRestoreTrackSelectionsAfterReopen()) {
emitTrackList()
return
}
updateTunnelingState("tracks changed")
emitTrackList()
}
@@ -959,11 +970,15 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
}
private fun updateTunnelingState(reason: String) {
val selector = trackSelector ?: return
val player = exoPlayer ?: return
val desiredTunneling = desiredTunnelingMode(player)
if (desiredTunneling == currentTunneledPlayback) return
emitLog("info", "tunneling", "Mode mismatch current=$currentTunneledPlayback desired=$desiredTunneling (reason=$reason, user=$tunnelingUserEnabled, speed=${player.playbackParameters.speed}, audioCodecDisabled=$tunnelingDisabledForAudioCodec, videoCodecDisabled=$tunnelingDisabledForVideoCodec)")
requestTunnelingCorrectionReopen(desiredTunneling, reason)
val shouldTunnel = tunnelingUserEnabled && (player.playbackParameters.speed == 1f) && !tunnelingDisabledForCodec
if (shouldTunnel == currentTunneledPlayback) return
currentTunneledPlayback = shouldTunnel
emitLog("info", "tunneling", "Toggling tunneling=$shouldTunnel (reason=$reason, user=$tunnelingUserEnabled, speed=${player.playbackParameters.speed}, audioCodecDisabled=$tunnelingDisabledForAudioCodec, videoCodecDisabled=$tunnelingDisabledForVideoCodec)")
selector.setParameters(
selector.buildUponParameters().setTunnelingEnabled(shouldTunnel)
)
}
private fun evaluateAudioCodecForTunneling() {
@@ -982,165 +997,6 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
}
}
private fun desiredTunnelingMode(player: ExoPlayer): Boolean {
if (!tunnelingUserEnabled) return false
if (player.playbackParameters.speed != 1f) return false
val selectedVideoGroup = player.currentTracks.groups.firstOrNull {
it.type == C.TRACK_TYPE_VIDEO && it.isSelected
} ?: return false
val selectedAudioGroup = player.currentTracks.groups.firstOrNull {
it.type == C.TRACK_TYPE_AUDIO && it.isSelected
} ?: return false
val videoMimeType = selectedVideoGroup.mediaTrackGroup.getFormat(0).sampleMimeType ?: return false
val audioMimeType = selectedAudioGroup.mediaTrackGroup.getFormat(0).sampleMimeType ?: return false
return videoCodecSupportsTunneledPlayback(videoMimeType) && hasHardwareAudioDecoder(audioMimeType)
}
private fun requestTunnelingCorrectionReopen(targetTunneledPlayback: Boolean, reason: String) {
val uri = currentMediaUri ?: return
val player = exoPlayer ?: return
if (tunnelingCorrectionInProgress) {
emitLog("debug", "tunneling", "Skipping reopen to $targetTunneledPlayback while correction is already in progress")
return
}
val now = SystemClock.elapsedRealtime()
if (lastTunnelingCorrectionTarget == targetTunneledPlayback &&
now - lastTunnelingCorrectionAtMs < 3000L) {
emitLog("warn", "tunneling", "Skipping reopen to $targetTunneledPlayback to avoid correction loop")
return
}
val shouldResume = player.playWhenReady
tunnelingCorrectionInProgress = true
pendingRestoredAudioTrackId = selectedAudioTrackId
pendingRestoredSubtitleTrackId = selectedSubtitleTrackId
lastTunnelingCorrectionTarget = targetTunneledPlayback
lastTunnelingCorrectionAtMs = now
currentTunneledPlayback = targetTunneledPlayback
tunnelingDisabledForAudioCodec = false
tunnelingDisabledForVideoCodec = false
pendingStartPositionMs = player.currentPosition
reapplySubtitleStyleOnNextReady = true
applyTunnelingMode(targetTunneledPlayback)
emitLog(
"info",
"tunneling",
"Reopening playback with tunneling=$targetTunneledPlayback (reason=$reason, position=${pendingStartPositionMs}ms, audio=$pendingRestoredAudioTrackId, subtitle=$pendingRestoredSubtitleTrackId)"
)
if (currentMediaIsLive) {
val reopenHeaders = currentHeaders
val dataSourceFactory = if (!reopenHeaders.isNullOrEmpty()) {
DefaultDataSource.Factory(activity,
androidx.media3.datasource.DefaultHttpDataSource.Factory()
.setDefaultRequestProperties(reopenHeaders))
} else {
DefaultDataSource.Factory(activity)
}
val extractorsFactory = androidx.media3.extractor.ExtractorsFactory {
arrayOf(MatroskaExtractor(MatroskaExtractor.FLAG_DISABLE_SEEK_FOR_CUES))
}
val mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory, extractorsFactory)
.createMediaSource(MediaItem.fromUri(uri))
player.setMediaSource(mediaSource, pendingStartPositionMs)
player.prepare()
player.playWhenReady = shouldResume
setVisible(currentVisible)
return
}
player.setMediaItem(buildMediaItem(uri), pendingStartPositionMs)
player.prepare()
player.playWhenReady = shouldResume
setVisible(currentVisible)
}
private fun captureSelectedTrackIds(tracks: Tracks) {
val audioGroups = tracks.groups.filter { it.type == C.TRACK_TYPE_AUDIO }
val textGroups = tracks.groups.filter { it.type == C.TRACK_TYPE_TEXT }
val selectedAudioIndex = audioGroups.indexOfFirst { it.isSelected }
val selectedSubtitleIndex = textGroups.indexOfFirst { it.isSelected }
selectedAudioTrackId = selectedAudioIndex.takeIf { it >= 0 }?.let { "${C.TRACK_TYPE_AUDIO}_$it" }
selectedSubtitleTrackId = when {
selectedExternalSubtitleIndex != null -> "ext_sub_$selectedExternalSubtitleIndex"
selectedSubtitleIndex >= 0 -> "${C.TRACK_TYPE_TEXT}_$selectedSubtitleIndex"
textGroups.isNotEmpty() || externalSubtitles.isNotEmpty() -> "no"
else -> null
}
}
private fun maybeRestoreTrackSelectionsAfterReopen(): Boolean {
if (!tunnelingCorrectionInProgress) return false
val audioToRestore = pendingRestoredAudioTrackId
if (audioToRestore != null && audioToRestore != selectedAudioTrackId) {
val audioIndex = audioToRestore.substringAfter('_', "").toIntOrNull()
val player = exoPlayer
if (player != null && audioIndex != null) {
val audioGroups = player.currentTracks.groups.filter { it.type == C.TRACK_TYPE_AUDIO }
if (audioIndex in audioGroups.indices) {
emitLog("info", "tunneling", "Restoring audio track $audioToRestore after reopen")
selectAudioTrack(audioToRestore)
return true
}
}
}
val subtitleToRestore = pendingRestoredSubtitleTrackId
if (subtitleToRestore != null && subtitleToRestore != selectedSubtitleTrackId) {
when {
subtitleToRestore == "no" -> {
emitLog("info", "tunneling", "Restoring subtitles off after reopen")
selectSubtitleTrack("no")
return true
}
subtitleToRestore.startsWith("ext_sub_") -> {
val externalIndex = subtitleToRestore.removePrefix("ext_sub_").toIntOrNull()
if (externalIndex != null && selectedExternalSubtitleIndex != externalIndex && externalIndex in externalSubtitles.indices) {
emitLog("info", "tunneling", "Restoring external subtitle $subtitleToRestore after reopen")
selectSubtitleTrack(subtitleToRestore)
return true
}
}
else -> {
val subtitleIndex = subtitleToRestore.substringAfter('_', "").toIntOrNull()
val player = exoPlayer
if (player != null && subtitleIndex != null) {
val textGroups = player.currentTracks.groups.filter { it.type == C.TRACK_TYPE_TEXT }
if (subtitleIndex in textGroups.indices) {
emitLog("info", "tunneling", "Restoring subtitle track $subtitleToRestore after reopen")
selectSubtitleTrack(subtitleToRestore)
return true
}
}
}
}
}
pendingRestoredAudioTrackId = null
pendingRestoredSubtitleTrackId = null
tunnelingCorrectionInProgress = false
emitLog("info", "tunneling", "Tunneling correction reopen completed")
return false
}
private fun applyTunnelingMode(enabled: Boolean) {
val selector = trackSelector ?: return
selector.parameters = selector.buildUponParameters()
.setTunnelingEnabled(enabled)
.build()
}
private fun buildMediaItem(uri: String): MediaItem {
val mediaItemBuilder = MediaItem.Builder()
.setUri(uri)
@@ -1152,23 +1008,6 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
return mediaItemBuilder.build()
}
private fun applyStoredSubtitleStyleIfNeeded() {
if (!reapplySubtitleStyleOnNextReady) return
reapplySubtitleStyleOnNextReady = false
subtitleStyleState?.let {
setSubtitleStyle(
fontSize = it.fontSize,
textColor = it.textColor,
borderSize = it.borderSize,
borderColor = it.borderColor,
bgColor = it.bgColor,
bgOpacity = it.bgOpacity,
subtitlePosition = it.subtitlePosition,
)
}
}
// Decoder hang detection via AnalyticsListener:
// Tracks the gap between onVideoDecoderInitialized and onRenderedFirstFrame.
// If the decoder is initialized and fed input but never produces output, it's hung
@@ -1325,40 +1164,35 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
currentMediaUri = uri
currentHeaders = headers
currentMediaIsLive = isLive
// Apply auth/custom headers to the OkHttp DataSource for this session
httpDataSourceFactory?.setDefaultRequestProperties(
if (!headers.isNullOrEmpty()) headers else emptyMap()
)
externalSubtitles.clear()
selectedExternalSubtitleIndex = null
selectedAudioTrackId = null
selectedSubtitleTrackId = null
pendingRestoredAudioTrackId = null
pendingRestoredSubtitleTrackId = null
tunnelingCorrectionInProgress = false
lastTunnelingCorrectionTarget = null
lastTunnelingCorrectionAtMs = 0L
reapplySubtitleStyleOnNextReady = false
tunnelingDisabledForAudioCodec = false
tunnelingDisabledForVideoCodec = false
currentTunneledPlayback = false
currentTunneledPlayback = tunnelingUserEnabled
pendingStartPositionMs = startPositionMs
applyTunnelingMode(false)
trackSelector?.setParameters(
trackSelector!!.buildUponParameters().setTunnelingEnabled(tunnelingUserEnabled)
)
emitSeekable(false, force = true)
if (isLive) {
// Live MKV streams lack Cues (seek index). FLAG_DISABLE_SEEK_FOR_CUES tells
// MatroskaExtractor to not seek for them, treating the stream as unseekable
// so data flows immediately without hanging.
val dataSourceFactory = if (!headers.isNullOrEmpty()) {
DefaultDataSource.Factory(activity,
androidx.media3.datasource.DefaultHttpDataSource.Factory()
.setDefaultRequestProperties(headers))
} else {
DefaultDataSource.Factory(activity)
}
// Headers already applied to httpDataSourceFactory above.
val extractorsFactory = androidx.media3.extractor.ExtractorsFactory {
arrayOf(MatroskaExtractor(MatroskaExtractor.FLAG_DISABLE_SEEK_FOR_CUES))
}
val mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory, extractorsFactory)
val mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory!!, extractorsFactory)
.createMediaSource(MediaItem.fromUri(uri))
exoPlayer?.apply {
@@ -1550,15 +1384,6 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
bgOpacity: Int,
subtitlePosition: Int = 100
) {
subtitleStyleState = SubtitleStyleState(
fontSize = fontSize,
textColor = textColor,
borderSize = borderSize,
borderColor = borderColor,
bgColor = bgColor,
bgOpacity = bgOpacity,
subtitlePosition = subtitlePosition,
)
activity.runOnUiThread {
// 1. Non-ASS subtitles: CaptionStyleCompat on SubtitleView
val fgColor = Color.parseColor(textColor)
@@ -1694,7 +1519,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
"isPlaying" to player.isPlaying,
"playbackState" to player.playbackState,
// DV conversion (query extractor's track output, which is set during extraction)
*(activeDoviMkvExtractor?.doviTrackOutput
*(activeDoviMkvWrapper?.doviTrackOutput
?: activeDoviMp4Wrapper?.doviTrackOutput).let { dovi ->
arrayOf(
"dvConversionActive" to (dovi?.conversionActive == true),
@@ -1762,11 +1587,6 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
tunnelingDisabledForAudioCodec = false
tunnelingDisabledForVideoCodec = false
currentTunneledPlayback = false
tunnelingCorrectionInProgress = false
pendingRestoredAudioTrackId = null
pendingRestoredSubtitleTrackId = null
lastTunnelingCorrectionTarget = null
lastTunnelingCorrectionAtMs = 0L
pendingStartPositionMs = 0L
currentMediaIsLive = false
currentVisible = false
@@ -1774,12 +1594,13 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
selectedAudioTrackId = null
selectedSubtitleTrackId = null
selectedExternalSubtitleIndex = null
reapplySubtitleStyleOnNextReady = false
exoPlayer?.clearVideoSurface()
exoPlayer?.removeListener(this)
exoPlayer?.release()
exoPlayer = null
trackSelector = null
httpDataSourceFactory = null
dataSourceFactory = null
assHandler = null
val cb = surfaceCallback
@@ -6,7 +6,6 @@ import android.content.Context
import android.net.Uri
import android.util.Log
import com.edde746.plezy.mpv.MpvPlayerCore
import com.edde746.plezy.mpv.MpvPlayerDelegate
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.embedding.engine.plugins.activity.ActivityAware
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
@@ -15,7 +14,7 @@ import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
EventChannel.StreamHandler, ActivityAware, ExoPlayerDelegate, MpvPlayerDelegate {
EventChannel.StreamHandler, ActivityAware, ExoPlayerDelegate {
companion object {
private const val TAG = "ExoPlayerPlugin"
@@ -34,9 +33,9 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
private var activityBinding: ActivityPluginBinding? = null
private val nameToId = mutableMapOf<String, Int>()
private var configuredBufferSizeBytes: Int? = null
private var configuredTunnelingEnabled: Boolean = true
private var debugLoggingEnabled: Boolean = false
private val pendingMpvProperties = mutableListOf<Pair<String, String>>()
// FlutterPlugin
@@ -133,6 +132,7 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
}
"setSubtitleStyle" -> handleSetSubtitleStyle(call, result)
"observeProperty" -> handleObserveProperty(call, result)
"setMpvProperty" -> handleSetMpvProperty(call, result)
"setLogLevel" -> {
val level = call.argument<String>("level") ?: "warn"
debugLoggingEnabled = (level == "v" || level == "debug" || level == "trace")
@@ -159,7 +159,6 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
val bufferSizeBytes = call.argument<Int>("bufferSizeBytes")
val tunnelingEnabled = call.argument<Boolean>("tunnelingEnabled") ?: true
configuredBufferSizeBytes = bufferSizeBytes
configuredTunnelingEnabled = tunnelingEnabled
currentActivity.runOnUiThread {
try {
@@ -209,6 +208,9 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
return
}
// New media = fresh slate for pending MPV properties
pendingMpvProperties.clear()
activity?.runOnUiThread {
if (usingMpvFallback) {
// MPV: Build loadfile command with options
@@ -222,7 +224,9 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
val optionsStr = options.joinToString(",")
// Convert content:// URIs to fdclose:// for MPV (SAF SD card downloads)
val mpvUri = openContentFd(uri)?.let { "fdclose://$it" } ?: uri
mpvCore?.command(arrayOf("loadfile", mpvUri, "replace", "-1", optionsStr))
mpvCore?.runOnExecutor {
mpvCore?.command(arrayOf("loadfile", mpvUri, "replace", "-1", optionsStr))
}
} else {
playerCore?.open(uri, headers, startPositionMs, autoPlay, isLive)
}
@@ -233,7 +237,7 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
private fun handlePlay(result: MethodChannel.Result) {
activity?.runOnUiThread {
if (usingMpvFallback) {
mpvCore?.setProperty("pause", "no")
mpvCore?.runOnExecutor { mpvCore?.setProperty("pause", "no") }
} else {
playerCore?.play()
}
@@ -244,7 +248,7 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
private fun handlePause(result: MethodChannel.Result) {
activity?.runOnUiThread {
if (usingMpvFallback) {
mpvCore?.setProperty("pause", "yes")
mpvCore?.runOnExecutor { mpvCore?.setProperty("pause", "yes") }
} else {
playerCore?.pause()
}
@@ -255,8 +259,10 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
private fun handleStop(result: MethodChannel.Result) {
activity?.runOnUiThread {
if (usingMpvFallback) {
mpvCore?.command(arrayOf("stop"))
mpvCore?.setVisible(false)
mpvCore?.runOnExecutor {
mpvCore?.command(arrayOf("stop"))
mpvCore?.setVisible(false)
}
} else {
playerCore?.stop()
}
@@ -275,7 +281,9 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
activity?.runOnUiThread {
if (usingMpvFallback) {
val positionSeconds = positionMs / 1000.0
mpvCore?.command(arrayOf("seek", positionSeconds.toString(), "absolute"))
mpvCore?.runOnExecutor {
mpvCore?.command(arrayOf("seek", positionSeconds.toString(), "absolute"))
}
} else {
playerCore?.seekTo(positionMs)
}
@@ -293,7 +301,7 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
activity?.runOnUiThread {
if (usingMpvFallback) {
mpvCore?.setProperty("volume", volume.toString())
mpvCore?.runOnExecutor { mpvCore?.setProperty("volume", volume.toString()) }
} else {
playerCore?.setVolume(volume / 100f) // Convert 0-100 to 0-1
}
@@ -311,7 +319,7 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
activity?.runOnUiThread {
if (usingMpvFallback) {
mpvCore?.setProperty("speed", rate.toString())
mpvCore?.runOnExecutor { mpvCore?.setProperty("speed", rate.toString()) }
} else {
playerCore?.setPlaybackSpeed(rate)
}
@@ -331,7 +339,7 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
if (usingMpvFallback) {
// MPV uses numeric track IDs - extract from string format
val numericId = trackId.split("_").lastOrNull()?.toIntOrNull() ?: 1
mpvCore?.setProperty("aid", numericId.toString())
mpvCore?.runOnExecutor { mpvCore?.setProperty("aid", numericId.toString()) }
} else {
playerCore?.selectAudioTrack(trackId)
}
@@ -345,11 +353,13 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
// trackId can be null or "no" to disable subtitles
activity?.runOnUiThread {
if (usingMpvFallback) {
if (trackId == null || trackId == "no") {
mpvCore?.setProperty("sid", "no")
} else {
val numericId = trackId.split("_").lastOrNull()?.toIntOrNull() ?: 1
mpvCore?.setProperty("sid", numericId.toString())
mpvCore?.runOnExecutor {
if (trackId == null || trackId == "no") {
mpvCore?.setProperty("sid", "no")
} else {
val numericId = trackId.split("_").lastOrNull()?.toIntOrNull() ?: 1
mpvCore?.setProperty("sid", numericId.toString())
}
}
} else {
playerCore?.selectSubtitleTrack(trackId)
@@ -373,7 +383,9 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
activity?.runOnUiThread {
if (usingMpvFallback) {
val selectFlag = if (select) "select" else "auto"
mpvCore?.command(arrayOf("sub-add", uri, selectFlag, title ?: "External"))
mpvCore?.runOnExecutor {
mpvCore?.command(arrayOf("sub-add", uri, selectFlag, title ?: "External"))
}
} else {
playerCore?.addSubtitleTrack(uri, title, language, mimeType, select)
}
@@ -472,17 +484,36 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
result.success(null)
}
private fun handleSetMpvProperty(call: MethodCall, result: MethodChannel.Result) {
val name = call.argument<String>("name")
val value = call.argument<String>("value")
if (name == null || value == null) {
result.error("INVALID_ARGS", "Missing 'name' or 'value'", null)
return
}
if (usingMpvFallback) {
mpvCore?.runOnExecutor { mpvCore?.setProperty(name, value) }
} else {
// Store for later application if ExoPlayer falls back to MPV
pendingMpvProperties.add(Pair(name, value))
}
result.success(null)
}
private fun handleGetStats(result: MethodChannel.Result) {
activity?.runOnUiThread {
val stats = if (usingMpvFallback) {
// For MPV fallback, query MPV properties directly
getMpvStats()
} else {
if (usingMpvFallback) {
mpvCore?.runOnExecutor {
val stats = getMpvStats()
activity?.runOnUiThread { result.success(stats) }
} ?: result.success(mapOf("playerType" to "mpv"))
} else {
activity?.runOnUiThread {
val coreStats = playerCore?.getStats() ?: emptyMap()
coreStats + mapOf("playerType" to "exoplayer")
}
result.success(stats)
} ?: result.success(mapOf("playerType" to "unknown"))
result.success(coreStats + mapOf("playerType" to "exoplayer"))
} ?: result.success(mapOf("playerType" to "unknown"))
}
}
/**
@@ -632,65 +663,80 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
usingMpvFallback = true
fallbackInProgress = false
// Configure basic MPV properties for Plex playback
mpvCore?.setProperty("hwdec", "mediacodec,mediacodec-copy")
mpvCore?.setProperty("vo", "gpu")
mpvCore?.setProperty("ao", "audiotrack")
// Snapshot pending properties on main thread before clearing
val pendingProps = pendingMpvProperties.toList()
pendingMpvProperties.clear()
// Forward user's buffer config to MPV fallback
configuredBufferSizeBytes?.let { bytes ->
if (bytes > 0) {
mpvCore?.setProperty("demuxer-max-bytes", bytes.toString())
}
}
// Setup property observers
mpvCore?.observeProperty("time-pos", "double")
mpvCore?.observeProperty("duration", "double")
mpvCore?.observeProperty("seekable", "flag")
mpvCore?.observeProperty("pause", "flag")
mpvCore?.observeProperty("paused-for-cache", "flag")
mpvCore?.observeProperty("demuxer-cache-time", "double")
mpvCore?.observeProperty("eof-reached", "flag")
mpvCore?.observeProperty("track-list", "string")
mpvCore?.observeProperty("aid", "string")
mpvCore?.observeProperty("sid", "string")
mpvCore?.observeProperty("volume", "double")
mpvCore?.observeProperty("speed", "double")
// Show the MPV surface
mpvCore?.setVisible(true)
// Load media at the same position
val startSeconds = positionMs / 1000.0
val options = mutableListOf<String>()
options.add("start=$startSeconds")
headers?.forEach { (key, value) ->
options.add("http-header-fields-append=$key: $value")
}
val optionsStr = options.joinToString(",")
// Convert content:// URIs to fdclose:// for MPV (SAF SD card downloads)
// Compute content FD on main thread (needs contentResolver)
val mpvUri = openContentFd(uri)?.let { "fdclose://$it" } ?: uri
mpvCore?.command(arrayOf("loadfile", mpvUri, "replace", "-1", optionsStr))
// On GPUs without compute shaders, MPV can't do dynamic peak detection
// and spline tone-mapping produces dim/washed-out results with extreme
// static HDR peak metadata. Use reinhard which handles this better.
val peakDetection = mpvCore?.getProperty("hdr-compute-peak")
if (peakDetection == "no") {
Log.i(TAG, "No compute shaders — overriding tone-mapping to reinhard")
mpvCore?.setProperty("tone-mapping", "reinhard")
mpvCore?.setProperty("tone-mapping-param", "0.7")
mpvCore?.setProperty("tone-mapping-mode", "luma")
// Buffer size for closure
val bufferSize = configuredBufferSizeBytes
mpvCore?.runOnExecutor {
// Configure basic MPV properties for Plex playback
mpvCore?.setProperty("hwdec", "mediacodec,mediacodec-copy")
mpvCore?.setProperty("vo", "gpu")
mpvCore?.setProperty("ao", "audiotrack")
// Forward user's buffer config to MPV fallback
if (bufferSize != null && bufferSize > 0) {
mpvCore?.setProperty("demuxer-max-bytes", bufferSize.toString())
}
// Apply pending MPV properties from Dart
for ((propName, propValue) in pendingProps) {
mpvCore?.setProperty(propName, propValue)
}
// Setup property observers
mpvCore?.observeProperty("time-pos", "double")
mpvCore?.observeProperty("duration", "double")
mpvCore?.observeProperty("seekable", "flag")
mpvCore?.observeProperty("pause", "flag")
mpvCore?.observeProperty("paused-for-cache", "flag")
mpvCore?.observeProperty("demuxer-cache-time", "double")
mpvCore?.observeProperty("eof-reached", "flag")
mpvCore?.observeProperty("track-list", "string")
mpvCore?.observeProperty("aid", "string")
mpvCore?.observeProperty("sid", "string")
mpvCore?.observeProperty("volume", "double")
mpvCore?.observeProperty("speed", "double")
// Show the MPV surface (internally posts to UI)
mpvCore?.setVisible(true)
// Load media at the same position
val startSeconds = positionMs / 1000.0
val options = mutableListOf<String>()
options.add("start=$startSeconds")
headers?.forEach { (key, value) ->
options.add("http-header-fields-append=$key: $value")
}
val optionsStr = options.joinToString(",")
mpvCore?.command(arrayOf("loadfile", mpvUri, "replace", "-1", optionsStr))
// On GPUs without compute shaders, MPV can't do dynamic peak detection
// and spline tone-mapping produces dim/washed-out results with extreme
// static HDR peak metadata. Use reinhard which handles this better.
val peakDetection = mpvCore?.getProperty("hdr-compute-peak")
if (peakDetection == "no") {
Log.i(TAG, "No compute shaders — overriding tone-mapping to reinhard")
mpvCore?.setProperty("tone-mapping", "reinhard")
mpvCore?.setProperty("tone-mapping-param", "0.7")
mpvCore?.setProperty("tone-mapping-mode", "luma")
}
// Request audio focus
mpvCore?.requestAudioFocus()
// Emit backend-switched event on main thread
activity?.runOnUiThread {
onEvent("backend-switched", null)
}
Log.i(TAG, "Successfully switched to MPV fallback")
}
// Request audio focus
mpvCore?.requestAudioFocus()
// Emit backend-switched event so Flutter can show notification
onEvent("backend-switched", null)
Log.i(TAG, "Successfully switched to MPV fallback")
}
} catch (e: Exception) {
fallbackInProgress = false
@@ -16,15 +16,11 @@ import android.view.ViewTreeObserver
import com.edde746.plezy.shared.AudioFocusManager
import com.edde746.plezy.shared.FlutterOverlayHelper
import com.edde746.plezy.shared.FrameRateManager
import com.edde746.plezy.shared.PlayerDelegate
import dev.jdtech.mpv.MPVLib
import io.flutter.plugin.common.MethodChannel
import java.util.concurrent.Executors
interface MpvPlayerDelegate {
fun onPropertyChange(name: String, value: Any?)
fun onEvent(name: String, data: Map<String, Any>?)
}
class MpvPlayerCore(private val activity: Activity) :
SurfaceHolder.Callback,
MPVLib.EventObserver,
@@ -59,7 +55,7 @@ class MpvPlayerCore(private val activity: Activity) :
@Volatile private var disposing: Boolean = false
private var pendingSurface: Surface? = null
private var lastSurfaceSize: String? = null
var delegate: MpvPlayerDelegate? = null
var delegate: PlayerDelegate? = null
var isInitialized: Boolean = false
private set
@@ -119,7 +115,7 @@ class MpvPlayerCore(private val activity: Activity) :
}
},
isPaused = {
try { MPVLib.getPropertyBoolean("pause") }
try { MPVLib.getPropertyBoolean("pause") == true }
catch (e: Exception) { true }
}
)
@@ -128,7 +124,7 @@ class MpvPlayerCore(private val activity: Activity) :
handler = handler,
onDisplayChanged = {
try {
if (MPVLib.getPropertyBoolean("pause")) {
if (MPVLib.getPropertyBoolean("pause") == true) {
Log.d(TAG, "Display changed, resuming playback")
MPVLib.setPropertyBoolean("pause", false)
}
@@ -365,15 +361,22 @@ class MpvPlayerCore(private val activity: Activity) :
}
override fun event(eventId: Int) {
val eventName = when (eventId) {
MPVLib.MPV_EVENT_FILE_LOADED -> "file-loaded"
MPVLib.MPV_EVENT_END_FILE -> "end-file"
MPVLib.MPV_EVENT_PLAYBACK_RESTART -> "playback-restart"
else -> null
}
eventName?.let { name ->
activity.runOnUiThread {
delegate?.onEvent(name, null)
if (disposing) return
when (eventId) {
MPVLib.MPV_EVENT_END_FILE -> {
val eofReached = try { MPVLib.getPropertyBoolean("eof-reached") == true } catch (_: Exception) { false }
val data: Map<String, Any>? = if (eofReached) {
mapOf("reason" to 0) // EOF
} else {
null // Could be stop, quit, or error — no way to distinguish from JNI
}
activity.runOnUiThread { delegate?.onEvent("end-file", data) }
}
MPVLib.MPV_EVENT_FILE_LOADED -> {
activity.runOnUiThread { delegate?.onEvent("file-loaded", null) }
}
MPVLib.MPV_EVENT_PLAYBACK_RESTART -> {
activity.runOnUiThread { delegate?.onEvent("playback-restart", null) }
}
}
}
@@ -436,17 +439,22 @@ class MpvPlayerCore(private val activity: Activity) :
* This prevents ANR when mpv_get_property blocks waiting for internal locks.
*/
fun getPropertyAsync(name: String, result: MethodChannel.Result) {
if (!isInitialized) {
if (!isInitialized || disposing) {
result.success(null)
return
}
commandExecutor.execute {
try {
val value = MPVLib.getPropertyString(name)
activity.runOnUiThread { result.success(value) }
} catch (e: Exception) {
activity.runOnUiThread { result.success(null) }
try {
commandExecutor.execute {
try {
val value = MPVLib.getPropertyString(name)
activity.runOnUiThread { result.success(value) }
} catch (e: Exception) {
activity.runOnUiThread { result.success(null) }
}
}
} catch (e: java.util.concurrent.RejectedExecutionException) {
Log.w(TAG, "getPropertyAsync rejected (executor shut down)")
result.success(null)
}
}
@@ -455,18 +463,23 @@ class MpvPlayerCore(private val activity: Activity) :
* This prevents ANR when mpv_set_property blocks waiting for internal locks.
*/
fun setPropertyAsync(name: String, value: String, result: MethodChannel.Result) {
if (!isInitialized) {
if (!isInitialized || disposing) {
result.success(null)
return
}
commandExecutor.execute {
try {
MPVLib.setPropertyString(name, value)
activity.runOnUiThread { result.success(null) }
} catch (e: Exception) {
Log.e(TAG, "Async setProperty failed: ${e.message}", e)
activity.runOnUiThread { result.success(null) }
try {
commandExecutor.execute {
try {
MPVLib.setPropertyString(name, value)
activity.runOnUiThread { result.success(null) }
} catch (e: Exception) {
Log.e(TAG, "Async setProperty failed: ${e.message}", e)
activity.runOnUiThread { result.success(null) }
}
}
} catch (e: java.util.concurrent.RejectedExecutionException) {
Log.w(TAG, "setPropertyAsync rejected (executor shut down)")
result.success(null)
}
}
@@ -475,7 +488,7 @@ class MpvPlayerCore(private val activity: Activity) :
* This prevents ANR when mpv_observe_property blocks waiting for internal locks.
*/
fun observePropertyAsync(name: String, format: String, result: MethodChannel.Result) {
if (!isInitialized) {
if (!isInitialized || disposing) {
result.success(null)
return
}
@@ -486,14 +499,19 @@ class MpvPlayerCore(private val activity: Activity) :
"node" -> MPVLib.MPV_FORMAT_NODE
else -> MPVLib.MPV_FORMAT_NONE
}
commandExecutor.execute {
try {
MPVLib.observeProperty(name, mpvFormat)
activity.runOnUiThread { result.success(null) }
} catch (e: Exception) {
Log.e(TAG, "Async observeProperty failed: ${e.message}", e)
activity.runOnUiThread { result.success(null) }
try {
commandExecutor.execute {
try {
MPVLib.observeProperty(name, mpvFormat)
activity.runOnUiThread { result.success(null) }
} catch (e: Exception) {
Log.e(TAG, "Async observeProperty failed: ${e.message}", e)
activity.runOnUiThread { result.success(null) }
}
}
} catch (e: java.util.concurrent.RejectedExecutionException) {
Log.w(TAG, "observePropertyAsync rejected (executor shut down)")
result.success(null)
}
}
@@ -502,7 +520,12 @@ class MpvPlayerCore(private val activity: Activity) :
* Used by the plugin for I/O operations that shouldn't block the main thread.
*/
fun runOnExecutor(block: () -> Unit) {
commandExecutor.execute(block)
if (disposing) return
try {
commandExecutor.execute(block)
} catch (e: java.util.concurrent.RejectedExecutionException) {
Log.w(TAG, "runOnExecutor rejected (executor shut down)")
}
}
fun command(args: Array<String>) {
@@ -516,23 +539,28 @@ class MpvPlayerCore(private val activity: Activity) :
* The result is called back on the UI thread when the command completes.
*/
fun commandAsync(args: Array<String>, result: MethodChannel.Result) {
if (!isInitialized || args.isEmpty()) {
if (!isInitialized || disposing || args.isEmpty()) {
result.success(null)
return
}
commandExecutor.execute {
try {
MPVLib.command(args)
activity.runOnUiThread {
result.success(null)
}
} catch (e: Exception) {
Log.e(TAG, "Async command failed: ${e.message}", e)
activity.runOnUiThread {
result.error("COMMAND_FAILED", e.message, null)
try {
commandExecutor.execute {
try {
MPVLib.command(args)
activity.runOnUiThread {
result.success(null)
}
} catch (e: Exception) {
Log.e(TAG, "Async command failed: ${e.message}", e)
activity.runOnUiThread {
result.error("COMMAND_FAILED", e.message, null)
}
}
}
} catch (e: java.util.concurrent.RejectedExecutionException) {
Log.w(TAG, "commandAsync rejected (executor shut down)")
result.success(null)
}
}
@@ -586,7 +614,6 @@ class MpvPlayerCore(private val activity: Activity) :
} catch (e: Exception) {
Log.w(TAG, "Failed to remove MPV observers during dispose", e)
}
detachSurfaceInternal()
}
overlayLayoutListener?.let { listener ->
@@ -621,6 +648,7 @@ class MpvPlayerCore(private val activity: Activity) :
Thread {
synchronized(mpvLock) {
try {
detachSurfaceInternal()
MPVLib.destroy()
} catch (e: Exception) {
Log.w(TAG, "MPV destroy failed", e)
@@ -11,7 +11,7 @@ import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
class MpvPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
EventChannel.StreamHandler, ActivityAware, MpvPlayerDelegate {
EventChannel.StreamHandler, ActivityAware, com.edde746.plezy.shared.PlayerDelegate {
companion object {
private const val TAG = "MpvPlayerPlugin"
@@ -281,7 +281,7 @@ class MpvPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
} ?: result.error("NO_PLAYER", "Player not initialized", null)
}
// MpvPlayerDelegate
// PlayerDelegate
override fun onPropertyChange(name: String, value: Any?) {
val propId = nameToId[name] ?: return
@@ -0,0 +1,6 @@
package com.edde746.plezy.shared
interface PlayerDelegate {
fun onPropertyChange(name: String, value: Any?)
fun onEvent(name: String, data: Map<String, Any>?)
}
@@ -3,11 +3,14 @@ package com.edde746.plezy.watchnext
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.tvprovider.media.tv.TvContractCompat
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import java.util.concurrent.Executors
/**
* Flutter plugin for Android TV Watch Next integration.
@@ -17,7 +20,7 @@ class WatchNextPlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
companion object {
private const val TAG = "WatchNextPlugin"
private const val METHOD_CHANNEL = "app.plezy/watch_next"
private const val METHOD_CHANNEL = "com.plezy/watch_next"
private var pendingDeepLink: String? = null
@@ -37,6 +40,8 @@ class WatchNextPlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
private lateinit var methodChannel: MethodChannel
private var applicationContext: Context? = null
private var watchNextProvider: WatchNextProvider? = null
private val ioExecutor by lazy { Executors.newSingleThreadExecutor() }
private val mainHandler = Handler(Looper.getMainLooper())
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
applicationContext = binding.applicationContext
@@ -49,6 +54,7 @@ class WatchNextPlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
methodChannel.setMethodCallHandler(null)
applicationContext = null
watchNextProvider = null
ioExecutor.shutdown()
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
@@ -85,7 +91,7 @@ class WatchNextPlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
}
val items = itemsData.mapNotNull { parseWatchNextItem(it) }
result.success(provider.syncWatchNextPrograms(items))
executeOnIo(result) { provider.syncWatchNextPrograms(items) }
}
private fun handleClear(result: MethodChannel.Result) {
@@ -94,7 +100,7 @@ class WatchNextPlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
result.error("NOT_INITIALIZED", "WatchNextProvider not initialized", null)
return
}
result.success(provider.clearAll())
executeOnIo(result) { provider.clearAll() }
}
private fun handleRemove(call: MethodCall, result: MethodChannel.Result) {
@@ -109,7 +115,23 @@ class WatchNextPlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
result.error("INVALID_ARGS", "Missing 'contentId' argument", null)
return
}
result.success(provider.removeItem(contentId))
executeOnIo(result) { provider.removeItem(contentId) }
}
private fun executeOnIo(result: MethodChannel.Result, block: () -> Any?) {
try {
ioExecutor.execute {
try {
val value = block()
mainHandler.post { result.success(value) }
} catch (e: Exception) {
Log.e(TAG, "IO operation failed: ${e.message}", e)
mainHandler.post { result.error("IO_ERROR", e.message, null) }
}
}
} catch (e: java.util.concurrent.RejectedExecutionException) {
result.error("SHUTDOWN", "Plugin is shutting down", null)
}
}
private fun handleGetInitialDeepLink(result: MethodChannel.Result) {
+3 -2
View File
@@ -20,9 +20,10 @@ platform :android do
end
debug_info_dir = "./debug-info/#{version_name}+#{version_code}"
git_commit = `git rev-parse --short HEAD`.strip
# Build the Flutter app
sh("cd #{ENV['PWD']}/.. && flutter build appbundle --dart-define=ENABLE_IN_APP_REVIEW=true --dart-define=ENABLE_SENTRY=true --obfuscate --split-debug-info=#{debug_info_dir}/aab")
sh("cd #{ENV['PWD']}/.. && flutter build appbundle --dart-define=ENABLE_IN_APP_REVIEW=true --dart-define=ENABLE_SENTRY=true --dart-define=GIT_COMMIT=#{git_commit} --obfuscate --split-debug-info=#{debug_info_dir}/aab")
upload_to_play_store(
track: "production",
@@ -32,7 +33,7 @@ platform :android do
)
# Build universal APK for Amazon Appstore (doesn't accept AABs, excludes x86 via env var)
sh("cd #{ENV['PWD']}/.. && AMAZON=1 flutter build apk --release --dart-define=ENABLE_SENTRY=true --obfuscate --split-debug-info=#{debug_info_dir}/apk")
sh("cd #{ENV['PWD']}/.. && AMAZON=1 flutter build apk --release --dart-define=ENABLE_SENTRY=true --dart-define=GIT_COMMIT=#{git_commit} --obfuscate --split-debug-info=#{debug_info_dir}/apk")
upload_to_amazon_appstore(
apk_paths: [
+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 185.04 133.4"><defs><style>.cls-1{fill:url(#linear-gradient);}</style><linearGradient id="linear-gradient" y1="66.7" x2="185.04" y2="66.7" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#90cea1"/><stop offset="0.56" stop-color="#3cbec9"/><stop offset="1" stop-color="#00b3e5"/></linearGradient></defs><title>Asset 4</title><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><path class="cls-1" d="M51.06,66.7h0A17.67,17.67,0,0,1,68.73,49h-.1A17.67,17.67,0,0,1,86.3,66.7h0A17.67,17.67,0,0,1,68.63,84.37h.1A17.67,17.67,0,0,1,51.06,66.7Zm82.67-31.33h32.9A17.67,17.67,0,0,0,184.3,17.7h0A17.67,17.67,0,0,0,166.63,0h-32.9A17.67,17.67,0,0,0,116.06,17.7h0A17.67,17.67,0,0,0,133.73,35.37Zm-113,98h63.9A17.67,17.67,0,0,0,102.3,115.7h0A17.67,17.67,0,0,0,84.63,98H20.73A17.67,17.67,0,0,0,3.06,115.7h0A17.67,17.67,0,0,0,20.73,133.37Zm83.92-49h6.25L125.5,49h-8.35l-8.9,23.2h-.1L99.4,49H90.5Zm32.45,0h7.8V49h-7.8Zm22.2,0h24.95V77.2H167.1V70h15.35V62.8H167.1V56.2h16.25V49h-24ZM10.1,35.4h7.8V6.9H28V0H0V6.9H10.1ZM39,35.4h7.8V20.1H61.9V35.4h7.8V0H61.9V13.2H46.75V0H39Zm41.25,0h25V28.2H88V21h15.35V13.8H88V7.2h16.25V0h-24Zm-79,49H9V57.25h.1l9,27.15H24l9.3-27.15h.1V84.4h7.8V49H29.45l-8.2,23.1h-.1L13,49H1.2Zm112.09,49H126a24.59,24.59,0,0,0,7.56-1.15,19.52,19.52,0,0,0,6.35-3.37,16.37,16.37,0,0,0,4.37-5.5A16.91,16.91,0,0,0,146,115.8a18.5,18.5,0,0,0-1.68-8.25,15.1,15.1,0,0,0-4.52-5.53A18.55,18.55,0,0,0,133.07,99,33.54,33.54,0,0,0,125,98H113.29Zm7.81-28.2h4.6a17.43,17.43,0,0,1,4.67.62,11.68,11.68,0,0,1,3.88,1.88,9,9,0,0,1,2.62,3.18,9.87,9.87,0,0,1,1,4.52,11.92,11.92,0,0,1-1,5.08,8.69,8.69,0,0,1-2.67,3.34,10.87,10.87,0,0,1-4,1.83,21.57,21.57,0,0,1-5,.55H121.1Zm36.14,28.2h14.5a23.11,23.11,0,0,0,4.73-.5,13.38,13.38,0,0,0,4.27-1.65,9.42,9.42,0,0,0,3.1-3,8.52,8.52,0,0,0,1.2-4.68,9.16,9.16,0,0,0-.55-3.2,7.79,7.79,0,0,0-1.57-2.62,8.38,8.38,0,0,0-2.45-1.85,10,10,0,0,0-3.18-1v-.1a9.28,9.28,0,0,0,4.43-2.82,7.42,7.42,0,0,0,1.67-5,8.34,8.34,0,0,0-1.15-4.65,7.88,7.88,0,0,0-3-2.73,12.9,12.9,0,0,0-4.17-1.3,34.42,34.42,0,0,0-4.63-.32h-13.2Zm7.8-28.8h5.3a10.79,10.79,0,0,1,1.85.17,5.77,5.77,0,0,1,1.7.58,3.33,3.33,0,0,1,1.23,1.13,3.22,3.22,0,0,1,.47,1.82,3.63,3.63,0,0,1-.42,1.8,3.34,3.34,0,0,1-1.13,1.2,4.78,4.78,0,0,1-1.57.65,8.16,8.16,0,0,1-1.78.2H165Zm0,14.15h5.9a15.12,15.12,0,0,1,2.05.15,7.83,7.83,0,0,1,2,.55,4,4,0,0,1,1.58,1.17,3.13,3.13,0,0,1,.62,2,3.71,3.71,0,0,1-.47,1.95,4,4,0,0,1-1.23,1.3,4.78,4.78,0,0,1-1.67.7,8.91,8.91,0,0,1-1.83.2h-7Z"/></g></g></svg>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 185.04 133.4"><defs><linearGradient id="linear-gradient" y1="66.7" x2="185.04" y2="66.7" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#90cea1"/><stop offset="0.56" stop-color="#3cbec9"/><stop offset="1" stop-color="#00b3e5"/></linearGradient></defs><title>Asset 4</title><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><path fill="url(#linear-gradient)" d="M51.06,66.7h0A17.67,17.67,0,0,1,68.73,49h-.1A17.67,17.67,0,0,1,86.3,66.7h0A17.67,17.67,0,0,1,68.63,84.37h.1A17.67,17.67,0,0,1,51.06,66.7Zm82.67-31.33h32.9A17.67,17.67,0,0,0,184.3,17.7h0A17.67,17.67,0,0,0,166.63,0h-32.9A17.67,17.67,0,0,0,116.06,17.7h0A17.67,17.67,0,0,0,133.73,35.37Zm-113,98h63.9A17.67,17.67,0,0,0,102.3,115.7h0A17.67,17.67,0,0,0,84.63,98H20.73A17.67,17.67,0,0,0,3.06,115.7h0A17.67,17.67,0,0,0,20.73,133.37Zm83.92-49h6.25L125.5,49h-8.35l-8.9,23.2h-.1L99.4,49H90.5Zm32.45,0h7.8V49h-7.8Zm22.2,0h24.95V77.2H167.1V70h15.35V62.8H167.1V56.2h16.25V49h-24ZM10.1,35.4h7.8V6.9H28V0H0V6.9H10.1ZM39,35.4h7.8V20.1H61.9V35.4h7.8V0H61.9V13.2H46.75V0H39Zm41.25,0h25V28.2H88V21h15.35V13.8H88V7.2h16.25V0h-24Zm-79,49H9V57.25h.1l9,27.15H24l9.3-27.15h.1V84.4h7.8V49H29.45l-8.2,23.1h-.1L13,49H1.2Zm112.09,49H126a24.59,24.59,0,0,0,7.56-1.15,19.52,19.52,0,0,0,6.35-3.37,16.37,16.37,0,0,0,4.37-5.5A16.91,16.91,0,0,0,146,115.8a18.5,18.5,0,0,0-1.68-8.25,15.1,15.1,0,0,0-4.52-5.53A18.55,18.55,0,0,0,133.07,99,33.54,33.54,0,0,0,125,98H113.29Zm7.81-28.2h4.6a17.43,17.43,0,0,1,4.67.62,11.68,11.68,0,0,1,3.88,1.88,9,9,0,0,1,2.62,3.18,9.87,9.87,0,0,1,1,4.52,11.92,11.92,0,0,1-1,5.08,8.69,8.69,0,0,1-2.67,3.34,10.87,10.87,0,0,1-4,1.83,21.57,21.57,0,0,1-5,.55H121.1Zm36.14,28.2h14.5a23.11,23.11,0,0,0,4.73-.5,13.38,13.38,0,0,0,4.27-1.65,9.42,9.42,0,0,0,3.1-3,8.52,8.52,0,0,0,1.2-4.68,9.16,9.16,0,0,0-.55-3.2,7.79,7.79,0,0,0-1.57-2.62,8.38,8.38,0,0,0-2.45-1.85,10,10,0,0,0-3.18-1v-.1a9.28,9.28,0,0,0,4.43-2.82,7.42,7.42,0,0,0,1.67-5,8.34,8.34,0,0,0-1.15-4.65,7.88,7.88,0,0,0-3-2.73,12.9,12.9,0,0,0-4.17-1.3,34.42,34.42,0,0,0-4.63-.32h-13.2Zm7.8-28.8h5.3a10.79,10.79,0,0,1,1.85.17,5.77,5.77,0,0,1,1.7.58,3.33,3.33,0,0,1,1.23,1.13,3.22,3.22,0,0,1,.47,1.82,3.63,3.63,0,0,1-.42,1.8,3.34,3.34,0,0,1-1.13,1.2,4.78,4.78,0,0,1-1.57.65,8.16,8.16,0,0,1-1.78.2H165Zm0,14.15h5.9a15.12,15.12,0,0,1,2.05.15,7.83,7.83,0,0,1,2,.55,4,4,0,0,1,1.58,1.17,3.13,3.13,0,0,1,.62,2,3.71,3.71,0,0,1-.47,1.95,4,4,0,0,1-1.23,1.3,4.78,4.78,0,0,1-1.67.7,8.91,8.91,0,0,1-1.83.2h-7Z"/></g></g></svg>

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

+5 -1
View File
@@ -16,6 +16,7 @@
6A8A46252EDB370C0057B88C /* MpvPlayerPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A8A46232EDB370C0057B88C /* MpvPlayerPlugin.swift */; };
6A8A46262EDB370C0057B88C /* MpvPlayerCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A8A46222EDB370C0057B88C /* MpvPlayerCore.swift */; };
B1D51A6A2F00110000000001 /* MpvPlayerCoreBase.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D51A6A2F00110000000002 /* MpvPlayerCoreBase.swift */; };
B1D51A6A2F00110000000005 /* MpvPlayerPluginShared.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D51A6A2F00110000000006 /* MpvPlayerPluginShared.swift */; };
92F969587D0E464D999910F5 /* MpvPipController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92F969587D0E464D999910F4 /* MpvPipController.swift */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
@@ -59,7 +60,8 @@
6A48DFAF2EA70C7100C1F7CD /* plezy.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = plezy.icon; sourceTree = "<group>"; };
6A8A46222EDB370C0057B88C /* MpvPlayerCore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MpvPlayerCore.swift; sourceTree = "<group>"; };
6A8A46232EDB370C0057B88C /* MpvPlayerPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MpvPlayerPlugin.swift; sourceTree = "<group>"; };
B1D51A6A2F00110000000002 /* MpvPlayerCoreBase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MpvPlayerCoreBase.swift; path = ../apple/Shared/MpvPlayer/MpvPlayerCoreBase.swift; sourceTree = SOURCE_ROOT; };
B1D51A6A2F00110000000002 /* MpvPlayerCoreBase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MpvPlayerCoreBase.swift; path = ../shared/apple/MpvPlayer/MpvPlayerCoreBase.swift; sourceTree = SOURCE_ROOT; };
B1D51A6A2F00110000000006 /* MpvPlayerPluginShared.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MpvPlayerPluginShared.swift; path = ../shared/apple/MpvPlayer/MpvPlayerPluginShared.swift; sourceTree = SOURCE_ROOT; };
92F969587D0E464D999910F4 /* MpvPipController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MpvPipController.swift; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
@@ -118,6 +120,7 @@
isa = PBXGroup;
children = (
B1D51A6A2F00110000000002 /* MpvPlayerCoreBase.swift */,
B1D51A6A2F00110000000006 /* MpvPlayerPluginShared.swift */,
6A8A46222EDB370C0057B88C /* MpvPlayerCore.swift */,
6A8A46232EDB370C0057B88C /* MpvPlayerPlugin.swift */,
92F969587D0E464D999910F4 /* MpvPipController.swift */,
@@ -420,6 +423,7 @@
buildActionMask = 2147483647;
files = (
B1D51A6A2F00110000000001 /* MpvPlayerCoreBase.swift in Sources */,
B1D51A6A2F00110000000005 /* MpvPlayerPluginShared.swift in Sources */,
6A8A46252EDB370C0057B88C /* MpvPlayerPlugin.swift in Sources */,
6A8A46262EDB370C0057B88C /* MpvPlayerCore.swift in Sources */,
92F969587D0E464D999910F5 /* MpvPipController.swift in Sources */,
+10 -90
View File
@@ -3,14 +3,19 @@ import Flutter
import AVKit
/// Flutter plugin that bridges MPV player to Dart via method and event channels
class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPlayerDelegate {
class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginShared {
// MARK: - Properties
private var playerCore: MpvPlayerCore?
private var eventSink: FlutterEventSink?
var eventSink: FlutterEventSink?
private weak var registrar: FlutterPluginRegistrar?
private var nameToId: [String: Int] = [:]
var nameToId: [String: Int] = [:]
// MpvPluginShared conformance
var coreBase: MpvPlayerCoreBase? { playerCore }
func setPlayerVisible(_ visible: Bool) { playerCore?.setVisible(visible) }
func updatePlayerFrame() { playerCore?.updateFrame() }
// PiP
private var pipController: MpvPipController?
@@ -33,7 +38,7 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPlayerD
binaryMessenger: registrar.messenger()
)
let pipChannel = FlutterMethodChannel(
name: "app.plezy/pip",
name: "com.plezy/pip",
binaryMessenger: registrar.messenger()
)
@@ -252,7 +257,7 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPlayerD
pipTimebaseSyncTimer = nil
}
// MARK: - Method Handlers
// MARK: - Platform-Specific Method Handlers
private func handleInitialize(result: @escaping FlutterResult) {
DispatchQueue.main.async { [weak self] in
@@ -320,91 +325,6 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPlayerD
result(nil)
}
private func handleGetProperty(call: FlutterMethodCall, result: @escaping FlutterResult) {
guard let args = call.arguments as? [String: Any],
let name = args["name"] as? String else {
result(FlutterError(code: "INVALID_ARGS", message: "Missing 'name' argument", details: nil))
return
}
result(playerCore?.getProperty(name))
}
private func handleObserveProperty(call: FlutterMethodCall, result: @escaping FlutterResult) {
guard let args = call.arguments as? [String: Any],
let name = args["name"] as? String,
let format = args["format"] as? String,
let id = args["id"] as? Int else {
result(FlutterError(code: "INVALID_ARGS", message: "Missing 'name', 'format', or 'id' argument", details: nil))
return
}
nameToId[name] = id
playerCore?.observeProperty(name, format: format)
result(nil)
}
private func handleCommand(call: FlutterMethodCall, result: @escaping FlutterResult) {
guard let args = call.arguments as? [String: Any],
let commandArgs = args["args"] as? [String] else {
result(FlutterError(code: "INVALID_ARGS", message: "Missing 'args' argument", details: nil))
return
}
playerCore?.commandAsync(commandArgs) { commandResult in
switch commandResult {
case .success:
result(nil)
case .failure(let error):
result(FlutterError(code: "COMMAND_FAILED", message: error.localizedDescription, details: nil))
}
} ?? result(nil)
}
private func handleSetVisible(call: FlutterMethodCall, result: @escaping FlutterResult) {
guard let args = call.arguments as? [String: Any],
let visible = args["visible"] as? Bool else {
result(FlutterError(code: "INVALID_ARGS", message: "Missing 'visible' argument", details: nil))
return
}
DispatchQueue.main.async { [weak self] in
self?.playerCore?.setVisible(visible)
if visible { self?.playerCore?.updateFrame() }
result(nil)
}
}
private func handleUpdateFrame(result: @escaping FlutterResult) {
DispatchQueue.main.async { [weak self] in
self?.playerCore?.updateFrame()
result(nil)
}
}
private func handleSetLogLevel(call: FlutterMethodCall, result: @escaping FlutterResult) {
guard let args = call.arguments as? [String: Any],
let level = args["level"] as? String else {
result(FlutterError(code: "INVALID_ARGS", message: "Missing 'level'", details: nil))
return
}
playerCore?.setLogLevel(level)
result(nil)
}
// MARK: - MpvPlayerDelegate
func onPropertyChange(name: String, value: Any?) {
guard let eventSink = eventSink, let propId = nameToId[name] else { return }
eventSink([propId, value as Any])
}
func onEvent(name: String, data: [String: Any]?) {
guard let eventSink = eventSink else { return }
var event: [String: Any] = ["type": "event", "name": name]
if let data = data { event["data"] = data }
eventSink(event)
}
// MARK: - Helpers
private func findKeyWindow() -> UIWindow? {
+3 -1
View File
@@ -6,8 +6,10 @@ default_platform(:ios)
platform :ios do
desc "Build and deploy to App Store"
lane :deploy_appstore do
git_commit = `git rev-parse --short HEAD`.strip
# Build the Flutter app
sh("cd #{ENV['PWD']}/.. && flutter build ipa --dart-define=ENABLE_IN_APP_REVIEW=true --dart-define=ENABLE_SENTRY=true")
sh("cd #{ENV['PWD']}/.. && flutter build ipa --dart-define=ENABLE_IN_APP_REVIEW=true --dart-define=ENABLE_SENTRY=true --dart-define=GIT_COMMIT=#{git_commit}")
# Upload to App Store
upload_to_app_store(
File diff suppressed because it is too large Load Diff
+54 -7
View File
@@ -1,13 +1,19 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'dpad_navigator.dart';
import 'key_event_utils.dart';
/// Callbacks for chip key event handling.
class ChipKeyCallbacks {
/// Called when SELECT key is pressed.
/// Called when SELECT key is pressed (short press when [onLongPress] is set).
final VoidCallback? onSelect;
/// Called when SELECT key is held for 500ms.
final VoidCallback? onLongPress;
/// Called when DOWN arrow is pressed.
final VoidCallback? onNavigateDown;
@@ -25,6 +31,7 @@ class ChipKeyCallbacks {
const ChipKeyCallbacks({
this.onSelect,
this.onLongPress,
this.onNavigateDown,
this.onNavigateUp,
this.onNavigateLeft,
@@ -53,6 +60,8 @@ class ChipKeyCallbacks {
mixin FocusableChipStateMixin<T extends StatefulWidget> on State<T> {
FocusNode? _internalFocusNode;
bool _isFocused = false;
Timer? _longPressTimer;
bool _isSelectKeyDown = false;
/// Override to return the widget's optional external focus node.
FocusNode? get widgetFocusNode;
@@ -85,6 +94,7 @@ mixin FocusableChipStateMixin<T extends StatefulWidget> on State<T> {
void disposeFocusNode() {
focusNode.removeListener(_onFocusChange);
_internalFocusNode?.dispose();
_longPressTimer?.cancel();
}
void _onFocusChange() {
@@ -96,7 +106,7 @@ mixin FocusableChipStateMixin<T extends StatefulWidget> on State<T> {
/// Shared key event handler for chip widgets.
///
/// Handles common key patterns:
/// - SELECT key -> onSelect
/// - SELECT key -> onSelect (short press) / onLongPress (hold 500ms)
/// - Arrow keys -> navigation callbacks
/// - BACK key -> onBack
///
@@ -112,16 +122,53 @@ mixin FocusableChipStateMixin<T extends StatefulWidget> on State<T> {
}
}
if (!event.isActionable) {
return KeyEventResult.ignored;
if (SelectKeyUpSuppressor.consumeIfSuppressed(event)) {
return KeyEventResult.handled;
}
// SELECT key activates the chip
if (key.isSelectKey && callbacks.onSelect != null) {
callbacks.onSelect!();
// SELECT key with long press support
if (key.isSelectKey) {
if (callbacks.onLongPress != null) {
if (event is KeyDownEvent) {
if (!_isSelectKeyDown) {
_isSelectKeyDown = true;
_longPressTimer?.cancel();
_longPressTimer = Timer(const Duration(milliseconds: 500), () {
if (mounted) {
SelectKeyUpSuppressor.suppressSelectUntilKeyUp();
callbacks.onLongPress?.call();
}
});
}
return KeyEventResult.handled;
} else if (event is KeyRepeatEvent) {
return KeyEventResult.handled;
} else if (event is KeyUpEvent) {
final timerWasActive = _longPressTimer?.isActive ?? false;
_longPressTimer?.cancel();
if (timerWasActive && _isSelectKeyDown) {
callbacks.onSelect?.call();
}
_isSelectKeyDown = false;
return KeyEventResult.handled;
}
} else if (event.isActionable && callbacks.onSelect != null) {
callbacks.onSelect!();
return KeyEventResult.handled;
}
}
// Context menu key triggers long press directly
if (event.isActionable && key.isContextMenuKey && callbacks.onLongPress != null) {
SelectKeyUpSuppressor.suppressSelectUntilKeyUp();
callbacks.onLongPress!();
return KeyEventResult.handled;
}
if (!event.isActionable) {
return KeyEventResult.ignored;
}
// LEFT arrow - call callback if provided, otherwise propagate to parent
if (key.isLeftKey) {
if (callbacks.onNavigateLeft != null) {
+9 -1
View File
@@ -240,7 +240,15 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
final renderObject = context.findRenderObject();
if (renderObject == null) return;
final scrollable = Scrollable.maybeOf(context);
// Find the nearest scrollable that actually has scroll range.
// Skip inner scrollables with no extent (e.g. shrinkWrap ListView
// with NeverScrollableScrollPhysics inside an outer scroll view).
var scrollable = Scrollable.maybeOf(context);
while (scrollable != null) {
final pos = scrollable.position;
if (pos.maxScrollExtent > pos.minScrollExtent) break;
scrollable = Scrollable.maybeOf(scrollable.context);
}
if (scrollable == null) return;
final viewport = scrollable.context.findRenderObject() as RenderBox?;
+6 -1
View File
@@ -97,6 +97,11 @@
"autoSkipCreditsDescription": "Spring automatisk rulletekster over og afspil næste episode",
"autoSkipDelay": "Auto-spring forsinkelse",
"autoSkipDelayDescription": "Vent ${seconds} sekunder før automatisk spring",
"introPattern": "Intromarkørmønster",
"introPatternDescription": "Regulært udtryk til at genkende intromarkører i kapiteltitler",
"creditsPattern": "Rulletekstmarkørmønster",
"creditsPatternDescription": "Regulært udtryk til at genkende rulletekstmarkører i kapiteltitler",
"invalidRegex": "Ugyldigt regulært udtryk",
"downloads": "Downloads", "downloadLocationDescription": "Vælg hvor downloadet indhold skal gemmes",
"downloadLocationDefault": "Standard (App-lagring)", "downloadLocationCustom": "Brugerdefineret placering",
"selectFolder": "Vælg mappe", "resetToDefault": "Nulstil til standard",
@@ -295,7 +300,7 @@
"noCollections": "Ingen samlinger i dette bibliotek",
"noFoldersFound": "Ingen mapper fundet", "folders": "mapper",
"tabs": { "recommended": "Anbefalet", "browse": "Gennemse", "collections": "Samlinger", "playlists": "Playlister" },
"groupings": { "all": "Alle", "movies": "Film", "shows": "TV-serier", "seasons": "Sæsoner", "episodes": "Episoder", "folders": "Mapper" }
"groupings": { "title": "Gruppering", "all": "Alle", "movies": "Film", "shows": "TV-serier", "seasons": "Sæsoner", "episodes": "Episoder", "folders": "Mapper" }
},
"about": {
"title": "Om", "openSourceLicenses": "Open source-licenser", "versionLabel": "Version ${version}",
+21 -15
View File
@@ -178,6 +178,11 @@
"autoSkipCreditsDescription": "Abspann automatisch überspringen und nächste Episode abspielen",
"autoSkipDelay": "Verzögerung für automatisches Überspringen",
"autoSkipDelayDescription": "${seconds} Sekunden vor dem automatischen Überspringen warten",
"introPattern": "Intro-Markierungsmuster",
"introPatternDescription": "Regulärer Ausdruck zum Erkennen von Intro-Markierungen in Kapiteltiteln",
"creditsPattern": "Abspann-Markierungsmuster",
"creditsPatternDescription": "Regulärer Ausdruck zum Erkennen von Abspann-Markierungen in Kapiteltiteln",
"invalidRegex": "Ungültiger regulärer Ausdruck",
"downloads": "Downloads",
"downloadLocationDescription": "Speicherort für heruntergeladene Inhalte wählen",
"downloadLocationDefault": "Standard (App-Speicher)",
@@ -201,8 +206,8 @@
"autoPipDescription": "Automatisch Bild-in-Bild aktivieren, wenn die App während der Wiedergabe verlassen wird",
"matchContentFrameRate": "Inhalts-Bildrate anpassen",
"matchContentFrameRateDescription": "Bildwiederholfrequenz des Displays an den Videoinhalt anpassen, reduziert Ruckeln und spart Akku",
"tunneledPlayback": "Tunneled Playback",
"tunneledPlaybackDescription": "Use hardware-accelerated video tunneling. Disable if you see a black screen with audio on HDR content",
"tunneledPlayback": "Tunnelwiedergabe",
"tunneledPlaybackDescription": "Hardware-beschleunigte Video-Tunnelierung verwenden. Deaktivieren, wenn bei HDR-Inhalten ein schwarzer Bildschirm mit Ton erscheint",
"requireProfileSelectionOnOpen": "Profil beim Öffnen abfragen",
"requireProfileSelectionOnOpenDescription": "Profilauswahl bei jedem Öffnen der App anzeigen",
"confirmExitOnBack": "Vor dem Beenden bestätigen",
@@ -365,8 +370,8 @@
},
"chapters": "Kapitel",
"noChaptersAvailable": "Keine Kapitel verfügbar",
"queue": "Queue",
"noQueueItems": "No items in queue"
"queue": "Warteschlange",
"noQueueItems": "Keine Elemente in der Warteschlange"
},
"userStatus": {
"admin": "Eigentümer",
@@ -405,9 +410,9 @@
"failedToCreatePlayQueueNoItems": "Wiedergabewarteschlange konnte nicht erstellt werden keine Elemente",
"failedPlayback": "Wiedergabe für ${action} fehlgeschlagen: ${error}",
"switchingToCompatiblePlayer": "Wechsle zu kompatiblem Player...",
"logsUploaded": "Logs uploaded",
"logsUploadFailed": "Failed to upload logs",
"logId": "Log ID"
"logsUploaded": "Protokolle hochgeladen",
"logsUploadFailed": "Protokolle konnten nicht hochgeladen werden",
"logId": "Protokoll-ID"
},
"subtitlingStyling": {
"stylingOptions": "Stiloptionen",
@@ -514,6 +519,7 @@
"playlists": "Wiedergabelisten"
},
"groupings": {
"title": "Gruppierung",
"all": "Alle",
"movies": "Filme",
"shows": "Serien",
@@ -544,7 +550,7 @@
"logs": {
"clearLogs": "Protokolle löschen",
"copyLogs": "Protokolle kopieren",
"uploadLogs": "Upload Logs",
"uploadLogs": "Protokolle hochladen",
"error": "Fehler:",
"stackTrace": "Stacktrace:"
},
@@ -715,15 +721,15 @@
"qualityFast": "Schnell",
"qualityHQ": "Hohe Qualität",
"mode": "Modus",
"importShader": "Import Shader",
"customShaderDescription": "Custom GLSL shader",
"shaderImported": "Shader imported",
"shaderImportFailed": "Failed to import shader",
"deleteShader": "Delete Shader",
"deleteShaderConfirm": "Delete \"${name}\"?"
"importShader": "Shader importieren",
"customShaderDescription": "Benutzerdefinierter GLSL-Shader",
"shaderImported": "Shader importiert",
"shaderImportFailed": "Shader konnte nicht importiert werden",
"deleteShader": "Shader löschen",
"deleteShaderConfirm": "\"${name}\" löschen?"
},
"companionRemote": {
"title": "Companion Remote",
"title": "Companion-Fernbedienung",
"connectToDevice": "Mit Gerät verbinden",
"hostRemoteSession": "Fernsteuerungssitzung starten",
"controlThisDevice": "Dieses Gerät mit dem Handy steuern",
+6
View File
@@ -178,6 +178,11 @@
"autoSkipCreditsDescription": "Automatically skip credits and play next episode",
"autoSkipDelay": "Auto Skip Delay",
"autoSkipDelayDescription": "Wait ${seconds} seconds before auto-skipping",
"introPattern": "Intro Marker Pattern",
"introPatternDescription": "Regex pattern to match intro markers in chapter titles",
"creditsPattern": "Credits Marker Pattern",
"creditsPatternDescription": "Regex pattern to match credits markers in chapter titles",
"invalidRegex": "Invalid regular expression",
"downloads": "Downloads",
"downloadLocationDescription": "Choose where to store downloaded content",
"downloadLocationDefault": "Default (App Storage)",
@@ -514,6 +519,7 @@
"playlists": "Playlists"
},
"groupings": {
"title": "Grouping",
"all": "All",
"movies": "Movies",
"shows": "TV Shows",
+22 -16
View File
@@ -178,6 +178,11 @@
"autoSkipCreditsDescription": "Saltar automáticamente los créditos y reproducir el siguiente episodio",
"autoSkipDelay": "Retraso de Salto automático",
"autoSkipDelayDescription": "Esperar ${seconds} segundos antes de saltar automáticamente",
"introPattern": "Patrón de marcador de intro",
"introPatternDescription": "Expresión regular para reconocer marcadores de intro en los títulos de capítulos",
"creditsPattern": "Patrón de marcador de créditos",
"creditsPatternDescription": "Expresión regular para reconocer marcadores de créditos en los títulos de capítulos",
"invalidRegex": "Expresión regular no válida",
"downloads": "Descargas",
"downloadLocationDescription": "Elegir dónde almacenar el contenido descargado",
"downloadLocationDefault": "Predeterminado (Almacenamiento de la App)",
@@ -201,8 +206,8 @@
"autoPipDescription": "Activar automáticamente imagen en imagen al salir de la app durante la reproducción",
"matchContentFrameRate": "Ajustar frecuencia de actualización",
"matchContentFrameRateDescription": "Ajustar la frecuencia de actualización de la pantalla para que coincida con el video, reduciendo tirones y ahorrando batería",
"tunneledPlayback": "Tunneled Playback",
"tunneledPlaybackDescription": "Use hardware-accelerated video tunneling. Disable if you see a black screen with audio on HDR content",
"tunneledPlayback": "Reproducción tunelizada",
"tunneledPlaybackDescription": "Usar tunelización de video acelerada por hardware. Desactivar si ves una pantalla negra con audio en contenido HDR",
"requireProfileSelectionOnOpen": "Pedir perfil al abrir la app",
"requireProfileSelectionOnOpenDescription": "Mostrar selección de perfil cada vez que se abre la aplicación",
"confirmExitOnBack": "Confirmar antes de salir",
@@ -365,8 +370,8 @@
},
"chapters": "Capítulos",
"noChaptersAvailable": "No hay capítulos disponibles",
"queue": "Queue",
"noQueueItems": "No items in queue"
"queue": "Cola",
"noQueueItems": "No hay elementos en la cola"
},
"userStatus": {
"admin": "Administrador",
@@ -405,9 +410,9 @@
"failedToCreatePlayQueueNoItems": "Error al crear la cola de reproducción - no hay elementos",
"failedPlayback": "Error al ${action}: ${error}",
"switchingToCompatiblePlayer": "Cambiando a reproductor compatible...",
"logsUploaded": "Logs uploaded",
"logsUploadFailed": "Failed to upload logs",
"logId": "Log ID"
"logsUploaded": "Registros subidos",
"logsUploadFailed": "Error al subir registros",
"logId": "ID de registro"
},
"subtitlingStyling": {
"stylingOptions": "Opciones de Estilo",
@@ -417,7 +422,7 @@
"borderColor": "Color de Borde",
"backgroundOpacity": "Opacidad de Fondo",
"backgroundColor": "Color de Fondo",
"position": "Position"
"position": "Posición"
},
"mpvConfig": {
"title": "Configuración de mpv",
@@ -514,6 +519,7 @@
"playlists": "Listas"
},
"groupings": {
"title": "Agrupación",
"all": "Todo",
"movies": "Películas",
"shows": "Series",
@@ -544,7 +550,7 @@
"logs": {
"clearLogs": "Borrar Logs",
"copyLogs": "Copiar Logs",
"uploadLogs": "Upload Logs",
"uploadLogs": "Subir registros",
"error": "Error:",
"stackTrace": "Traza de la pila (Stack Trace):"
},
@@ -715,15 +721,15 @@
"qualityFast": "Rápido",
"qualityHQ": "Alta Calidad",
"mode": "Modo",
"importShader": "Import Shader",
"customShaderDescription": "Custom GLSL shader",
"shaderImported": "Shader imported",
"shaderImportFailed": "Failed to import shader",
"deleteShader": "Delete Shader",
"deleteShaderConfirm": "Delete \"${name}\"?"
"importShader": "Importar shader",
"customShaderDescription": "Shader GLSL personalizado",
"shaderImported": "Shader importado",
"shaderImportFailed": "Error al importar shader",
"deleteShader": "Eliminar shader",
"deleteShaderConfirm": "¿Eliminar \"${name}\"?"
},
"companionRemote": {
"title": "Companion Remote",
"title": "Control remoto",
"connectToDevice": "Conectar a dispositivo",
"hostRemoteSession": "Iniciar sesión remota",
"controlThisDevice": "Controla este dispositivo con tu teléfono",
+28 -22
View File
@@ -95,8 +95,8 @@
"durationHint": "Entrez la durée (${min}-${max})",
"systemTheme": "Système",
"systemThemeDescription": "Suivre les paramètres système",
"lightTheme": "Light",
"darkTheme": "Dark",
"lightTheme": "Clair",
"darkTheme": "Sombre",
"oledTheme": "OLED",
"oledThemeDescription": "Noir pur pour les écrans OLED",
"libraryDensity": "Densité des bibliothèques",
@@ -135,8 +135,8 @@
"bufferSizeWarning": "Votre appareil dispose de ${heap}MB de mémoire. Un tampon de ${size}MB peut causer des problèmes de lecture.",
"subtitleStyling": "Stylisation des sous-titres",
"subtitleStylingDescription": "Personnaliser l'apparence des sous-titres",
"smallSkipDuration": "Small Skip Duration",
"largeSkipDuration": "Large Skip Duration",
"smallSkipDuration": "Durée du petit saut",
"largeSkipDuration": "Durée du grand saut",
"secondsUnit": "${seconds} secondes",
"defaultSleepTimer": "Minuterie de mise en veille par défaut",
"minutesUnit": "${minutes} minutes",
@@ -178,6 +178,11 @@
"autoSkipCreditsDescription": "Passer les crédits et passer à l'épisode suivant automatiquement",
"autoSkipDelay": "Délai avant skip automatique",
"autoSkipDelayDescription": "Attendre ${seconds} secondes avant l'auto-skip",
"introPattern": "Modèle de marqueur d'intro",
"introPatternDescription": "Expression régulière pour reconnaître les marqueurs d'intro dans les titres de chapitres",
"creditsPattern": "Modèle de marqueur de générique",
"creditsPatternDescription": "Expression régulière pour reconnaître les marqueurs de générique dans les titres de chapitres",
"invalidRegex": "Expression régulière invalide",
"downloads": "Téléchargement",
"downloadLocationDescription": "Choisissez où stocker le contenu téléchargé",
"downloadLocationDefault": "Par défaut (stockage de l'application)",
@@ -201,8 +206,8 @@
"autoPipDescription": "Activer automatiquement l'image dans l'image en quittant l'application pendant la lecture",
"matchContentFrameRate": "Fréquence d'images du contenu correspondant",
"matchContentFrameRateDescription": "Ajustez la fréquence de rafraîchissement de l'écran en fonction du contenu vidéo, ce qui réduit les saccades et économise la batterie",
"tunneledPlayback": "Tunneled Playback",
"tunneledPlaybackDescription": "Use hardware-accelerated video tunneling. Disable if you see a black screen with audio on HDR content",
"tunneledPlayback": "Lecture tunnelée",
"tunneledPlaybackDescription": "Utiliser le tunnelage vidéo accéléré par matériel. Désactiver si vous voyez un écran noir avec du son sur du contenu HDR",
"requireProfileSelectionOnOpen": "Demander le profil à l'ouverture",
"requireProfileSelectionOnOpenDescription": "Afficher la sélection de profil à chaque ouverture de l'application",
"confirmExitOnBack": "Confirmer avant de quitter",
@@ -263,7 +268,7 @@
"colorRange": "Gamme de couleurs",
"colorPrimaries": "Couleurs primaires",
"chromaSubsampling": "Sous-échantillonnage chromatique",
"channels": "Channels",
"channels": "Canaux",
"path": "Chemin",
"size": "Taille",
"container": "Conteneur",
@@ -347,7 +352,7 @@
"ambientLightingOff": "Désactiver l'éclairage ambiant",
"fullscreenButton": "Passer en mode plein écran",
"exitFullscreenButton": "Quitter le mode plein écran",
"alwaysOnTopButton": "Always on top",
"alwaysOnTopButton": "Toujours au premier plan",
"rotationLockButton": "Verrouillage de rotation",
"timelineSlider": "Timeline vidéo",
"volumeSlider": "Niveau sonore",
@@ -365,8 +370,8 @@
},
"chapters": "Chapitres",
"noChaptersAvailable": "Aucun chapitre disponible",
"queue": "Queue",
"noQueueItems": "No items in queue"
"queue": "File d'attente",
"noQueueItems": "Aucun élément dans la file d'attente"
},
"userStatus": {
"admin": "Admin",
@@ -405,9 +410,9 @@
"failedToCreatePlayQueueNoItems": "Échec de la création de la file d'attente de lecture - aucun élément",
"failedPlayback": "Echec de ${action}: ${error}",
"switchingToCompatiblePlayer": "Passage au lecteur compatible...",
"logsUploaded": "Logs uploaded",
"logsUploadFailed": "Failed to upload logs",
"logId": "Log ID"
"logsUploaded": "Logs envoyés",
"logsUploadFailed": "Échec de l'envoi des logs",
"logId": "ID du log"
},
"subtitlingStyling": {
"stylingOptions": "Options de style",
@@ -514,6 +519,7 @@
"playlists": "Playlists"
},
"groupings": {
"title": "Regroupement",
"all": "Tous",
"movies": "Films",
"shows": "Show TV",
@@ -544,7 +550,7 @@
"logs": {
"clearLogs": "Effacer les logs",
"copyLogs": "Copier les logs",
"uploadLogs": "Upload Logs",
"uploadLogs": "Envoyer les logs",
"error": "Erreur:",
"stackTrace": "Liste des appels:"
},
@@ -628,7 +634,7 @@
"deleted": "Playlist supprimée",
"itemAdded": "Ajouté à la playlist",
"itemRemoved": "Retiré de la playlist",
"selectPlaylist": "Select Playlist",
"selectPlaylist": "Sélectionner une playlist",
"errorCreating": "Échec de la création de playlist",
"errorDeleting": "Échec de suppression de playlist",
"errorLoading": "Échec de chargement de playlists",
@@ -715,15 +721,15 @@
"qualityFast": "Rapide",
"qualityHQ": "Haute qualité",
"mode": "Mode",
"importShader": "Import Shader",
"customShaderDescription": "Custom GLSL shader",
"shaderImported": "Shader imported",
"shaderImportFailed": "Failed to import shader",
"deleteShader": "Delete Shader",
"deleteShaderConfirm": "Delete \"${name}\"?"
"importShader": "Importer un shader",
"customShaderDescription": "Shader GLSL personnalisé",
"shaderImported": "Shader importé",
"shaderImportFailed": "Échec de l'importation du shader",
"deleteShader": "Supprimer le shader",
"deleteShaderConfirm": "Supprimer \"${name}\" ?"
},
"companionRemote": {
"title": "Companion Remote",
"title": "Télécommande compagnon",
"connectToDevice": "Se connecter à un appareil",
"hostRemoteSession": "Héberger une session distante",
"controlThisDevice": "Contrôlez cet appareil avec votre téléphone",
+22 -16
View File
@@ -178,6 +178,11 @@
"autoSkipCreditsDescription": "Salta automaticamente i crediti e riproduci l'episodio successivo",
"autoSkipDelay": "Ritardo Salto Automatico",
"autoSkipDelayDescription": "Aspetta ${seconds} secondi prima del salto automatico",
"introPattern": "Pattern marcatore intro",
"introPatternDescription": "Espressione regolare per riconoscere i marcatori intro nei titoli dei capitoli",
"creditsPattern": "Pattern marcatore titoli di coda",
"creditsPatternDescription": "Espressione regolare per riconoscere i marcatori dei titoli di coda nei capitoli",
"invalidRegex": "Espressione regolare non valida",
"downloads": "Download",
"downloadLocationDescription": "Scegli dove salvare i contenuti scaricati",
"downloadLocationDefault": "Predefinita (Archiviazione App)",
@@ -201,8 +206,8 @@
"autoPipDescription": "Attiva automaticamente il picture-in-picture quando si esce dall'app durante la riproduzione",
"matchContentFrameRate": "Adatta frequenza fotogrammi",
"matchContentFrameRateDescription": "Regola la frequenza di aggiornamento del display in base al contenuto video, riducendo i tremolii e risparmiando batteria",
"tunneledPlayback": "Tunneled Playback",
"tunneledPlaybackDescription": "Use hardware-accelerated video tunneling. Disable if you see a black screen with audio on HDR content",
"tunneledPlayback": "Riproduzione tunnelizzata",
"tunneledPlaybackDescription": "Usa il tunneling video accelerato dall'hardware. Disattiva se vedi uno schermo nero con audio su contenuti HDR",
"requireProfileSelectionOnOpen": "Chiedi profilo all'apertura",
"requireProfileSelectionOnOpenDescription": "Mostra la selezione del profilo ogni volta che l'app viene aperta",
"confirmExitOnBack": "Conferma prima di uscire",
@@ -365,8 +370,8 @@
},
"chapters": "Capitoli",
"noChaptersAvailable": "Nessun capitolo disponibile",
"queue": "Queue",
"noQueueItems": "No items in queue"
"queue": "Coda",
"noQueueItems": "Nessun elemento in coda"
},
"userStatus": {
"admin": "Admin",
@@ -405,9 +410,9 @@
"failedToCreatePlayQueueNoItems": "Impossibile creare la coda di riproduzione - nessun elemento",
"failedPlayback": "Impossibile ${action}: ${error}",
"switchingToCompatiblePlayer": "Passaggio al lettore compatibile...",
"logsUploaded": "Logs uploaded",
"logsUploadFailed": "Failed to upload logs",
"logId": "Log ID"
"logsUploaded": "Log caricati",
"logsUploadFailed": "Caricamento log fallito",
"logId": "ID log"
},
"subtitlingStyling": {
"stylingOptions": "Opzioni stile",
@@ -417,7 +422,7 @@
"borderColor": "Colore bordo",
"backgroundOpacity": "Opacità sfondo",
"backgroundColor": "Colore sfondo",
"position": "Position"
"position": "Posizione"
},
"mpvConfig": {
"title": "Configurazione mpv",
@@ -514,6 +519,7 @@
"playlists": "Playlist"
},
"groupings": {
"title": "Raggruppamento",
"all": "Tutti",
"movies": "Film",
"shows": "Serie TV",
@@ -544,7 +550,7 @@
"logs": {
"clearLogs": "Cancella log",
"copyLogs": "Copia log",
"uploadLogs": "Upload Logs",
"uploadLogs": "Carica log",
"error": "Errore:",
"stackTrace": "Traccia dello stack:"
},
@@ -715,15 +721,15 @@
"qualityFast": "Veloce",
"qualityHQ": "Alta qualità",
"mode": "Modalità",
"importShader": "Import Shader",
"customShaderDescription": "Custom GLSL shader",
"shaderImported": "Shader imported",
"shaderImportFailed": "Failed to import shader",
"deleteShader": "Delete Shader",
"deleteShaderConfirm": "Delete \"${name}\"?"
"importShader": "Importa shader",
"customShaderDescription": "Shader GLSL personalizzato",
"shaderImported": "Shader importato",
"shaderImportFailed": "Importazione shader fallita",
"deleteShader": "Elimina shader",
"deleteShaderConfirm": "Eliminare \"${name}\"?"
},
"companionRemote": {
"title": "Companion Remote",
"title": "Telecomando",
"connectToDevice": "Connetti a un dispositivo",
"hostRemoteSession": "Ospita sessione remota",
"controlThisDevice": "Controlla questo dispositivo con il tuo telefono",
+6
View File
@@ -178,6 +178,11 @@
"autoSkipCreditsDescription": "クレジットを自動的にスキップして次のエピソードを再生",
"autoSkipDelay": "自動スキップの遅延",
"autoSkipDelayDescription": "自動スキップまで${seconds}秒待機",
"introPattern": "イントロマーカーパターン",
"introPatternDescription": "チャプタータイトルのイントロマーカーに一致する正規表現パターン",
"creditsPattern": "クレジットマーカーパターン",
"creditsPatternDescription": "チャプタータイトルのクレジットマーカーに一致する正規表現パターン",
"invalidRegex": "無効な正規表現",
"downloads": "ダウンロード",
"downloadLocationDescription": "ダウンロードコンテンツの保存場所を選択",
"downloadLocationDefault": "デフォルト(アプリストレージ)",
@@ -514,6 +519,7 @@
"playlists": "プレイリスト"
},
"groupings": {
"title": "グループ",
"all": "すべて",
"movies": "映画",
"shows": "テレビ番組",
+22 -16
View File
@@ -178,6 +178,11 @@
"autoSkipCreditsDescription": "엔딩 크레딧 자동 건너뛰기 후 다음 에피소드 재생",
"autoSkipDelay": "자동 건너뛰기 지연",
"autoSkipDelayDescription": "자동 건너뛰기 전 ${seconds} 초 대기",
"introPattern": "인트로 마커 패턴",
"introPatternDescription": "챕터 제목에서 인트로 마커를 인식하는 정규식 패턴",
"creditsPattern": "크레딧 마커 패턴",
"creditsPatternDescription": "챕터 제목에서 크레딧 마커를 인식하는 정규식 패턴",
"invalidRegex": "잘못된 정규식",
"downloads": "다운로드",
"downloadLocationDescription": "다운로드 콘텐츠 저장 위치 선택",
"downloadLocationDefault": "기본값 (앱 저장소)",
@@ -201,8 +206,8 @@
"autoPipDescription": "재생 중 앱을 나갈 때 자동으로 PIP 모드로 전환",
"matchContentFrameRate": "콘텐츠 프레임 레이트 맞춤",
"matchContentFrameRateDescription": "비디오 콘텐츠에 맞게 디스플레이 주사율을 조정하여 떨림을 줄이고 배터리를 절약합니다",
"tunneledPlayback": "Tunneled Playback",
"tunneledPlaybackDescription": "Use hardware-accelerated video tunneling. Disable if you see a black screen with audio on HDR content",
"tunneledPlayback": "터널 재생",
"tunneledPlaybackDescription": "하드웨어 가속 비디오 터널링을 사용합니다. HDR 콘텐츠에서 소리만 나고 검은 화면이 보이면 비활성화하세요",
"requireProfileSelectionOnOpen": "앱 실행 시 프로필 선택",
"requireProfileSelectionOnOpenDescription": "앱을 열 때마다 프로필 선택 화면을 표시합니다",
"confirmExitOnBack": "종료 전 확인",
@@ -365,8 +370,8 @@
},
"chapters": "챕터",
"noChaptersAvailable": "사용 가능한 챕터가 없습니다",
"queue": "Queue",
"noQueueItems": "No items in queue"
"queue": "재생 대기열",
"noQueueItems": "대기열에 항목이 없습니다"
},
"userStatus": {
"admin": "관리자",
@@ -405,9 +410,9 @@
"failedToCreatePlayQueueNoItems": "재생 대기열 생성 실패 - 항목 없음",
"failedPlayback": "${action}을(를) 수행할 수 없습니다: ${error}",
"switchingToCompatiblePlayer": "호환되는 플레이어로 전환 중...",
"logsUploaded": "Logs uploaded",
"logsUploadFailed": "Failed to upload logs",
"logId": "Log ID"
"logsUploaded": "로그 업로드 완료",
"logsUploadFailed": "로그 업로드 실패",
"logId": "로그 ID"
},
"subtitlingStyling": {
"stylingOptions": "스타일 옵션",
@@ -417,7 +422,7 @@
"borderColor": "테두리 색상",
"backgroundOpacity": "배경 불투명도",
"backgroundColor": "배경색",
"position": "Position"
"position": "위치"
},
"mpvConfig": {
"title": "mpv 설정",
@@ -514,6 +519,7 @@
"playlists": "재생 목록"
},
"groupings": {
"title": "그룹",
"all": "전체",
"movies": "영화",
"shows": "TV 프로그램",
@@ -544,7 +550,7 @@
"logs": {
"clearLogs": "로그 지우기",
"copyLogs": "로그 복사",
"uploadLogs": "Upload Logs",
"uploadLogs": "로그 업로드",
"error": "오류:",
"stackTrace": "스택 추적 (Stack Trace):"
},
@@ -715,15 +721,15 @@
"qualityFast": "빠름",
"qualityHQ": "고품질",
"mode": "모드",
"importShader": "Import Shader",
"customShaderDescription": "Custom GLSL shader",
"shaderImported": "Shader imported",
"shaderImportFailed": "Failed to import shader",
"deleteShader": "Delete Shader",
"deleteShaderConfirm": "Delete \"${name}\"?"
"importShader": "셰이더 가져오기",
"customShaderDescription": "사용자 정의 GLSL 셰이더",
"shaderImported": "셰이더를 가져왔습니다",
"shaderImportFailed": "셰이더 가져오기 실패",
"deleteShader": "셰이더 삭제",
"deleteShaderConfirm": "\"${name}\"을(를) 삭제하시겠습니까?"
},
"companionRemote": {
"title": "Companion Remote",
"title": "컴패니언 리모컨",
"connectToDevice": "기기에 연결",
"hostRemoteSession": "원격 세션 호스트",
"controlThisDevice": "휴대폰으로 이 기기를 제어하세요",
+6
View File
@@ -178,6 +178,11 @@
"autoSkipCreditsDescription": "Hopp automatisk over rulletekst og spill neste episode",
"autoSkipDelay": "Forsinkelse for automatisk hopp",
"autoSkipDelayDescription": "Vent ${seconds} sekunder før automatisk hopping",
"introPattern": "Intromarkørmønster",
"introPatternDescription": "Regulært uttrykk for å gjenkjenne intromarkører i kapitteltitler",
"creditsPattern": "Rulletekstmarkørmønster",
"creditsPatternDescription": "Regulært uttrykk for å gjenkjenne rulletekstmarkører i kapitteltitler",
"invalidRegex": "Ugyldig regulært uttrykk",
"downloads": "Nedlastinger",
"downloadLocationDescription": "Velg hvor nedlastet innhold skal lagres",
"downloadLocationDefault": "Standard (App-lagring)",
@@ -514,6 +519,7 @@
"playlists": "Spillelister"
},
"groupings": {
"title": "Gruppering",
"all": "Alle",
"movies": "Filmer",
"shows": "TV-serier",
+22 -16
View File
@@ -178,6 +178,11 @@
"autoSkipCreditsDescription": "Credits automatisch overslaan en volgende aflevering afspelen",
"autoSkipDelay": "Vertraging Automatisch Overslaan",
"autoSkipDelayDescription": "${seconds} seconden wachten voor automatisch overslaan",
"introPattern": "Intromarkeringspatroon",
"introPatternDescription": "Reguliere expressie om intromarkeringen in hoofdstuktitels te herkennen",
"creditsPattern": "Aftitelingmarkeringspatroon",
"creditsPatternDescription": "Reguliere expressie om aftitelingmarkeringen in hoofdstuktitels te herkennen",
"invalidRegex": "Ongeldige reguliere expressie",
"downloads": "Downloads",
"downloadLocationDescription": "Kies waar gedownloade content wordt opgeslagen",
"downloadLocationDefault": "Standaard (App-opslag)",
@@ -201,8 +206,8 @@
"autoPipDescription": "Automatisch beeld-in-beeld activeren bij het verlaten van de app tijdens afspelen",
"matchContentFrameRate": "Inhoudsframesnelheid afstemmen",
"matchContentFrameRateDescription": "Pas de schermverversingssnelheid aan op de video-inhoud, vermindert haperingen en bespaart batterij",
"tunneledPlayback": "Tunneled Playback",
"tunneledPlaybackDescription": "Use hardware-accelerated video tunneling. Disable if you see a black screen with audio on HDR content",
"tunneledPlayback": "Getunnelde weergave",
"tunneledPlaybackDescription": "Gebruik hardwareversnelde videotunneling. Schakel uit als je een zwart scherm met geluid ziet bij HDR-content",
"requireProfileSelectionOnOpen": "Vraag om profiel bij openen",
"requireProfileSelectionOnOpenDescription": "Toon profielselectie telkens wanneer de app wordt geopend",
"confirmExitOnBack": "Bevestigen voor afsluiten",
@@ -365,8 +370,8 @@
},
"chapters": "Hoofdstukken",
"noChaptersAvailable": "Geen hoofdstukken beschikbaar",
"queue": "Queue",
"noQueueItems": "No items in queue"
"queue": "Wachtrij",
"noQueueItems": "Geen items in de wachtrij"
},
"userStatus": {
"admin": "Beheerder",
@@ -405,9 +410,9 @@
"failedToCreatePlayQueueNoItems": "Kan afspeelwachtrij niet maken - geen items",
"failedPlayback": "Afspelen van ${action} mislukt: ${error}",
"switchingToCompatiblePlayer": "Overschakelen naar compatibele speler...",
"logsUploaded": "Logs uploaded",
"logsUploadFailed": "Failed to upload logs",
"logId": "Log ID"
"logsUploaded": "Logs geüpload",
"logsUploadFailed": "Uploaden van logs mislukt",
"logId": "Log-ID"
},
"subtitlingStyling": {
"stylingOptions": "Opmaak opties",
@@ -417,7 +422,7 @@
"borderColor": "Randkleur",
"backgroundOpacity": "Achtergrond transparantie",
"backgroundColor": "Achtergrondkleur",
"position": "Position"
"position": "Positie"
},
"mpvConfig": {
"title": "mpv-configuratie",
@@ -514,6 +519,7 @@
"playlists": "Afspeellijsten"
},
"groupings": {
"title": "Groepering",
"all": "Alles",
"movies": "Films",
"shows": "Series",
@@ -544,7 +550,7 @@
"logs": {
"clearLogs": "Wis logs",
"copyLogs": "Kopieer logs",
"uploadLogs": "Upload Logs",
"uploadLogs": "Logs uploaden",
"error": "Fout:",
"stackTrace": "Stacktracering:"
},
@@ -715,15 +721,15 @@
"qualityFast": "Snel",
"qualityHQ": "Hoge kwaliteit",
"mode": "Modus",
"importShader": "Import Shader",
"customShaderDescription": "Custom GLSL shader",
"shaderImported": "Shader imported",
"shaderImportFailed": "Failed to import shader",
"deleteShader": "Delete Shader",
"deleteShaderConfirm": "Delete \"${name}\"?"
"importShader": "Shader importeren",
"customShaderDescription": "Aangepaste GLSL-shader",
"shaderImported": "Shader geïmporteerd",
"shaderImportFailed": "Shader importeren mislukt",
"deleteShader": "Shader verwijderen",
"deleteShaderConfirm": "\"${name}\" verwijderen?"
},
"companionRemote": {
"title": "Companion Remote",
"title": "Afstandsbediening",
"connectToDevice": "Verbinden met apparaat",
"hostRemoteSession": "Externe sessie hosten",
"controlThisDevice": "Bedien dit apparaat met je telefoon",
+6
View File
@@ -178,6 +178,11 @@
"autoSkipCreditsDescription": "Automatycznie pomijaj napisy końcowe i odtwórz następny odcinek",
"autoSkipDelay": "Opóźnienie automatycznego pomijania",
"autoSkipDelayDescription": "Czekaj ${seconds} sekund przed automatycznym pominięciem",
"introPattern": "Wzorzec znacznika intro",
"introPatternDescription": "Wyrażenie regularne do rozpoznawania znaczników intro w tytułach rozdziałów",
"creditsPattern": "Wzorzec znacznika napisów końcowych",
"creditsPatternDescription": "Wyrażenie regularne do rozpoznawania znaczników napisów końcowych w tytułach rozdziałów",
"invalidRegex": "Nieprawidłowe wyrażenie regularne",
"downloads": "Pobrania",
"downloadLocationDescription": "Wybierz miejsce przechowywania pobranych treści",
"downloadLocationDefault": "Domyślne (Pamięć aplikacji)",
@@ -514,6 +519,7 @@
"playlists": "Playlisty"
},
"groupings": {
"title": "Grupowanie",
"all": "Wszystkie",
"movies": "Filmy",
"shows": "Seriale TV",
+6
View File
@@ -178,6 +178,11 @@
"autoSkipCreditsDescription": "Pular créditos automaticamente e reproduzir próximo episódio",
"autoSkipDelay": "Atraso do Pulo Automático",
"autoSkipDelayDescription": "Aguardar ${seconds} segundos antes de pular automaticamente",
"introPattern": "Padrão de marcador de intro",
"introPatternDescription": "Expressão regular para corresponder marcadores de intro nos títulos dos capítulos",
"creditsPattern": "Padrão de marcador de créditos",
"creditsPatternDescription": "Expressão regular para corresponder marcadores de créditos nos títulos dos capítulos",
"invalidRegex": "Expressão regular inválida",
"downloads": "Downloads",
"downloadLocationDescription": "Escolha onde armazenar conteúdo baixado",
"downloadLocationDefault": "Padrão (Armazenamento do App)",
@@ -514,6 +519,7 @@
"playlists": "Playlists"
},
"groupings": {
"title": "Agrupamento",
"all": "Todos",
"movies": "Filmes",
"shows": "Séries de TV",
+6
View File
@@ -178,6 +178,11 @@
"autoSkipCreditsDescription": "Автоматически пропускать титры и воспроизводить следующий эпизод",
"autoSkipDelay": "Задержка автопропуска",
"autoSkipDelayDescription": "Подождать ${seconds} секунд перед автопропуском",
"introPattern": "Шаблон маркера вступления",
"introPatternDescription": "Регулярное выражение для распознавания маркеров вступления в заголовках глав",
"creditsPattern": "Шаблон маркера титров",
"creditsPatternDescription": "Регулярное выражение для распознавания маркеров титров в заголовках глав",
"invalidRegex": "Недопустимое регулярное выражение",
"downloads": "Загрузки",
"downloadLocationDescription": "Выберите место для хранения загруженного контента",
"downloadLocationDefault": "По умолчанию (Хранилище приложения)",
@@ -514,6 +519,7 @@
"playlists": "Плейлисты"
},
"groupings": {
"title": "Группировка",
"all": "Все",
"movies": "Фильмы",
"shows": "Сериалы",
+2 -2
View File
@@ -4,9 +4,9 @@
/// To regenerate, run: `dart run slang`
///
/// Locales: 15
/// Strings: 12105 (807 per locale)
/// Strings: 12195 (813 per locale)
///
/// Built on 2026-03-08 at 14:11 UTC
/// Built on 2026-03-13 at 05:11 UTC
// coverage:ignore-file
// ignore_for_file: type=lint, unused_import
+14 -2
View File
@@ -297,6 +297,11 @@ class _TranslationsSettingsDa implements TranslationsSettingsEn {
@override String get autoSkipCreditsDescription => 'Spring automatisk rulletekster over og afspil næste episode';
@override String get autoSkipDelay => 'Auto-spring forsinkelse';
@override String autoSkipDelayDescription({required Object seconds}) => 'Vent ${seconds} sekunder før automatisk spring';
@override String get introPattern => 'Intromarkørmønster';
@override String get introPatternDescription => 'Regulært udtryk til at genkende intromarkører i kapiteltitler';
@override String get creditsPattern => 'Rulletekstmarkørmønster';
@override String get creditsPatternDescription => 'Regulært udtryk til at genkende rulletekstmarkører i kapiteltitler';
@override String get invalidRegex => 'Ugyldigt regulært udtryk';
@override String get downloads => 'Downloads';
@override String get downloadLocationDescription => 'Vælg hvor downloadet indhold skal gemmes';
@override String get downloadLocationDefault => 'Standard (App-lagring)';
@@ -1192,6 +1197,7 @@ class _TranslationsLibrariesGroupingsDa implements TranslationsLibrariesGrouping
final TranslationsDa _root; // ignore: unused_field
// Translations
@override String get title => 'Gruppering';
@override String get all => 'Alle';
@override String get movies => 'Film';
@override String get shows => 'TV-serier';
@@ -1472,6 +1478,11 @@ extension on TranslationsDa {
'settings.autoSkipCreditsDescription' => 'Spring automatisk rulletekster over og afspil næste episode',
'settings.autoSkipDelay' => 'Auto-spring forsinkelse',
'settings.autoSkipDelayDescription' => ({required Object seconds}) => 'Vent ${seconds} sekunder før automatisk spring',
'settings.introPattern' => 'Intromarkørmønster',
'settings.introPatternDescription' => 'Regulært udtryk til at genkende intromarkører i kapiteltitler',
'settings.creditsPattern' => 'Rulletekstmarkørmønster',
'settings.creditsPatternDescription' => 'Regulært udtryk til at genkende rulletekstmarkører i kapiteltitler',
'settings.invalidRegex' => 'Ugyldigt regulært udtryk',
'settings.downloads' => 'Downloads',
'settings.downloadLocationDescription' => 'Vælg hvor downloadet indhold skal gemmes',
'settings.downloadLocationDefault' => 'Standard (App-lagring)',
@@ -1769,6 +1780,7 @@ extension on TranslationsDa {
'libraries.tabs.browse' => 'Gennemse',
'libraries.tabs.collections' => 'Samlinger',
'libraries.tabs.playlists' => 'Playlister',
'libraries.groupings.title' => 'Gruppering',
'libraries.groupings.all' => 'Alle',
'libraries.groupings.movies' => 'Film',
'libraries.groupings.shows' => 'TV-serier',
@@ -1810,14 +1822,14 @@ extension on TranslationsDa {
'liveTv.nowPlaying' => 'Afspiller nu',
'liveTv.noPrograms' => 'Ingen programdata tilgængelig',
'liveTv.channelNumber' => ({required Object number}) => 'Kn. ${number}',
_ => null,
} ?? switch (path) {
'liveTv.live' => 'LIVE',
'liveTv.hd' => 'HD',
'liveTv.premiere' => 'NY',
'liveTv.reloadGuide' => 'Genindlæs guide',
'liveTv.allChannels' => 'Alle kanaler',
'liveTv.now' => 'Nu',
_ => null,
} ?? switch (path) {
'liveTv.today' => 'I dag',
'liveTv.midnight' => 'Midnat',
'liveTv.overnight' => 'Nat',
+44 -32
View File
@@ -297,6 +297,11 @@ class _TranslationsSettingsDe implements TranslationsSettingsEn {
@override String get autoSkipCreditsDescription => 'Abspann automatisch überspringen und nächste Episode abspielen';
@override String get autoSkipDelay => 'Verzögerung für automatisches Überspringen';
@override String autoSkipDelayDescription({required Object seconds}) => '${seconds} Sekunden vor dem automatischen Überspringen warten';
@override String get introPattern => 'Intro-Markierungsmuster';
@override String get introPatternDescription => 'Regulärer Ausdruck zum Erkennen von Intro-Markierungen in Kapiteltiteln';
@override String get creditsPattern => 'Abspann-Markierungsmuster';
@override String get creditsPatternDescription => 'Regulärer Ausdruck zum Erkennen von Abspann-Markierungen in Kapiteltiteln';
@override String get invalidRegex => 'Ungültiger regulärer Ausdruck';
@override String get downloads => 'Downloads';
@override String get downloadLocationDescription => 'Speicherort für heruntergeladene Inhalte wählen';
@override String get downloadLocationDefault => 'Standard (App-Speicher)';
@@ -320,8 +325,8 @@ class _TranslationsSettingsDe implements TranslationsSettingsEn {
@override String get autoPipDescription => 'Automatisch Bild-in-Bild aktivieren, wenn die App während der Wiedergabe verlassen wird';
@override String get matchContentFrameRate => 'Inhalts-Bildrate anpassen';
@override String get matchContentFrameRateDescription => 'Bildwiederholfrequenz des Displays an den Videoinhalt anpassen, reduziert Ruckeln und spart Akku';
@override String get tunneledPlayback => 'Tunneled Playback';
@override String get tunneledPlaybackDescription => 'Use hardware-accelerated video tunneling. Disable if you see a black screen with audio on HDR content';
@override String get tunneledPlayback => 'Tunnelwiedergabe';
@override String get tunneledPlaybackDescription => 'Hardware-beschleunigte Video-Tunnelierung verwenden. Deaktivieren, wenn bei HDR-Inhalten ein schwarzer Bildschirm mit Ton erscheint';
@override String get requireProfileSelectionOnOpen => 'Profil beim Öffnen abfragen';
@override String get requireProfileSelectionOnOpenDescription => 'Profilauswahl bei jedem Öffnen der App anzeigen';
@override String get confirmExitOnBack => 'Vor dem Beenden bestätigen';
@@ -512,8 +517,8 @@ class _TranslationsVideoControlsDe implements TranslationsVideoControlsEn {
@override late final _TranslationsVideoControlsPipErrorsDe pipErrors = _TranslationsVideoControlsPipErrorsDe._(_root);
@override String get chapters => 'Kapitel';
@override String get noChaptersAvailable => 'Keine Kapitel verfügbar';
@override String get queue => 'Queue';
@override String get noQueueItems => 'No items in queue';
@override String get queue => 'Warteschlange';
@override String get noQueueItems => 'Keine Elemente in der Warteschlange';
}
// Path: userStatus
@@ -566,9 +571,9 @@ class _TranslationsMessagesDe implements TranslationsMessagesEn {
@override String get failedToCreatePlayQueueNoItems => 'Wiedergabewarteschlange konnte nicht erstellt werden keine Elemente';
@override String failedPlayback({required Object action, required Object error}) => 'Wiedergabe für ${action} fehlgeschlagen: ${error}';
@override String get switchingToCompatiblePlayer => 'Wechsle zu kompatiblem Player...';
@override String get logsUploaded => 'Logs uploaded';
@override String get logsUploadFailed => 'Failed to upload logs';
@override String get logId => 'Log ID';
@override String get logsUploaded => 'Protokolle hochgeladen';
@override String get logsUploadFailed => 'Protokolle konnten nicht hochgeladen werden';
@override String get logId => 'Protokoll-ID';
}
// Path: subtitlingStyling
@@ -763,7 +768,7 @@ class _TranslationsLogsDe implements TranslationsLogsEn {
// Translations
@override String get clearLogs => 'Protokolle löschen';
@override String get copyLogs => 'Protokolle kopieren';
@override String get uploadLogs => 'Upload Logs';
@override String get uploadLogs => 'Protokolle hochladen';
@override String get error => 'Fehler:';
@override String get stackTrace => 'Stacktrace:';
}
@@ -990,12 +995,12 @@ class _TranslationsShadersDe implements TranslationsShadersEn {
@override String get qualityFast => 'Schnell';
@override String get qualityHQ => 'Hohe Qualität';
@override String get mode => 'Modus';
@override String get importShader => 'Import Shader';
@override String get customShaderDescription => 'Custom GLSL shader';
@override String get shaderImported => 'Shader imported';
@override String get shaderImportFailed => 'Failed to import shader';
@override String get deleteShader => 'Delete Shader';
@override String deleteShaderConfirm({required Object name}) => 'Delete "${name}"?';
@override String get importShader => 'Shader importieren';
@override String get customShaderDescription => 'Benutzerdefinierter GLSL-Shader';
@override String get shaderImported => 'Shader importiert';
@override String get shaderImportFailed => 'Shader konnte nicht importiert werden';
@override String get deleteShader => 'Shader löschen';
@override String deleteShaderConfirm({required Object name}) => '"${name}" löschen?';
}
// Path: companionRemote
@@ -1005,7 +1010,7 @@ class _TranslationsCompanionRemoteDe implements TranslationsCompanionRemoteEn {
final TranslationsDe _root; // ignore: unused_field
// Translations
@override String get title => 'Companion Remote';
@override String get title => 'Companion-Fernbedienung';
@override String get connectToDevice => 'Mit Gerät verbinden';
@override String get hostRemoteSession => 'Fernsteuerungssitzung starten';
@override String get controlThisDevice => 'Dieses Gerät mit dem Handy steuern';
@@ -1192,6 +1197,7 @@ class _TranslationsLibrariesGroupingsDe implements TranslationsLibrariesGrouping
final TranslationsDe _root; // ignore: unused_field
// Translations
@override String get title => 'Gruppierung';
@override String get all => 'Alle';
@override String get movies => 'Filme';
@override String get shows => 'Serien';
@@ -1472,6 +1478,11 @@ extension on TranslationsDe {
'settings.autoSkipCreditsDescription' => 'Abspann automatisch überspringen und nächste Episode abspielen',
'settings.autoSkipDelay' => 'Verzögerung für automatisches Überspringen',
'settings.autoSkipDelayDescription' => ({required Object seconds}) => '${seconds} Sekunden vor dem automatischen Überspringen warten',
'settings.introPattern' => 'Intro-Markierungsmuster',
'settings.introPatternDescription' => 'Regulärer Ausdruck zum Erkennen von Intro-Markierungen in Kapiteltiteln',
'settings.creditsPattern' => 'Abspann-Markierungsmuster',
'settings.creditsPatternDescription' => 'Regulärer Ausdruck zum Erkennen von Abspann-Markierungen in Kapiteltiteln',
'settings.invalidRegex' => 'Ungültiger regulärer Ausdruck',
'settings.downloads' => 'Downloads',
'settings.downloadLocationDescription' => 'Speicherort für heruntergeladene Inhalte wählen',
'settings.downloadLocationDefault' => 'Standard (App-Speicher)',
@@ -1495,8 +1506,8 @@ extension on TranslationsDe {
'settings.autoPipDescription' => 'Automatisch Bild-in-Bild aktivieren, wenn die App während der Wiedergabe verlassen wird',
'settings.matchContentFrameRate' => 'Inhalts-Bildrate anpassen',
'settings.matchContentFrameRateDescription' => 'Bildwiederholfrequenz des Displays an den Videoinhalt anpassen, reduziert Ruckeln und spart Akku',
'settings.tunneledPlayback' => 'Tunneled Playback',
'settings.tunneledPlaybackDescription' => 'Use hardware-accelerated video tunneling. Disable if you see a black screen with audio on HDR content',
'settings.tunneledPlayback' => 'Tunnelwiedergabe',
'settings.tunneledPlaybackDescription' => 'Hardware-beschleunigte Video-Tunnelierung verwenden. Deaktivieren, wenn bei HDR-Inhalten ein schwarzer Bildschirm mit Ton erscheint',
'settings.requireProfileSelectionOnOpen' => 'Profil beim Öffnen abfragen',
'settings.requireProfileSelectionOnOpenDescription' => 'Profilauswahl bei jedem Öffnen der App anzeigen',
'settings.confirmExitOnBack' => 'Vor dem Beenden bestätigen',
@@ -1639,8 +1650,8 @@ extension on TranslationsDe {
'videoControls.pipErrors.unknown' => ({required Object error}) => 'Ein Fehler ist aufgetreten: ${error}',
'videoControls.chapters' => 'Kapitel',
'videoControls.noChaptersAvailable' => 'Keine Kapitel verfügbar',
'videoControls.queue' => 'Queue',
'videoControls.noQueueItems' => 'No items in queue',
'videoControls.queue' => 'Warteschlange',
'videoControls.noQueueItems' => 'Keine Elemente in der Warteschlange',
'userStatus.admin' => 'Eigentümer',
'userStatus.restricted' => 'Eingeschränkt',
'userStatus.protected' => 'Geschützt',
@@ -1675,9 +1686,9 @@ extension on TranslationsDe {
'messages.failedToCreatePlayQueueNoItems' => 'Wiedergabewarteschlange konnte nicht erstellt werden keine Elemente',
'messages.failedPlayback' => ({required Object action, required Object error}) => 'Wiedergabe für ${action} fehlgeschlagen: ${error}',
'messages.switchingToCompatiblePlayer' => 'Wechsle zu kompatiblem Player...',
'messages.logsUploaded' => 'Logs uploaded',
'messages.logsUploadFailed' => 'Failed to upload logs',
'messages.logId' => 'Log ID',
'messages.logsUploaded' => 'Protokolle hochgeladen',
'messages.logsUploadFailed' => 'Protokolle konnten nicht hochgeladen werden',
'messages.logId' => 'Protokoll-ID',
'subtitlingStyling.stylingOptions' => 'Stiloptionen',
'subtitlingStyling.fontSize' => 'Schriftgröße',
'subtitlingStyling.textColor' => 'Textfarbe',
@@ -1769,6 +1780,7 @@ extension on TranslationsDe {
'libraries.tabs.browse' => 'Durchsuchen',
'libraries.tabs.collections' => 'Sammlungen',
'libraries.tabs.playlists' => 'Wiedergabelisten',
'libraries.groupings.title' => 'Gruppierung',
'libraries.groupings.all' => 'Alle',
'libraries.groupings.movies' => 'Filme',
'libraries.groupings.shows' => 'Serien',
@@ -1790,7 +1802,7 @@ extension on TranslationsDe {
'hubDetail.noItemsFound' => 'Keine Elemente gefunden',
'logs.clearLogs' => 'Protokolle löschen',
'logs.copyLogs' => 'Protokolle kopieren',
'logs.uploadLogs' => 'Upload Logs',
'logs.uploadLogs' => 'Protokolle hochladen',
'logs.error' => 'Fehler:',
'logs.stackTrace' => 'Stacktrace:',
'licenses.relatedPackages' => 'Verwandte Pakete',
@@ -1810,14 +1822,14 @@ extension on TranslationsDe {
'liveTv.nowPlaying' => 'Läuft gerade',
'liveTv.noPrograms' => 'Keine Programmdaten verfügbar',
'liveTv.channelNumber' => ({required Object number}) => 'Kanal ${number}',
_ => null,
} ?? switch (path) {
'liveTv.live' => 'LIVE',
'liveTv.hd' => 'HD',
'liveTv.premiere' => 'NEU',
'liveTv.reloadGuide' => 'Programmführer neu laden',
'liveTv.allChannels' => 'Alle Kanäle',
'liveTv.now' => 'Jetzt',
_ => null,
} ?? switch (path) {
'liveTv.today' => 'Heute',
'liveTv.midnight' => 'Mitternacht',
'liveTv.overnight' => 'Nacht',
@@ -1947,13 +1959,13 @@ extension on TranslationsDe {
'shaders.qualityFast' => 'Schnell',
'shaders.qualityHQ' => 'Hohe Qualität',
'shaders.mode' => 'Modus',
'shaders.importShader' => 'Import Shader',
'shaders.customShaderDescription' => 'Custom GLSL shader',
'shaders.shaderImported' => 'Shader imported',
'shaders.shaderImportFailed' => 'Failed to import shader',
'shaders.deleteShader' => 'Delete Shader',
'shaders.deleteShaderConfirm' => ({required Object name}) => 'Delete "${name}"?',
'companionRemote.title' => 'Companion Remote',
'shaders.importShader' => 'Shader importieren',
'shaders.customShaderDescription' => 'Benutzerdefinierter GLSL-Shader',
'shaders.shaderImported' => 'Shader importiert',
'shaders.shaderImportFailed' => 'Shader konnte nicht importiert werden',
'shaders.deleteShader' => 'Shader löschen',
'shaders.deleteShaderConfirm' => ({required Object name}) => '"${name}" löschen?',
'companionRemote.title' => 'Companion-Fernbedienung',
'companionRemote.connectToDevice' => 'Mit Gerät verbinden',
'companionRemote.hostRemoteSession' => 'Fernsteuerungssitzung starten',
'companionRemote.controlThisDevice' => 'Dieses Gerät mit dem Handy steuern',
+26 -2
View File
@@ -637,6 +637,21 @@ class TranslationsSettingsEn {
/// en: 'Wait ${seconds} seconds before auto-skipping'
String autoSkipDelayDescription({required Object seconds}) => 'Wait ${seconds} seconds before auto-skipping';
/// en: 'Intro Marker Pattern'
String get introPattern => 'Intro Marker Pattern';
/// en: 'Regex pattern to match intro markers in chapter titles'
String get introPatternDescription => 'Regex pattern to match intro markers in chapter titles';
/// en: 'Credits Marker Pattern'
String get creditsPattern => 'Credits Marker Pattern';
/// en: 'Regex pattern to match credits markers in chapter titles'
String get creditsPatternDescription => 'Regex pattern to match credits markers in chapter titles';
/// en: 'Invalid regular expression'
String get invalidRegex => 'Invalid regular expression';
/// en: 'Downloads'
String get downloads => 'Downloads';
@@ -2664,6 +2679,9 @@ class TranslationsLibrariesGroupingsEn {
// Translations
/// en: 'Grouping'
String get title => 'Grouping';
/// en: 'All'
String get all => 'All';
@@ -3093,6 +3111,11 @@ extension on Translations {
'settings.autoSkipCreditsDescription' => 'Automatically skip credits and play next episode',
'settings.autoSkipDelay' => 'Auto Skip Delay',
'settings.autoSkipDelayDescription' => ({required Object seconds}) => 'Wait ${seconds} seconds before auto-skipping',
'settings.introPattern' => 'Intro Marker Pattern',
'settings.introPatternDescription' => 'Regex pattern to match intro markers in chapter titles',
'settings.creditsPattern' => 'Credits Marker Pattern',
'settings.creditsPatternDescription' => 'Regex pattern to match credits markers in chapter titles',
'settings.invalidRegex' => 'Invalid regular expression',
'settings.downloads' => 'Downloads',
'settings.downloadLocationDescription' => 'Choose where to store downloaded content',
'settings.downloadLocationDefault' => 'Default (App Storage)',
@@ -3390,6 +3413,7 @@ extension on Translations {
'libraries.tabs.browse' => 'Browse',
'libraries.tabs.collections' => 'Collections',
'libraries.tabs.playlists' => 'Playlists',
'libraries.groupings.title' => 'Grouping',
'libraries.groupings.all' => 'All',
'libraries.groupings.movies' => 'Movies',
'libraries.groupings.shows' => 'TV Shows',
@@ -3431,14 +3455,14 @@ extension on Translations {
'liveTv.nowPlaying' => 'Now Playing',
'liveTv.noPrograms' => 'No program data available',
'liveTv.channelNumber' => ({required Object number}) => 'Ch. ${number}',
_ => null,
} ?? switch (path) {
'liveTv.live' => 'LIVE',
'liveTv.hd' => 'HD',
'liveTv.premiere' => 'NEW',
'liveTv.reloadGuide' => 'Reload Guide',
'liveTv.allChannels' => 'All Channels',
'liveTv.now' => 'Now',
_ => null,
} ?? switch (path) {
'liveTv.today' => 'Today',
'liveTv.midnight' => 'Midnight',
'liveTv.overnight' => 'Overnight',
+46 -34
View File
@@ -297,6 +297,11 @@ class _TranslationsSettingsEs implements TranslationsSettingsEn {
@override String get autoSkipCreditsDescription => 'Saltar automáticamente los créditos y reproducir el siguiente episodio';
@override String get autoSkipDelay => 'Retraso de Salto automático';
@override String autoSkipDelayDescription({required Object seconds}) => 'Esperar ${seconds} segundos antes de saltar automáticamente';
@override String get introPattern => 'Patrón de marcador de intro';
@override String get introPatternDescription => 'Expresión regular para reconocer marcadores de intro en los títulos de capítulos';
@override String get creditsPattern => 'Patrón de marcador de créditos';
@override String get creditsPatternDescription => 'Expresión regular para reconocer marcadores de créditos en los títulos de capítulos';
@override String get invalidRegex => 'Expresión regular no válida';
@override String get downloads => 'Descargas';
@override String get downloadLocationDescription => 'Elegir dónde almacenar el contenido descargado';
@override String get downloadLocationDefault => 'Predeterminado (Almacenamiento de la App)';
@@ -320,8 +325,8 @@ class _TranslationsSettingsEs implements TranslationsSettingsEn {
@override String get autoPipDescription => 'Activar automáticamente imagen en imagen al salir de la app durante la reproducción';
@override String get matchContentFrameRate => 'Ajustar frecuencia de actualización';
@override String get matchContentFrameRateDescription => 'Ajustar la frecuencia de actualización de la pantalla para que coincida con el video, reduciendo tirones y ahorrando batería';
@override String get tunneledPlayback => 'Tunneled Playback';
@override String get tunneledPlaybackDescription => 'Use hardware-accelerated video tunneling. Disable if you see a black screen with audio on HDR content';
@override String get tunneledPlayback => 'Reproducción tunelizada';
@override String get tunneledPlaybackDescription => 'Usar tunelización de video acelerada por hardware. Desactivar si ves una pantalla negra con audio en contenido HDR';
@override String get requireProfileSelectionOnOpen => 'Pedir perfil al abrir la app';
@override String get requireProfileSelectionOnOpenDescription => 'Mostrar selección de perfil cada vez que se abre la aplicación';
@override String get confirmExitOnBack => 'Confirmar antes de salir';
@@ -512,8 +517,8 @@ class _TranslationsVideoControlsEs implements TranslationsVideoControlsEn {
@override late final _TranslationsVideoControlsPipErrorsEs pipErrors = _TranslationsVideoControlsPipErrorsEs._(_root);
@override String get chapters => 'Capítulos';
@override String get noChaptersAvailable => 'No hay capítulos disponibles';
@override String get queue => 'Queue';
@override String get noQueueItems => 'No items in queue';
@override String get queue => 'Cola';
@override String get noQueueItems => 'No hay elementos en la cola';
}
// Path: userStatus
@@ -566,9 +571,9 @@ class _TranslationsMessagesEs implements TranslationsMessagesEn {
@override String get failedToCreatePlayQueueNoItems => 'Error al crear la cola de reproducción - no hay elementos';
@override String failedPlayback({required Object action, required Object error}) => 'Error al ${action}: ${error}';
@override String get switchingToCompatiblePlayer => 'Cambiando a reproductor compatible...';
@override String get logsUploaded => 'Logs uploaded';
@override String get logsUploadFailed => 'Failed to upload logs';
@override String get logId => 'Log ID';
@override String get logsUploaded => 'Registros subidos';
@override String get logsUploadFailed => 'Error al subir registros';
@override String get logId => 'ID de registro';
}
// Path: subtitlingStyling
@@ -585,7 +590,7 @@ class _TranslationsSubtitlingStylingEs implements TranslationsSubtitlingStylingE
@override String get borderColor => 'Color de Borde';
@override String get backgroundOpacity => 'Opacidad de Fondo';
@override String get backgroundColor => 'Color de Fondo';
@override String get position => 'Position';
@override String get position => 'Posición';
}
// Path: mpvConfig
@@ -763,7 +768,7 @@ class _TranslationsLogsEs implements TranslationsLogsEn {
// Translations
@override String get clearLogs => 'Borrar Logs';
@override String get copyLogs => 'Copiar Logs';
@override String get uploadLogs => 'Upload Logs';
@override String get uploadLogs => 'Subir registros';
@override String get error => 'Error:';
@override String get stackTrace => 'Traza de la pila (Stack Trace):';
}
@@ -990,12 +995,12 @@ class _TranslationsShadersEs implements TranslationsShadersEn {
@override String get qualityFast => 'Rápido';
@override String get qualityHQ => 'Alta Calidad';
@override String get mode => 'Modo';
@override String get importShader => 'Import Shader';
@override String get customShaderDescription => 'Custom GLSL shader';
@override String get shaderImported => 'Shader imported';
@override String get shaderImportFailed => 'Failed to import shader';
@override String get deleteShader => 'Delete Shader';
@override String deleteShaderConfirm({required Object name}) => 'Delete "${name}"?';
@override String get importShader => 'Importar shader';
@override String get customShaderDescription => 'Shader GLSL personalizado';
@override String get shaderImported => 'Shader importado';
@override String get shaderImportFailed => 'Error al importar shader';
@override String get deleteShader => 'Eliminar shader';
@override String deleteShaderConfirm({required Object name}) => '¿Eliminar "${name}"?';
}
// Path: companionRemote
@@ -1005,7 +1010,7 @@ class _TranslationsCompanionRemoteEs implements TranslationsCompanionRemoteEn {
final TranslationsEs _root; // ignore: unused_field
// Translations
@override String get title => 'Companion Remote';
@override String get title => 'Control remoto';
@override String get connectToDevice => 'Conectar a dispositivo';
@override String get hostRemoteSession => 'Iniciar sesión remota';
@override String get controlThisDevice => 'Controla este dispositivo con tu teléfono';
@@ -1192,6 +1197,7 @@ class _TranslationsLibrariesGroupingsEs implements TranslationsLibrariesGrouping
final TranslationsEs _root; // ignore: unused_field
// Translations
@override String get title => 'Agrupación';
@override String get all => 'Todo';
@override String get movies => 'Películas';
@override String get shows => 'Series';
@@ -1472,6 +1478,11 @@ extension on TranslationsEs {
'settings.autoSkipCreditsDescription' => 'Saltar automáticamente los créditos y reproducir el siguiente episodio',
'settings.autoSkipDelay' => 'Retraso de Salto automático',
'settings.autoSkipDelayDescription' => ({required Object seconds}) => 'Esperar ${seconds} segundos antes de saltar automáticamente',
'settings.introPattern' => 'Patrón de marcador de intro',
'settings.introPatternDescription' => 'Expresión regular para reconocer marcadores de intro en los títulos de capítulos',
'settings.creditsPattern' => 'Patrón de marcador de créditos',
'settings.creditsPatternDescription' => 'Expresión regular para reconocer marcadores de créditos en los títulos de capítulos',
'settings.invalidRegex' => 'Expresión regular no válida',
'settings.downloads' => 'Descargas',
'settings.downloadLocationDescription' => 'Elegir dónde almacenar el contenido descargado',
'settings.downloadLocationDefault' => 'Predeterminado (Almacenamiento de la App)',
@@ -1495,8 +1506,8 @@ extension on TranslationsEs {
'settings.autoPipDescription' => 'Activar automáticamente imagen en imagen al salir de la app durante la reproducción',
'settings.matchContentFrameRate' => 'Ajustar frecuencia de actualización',
'settings.matchContentFrameRateDescription' => 'Ajustar la frecuencia de actualización de la pantalla para que coincida con el video, reduciendo tirones y ahorrando batería',
'settings.tunneledPlayback' => 'Tunneled Playback',
'settings.tunneledPlaybackDescription' => 'Use hardware-accelerated video tunneling. Disable if you see a black screen with audio on HDR content',
'settings.tunneledPlayback' => 'Reproducción tunelizada',
'settings.tunneledPlaybackDescription' => 'Usar tunelización de video acelerada por hardware. Desactivar si ves una pantalla negra con audio en contenido HDR',
'settings.requireProfileSelectionOnOpen' => 'Pedir perfil al abrir la app',
'settings.requireProfileSelectionOnOpenDescription' => 'Mostrar selección de perfil cada vez que se abre la aplicación',
'settings.confirmExitOnBack' => 'Confirmar antes de salir',
@@ -1639,8 +1650,8 @@ extension on TranslationsEs {
'videoControls.pipErrors.unknown' => ({required Object error}) => 'Ocurrió un error: ${error}',
'videoControls.chapters' => 'Capítulos',
'videoControls.noChaptersAvailable' => 'No hay capítulos disponibles',
'videoControls.queue' => 'Queue',
'videoControls.noQueueItems' => 'No items in queue',
'videoControls.queue' => 'Cola',
'videoControls.noQueueItems' => 'No hay elementos en la cola',
'userStatus.admin' => 'Administrador',
'userStatus.restricted' => 'Restringido',
'userStatus.protected' => 'Protegido',
@@ -1675,9 +1686,9 @@ extension on TranslationsEs {
'messages.failedToCreatePlayQueueNoItems' => 'Error al crear la cola de reproducción - no hay elementos',
'messages.failedPlayback' => ({required Object action, required Object error}) => 'Error al ${action}: ${error}',
'messages.switchingToCompatiblePlayer' => 'Cambiando a reproductor compatible...',
'messages.logsUploaded' => 'Logs uploaded',
'messages.logsUploadFailed' => 'Failed to upload logs',
'messages.logId' => 'Log ID',
'messages.logsUploaded' => 'Registros subidos',
'messages.logsUploadFailed' => 'Error al subir registros',
'messages.logId' => 'ID de registro',
'subtitlingStyling.stylingOptions' => 'Opciones de Estilo',
'subtitlingStyling.fontSize' => 'Tamaño de Fuente',
'subtitlingStyling.textColor' => 'Color de Texto',
@@ -1685,7 +1696,7 @@ extension on TranslationsEs {
'subtitlingStyling.borderColor' => 'Color de Borde',
'subtitlingStyling.backgroundOpacity' => 'Opacidad de Fondo',
'subtitlingStyling.backgroundColor' => 'Color de Fondo',
'subtitlingStyling.position' => 'Position',
'subtitlingStyling.position' => 'Posición',
'mpvConfig.title' => 'Configuración de mpv',
'mpvConfig.description' => 'Ajustes avanzados del reproductor de video',
'mpvConfig.presets' => 'Ajustes preestablecidos',
@@ -1769,6 +1780,7 @@ extension on TranslationsEs {
'libraries.tabs.browse' => 'Explorar',
'libraries.tabs.collections' => 'Colecciones',
'libraries.tabs.playlists' => 'Listas',
'libraries.groupings.title' => 'Agrupación',
'libraries.groupings.all' => 'Todo',
'libraries.groupings.movies' => 'Películas',
'libraries.groupings.shows' => 'Series',
@@ -1790,7 +1802,7 @@ extension on TranslationsEs {
'hubDetail.noItemsFound' => 'No se encontraron elementos',
'logs.clearLogs' => 'Borrar Logs',
'logs.copyLogs' => 'Copiar Logs',
'logs.uploadLogs' => 'Upload Logs',
'logs.uploadLogs' => 'Subir registros',
'logs.error' => 'Error:',
'logs.stackTrace' => 'Traza de la pila (Stack Trace):',
'licenses.relatedPackages' => 'Paquetes relacionados',
@@ -1810,14 +1822,14 @@ extension on TranslationsEs {
'liveTv.nowPlaying' => 'Reproduciendo ahora',
'liveTv.noPrograms' => 'No hay datos de programación disponibles',
'liveTv.channelNumber' => ({required Object number}) => 'Canal ${number}',
_ => null,
} ?? switch (path) {
'liveTv.live' => 'EN VIVO',
'liveTv.hd' => 'HD',
'liveTv.premiere' => 'NUEVO',
'liveTv.reloadGuide' => 'Recargar guía',
'liveTv.allChannels' => 'Todos los canales',
'liveTv.now' => 'Ahora',
_ => null,
} ?? switch (path) {
'liveTv.today' => 'Hoy',
'liveTv.midnight' => 'Medianoche',
'liveTv.overnight' => 'Madrugada',
@@ -1947,13 +1959,13 @@ extension on TranslationsEs {
'shaders.qualityFast' => 'Rápido',
'shaders.qualityHQ' => 'Alta Calidad',
'shaders.mode' => 'Modo',
'shaders.importShader' => 'Import Shader',
'shaders.customShaderDescription' => 'Custom GLSL shader',
'shaders.shaderImported' => 'Shader imported',
'shaders.shaderImportFailed' => 'Failed to import shader',
'shaders.deleteShader' => 'Delete Shader',
'shaders.deleteShaderConfirm' => ({required Object name}) => 'Delete "${name}"?',
'companionRemote.title' => 'Companion Remote',
'shaders.importShader' => 'Importar shader',
'shaders.customShaderDescription' => 'Shader GLSL personalizado',
'shaders.shaderImported' => 'Shader importado',
'shaders.shaderImportFailed' => 'Error al importar shader',
'shaders.deleteShader' => 'Eliminar shader',
'shaders.deleteShaderConfirm' => ({required Object name}) => '¿Eliminar "${name}"?',
'companionRemote.title' => 'Control remoto',
'companionRemote.connectToDevice' => 'Conectar a dispositivo',
'companionRemote.hostRemoteSession' => 'Iniciar sesión remota',
'companionRemote.controlThisDevice' => 'Controla este dispositivo con tu teléfono',
+58 -46
View File
@@ -214,8 +214,8 @@ class _TranslationsSettingsFr implements TranslationsSettingsEn {
@override String durationHint({required Object min, required Object max}) => 'Entrez la durée (${min}-${max})';
@override String get systemTheme => 'Système';
@override String get systemThemeDescription => 'Suivre les paramètres système';
@override String get lightTheme => 'Light';
@override String get darkTheme => 'Dark';
@override String get lightTheme => 'Clair';
@override String get darkTheme => 'Sombre';
@override String get oledTheme => 'OLED';
@override String get oledThemeDescription => 'Noir pur pour les écrans OLED';
@override String get libraryDensity => 'Densité des bibliothèques';
@@ -254,8 +254,8 @@ class _TranslationsSettingsFr implements TranslationsSettingsEn {
@override String bufferSizeWarning({required Object heap, required Object size}) => 'Votre appareil dispose de ${heap}MB de mémoire. Un tampon de ${size}MB peut causer des problèmes de lecture.';
@override String get subtitleStyling => 'Stylisation des sous-titres';
@override String get subtitleStylingDescription => 'Personnaliser l\'apparence des sous-titres';
@override String get smallSkipDuration => 'Small Skip Duration';
@override String get largeSkipDuration => 'Large Skip Duration';
@override String get smallSkipDuration => 'Durée du petit saut';
@override String get largeSkipDuration => 'Durée du grand saut';
@override String secondsUnit({required Object seconds}) => '${seconds} secondes';
@override String get defaultSleepTimer => 'Minuterie de mise en veille par défaut';
@override String minutesUnit({required Object minutes}) => '${minutes} minutes';
@@ -297,6 +297,11 @@ class _TranslationsSettingsFr implements TranslationsSettingsEn {
@override String get autoSkipCreditsDescription => 'Passer les crédits et passer à l\'épisode suivant automatiquement';
@override String get autoSkipDelay => 'Délai avant skip automatique';
@override String autoSkipDelayDescription({required Object seconds}) => 'Attendre ${seconds} secondes avant l\'auto-skip';
@override String get introPattern => 'Modèle de marqueur d\'intro';
@override String get introPatternDescription => 'Expression régulière pour reconnaître les marqueurs d\'intro dans les titres de chapitres';
@override String get creditsPattern => 'Modèle de marqueur de générique';
@override String get creditsPatternDescription => 'Expression régulière pour reconnaître les marqueurs de générique dans les titres de chapitres';
@override String get invalidRegex => 'Expression régulière invalide';
@override String get downloads => 'Téléchargement';
@override String get downloadLocationDescription => 'Choisissez où stocker le contenu téléchargé';
@override String get downloadLocationDefault => 'Par défaut (stockage de l\'application)';
@@ -320,8 +325,8 @@ class _TranslationsSettingsFr implements TranslationsSettingsEn {
@override String get autoPipDescription => 'Activer automatiquement l\'image dans l\'image en quittant l\'application pendant la lecture';
@override String get matchContentFrameRate => 'Fréquence d\'images du contenu correspondant';
@override String get matchContentFrameRateDescription => 'Ajustez la fréquence de rafraîchissement de l\'écran en fonction du contenu vidéo, ce qui réduit les saccades et économise la batterie';
@override String get tunneledPlayback => 'Tunneled Playback';
@override String get tunneledPlaybackDescription => 'Use hardware-accelerated video tunneling. Disable if you see a black screen with audio on HDR content';
@override String get tunneledPlayback => 'Lecture tunnelée';
@override String get tunneledPlaybackDescription => 'Utiliser le tunnelage vidéo accéléré par matériel. Désactiver si vous voyez un écran noir avec du son sur du contenu HDR';
@override String get requireProfileSelectionOnOpen => 'Demander le profil à l\'ouverture';
@override String get requireProfileSelectionOnOpenDescription => 'Afficher la sélection de profil à chaque ouverture de l\'application';
@override String get confirmExitOnBack => 'Confirmer avant de quitter';
@@ -390,7 +395,7 @@ class _TranslationsFileInfoFr implements TranslationsFileInfoEn {
@override String get colorRange => 'Gamme de couleurs';
@override String get colorPrimaries => 'Couleurs primaires';
@override String get chromaSubsampling => 'Sous-échantillonnage chromatique';
@override String get channels => 'Channels';
@override String get channels => 'Canaux';
@override String get path => 'Chemin';
@override String get size => 'Taille';
@override String get container => 'Conteneur';
@@ -502,7 +507,7 @@ class _TranslationsVideoControlsFr implements TranslationsVideoControlsEn {
@override String get ambientLightingOff => 'Désactiver l\'éclairage ambiant';
@override String get fullscreenButton => 'Passer en mode plein écran';
@override String get exitFullscreenButton => 'Quitter le mode plein écran';
@override String get alwaysOnTopButton => 'Always on top';
@override String get alwaysOnTopButton => 'Toujours au premier plan';
@override String get rotationLockButton => 'Verrouillage de rotation';
@override String get timelineSlider => 'Timeline vidéo';
@override String get volumeSlider => 'Niveau sonore';
@@ -512,8 +517,8 @@ class _TranslationsVideoControlsFr implements TranslationsVideoControlsEn {
@override late final _TranslationsVideoControlsPipErrorsFr pipErrors = _TranslationsVideoControlsPipErrorsFr._(_root);
@override String get chapters => 'Chapitres';
@override String get noChaptersAvailable => 'Aucun chapitre disponible';
@override String get queue => 'Queue';
@override String get noQueueItems => 'No items in queue';
@override String get queue => 'File d\'attente';
@override String get noQueueItems => 'Aucun élément dans la file d\'attente';
}
// Path: userStatus
@@ -566,9 +571,9 @@ class _TranslationsMessagesFr implements TranslationsMessagesEn {
@override String get failedToCreatePlayQueueNoItems => 'Échec de la création de la file d\'attente de lecture - aucun élément';
@override String failedPlayback({required Object action, required Object error}) => 'Echec de ${action}: ${error}';
@override String get switchingToCompatiblePlayer => 'Passage au lecteur compatible...';
@override String get logsUploaded => 'Logs uploaded';
@override String get logsUploadFailed => 'Failed to upload logs';
@override String get logId => 'Log ID';
@override String get logsUploaded => 'Logs envoyés';
@override String get logsUploadFailed => 'Échec de l\'envoi des logs';
@override String get logId => 'ID du log';
}
// Path: subtitlingStyling
@@ -763,7 +768,7 @@ class _TranslationsLogsFr implements TranslationsLogsEn {
// Translations
@override String get clearLogs => 'Effacer les logs';
@override String get copyLogs => 'Copier les logs';
@override String get uploadLogs => 'Upload Logs';
@override String get uploadLogs => 'Envoyer les logs';
@override String get error => 'Erreur:';
@override String get stackTrace => 'Liste des appels:';
}
@@ -882,7 +887,7 @@ class _TranslationsPlaylistsFr implements TranslationsPlaylistsEn {
@override String get deleted => 'Playlist supprimée';
@override String get itemAdded => 'Ajouté à la playlist';
@override String get itemRemoved => 'Retiré de la playlist';
@override String get selectPlaylist => 'Select Playlist';
@override String get selectPlaylist => 'Sélectionner une playlist';
@override String get errorCreating => 'Échec de la création de playlist';
@override String get errorDeleting => 'Échec de suppression de playlist';
@override String get errorLoading => 'Échec de chargement de playlists';
@@ -990,12 +995,12 @@ class _TranslationsShadersFr implements TranslationsShadersEn {
@override String get qualityFast => 'Rapide';
@override String get qualityHQ => 'Haute qualité';
@override String get mode => 'Mode';
@override String get importShader => 'Import Shader';
@override String get customShaderDescription => 'Custom GLSL shader';
@override String get shaderImported => 'Shader imported';
@override String get shaderImportFailed => 'Failed to import shader';
@override String get deleteShader => 'Delete Shader';
@override String deleteShaderConfirm({required Object name}) => 'Delete "${name}"?';
@override String get importShader => 'Importer un shader';
@override String get customShaderDescription => 'Shader GLSL personnalisé';
@override String get shaderImported => 'Shader importé';
@override String get shaderImportFailed => 'Échec de l\'importation du shader';
@override String get deleteShader => 'Supprimer le shader';
@override String deleteShaderConfirm({required Object name}) => 'Supprimer "${name}" ?';
}
// Path: companionRemote
@@ -1005,7 +1010,7 @@ class _TranslationsCompanionRemoteFr implements TranslationsCompanionRemoteEn {
final TranslationsFr _root; // ignore: unused_field
// Translations
@override String get title => 'Companion Remote';
@override String get title => 'Télécommande compagnon';
@override String get connectToDevice => 'Se connecter à un appareil';
@override String get hostRemoteSession => 'Héberger une session distante';
@override String get controlThisDevice => 'Contrôlez cet appareil avec votre téléphone';
@@ -1192,6 +1197,7 @@ class _TranslationsLibrariesGroupingsFr implements TranslationsLibrariesGrouping
final TranslationsFr _root; // ignore: unused_field
// Translations
@override String get title => 'Regroupement';
@override String get all => 'Tous';
@override String get movies => 'Films';
@override String get shows => 'Show TV';
@@ -1389,8 +1395,8 @@ extension on TranslationsFr {
'settings.durationHint' => ({required Object min, required Object max}) => 'Entrez la durée (${min}-${max})',
'settings.systemTheme' => 'Système',
'settings.systemThemeDescription' => 'Suivre les paramètres système',
'settings.lightTheme' => 'Light',
'settings.darkTheme' => 'Dark',
'settings.lightTheme' => 'Clair',
'settings.darkTheme' => 'Sombre',
'settings.oledTheme' => 'OLED',
'settings.oledThemeDescription' => 'Noir pur pour les écrans OLED',
'settings.libraryDensity' => 'Densité des bibliothèques',
@@ -1429,8 +1435,8 @@ extension on TranslationsFr {
'settings.bufferSizeWarning' => ({required Object heap, required Object size}) => 'Votre appareil dispose de ${heap}MB de mémoire. Un tampon de ${size}MB peut causer des problèmes de lecture.',
'settings.subtitleStyling' => 'Stylisation des sous-titres',
'settings.subtitleStylingDescription' => 'Personnaliser l\'apparence des sous-titres',
'settings.smallSkipDuration' => 'Small Skip Duration',
'settings.largeSkipDuration' => 'Large Skip Duration',
'settings.smallSkipDuration' => 'Durée du petit saut',
'settings.largeSkipDuration' => 'Durée du grand saut',
'settings.secondsUnit' => ({required Object seconds}) => '${seconds} secondes',
'settings.defaultSleepTimer' => 'Minuterie de mise en veille par défaut',
'settings.minutesUnit' => ({required Object minutes}) => '${minutes} minutes',
@@ -1472,6 +1478,11 @@ extension on TranslationsFr {
'settings.autoSkipCreditsDescription' => 'Passer les crédits et passer à l\'épisode suivant automatiquement',
'settings.autoSkipDelay' => 'Délai avant skip automatique',
'settings.autoSkipDelayDescription' => ({required Object seconds}) => 'Attendre ${seconds} secondes avant l\'auto-skip',
'settings.introPattern' => 'Modèle de marqueur d\'intro',
'settings.introPatternDescription' => 'Expression régulière pour reconnaître les marqueurs d\'intro dans les titres de chapitres',
'settings.creditsPattern' => 'Modèle de marqueur de générique',
'settings.creditsPatternDescription' => 'Expression régulière pour reconnaître les marqueurs de générique dans les titres de chapitres',
'settings.invalidRegex' => 'Expression régulière invalide',
'settings.downloads' => 'Téléchargement',
'settings.downloadLocationDescription' => 'Choisissez où stocker le contenu téléchargé',
'settings.downloadLocationDefault' => 'Par défaut (stockage de l\'application)',
@@ -1495,8 +1506,8 @@ extension on TranslationsFr {
'settings.autoPipDescription' => 'Activer automatiquement l\'image dans l\'image en quittant l\'application pendant la lecture',
'settings.matchContentFrameRate' => 'Fréquence d\'images du contenu correspondant',
'settings.matchContentFrameRateDescription' => 'Ajustez la fréquence de rafraîchissement de l\'écran en fonction du contenu vidéo, ce qui réduit les saccades et économise la batterie',
'settings.tunneledPlayback' => 'Tunneled Playback',
'settings.tunneledPlaybackDescription' => 'Use hardware-accelerated video tunneling. Disable if you see a black screen with audio on HDR content',
'settings.tunneledPlayback' => 'Lecture tunnelée',
'settings.tunneledPlaybackDescription' => 'Utiliser le tunnelage vidéo accéléré par matériel. Désactiver si vous voyez un écran noir avec du son sur du contenu HDR',
'settings.requireProfileSelectionOnOpen' => 'Demander le profil à l\'ouverture',
'settings.requireProfileSelectionOnOpenDescription' => 'Afficher la sélection de profil à chaque ouverture de l\'application',
'settings.confirmExitOnBack' => 'Confirmer avant de quitter',
@@ -1547,7 +1558,7 @@ extension on TranslationsFr {
'fileInfo.colorRange' => 'Gamme de couleurs',
'fileInfo.colorPrimaries' => 'Couleurs primaires',
'fileInfo.chromaSubsampling' => 'Sous-échantillonnage chromatique',
'fileInfo.channels' => 'Channels',
'fileInfo.channels' => 'Canaux',
'fileInfo.path' => 'Chemin',
'fileInfo.size' => 'Taille',
'fileInfo.container' => 'Conteneur',
@@ -1623,7 +1634,7 @@ extension on TranslationsFr {
'videoControls.ambientLightingOff' => 'Désactiver l\'éclairage ambiant',
'videoControls.fullscreenButton' => 'Passer en mode plein écran',
'videoControls.exitFullscreenButton' => 'Quitter le mode plein écran',
'videoControls.alwaysOnTopButton' => 'Always on top',
'videoControls.alwaysOnTopButton' => 'Toujours au premier plan',
'videoControls.rotationLockButton' => 'Verrouillage de rotation',
'videoControls.timelineSlider' => 'Timeline vidéo',
'videoControls.volumeSlider' => 'Niveau sonore',
@@ -1639,8 +1650,8 @@ extension on TranslationsFr {
'videoControls.pipErrors.unknown' => ({required Object error}) => 'Une erreur s\'est produite : ${error}',
'videoControls.chapters' => 'Chapitres',
'videoControls.noChaptersAvailable' => 'Aucun chapitre disponible',
'videoControls.queue' => 'Queue',
'videoControls.noQueueItems' => 'No items in queue',
'videoControls.queue' => 'File d\'attente',
'videoControls.noQueueItems' => 'Aucun élément dans la file d\'attente',
'userStatus.admin' => 'Admin',
'userStatus.restricted' => 'Restreint',
'userStatus.protected' => 'Protégé',
@@ -1675,9 +1686,9 @@ extension on TranslationsFr {
'messages.failedToCreatePlayQueueNoItems' => 'Échec de la création de la file d\'attente de lecture - aucun élément',
'messages.failedPlayback' => ({required Object action, required Object error}) => 'Echec de ${action}: ${error}',
'messages.switchingToCompatiblePlayer' => 'Passage au lecteur compatible...',
'messages.logsUploaded' => 'Logs uploaded',
'messages.logsUploadFailed' => 'Failed to upload logs',
'messages.logId' => 'Log ID',
'messages.logsUploaded' => 'Logs envoyés',
'messages.logsUploadFailed' => 'Échec de l\'envoi des logs',
'messages.logId' => 'ID du log',
'subtitlingStyling.stylingOptions' => 'Options de style',
'subtitlingStyling.fontSize' => 'Taille de la police',
'subtitlingStyling.textColor' => 'Couleur du texte',
@@ -1769,6 +1780,7 @@ extension on TranslationsFr {
'libraries.tabs.browse' => 'Parcourir',
'libraries.tabs.collections' => 'Collections',
'libraries.tabs.playlists' => 'Playlists',
'libraries.groupings.title' => 'Regroupement',
'libraries.groupings.all' => 'Tous',
'libraries.groupings.movies' => 'Films',
'libraries.groupings.shows' => 'Show TV',
@@ -1790,7 +1802,7 @@ extension on TranslationsFr {
'hubDetail.noItemsFound' => 'Aucun élément trouvé',
'logs.clearLogs' => 'Effacer les logs',
'logs.copyLogs' => 'Copier les logs',
'logs.uploadLogs' => 'Upload Logs',
'logs.uploadLogs' => 'Envoyer les logs',
'logs.error' => 'Erreur:',
'logs.stackTrace' => 'Liste des appels:',
'licenses.relatedPackages' => 'Package associés',
@@ -1810,14 +1822,14 @@ extension on TranslationsFr {
'liveTv.nowPlaying' => 'En cours de lecture',
'liveTv.noPrograms' => 'Aucune donnée de programme disponible',
'liveTv.channelNumber' => ({required Object number}) => 'Ch. ${number}',
_ => null,
} ?? switch (path) {
'liveTv.live' => 'EN DIRECT',
'liveTv.hd' => 'HD',
'liveTv.premiere' => 'NOUVEAU',
'liveTv.reloadGuide' => 'Recharger le guide',
'liveTv.allChannels' => 'Toutes les chaînes',
'liveTv.now' => 'Maintenant',
_ => null,
} ?? switch (path) {
'liveTv.today' => 'Aujourd\'hui',
'liveTv.midnight' => 'Minuit',
'liveTv.overnight' => 'Nuit',
@@ -1866,7 +1878,7 @@ extension on TranslationsFr {
'playlists.deleted' => 'Playlist supprimée',
'playlists.itemAdded' => 'Ajouté à la playlist',
'playlists.itemRemoved' => 'Retiré de la playlist',
'playlists.selectPlaylist' => 'Select Playlist',
'playlists.selectPlaylist' => 'Sélectionner une playlist',
'playlists.errorCreating' => 'Échec de la création de playlist',
'playlists.errorDeleting' => 'Échec de suppression de playlist',
'playlists.errorLoading' => 'Échec de chargement de playlists',
@@ -1947,13 +1959,13 @@ extension on TranslationsFr {
'shaders.qualityFast' => 'Rapide',
'shaders.qualityHQ' => 'Haute qualité',
'shaders.mode' => 'Mode',
'shaders.importShader' => 'Import Shader',
'shaders.customShaderDescription' => 'Custom GLSL shader',
'shaders.shaderImported' => 'Shader imported',
'shaders.shaderImportFailed' => 'Failed to import shader',
'shaders.deleteShader' => 'Delete Shader',
'shaders.deleteShaderConfirm' => ({required Object name}) => 'Delete "${name}"?',
'companionRemote.title' => 'Companion Remote',
'shaders.importShader' => 'Importer un shader',
'shaders.customShaderDescription' => 'Shader GLSL personnalisé',
'shaders.shaderImported' => 'Shader importé',
'shaders.shaderImportFailed' => 'Échec de l\'importation du shader',
'shaders.deleteShader' => 'Supprimer le shader',
'shaders.deleteShaderConfirm' => ({required Object name}) => 'Supprimer "${name}" ?',
'companionRemote.title' => 'Télécommande compagnon',
'companionRemote.connectToDevice' => 'Se connecter à un appareil',
'companionRemote.hostRemoteSession' => 'Héberger une session distante',
'companionRemote.controlThisDevice' => 'Contrôlez cet appareil avec votre téléphone',
+46 -34
View File
@@ -297,6 +297,11 @@ class _TranslationsSettingsIt implements TranslationsSettingsEn {
@override String get autoSkipCreditsDescription => 'Salta automaticamente i crediti e riproduci l\'episodio successivo';
@override String get autoSkipDelay => 'Ritardo Salto Automatico';
@override String autoSkipDelayDescription({required Object seconds}) => 'Aspetta ${seconds} secondi prima del salto automatico';
@override String get introPattern => 'Pattern marcatore intro';
@override String get introPatternDescription => 'Espressione regolare per riconoscere i marcatori intro nei titoli dei capitoli';
@override String get creditsPattern => 'Pattern marcatore titoli di coda';
@override String get creditsPatternDescription => 'Espressione regolare per riconoscere i marcatori dei titoli di coda nei capitoli';
@override String get invalidRegex => 'Espressione regolare non valida';
@override String get downloads => 'Download';
@override String get downloadLocationDescription => 'Scegli dove salvare i contenuti scaricati';
@override String get downloadLocationDefault => 'Predefinita (Archiviazione App)';
@@ -320,8 +325,8 @@ class _TranslationsSettingsIt implements TranslationsSettingsEn {
@override String get autoPipDescription => 'Attiva automaticamente il picture-in-picture quando si esce dall\'app durante la riproduzione';
@override String get matchContentFrameRate => 'Adatta frequenza fotogrammi';
@override String get matchContentFrameRateDescription => 'Regola la frequenza di aggiornamento del display in base al contenuto video, riducendo i tremolii e risparmiando batteria';
@override String get tunneledPlayback => 'Tunneled Playback';
@override String get tunneledPlaybackDescription => 'Use hardware-accelerated video tunneling. Disable if you see a black screen with audio on HDR content';
@override String get tunneledPlayback => 'Riproduzione tunnelizzata';
@override String get tunneledPlaybackDescription => 'Usa il tunneling video accelerato dall\'hardware. Disattiva se vedi uno schermo nero con audio su contenuti HDR';
@override String get requireProfileSelectionOnOpen => 'Chiedi profilo all\'apertura';
@override String get requireProfileSelectionOnOpenDescription => 'Mostra la selezione del profilo ogni volta che l\'app viene aperta';
@override String get confirmExitOnBack => 'Conferma prima di uscire';
@@ -512,8 +517,8 @@ class _TranslationsVideoControlsIt implements TranslationsVideoControlsEn {
@override late final _TranslationsVideoControlsPipErrorsIt pipErrors = _TranslationsVideoControlsPipErrorsIt._(_root);
@override String get chapters => 'Capitoli';
@override String get noChaptersAvailable => 'Nessun capitolo disponibile';
@override String get queue => 'Queue';
@override String get noQueueItems => 'No items in queue';
@override String get queue => 'Coda';
@override String get noQueueItems => 'Nessun elemento in coda';
}
// Path: userStatus
@@ -566,9 +571,9 @@ class _TranslationsMessagesIt implements TranslationsMessagesEn {
@override String get failedToCreatePlayQueueNoItems => 'Impossibile creare la coda di riproduzione - nessun elemento';
@override String failedPlayback({required Object action, required Object error}) => 'Impossibile ${action}: ${error}';
@override String get switchingToCompatiblePlayer => 'Passaggio al lettore compatibile...';
@override String get logsUploaded => 'Logs uploaded';
@override String get logsUploadFailed => 'Failed to upload logs';
@override String get logId => 'Log ID';
@override String get logsUploaded => 'Log caricati';
@override String get logsUploadFailed => 'Caricamento log fallito';
@override String get logId => 'ID log';
}
// Path: subtitlingStyling
@@ -585,7 +590,7 @@ class _TranslationsSubtitlingStylingIt implements TranslationsSubtitlingStylingE
@override String get borderColor => 'Colore bordo';
@override String get backgroundOpacity => 'Opacità sfondo';
@override String get backgroundColor => 'Colore sfondo';
@override String get position => 'Position';
@override String get position => 'Posizione';
}
// Path: mpvConfig
@@ -763,7 +768,7 @@ class _TranslationsLogsIt implements TranslationsLogsEn {
// Translations
@override String get clearLogs => 'Cancella log';
@override String get copyLogs => 'Copia log';
@override String get uploadLogs => 'Upload Logs';
@override String get uploadLogs => 'Carica log';
@override String get error => 'Errore:';
@override String get stackTrace => 'Traccia dello stack:';
}
@@ -990,12 +995,12 @@ class _TranslationsShadersIt implements TranslationsShadersEn {
@override String get qualityFast => 'Veloce';
@override String get qualityHQ => 'Alta qualità';
@override String get mode => 'Modalità';
@override String get importShader => 'Import Shader';
@override String get customShaderDescription => 'Custom GLSL shader';
@override String get shaderImported => 'Shader imported';
@override String get shaderImportFailed => 'Failed to import shader';
@override String get deleteShader => 'Delete Shader';
@override String deleteShaderConfirm({required Object name}) => 'Delete "${name}"?';
@override String get importShader => 'Importa shader';
@override String get customShaderDescription => 'Shader GLSL personalizzato';
@override String get shaderImported => 'Shader importato';
@override String get shaderImportFailed => 'Importazione shader fallita';
@override String get deleteShader => 'Elimina shader';
@override String deleteShaderConfirm({required Object name}) => 'Eliminare "${name}"?';
}
// Path: companionRemote
@@ -1005,7 +1010,7 @@ class _TranslationsCompanionRemoteIt implements TranslationsCompanionRemoteEn {
final TranslationsIt _root; // ignore: unused_field
// Translations
@override String get title => 'Companion Remote';
@override String get title => 'Telecomando';
@override String get connectToDevice => 'Connetti a un dispositivo';
@override String get hostRemoteSession => 'Ospita sessione remota';
@override String get controlThisDevice => 'Controlla questo dispositivo con il tuo telefono';
@@ -1192,6 +1197,7 @@ class _TranslationsLibrariesGroupingsIt implements TranslationsLibrariesGrouping
final TranslationsIt _root; // ignore: unused_field
// Translations
@override String get title => 'Raggruppamento';
@override String get all => 'Tutti';
@override String get movies => 'Film';
@override String get shows => 'Serie TV';
@@ -1472,6 +1478,11 @@ extension on TranslationsIt {
'settings.autoSkipCreditsDescription' => 'Salta automaticamente i crediti e riproduci l\'episodio successivo',
'settings.autoSkipDelay' => 'Ritardo Salto Automatico',
'settings.autoSkipDelayDescription' => ({required Object seconds}) => 'Aspetta ${seconds} secondi prima del salto automatico',
'settings.introPattern' => 'Pattern marcatore intro',
'settings.introPatternDescription' => 'Espressione regolare per riconoscere i marcatori intro nei titoli dei capitoli',
'settings.creditsPattern' => 'Pattern marcatore titoli di coda',
'settings.creditsPatternDescription' => 'Espressione regolare per riconoscere i marcatori dei titoli di coda nei capitoli',
'settings.invalidRegex' => 'Espressione regolare non valida',
'settings.downloads' => 'Download',
'settings.downloadLocationDescription' => 'Scegli dove salvare i contenuti scaricati',
'settings.downloadLocationDefault' => 'Predefinita (Archiviazione App)',
@@ -1495,8 +1506,8 @@ extension on TranslationsIt {
'settings.autoPipDescription' => 'Attiva automaticamente il picture-in-picture quando si esce dall\'app durante la riproduzione',
'settings.matchContentFrameRate' => 'Adatta frequenza fotogrammi',
'settings.matchContentFrameRateDescription' => 'Regola la frequenza di aggiornamento del display in base al contenuto video, riducendo i tremolii e risparmiando batteria',
'settings.tunneledPlayback' => 'Tunneled Playback',
'settings.tunneledPlaybackDescription' => 'Use hardware-accelerated video tunneling. Disable if you see a black screen with audio on HDR content',
'settings.tunneledPlayback' => 'Riproduzione tunnelizzata',
'settings.tunneledPlaybackDescription' => 'Usa il tunneling video accelerato dall\'hardware. Disattiva se vedi uno schermo nero con audio su contenuti HDR',
'settings.requireProfileSelectionOnOpen' => 'Chiedi profilo all\'apertura',
'settings.requireProfileSelectionOnOpenDescription' => 'Mostra la selezione del profilo ogni volta che l\'app viene aperta',
'settings.confirmExitOnBack' => 'Conferma prima di uscire',
@@ -1639,8 +1650,8 @@ extension on TranslationsIt {
'videoControls.pipErrors.unknown' => ({required Object error}) => 'Si è verificato un errore: ${error}',
'videoControls.chapters' => 'Capitoli',
'videoControls.noChaptersAvailable' => 'Nessun capitolo disponibile',
'videoControls.queue' => 'Queue',
'videoControls.noQueueItems' => 'No items in queue',
'videoControls.queue' => 'Coda',
'videoControls.noQueueItems' => 'Nessun elemento in coda',
'userStatus.admin' => 'Admin',
'userStatus.restricted' => 'Limitato',
'userStatus.protected' => 'Protetto',
@@ -1675,9 +1686,9 @@ extension on TranslationsIt {
'messages.failedToCreatePlayQueueNoItems' => 'Impossibile creare la coda di riproduzione - nessun elemento',
'messages.failedPlayback' => ({required Object action, required Object error}) => 'Impossibile ${action}: ${error}',
'messages.switchingToCompatiblePlayer' => 'Passaggio al lettore compatibile...',
'messages.logsUploaded' => 'Logs uploaded',
'messages.logsUploadFailed' => 'Failed to upload logs',
'messages.logId' => 'Log ID',
'messages.logsUploaded' => 'Log caricati',
'messages.logsUploadFailed' => 'Caricamento log fallito',
'messages.logId' => 'ID log',
'subtitlingStyling.stylingOptions' => 'Opzioni stile',
'subtitlingStyling.fontSize' => 'Dimensione',
'subtitlingStyling.textColor' => 'Colore testo',
@@ -1685,7 +1696,7 @@ extension on TranslationsIt {
'subtitlingStyling.borderColor' => 'Colore bordo',
'subtitlingStyling.backgroundOpacity' => 'Opacità sfondo',
'subtitlingStyling.backgroundColor' => 'Colore sfondo',
'subtitlingStyling.position' => 'Position',
'subtitlingStyling.position' => 'Posizione',
'mpvConfig.title' => 'Configurazione mpv',
'mpvConfig.description' => 'Impostazioni avanzate del lettore video',
'mpvConfig.presets' => 'Preset',
@@ -1769,6 +1780,7 @@ extension on TranslationsIt {
'libraries.tabs.browse' => 'Esplora',
'libraries.tabs.collections' => 'Raccolte',
'libraries.tabs.playlists' => 'Playlist',
'libraries.groupings.title' => 'Raggruppamento',
'libraries.groupings.all' => 'Tutti',
'libraries.groupings.movies' => 'Film',
'libraries.groupings.shows' => 'Serie TV',
@@ -1790,7 +1802,7 @@ extension on TranslationsIt {
'hubDetail.noItemsFound' => 'Nessun elemento trovato',
'logs.clearLogs' => 'Cancella log',
'logs.copyLogs' => 'Copia log',
'logs.uploadLogs' => 'Upload Logs',
'logs.uploadLogs' => 'Carica log',
'logs.error' => 'Errore:',
'logs.stackTrace' => 'Traccia dello stack:',
'licenses.relatedPackages' => 'Pacchetti correlati',
@@ -1810,14 +1822,14 @@ extension on TranslationsIt {
'liveTv.nowPlaying' => 'In riproduzione',
'liveTv.noPrograms' => 'Nessun dato di programma disponibile',
'liveTv.channelNumber' => ({required Object number}) => 'Canale ${number}',
_ => null,
} ?? switch (path) {
'liveTv.live' => 'IN DIRETTA',
'liveTv.hd' => 'HD',
'liveTv.premiere' => 'NUOVO',
'liveTv.reloadGuide' => 'Ricarica guida',
'liveTv.allChannels' => 'Tutti i canali',
'liveTv.now' => 'Ora',
_ => null,
} ?? switch (path) {
'liveTv.today' => 'Oggi',
'liveTv.midnight' => 'Mezzanotte',
'liveTv.overnight' => 'Notte',
@@ -1947,13 +1959,13 @@ extension on TranslationsIt {
'shaders.qualityFast' => 'Veloce',
'shaders.qualityHQ' => 'Alta qualità',
'shaders.mode' => 'Modalità',
'shaders.importShader' => 'Import Shader',
'shaders.customShaderDescription' => 'Custom GLSL shader',
'shaders.shaderImported' => 'Shader imported',
'shaders.shaderImportFailed' => 'Failed to import shader',
'shaders.deleteShader' => 'Delete Shader',
'shaders.deleteShaderConfirm' => ({required Object name}) => 'Delete "${name}"?',
'companionRemote.title' => 'Companion Remote',
'shaders.importShader' => 'Importa shader',
'shaders.customShaderDescription' => 'Shader GLSL personalizzato',
'shaders.shaderImported' => 'Shader importato',
'shaders.shaderImportFailed' => 'Importazione shader fallita',
'shaders.deleteShader' => 'Elimina shader',
'shaders.deleteShaderConfirm' => ({required Object name}) => 'Eliminare "${name}"?',
'companionRemote.title' => 'Telecomando',
'companionRemote.connectToDevice' => 'Connetti a un dispositivo',
'companionRemote.hostRemoteSession' => 'Ospita sessione remota',
'companionRemote.controlThisDevice' => 'Controlla questo dispositivo con il tuo telefono',
+14 -2
View File
@@ -297,6 +297,11 @@ class _TranslationsSettingsJa implements TranslationsSettingsEn {
@override String get autoSkipCreditsDescription => 'クレジットを自動的にスキップして次のエピソードを再生';
@override String get autoSkipDelay => '自動スキップの遅延';
@override String autoSkipDelayDescription({required Object seconds}) => '自動スキップまで${seconds}秒待機';
@override String get introPattern => 'イントロマーカーパターン';
@override String get introPatternDescription => 'チャプタータイトルのイントロマーカーに一致する正規表現パターン';
@override String get creditsPattern => 'クレジットマーカーパターン';
@override String get creditsPatternDescription => 'チャプタータイトルのクレジットマーカーに一致する正規表現パターン';
@override String get invalidRegex => '無効な正規表現';
@override String get downloads => 'ダウンロード';
@override String get downloadLocationDescription => 'ダウンロードコンテンツの保存場所を選択';
@override String get downloadLocationDefault => 'デフォルト(アプリストレージ)';
@@ -1192,6 +1197,7 @@ class _TranslationsLibrariesGroupingsJa implements TranslationsLibrariesGrouping
final TranslationsJa _root; // ignore: unused_field
// Translations
@override String get title => 'グループ';
@override String get all => 'すべて';
@override String get movies => '映画';
@override String get shows => 'テレビ番組';
@@ -1472,6 +1478,11 @@ extension on TranslationsJa {
'settings.autoSkipCreditsDescription' => 'クレジットを自動的にスキップして次のエピソードを再生',
'settings.autoSkipDelay' => '自動スキップの遅延',
'settings.autoSkipDelayDescription' => ({required Object seconds}) => '自動スキップまで${seconds}秒待機',
'settings.introPattern' => 'イントロマーカーパターン',
'settings.introPatternDescription' => 'チャプタータイトルのイントロマーカーに一致する正規表現パターン',
'settings.creditsPattern' => 'クレジットマーカーパターン',
'settings.creditsPatternDescription' => 'チャプタータイトルのクレジットマーカーに一致する正規表現パターン',
'settings.invalidRegex' => '無効な正規表現',
'settings.downloads' => 'ダウンロード',
'settings.downloadLocationDescription' => 'ダウンロードコンテンツの保存場所を選択',
'settings.downloadLocationDefault' => 'デフォルト(アプリストレージ)',
@@ -1769,6 +1780,7 @@ extension on TranslationsJa {
'libraries.tabs.browse' => 'ブラウズ',
'libraries.tabs.collections' => 'コレクション',
'libraries.tabs.playlists' => 'プレイリスト',
'libraries.groupings.title' => 'グループ',
'libraries.groupings.all' => 'すべて',
'libraries.groupings.movies' => '映画',
'libraries.groupings.shows' => 'テレビ番組',
@@ -1810,14 +1822,14 @@ extension on TranslationsJa {
'liveTv.nowPlaying' => '現在放送中',
'liveTv.noPrograms' => '番組データがありません',
'liveTv.channelNumber' => ({required Object number}) => 'Ch. ${number}',
_ => null,
} ?? switch (path) {
'liveTv.live' => 'ライブ',
'liveTv.hd' => 'HD',
'liveTv.premiere' => '新着',
'liveTv.reloadGuide' => '番組表を再読込',
'liveTv.allChannels' => 'すべてのチャンネル',
'liveTv.now' => '現在',
_ => null,
} ?? switch (path) {
'liveTv.today' => '今日',
'liveTv.midnight' => '深夜',
'liveTv.overnight' => '深夜',
+46 -34
View File
@@ -297,6 +297,11 @@ class _TranslationsSettingsKo implements TranslationsSettingsEn {
@override String get autoSkipCreditsDescription => '엔딩 크레딧 자동 건너뛰기 후 다음 에피소드 재생';
@override String get autoSkipDelay => '자동 건너뛰기 지연';
@override String autoSkipDelayDescription({required Object seconds}) => '자동 건너뛰기 전 ${seconds} 초 대기';
@override String get introPattern => '인트로 마커 패턴';
@override String get introPatternDescription => '챕터 제목에서 인트로 마커를 인식하는 정규식 패턴';
@override String get creditsPattern => '크레딧 마커 패턴';
@override String get creditsPatternDescription => '챕터 제목에서 크레딧 마커를 인식하는 정규식 패턴';
@override String get invalidRegex => '잘못된 정규식';
@override String get downloads => '다운로드';
@override String get downloadLocationDescription => '다운로드 콘텐츠 저장 위치 선택';
@override String get downloadLocationDefault => '기본값 (앱 저장소)';
@@ -320,8 +325,8 @@ class _TranslationsSettingsKo implements TranslationsSettingsEn {
@override String get autoPipDescription => '재생 중 앱을 나갈 때 자동으로 PIP 모드로 전환';
@override String get matchContentFrameRate => '콘텐츠 프레임 레이트 맞춤';
@override String get matchContentFrameRateDescription => '비디오 콘텐츠에 맞게 디스플레이 주사율을 조정하여 떨림을 줄이고 배터리를 절약합니다';
@override String get tunneledPlayback => 'Tunneled Playback';
@override String get tunneledPlaybackDescription => 'Use hardware-accelerated video tunneling. Disable if you see a black screen with audio on HDR content';
@override String get tunneledPlayback => '터널 재생';
@override String get tunneledPlaybackDescription => '하드웨어 가속 비디오 터널링을 사용합니다. HDR 콘텐츠에서 소리만 나고 검은 화면이 보이면 비활성화하세요';
@override String get requireProfileSelectionOnOpen => '앱 실행 시 프로필 선택';
@override String get requireProfileSelectionOnOpenDescription => '앱을 열 때마다 프로필 선택 화면을 표시합니다';
@override String get confirmExitOnBack => '종료 전 확인';
@@ -512,8 +517,8 @@ class _TranslationsVideoControlsKo implements TranslationsVideoControlsEn {
@override late final _TranslationsVideoControlsPipErrorsKo pipErrors = _TranslationsVideoControlsPipErrorsKo._(_root);
@override String get chapters => '챕터';
@override String get noChaptersAvailable => '사용 가능한 챕터가 없습니다';
@override String get queue => 'Queue';
@override String get noQueueItems => 'No items in queue';
@override String get queue => '재생 대기열';
@override String get noQueueItems => '대기열에 항목이 없습니다';
}
// Path: userStatus
@@ -566,9 +571,9 @@ class _TranslationsMessagesKo implements TranslationsMessagesEn {
@override String get failedToCreatePlayQueueNoItems => '재생 대기열 생성 실패 - 항목 없음';
@override String failedPlayback({required Object action, required Object error}) => '${action}을(를) 수행할 수 없습니다: ${error}';
@override String get switchingToCompatiblePlayer => '호환되는 플레이어로 전환 중...';
@override String get logsUploaded => 'Logs uploaded';
@override String get logsUploadFailed => 'Failed to upload logs';
@override String get logId => 'Log ID';
@override String get logsUploaded => '로그 업로드 완료';
@override String get logsUploadFailed => '로그 업로드 실패';
@override String get logId => '로그 ID';
}
// Path: subtitlingStyling
@@ -585,7 +590,7 @@ class _TranslationsSubtitlingStylingKo implements TranslationsSubtitlingStylingE
@override String get borderColor => '테두리 색상';
@override String get backgroundOpacity => '배경 불투명도';
@override String get backgroundColor => '배경색';
@override String get position => 'Position';
@override String get position => '위치';
}
// Path: mpvConfig
@@ -763,7 +768,7 @@ class _TranslationsLogsKo implements TranslationsLogsEn {
// Translations
@override String get clearLogs => '로그 지우기';
@override String get copyLogs => '로그 복사';
@override String get uploadLogs => 'Upload Logs';
@override String get uploadLogs => '로그 업로드';
@override String get error => '오류:';
@override String get stackTrace => '스택 추적 (Stack Trace):';
}
@@ -990,12 +995,12 @@ class _TranslationsShadersKo implements TranslationsShadersEn {
@override String get qualityFast => '빠름';
@override String get qualityHQ => '고품질';
@override String get mode => '모드';
@override String get importShader => 'Import Shader';
@override String get customShaderDescription => 'Custom GLSL shader';
@override String get shaderImported => 'Shader imported';
@override String get shaderImportFailed => 'Failed to import shader';
@override String get deleteShader => 'Delete Shader';
@override String deleteShaderConfirm({required Object name}) => 'Delete "${name}"?';
@override String get importShader => '셰이더 가져오기';
@override String get customShaderDescription => '사용자 정의 GLSL 셰이더';
@override String get shaderImported => '셰이더를 가져왔습니다';
@override String get shaderImportFailed => '셰이더 가져오기 실패';
@override String get deleteShader => '셰이더 삭제';
@override String deleteShaderConfirm({required Object name}) => '"${name}"을(를) 삭제하시겠습니까?';
}
// Path: companionRemote
@@ -1005,7 +1010,7 @@ class _TranslationsCompanionRemoteKo implements TranslationsCompanionRemoteEn {
final TranslationsKo _root; // ignore: unused_field
// Translations
@override String get title => 'Companion Remote';
@override String get title => '컴패니언 리모컨';
@override String get connectToDevice => '기기에 연결';
@override String get hostRemoteSession => '원격 세션 호스트';
@override String get controlThisDevice => '휴대폰으로 이 기기를 제어하세요';
@@ -1192,6 +1197,7 @@ class _TranslationsLibrariesGroupingsKo implements TranslationsLibrariesGrouping
final TranslationsKo _root; // ignore: unused_field
// Translations
@override String get title => '그룹';
@override String get all => '전체';
@override String get movies => '영화';
@override String get shows => 'TV 프로그램';
@@ -1472,6 +1478,11 @@ extension on TranslationsKo {
'settings.autoSkipCreditsDescription' => '엔딩 크레딧 자동 건너뛰기 후 다음 에피소드 재생',
'settings.autoSkipDelay' => '자동 건너뛰기 지연',
'settings.autoSkipDelayDescription' => ({required Object seconds}) => '자동 건너뛰기 전 ${seconds} 초 대기',
'settings.introPattern' => '인트로 마커 패턴',
'settings.introPatternDescription' => '챕터 제목에서 인트로 마커를 인식하는 정규식 패턴',
'settings.creditsPattern' => '크레딧 마커 패턴',
'settings.creditsPatternDescription' => '챕터 제목에서 크레딧 마커를 인식하는 정규식 패턴',
'settings.invalidRegex' => '잘못된 정규식',
'settings.downloads' => '다운로드',
'settings.downloadLocationDescription' => '다운로드 콘텐츠 저장 위치 선택',
'settings.downloadLocationDefault' => '기본값 (앱 저장소)',
@@ -1495,8 +1506,8 @@ extension on TranslationsKo {
'settings.autoPipDescription' => '재생 중 앱을 나갈 때 자동으로 PIP 모드로 전환',
'settings.matchContentFrameRate' => '콘텐츠 프레임 레이트 맞춤',
'settings.matchContentFrameRateDescription' => '비디오 콘텐츠에 맞게 디스플레이 주사율을 조정하여 떨림을 줄이고 배터리를 절약합니다',
'settings.tunneledPlayback' => 'Tunneled Playback',
'settings.tunneledPlaybackDescription' => 'Use hardware-accelerated video tunneling. Disable if you see a black screen with audio on HDR content',
'settings.tunneledPlayback' => '터널 재생',
'settings.tunneledPlaybackDescription' => '하드웨어 가속 비디오 터널링을 사용합니다. HDR 콘텐츠에서 소리만 나고 검은 화면이 보이면 비활성화하세요',
'settings.requireProfileSelectionOnOpen' => '앱 실행 시 프로필 선택',
'settings.requireProfileSelectionOnOpenDescription' => '앱을 열 때마다 프로필 선택 화면을 표시합니다',
'settings.confirmExitOnBack' => '종료 전 확인',
@@ -1639,8 +1650,8 @@ extension on TranslationsKo {
'videoControls.pipErrors.unknown' => ({required Object error}) => '오류가 발생했습니다: ${error}',
'videoControls.chapters' => '챕터',
'videoControls.noChaptersAvailable' => '사용 가능한 챕터가 없습니다',
'videoControls.queue' => 'Queue',
'videoControls.noQueueItems' => 'No items in queue',
'videoControls.queue' => '재생 대기열',
'videoControls.noQueueItems' => '대기열에 항목이 없습니다',
'userStatus.admin' => '관리자',
'userStatus.restricted' => '제한됨',
'userStatus.protected' => '보호됨',
@@ -1675,9 +1686,9 @@ extension on TranslationsKo {
'messages.failedToCreatePlayQueueNoItems' => '재생 대기열 생성 실패 - 항목 없음',
'messages.failedPlayback' => ({required Object action, required Object error}) => '${action}을(를) 수행할 수 없습니다: ${error}',
'messages.switchingToCompatiblePlayer' => '호환되는 플레이어로 전환 중...',
'messages.logsUploaded' => 'Logs uploaded',
'messages.logsUploadFailed' => 'Failed to upload logs',
'messages.logId' => 'Log ID',
'messages.logsUploaded' => '로그 업로드 완료',
'messages.logsUploadFailed' => '로그 업로드 실패',
'messages.logId' => '로그 ID',
'subtitlingStyling.stylingOptions' => '스타일 옵션',
'subtitlingStyling.fontSize' => '글자 크기',
'subtitlingStyling.textColor' => '텍스트 색상',
@@ -1685,7 +1696,7 @@ extension on TranslationsKo {
'subtitlingStyling.borderColor' => '테두리 색상',
'subtitlingStyling.backgroundOpacity' => '배경 불투명도',
'subtitlingStyling.backgroundColor' => '배경색',
'subtitlingStyling.position' => 'Position',
'subtitlingStyling.position' => '위치',
'mpvConfig.title' => 'mpv 설정',
'mpvConfig.description' => '고급 비디오 플레이어 설정',
'mpvConfig.presets' => '사전 설정',
@@ -1769,6 +1780,7 @@ extension on TranslationsKo {
'libraries.tabs.browse' => '찾아보기',
'libraries.tabs.collections' => '컬렉션',
'libraries.tabs.playlists' => '재생 목록',
'libraries.groupings.title' => '그룹',
'libraries.groupings.all' => '전체',
'libraries.groupings.movies' => '영화',
'libraries.groupings.shows' => 'TV 프로그램',
@@ -1790,7 +1802,7 @@ extension on TranslationsKo {
'hubDetail.noItemsFound' => '항목이 없습니다',
'logs.clearLogs' => '로그 지우기',
'logs.copyLogs' => '로그 복사',
'logs.uploadLogs' => 'Upload Logs',
'logs.uploadLogs' => '로그 업로드',
'logs.error' => '오류:',
'logs.stackTrace' => '스택 추적 (Stack Trace):',
'licenses.relatedPackages' => '관련 소프트웨어 패키지',
@@ -1810,14 +1822,14 @@ extension on TranslationsKo {
'liveTv.nowPlaying' => '현재 재생 중',
'liveTv.noPrograms' => '프로그램 데이터가 없습니다',
'liveTv.channelNumber' => ({required Object number}) => '채널 ${number}',
_ => null,
} ?? switch (path) {
'liveTv.live' => '실시간',
'liveTv.hd' => 'HD',
'liveTv.premiere' => '신규',
'liveTv.reloadGuide' => '편성표 새로고침',
'liveTv.allChannels' => '전체 채널',
'liveTv.now' => '지금',
_ => null,
} ?? switch (path) {
'liveTv.today' => '오늘',
'liveTv.midnight' => '자정',
'liveTv.overnight' => '심야',
@@ -1947,13 +1959,13 @@ extension on TranslationsKo {
'shaders.qualityFast' => '빠름',
'shaders.qualityHQ' => '고품질',
'shaders.mode' => '모드',
'shaders.importShader' => 'Import Shader',
'shaders.customShaderDescription' => 'Custom GLSL shader',
'shaders.shaderImported' => 'Shader imported',
'shaders.shaderImportFailed' => 'Failed to import shader',
'shaders.deleteShader' => 'Delete Shader',
'shaders.deleteShaderConfirm' => ({required Object name}) => 'Delete "${name}"?',
'companionRemote.title' => 'Companion Remote',
'shaders.importShader' => '셰이더 가져오기',
'shaders.customShaderDescription' => '사용자 정의 GLSL 셰이더',
'shaders.shaderImported' => '셰이더를 가져왔습니다',
'shaders.shaderImportFailed' => '셰이더 가져오기 실패',
'shaders.deleteShader' => '셰이더 삭제',
'shaders.deleteShaderConfirm' => ({required Object name}) => '"${name}"을(를) 삭제하시겠습니까?',
'companionRemote.title' => '컴패니언 리모컨',
'companionRemote.connectToDevice' => '기기에 연결',
'companionRemote.hostRemoteSession' => '원격 세션 호스트',
'companionRemote.controlThisDevice' => '휴대폰으로 이 기기를 제어하세요',
+14 -2
View File
@@ -297,6 +297,11 @@ class _TranslationsSettingsNb implements TranslationsSettingsEn {
@override String get autoSkipCreditsDescription => 'Hopp automatisk over rulletekst og spill neste episode';
@override String get autoSkipDelay => 'Forsinkelse for automatisk hopp';
@override String autoSkipDelayDescription({required Object seconds}) => 'Vent ${seconds} sekunder før automatisk hopping';
@override String get introPattern => 'Intromarkørmønster';
@override String get introPatternDescription => 'Regulært uttrykk for å gjenkjenne intromarkører i kapitteltitler';
@override String get creditsPattern => 'Rulletekstmarkørmønster';
@override String get creditsPatternDescription => 'Regulært uttrykk for å gjenkjenne rulletekstmarkører i kapitteltitler';
@override String get invalidRegex => 'Ugyldig regulært uttrykk';
@override String get downloads => 'Nedlastinger';
@override String get downloadLocationDescription => 'Velg hvor nedlastet innhold skal lagres';
@override String get downloadLocationDefault => 'Standard (App-lagring)';
@@ -1192,6 +1197,7 @@ class _TranslationsLibrariesGroupingsNb implements TranslationsLibrariesGrouping
final TranslationsNb _root; // ignore: unused_field
// Translations
@override String get title => 'Gruppering';
@override String get all => 'Alle';
@override String get movies => 'Filmer';
@override String get shows => 'TV-serier';
@@ -1472,6 +1478,11 @@ extension on TranslationsNb {
'settings.autoSkipCreditsDescription' => 'Hopp automatisk over rulletekst og spill neste episode',
'settings.autoSkipDelay' => 'Forsinkelse for automatisk hopp',
'settings.autoSkipDelayDescription' => ({required Object seconds}) => 'Vent ${seconds} sekunder før automatisk hopping',
'settings.introPattern' => 'Intromarkørmønster',
'settings.introPatternDescription' => 'Regulært uttrykk for å gjenkjenne intromarkører i kapitteltitler',
'settings.creditsPattern' => 'Rulletekstmarkørmønster',
'settings.creditsPatternDescription' => 'Regulært uttrykk for å gjenkjenne rulletekstmarkører i kapitteltitler',
'settings.invalidRegex' => 'Ugyldig regulært uttrykk',
'settings.downloads' => 'Nedlastinger',
'settings.downloadLocationDescription' => 'Velg hvor nedlastet innhold skal lagres',
'settings.downloadLocationDefault' => 'Standard (App-lagring)',
@@ -1769,6 +1780,7 @@ extension on TranslationsNb {
'libraries.tabs.browse' => 'Bla gjennom',
'libraries.tabs.collections' => 'Samlinger',
'libraries.tabs.playlists' => 'Spillelister',
'libraries.groupings.title' => 'Gruppering',
'libraries.groupings.all' => 'Alle',
'libraries.groupings.movies' => 'Filmer',
'libraries.groupings.shows' => 'TV-serier',
@@ -1810,14 +1822,14 @@ extension on TranslationsNb {
'liveTv.nowPlaying' => 'Spilles nå',
'liveTv.noPrograms' => 'Ingen programdata tilgjengelig',
'liveTv.channelNumber' => ({required Object number}) => 'Kanal ${number}',
_ => null,
} ?? switch (path) {
'liveTv.live' => 'DIREKTE',
'liveTv.hd' => 'HD',
'liveTv.premiere' => 'NY',
'liveTv.reloadGuide' => 'Last inn programguide på nytt',
'liveTv.allChannels' => 'Alle kanaler',
'liveTv.now' => '',
_ => null,
} ?? switch (path) {
'liveTv.today' => 'I dag',
'liveTv.midnight' => 'Midnatt',
'liveTv.overnight' => 'Natt',
+46 -34
View File
@@ -297,6 +297,11 @@ class _TranslationsSettingsNl implements TranslationsSettingsEn {
@override String get autoSkipCreditsDescription => 'Credits automatisch overslaan en volgende aflevering afspelen';
@override String get autoSkipDelay => 'Vertraging Automatisch Overslaan';
@override String autoSkipDelayDescription({required Object seconds}) => '${seconds} seconden wachten voor automatisch overslaan';
@override String get introPattern => 'Intromarkeringspatroon';
@override String get introPatternDescription => 'Reguliere expressie om intromarkeringen in hoofdstuktitels te herkennen';
@override String get creditsPattern => 'Aftitelingmarkeringspatroon';
@override String get creditsPatternDescription => 'Reguliere expressie om aftitelingmarkeringen in hoofdstuktitels te herkennen';
@override String get invalidRegex => 'Ongeldige reguliere expressie';
@override String get downloads => 'Downloads';
@override String get downloadLocationDescription => 'Kies waar gedownloade content wordt opgeslagen';
@override String get downloadLocationDefault => 'Standaard (App-opslag)';
@@ -320,8 +325,8 @@ class _TranslationsSettingsNl implements TranslationsSettingsEn {
@override String get autoPipDescription => 'Automatisch beeld-in-beeld activeren bij het verlaten van de app tijdens afspelen';
@override String get matchContentFrameRate => 'Inhoudsframesnelheid afstemmen';
@override String get matchContentFrameRateDescription => 'Pas de schermverversingssnelheid aan op de video-inhoud, vermindert haperingen en bespaart batterij';
@override String get tunneledPlayback => 'Tunneled Playback';
@override String get tunneledPlaybackDescription => 'Use hardware-accelerated video tunneling. Disable if you see a black screen with audio on HDR content';
@override String get tunneledPlayback => 'Getunnelde weergave';
@override String get tunneledPlaybackDescription => 'Gebruik hardwareversnelde videotunneling. Schakel uit als je een zwart scherm met geluid ziet bij HDR-content';
@override String get requireProfileSelectionOnOpen => 'Vraag om profiel bij openen';
@override String get requireProfileSelectionOnOpenDescription => 'Toon profielselectie telkens wanneer de app wordt geopend';
@override String get confirmExitOnBack => 'Bevestigen voor afsluiten';
@@ -512,8 +517,8 @@ class _TranslationsVideoControlsNl implements TranslationsVideoControlsEn {
@override late final _TranslationsVideoControlsPipErrorsNl pipErrors = _TranslationsVideoControlsPipErrorsNl._(_root);
@override String get chapters => 'Hoofdstukken';
@override String get noChaptersAvailable => 'Geen hoofdstukken beschikbaar';
@override String get queue => 'Queue';
@override String get noQueueItems => 'No items in queue';
@override String get queue => 'Wachtrij';
@override String get noQueueItems => 'Geen items in de wachtrij';
}
// Path: userStatus
@@ -566,9 +571,9 @@ class _TranslationsMessagesNl implements TranslationsMessagesEn {
@override String get failedToCreatePlayQueueNoItems => 'Kan afspeelwachtrij niet maken - geen items';
@override String failedPlayback({required Object action, required Object error}) => 'Afspelen van ${action} mislukt: ${error}';
@override String get switchingToCompatiblePlayer => 'Overschakelen naar compatibele speler...';
@override String get logsUploaded => 'Logs uploaded';
@override String get logsUploadFailed => 'Failed to upload logs';
@override String get logId => 'Log ID';
@override String get logsUploaded => 'Logs geüpload';
@override String get logsUploadFailed => 'Uploaden van logs mislukt';
@override String get logId => 'Log-ID';
}
// Path: subtitlingStyling
@@ -585,7 +590,7 @@ class _TranslationsSubtitlingStylingNl implements TranslationsSubtitlingStylingE
@override String get borderColor => 'Randkleur';
@override String get backgroundOpacity => 'Achtergrond transparantie';
@override String get backgroundColor => 'Achtergrondkleur';
@override String get position => 'Position';
@override String get position => 'Positie';
}
// Path: mpvConfig
@@ -763,7 +768,7 @@ class _TranslationsLogsNl implements TranslationsLogsEn {
// Translations
@override String get clearLogs => 'Wis logs';
@override String get copyLogs => 'Kopieer logs';
@override String get uploadLogs => 'Upload Logs';
@override String get uploadLogs => 'Logs uploaden';
@override String get error => 'Fout:';
@override String get stackTrace => 'Stacktracering:';
}
@@ -990,12 +995,12 @@ class _TranslationsShadersNl implements TranslationsShadersEn {
@override String get qualityFast => 'Snel';
@override String get qualityHQ => 'Hoge kwaliteit';
@override String get mode => 'Modus';
@override String get importShader => 'Import Shader';
@override String get customShaderDescription => 'Custom GLSL shader';
@override String get shaderImported => 'Shader imported';
@override String get shaderImportFailed => 'Failed to import shader';
@override String get deleteShader => 'Delete Shader';
@override String deleteShaderConfirm({required Object name}) => 'Delete "${name}"?';
@override String get importShader => 'Shader importeren';
@override String get customShaderDescription => 'Aangepaste GLSL-shader';
@override String get shaderImported => 'Shader geïmporteerd';
@override String get shaderImportFailed => 'Shader importeren mislukt';
@override String get deleteShader => 'Shader verwijderen';
@override String deleteShaderConfirm({required Object name}) => '"${name}" verwijderen?';
}
// Path: companionRemote
@@ -1005,7 +1010,7 @@ class _TranslationsCompanionRemoteNl implements TranslationsCompanionRemoteEn {
final TranslationsNl _root; // ignore: unused_field
// Translations
@override String get title => 'Companion Remote';
@override String get title => 'Afstandsbediening';
@override String get connectToDevice => 'Verbinden met apparaat';
@override String get hostRemoteSession => 'Externe sessie hosten';
@override String get controlThisDevice => 'Bedien dit apparaat met je telefoon';
@@ -1192,6 +1197,7 @@ class _TranslationsLibrariesGroupingsNl implements TranslationsLibrariesGrouping
final TranslationsNl _root; // ignore: unused_field
// Translations
@override String get title => 'Groepering';
@override String get all => 'Alles';
@override String get movies => 'Films';
@override String get shows => 'Series';
@@ -1472,6 +1478,11 @@ extension on TranslationsNl {
'settings.autoSkipCreditsDescription' => 'Credits automatisch overslaan en volgende aflevering afspelen',
'settings.autoSkipDelay' => 'Vertraging Automatisch Overslaan',
'settings.autoSkipDelayDescription' => ({required Object seconds}) => '${seconds} seconden wachten voor automatisch overslaan',
'settings.introPattern' => 'Intromarkeringspatroon',
'settings.introPatternDescription' => 'Reguliere expressie om intromarkeringen in hoofdstuktitels te herkennen',
'settings.creditsPattern' => 'Aftitelingmarkeringspatroon',
'settings.creditsPatternDescription' => 'Reguliere expressie om aftitelingmarkeringen in hoofdstuktitels te herkennen',
'settings.invalidRegex' => 'Ongeldige reguliere expressie',
'settings.downloads' => 'Downloads',
'settings.downloadLocationDescription' => 'Kies waar gedownloade content wordt opgeslagen',
'settings.downloadLocationDefault' => 'Standaard (App-opslag)',
@@ -1495,8 +1506,8 @@ extension on TranslationsNl {
'settings.autoPipDescription' => 'Automatisch beeld-in-beeld activeren bij het verlaten van de app tijdens afspelen',
'settings.matchContentFrameRate' => 'Inhoudsframesnelheid afstemmen',
'settings.matchContentFrameRateDescription' => 'Pas de schermverversingssnelheid aan op de video-inhoud, vermindert haperingen en bespaart batterij',
'settings.tunneledPlayback' => 'Tunneled Playback',
'settings.tunneledPlaybackDescription' => 'Use hardware-accelerated video tunneling. Disable if you see a black screen with audio on HDR content',
'settings.tunneledPlayback' => 'Getunnelde weergave',
'settings.tunneledPlaybackDescription' => 'Gebruik hardwareversnelde videotunneling. Schakel uit als je een zwart scherm met geluid ziet bij HDR-content',
'settings.requireProfileSelectionOnOpen' => 'Vraag om profiel bij openen',
'settings.requireProfileSelectionOnOpenDescription' => 'Toon profielselectie telkens wanneer de app wordt geopend',
'settings.confirmExitOnBack' => 'Bevestigen voor afsluiten',
@@ -1639,8 +1650,8 @@ extension on TranslationsNl {
'videoControls.pipErrors.unknown' => ({required Object error}) => 'Er is een fout opgetreden: ${error}',
'videoControls.chapters' => 'Hoofdstukken',
'videoControls.noChaptersAvailable' => 'Geen hoofdstukken beschikbaar',
'videoControls.queue' => 'Queue',
'videoControls.noQueueItems' => 'No items in queue',
'videoControls.queue' => 'Wachtrij',
'videoControls.noQueueItems' => 'Geen items in de wachtrij',
'userStatus.admin' => 'Beheerder',
'userStatus.restricted' => 'Beperkt',
'userStatus.protected' => 'Beschermd',
@@ -1675,9 +1686,9 @@ extension on TranslationsNl {
'messages.failedToCreatePlayQueueNoItems' => 'Kan afspeelwachtrij niet maken - geen items',
'messages.failedPlayback' => ({required Object action, required Object error}) => 'Afspelen van ${action} mislukt: ${error}',
'messages.switchingToCompatiblePlayer' => 'Overschakelen naar compatibele speler...',
'messages.logsUploaded' => 'Logs uploaded',
'messages.logsUploadFailed' => 'Failed to upload logs',
'messages.logId' => 'Log ID',
'messages.logsUploaded' => 'Logs geüpload',
'messages.logsUploadFailed' => 'Uploaden van logs mislukt',
'messages.logId' => 'Log-ID',
'subtitlingStyling.stylingOptions' => 'Opmaak opties',
'subtitlingStyling.fontSize' => 'Lettergrootte',
'subtitlingStyling.textColor' => 'Tekstkleur',
@@ -1685,7 +1696,7 @@ extension on TranslationsNl {
'subtitlingStyling.borderColor' => 'Randkleur',
'subtitlingStyling.backgroundOpacity' => 'Achtergrond transparantie',
'subtitlingStyling.backgroundColor' => 'Achtergrondkleur',
'subtitlingStyling.position' => 'Position',
'subtitlingStyling.position' => 'Positie',
'mpvConfig.title' => 'mpv-configuratie',
'mpvConfig.description' => 'Geavanceerde videospeler-instellingen',
'mpvConfig.presets' => 'Voorinstellingen',
@@ -1769,6 +1780,7 @@ extension on TranslationsNl {
'libraries.tabs.browse' => 'Bladeren',
'libraries.tabs.collections' => 'Collecties',
'libraries.tabs.playlists' => 'Afspeellijsten',
'libraries.groupings.title' => 'Groepering',
'libraries.groupings.all' => 'Alles',
'libraries.groupings.movies' => 'Films',
'libraries.groupings.shows' => 'Series',
@@ -1790,7 +1802,7 @@ extension on TranslationsNl {
'hubDetail.noItemsFound' => 'Geen items gevonden',
'logs.clearLogs' => 'Wis logs',
'logs.copyLogs' => 'Kopieer logs',
'logs.uploadLogs' => 'Upload Logs',
'logs.uploadLogs' => 'Logs uploaden',
'logs.error' => 'Fout:',
'logs.stackTrace' => 'Stacktracering:',
'licenses.relatedPackages' => 'Gerelateerde pakketten',
@@ -1810,14 +1822,14 @@ extension on TranslationsNl {
'liveTv.nowPlaying' => 'Nu aan het afspelen',
'liveTv.noPrograms' => 'Geen programmagegevens beschikbaar',
'liveTv.channelNumber' => ({required Object number}) => 'Kanaal ${number}',
_ => null,
} ?? switch (path) {
'liveTv.live' => 'LIVE',
'liveTv.hd' => 'HD',
'liveTv.premiere' => 'NIEUW',
'liveTv.reloadGuide' => 'Gids herladen',
'liveTv.allChannels' => 'Alle zenders',
'liveTv.now' => 'Nu',
_ => null,
} ?? switch (path) {
'liveTv.today' => 'Vandaag',
'liveTv.midnight' => 'Middernacht',
'liveTv.overnight' => 'Nacht',
@@ -1947,13 +1959,13 @@ extension on TranslationsNl {
'shaders.qualityFast' => 'Snel',
'shaders.qualityHQ' => 'Hoge kwaliteit',
'shaders.mode' => 'Modus',
'shaders.importShader' => 'Import Shader',
'shaders.customShaderDescription' => 'Custom GLSL shader',
'shaders.shaderImported' => 'Shader imported',
'shaders.shaderImportFailed' => 'Failed to import shader',
'shaders.deleteShader' => 'Delete Shader',
'shaders.deleteShaderConfirm' => ({required Object name}) => 'Delete "${name}"?',
'companionRemote.title' => 'Companion Remote',
'shaders.importShader' => 'Shader importeren',
'shaders.customShaderDescription' => 'Aangepaste GLSL-shader',
'shaders.shaderImported' => 'Shader geïmporteerd',
'shaders.shaderImportFailed' => 'Shader importeren mislukt',
'shaders.deleteShader' => 'Shader verwijderen',
'shaders.deleteShaderConfirm' => ({required Object name}) => '"${name}" verwijderen?',
'companionRemote.title' => 'Afstandsbediening',
'companionRemote.connectToDevice' => 'Verbinden met apparaat',
'companionRemote.hostRemoteSession' => 'Externe sessie hosten',
'companionRemote.controlThisDevice' => 'Bedien dit apparaat met je telefoon',
+14 -2
View File
@@ -297,6 +297,11 @@ class _TranslationsSettingsPl implements TranslationsSettingsEn {
@override String get autoSkipCreditsDescription => 'Automatycznie pomijaj napisy końcowe i odtwórz następny odcinek';
@override String get autoSkipDelay => 'Opóźnienie automatycznego pomijania';
@override String autoSkipDelayDescription({required Object seconds}) => 'Czekaj ${seconds} sekund przed automatycznym pominięciem';
@override String get introPattern => 'Wzorzec znacznika intro';
@override String get introPatternDescription => 'Wyrażenie regularne do rozpoznawania znaczników intro w tytułach rozdziałów';
@override String get creditsPattern => 'Wzorzec znacznika napisów końcowych';
@override String get creditsPatternDescription => 'Wyrażenie regularne do rozpoznawania znaczników napisów końcowych w tytułach rozdziałów';
@override String get invalidRegex => 'Nieprawidłowe wyrażenie regularne';
@override String get downloads => 'Pobrania';
@override String get downloadLocationDescription => 'Wybierz miejsce przechowywania pobranych treści';
@override String get downloadLocationDefault => 'Domyślne (Pamięć aplikacji)';
@@ -1192,6 +1197,7 @@ class _TranslationsLibrariesGroupingsPl implements TranslationsLibrariesGrouping
final TranslationsPl _root; // ignore: unused_field
// Translations
@override String get title => 'Grupowanie';
@override String get all => 'Wszystkie';
@override String get movies => 'Filmy';
@override String get shows => 'Seriale TV';
@@ -1472,6 +1478,11 @@ extension on TranslationsPl {
'settings.autoSkipCreditsDescription' => 'Automatycznie pomijaj napisy końcowe i odtwórz następny odcinek',
'settings.autoSkipDelay' => 'Opóźnienie automatycznego pomijania',
'settings.autoSkipDelayDescription' => ({required Object seconds}) => 'Czekaj ${seconds} sekund przed automatycznym pominięciem',
'settings.introPattern' => 'Wzorzec znacznika intro',
'settings.introPatternDescription' => 'Wyrażenie regularne do rozpoznawania znaczników intro w tytułach rozdziałów',
'settings.creditsPattern' => 'Wzorzec znacznika napisów końcowych',
'settings.creditsPatternDescription' => 'Wyrażenie regularne do rozpoznawania znaczników napisów końcowych w tytułach rozdziałów',
'settings.invalidRegex' => 'Nieprawidłowe wyrażenie regularne',
'settings.downloads' => 'Pobrania',
'settings.downloadLocationDescription' => 'Wybierz miejsce przechowywania pobranych treści',
'settings.downloadLocationDefault' => 'Domyślne (Pamięć aplikacji)',
@@ -1769,6 +1780,7 @@ extension on TranslationsPl {
'libraries.tabs.browse' => 'Przeglądaj',
'libraries.tabs.collections' => 'Kolekcje',
'libraries.tabs.playlists' => 'Playlisty',
'libraries.groupings.title' => 'Grupowanie',
'libraries.groupings.all' => 'Wszystkie',
'libraries.groupings.movies' => 'Filmy',
'libraries.groupings.shows' => 'Seriale TV',
@@ -1810,14 +1822,14 @@ extension on TranslationsPl {
'liveTv.nowPlaying' => 'Teraz odtwarzane',
'liveTv.noPrograms' => 'Brak danych o programach',
'liveTv.channelNumber' => ({required Object number}) => 'Kn. ${number}',
_ => null,
} ?? switch (path) {
'liveTv.live' => 'NA ŻYWO',
'liveTv.hd' => 'HD',
'liveTv.premiere' => 'NOWE',
'liveTv.reloadGuide' => 'Odśwież przewodnik',
'liveTv.allChannels' => 'Wszystkie kanały',
'liveTv.now' => 'Teraz',
_ => null,
} ?? switch (path) {
'liveTv.today' => 'Dzisiaj',
'liveTv.midnight' => 'Północ',
'liveTv.overnight' => 'Nocą',
+14 -2
View File
@@ -297,6 +297,11 @@ class _TranslationsSettingsPt implements TranslationsSettingsEn {
@override String get autoSkipCreditsDescription => 'Pular créditos automaticamente e reproduzir próximo episódio';
@override String get autoSkipDelay => 'Atraso do Pulo Automático';
@override String autoSkipDelayDescription({required Object seconds}) => 'Aguardar ${seconds} segundos antes de pular automaticamente';
@override String get introPattern => 'Padrão de marcador de intro';
@override String get introPatternDescription => 'Expressão regular para corresponder marcadores de intro nos títulos dos capítulos';
@override String get creditsPattern => 'Padrão de marcador de créditos';
@override String get creditsPatternDescription => 'Expressão regular para corresponder marcadores de créditos nos títulos dos capítulos';
@override String get invalidRegex => 'Expressão regular inválida';
@override String get downloads => 'Downloads';
@override String get downloadLocationDescription => 'Escolha onde armazenar conteúdo baixado';
@override String get downloadLocationDefault => 'Padrão (Armazenamento do App)';
@@ -1192,6 +1197,7 @@ class _TranslationsLibrariesGroupingsPt implements TranslationsLibrariesGrouping
final TranslationsPt _root; // ignore: unused_field
// Translations
@override String get title => 'Agrupamento';
@override String get all => 'Todos';
@override String get movies => 'Filmes';
@override String get shows => 'Séries de TV';
@@ -1472,6 +1478,11 @@ extension on TranslationsPt {
'settings.autoSkipCreditsDescription' => 'Pular créditos automaticamente e reproduzir próximo episódio',
'settings.autoSkipDelay' => 'Atraso do Pulo Automático',
'settings.autoSkipDelayDescription' => ({required Object seconds}) => 'Aguardar ${seconds} segundos antes de pular automaticamente',
'settings.introPattern' => 'Padrão de marcador de intro',
'settings.introPatternDescription' => 'Expressão regular para corresponder marcadores de intro nos títulos dos capítulos',
'settings.creditsPattern' => 'Padrão de marcador de créditos',
'settings.creditsPatternDescription' => 'Expressão regular para corresponder marcadores de créditos nos títulos dos capítulos',
'settings.invalidRegex' => 'Expressão regular inválida',
'settings.downloads' => 'Downloads',
'settings.downloadLocationDescription' => 'Escolha onde armazenar conteúdo baixado',
'settings.downloadLocationDefault' => 'Padrão (Armazenamento do App)',
@@ -1769,6 +1780,7 @@ extension on TranslationsPt {
'libraries.tabs.browse' => 'Navegar',
'libraries.tabs.collections' => 'Coleções',
'libraries.tabs.playlists' => 'Playlists',
'libraries.groupings.title' => 'Agrupamento',
'libraries.groupings.all' => 'Todos',
'libraries.groupings.movies' => 'Filmes',
'libraries.groupings.shows' => 'Séries de TV',
@@ -1810,14 +1822,14 @@ extension on TranslationsPt {
'liveTv.nowPlaying' => 'Reproduzindo Agora',
'liveTv.noPrograms' => 'Nenhum dado de programação disponível',
'liveTv.channelNumber' => ({required Object number}) => 'Ch. ${number}',
_ => null,
} ?? switch (path) {
'liveTv.live' => 'AO VIVO',
'liveTv.hd' => 'HD',
'liveTv.premiere' => 'NOVO',
'liveTv.reloadGuide' => 'Recarregar Guia',
'liveTv.allChannels' => 'Todos os Canais',
'liveTv.now' => 'Agora',
_ => null,
} ?? switch (path) {
'liveTv.today' => 'Hoje',
'liveTv.midnight' => 'Meia-noite',
'liveTv.overnight' => 'Madrugada',
+14 -2
View File
@@ -297,6 +297,11 @@ class _TranslationsSettingsRu implements TranslationsSettingsEn {
@override String get autoSkipCreditsDescription => 'Автоматически пропускать титры и воспроизводить следующий эпизод';
@override String get autoSkipDelay => 'Задержка автопропуска';
@override String autoSkipDelayDescription({required Object seconds}) => 'Подождать ${seconds} секунд перед автопропуском';
@override String get introPattern => 'Шаблон маркера вступления';
@override String get introPatternDescription => 'Регулярное выражение для распознавания маркеров вступления в заголовках глав';
@override String get creditsPattern => 'Шаблон маркера титров';
@override String get creditsPatternDescription => 'Регулярное выражение для распознавания маркеров титров в заголовках глав';
@override String get invalidRegex => 'Недопустимое регулярное выражение';
@override String get downloads => 'Загрузки';
@override String get downloadLocationDescription => 'Выберите место для хранения загруженного контента';
@override String get downloadLocationDefault => 'По умолчанию (Хранилище приложения)';
@@ -1192,6 +1197,7 @@ class _TranslationsLibrariesGroupingsRu implements TranslationsLibrariesGrouping
final TranslationsRu _root; // ignore: unused_field
// Translations
@override String get title => 'Группировка';
@override String get all => 'Все';
@override String get movies => 'Фильмы';
@override String get shows => 'Сериалы';
@@ -1472,6 +1478,11 @@ extension on TranslationsRu {
'settings.autoSkipCreditsDescription' => 'Автоматически пропускать титры и воспроизводить следующий эпизод',
'settings.autoSkipDelay' => 'Задержка автопропуска',
'settings.autoSkipDelayDescription' => ({required Object seconds}) => 'Подождать ${seconds} секунд перед автопропуском',
'settings.introPattern' => 'Шаблон маркера вступления',
'settings.introPatternDescription' => 'Регулярное выражение для распознавания маркеров вступления в заголовках глав',
'settings.creditsPattern' => 'Шаблон маркера титров',
'settings.creditsPatternDescription' => 'Регулярное выражение для распознавания маркеров титров в заголовках глав',
'settings.invalidRegex' => 'Недопустимое регулярное выражение',
'settings.downloads' => 'Загрузки',
'settings.downloadLocationDescription' => 'Выберите место для хранения загруженного контента',
'settings.downloadLocationDefault' => 'По умолчанию (Хранилище приложения)',
@@ -1769,6 +1780,7 @@ extension on TranslationsRu {
'libraries.tabs.browse' => 'Обзор',
'libraries.tabs.collections' => 'Коллекции',
'libraries.tabs.playlists' => 'Плейлисты',
'libraries.groupings.title' => 'Группировка',
'libraries.groupings.all' => 'Все',
'libraries.groupings.movies' => 'Фильмы',
'libraries.groupings.shows' => 'Сериалы',
@@ -1810,14 +1822,14 @@ extension on TranslationsRu {
'liveTv.nowPlaying' => 'Сейчас воспроизводится',
'liveTv.noPrograms' => 'Нет данных о программах',
'liveTv.channelNumber' => ({required Object number}) => 'Кн. ${number}',
_ => null,
} ?? switch (path) {
'liveTv.live' => 'ЭФИР',
'liveTv.hd' => 'HD',
'liveTv.premiere' => 'НОВОЕ',
'liveTv.reloadGuide' => 'Перезагрузить программу',
'liveTv.allChannels' => 'Все каналы',
'liveTv.now' => 'Сейчас',
_ => null,
} ?? switch (path) {
'liveTv.today' => 'Сегодня',
'liveTv.midnight' => 'Полночь',
'liveTv.overnight' => 'Ночь',
+44 -32
View File
@@ -297,6 +297,11 @@ class _TranslationsSettingsSv implements TranslationsSettingsEn {
@override String get autoSkipCreditsDescription => 'Hoppa automatiskt över credits och spela nästa avsnitt';
@override String get autoSkipDelay => 'Fördröjning Auto Hoppa Över';
@override String autoSkipDelayDescription({required Object seconds}) => 'Vänta ${seconds} sekunder innan automatisk överhoppning';
@override String get introPattern => 'Intromarkörsmönster';
@override String get introPatternDescription => 'Reguljärt uttryck för att matcha intromarkörer i kapiteltitlar';
@override String get creditsPattern => 'Eftertextmarkörsmönster';
@override String get creditsPatternDescription => 'Reguljärt uttryck för att matcha eftertextmarkörer i kapiteltitlar';
@override String get invalidRegex => 'Ogiltigt reguljärt uttryck';
@override String get downloads => 'Nedladdningar';
@override String get downloadLocationDescription => 'Välj var nedladdat innehåll ska lagras';
@override String get downloadLocationDefault => 'Standard (App-lagring)';
@@ -320,8 +325,8 @@ class _TranslationsSettingsSv implements TranslationsSettingsEn {
@override String get autoPipDescription => 'Aktivera bild-i-bild automatiskt när appen lämnas under uppspelning';
@override String get matchContentFrameRate => 'Matcha innehållets bildfrekvens';
@override String get matchContentFrameRateDescription => 'Justera skärmens uppdateringsfrekvens för att matcha videoinnehållet, minskar hackighet och sparar batteri';
@override String get tunneledPlayback => 'Tunneled Playback';
@override String get tunneledPlaybackDescription => 'Use hardware-accelerated video tunneling. Disable if you see a black screen with audio on HDR content';
@override String get tunneledPlayback => 'Tunneluppspelning';
@override String get tunneledPlaybackDescription => 'Använd hårdvaruaccelererad videotunnling. Inaktivera om du ser en svart skärm med ljud vid HDR-innehåll';
@override String get requireProfileSelectionOnOpen => 'Fråga efter profil vid appstart';
@override String get requireProfileSelectionOnOpenDescription => 'Visa profilval varje gång appen öppnas';
@override String get confirmExitOnBack => 'Bekräfta innan avslut';
@@ -512,8 +517,8 @@ class _TranslationsVideoControlsSv implements TranslationsVideoControlsEn {
@override late final _TranslationsVideoControlsPipErrorsSv pipErrors = _TranslationsVideoControlsPipErrorsSv._(_root);
@override String get chapters => 'Kapitel';
@override String get noChaptersAvailable => 'Inga kapitel tillgängliga';
@override String get queue => 'Queue';
@override String get noQueueItems => 'No items in queue';
@override String get queue => '';
@override String get noQueueItems => 'Inga objekt i kön';
}
// Path: userStatus
@@ -566,9 +571,9 @@ class _TranslationsMessagesSv implements TranslationsMessagesEn {
@override String get failedToCreatePlayQueueNoItems => 'Det gick inte att skapa uppspelningskö inga objekt';
@override String failedPlayback({required Object action, required Object error}) => 'Kunde inte ${action}: ${error}';
@override String get switchingToCompatiblePlayer => 'Byter till kompatibel spelare...';
@override String get logsUploaded => 'Logs uploaded';
@override String get logsUploadFailed => 'Failed to upload logs';
@override String get logId => 'Log ID';
@override String get logsUploaded => 'Loggar uppladdade';
@override String get logsUploadFailed => 'Uppladdning av loggar misslyckades';
@override String get logId => 'Logg-ID';
}
// Path: subtitlingStyling
@@ -763,7 +768,7 @@ class _TranslationsLogsSv implements TranslationsLogsEn {
// Translations
@override String get clearLogs => 'Rensa loggar';
@override String get copyLogs => 'Kopiera loggar';
@override String get uploadLogs => 'Upload Logs';
@override String get uploadLogs => 'Ladda upp loggar';
@override String get error => 'Fel:';
@override String get stackTrace => 'Stack trace:';
}
@@ -990,12 +995,12 @@ class _TranslationsShadersSv implements TranslationsShadersEn {
@override String get qualityFast => 'Snabb';
@override String get qualityHQ => 'Hög kvalitet';
@override String get mode => 'Läge';
@override String get importShader => 'Import Shader';
@override String get customShaderDescription => 'Custom GLSL shader';
@override String get shaderImported => 'Shader imported';
@override String get shaderImportFailed => 'Failed to import shader';
@override String get deleteShader => 'Delete Shader';
@override String deleteShaderConfirm({required Object name}) => 'Delete "${name}"?';
@override String get importShader => 'Importera shader';
@override String get customShaderDescription => 'Anpassad GLSL-shader';
@override String get shaderImported => 'Shader importerad';
@override String get shaderImportFailed => 'Kunde inte importera shader';
@override String get deleteShader => 'Ta bort shader';
@override String deleteShaderConfirm({required Object name}) => 'Ta bort "${name}"?';
}
// Path: companionRemote
@@ -1005,7 +1010,7 @@ class _TranslationsCompanionRemoteSv implements TranslationsCompanionRemoteEn {
final TranslationsSv _root; // ignore: unused_field
// Translations
@override String get title => 'Companion Remote';
@override String get title => 'Fjärrkontroll';
@override String get connectToDevice => 'Anslut till enhet';
@override String get hostRemoteSession => 'Starta fjärrsession';
@override String get controlThisDevice => 'Styr den här enheten med din telefon';
@@ -1192,6 +1197,7 @@ class _TranslationsLibrariesGroupingsSv implements TranslationsLibrariesGrouping
final TranslationsSv _root; // ignore: unused_field
// Translations
@override String get title => 'Gruppering';
@override String get all => 'Alla';
@override String get movies => 'Filmer';
@override String get shows => 'Serier';
@@ -1472,6 +1478,11 @@ extension on TranslationsSv {
'settings.autoSkipCreditsDescription' => 'Hoppa automatiskt över credits och spela nästa avsnitt',
'settings.autoSkipDelay' => 'Fördröjning Auto Hoppa Över',
'settings.autoSkipDelayDescription' => ({required Object seconds}) => 'Vänta ${seconds} sekunder innan automatisk överhoppning',
'settings.introPattern' => 'Intromarkörsmönster',
'settings.introPatternDescription' => 'Reguljärt uttryck för att matcha intromarkörer i kapiteltitlar',
'settings.creditsPattern' => 'Eftertextmarkörsmönster',
'settings.creditsPatternDescription' => 'Reguljärt uttryck för att matcha eftertextmarkörer i kapiteltitlar',
'settings.invalidRegex' => 'Ogiltigt reguljärt uttryck',
'settings.downloads' => 'Nedladdningar',
'settings.downloadLocationDescription' => 'Välj var nedladdat innehåll ska lagras',
'settings.downloadLocationDefault' => 'Standard (App-lagring)',
@@ -1495,8 +1506,8 @@ extension on TranslationsSv {
'settings.autoPipDescription' => 'Aktivera bild-i-bild automatiskt när appen lämnas under uppspelning',
'settings.matchContentFrameRate' => 'Matcha innehållets bildfrekvens',
'settings.matchContentFrameRateDescription' => 'Justera skärmens uppdateringsfrekvens för att matcha videoinnehållet, minskar hackighet och sparar batteri',
'settings.tunneledPlayback' => 'Tunneled Playback',
'settings.tunneledPlaybackDescription' => 'Use hardware-accelerated video tunneling. Disable if you see a black screen with audio on HDR content',
'settings.tunneledPlayback' => 'Tunneluppspelning',
'settings.tunneledPlaybackDescription' => 'Använd hårdvaruaccelererad videotunnling. Inaktivera om du ser en svart skärm med ljud vid HDR-innehåll',
'settings.requireProfileSelectionOnOpen' => 'Fråga efter profil vid appstart',
'settings.requireProfileSelectionOnOpenDescription' => 'Visa profilval varje gång appen öppnas',
'settings.confirmExitOnBack' => 'Bekräfta innan avslut',
@@ -1639,8 +1650,8 @@ extension on TranslationsSv {
'videoControls.pipErrors.unknown' => ({required Object error}) => 'Ett fel uppstod: ${error}',
'videoControls.chapters' => 'Kapitel',
'videoControls.noChaptersAvailable' => 'Inga kapitel tillgängliga',
'videoControls.queue' => 'Queue',
'videoControls.noQueueItems' => 'No items in queue',
'videoControls.queue' => '',
'videoControls.noQueueItems' => 'Inga objekt i kön',
'userStatus.admin' => 'Admin',
'userStatus.restricted' => 'Begränsad',
'userStatus.protected' => 'Skyddad',
@@ -1675,9 +1686,9 @@ extension on TranslationsSv {
'messages.failedToCreatePlayQueueNoItems' => 'Det gick inte att skapa uppspelningskö inga objekt',
'messages.failedPlayback' => ({required Object action, required Object error}) => 'Kunde inte ${action}: ${error}',
'messages.switchingToCompatiblePlayer' => 'Byter till kompatibel spelare...',
'messages.logsUploaded' => 'Logs uploaded',
'messages.logsUploadFailed' => 'Failed to upload logs',
'messages.logId' => 'Log ID',
'messages.logsUploaded' => 'Loggar uppladdade',
'messages.logsUploadFailed' => 'Uppladdning av loggar misslyckades',
'messages.logId' => 'Logg-ID',
'subtitlingStyling.stylingOptions' => 'Stilalternativ',
'subtitlingStyling.fontSize' => 'Teckenstorlek',
'subtitlingStyling.textColor' => 'Textfärg',
@@ -1769,6 +1780,7 @@ extension on TranslationsSv {
'libraries.tabs.browse' => 'Bläddra',
'libraries.tabs.collections' => 'Samlingar',
'libraries.tabs.playlists' => 'Spellistor',
'libraries.groupings.title' => 'Gruppering',
'libraries.groupings.all' => 'Alla',
'libraries.groupings.movies' => 'Filmer',
'libraries.groupings.shows' => 'Serier',
@@ -1790,7 +1802,7 @@ extension on TranslationsSv {
'hubDetail.noItemsFound' => 'Inga objekt hittades',
'logs.clearLogs' => 'Rensa loggar',
'logs.copyLogs' => 'Kopiera loggar',
'logs.uploadLogs' => 'Upload Logs',
'logs.uploadLogs' => 'Ladda upp loggar',
'logs.error' => 'Fel:',
'logs.stackTrace' => 'Stack trace:',
'licenses.relatedPackages' => 'Relaterade paket',
@@ -1810,14 +1822,14 @@ extension on TranslationsSv {
'liveTv.nowPlaying' => 'Spelas nu',
'liveTv.noPrograms' => 'Ingen programdata tillgänglig',
'liveTv.channelNumber' => ({required Object number}) => 'Kanal ${number}',
_ => null,
} ?? switch (path) {
'liveTv.live' => 'LIVE',
'liveTv.hd' => 'HD',
'liveTv.premiere' => 'NY',
'liveTv.reloadGuide' => 'Ladda om programguide',
'liveTv.allChannels' => 'Alla kanaler',
'liveTv.now' => 'Nu',
_ => null,
} ?? switch (path) {
'liveTv.today' => 'Idag',
'liveTv.midnight' => 'Midnatt',
'liveTv.overnight' => 'Natt',
@@ -1947,13 +1959,13 @@ extension on TranslationsSv {
'shaders.qualityFast' => 'Snabb',
'shaders.qualityHQ' => 'Hög kvalitet',
'shaders.mode' => 'Läge',
'shaders.importShader' => 'Import Shader',
'shaders.customShaderDescription' => 'Custom GLSL shader',
'shaders.shaderImported' => 'Shader imported',
'shaders.shaderImportFailed' => 'Failed to import shader',
'shaders.deleteShader' => 'Delete Shader',
'shaders.deleteShaderConfirm' => ({required Object name}) => 'Delete "${name}"?',
'companionRemote.title' => 'Companion Remote',
'shaders.importShader' => 'Importera shader',
'shaders.customShaderDescription' => 'Anpassad GLSL-shader',
'shaders.shaderImported' => 'Shader importerad',
'shaders.shaderImportFailed' => 'Kunde inte importera shader',
'shaders.deleteShader' => 'Ta bort shader',
'shaders.deleteShaderConfirm' => ({required Object name}) => 'Ta bort "${name}"?',
'companionRemote.title' => 'Fjärrkontroll',
'companionRemote.connectToDevice' => 'Anslut till enhet',
'companionRemote.hostRemoteSession' => 'Starta fjärrsession',
'companionRemote.controlThisDevice' => 'Styr den här enheten med din telefon',
+46 -34
View File
@@ -297,6 +297,11 @@ class _TranslationsSettingsZh implements TranslationsSettingsEn {
@override String get autoSkipCreditsDescription => '自动跳过片尾并播放下一集';
@override String get autoSkipDelay => '自动跳过延迟';
@override String autoSkipDelayDescription({required Object seconds}) => '自动跳过前等待 ${seconds}';
@override String get introPattern => '片头标记模式';
@override String get introPatternDescription => '用于匹配章节标题中片头标记的正则表达式';
@override String get creditsPattern => '片尾标记模式';
@override String get creditsPatternDescription => '用于匹配章节标题中片尾标记的正则表达式';
@override String get invalidRegex => '无效的正则表达式';
@override String get downloads => '下载';
@override String get downloadLocationDescription => '选择下载内容的存储位置';
@override String get downloadLocationDefault => '默认(应用存储)';
@@ -320,8 +325,8 @@ class _TranslationsSettingsZh implements TranslationsSettingsEn {
@override String get autoPipDescription => '在播放期间离开应用时自动进入画中画模式';
@override String get matchContentFrameRate => '匹配内容帧率';
@override String get matchContentFrameRateDescription => '调整显示刷新率以匹配视频内容,减少画面抖动并节省电量';
@override String get tunneledPlayback => 'Tunneled Playback';
@override String get tunneledPlaybackDescription => 'Use hardware-accelerated video tunneling. Disable if you see a black screen with audio on HDR content';
@override String get tunneledPlayback => '通道化播放';
@override String get tunneledPlaybackDescription => '使用硬件加速视频通道。如果在 HDR 内容上看到黑屏但有声音,请禁用此选项';
@override String get requireProfileSelectionOnOpen => '打开应用时询问配置文件';
@override String get requireProfileSelectionOnOpenDescription => '每次打开应用时显示配置文件选择';
@override String get confirmExitOnBack => '退出前确认';
@@ -512,8 +517,8 @@ class _TranslationsVideoControlsZh implements TranslationsVideoControlsEn {
@override late final _TranslationsVideoControlsPipErrorsZh pipErrors = _TranslationsVideoControlsPipErrorsZh._(_root);
@override String get chapters => '章节';
@override String get noChaptersAvailable => '没有可用的章节';
@override String get queue => 'Queue';
@override String get noQueueItems => 'No items in queue';
@override String get queue => '播放队列';
@override String get noQueueItems => '队列中没有项目';
}
// Path: userStatus
@@ -566,9 +571,9 @@ class _TranslationsMessagesZh implements TranslationsMessagesEn {
@override String get failedToCreatePlayQueueNoItems => '创建播放队列失败 - 没有项目';
@override String failedPlayback({required Object action, required Object error}) => '无法${action}: ${error}';
@override String get switchingToCompatiblePlayer => '正在切换到兼容的播放器...';
@override String get logsUploaded => 'Logs uploaded';
@override String get logsUploadFailed => 'Failed to upload logs';
@override String get logId => 'Log ID';
@override String get logsUploaded => '日志已上传';
@override String get logsUploadFailed => '上传日志失败';
@override String get logId => '日志 ID';
}
// Path: subtitlingStyling
@@ -585,7 +590,7 @@ class _TranslationsSubtitlingStylingZh implements TranslationsSubtitlingStylingE
@override String get borderColor => '边框颜色';
@override String get backgroundOpacity => '背景不透明度';
@override String get backgroundColor => '背景颜色';
@override String get position => 'Position';
@override String get position => '位置';
}
// Path: mpvConfig
@@ -763,7 +768,7 @@ class _TranslationsLogsZh implements TranslationsLogsEn {
// Translations
@override String get clearLogs => '清除日志';
@override String get copyLogs => '复制日志';
@override String get uploadLogs => 'Upload Logs';
@override String get uploadLogs => '上传日志';
@override String get error => '错误:';
@override String get stackTrace => '堆栈跟踪 (Stack Trace):';
}
@@ -990,12 +995,12 @@ class _TranslationsShadersZh implements TranslationsShadersEn {
@override String get qualityFast => '快速';
@override String get qualityHQ => '高质量';
@override String get mode => '模式';
@override String get importShader => 'Import Shader';
@override String get customShaderDescription => 'Custom GLSL shader';
@override String get shaderImported => 'Shader imported';
@override String get shaderImportFailed => 'Failed to import shader';
@override String get deleteShader => 'Delete Shader';
@override String deleteShaderConfirm({required Object name}) => 'Delete "${name}"?';
@override String get importShader => '导入着色器';
@override String get customShaderDescription => '自定义 GLSL 着色器';
@override String get shaderImported => '着色器已导入';
@override String get shaderImportFailed => '导入着色器失败';
@override String get deleteShader => '删除着色器';
@override String deleteShaderConfirm({required Object name}) => '删除"${name}"';
}
// Path: companionRemote
@@ -1005,7 +1010,7 @@ class _TranslationsCompanionRemoteZh implements TranslationsCompanionRemoteEn {
final TranslationsZh _root; // ignore: unused_field
// Translations
@override String get title => 'Companion Remote';
@override String get title => '伴侣遥控';
@override String get connectToDevice => '连接到设备';
@override String get hostRemoteSession => '创建远程会话';
@override String get controlThisDevice => '使用手机控制此设备';
@@ -1192,6 +1197,7 @@ class _TranslationsLibrariesGroupingsZh implements TranslationsLibrariesGrouping
final TranslationsZh _root; // ignore: unused_field
// Translations
@override String get title => '分组';
@override String get all => '全部';
@override String get movies => '电影';
@override String get shows => '剧集';
@@ -1472,6 +1478,11 @@ extension on TranslationsZh {
'settings.autoSkipCreditsDescription' => '自动跳过片尾并播放下一集',
'settings.autoSkipDelay' => '自动跳过延迟',
'settings.autoSkipDelayDescription' => ({required Object seconds}) => '自动跳过前等待 ${seconds}',
'settings.introPattern' => '片头标记模式',
'settings.introPatternDescription' => '用于匹配章节标题中片头标记的正则表达式',
'settings.creditsPattern' => '片尾标记模式',
'settings.creditsPatternDescription' => '用于匹配章节标题中片尾标记的正则表达式',
'settings.invalidRegex' => '无效的正则表达式',
'settings.downloads' => '下载',
'settings.downloadLocationDescription' => '选择下载内容的存储位置',
'settings.downloadLocationDefault' => '默认(应用存储)',
@@ -1495,8 +1506,8 @@ extension on TranslationsZh {
'settings.autoPipDescription' => '在播放期间离开应用时自动进入画中画模式',
'settings.matchContentFrameRate' => '匹配内容帧率',
'settings.matchContentFrameRateDescription' => '调整显示刷新率以匹配视频内容,减少画面抖动并节省电量',
'settings.tunneledPlayback' => 'Tunneled Playback',
'settings.tunneledPlaybackDescription' => 'Use hardware-accelerated video tunneling. Disable if you see a black screen with audio on HDR content',
'settings.tunneledPlayback' => '通道化播放',
'settings.tunneledPlaybackDescription' => '使用硬件加速视频通道。如果在 HDR 内容上看到黑屏但有声音,请禁用此选项',
'settings.requireProfileSelectionOnOpen' => '打开应用时询问配置文件',
'settings.requireProfileSelectionOnOpenDescription' => '每次打开应用时显示配置文件选择',
'settings.confirmExitOnBack' => '退出前确认',
@@ -1639,8 +1650,8 @@ extension on TranslationsZh {
'videoControls.pipErrors.unknown' => ({required Object error}) => '发生错误:${error}',
'videoControls.chapters' => '章节',
'videoControls.noChaptersAvailable' => '没有可用的章节',
'videoControls.queue' => 'Queue',
'videoControls.noQueueItems' => 'No items in queue',
'videoControls.queue' => '播放队列',
'videoControls.noQueueItems' => '队列中没有项目',
'userStatus.admin' => '管理员',
'userStatus.restricted' => '受限',
'userStatus.protected' => '受保护',
@@ -1675,9 +1686,9 @@ extension on TranslationsZh {
'messages.failedToCreatePlayQueueNoItems' => '创建播放队列失败 - 没有项目',
'messages.failedPlayback' => ({required Object action, required Object error}) => '无法${action}: ${error}',
'messages.switchingToCompatiblePlayer' => '正在切换到兼容的播放器...',
'messages.logsUploaded' => 'Logs uploaded',
'messages.logsUploadFailed' => 'Failed to upload logs',
'messages.logId' => 'Log ID',
'messages.logsUploaded' => '日志已上传',
'messages.logsUploadFailed' => '上传日志失败',
'messages.logId' => '日志 ID',
'subtitlingStyling.stylingOptions' => '样式选项',
'subtitlingStyling.fontSize' => '字号',
'subtitlingStyling.textColor' => '文本颜色',
@@ -1685,7 +1696,7 @@ extension on TranslationsZh {
'subtitlingStyling.borderColor' => '边框颜色',
'subtitlingStyling.backgroundOpacity' => '背景不透明度',
'subtitlingStyling.backgroundColor' => '背景颜色',
'subtitlingStyling.position' => 'Position',
'subtitlingStyling.position' => '位置',
'mpvConfig.title' => 'mpv 配置',
'mpvConfig.description' => '高级视频播放器设置',
'mpvConfig.presets' => '预设',
@@ -1769,6 +1780,7 @@ extension on TranslationsZh {
'libraries.tabs.browse' => '浏览',
'libraries.tabs.collections' => '合集',
'libraries.tabs.playlists' => '播放列表',
'libraries.groupings.title' => '分组',
'libraries.groupings.all' => '全部',
'libraries.groupings.movies' => '电影',
'libraries.groupings.shows' => '剧集',
@@ -1790,7 +1802,7 @@ extension on TranslationsZh {
'hubDetail.noItemsFound' => '未找到项目',
'logs.clearLogs' => '清除日志',
'logs.copyLogs' => '复制日志',
'logs.uploadLogs' => 'Upload Logs',
'logs.uploadLogs' => '上传日志',
'logs.error' => '错误:',
'logs.stackTrace' => '堆栈跟踪 (Stack Trace):',
'licenses.relatedPackages' => '相关软件包',
@@ -1810,14 +1822,14 @@ extension on TranslationsZh {
'liveTv.nowPlaying' => '正在播放',
'liveTv.noPrograms' => '没有可用的节目数据',
'liveTv.channelNumber' => ({required Object number}) => '频道 ${number}',
_ => null,
} ?? switch (path) {
'liveTv.live' => '直播',
'liveTv.hd' => '高清',
'liveTv.premiere' => '',
'liveTv.reloadGuide' => '重新加载节目指南',
'liveTv.allChannels' => '所有频道',
'liveTv.now' => '现在',
_ => null,
} ?? switch (path) {
'liveTv.today' => '今天',
'liveTv.midnight' => '午夜',
'liveTv.overnight' => '凌晨',
@@ -1947,13 +1959,13 @@ extension on TranslationsZh {
'shaders.qualityFast' => '快速',
'shaders.qualityHQ' => '高质量',
'shaders.mode' => '模式',
'shaders.importShader' => 'Import Shader',
'shaders.customShaderDescription' => 'Custom GLSL shader',
'shaders.shaderImported' => 'Shader imported',
'shaders.shaderImportFailed' => 'Failed to import shader',
'shaders.deleteShader' => 'Delete Shader',
'shaders.deleteShaderConfirm' => ({required Object name}) => 'Delete "${name}"?',
'companionRemote.title' => 'Companion Remote',
'shaders.importShader' => '导入着色器',
'shaders.customShaderDescription' => '自定义 GLSL 着色器',
'shaders.shaderImported' => '着色器已导入',
'shaders.shaderImportFailed' => '导入着色器失败',
'shaders.deleteShader' => '删除着色器',
'shaders.deleteShaderConfirm' => ({required Object name}) => '删除"${name}"',
'companionRemote.title' => '伴侣遥控',
'companionRemote.connectToDevice' => '连接到设备',
'companionRemote.hostRemoteSession' => '创建远程会话',
'companionRemote.controlThisDevice' => '使用手机控制此设备',
+21 -15
View File
@@ -178,6 +178,11 @@
"autoSkipCreditsDescription": "Hoppa automatiskt över credits och spela nästa avsnitt",
"autoSkipDelay": "Fördröjning Auto Hoppa Över",
"autoSkipDelayDescription": "Vänta ${seconds} sekunder innan automatisk överhoppning",
"introPattern": "Intromarkörsmönster",
"introPatternDescription": "Reguljärt uttryck för att matcha intromarkörer i kapiteltitlar",
"creditsPattern": "Eftertextmarkörsmönster",
"creditsPatternDescription": "Reguljärt uttryck för att matcha eftertextmarkörer i kapiteltitlar",
"invalidRegex": "Ogiltigt reguljärt uttryck",
"downloads": "Nedladdningar",
"downloadLocationDescription": "Välj var nedladdat innehåll ska lagras",
"downloadLocationDefault": "Standard (App-lagring)",
@@ -201,8 +206,8 @@
"autoPipDescription": "Aktivera bild-i-bild automatiskt när appen lämnas under uppspelning",
"matchContentFrameRate": "Matcha innehållets bildfrekvens",
"matchContentFrameRateDescription": "Justera skärmens uppdateringsfrekvens för att matcha videoinnehållet, minskar hackighet och sparar batteri",
"tunneledPlayback": "Tunneled Playback",
"tunneledPlaybackDescription": "Use hardware-accelerated video tunneling. Disable if you see a black screen with audio on HDR content",
"tunneledPlayback": "Tunneluppspelning",
"tunneledPlaybackDescription": "Använd hårdvaruaccelererad videotunnling. Inaktivera om du ser en svart skärm med ljud vid HDR-innehåll",
"requireProfileSelectionOnOpen": "Fråga efter profil vid appstart",
"requireProfileSelectionOnOpenDescription": "Visa profilval varje gång appen öppnas",
"confirmExitOnBack": "Bekräfta innan avslut",
@@ -365,8 +370,8 @@
},
"chapters": "Kapitel",
"noChaptersAvailable": "Inga kapitel tillgängliga",
"queue": "Queue",
"noQueueItems": "No items in queue"
"queue": "",
"noQueueItems": "Inga objekt i kön"
},
"userStatus": {
"admin": "Admin",
@@ -405,9 +410,9 @@
"failedToCreatePlayQueueNoItems": "Det gick inte att skapa uppspelningskö inga objekt",
"failedPlayback": "Kunde inte ${action}: ${error}",
"switchingToCompatiblePlayer": "Byter till kompatibel spelare...",
"logsUploaded": "Logs uploaded",
"logsUploadFailed": "Failed to upload logs",
"logId": "Log ID"
"logsUploaded": "Loggar uppladdade",
"logsUploadFailed": "Uppladdning av loggar misslyckades",
"logId": "Logg-ID"
},
"subtitlingStyling": {
"stylingOptions": "Stilalternativ",
@@ -514,6 +519,7 @@
"playlists": "Spellistor"
},
"groupings": {
"title": "Gruppering",
"all": "Alla",
"movies": "Filmer",
"shows": "Serier",
@@ -544,7 +550,7 @@
"logs": {
"clearLogs": "Rensa loggar",
"copyLogs": "Kopiera loggar",
"uploadLogs": "Upload Logs",
"uploadLogs": "Ladda upp loggar",
"error": "Fel:",
"stackTrace": "Stack trace:"
},
@@ -715,15 +721,15 @@
"qualityFast": "Snabb",
"qualityHQ": "Hög kvalitet",
"mode": "Läge",
"importShader": "Import Shader",
"customShaderDescription": "Custom GLSL shader",
"shaderImported": "Shader imported",
"shaderImportFailed": "Failed to import shader",
"deleteShader": "Delete Shader",
"deleteShaderConfirm": "Delete \"${name}\"?"
"importShader": "Importera shader",
"customShaderDescription": "Anpassad GLSL-shader",
"shaderImported": "Shader importerad",
"shaderImportFailed": "Kunde inte importera shader",
"deleteShader": "Ta bort shader",
"deleteShaderConfirm": "Ta bort \"${name}\"?"
},
"companionRemote": {
"title": "Companion Remote",
"title": "Fjärrkontroll",
"connectToDevice": "Anslut till enhet",
"hostRemoteSession": "Starta fjärrsession",
"controlThisDevice": "Styr den här enheten med din telefon",
+22 -16
View File
@@ -178,6 +178,11 @@
"autoSkipCreditsDescription": "自动跳过片尾并播放下一集",
"autoSkipDelay": "自动跳过延迟",
"autoSkipDelayDescription": "自动跳过前等待 ${seconds} 秒",
"introPattern": "片头标记模式",
"introPatternDescription": "用于匹配章节标题中片头标记的正则表达式",
"creditsPattern": "片尾标记模式",
"creditsPatternDescription": "用于匹配章节标题中片尾标记的正则表达式",
"invalidRegex": "无效的正则表达式",
"downloads": "下载",
"downloadLocationDescription": "选择下载内容的存储位置",
"downloadLocationDefault": "默认(应用存储)",
@@ -201,8 +206,8 @@
"autoPipDescription": "在播放期间离开应用时自动进入画中画模式",
"matchContentFrameRate": "匹配内容帧率",
"matchContentFrameRateDescription": "调整显示刷新率以匹配视频内容,减少画面抖动并节省电量",
"tunneledPlayback": "Tunneled Playback",
"tunneledPlaybackDescription": "Use hardware-accelerated video tunneling. Disable if you see a black screen with audio on HDR content",
"tunneledPlayback": "通道化播放",
"tunneledPlaybackDescription": "使用硬件加速视频通道。如果在 HDR 内容上看到黑屏但有声音,请禁用此选项",
"requireProfileSelectionOnOpen": "打开应用时询问配置文件",
"requireProfileSelectionOnOpenDescription": "每次打开应用时显示配置文件选择",
"confirmExitOnBack": "退出前确认",
@@ -365,8 +370,8 @@
},
"chapters": "章节",
"noChaptersAvailable": "没有可用的章节",
"queue": "Queue",
"noQueueItems": "No items in queue"
"queue": "播放队列",
"noQueueItems": "队列中没有项目"
},
"userStatus": {
"admin": "管理员",
@@ -405,9 +410,9 @@
"failedToCreatePlayQueueNoItems": "创建播放队列失败 - 没有项目",
"failedPlayback": "无法${action}: ${error}",
"switchingToCompatiblePlayer": "正在切换到兼容的播放器...",
"logsUploaded": "Logs uploaded",
"logsUploadFailed": "Failed to upload logs",
"logId": "Log ID"
"logsUploaded": "日志已上传",
"logsUploadFailed": "上传日志失败",
"logId": "日志 ID"
},
"subtitlingStyling": {
"stylingOptions": "样式选项",
@@ -417,7 +422,7 @@
"borderColor": "边框颜色",
"backgroundOpacity": "背景不透明度",
"backgroundColor": "背景颜色",
"position": "Position"
"position": "位置"
},
"mpvConfig": {
"title": "mpv 配置",
@@ -514,6 +519,7 @@
"playlists": "播放列表"
},
"groupings": {
"title": "分组",
"all": "全部",
"movies": "电影",
"shows": "剧集",
@@ -544,7 +550,7 @@
"logs": {
"clearLogs": "清除日志",
"copyLogs": "复制日志",
"uploadLogs": "Upload Logs",
"uploadLogs": "上传日志",
"error": "错误:",
"stackTrace": "堆栈跟踪 (Stack Trace):"
},
@@ -715,15 +721,15 @@
"qualityFast": "快速",
"qualityHQ": "高质量",
"mode": "模式",
"importShader": "Import Shader",
"customShaderDescription": "Custom GLSL shader",
"shaderImported": "Shader imported",
"shaderImportFailed": "Failed to import shader",
"deleteShader": "Delete Shader",
"deleteShaderConfirm": "Delete \"${name}\"?"
"importShader": "导入着色器",
"customShaderDescription": "自定义 GLSL 着色器",
"shaderImported": "着色器已导入",
"shaderImportFailed": "导入着色器失败",
"deleteShader": "删除着色器",
"deleteShaderConfirm": "删除\"${name}\""
},
"companionRemote": {
"title": "Companion Remote",
"title": "伴侣遥控",
"connectToDevice": "连接到设备",
"hostRemoteSession": "创建远程会话",
"controlThisDevice": "使用手机控制此设备",
+132 -26
View File
@@ -1,5 +1,4 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/gestures.dart';
@@ -29,6 +28,7 @@ import 'providers/offline_mode_provider.dart';
import 'providers/offline_watch_provider.dart';
import 'providers/companion_remote_provider.dart';
import 'providers/shader_provider.dart';
import 'utils/snackbar_helper.dart';
import 'watch_together/watch_together.dart';
import 'services/multi_server_manager.dart';
import 'services/offline_watch_sync_service.dart';
@@ -54,6 +54,7 @@ import 'utils/log_redaction_manager.dart';
import 'package:package_info_plus/package_info_plus.dart';
const bool _enableSentry = bool.fromEnvironment('ENABLE_SENTRY', defaultValue: false);
const String _gitCommit = String.fromEnvironment('GIT_COMMIT');
// Workaround for Flutter bug #177992: iPadOS 26.1+ misinterprets fake touch events
// at (0,0) as barrier taps, causing modals to dismiss immediately.
@@ -81,7 +82,9 @@ Future<void> main() async {
await SentryFlutter.init((options) {
options.dsn = 'https://6a1a6ef8c72140099b2798973c1bfb2f@bugs.plezy.app/1';
options.release = 'plezy@${packageInfo.version}+${packageInfo.buildNumber}';
options.release = _gitCommit.isNotEmpty
? 'plezy@${_gitCommit.substring(0, 7)}'
: 'plezy@${packageInfo.version}+${packageInfo.buildNumber}';
options.tracesSampleRate = 0;
options.attachStacktrace = true;
options.enableAutoSessionTracking = false;
@@ -195,6 +198,11 @@ FutureOr<SentryEvent?> _beforeSend(SentryEvent event, Hint _) {
return null;
}
// Drop DBusServiceUnknownException from Linux without NetworkManager
if (exceptions != null && exceptions.any((e) => e.type == 'DBusServiceUnknownException')) {
return null;
}
// Scrub Plex tokens and server URLs from exception messages
if (exceptions != null) {
exceptions = exceptions.map((e) {
@@ -293,6 +301,9 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
late final DownloadManagerService _downloadManager;
late final OfflineWatchSyncService _offlineWatchSyncService;
/// Last time server health probes ran from a resume event (cooldown for desktop)
DateTime _lastResumeProbe = DateTime(0);
@override
void initState() {
super.initState();
@@ -327,9 +338,18 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
// App came back to foreground - trigger sync check and start new session
_offlineWatchSyncService.onAppResumed();
InAppReviewService.instance.startSession();
// Re-probe servers — mobile OS may have dropped TCP connections during doze/sleep
_serverManager.checkServerHealth();
_serverManager.reconnectOfflineServers();
// Re-probe servers — mobile OS may have dropped TCP connections during doze/sleep.
// On desktop, resumed fires on every window focus (alt-tab), so apply a cooldown
// to avoid piling up network probes from rapid alt-tabbing.
final now = DateTime.now();
final cooldown = (Platform.isIOS || Platform.isAndroid)
? const Duration(seconds: 10)
: const Duration(minutes: 2);
if (now.difference(_lastResumeProbe) >= cooldown) {
_lastResumeProbe = now;
_serverManager.checkServerHealth();
_serverManager.reconnectOfflineServers();
}
case AppLifecycleState.paused:
case AppLifecycleState.detached:
// App went to background or is closing - end session
@@ -409,6 +429,13 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
themeMode: themeProvider.materialThemeMode,
navigatorObservers: [routeObserver, BackKeySuppressorObserver()],
home: const OrientationAwareSetup(),
builder: (context, child) => ScaffoldMessenger(
key: rootScaffoldMessengerKey,
child: Scaffold(
backgroundColor: Colors.transparent,
body: child,
),
),
),
),
);
@@ -452,6 +479,9 @@ class SetupScreen extends StatefulWidget {
class _SetupScreenState extends State<SetupScreen> {
String _statusMessage = '';
// Per-server connection status: serverId -> (name, connected?)
final Map<String, (String name, bool? connected)> _serverStatus = {};
@override
void initState() {
super.initState();
@@ -470,11 +500,17 @@ class _SetupScreenState extends State<SetupScreen> {
// Check network connectivity early to fast-path airplane mode.
// Timeout guards against connectivity_plus hanging on some Android TV devices after force-close.
final connectivityResult = await Connectivity().checkConnectivity().timeout(
const Duration(seconds: 3),
onTimeout: () => [ConnectivityResult.other],
);
final hasNetwork = !connectivityResult.contains(ConnectivityResult.none);
bool hasNetwork;
try {
final connectivityResult = await Connectivity().checkConnectivity().timeout(
const Duration(seconds: 3),
onTimeout: () => [ConnectivityResult.other],
);
hasNetwork = !connectivityResult.contains(ConnectivityResult.none);
} catch (e) {
// connectivity_plus throws DBusServiceUnknownException on Linux without NetworkManager
hasNetwork = true;
}
if (hasNetwork) {
_setStatus(t.common.refreshingServers);
@@ -517,6 +553,15 @@ class _SetupScreenState extends State<SetupScreen> {
_setStatus(t.common.connectingToServers);
// Populate per-server status for splash display
if (mounted) {
setState(() {
for (final server in servers) {
_serverStatus[server.clientIdentifier] = (server.name, null);
}
});
}
try {
final result = await ServerConnectionOrchestrator.connectAndInitialize(
servers: servers,
@@ -524,6 +569,16 @@ class _SetupScreenState extends State<SetupScreen> {
librariesProvider: context.read<LibrariesProvider>(),
syncService: context.read<OfflineWatchSyncService>(),
clientIdentifier: storage.getClientIdentifier(),
onServerStatus: (serverId, success) {
if (mounted) {
setState(() {
final existing = _serverStatus[serverId];
if (existing != null) {
_serverStatus[serverId] = (existing.$1, success);
}
});
}
},
);
if (!mounted) return;
@@ -554,30 +609,81 @@ class _SetupScreenState extends State<SetupScreen> {
}
}
Widget _buildStatusText(BuildContext context) {
return AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: Text(
_statusMessage,
key: ValueKey(_statusMessage),
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
),
),
);
}
Widget _buildServerStatusList(BuildContext context) {
if (_serverStatus.isEmpty) return const SizedBox.shrink();
final textTheme = Theme.of(context).textTheme;
final dimColor = Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5);
const coralColor = Color(0xFFE5A00D);
const successColor = Color(0xFF4CAF50);
const failColor = Color(0xFFEF5350);
return Column(
mainAxisSize: MainAxisSize.min,
children: _serverStatus.entries.map((entry) {
final (name, connected) = entry.value;
final Widget statusIcon;
if (connected == null) {
statusIcon = const SizedBox(
width: 12, height: 12,
child: CircularProgressIndicator(strokeWidth: 1.5, color: coralColor),
);
} else if (connected) {
statusIcon = const Icon(Icons.check_circle, size: 14, color: successColor);
} else {
statusIcon = const Icon(Icons.cancel, size: 14, color: failColor);
}
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
statusIcon,
const SizedBox(width: 8),
Text(name, style: textTheme.bodySmall?.copyWith(color: dimColor)),
],
),
);
}).toList(),
);
}
@override
Widget build(BuildContext context) {
const coralColor = Color(0xFFE5A00D);
return ColoredBox(
color: Theme.of(context).scaffoldBackgroundColor,
child: Stack(
children: [
// Icon dead-center, matching Android 12+ splash position.
// 192dp accounts for the 16% inset in ic_launcher.xml.
Center(child: SvgPicture.asset('assets/plezy_adaptive_foreground.svg', width: 288, height: 288)),
// Status text below center, independent of icon position.
Positioned(
left: 0,
right: 0,
bottom: MediaQuery.of(context).size.height * 0.5 - 140,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: Text(
_statusMessage,
key: ValueKey(_statusMessage),
textAlign: TextAlign.center,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6)),
),
left: 0, right: 0,
bottom: MediaQuery.of(context).size.height * 0.5 - 170,
child: _buildStatusText(context),
),
Positioned(
left: 0, right: 0,
top: MediaQuery.of(context).size.height * 0.5 + 180,
child: Center(
child: _serverStatus.isEmpty
? const SizedBox(
width: 20, height: 20,
child: CircularProgressIndicator(strokeWidth: 2, color: coralColor),
)
: _buildServerStatusList(context),
),
),
],
@@ -10,47 +10,65 @@ RemoteDevice _$RemoteDeviceFromJson(Map<String, dynamic> json) => RemoteDevice(
id: json['id'] as String,
name: json['name'] as String,
platform: json['platform'] as String,
connectedAt: json['connectedAt'] == null ? null : DateTime.parse(json['connectedAt'] as String),
capabilities: (json['capabilities'] as Map<String, dynamic>?)?.map((k, e) => MapEntry(k, e as bool)),
);
Map<String, dynamic> _$RemoteDeviceToJson(RemoteDevice instance) => <String, dynamic>{
'id': instance.id,
'name': instance.name,
'platform': instance.platform,
'connectedAt': instance.connectedAt.toIso8601String(),
'capabilities': instance.capabilities,
};
RemoteSession _$RemoteSessionFromJson(Map<String, dynamic> json) => RemoteSession(
sessionId: json['sessionId'] as String,
pin: json['pin'] as String,
role: $enumDecode(_$RemoteSessionRoleEnumMap, json['role'], unknownValue: RemoteSessionRole.remote),
status:
$enumDecodeNullable(
_$RemoteSessionStatusEnumMap,
json['status'],
unknownValue: RemoteSessionStatus.disconnected,
) ??
RemoteSessionStatus.disconnected,
connectedDevice: json['connectedDevice'] == null
connectedAt: json['connectedAt'] == null
? null
: RemoteDevice.fromJson(json['connectedDevice'] as Map<String, dynamic>),
createdAt: json['createdAt'] == null ? null : DateTime.parse(json['createdAt'] as String),
errorMessage: json['errorMessage'] as String?,
: DateTime.parse(json['connectedAt'] as String),
capabilities: (json['capabilities'] as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, e as bool),
),
);
Map<String, dynamic> _$RemoteSessionToJson(RemoteSession instance) => <String, dynamic>{
'sessionId': instance.sessionId,
'pin': instance.pin,
'role': _$RemoteSessionRoleEnumMap[instance.role]!,
'status': _$RemoteSessionStatusEnumMap[instance.status]!,
'connectedDevice': instance.connectedDevice,
'createdAt': instance.createdAt.toIso8601String(),
'errorMessage': instance.errorMessage,
};
Map<String, dynamic> _$RemoteDeviceToJson(RemoteDevice instance) =>
<String, dynamic>{
'id': instance.id,
'name': instance.name,
'platform': instance.platform,
'connectedAt': instance.connectedAt.toIso8601String(),
'capabilities': instance.capabilities,
};
const _$RemoteSessionRoleEnumMap = {RemoteSessionRole.host: 'host', RemoteSessionRole.remote: 'remote'};
RemoteSession _$RemoteSessionFromJson(Map<String, dynamic> json) =>
RemoteSession(
sessionId: json['sessionId'] as String,
pin: json['pin'] as String,
role: $enumDecode(
_$RemoteSessionRoleEnumMap,
json['role'],
unknownValue: RemoteSessionRole.remote,
),
status:
$enumDecodeNullable(
_$RemoteSessionStatusEnumMap,
json['status'],
unknownValue: RemoteSessionStatus.disconnected,
) ??
RemoteSessionStatus.disconnected,
connectedDevice: json['connectedDevice'] == null
? null
: RemoteDevice.fromJson(
json['connectedDevice'] as Map<String, dynamic>,
),
createdAt: json['createdAt'] == null
? null
: DateTime.parse(json['createdAt'] as String),
errorMessage: json['errorMessage'] as String?,
);
Map<String, dynamic> _$RemoteSessionToJson(RemoteSession instance) =>
<String, dynamic>{
'sessionId': instance.sessionId,
'pin': instance.pin,
'role': _$RemoteSessionRoleEnumMap[instance.role]!,
'status': _$RemoteSessionStatusEnumMap[instance.status]!,
'connectedDevice': instance.connectedDevice,
'createdAt': instance.createdAt.toIso8601String(),
'errorMessage': instance.errorMessage,
};
const _$RemoteSessionRoleEnumMap = {
RemoteSessionRole.host: 'host',
RemoteSessionRole.remote: 'remote',
};
const _$RemoteSessionStatusEnumMap = {
RemoteSessionStatus.disconnected: 'disconnected',
+20 -12
View File
@@ -6,15 +6,23 @@ part of 'play_queue_response.dart';
// JsonSerializableGenerator
// **************************************************************************
PlayQueueResponse _$PlayQueueResponseFromJson(Map<String, dynamic> json) => PlayQueueResponse(
playQueueID: (json['playQueueID'] as num).toInt(),
playQueueSelectedItemID: (json['playQueueSelectedItemID'] as num?)?.toInt(),
playQueueSelectedItemOffset: (json['playQueueSelectedItemOffset'] as num?)?.toInt(),
playQueueSelectedMetadataItemID: json['playQueueSelectedMetadataItemID'] as String?,
playQueueShuffled: const BoolOrIntConverter().fromJson(json['playQueueShuffled'] as Object),
playQueueSourceURI: json['playQueueSourceURI'] as String?,
playQueueTotalCount: (json['playQueueTotalCount'] as num?)?.toInt(),
playQueueVersion: (json['playQueueVersion'] as num).toInt(),
size: (json['size'] as num?)?.toInt(),
items: (json['Metadata'] as List<dynamic>?)?.map((e) => PlexMetadata.fromJson(e as Map<String, dynamic>)).toList(),
);
PlayQueueResponse _$PlayQueueResponseFromJson(Map<String, dynamic> json) =>
PlayQueueResponse(
playQueueID: (json['playQueueID'] as num).toInt(),
playQueueSelectedItemID: (json['playQueueSelectedItemID'] as num?)
?.toInt(),
playQueueSelectedItemOffset: (json['playQueueSelectedItemOffset'] as num?)
?.toInt(),
playQueueSelectedMetadataItemID:
json['playQueueSelectedMetadataItemID'] as String?,
playQueueShuffled: const BoolOrIntConverter().fromJson(
json['playQueueShuffled'] as Object,
),
playQueueSourceURI: json['playQueueSourceURI'] as String?,
playQueueTotalCount: (json['playQueueTotalCount'] as num?)?.toInt(),
playQueueVersion: (json['playQueueVersion'] as num).toInt(),
size: (json['size'] as num?)?.toInt(),
items: (json['Metadata'] as List<dynamic>?)
?.map((e) => PlexMetadata.fromJson(e as Map<String, dynamic>))
.toList(),
);
+4 -4
View File
@@ -25,11 +25,11 @@ class PlexHome {
return PlexHome(
id: (json['id'] as num?)?.toInt() ?? 0,
name: json['name'] as String,
name: json['name'] as String? ?? '',
guestUserID: (json['guestUserID'] as num?)?.toInt(),
guestUserUUID: json['guestUserUUID'] as String,
guestEnabled: json['guestEnabled'] as bool,
subscription: json['subscription'] as bool,
guestUserUUID: json['guestUserUUID'] as String? ?? '',
guestEnabled: json['guestEnabled'] as bool? ?? false,
subscription: json['subscription'] as bool? ?? false,
users: users,
);
}
+13 -12
View File
@@ -19,15 +19,16 @@ PlexLibrary _$PlexLibraryFromJson(Map<String, dynamic> json) => PlexLibrary(
hidden: (json['hidden'] as num?)?.toInt(),
);
Map<String, dynamic> _$PlexLibraryToJson(PlexLibrary instance) => <String, dynamic>{
'key': instance.key,
'title': instance.title,
'type': instance.type,
'agent': instance.agent,
'scanner': instance.scanner,
'language': instance.language,
'uuid': instance.uuid,
'updatedAt': instance.updatedAt,
'createdAt': instance.createdAt,
'hidden': instance.hidden,
};
Map<String, dynamic> _$PlexLibraryToJson(PlexLibrary instance) =>
<String, dynamic>{
'key': instance.key,
'title': instance.title,
'type': instance.type,
'agent': instance.agent,
'scanner': instance.scanner,
'language': instance.language,
'uuid': instance.uuid,
'updatedAt': instance.updatedAt,
'createdAt': instance.createdAt,
'hidden': instance.hidden,
};
+27 -14
View File
@@ -227,29 +227,42 @@ class PlaybackExtras {
PlaybackExtras({required this.chapters, required this.markers});
static final _introPattern = RegExp(
r'(?:^|\b)(?:intro(?:duction)?|opening)(?:\b|$)|^op(?:\s?\d+)?$',
caseSensitive: false,
);
static final _creditsPattern = RegExp(
r'(?:^|\b)(?:outro|closing|credits?|ending)(?:\b|$)|^ed(?:\s?\d+)?$',
caseSensitive: false,
);
static String? _classifyChapterTitle(String title) {
if (_introPattern.hasMatch(title)) return 'intro';
if (_creditsPattern.hasMatch(title)) return 'credits';
static String? _classifyChapterTitle(String title, RegExp introPattern, RegExp creditsPattern) {
if (introPattern.hasMatch(title)) return 'intro';
if (creditsPattern.hasMatch(title)) return 'credits';
return null;
}
/// Returns [PlaybackExtras] using real markers when available, otherwise
/// synthesises markers from chapter titles matching intro/credits patterns.
/// When real markers exist, reclassifies markers with unknown types against
/// the patterns so non-standard type strings (e.g. "OP-Song") get recognized.
factory PlaybackExtras.withChapterFallback({
required List<PlexChapter> chapters,
required List<PlexMarker> markers,
String? introPatternStr,
String? creditsPatternStr,
}) {
final introPattern = RegExp(
introPatternStr ?? r'(?:^|\b)(?:intro(?:duction)?|opening)(?:\b|$)|^op(?:\s?\d+)?$',
caseSensitive: false,
);
final creditsPattern = RegExp(
creditsPatternStr ?? r'(?:^|\b)(?:outro|closing|credits?|ending)(?:\b|$)|^ed(?:\s?\d+)?$',
caseSensitive: false,
);
if (markers.isNotEmpty) {
return PlaybackExtras(chapters: chapters, markers: markers);
// Reclassify markers with non-standard types against the patterns
final reclassified = markers.map((m) {
if (m.type == 'intro' || m.type == 'credits') return m;
final newType = _classifyChapterTitle(m.type, introPattern, creditsPattern);
if (newType != null) {
return PlexMarker(id: m.id, type: newType, startTimeOffset: m.startTimeOffset, endTimeOffset: m.endTimeOffset);
}
return m;
}).toList();
return PlaybackExtras(chapters: chapters, markers: reclassified);
}
final synthetic = <PlexMarker>[];
@@ -258,7 +271,7 @@ class PlaybackExtras {
final title = ch.title;
if (title == null || title.isEmpty) continue;
final type = _classifyChapterTitle(title);
final type = _classifyChapterTitle(title, introPattern, creditsPattern);
if (type == null) continue;
final start = ch.startTimeOffset;
+29 -16
View File
@@ -9,6 +9,15 @@ import '../utils/global_key_utils.dart';
part 'plex_metadata.g.dart';
Object? _readRatingKey(Map json, String key) =>
json['ratingKey'] ?? json['key'] ?? '';
int? _flexibleInt(Object? v) => switch (v) {
num n => n.toInt(),
String s => int.tryParse(s),
_ => null,
};
/// Media type enum for type-safe media type handling
enum PlexMediaType {
movie,
@@ -51,12 +60,13 @@ enum PlexMediaType {
@JsonSerializable()
class PlexMetadata with MultiServerFields {
@JsonKey(readValue: _readRatingKey)
final String ratingKey;
final String key;
final String? key;
final String? guid;
final String? studio;
final String type;
final String title;
final String? type;
final String? title;
final String? titleSort;
final String? contentRating;
final String? summary;
@@ -85,11 +95,13 @@ class PlexMetadata with MultiServerFields {
final int? viewCount;
final int? leafCount; // Total number of episodes in a series/season
final int? viewedLeafCount; // Number of watched episodes in a series/season
@JsonKey(fromJson: _flexibleInt)
final int? childCount; // Number of items in a collection or playlist
@JsonKey(name: 'Role')
final List<PlexRole>? role; // Cast members
final String? audioLanguage; // Per-media preferred audio language
final String? subtitleLanguage; // Per-media preferred subtitle language
@JsonKey(fromJson: _flexibleInt)
final int? subtitleMode; // Per-media subtitle mode (0=manual, 1=foreign audio, 2=always, -1=account default)
final int? playlistItemID; // Playlist item ID (for dumb playlists only)
final int? playQueueItemID; // Play queue item ID (unique even for duplicates)
@@ -99,6 +111,7 @@ class PlexMetadata with MultiServerFields {
final String? audienceRatingImage; // Audience rating source URI
final String? tagline;
final String? originalTitle;
final String? editionTitle; // Edition name for movies (e.g., "Director's Cut", "Extended")
final String? subtype; // Clip subtype: "trailer", "behindTheScenes", "deleted", etc.
final int? extraType; // Numeric extra type identifier
final String? primaryExtraKey; // Points to main trailer (e.g., "/library/metadata/52601")
@@ -122,7 +135,8 @@ class PlexMetadata with MultiServerFields {
/// Parsed media type enum for type-safe comparisons
PlexMediaType get mediaType {
return switch (type.toLowerCase()) {
if (type == null) return PlexMediaType.unknown;
return switch (type!.toLowerCase()) {
'movie' => PlexMediaType.movie,
'show' => PlexMediaType.show,
'season' => PlexMediaType.season,
@@ -140,11 +154,11 @@ class PlexMetadata with MultiServerFields {
PlexMetadata({
required this.ratingKey,
required this.key,
this.key,
this.guid,
this.studio,
required this.type,
required this.title,
this.type,
this.title,
this.titleSort,
this.contentRating,
this.summary,
@@ -186,6 +200,7 @@ class PlexMetadata with MultiServerFields {
this.audienceRatingImage,
this.tagline,
this.originalTitle,
this.editionTitle,
this.subtype,
this.extraType,
this.primaryExtraKey,
@@ -244,6 +259,7 @@ class PlexMetadata with MultiServerFields {
String? audienceRatingImage,
String? tagline,
String? originalTitle,
String? editionTitle,
String? subtype,
int? extraType,
String? primaryExtraKey,
@@ -300,6 +316,7 @@ class PlexMetadata with MultiServerFields {
audienceRatingImage: audienceRatingImage ?? this.audienceRatingImage,
tagline: tagline ?? this.tagline,
originalTitle: originalTitle ?? this.originalTitle,
editionTitle: editionTitle ?? this.editionTitle,
subtype: subtype ?? this.subtype,
extraType: extraType ?? this.extraType,
primaryExtraKey: primaryExtraKey ?? this.primaryExtraKey,
@@ -350,7 +367,7 @@ class PlexMetadata with MultiServerFields {
// Helper to get the display title (show name for episodes/seasons, title otherwise)
String get displayTitle {
final itemType = type.toLowerCase();
final itemType = type?.toLowerCase();
// For episodes and seasons, prefer grandparent title (show name)
if ((itemType == 'episode' || itemType == 'season') && grandparentTitle != null) {
@@ -360,12 +377,12 @@ class PlexMetadata with MultiServerFields {
if (itemType == 'season' && parentTitle != null) {
return parentTitle!;
}
return title;
return title ?? '';
}
// Helper to get the subtitle (episode/season title)
String? get displaySubtitle {
final itemType = type.toLowerCase();
final itemType = type?.toLowerCase();
if (itemType == 'episode' || itemType == 'season') {
// If we showed grandparent/parent as title, show this item's title as subtitle
@@ -385,7 +402,7 @@ class PlexMetadata with MultiServerFields {
/// For movies/shows/seasons in mixed hub context: returns art (16:9 background)
/// For other types: returns thumb
String? posterThumb({EpisodePosterMode mode = EpisodePosterMode.seriesPoster, bool mixedHubContext = false}) {
final itemType = type.toLowerCase();
final itemType = type?.toLowerCase();
if (itemType == 'episode') {
switch (mode) {
@@ -420,7 +437,7 @@ class PlexMetadata with MultiServerFields {
/// Clips (trailers, extras) always use 16:9.
/// Movies, shows, and seasons use 16:9 in mixed hub context with episodeThumbnail mode.
bool usesWideAspectRatio(EpisodePosterMode mode, {bool mixedHubContext = false}) {
final itemType = type.toLowerCase();
final itemType = type?.toLowerCase();
// Clips (trailers, extras) are always 16:9
if (itemType == 'clip') return true;
if (itemType == 'episode' && mode == EpisodePosterMode.episodeThumbnail) {
@@ -454,10 +471,6 @@ class PlexMetadata with MultiServerFields {
}
factory PlexMetadata.fromJson(Map<String, dynamic> json) {
// Plex API returns subtitleMode as a string
if (json['subtitleMode'] is String) {
json = {...json, 'subtitleMode': num.tryParse(json['subtitleMode'] as String)};
}
try {
return _$PlexMetadataFromJson(kBlurArtwork ? _obfuscateJson(json) : json);
} on TypeError catch (e, st) {
+66 -61
View File
@@ -7,12 +7,12 @@ part of 'plex_metadata.dart';
// **************************************************************************
PlexMetadata _$PlexMetadataFromJson(Map<String, dynamic> json) => PlexMetadata(
ratingKey: json['ratingKey'] as String,
key: json['key'] as String,
ratingKey: _readRatingKey(json, 'ratingKey') as String,
key: json['key'] as String?,
guid: json['guid'] as String?,
studio: json['studio'] as String?,
type: json['type'] as String,
title: json['title'] as String,
type: json['type'] as String?,
title: json['title'] as String?,
titleSort: json['titleSort'] as String?,
contentRating: json['contentRating'] as String?,
summary: json['summary'] as String?,
@@ -41,11 +41,13 @@ PlexMetadata _$PlexMetadataFromJson(Map<String, dynamic> json) => PlexMetadata(
viewCount: (json['viewCount'] as num?)?.toInt(),
leafCount: (json['leafCount'] as num?)?.toInt(),
viewedLeafCount: (json['viewedLeafCount'] as num?)?.toInt(),
childCount: (json['childCount'] as num?)?.toInt(),
role: (json['Role'] as List<dynamic>?)?.map((e) => PlexRole.fromJson(e as Map<String, dynamic>)).toList(),
childCount: _flexibleInt(json['childCount']),
role: (json['Role'] as List<dynamic>?)
?.map((e) => PlexRole.fromJson(e as Map<String, dynamic>))
.toList(),
audioLanguage: json['audioLanguage'] as String?,
subtitleLanguage: json['subtitleLanguage'] as String?,
subtitleMode: (json['subtitleMode'] as num?)?.toInt(),
subtitleMode: _flexibleInt(json['subtitleMode']),
playlistItemID: (json['playlistItemID'] as num?)?.toInt(),
playQueueItemID: (json['playQueueItemID'] as num?)?.toInt(),
librarySectionID: (json['librarySectionID'] as num?)?.toInt(),
@@ -54,6 +56,7 @@ PlexMetadata _$PlexMetadataFromJson(Map<String, dynamic> json) => PlexMetadata(
audienceRatingImage: json['audienceRatingImage'] as String?,
tagline: json['tagline'] as String?,
originalTitle: json['originalTitle'] as String?,
editionTitle: json['editionTitle'] as String?,
subtype: json['subtype'] as String?,
extraType: (json['extraType'] as num?)?.toInt(),
primaryExtraKey: json['primaryExtraKey'] as String?,
@@ -61,57 +64,59 @@ PlexMetadata _$PlexMetadataFromJson(Map<String, dynamic> json) => PlexMetadata(
backgroundSquare: json['backgroundSquare'] as String?,
);
Map<String, dynamic> _$PlexMetadataToJson(PlexMetadata instance) => <String, dynamic>{
'ratingKey': instance.ratingKey,
'key': instance.key,
'guid': instance.guid,
'studio': instance.studio,
'type': instance.type,
'title': instance.title,
'titleSort': instance.titleSort,
'contentRating': instance.contentRating,
'summary': instance.summary,
'rating': instance.rating,
'audienceRating': instance.audienceRating,
'userRating': instance.userRating,
'year': instance.year,
'originallyAvailableAt': instance.originallyAvailableAt,
'thumb': instance.thumb,
'art': instance.art,
'duration': instance.duration,
'addedAt': instance.addedAt,
'updatedAt': instance.updatedAt,
'lastViewedAt': instance.lastViewedAt,
'grandparentTitle': instance.grandparentTitle,
'grandparentThumb': instance.grandparentThumb,
'grandparentArt': instance.grandparentArt,
'grandparentRatingKey': instance.grandparentRatingKey,
'parentTitle': instance.parentTitle,
'parentThumb': instance.parentThumb,
'parentRatingKey': instance.parentRatingKey,
'parentIndex': instance.parentIndex,
'index': instance.index,
'grandparentTheme': instance.grandparentTheme,
'viewOffset': instance.viewOffset,
'viewCount': instance.viewCount,
'leafCount': instance.leafCount,
'viewedLeafCount': instance.viewedLeafCount,
'childCount': instance.childCount,
'Role': instance.role,
'audioLanguage': instance.audioLanguage,
'subtitleLanguage': instance.subtitleLanguage,
'subtitleMode': instance.subtitleMode,
'playlistItemID': instance.playlistItemID,
'playQueueItemID': instance.playQueueItemID,
'librarySectionID': instance.librarySectionID,
'librarySectionTitle': instance.librarySectionTitle,
'ratingImage': instance.ratingImage,
'audienceRatingImage': instance.audienceRatingImage,
'tagline': instance.tagline,
'originalTitle': instance.originalTitle,
'subtype': instance.subtype,
'extraType': instance.extraType,
'primaryExtraKey': instance.primaryExtraKey,
'clearLogo': instance.clearLogo,
'backgroundSquare': instance.backgroundSquare,
};
Map<String, dynamic> _$PlexMetadataToJson(PlexMetadata instance) =>
<String, dynamic>{
'ratingKey': instance.ratingKey,
'key': instance.key,
'guid': instance.guid,
'studio': instance.studio,
'type': instance.type,
'title': instance.title,
'titleSort': instance.titleSort,
'contentRating': instance.contentRating,
'summary': instance.summary,
'rating': instance.rating,
'audienceRating': instance.audienceRating,
'userRating': instance.userRating,
'year': instance.year,
'originallyAvailableAt': instance.originallyAvailableAt,
'thumb': instance.thumb,
'art': instance.art,
'duration': instance.duration,
'addedAt': instance.addedAt,
'updatedAt': instance.updatedAt,
'lastViewedAt': instance.lastViewedAt,
'grandparentTitle': instance.grandparentTitle,
'grandparentThumb': instance.grandparentThumb,
'grandparentArt': instance.grandparentArt,
'grandparentRatingKey': instance.grandparentRatingKey,
'parentTitle': instance.parentTitle,
'parentThumb': instance.parentThumb,
'parentRatingKey': instance.parentRatingKey,
'parentIndex': instance.parentIndex,
'index': instance.index,
'grandparentTheme': instance.grandparentTheme,
'viewOffset': instance.viewOffset,
'viewCount': instance.viewCount,
'leafCount': instance.leafCount,
'viewedLeafCount': instance.viewedLeafCount,
'childCount': instance.childCount,
'Role': instance.role,
'audioLanguage': instance.audioLanguage,
'subtitleLanguage': instance.subtitleLanguage,
'subtitleMode': instance.subtitleMode,
'playlistItemID': instance.playlistItemID,
'playQueueItemID': instance.playQueueItemID,
'librarySectionID': instance.librarySectionID,
'librarySectionTitle': instance.librarySectionTitle,
'ratingImage': instance.ratingImage,
'audienceRatingImage': instance.audienceRatingImage,
'tagline': instance.tagline,
'originalTitle': instance.originalTitle,
'editionTitle': instance.editionTitle,
'subtype': instance.subtype,
'extraType': instance.extraType,
'primaryExtraKey': instance.primaryExtraKey,
'clearLogo': instance.clearLogo,
'backgroundSquare': instance.backgroundSquare,
};
+20 -19
View File
@@ -26,22 +26,23 @@ PlexPlaylist _$PlexPlaylistFromJson(Map<String, dynamic> json) => PlexPlaylist(
thumb: json['thumb'] as String?,
);
Map<String, dynamic> _$PlexPlaylistToJson(PlexPlaylist instance) => <String, dynamic>{
'ratingKey': instance.ratingKey,
'key': instance.key,
'type': instance.type,
'title': instance.title,
'summary': instance.summary,
'smart': instance.smart,
'playlistType': instance.playlistType,
'duration': instance.duration,
'leafCount': instance.leafCount,
'composite': instance.composite,
'addedAt': instance.addedAt,
'updatedAt': instance.updatedAt,
'lastViewedAt': instance.lastViewedAt,
'viewCount': instance.viewCount,
'content': instance.content,
'guid': instance.guid,
'thumb': instance.thumb,
};
Map<String, dynamic> _$PlexPlaylistToJson(PlexPlaylist instance) =>
<String, dynamic>{
'ratingKey': instance.ratingKey,
'key': instance.key,
'type': instance.type,
'title': instance.title,
'summary': instance.summary,
'smart': instance.smart,
'playlistType': instance.playlistType,
'duration': instance.duration,
'leafCount': instance.leafCount,
'composite': instance.composite,
'addedAt': instance.addedAt,
'updatedAt': instance.updatedAt,
'lastViewedAt': instance.lastViewedAt,
'viewCount': instance.viewCount,
'content': instance.content,
'guid': instance.guid,
'thumb': instance.thumb,
};
+3 -1
View File
@@ -220,7 +220,9 @@ class PlayerAndroid extends PlayerBase {
}
}
break;
// Other properties are no-ops for ExoPlayer
default:
// Forward unknown properties to Kotlin for MPV fallback
await invoke('setMpvProperty', {'name': name, 'value': value});
}
}
@@ -226,6 +226,10 @@ class CompanionRemoteProvider with ChangeNotifier {
}
Future<void> joinSessionMulti(String sessionId, String pin, List<String> hostAddresses) async {
if (hostAddresses.isEmpty) {
throw ArgumentError('hostAddresses must not be empty');
}
await leaveSession();
_lastSessionId = sessionId;
+10 -10
View File
@@ -515,10 +515,10 @@ class DownloadProvider extends ChangeNotifier {
}
// We have metadata, check type
final type = meta.type.toLowerCase();
if (type == 'show') {
final mt = meta.mediaType;
if (mt == PlexMediaType.show) {
return getAggregateProgressForShow(serverId, ratingKey);
} else if (type == 'season') {
} else if (mt == PlexMediaType.season) {
return getAggregateProgressForSeason(serverId, ratingKey);
}
@@ -606,19 +606,19 @@ class DownloadProvider extends ChangeNotifier {
_queueing.add(globalKey);
notifyListeners();
final type = metadata.type.toLowerCase();
final mt = metadata.mediaType;
if (type == 'movie' || type == 'episode') {
if (mt == PlexMediaType.movie || mt == PlexMediaType.episode) {
// Direct download of a single item
await _queueSingleDownload(metadata, client);
return 1;
} else if (type == 'show') {
} else if (mt == PlexMediaType.show) {
// Store show metadata so getProgress() can identify it as a show
_metadata[globalKey] = metadata;
// Download all episodes from all seasons
return await _queueShowDownload(metadata, client);
} else if (type == 'season') {
} else if (mt == PlexMediaType.season) {
// Store season metadata so getProgress() can identify it as a season
_metadata[globalKey] = metadata;
@@ -765,11 +765,11 @@ class DownloadProvider extends ChangeNotifier {
/// Used for resuming partial downloads
/// Returns the number of episodes queued
Future<int> queueMissingEpisodes(PlexMetadata metadata, PlexClient client) async {
final type = metadata.type.toLowerCase();
final mt = metadata.mediaType;
if (type == 'show') {
if (mt == PlexMediaType.show) {
return await _queueMissingShowEpisodes(metadata, client);
} else if (type == 'season') {
} else if (mt == PlexMediaType.season) {
return await _queueMissingSeasonEpisodes(metadata, client);
} else {
throw Exception('queueMissingEpisodes only supports shows/seasons');
+6 -7
View File
@@ -48,9 +48,9 @@ class OfflineModeProvider extends ChangeNotifier {
// Check initial connectivity
await _updateConnectionFlags();
// Monitor connectivity changes — wrapped in try-catch because
// connectivity_plus can throw synchronously on Windows (NetworkManager::StartListen)
try {
// Monitor connectivity changes — runZonedGuarded catches async errors from
// connectivity_plus (e.g. DBusServiceUnknownException on Linux without NetworkManager)
runZonedGuarded(() {
_connectivitySubscription = Connectivity().onConnectivityChanged.listen(
(results) {
final wasOffline = isOffline;
@@ -61,14 +61,13 @@ class OfflineModeProvider extends ChangeNotifier {
}
},
onError: (e) {
// Assume network available on stream error
_hasNetworkConnection = true;
},
);
} catch (e) {
// Assume network available if stream activation fails
}, (error, stack) {
// connectivity_plus throws DBusServiceUnknownException on Linux without NetworkManager
_hasNetworkConnection = true;
}
});
// Monitor server status from MultiServerManager
_serverStatusSubscription = _serverManager.statusStream.listen((statusMap) {
+2 -2
View File
@@ -145,7 +145,7 @@ class OfflineWatchProvider extends ChangeNotifier {
}
// All episodes watched - return first episode for replay
return episodes.first;
return episodes.firstOrNull;
}
/// Find the next unwatched downloaded episode synchronously.
@@ -164,7 +164,7 @@ class OfflineWatchProvider extends ChangeNotifier {
}
// All episodes watched - return first episode for replay
return episodes.first;
return episodes.firstOrNull;
}
/// Emit a watch state change event for immediate UI update.
+2 -2
View File
@@ -210,7 +210,7 @@ class PlaybackStateProvider with ChangeNotifier {
}
// Need to load next window
if (_client != null && _playQueueId != null) {
if (_client != null && _playQueueId != null && _loadedItems.isNotEmpty) {
// Load next window centered on the item after current
final nextItemID = _loadedItems.last.playQueueItemID;
if (nextItemID != null) {
@@ -248,7 +248,7 @@ class PlaybackStateProvider with ChangeNotifier {
}
// Need to load previous window
if (_client != null && _playQueueId != null) {
if (_client != null && _playQueueId != null && _loadedItems.isNotEmpty) {
final prevItemID = _loadedItems.first.playQueueItemID;
if (prevItemID != null && prevItemID > 0) {
final loaded = await _ensureItemsLoaded(prevItemID - 1);
+1 -1
View File
@@ -66,7 +66,7 @@ class ThemeProvider extends ChangeNotifier {
}
}
static const _themeChannel = MethodChannel('app.plezy/theme');
static const _themeChannel = MethodChannel('com.plezy/theme');
Future<void> setThemeMode(settings.ThemeMode mode) async {
if (_themeMode != mode) {
@@ -59,10 +59,10 @@ abstract class BaseMediaListDetailScreen<T extends StatefulWidget> extends State
// If serverId is null, fall back to first available server
if (serverId == null) {
final multiServerProvider = Provider.of<MultiServerProvider>(context, listen: false);
if (!multiServerProvider.hasConnectedServers) {
serverId = multiServerProvider.onlineServerIds.firstOrNull;
if (serverId == null) {
throw Exception(t.errors.noClientAvailable);
}
serverId = multiServerProvider.onlineServerIds.first;
}
return context.getClientForServer(serverId);
+3 -3
View File
@@ -31,7 +31,7 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen<CollectionD
PlexMetadata get mediaItem => widget.collection;
@override
String get title => widget.collection.title;
String get title => widget.collection.title!;
@override
String get emptyMessage => t.collections.empty;
@@ -93,7 +93,7 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen<CollectionD
final confirmed = await showDeleteConfirmation(
context,
title: t.collections.deleteCollection,
message: t.collections.deleteConfirm(title: widget.collection.title),
message: t.collections.deleteConfirm(title: widget.collection.displayTitle),
);
if (!confirmed) return;
@@ -134,7 +134,7 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen<CollectionD
body: CustomScrollView(
controller: scrollController,
slivers: [
CustomAppBar(title: Text(widget.collection.title), actions: buildFocusableAppBarActions()),
CustomAppBar(title: Text(widget.collection.title!), actions: buildFocusableAppBarActions()),
...buildStateSlivers(),
if (items.isNotEmpty)
buildFocusableGrid(
+7 -5
View File
@@ -63,10 +63,11 @@ class _DiscoverScreenState extends State<DiscoverScreen>
@override
PlexClient get client {
final multiServerProvider = Provider.of<MultiServerProvider>(context, listen: false);
if (!multiServerProvider.hasConnectedServers) {
final serverId = multiServerProvider.onlineServerIds.firstOrNull;
if (serverId == null) {
throw Exception('No servers available');
}
return context.getClientForServer(multiServerProvider.onlineServerIds.first);
return context.getClientForServer(serverId);
}
List<PlexMetadata> _onDeck = [];
@@ -140,10 +141,11 @@ class _DiscoverScreenState extends State<DiscoverScreen>
final serverId = item?.serverId;
if (serverId == null) {
final multiServerProvider = Provider.of<MultiServerProvider>(context, listen: false);
if (!multiServerProvider.hasConnectedServers) {
final fallbackId = multiServerProvider.onlineServerIds.firstOrNull;
if (fallbackId == null) {
throw Exception('No servers available');
}
return context.getClientForServer(multiServerProvider.onlineServerIds.first);
return context.getClientForServer(fallbackId);
}
return context.getClientForServer(serverId);
}
@@ -1261,7 +1263,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
Widget _buildHeroItem(PlexMetadata heroItem, double heroHeight) {
final isEpisode = heroItem.isEpisode;
final showName = heroItem.grandparentTitle ?? heroItem.title;
final showName = heroItem.grandparentTitle ?? heroItem.displayTitle;
final screenWidth = MediaQuery.of(context).size.width;
final isLargeScreen = ScreenBreakpoints.isWideTabletOrLarger(screenWidth);
+2 -2
View File
@@ -180,7 +180,7 @@ class _HubDetailScreenState extends State<HubDetailScreen>
switch (sortKey) {
case 'titleSort':
case 'title':
comparison = a.title.compareTo(b.title);
comparison = (a.title ?? '').compareTo(b.title ?? '');
break;
case 'addedAt':
comparison = (a.addedAt ?? 0).compareTo(b.addedAt ?? 0);
@@ -193,7 +193,7 @@ class _HubDetailScreenState extends State<HubDetailScreen>
comparison = (a.rating ?? 0).compareTo(b.rating ?? 0);
break;
default:
comparison = a.title.compareTo(b.title);
comparison = (a.title ?? '').compareTo(b.title ?? '');
}
return _isSortDescending ? -comparison : comparison;
+9 -16
View File
@@ -42,21 +42,14 @@ class FolderTreeItem extends StatelessWidget {
}
// File icons based on type
final type = item.type.toLowerCase();
switch (type) {
case 'movie':
return Symbols.movie_rounded;
case 'show':
return Symbols.tv_rounded;
case 'season':
return Symbols.video_library_rounded;
case 'episode':
return Symbols.play_circle_rounded;
case 'collection':
return Symbols.collections_rounded;
default:
return Symbols.insert_drive_file_rounded;
}
return switch (item.mediaType) {
PlexMediaType.movie => Symbols.movie_rounded,
PlexMediaType.show => Symbols.tv_rounded,
PlexMediaType.season => Symbols.video_library_rounded,
PlexMediaType.episode => Symbols.play_circle_rounded,
PlexMediaType.collection => Symbols.collections_rounded,
_ => Symbols.insert_drive_file_rounded,
};
}
void _handleTap() {
@@ -104,7 +97,7 @@ class FolderTreeItem extends StatelessWidget {
// Item title
Expanded(
child: Text(
item.title,
item.displayTitle,
style: TextStyle(fontSize: 14, fontWeight: isFolder ? FontWeight.w500 : FontWeight.w400),
maxLines: 1,
overflow: TextOverflow.ellipsis,
+14 -14
View File
@@ -87,32 +87,32 @@ class _FolderTreeViewState extends State<FolderTreeView> {
Future<void> _loadFolderChildren(PlexMetadata folder) async {
// Already loading this folder
if (_loadingFolders.contains(folder.key)) return;
if (_loadingFolders.contains(folder.key!)) return;
// Already loaded and cached
if (_childrenCache.containsKey(folder.key)) {
if (_childrenCache.containsKey(folder.key!)) {
setState(() {
_expandedFolders.add(folder.key);
_expandedFolders.add(folder.key!);
});
return;
}
setState(() {
_loadingFolders.add(folder.key);
_loadingFolders.add(folder.key!);
});
try {
final client = context.getClientForServer(widget.serverId!);
// Items are automatically tagged with server info by PlexClient
final children = await client.getFolderChildren(folder.key);
final children = await client.getFolderChildren(folder.key!);
if (!mounted) return;
setState(() {
_childrenCache[folder.key] = children;
_expandedFolders.add(folder.key);
_loadingFolders.remove(folder.key);
_childrenCache[folder.key!] = children;
_expandedFolders.add(folder.key!);
_loadingFolders.remove(folder.key!);
});
appLogger.d('Loaded ${children.length} children for folder: ${folder.title}');
@@ -121,7 +121,7 @@ class _FolderTreeViewState extends State<FolderTreeView> {
appLogger.e('Failed to load folder children', error: e);
setState(() {
_loadingFolders.remove(folder.key);
_loadingFolders.remove(folder.key!);
});
if (mounted) {
@@ -131,9 +131,9 @@ class _FolderTreeViewState extends State<FolderTreeView> {
}
void _toggleFolder(PlexMetadata folder) {
if (_expandedFolders.contains(folder.key)) {
if (_expandedFolders.contains(folder.key!)) {
setState(() {
_expandedFolders.remove(folder.key);
_expandedFolders.remove(folder.key!);
});
} else {
_loadFolderChildren(folder);
@@ -147,19 +147,19 @@ class _FolderTreeViewState extends State<FolderTreeView> {
Future<void> _handleFolderPlay(PlexMetadata folder) async {
final client = context.getClientForServer(widget.serverId!);
final launcher = PlayQueueLauncher(context: context, client: client, serverId: widget.serverId);
await launcher.launchFromFolder(folderKey: folder.key, shuffle: false);
await launcher.launchFromFolder(folderKey: folder.key!, shuffle: false);
}
Future<void> _handleFolderShuffle(PlexMetadata folder) async {
final client = context.getClientForServer(widget.serverId!);
final launcher = PlayQueueLauncher(context: context, client: client, serverId: widget.serverId);
await launcher.launchFromFolder(folderKey: folder.key, shuffle: true);
await launcher.launchFromFolder(folderKey: folder.key!, shuffle: true);
}
bool _isFolder(PlexMetadata item) {
// Folders typically don't have a specific type or might have special indicators
// Check for common folder indicators
return item.key.contains('/folder') || item.type.isEmpty || item.type.toLowerCase() == 'folder';
return item.key?.contains('/folder') == true || item.type == null || item.type!.isEmpty || item.mediaType == PlexMediaType.unknown;
}
List<Widget> _buildTreeItems(List<PlexMetadata> items, int depth, [String parentPath = '']) {
+46 -30
View File
@@ -79,10 +79,11 @@ class _LibrariesScreenState extends State<LibrariesScreen>
@override
PlexClient get client {
final multiServerProvider = Provider.of<MultiServerProvider>(context, listen: false);
if (!multiServerProvider.hasConnectedServers) {
final serverId = multiServerProvider.onlineServerIds.firstOrNull;
if (serverId == null) {
throw Exception(t.errors.noClientAvailable);
}
return context.getClientForServer(multiServerProvider.onlineServerIds.first);
return context.getClientForServer(serverId);
}
// GlobalKeys for tabs to enable refresh
@@ -218,6 +219,9 @@ class _LibrariesScreenState extends State<LibrariesScreen>
if (tabController.indexIsChanging) {
return;
}
// On mobile (touch mode), skip auto-focus to prevent ensureVisible()
// from interfering with TabBarView page animations
if (!InputModeTracker.isKeyboardMode(context)) return;
// Re-enable auto-focus since user is navigating into tab content
// Only call setState if the value actually changes to avoid unnecessary rebuilds
@@ -1063,38 +1067,50 @@ class _LibrariesScreenState extends State<LibrariesScreen>
// Disable swipe on desktop - trackpad scrolling triggers accidental tab switches
// See: https://github.com/flutter/flutter/issues/11132
physics: PlatformDetector.isDesktop(context) ? const NeverScrollableScrollPhysics() : null,
// Wrap each tab in ClipRect so horizontal overflow (e.g. hub rows
// with Clip.none) doesn't bleed into adjacent tabs during swipe transitions.
// The TabBarView's own clipBehavior only clips at the viewport level,
// not per-page, so we need per-child clipping.
children: [
LibraryRecommendedTab(
key: _recommendedTabKey,
library: allLibraries.firstWhere((lib) => lib.globalKey == _selectedLibraryGlobalKey),
isActive: tabController.index == 0,
suppressAutoFocus: suppressAutoFocus,
onDataLoaded: () => _handleTabDataLoaded(0),
onBack: focusTabBar,
ClipRect(
child: LibraryRecommendedTab(
key: _recommendedTabKey,
library: allLibraries.firstWhere((lib) => lib.globalKey == _selectedLibraryGlobalKey),
isActive: tabController.index == 0,
suppressAutoFocus: suppressAutoFocus,
onDataLoaded: () => _handleTabDataLoaded(0),
onBack: focusTabBar,
),
),
LibraryBrowseTab(
key: _browseTabKey,
library: allLibraries.firstWhere((lib) => lib.globalKey == _selectedLibraryGlobalKey),
isActive: tabController.index == 1,
suppressAutoFocus: suppressAutoFocus,
onDataLoaded: () => _handleTabDataLoaded(1),
onBack: focusTabBar,
ClipRect(
child: LibraryBrowseTab(
key: _browseTabKey,
library: allLibraries.firstWhere((lib) => lib.globalKey == _selectedLibraryGlobalKey),
isActive: tabController.index == 1,
suppressAutoFocus: suppressAutoFocus,
onDataLoaded: () => _handleTabDataLoaded(1),
onBack: focusTabBar,
),
),
LibraryCollectionsTab(
key: _collectionsTabKey,
library: allLibraries.firstWhere((lib) => lib.globalKey == _selectedLibraryGlobalKey),
isActive: tabController.index == 2,
suppressAutoFocus: suppressAutoFocus,
onDataLoaded: () => _handleTabDataLoaded(2),
onBack: focusTabBar,
ClipRect(
child: LibraryCollectionsTab(
key: _collectionsTabKey,
library: allLibraries.firstWhere((lib) => lib.globalKey == _selectedLibraryGlobalKey),
isActive: tabController.index == 2,
suppressAutoFocus: suppressAutoFocus,
onDataLoaded: () => _handleTabDataLoaded(2),
onBack: focusTabBar,
),
),
LibraryPlaylistsTab(
key: _playlistsTabKey,
library: allLibraries.firstWhere((lib) => lib.globalKey == _selectedLibraryGlobalKey),
isActive: tabController.index == 3,
suppressAutoFocus: suppressAutoFocus,
onDataLoaded: () => _handleTabDataLoaded(3),
onBack: focusTabBar,
ClipRect(
child: LibraryPlaylistsTab(
key: _playlistsTabKey,
library: allLibraries.firstWhere((lib) => lib.globalKey == _selectedLibraryGlobalKey),
isActive: tabController.index == 3,
suppressAutoFocus: suppressAutoFocus,
onDataLoaded: () => _handleTabDataLoaded(3),
onBack: focusTabBar,
),
),
],
),
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:plezy/widgets/app_icon.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../focus/dpad_navigator.dart';
import '../../focus/input_mode_tracker.dart';
import '../../models/plex_sort.dart';
import '../../widgets/bottom_sheet_header.dart';
import '../../widgets/focusable_list_tile.dart';
@@ -41,6 +42,7 @@ class _SortBottomSheetState extends State<SortBottomSheet> {
_initialFocusNode = FocusNode(debugLabel: 'SortBottomSheetInitialFocus');
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
if (!InputModeTracker.isKeyboardMode(context)) return;
final ctx = _initialFocusNode.context;
if (ctx != null) {
Scrollable.ensureVisible(ctx, alignment: 0.5);
@@ -1,5 +1,6 @@
import 'dart:async';
import 'package:flutter/material.dart';
import '../../../focus/input_mode_tracker.dart';
import '../../../models/plex_library.dart';
import '../../../utils/app_logger.dart';
import '../../../mixins/library_tab_state.dart';
@@ -164,6 +165,9 @@ abstract class BaseLibraryTabState<T, W extends BaseLibraryTab<T>> extends State
void tryFocus() {
// Don't auto-focus if suppressed (e.g., when navigating via tab bar)
if (widget.suppressAutoFocus) return;
// On mobile (touch mode), skip auto-focus to prevent ensureVisible()
// from interfering with TabBarView page animations
if (!InputModeTracker.isKeyboardMode(context)) return;
if (widget.isActive && _hasLoadedData && !hasFocused && _items.isNotEmpty) {
hasFocused = true;
@@ -5,6 +5,7 @@ import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:dio/dio.dart';
import '../../../focus/dpad_navigator.dart';
import '../../../focus/input_mode_tracker.dart';
import '../../../../services/plex_client.dart';
import '../../../models/plex_metadata.dart';
import '../../../models/plex_filter.dart';
@@ -26,6 +27,8 @@ import '../../../mixins/library_tab_focus_mixin.dart';
import '../folder_tree_view.dart';
import '../filters_bottom_sheet.dart';
import '../sort_bottom_sheet.dart';
import '../../../widgets/app_icon.dart';
import '../../../widgets/focusable_list_tile.dart';
import '../state_messages.dart';
import '../../../services/storage_service.dart';
import '../../../services/settings_service.dart' show ViewMode, EpisodePosterMode;
@@ -224,6 +227,9 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
@override
void tryFocus() {
if (widget.suppressAutoFocus) return;
// On mobile (touch mode), skip auto-focus to prevent ensureVisible()
// from interfering with TabBarView page animations
if (!InputModeTracker.isKeyboardMode(context)) return;
if (widget.isActive && hasLoadedData && !hasFocused && _loadedItems.isNotEmpty) {
hasFocused = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -605,49 +611,54 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
void _showGroupingBottomSheet() {
SelectKeyUpSuppressor.suppressSelectUntilKeyUp();
var pendingGrouping = _selectedGrouping;
OverlaySheetController.of(context)
.show(
builder: (sheetContext) {
final options = _getGroupingOptions();
return StatefulBuilder(
builder: (context, setSheetState) {
return RadioGroup<String>(
groupValue: pendingGrouping,
onChanged: (value) {
if (value == null) return;
setSheetState(() {
pendingGrouping = value;
});
},
child: ListView.builder(
shrinkWrap: true,
itemCount: options.length,
itemBuilder: (context, index) {
final grouping = options[index];
return RadioListTile<String>(
title: Text(_getGroupingLabel(grouping)),
value: grouping,
);
},
),
);
},
);
},
)
.then((_) {
if (!mounted) return;
if (pendingGrouping == _selectedGrouping) return;
setState(() {
_selectedGrouping = pendingGrouping;
});
StorageService.getInstance().then((storage) {
storage.saveLibraryGrouping(widget.library.globalKey, pendingGrouping);
});
_loadItems();
_loadFirstCharacters();
});
final options = _getGroupingOptions();
final controller = OverlaySheetController.of(context);
controller.show<String>(
showDragHandle: true,
builder: (sheetContext) => Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 8),
child: Text(
t.libraries.groupings.title,
style: Theme.of(sheetContext).textTheme.titleMedium,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
Flexible(
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: options.map((grouping) {
final isSelected = _selectedGrouping == grouping;
return FocusableListTile(
dense: true,
leading: AppIcon(
isSelected ? Symbols.radio_button_checked_rounded : Symbols.radio_button_unchecked_rounded,
fill: 1,
),
title: Text(_getGroupingLabel(grouping)),
onTap: () => controller.close(grouping),
);
}).toList(),
),
),
),
],
),
).then((value) {
if (!mounted || value == null || value == _selectedGrouping) return;
setState(() {
_selectedGrouping = value;
});
StorageService.getInstance().then((storage) {
storage.saveLibraryGrouping(widget.library.globalKey, value);
});
_loadItems();
_loadFirstCharacters();
});
}
void _showFiltersBottomSheet() {
+13 -13
View File
@@ -1,6 +1,7 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:flutter/services.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
@@ -37,7 +38,7 @@ enum _GuideZone { timeNav, grid }
class GuideTabState extends State<GuideTab> {
static const _slotWidth = 180.0;
static const _channelColumnWidth = 140.0;
static const _channelColumnWidth = 100.0;
static const _rowHeight = 64.0;
static const _timeHeaderHeight = 40.0;
static const _minutesPerSlot = 30;
@@ -636,9 +637,7 @@ class GuideTabState extends State<GuideTab> {
if (target == today) return t.liveTv.today;
final format = MaterialLocalizations.of(context);
final full = format.formatFullDate(target);
return full.split(',').first;
return DateFormat('EEEE', LocaleSettings.currentLocale.languageCode).format(target);
}
List<(String, int)> get _timeSlots => [
@@ -776,8 +775,7 @@ class GuideTabState extends State<GuideTab> {
}
Widget _buildTimeNavigation(ThemeData theme) {
final format = MaterialLocalizations.of(context);
final timeLabel = format.formatTimeOfDay(TimeOfDay.fromDateTime(_gridStart));
final timeLabel = formatClockTime(_gridStart, is24Hour: MediaQuery.alwaysUse24HourFormatOf(context));
final dayLabel = _dayLabel(_gridStart);
return Container(
@@ -972,7 +970,8 @@ class GuideTabState extends State<GuideTab> {
channel,
program,
theme,
isLast: program == programs.last,
isFirst: progStart == gridStartEpoch,
isLast: program == programs.last && progEnd != gridEndEpoch,
isFocused: identical(program, focusProg),
),
),
@@ -992,6 +991,7 @@ class GuideTabState extends State<GuideTab> {
LiveTvChannel channel,
LiveTvProgram program,
ThemeData theme, {
bool isFirst = false,
bool isLast = false,
bool isFocused = false,
}) {
@@ -1000,11 +1000,11 @@ class GuideTabState extends State<GuideTab> {
Color materialColor;
if (isFocused) {
materialColor = theme.colorScheme.primary.withValues(alpha: 0.25);
materialColor = theme.colorScheme.primary.withValues(alpha: 0.15);
} else if (isCurrentlyAiring) {
materialColor = theme.colorScheme.onSurface.withValues(alpha: 0.3);
} else {
materialColor = theme.colorScheme.onSurface.withValues(alpha: 0.12);
} else {
materialColor = theme.colorScheme.onSurface.withValues(alpha: 0.05);
}
Color titleColor;
@@ -1038,7 +1038,7 @@ class GuideTabState extends State<GuideTab> {
child: Container(
decoration: BoxDecoration(
border: Border(
left: BorderSide(color: theme.dividerColor.withValues(alpha: 0.3)),
left: isFirst ? BorderSide.none : BorderSide(color: theme.dividerColor.withValues(alpha: 0.3)),
right: isLast ? BorderSide(color: theme.dividerColor.withValues(alpha: 0.3)) : BorderSide.none,
),
),
@@ -1051,8 +1051,8 @@ class GuideTabState extends State<GuideTab> {
children: [
Text(
program.grandparentTitle ?? program.title,
style: theme.textTheme.bodySmall?.copyWith(
fontWeight: isCurrentlyAiring ? FontWeight.w600 : FontWeight.normal,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
color: titleColor,
),
maxLines: 1,
+3 -2
View File
@@ -11,6 +11,7 @@ import '../../../focus/locked_hub_controller.dart';
import '../../../i18n/strings.g.dart';
import '../../../models/livetv_channel.dart';
import '../../../models/livetv_hub_result.dart';
import '../../../models/plex_metadata.dart';
import '../../../providers/multi_server_provider.dart';
import '../../../providers/settings_provider.dart';
import '../../../services/settings_service.dart' show LibraryDensity;
@@ -168,12 +169,12 @@ class WhatsOnTabState extends State<WhatsOnTab> {
if (entry.program.isCurrentlyAiring && channel != null) {
// Live → play directly
_tuneChannel(channel);
} else if (entry.metadata.type.toLowerCase() == 'show') {
} else if (entry.metadata.mediaType == PlexMediaType.show) {
// Show with upcoming episodes → show full schedule
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => LiveTvShowScheduleScreen(
showTitle: entry.metadata.title,
showTitle: entry.metadata.displayTitle,
serverId: entry.metadata.serverId ?? '',
channels: widget.channels,
),
File diff suppressed because it is too large Load Diff
+10 -6
View File
@@ -237,9 +237,9 @@ class _PlaylistItemCardState extends State<PlaylistItemCard> {
}
String _buildSubtitle() {
final itemType = widget.item.type.toLowerCase();
final itemType = widget.item.mediaType;
if (itemType == 'episode') {
if (itemType == PlexMediaType.episode) {
// For episodes, show "S#E# - Episode Title"
final season = widget.item.parentIndex;
final episode = widget.item.index;
@@ -247,12 +247,16 @@ class _PlaylistItemCardState extends State<PlaylistItemCard> {
return 'S${season}E$episode${widget.item.displaySubtitle != null ? ' - ${widget.item.displaySubtitle}' : ''}';
}
return widget.item.displaySubtitle ?? t.discover.tvShow;
} else if (itemType == 'movie') {
// For movies, show year
return widget.item.year?.toString() ?? t.discover.movie;
} else if (itemType == PlexMediaType.movie) {
// For movies, show year and edition
final year = widget.item.year?.toString();
if (year != null && widget.item.editionTitle != null) {
return '$year · ${widget.item.editionTitle}';
}
return year ?? t.discover.movie;
}
// Default to type
return widget.item.type;
return widget.item.mediaType.name;
}
}
File diff suppressed because it is too large Load Diff
+79 -29
View File
@@ -152,11 +152,13 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
StreamSubscription<Duration>? _positionSubscription;
StreamSubscription<void>? _playbackRestartSubscription;
StreamSubscription<void>? _backendSwitchedSubscription;
StreamSubscription<PlayerLog>? _logSubscription;
StreamSubscription<void>? _sleepTimerSubscription;
StreamSubscription<bool>? _mediaControlsPlayingSubscription;
StreamSubscription<Duration>? _mediaControlsPositionSubscription;
StreamSubscription<double>? _mediaControlsRateSubscription;
StreamSubscription<bool>? _mediaControlsSeekableSubscription;
StreamSubscription<Map<String, bool>>? _serverStatusSubscription;
bool _isReplacingWithVideo = false; // Flag to skip orientation restoration during video-to-video navigation
bool _isDisposingForNavigation = false;
bool _waitingForExternalSubsTrackSelection = false;
@@ -581,6 +583,11 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
// Listen to MPV errors
_errorSubscription = player!.streams.error.listen(_onPlayerError);
// Listen to error-level log messages for user-visible snackbars
_logSubscription = player!.streams.log
.where((log) => log.level == PlayerLogLevel.error || log.level == PlayerLogLevel.fatal)
.listen(_onPlayerLogError);
// Listen for backend switched event (ExoPlayer -> MPV fallback on Android)
if (Platform.isAndroid && useExoPlayer) {
_backendSwitchedSubscription = player!.streams.backendSwitched.listen((_) => _onBackendSwitched());
@@ -591,8 +598,28 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
_isBuffering.value = isBuffering;
});
// When server comes back online while buffering, force mpv to reconnect
// immediately instead of waiting for ffmpeg's exponential backoff
if (!widget.isOffline && !widget.isLive) {
final serverId = widget.metadata.serverId;
if (serverId != null) {
final serverManager = context.read<MultiServerProvider>().serverManager;
bool wasOffline = false;
_serverStatusSubscription = serverManager.statusStream.listen((statusMap) {
final isOnline = statusMap[serverId] == true;
if (!isOnline) {
wasOffline = true;
} else if (wasOffline && _isBuffering.value) {
wasOffline = false;
_forceStreamReconnect();
}
});
}
}
// Listen to playback restart to detect first frame ready
_playbackRestartSubscription = player!.streams.playbackRestart.listen((_) async {
_lastLogError = null;
if (!_hasFirstFrame.value) {
_hasFirstFrame.value = true;
Sentry.addBreadcrumb(Breadcrumb(message: 'First frame ready', category: 'player'));
@@ -742,37 +769,36 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
// Set up media control event handling
_mediaControlSubscription = _mediaControlsManager!.controlEvents.listen((event) {
final currentPlayer = player;
if (currentPlayer == null && event is! NextTrackEvent && event is! PreviousTrackEvent) return;
if (event is PlayEvent) {
appLogger.d('Media control: Play event received');
if (player != null) {
player!.play();
_wasPlayingBeforeInactive = false;
appLogger.d('Cleared _wasPlayingBeforeInactive due to manual play via media controls');
_updateMediaControlsPlaybackState();
}
currentPlayer!.play();
_wasPlayingBeforeInactive = false;
_updateMediaControlsPlaybackState();
} else if (event is PauseEvent) {
appLogger.d('Media control: Pause event received');
if (player != null) {
player!.pause();
appLogger.d('Video paused via media controls');
_updateMediaControlsPlaybackState();
currentPlayer!.pause();
_updateMediaControlsPlaybackState();
} else if (event is TogglePlayPauseEvent) {
appLogger.d('Media control: Toggle play/pause event received');
if (currentPlayer!.state.playing) {
currentPlayer.pause();
} else {
currentPlayer.play();
_wasPlayingBeforeInactive = false;
}
_updateMediaControlsPlaybackState();
} else if (event is SeekEvent) {
appLogger.d('Media control: Seek event received to ${event.position}');
final currentPlayer = player;
if (currentPlayer != null) {
unawaited(currentPlayer.seek(clampSeekPosition(currentPlayer, event.position)));
}
unawaited(currentPlayer!.seek(clampSeekPosition(currentPlayer, event.position)));
} else if (event is NextTrackEvent) {
appLogger.d('Media control: Next track event received');
if (_nextEpisode != null) {
_playNext();
}
if (_nextEpisode != null) _playNext();
} else if (event is PreviousTrackEvent) {
appLogger.d('Media control: Previous track event received');
if (_previousEpisode != null) {
_playPrevious();
}
if (_previousEpisode != null) _playPrevious();
}
});
@@ -1053,6 +1079,12 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
? Duration(milliseconds: widget.metadata.viewOffset!)
: null;
// Enable FFmpeg auto-reconnect for VOD streams (covers network drops up to 10 min)
if (!widget.isOffline && !widget.isLive) {
await player!.setProperty('stream-lavf-o',
'reconnect=1,reconnect_on_network_error=1,reconnect_streamed=1,reconnect_delay_max=600');
}
// If we have external subtitles, open paused to add them before playback starts.
// This prevents a race condition on Android where adding subtitle tracks
// during active playback can freeze the video decoder (issue #226).
@@ -1453,7 +1485,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
watchTogether.setCurrentMedia(
ratingKey: targetMetadata.ratingKey,
serverId: targetMetadata.serverId!,
mediaTitle: targetMetadata.title,
mediaTitle: targetMetadata.displayTitle,
);
}
} catch (e) {
@@ -1734,11 +1766,13 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
_positionSubscription?.cancel();
_playbackRestartSubscription?.cancel();
_backendSwitchedSubscription?.cancel();
_logSubscription?.cancel();
_sleepTimerSubscription?.cancel();
_mediaControlsPlayingSubscription?.cancel();
_mediaControlsPositionSubscription?.cancel();
_mediaControlsRateSubscription?.cancel();
_mediaControlsSeekableSubscription?.cancel();
_serverStatusSubscription?.cancel();
// Cancel auto-play timer
_autoPlayTimer?.cancel();
@@ -1893,9 +1927,16 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
void _onPlayerError(String error) {
appLogger.e('[Player ERROR] $error');
if (!mounted) return;
if (!mounted || _isExiting.value) return;
showGlobalErrorSnackBar(_lastLogError ?? error);
_handleBackButton();
}
showErrorSnackBar(context, t.messages.failedPlayback(action: 'play', error: error));
String? _lastLogError;
void _onPlayerLogError(PlayerLog log) {
appLogger.e('[Player LOG ERROR] [${log.prefix}] ${log.text}');
_lastLogError = log.text.trim();
}
/// Handle notification when native player switched from ExoPlayer to MPV
@@ -2061,16 +2102,24 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
}
}
/// Force mpv to reconnect its HTTP stream by seeking to the current position.
/// This bypasses ffmpeg's exponential reconnect backoff when the app detects
/// that network connectivity has been restored.
void _forceStreamReconnect() {
final p = player;
if (p == null || !_isPlayerInitialized) return;
final pos = p.state.position;
appLogger.i('Network restored while buffering, forcing stream reconnect at ${pos.inSeconds}s');
p.seek(pos);
}
/// Configure MPV/FFmpeg options for live streaming resilience.
/// Enables automatic reconnection on EOF and network errors.
Future<void> _setLiveStreamOptions() async {
final p = player!;
// FFmpeg HTTP protocol reconnection
await p.setProperty('stream-lavf-o-append', 'reconnect=1');
await p.setProperty('stream-lavf-o-append', 'reconnect_at_eof=1');
await p.setProperty('stream-lavf-o-append', 'reconnect_streamed=1');
await p.setProperty('stream-lavf-o-append', 'reconnect_on_network_error=1');
await p.setProperty('stream-lavf-o-append', 'reconnect_delay_max=30');
await p.setProperty('stream-lavf-o',
'reconnect=1,reconnect_at_eof=1,reconnect_streamed=1,reconnect_on_network_error=1,reconnect_delay_max=30');
// Demuxer: retry up to 1000 times on stream reload failures
await p.setProperty('demuxer-lavf-o', 'max_reload=1000');
await p.setProperty('force-seekable', 'no');
@@ -2132,6 +2181,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
_startLiveTimelineUpdates();
} catch (e) {
appLogger.e('Failed to switch channel', error: e);
if (mounted) showErrorSnackBar(context, e.toString());
} finally {
_isSwitchingChannel = false;
}
@@ -2848,7 +2898,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
)
else
Text(
_nextEpisode!.title,
_nextEpisode!.title!,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
+6 -6
View File
@@ -351,7 +351,7 @@ class DiscordRPCService {
timestamps: _buildTimestamps(),
statusDisplayType: DiscordStatusDisplayType.details,
largeAsset: _cachedThumbnailUrl != null
? DiscordAsset(url: _cachedThumbnailUrl!, text: metadata.grandparentTitle ?? metadata.title)
? DiscordAsset(url: _cachedThumbnailUrl!, text: metadata.grandparentTitle ?? metadata.title!)
: null,
),
);
@@ -392,14 +392,14 @@ class DiscordRPCService {
switch (metadata.mediaType) {
case PlexMediaType.movie:
final year = metadata.year != null ? ' (${metadata.year})' : '';
return metadata.title + year;
return metadata.title! + year;
case PlexMediaType.episode:
// Show: "Show Name" or just episode title if no show name
return metadata.grandparentTitle ?? metadata.title;
return metadata.grandparentTitle ?? metadata.title!;
default:
return metadata.title;
return metadata.title!;
}
}
@@ -411,9 +411,9 @@ class DiscordRPCService {
final season = metadata.parentIndex;
final episode = metadata.index;
if (season != null && episode != null) {
return 'S$season E$episode - ${metadata.title}';
return 'S$season E$episode - ${metadata.title!}';
}
return metadata.title;
return metadata.title!;
case PlexMediaType.movie:
return metadata.studio;
+18 -21
View File
@@ -341,7 +341,7 @@ class DownloadManagerService {
serverId: metadata.serverId!,
ratingKey: metadata.ratingKey,
globalKey: globalKey,
type: metadata.type,
type: metadata.type ?? '',
parentRatingKey: metadata.parentRatingKey,
grandparentRatingKey: metadata.grandparentRatingKey,
status: DownloadStatus.queued.index,
@@ -446,8 +446,8 @@ class DownloadManagerService {
// Build display name for notifications
final displayName = metadata.type == 'episode'
? '${metadata.grandparentTitle ?? metadata.title} - ${metadata.title}'
: metadata.title;
? '${metadata.grandparentTitle ?? metadata.displayTitle} - ${metadata.displayTitle}'
: metadata.displayTitle;
// Get WiFi-only setting for native enforcement
final settings = await SettingsService.getInstance();
@@ -1234,7 +1234,7 @@ class DownloadManagerService {
// Emit initial progress
_emitDeletionProgress(
DeletionProgress(globalKey: globalKey, itemTitle: metadata.title, currentItem: 0, totalItems: totalItems),
DeletionProgress(globalKey: globalKey, itemTitle: metadata.displayTitle, currentItem: 0, totalItems: totalItems),
);
// Delete files from storage (with progress updates)
@@ -1250,7 +1250,7 @@ class DownloadManagerService {
_emitDeletionProgress(
DeletionProgress(
globalKey: globalKey,
itemTitle: metadata.title,
itemTitle: metadata.displayTitle,
currentItem: totalItems,
totalItems: totalItems,
),
@@ -1264,17 +1264,14 @@ class DownloadManagerService {
/// Calculate total items to delete (for progress tracking)
Future<int> _getTotalItemsToDelete(PlexMetadata metadata, String _) async {
switch (metadata.type.toLowerCase()) {
case 'episode':
return 1; // Single episode
case 'movie':
return 1; // Single movie
case 'season':
// Count episodes in season
switch (metadata.mediaType) {
case PlexMediaType.episode:
case PlexMediaType.movie:
return 1;
case PlexMediaType.season:
final episodes = await _database.getEpisodesBySeason(metadata.ratingKey);
return episodes.length;
case 'show':
// Count all episodes in show
case PlexMediaType.show:
final episodes = await _database.getEpisodesByShow(metadata.ratingKey);
return episodes.length;
default:
@@ -1301,17 +1298,17 @@ class DownloadManagerService {
}
// Delete based on type
switch (metadata.type.toLowerCase()) {
case 'episode':
switch (metadata.mediaType) {
case PlexMediaType.episode:
await _deleteEpisodeFiles(metadata, serverId);
break;
case 'season':
case PlexMediaType.season:
await _deleteSeasonFiles(metadata, serverId);
break;
case 'show':
case PlexMediaType.show:
await _deleteShowFiles(metadata, serverId);
break;
case 'movie':
case PlexMediaType.movie:
await _deleteMovieFiles(metadata, serverId);
break;
default:
@@ -1447,7 +1444,7 @@ class DownloadManagerService {
episodes: episodesInSeason,
serverId: serverId,
parentKey: season.ratingKey,
parentTitle: season.title,
parentTitle: season.displayTitle,
);
final seasonDir = await _storageService.getSeasonDirectory(season, showYear: showYear);
@@ -1510,7 +1507,7 @@ class DownloadManagerService {
episodes: episodesInShow,
serverId: serverId,
parentKey: show.ratingKey,
parentTitle: show.title,
parentTitle: show.displayTitle,
);
final showDir = await _storageService.getShowDirectory(show);
+3 -3
View File
@@ -65,7 +65,7 @@ class DownloadStorageService {
String _formatEpisodeFileName(PlexMetadata episode) {
final season = padNumber(episode.parentIndex ?? 0, 2);
final ep = padNumber(episode.index ?? 0, 2);
final episodeName = _sanitizeFileName(episode.title);
final episodeName = _sanitizeFileName(episode.title!);
return 'S${season}E$ep - $episodeName';
}
@@ -246,13 +246,13 @@ class DownloadStorageService {
/// Get the folder name for a movie: "Movie Name (YYYY)"
String _getMovieFolderName(PlexMetadata movie) {
return _formatTitleWithYear(movie.title, movie.year);
return _formatTitleWithYear(movie.title!, movie.year);
}
/// Get the folder name for a TV show: "Show Name (YYYY)"
/// [showYear]: Pass explicitly for episodes (episode.year may differ from show's year)
String _getShowFolderName(PlexMetadata metadata, {int? showYear}) {
final title = metadata.grandparentTitle ?? metadata.title;
final title = metadata.grandparentTitle ?? metadata.title!;
final year = showYear ?? metadata.year;
return _formatTitleWithYear(title, year);
}
+1 -1
View File
@@ -11,7 +11,7 @@ import '../i18n/strings.g.dart';
import 'plex_client.dart';
import 'settings_service.dart';
const _externalPlayerChannel = MethodChannel('app.plezy/external_player');
const _externalPlayerChannel = MethodChannel('com.plezy/external_player');
class ExternalPlayerService {
/// Launch an external player with either a pre-resolved [videoUrl] (e.g. local
+1 -1
View File
@@ -53,7 +53,7 @@ class MediaControlsManager {
// Update OS media controls
await OsMediaControls.setMetadata(
MediaMetadata(
title: metadata.title,
title: metadata.title!,
artist: _buildArtist(metadata),
artworkUrl: artworkUrl,
duration: duration,
+71 -24
View File
@@ -38,6 +38,15 @@ class MultiServerManager {
/// Debounce timers for endpoint-exhaustion-triggered reconnection (per server)
final Map<String, Timer> _reconnectDebounce = {};
/// Coalescing guard for checkServerHealth — prevents concurrent health checks
Future<void>? _activeHealthCheck;
/// Coalescing guard for reconnectOfflineServers — prevents concurrent reconnect sweeps
Future<void>? _activeReconnect;
/// Debounce timer for connectivity events — collapses rapid network flapping
Timer? _connectivityDebounce;
/// Get all registered server IDs
List<String> get serverIds => _servers.keys.toList();
@@ -292,6 +301,18 @@ class MultiServerManager {
/// Uses [PlexClient.isHealthy] which checks for HTTP 200, so servers with
/// invalid tokens (401) are correctly reported as offline.
Future<void> checkServerHealth() async {
// Coalesce concurrent calls — return the in-flight future if one exists
if (_activeHealthCheck != null) return _activeHealthCheck!;
_activeHealthCheck = _doCheckServerHealth();
try {
await _activeHealthCheck;
} finally {
_activeHealthCheck = null;
}
}
Future<void> _doCheckServerHealth() async {
appLogger.d('Checking health for ${_clients.length} servers');
final healthChecks = _clients.entries.map((entry) async {
@@ -316,39 +337,51 @@ class MultiServerManager {
}
appLogger.i('Starting network monitoring for all servers');
final connectivity = Connectivity();
_connectivitySubscription = connectivity.onConnectivityChanged.listen(
(results) {
final status = results.isNotEmpty ? results.first : ConnectivityResult.none;
runZonedGuarded(() {
final connectivity = Connectivity();
_connectivitySubscription = connectivity.onConnectivityChanged.listen(
(results) {
final status = results.isNotEmpty ? results.first : ConnectivityResult.none;
if (status == ConnectivityResult.none) {
appLogger.w('Connectivity lost, pausing optimization until network returns');
return;
}
if (status == ConnectivityResult.none) {
appLogger.w('Connectivity lost, pausing optimization until network returns');
return;
}
appLogger.d(
'Connectivity change detected, re-optimizing all servers',
error: {
'status': status.name,
'interfaces': results.map((r) => r.name).toList(),
'serverCount': _servers.length,
},
);
// Debounce rapid connectivity events (e.g. WiFi flapping) into a single trigger
_connectivityDebounce?.cancel();
_connectivityDebounce = Timer(const Duration(seconds: 2), () {
_connectivityDebounce = null;
// Re-optimize all servers and re-probe offline ones
_reoptimizeAllServers(reason: 'connectivity:${status.name}');
checkServerHealth();
},
onError: (error, stackTrace) {
appLogger.w('Connectivity listener error', error: error, stackTrace: stackTrace);
},
);
appLogger.d(
'Connectivity change detected, re-optimizing all servers',
error: {
'status': status.name,
'interfaces': results.map((r) => r.name).toList(),
'serverCount': _servers.length,
},
);
// Re-optimize all servers and re-probe offline ones
_reoptimizeAllServers(reason: 'connectivity:${status.name}');
checkServerHealth();
});
},
onError: (error, stackTrace) {
appLogger.w('Connectivity listener error', error: error, stackTrace: stackTrace);
},
);
}, (error, stack) {
appLogger.w('Connectivity monitoring unavailable', error: error);
});
}
/// Stop monitoring network connectivity
void stopNetworkMonitoring() {
_connectivitySubscription?.cancel();
_connectivitySubscription = null;
_connectivityDebounce?.cancel();
_connectivityDebounce = null;
appLogger.i('Stopped network monitoring');
}
@@ -437,6 +470,18 @@ class MultiServerManager {
/// Attempt reconnection for all offline servers
Future<void> reconnectOfflineServers() async {
// Coalesce concurrent calls — return the in-flight future if one exists
if (_activeReconnect != null) return _activeReconnect!;
_activeReconnect = _doReconnectOfflineServers();
try {
await _activeReconnect;
} finally {
_activeReconnect = null;
}
}
Future<void> _doReconnectOfflineServers() async {
final offline = offlineServerIds;
if (offline.isEmpty) return;
@@ -497,6 +542,8 @@ class MultiServerManager {
timer.cancel();
}
_reconnectDebounce.clear();
_activeHealthCheck = null;
_activeReconnect = null;
_clients.clear();
_servers.clear();
_serverStatus.clear();
+1 -1
View File
@@ -5,7 +5,7 @@ import 'package:flutter/services.dart';
import 'package:plezy/i18n/strings.g.dart';
class PipService {
static const MethodChannel _channel = MethodChannel('app.plezy/pip');
static const MethodChannel _channel = MethodChannel('com.plezy/pip');
/// PiP is only implemented natively on Android, iOS, and macOS.
static bool get _isAvailable => Platform.isAndroid || Platform.isIOS || Platform.isMacOS;
+1 -8
View File
@@ -815,15 +815,8 @@ class PlexServer {
}
/// Returns true if the address is known to be unreachable from external
/// clients (Docker bridge networks, IPv6 link-local, or all-zeros).
/// clients (IPv6 link-local or all-zeros).
static bool _isUnreachableAddress(String address) {
// Docker bridge subnets (172.17.0.0/16, 172.18.0.0/16, etc.)
// Docker uses 172.17-31.x.x by default.
final dockerPattern = RegExp(r'^172\.(1[7-9]|2[0-9]|3[01])\.');
if (dockerPattern.hasMatch(address)) {
return true;
}
// IPv6 all-zeros (::) or link-local (fe80::)
final normalized = address.replaceAll('-', ':').toLowerCase();
if (normalized == '::' || normalized == '0000:0000:0000:0000:0000:0000:0000:0000') {
+30 -6
View File
@@ -954,7 +954,7 @@ class PlexClient {
/// Get chapters and markers from cached metadata or fetch if needed
/// Uses same cache key as other metadata methods for consistency
Future<PlaybackExtras> getPlaybackExtras(String ratingKey) async {
Future<PlaybackExtras> getPlaybackExtras(String ratingKey, {String? introPattern, String? creditsPattern}) async {
try {
final data = await _fetchWithCacheFirst<Map<String, dynamic>>(
cacheKey: '/library/metadata/$ratingKey',
@@ -964,7 +964,7 @@ class PlexClient {
parseResponse: (response) => response.data as Map<String, dynamic>?,
);
final metadataJson = _getFirstMetadataJsonFromData(data);
return _parsePlaybackExtrasFromMetadataJson(metadataJson);
return _parsePlaybackExtrasFromMetadataJson(metadataJson, introPattern: introPattern, creditsPattern: creditsPattern);
} catch (e) {
appLogger.w('Failed to get playback extras', error: e);
return PlaybackExtras(chapters: [], markers: []);
@@ -972,10 +972,12 @@ class PlexClient {
}
/// Parse PlaybackExtras from metadata JSON
PlaybackExtras _parsePlaybackExtrasFromMetadataJson(Map<String, dynamic>? metadataJson) {
PlaybackExtras _parsePlaybackExtrasFromMetadataJson(Map<String, dynamic>? metadataJson, {String? introPattern, String? creditsPattern}) {
return PlaybackExtras.withChapterFallback(
chapters: _parseChapters(metadataJson),
markers: _parseMarkers(metadataJson),
introPatternStr: introPattern,
creditsPatternStr: creditsPattern,
);
}
@@ -1224,6 +1226,19 @@ class PlexClient {
return response.data;
}
/// Get preferences for a library section.
///
/// Returns a map of setting id --> value for all settings in the library.
Future<Map<String, dynamic>> getLibrarySectionPrefs(String sectionId) async {
final response = await _dio.get('/library/sections/$sectionId/prefs');
final container = _getMediaContainer(response);
if (container == null) return {};
final settings = container['Setting'];
if (settings == null) return {};
final list = settings is List ? settings : [settings];
return {for (final s in list) s['id'] as String: s['value']};
}
/// Get sessions (currently playing)
Future<List<dynamic>> getSessions() async {
final response = await _dio.get('/status/sessions');
@@ -2165,8 +2180,8 @@ class PlexClient {
if (providers.isEmpty) return [];
final queryParams = <String, dynamic>{};
if (beginsAt != null) queryParams['beginsAt>'] = beginsAt;
if (endsAt != null) queryParams['endsAt<'] = endsAt;
if (beginsAt != null) queryParams['endsAt>'] = beginsAt;
if (endsAt != null) queryParams['beginsAt<'] = endsAt;
final allPrograms = <LiveTvProgram>[];
@@ -2339,6 +2354,13 @@ class PlexClient {
final container = _getMediaContainer(response);
if (container == null) return null;
final containerStatus = container['status'];
if (containerStatus != null && containerStatus != 0 && containerStatus != 200) {
final msg = container['message'] ?? 'Unknown error';
appLogger.w('Tune channel error: $msg (status: $containerStatus)');
throw Exception(msg);
}
// Metadata is nested: MediaSubscription[0].MediaGrabOperation[0].Metadata
Map<String, dynamic>? metadataJson;
final subscriptions = container['MediaSubscription'] as List?;
@@ -2350,13 +2372,15 @@ class PlexClient {
final nested = op['Metadata'];
if (nested is Map<String, dynamic>) {
metadataJson = nested;
} else if (nested is List && nested.isNotEmpty) {
metadataJson = nested.first as Map<String, dynamic>;
}
}
}
metadataJson ??= (container['Metadata'] as List?)?.firstOrNull as Map<String, dynamic>?;
if (metadataJson == null) {
appLogger.w('Tune channel: no metadata in response');
appLogger.w('Tune channel failed: ${container['message'] ?? 'no metadata'} (status: ${container['status']}, keys: ${container.keys.toList()})');
return null;
}
@@ -37,6 +37,7 @@ class ServerConnectionOrchestrator {
required OfflineWatchSyncService syncService,
String? clientIdentifier,
Duration timeout = ConnectionTimeouts.connectAll,
void Function(String serverId, bool success)? onServerStatus,
}) async {
appLogger.i('Connecting to ${servers.length} servers...');
@@ -44,6 +45,12 @@ class ServerConnectionOrchestrator {
servers,
clientIdentifier: clientIdentifier,
timeout: timeout,
onServerConnected: onServerStatus != null
? (serverId, _) => onServerStatus(serverId, true)
: null,
onServerFailed: onServerStatus != null
? (serverId, _) => onServerStatus(serverId, false)
: null,
);
PlexClient? firstClient;
+27
View File
@@ -57,6 +57,13 @@ class SettingsService extends BaseSharedPreferencesService {
static const String _keyAutoSkipIntro = 'auto_skip_intro';
static const String _keyAutoSkipCredits = 'auto_skip_credits';
static const String _keyAutoSkipDelay = 'auto_skip_delay';
static const String _keyIntroPattern = 'intro_pattern';
static const String _keyCreditsPattern = 'credits_pattern';
static const String defaultIntroPattern =
r'(?:^|\b)(?:intro(?:duction)?|opening)(?:\b|$)|^op(?:\s?\d+)?$';
static const String defaultCreditsPattern =
r'(?:^|\b)(?:outro|closing|credits?|ending)(?:\b|$)|^ed(?:\s?\d+)?$';
static const String _keyCustomDownloadPath = 'custom_download_path';
static const String _keyCustomDownloadPathType = 'custom_download_path_type';
static const String _keyDownloadOnWifiOnly = 'download_on_wifi_only';
@@ -894,6 +901,24 @@ class SettingsService extends BaseSharedPreferencesService {
return prefs.getInt(_keyAutoSkipDelay) ?? 5; // Default: 5 seconds
}
// Intro Pattern
Future<void> setIntroPattern(String value) async {
await prefs.setString(_keyIntroPattern, value);
}
String getIntroPattern() {
return prefs.getString(_keyIntroPattern) ?? defaultIntroPattern;
}
// Credits Pattern
Future<void> setCreditsPattern(String value) async {
await prefs.setString(_keyCreditsPattern, value);
}
String getCreditsPattern() {
return prefs.getString(_keyCreditsPattern) ?? defaultCreditsPattern;
}
// Custom Download Path
Future<void> setCustomDownloadPath(String? path, {String type = 'file'}) async {
if (path == null) {
@@ -1381,6 +1406,8 @@ class SettingsService extends BaseSharedPreferencesService {
'autoSkipIntro': getAutoSkipIntro(),
'autoSkipCredits': getAutoSkipCredits(),
'autoSkipDelay': getAutoSkipDelay(),
'introPattern': getIntroPattern(),
'creditsPattern': getCreditsPattern(),
};
}
}
+3 -3
View File
@@ -9,7 +9,7 @@ import 'settings_service.dart' show EpisodePosterMode;
/// Service for syncing Plex "On Deck" content to Android TV's Watch Next row.
class WatchNextService {
static const MethodChannel _channel = MethodChannel('app.plezy/watch_next');
static const MethodChannel _channel = MethodChannel('com.plezy/watch_next');
static final WatchNextService _instance = WatchNextService._internal();
factory WatchNextService() => _instance;
@@ -134,7 +134,7 @@ class WatchNextService {
title = item.grandparentTitle!;
episodeTitle = item.title;
} else {
title = item.title;
title = item.title!;
episodeTitle = null;
}
@@ -148,7 +148,7 @@ class WatchNextService {
'episodeTitle': episodeTitle,
'description': item.summary,
'posterUri': posterUri,
'type': item.type.toLowerCase(),
'type': item.mediaType.name,
'duration': item.duration ?? 0,
'lastPlaybackPosition': item.viewOffset ?? 0,
'lastEngagementTime': lastEngagementTime,
+1 -1
View File
@@ -86,7 +86,7 @@ String formatContentRating(String? contentRating) {
/// Extension on PlexMetadata for type checking convenience methods
extension PlexMetadataType on PlexMetadata {
String get _lowerType => type.toLowerCase();
String get _lowerType => type?.toLowerCase() ?? '';
bool get isShow => _lowerType == ContentTypes.show;
bool get isMovie => _lowerType == ContentTypes.movie;
+1 -1
View File
@@ -80,7 +80,7 @@ class DeletionNotifier extends BaseNotifier<DeletionEvent> {
ratingKey: metadata.ratingKey,
serverId: metadata.serverId ?? '',
parentChain: _buildParentChain(metadata),
mediaType: metadata.type,
mediaType: metadata.type ?? '',
leafCount: metadata.leafCount ?? 1,
isDownloadOnly: isDownloadOnly,
),

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