Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9efdd5a17d | ||
|
|
dd11e8d3b2 | ||
|
|
cde6e99a17 | ||
|
|
045e68ca10 | ||
|
|
4f736c555b | ||
|
|
642c49b1ea | ||
|
|
e13b16f13c | ||
|
|
1f9fa9ba4f | ||
|
|
849ef6efe5 | ||
|
|
e968a20f4c | ||
|
|
7a6b866c28 | ||
|
|
6ccbaed3fd | ||
|
|
d01628a262 | ||
|
|
675ac84486 | ||
|
|
e84dc93910 | ||
|
|
3d30ea1342 | ||
|
|
efd28d3ca3 | ||
|
|
8a28a2c398 | ||
|
|
c6fa6afa9b | ||
|
|
912ebc2f90 | ||
|
|
f04b49e260 | ||
|
|
a17458ff9b | ||
|
|
c062dd944e | ||
|
|
1a4516bdd1 | ||
|
|
5538e0627b | ||
|
|
7febfa9771 | ||
|
|
160be52f02 | ||
|
|
828d57af24 | ||
|
|
1f7fb2201c | ||
|
|
32ae51142f | ||
|
|
7014df7a0e | ||
|
|
cb0ed15c0c | ||
|
|
d4e9364d29 | ||
|
|
4681d60850 | ||
|
|
78052dc07f | ||
|
|
67c66ff0c1 | ||
|
|
d1bb99635a | ||
|
|
69092e3656 | ||
|
|
98138a4f35 | ||
|
|
25c7f325b6 | ||
|
|
5dbaf961b7 | ||
|
|
b4471fab49 | ||
|
|
307fd354d6 | ||
|
|
a3b5557185 | ||
|
|
3c491be690 | ||
|
|
580c1d82e5 | ||
|
|
4cc83006ec | ||
|
|
4954c9c569 | ||
|
|
8f1b40d143 | ||
|
|
d72153c25a | ||
|
|
666b59c293 | ||
|
|
7543a8c55b | ||
|
|
bfc7be2b80 | ||
|
|
8b2c68f3ae | ||
|
|
9aa019fe6f | ||
|
|
23a100afdc | ||
|
|
af2ac2c993 | ||
|
|
166e73cdaa | ||
|
|
73fe9e054f | ||
|
|
42e066288f | ||
|
|
682a04f047 | ||
|
|
29cb88e6e2 | ||
|
|
6daf77c890 | ||
|
|
0372840ee1 | ||
|
|
3c51760203 | ||
|
|
f3c735e3a1 | ||
|
|
7784cb7511 | ||
|
|
82b0de9223 | ||
|
|
a6852d716f | ||
|
|
92dfe5b41f | ||
|
|
16e96cdde0 | ||
|
|
61e4bda616 | ||
|
|
e69fab87d0 | ||
|
|
a3e738773b | ||
|
|
425957c804 |
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
@@ -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) {
|
||||
|
||||
@@ -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 @@
|
||||
<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 |
@@ -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 */,
|
||||
|
||||
@@ -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? {
|
||||
|
||||
@@ -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(
|
||||
|
||||
+1146
-409
File diff suppressed because it is too large
Load Diff
@@ -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) {
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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": "휴대폰으로 이 기기를 제어하세요",
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Сериалы",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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',
|
||||
|
||||
@@ -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
@@ -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' => '휴대폰으로 이 기기를 제어하세요',
|
||||
|
||||
@@ -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' => 'Nå',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'liveTv.today' => 'I dag',
|
||||
'liveTv.midnight' => 'Midnatt',
|
||||
'liveTv.overnight' => 'Natt',
|
||||
|
||||
+46
-34
@@ -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',
|
||||
|
||||
@@ -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ą',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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 => 'Kö';
|
||||
@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' => 'Kö',
|
||||
'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
@@ -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
@@ -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": "Kö",
|
||||
"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
@@ -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
@@ -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',
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = '']) {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user