commit 656499cf94ce45960841482952c88736622bae77 Author: admin1 Date: Tue Jul 14 11:11:36 2026 +0800 Initial commit diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..c71ea97 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,11 @@ +*.g.dart linguist-generated=true +*.freezed.dart linguist-generated=true +*.sh text eol=lf + +android/app/libs/*.aar binary -text linguist-generated=true + +linux/flutter/generated_plugin_registrant.cc text eol=lf +linux/flutter/generated_plugins.cmake text eol=lf +macos/Flutter/GeneratedPluginRegistrant.swift text eol=lf +windows/flutter/generated_plugin_registrant.cc text eol=lf +windows/flutter/generated_plugins.cmake text eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..caa4ece --- /dev/null +++ b/.gitignore @@ -0,0 +1,100 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# Local editor/tool state +.idea/ +.cursor/ +*.iml +.ace-tool/ +.vscode/ +.claude/index.json +.claude/settings.local.json +.claude/worktrees/ +.codex/config.toml +.takumi/ +.workflow/ +.codestable/ +/.omc/ +*.moc +node_modules/ +out/ +devtools_options.yaml + +# Screenshots & debug captures +flutter_*.png + +# Flutter/Dart/Pub related +**/.gradle/ +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +build/ +coverage/ + +# Platform generated artifacts +**/generated_plugin_registrant.* +**/generated_plugins.cmake +linux/flutter/ephemeral/ +macos/Flutter/ephemeral/ +macos/Pods/ +windows/flutter/ephemeral/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release + +# Android machine-specific / generated (do not commit) +/android/local.properties +**/GeneratedPluginRegistrant.* + +# Large binaries & build artifacts (prevent re-commit) +.media3-build/ +dist/ +**/target/ +*.pdb +*.rlib +*.rmeta +*.dll +*.dylib +*.so +*.7z +*.wasm +test_link/ + +# Local git worktrees +.worktrees/ + +# Superpowers brainstorm visual companion +.superpowers/ + +# Opencode project config may be committed; dependency/runtime output should not. +.opencode/node_modules/ +.opencode/images/ +.opencode/.cache/ + +# Windows reserved device name (created accidentally) +nul + +# 本地签名密钥,勿提交 +android/release-signing.json diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..5f7acde --- /dev/null +++ b/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "02085feb3f5d8a8156e5e28512b9d99351d510c0" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 02085feb3f5d8a8156e5e28512b9d99351d510c0 + base_revision: 02085feb3f5d8a8156e5e28512b9d99351d510c0 + - platform: android + create_revision: 02085feb3f5d8a8156e5e28512b9d99351d510c0 + base_revision: 02085feb3f5d8a8156e5e28512b9d99351d510c0 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/README.md b/README.md new file mode 100644 index 0000000..b1d1671 --- /dev/null +++ b/README.md @@ -0,0 +1,185 @@ +# SMPlayer + +一个 Emby 媒体服务器跨平台客户端。 + +目前主要面向 Windows / macOS +## 主要功能 + +- Emby 媒体库浏览:主页、媒体库、详情、搜索、收藏、播放记录、最近添加。 +- 播放器:倍速、音轨 / 字幕切换、字幕样式、画面比例、选集、全屏、桌面画中画。 +- 弹幕:兼容弹弹 play / danmu_api 风格 API,支持多源、自动匹配、手动搜索、过滤和样式调整。 +- 多服务器:可添加多个 Emby 服务器,支持本地备份 / 导入配置。 +- 扩展功能:可选 TMDB 详情 / 发现页、Trakt scrobble 和应用内更新。 + +## 技术栈 + +Flutter、Riverpod、Dio / Retrofit、fvp / libmdk、media_kit / mpv、media3 / ExoPlayer + FFmpeg(Android)、canvas_danmaku。 + +## 播放引擎 + +播放器内置三个可切换的解码 / 渲染引擎,可在"设置 → 播放设置 → 播放引擎"中选择: + +| 引擎 | 适用平台 | 解码路径 | 说明 | +|------|----------|----------|------| +| fvp (libmdk) | Windows / macOS / Android | 平台硬解 + libmdk 软解兜底 | `auto` 模式的主引擎;杜比视界 / ProRes 优先走它 | +| mpv (media_kit) | Windows / macOS / Android | mpv / libmpv | 兼容性次引擎;桌面 `auto` 模式备选 | +| media3 (ExoPlayer + FFmpeg) | Android only | MediaCodec 硬解视频 + FFmpeg 软解音频 | 专为补 Android 高规格音轨(DTS / TrueHD / EAC3);需自编译 AAR,见 `plugins/media3_engine/README.md` 与 `scripts/build_media3_ffmpeg.sh` | + +`auto` 模式在 Android 上默认 fvp、桌面默认 mpv;遇 DV / ProRes 强制 fvp。media3 仅在用户手动选中时启用,且桌面平台不会展示该选项(运行时也有路由层守卫降级到 mpv)。 + +## 下载 + +前往 GitHub Releases 页面下载发布版本(需自行配置仓库地址)。 + +当前自动发布流程主要提供 Windows x64 安装包。macOS 和 Android 可按下方开发说明自行构建。 + +## 项目结构 + +- `lib/core` - 合约、领域用例、仓储端口、存储和 Emby API 适配器。 +- `lib/features` - 首页、媒体库、详情、播放、搜索、收藏、设置等功能页面。 +- `lib/providers` - Riverpod 状态和依赖注入。 +- `lib/shared` - 可复用组件、主题和映射工具。 +- `macos`, `windows` - Flutter 桌面平台外壳。 +- `android` - Flutter Android 平台外壳。 +- `installer/`, `scripts/` - Windows Inno Setup 脚本和开发 / 发布辅助脚本。 +- `test` - Flutter 测试。 + +## 开发 + +### Script entry (`package.json`, npm-style) + +If you prefer `npm run ` over remembering each Flutter/PowerShell invocation, a thin +`package.json` maps familiar names onto the existing scripts. It is `private` (no publish, +no `node_modules` needed) — npm is only used as a task runner; the single source of truth +stays in `scripts/*.ps1`. + +```sh +npm run dev # -> scripts/dev.ps1 run (Windows smart run) +npm run dev:macos # -> flutter run -d macos +npm run gen # -> build_runner build --delete-conflicting-outputs +npm run watch # -> build_runner watch --delete-conflicting-outputs +npm run build:windows # -> flutter build windows --release +npm run build:macos # -> flutter build macos --release +npm run build:apk # -> flutter build apk --release +npm run test # -> flutter test +npm run analyze # -> flutter analyze +npm run clean # -> flutter clean +npm run pub # -> flutter pub get +npm run doctor # -> flutter doctor -v +npm run installer # -> scripts/dev.ps1 installer (Windows .exe) +npm run dmg # -> scripts/build_dmg.sh (macOS .dmg) +``` + +Pass-through args use the npm `--` separator; everything after it is forwarded to the +underlying script. To force `pub get` and run in release mode: + +```sh +npm run dev -- -Force -FlutterArgs --release # -> scripts/dev.ps1 run -Force -FlutterArgs --release +``` + +`dev`/`installer` shell out to PowerShell (Windows); `dmg` needs bash (macOS). The rest are +plain Flutter CLI calls and work on any platform. + +### Windows — unified entry (`scripts/dev.ps1`) + +One-click subcommand dispatcher. Delegates to `dev-run.ps1` / `build-installer.ps1` and wraps common Flutter tasks. On Windows, `run` prepares the project-pinned fvp/libmdk SDK and clears `FVP_DEPS_LATEST`, so local development never silently switches to a nightly dependency. + +```powershell +# Smart dev run (skips `pub get` when unchanged) +powershell -ExecutionPolicy Bypass -File scripts\dev.ps1 run + +# Force a clean re-download of the project-pinned fvp/mdk-sdk: +powershell -ExecutionPolicy Bypass -File scripts\dev.ps1 run -RefreshFvpDeps + +# Build .exe installer (Inno Setup 6 required, see below) +powershell -ExecutionPolicy Bypass -File scripts\dev.ps1 installer +powershell -ExecutionPolicy Bypass -File scripts\dev.ps1 installer -SkipFlutterBuild +powershell -ExecutionPolicy Bypass -File scripts\dev.ps1 installer -Version 1.0.1 + +# Other shortcuts +scripts\dev.ps1 build # flutter build windows --release +scripts\dev.ps1 gen # dart run build_runner build --delete-conflicting-outputs +scripts\dev.ps1 clean # flutter clean +scripts\dev.ps1 pub # flutter pub get +scripts\dev.ps1 analyze # flutter analyze +scripts\dev.ps1 doctor # flutter doctor -v +scripts\dev.ps1 help # full subcommand list +``` + +Pass-through args: +- `dev.ps1 run -Force -FlutterArgs --release` → forces `pub get` and runs Flutter in release mode. +- `dev.ps1 run -Device macos` → choose a different target. +- `dev.ps1 run -RefreshFvpDeps` → deletes the cached SDK/archive and re-downloads the project-pinned fvp/MDK version. + +Avoid using this for daily Windows development: + +```powershell +$env:FVP_DEPS_LATEST=1; flutter run -d windows +``` + +Use `scripts\dev.ps1 run` instead; it also skips `pub get` when dependencies are unchanged. + +### macOS / Android + +Use the Flutter CLI directly: + +```sh +flutter pub get +flutter run -d macos # macOS +npm run dev:android # Android (auto-detect emulator or device) +flutter build macos # macOS bundle +npm run build:apk # Android APK with project-pinned MDK +./scripts/build_dmg.sh # macOS .dmg packaging +``` + +> **Android 构建前置 — Media3 FFmpeg decoder AAR**:Android 端的 media3 引擎依赖 +> 自编译的 `media3-decoder-ffmpeg-1.10.1-arm64.aar`。仓库默认不提交该二进制, +> 因此首次构建 Android 之前需要运行 `scripts/build_media3_ffmpeg.sh` 生成产物 +> (NDK r26b、~30–60 分钟)。AAR 缺失时 plugin 的 `preBuild` 会 fail-fast 抛出 +> 包含恢复指令的错误。仅构建 Windows / macOS 桌面端时无需此步骤。详见 +> `plugins/media3_engine/README.md`。 + +## Windows Installer (`.exe`) + +Produces `build/installer/smplayer-setup--x64.exe` via Inno Setup 6. + +**Prerequisite (one-time)** — install Inno Setup: + +```powershell +winget install -e --id JRSoftware.InnoSetup +``` + +**Build:** + +```powershell +scripts\dev.ps1 installer # full pipeline (flutter build + ISCC) +scripts\dev.ps1 installer -SkipFlutterBuild # reuse existing build/ artifacts +``` + +Layout: + +``` +installer/ +├── sm_emby.iss Inno Setup script (DV/HDR engine DLLs + flutter_assets) +└── lang/ChineseSimplified.isl Bundled language file (avoids relying on system ISL) +``` + +The installer is **unsigned** — first launch triggers Windows SmartScreen ("More info → Run anyway"). Add `SignTool=` to `sm_emby.iss` once a `.pfx` certificate is available. + +## Validation + +```sh +flutter analyze +``` + +## Code generation + +`@freezed` / `@JsonSerializable` / `@RestApi` annotations require regenerating part files after changes: + +```sh +dart run build_runner build --delete-conflicting-outputs +# or +scripts\dev.ps1 gen +``` + +Generated `*.g.dart` / `*.freezed.dart` files **must be committed** (marked `linguist-generated=true` so GitHub diffs collapse them). diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..fef5c84 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,33 @@ +include: package:flutter_lints/flutter.yaml + +linter: + rules: + prefer_const_constructors: true + prefer_const_declarations: true + prefer_final_fields: true + prefer_final_locals: true + avoid_print: true + prefer_single_quotes: true + sort_child_properties_last: true + use_key_in_widget_constructors: true + sized_box_for_whitespace: true + prefer_is_empty: true + prefer_is_not_empty: true + unnecessary_lambdas: true + always_declare_return_types: true + +analyzer: + exclude: + # Generated build output can contain partial package snapshots that are not + # valid standalone Dart packages and must not participate in app analysis. + - build/** + # test_link/ is a third-party Flutter example project (media_kit/fvp), not part of sm-emby. + - test_link/** + errors: + invalid_annotation_target: ignore + # Emby DTO fields intentionally mirror upstream JSON casing. + non_constant_identifier_names: ignore + # Current stable Flutter still accepts these APIs; migrate during the next UI pass. + deprecated_member_use: ignore + # Keep collection conditionals compatible with the existing code style. + use_null_aware_elements: ignore diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..b35995f --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,108 @@ +import java.util.Properties + +plugins { + id("com.android.application") + id("kotlin-android") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +val localProperties = Properties().apply { + val localPropertiesFile = rootProject.file("local.properties") + if (localPropertiesFile.exists()) { + localPropertiesFile.inputStream().use { load(it) } + } +} +val developmentVersionCode = System.getenv("SMPLAYER_ANDROID_VERSION_CODE")?.let { rawVersionCode -> + rawVersionCode.toIntOrNull()?.takeIf { versionCode -> versionCode > 0 } + ?: error("SMPLAYER_ANDROID_VERSION_CODE must be a positive integer") +} +val flutterVersionCode = developmentVersionCode + ?: localProperties.getProperty("flutter.versionCode")?.toIntOrNull() + ?: 1 +val flutterVersionName = localProperties.getProperty("flutter.versionName") ?: "1.0.0" + +val keystorePropertiesFile = rootProject.file("key.properties") +val keystoreProperties = Properties().apply { + if (keystorePropertiesFile.exists()) { + keystorePropertiesFile.inputStream().use { load(it) } + } +} + +android { + namespace = "com.smplayer.smplayer" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } + + defaultConfig { + applicationId = "com.smplayer.smplayer" + // fvp(libmdk) 与 media_kit(libmpv) 在 Android 上的硬解基线。 + minSdk = maxOf(flutter.minSdkVersion, 24) + targetSdk = flutter.targetSdkVersion + versionCode = flutterVersionCode + versionName = flutterVersionName + // ABI 限制策略:见下方 buildTypes。defaultConfig 不收缩,保留多 ABI 让 + // debug 在 x86_64 emulator 上仍能加载 fvp libmdk.so / media_kit libmpv.so; + // release 包仅 arm64-v8a 以匹配 media3 FFmpeg AAR 并控制包体。 + } + + signingConfigs { + if (keystorePropertiesFile.exists()) { + create("release") { + storeFile = file(keystoreProperties["storeFile"] as String) + storePassword = keystoreProperties["storePassword"] as String + keyAlias = keystoreProperties["keyAlias"] as String + keyPassword = keystoreProperties["keyPassword"] as String + } + } + } + + // 本地 debug 在没有 key.properties 时使用 Android debug 签名;release 构建 + // 绝不回退到临时 debug 证书,避免发布无法覆盖安装的 APK。 + val releaseSigningConfig = if (keystorePropertiesFile.exists()) { + signingConfigs.getByName("release") + } else { + null + } + + buildTypes { + debug { + applicationIdSuffix = ".dev" + manifestPlaceholders["appLabel"] = "smplayer-dev" + signingConfig = releaseSigningConfig ?: signingConfigs.getByName("debug") + } + release { + manifestPlaceholders["appLabel"] = "smplayer" + if (releaseSigningConfig != null) { + signingConfig = releaseSigningConfig + } + ndk { + // 仅 release 收缩到 arm64-v8a:与 media3_engine plugin 自编译的 + // FFmpeg decoder AAR 对齐(其只含 arm64-v8a),包体最小化。 + // debug 不限制 → x86_64 / arm64 emulator 上 fvp / media_kit 仍可运行。 + abiFilters += listOf("arm64-v8a") + } + } + } +} + +flutter { + source = "../.." +} + +// media3_engine plugin 把 media3-decoder-ffmpeg-*.aar 标为 compileOnly(AGP 8 +// 禁止 library module 直接 implementation 本地 .aar)。在 app 这一层把所有本地 +// .aar 实际打入 APK;目前只有 media3 FFmpeg decoder AAR,使用 include 通配避免 +// 后续新增 AAR 时再改这里。 +dependencies { + implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.aar")))) +} diff --git a/android/app/libs/CHECKSUMS.txt b/android/app/libs/CHECKSUMS.txt new file mode 100644 index 0000000..b1afb41 --- /dev/null +++ b/android/app/libs/CHECKSUMS.txt @@ -0,0 +1,11 @@ +# Media3 FFmpeg decoder AAR 校验清单。 +# +# 格式: <文件名> +# +# 由 scripts/build_media3_ffmpeg.sh 产出 AAR 后维护此文件(自动覆写对应文件名条目)。 +# Gradle 任务 `verifyMedia3FfmpegAar` 在 preBuild 前会读取此清单 + 计算 AAR 实际 +# SHA-256,不一致即 fail-fast(见 plugins/media3_engine/android/build.gradle.kts)。 +# +# 升级版本时同时更新下方文件名(含 media3 tag)与 SHA-256。 + +8e4979b0b9f1408314b149a9740310034238cb80f55167f388f5dbc310e8229e media3-decoder-ffmpeg-1.10.1-arm64.aar diff --git a/android/app/libs/media3-decoder-ffmpeg-1.10.1-arm64.aar b/android/app/libs/media3-decoder-ffmpeg-1.10.1-arm64.aar new file mode 100644 index 0000000..584ee41 Binary files /dev/null and b/android/app/libs/media3-decoder-ffmpeg-1.10.1-arm64.aar differ diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..576e135 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/com/smplayer/smplayer/AndroidTlsBridge.kt b/android/app/src/main/kotlin/com/smplayer/smplayer/AndroidTlsBridge.kt new file mode 100644 index 0000000..80db6e3 --- /dev/null +++ b/android/app/src/main/kotlin/com/smplayer/smplayer/AndroidTlsBridge.kt @@ -0,0 +1,109 @@ +package com.smplayer.smplayer + +import android.content.Context +import android.os.Handler +import android.os.Looper +import android.util.Base64 +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodChannel +import java.io.File +import java.security.KeyStore +import java.security.cert.X509Certificate +import java.util.concurrent.Executors + + +class AndroidTlsBridge( + private val context: Context, + flutterEngine: FlutterEngine, +) { + companion object { + private const val CHANNEL_NAME = "smplayer/android_tls" + private const val CA_BUNDLE_FILE_NAME = "android-trusted-ca-bundle.pem" + } + + private val mainHandler = Handler(Looper.getMainLooper()) + private val worker = Executors.newSingleThreadExecutor() + private val methodChannel = MethodChannel( + flutterEngine.dartExecutor.binaryMessenger, + CHANNEL_NAME, + ) + + @Volatile + private var disposed = false + + init { + methodChannel.setMethodCallHandler { call, result -> + when (call.method) { + "exportTrustedCaBundle" -> worker.execute { + try { + val bundlePath = exportTrustedCaBundle() + postResult { result.success(bundlePath) } + } catch (exception: Exception) { + postResult { + result.error( + "CA_EXPORT_FAILED", + exception.message ?: "Failed to export Android trusted CAs", + null, + ) + } + } + } + + else -> result.notImplemented() + } + } + } + + private fun exportTrustedCaBundle(): String { + val trustStore = KeyStore.getInstance("AndroidCAStore").apply { + load(null) + } + val encodedCertificates = linkedSetOf() + val aliases = trustStore.aliases().toList().sorted() + + for (alias in aliases) { + val certificate = trustStore.getCertificate(alias) as? X509Certificate + ?: continue + encodedCertificates += Base64.encodeToString( + certificate.encoded, + Base64.NO_WRAP, + ) + } + + check(encodedCertificates.isNotEmpty()) { + "AndroidCAStore contained no X.509 trust anchors" + } + + val outputFile = File(context.cacheDir, CA_BUNDLE_FILE_NAME) + val temporaryFile = File(context.cacheDir, "$CA_BUNDLE_FILE_NAME.tmp") + temporaryFile.bufferedWriter(Charsets.US_ASCII).use { writer -> + for (encodedCertificate in encodedCertificates) { + writer.appendLine("-----BEGIN CERTIFICATE-----") + encodedCertificate.chunked(64).forEach(writer::appendLine) + writer.appendLine("-----END CERTIFICATE-----") + } + } + + if (outputFile.exists() && !outputFile.delete()) { + throw IllegalStateException("Could not replace cached CA bundle") + } + if (!temporaryFile.renameTo(outputFile)) { + temporaryFile.copyTo(outputFile, overwrite = true) + temporaryFile.delete() + } + return outputFile.absolutePath + } + + private fun postResult(callback: () -> Unit) { + if (disposed) return + mainHandler.post { + if (!disposed) callback() + } + } + + fun dispose() { + disposed = true + methodChannel.setMethodCallHandler(null) + worker.shutdownNow() + } +} diff --git a/android/app/src/main/kotlin/com/smplayer/smplayer/ApkInstallerBridge.kt b/android/app/src/main/kotlin/com/smplayer/smplayer/ApkInstallerBridge.kt new file mode 100644 index 0000000..a96dbc4 --- /dev/null +++ b/android/app/src/main/kotlin/com/smplayer/smplayer/ApkInstallerBridge.kt @@ -0,0 +1,80 @@ +package com.smplayer.smplayer + +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.core.content.FileProvider +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodChannel +import java.io.File + + +class ApkInstallerBridge( + private val context: Context, + flutterEngine: FlutterEngine, +) { + companion object { + private const val CHANNEL_NAME = "smplayer/apk_installer" + } + + private val methodChannel = MethodChannel( + flutterEngine.dartExecutor.binaryMessenger, CHANNEL_NAME + ) + + init { + methodChannel.setMethodCallHandler { call, result -> + when (call.method) { + "installApk" -> { + val filePath = call.argument("path") + if (filePath.isNullOrEmpty()) { + result.error("INVALID_PATH", "APK file path is null or empty", null) + return@setMethodCallHandler + } + try { + launchApkInstall(filePath) + result.success(true) + } catch (exception: Exception) { + result.error( + "INSTALL_FAILED", + exception.message ?: "Failed to launch APK installer", + null, + ) + } + } + + "canRequestInstall" -> { + val canRequest = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.packageManager.canRequestPackageInstalls() + } else { + true + } + result.success(canRequest) + } + + else -> result.notImplemented() + } + } + } + + private fun launchApkInstall(filePath: String) { + val apkFile = File(filePath) + if (!apkFile.exists()) { + throw IllegalArgumentException("APK file does not exist: $filePath") + } + + val authority = "${context.packageName}.fileprovider" + val contentUri = FileProvider.getUriForFile(context, authority, apkFile) + + val installIntent = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(contentUri, "application/vnd.android.package-archive") + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + + context.startActivity(installIntent) + } + + fun dispose() { + methodChannel.setMethodCallHandler(null) + } +} diff --git a/android/app/src/main/kotlin/com/smplayer/smplayer/MainActivity.kt b/android/app/src/main/kotlin/com/smplayer/smplayer/MainActivity.kt new file mode 100644 index 0000000..0ec0433 --- /dev/null +++ b/android/app/src/main/kotlin/com/smplayer/smplayer/MainActivity.kt @@ -0,0 +1,71 @@ +package com.smplayer.smplayer + +import android.content.res.Configuration +import android.media.AudioManager +import android.os.Bundle +import android.view.KeyEvent +import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine + +class MainActivity : FlutterActivity() { + private var systemAudio: SystemAudioBridge? = null + private var pipBridge: PipBridge? = null + private var apkInstaller: ApkInstallerBridge? = null + private var androidTls: AndroidTlsBridge? = null + + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + systemAudio = SystemAudioBridge(applicationContext, flutterEngine) + pipBridge = PipBridge(this, flutterEngine) + apkInstaller = ApkInstallerBridge(applicationContext, flutterEngine) + androidTls = AndroidTlsBridge(applicationContext, flutterEngine) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + volumeControlStream = AudioManager.STREAM_MUSIC + } + + override fun dispatchKeyEvent(event: KeyEvent): Boolean { + val bridge = systemAudio + if (bridge != null && bridge.keyInterceptEnabled) { + val isVolumeUp = event.keyCode == KeyEvent.KEYCODE_VOLUME_UP + val isVolumeDown = event.keyCode == KeyEvent.KEYCODE_VOLUME_DOWN + if (isVolumeUp || isVolumeDown) { + if (event.action == KeyEvent.ACTION_DOWN) { + bridge.adjustVolume( + if (isVolumeUp) 1 else -1, + showSystemUi = false, + ) + } + return true + } + } + return super.dispatchKeyEvent(event) + } + + override fun onUserLeaveHint() { + super.onUserLeaveHint() + pipBridge?.onUserLeaveHint() + } + + override fun onPictureInPictureModeChanged( + isInPictureInPictureMode: Boolean, + newConfig: Configuration, + ) { + super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig) + pipBridge?.onPipModeChanged(isInPictureInPictureMode) + } + + override fun onDestroy() { + pipBridge?.dispose() + pipBridge = null + apkInstaller?.dispose() + apkInstaller = null + androidTls?.dispose() + androidTls = null + systemAudio?.dispose() + systemAudio = null + super.onDestroy() + } +} diff --git a/android/app/src/main/kotlin/com/smplayer/smplayer/PipBridge.kt b/android/app/src/main/kotlin/com/smplayer/smplayer/PipBridge.kt new file mode 100644 index 0000000..8bd8692 --- /dev/null +++ b/android/app/src/main/kotlin/com/smplayer/smplayer/PipBridge.kt @@ -0,0 +1,325 @@ +package com.smplayer.smplayer + +import android.app.Activity +import android.app.PictureInPictureParams +import android.app.RemoteAction +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.res.Configuration +import android.graphics.drawable.Icon +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.util.Rational +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MethodChannel + +class PipBridge( + private val activity: Activity, + flutterEngine: FlutterEngine, +) { + companion object { + private const val METHOD_CHANNEL = "smplayer/pip" + private const val EVENT_CHANNEL = "smplayer/pip_events" + + private const val ACTION_PLAY = "com.smplayer.pip.PLAY" + private const val ACTION_PAUSE = "com.smplayer.pip.PAUSE" + private const val ACTION_NEXT = "com.smplayer.pip.NEXT" + private const val ACTION_PREV = "com.smplayer.pip.PREV" + private const val ACTION_SEEK_FWD = "com.smplayer.pip.SEEK_FWD" + private const val ACTION_SEEK_BWD = "com.smplayer.pip.SEEK_BWD" + + private const val REQUEST_PLAY = 1 + private const val REQUEST_PAUSE = 2 + private const val REQUEST_NEXT = 3 + private const val REQUEST_PREV = 4 + private const val REQUEST_SEEK_FWD = 5 + private const val REQUEST_SEEK_BWD = 6 + } + + private val methodChannel = MethodChannel( + flutterEngine.dartExecutor.binaryMessenger, METHOD_CHANNEL + ) + private val eventChannel = EventChannel( + flutterEngine.dartExecutor.binaryMessenger, EVENT_CHANNEL + ) + + private var eventSink: EventChannel.EventSink? = null + private val mainHandler = Handler(Looper.getMainLooper()) + + private var autoEnterOnLeave = false + private var aspectX = 16 + private var aspectY = 9 + private var isPlaying = false + private var hasNext = false + private var hasPrev = false + private var useEpisodeActions = true + + private val actionReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + val action = intent?.action ?: return + val dartAction = when (action) { + ACTION_PLAY -> "play" + ACTION_PAUSE -> "pause" + ACTION_NEXT -> "next" + ACTION_PREV -> "prev" + ACTION_SEEK_FWD -> "seekForward" + ACTION_SEEK_BWD -> "seekBackward" + else -> return + } + mainHandler.post { + eventSink?.success(mapOf("type" to "action", "action" to dartAction)) + } + } + } + + private var receiverRegistered = false + + init { + methodChannel.setMethodCallHandler { call, result -> + when (call.method) { + "isSupported" -> { + result.success(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) + } + + "isInPipMode" -> { + result.success( + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) + activity.isInPictureInPictureMode + else false + ) + } + + "configure" -> { + val args = call.arguments as? Map<*, *> + if (args != null) { + aspectX = (args["aspectX"] as? Number)?.toInt() ?: 16 + aspectY = (args["aspectY"] as? Number)?.toInt() ?: 9 + autoEnterOnLeave = args["autoEnterOnLeave"] as? Boolean ?: false + isPlaying = args["isPlaying"] as? Boolean ?: false + hasNext = args["hasNext"] as? Boolean ?: false + hasPrev = args["hasPrev"] as? Boolean ?: false + useEpisodeActions = args["useEpisodeActions"] as? Boolean ?: true + updatePipParams() + } + result.success(true) + } + + "enterPip" -> { + val args = call.arguments as? Map<*, *> + if (args != null) { + aspectX = (args["aspectX"] as? Number)?.toInt() ?: aspectX + aspectY = (args["aspectY"] as? Number)?.toInt() ?: aspectY + } + result.success(enterPip()) + } + + "updatePlaybackState" -> { + val args = call.arguments as? Map<*, *> + if (args != null) { + isPlaying = args["isPlaying"] as? Boolean ?: isPlaying + hasNext = args["hasNext"] as? Boolean ?: hasNext + hasPrev = args["hasPrev"] as? Boolean ?: hasPrev + useEpisodeActions = args["useEpisodeActions"] as? Boolean ?: useEpisodeActions + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && + activity.isInPictureInPictureMode + ) { + updatePipParams() + } + } + result.success(true) + } + + else -> result.notImplemented() + } + } + + eventChannel.setStreamHandler(object : EventChannel.StreamHandler { + override fun onListen(arguments: Any?, sink: EventChannel.EventSink?) { + eventSink = sink + registerActionReceiver() + } + + override fun onCancel(arguments: Any?) { + eventSink = null + unregisterActionReceiver() + } + }) + } + + private fun registerActionReceiver() { + if (receiverRegistered) return + val filter = IntentFilter().apply { + addAction(ACTION_PLAY) + addAction(ACTION_PAUSE) + addAction(ACTION_NEXT) + addAction(ACTION_PREV) + addAction(ACTION_SEEK_FWD) + addAction(ACTION_SEEK_BWD) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + activity.registerReceiver(actionReceiver, filter, Context.RECEIVER_NOT_EXPORTED) + } else { + activity.registerReceiver(actionReceiver, filter) + } + receiverRegistered = true + } + + private fun unregisterActionReceiver() { + if (!receiverRegistered) return + try { + activity.unregisterReceiver(actionReceiver) + } catch (_: IllegalArgumentException) {} + receiverRegistered = false + } + + private fun buildRemoteActions(): List { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return emptyList() + + val actions = mutableListOf() + + if (useEpisodeActions && (hasNext || hasPrev)) { + + actions.add( + makeAction( + android.R.drawable.ic_media_previous, + "上一集", + ACTION_PREV, + REQUEST_PREV, + enabled = hasPrev, + ) + ) + actions.add( + if (isPlaying) makeAction( + android.R.drawable.ic_media_pause, + "暂停", + ACTION_PAUSE, + REQUEST_PAUSE, + ) else makeAction( + android.R.drawable.ic_media_play, + "播放", + ACTION_PLAY, + REQUEST_PLAY, + ) + ) + actions.add( + makeAction( + android.R.drawable.ic_media_next, + "下一集", + ACTION_NEXT, + REQUEST_NEXT, + enabled = hasNext, + ) + ) + } else { + + actions.add( + makeAction( + android.R.drawable.ic_media_rew, + "快退", + ACTION_SEEK_BWD, + REQUEST_SEEK_BWD, + ) + ) + actions.add( + if (isPlaying) makeAction( + android.R.drawable.ic_media_pause, + "暂停", + ACTION_PAUSE, + REQUEST_PAUSE, + ) else makeAction( + android.R.drawable.ic_media_play, + "播放", + ACTION_PLAY, + REQUEST_PLAY, + ) + ) + actions.add( + makeAction( + android.R.drawable.ic_media_ff, + "快进", + ACTION_SEEK_FWD, + REQUEST_SEEK_FWD, + ) + ) + } + + return actions + } + + private fun makeAction( + iconRes: Int, + title: String, + action: String, + requestCode: Int, + enabled: Boolean = true, + ): RemoteAction { + val intent = Intent(action).setPackage(activity.packageName) + val flags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + val pendingIntent = PendingIntent.getBroadcast(activity, requestCode, intent, flags) + val icon = Icon.createWithResource(activity, iconRes) + return RemoteAction(icon, title, title, pendingIntent).apply { + isEnabled = enabled + } + } + + private fun buildPipParams(): PictureInPictureParams? { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return null + + val builder = PictureInPictureParams.Builder() + .setAspectRatio(Rational(aspectX, aspectY)) + .setActions(buildRemoteActions()) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + builder.setAutoEnterEnabled(autoEnterOnLeave && isPlaying) + builder.setSeamlessResizeEnabled(true) + } + + return builder.build() + } + + private fun updatePipParams() { + val params = buildPipParams() ?: return + try { + activity.setPictureInPictureParams(params) + } catch (e: Exception) { + + } + } + + fun enterPip(): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return false + val params = buildPipParams() ?: return false + return try { + activity.enterPictureInPictureMode(params) + true + } catch (e: Exception) { + false + } + } + + + fun onUserLeaveHint() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) return + if (!autoEnterOnLeave || !isPlaying) return + enterPip() + } + + + fun onPipModeChanged(isInPip: Boolean) { + mainHandler.post { + eventSink?.success(mapOf("type" to "modeChanged", "isInPip" to isInPip)) + } + } + + fun dispose() { + unregisterActionReceiver() + methodChannel.setMethodCallHandler(null) + eventChannel.setStreamHandler(null) + eventSink = null + } +} diff --git a/android/app/src/main/kotlin/com/smplayer/smplayer/SystemAudioBridge.kt b/android/app/src/main/kotlin/com/smplayer/smplayer/SystemAudioBridge.kt new file mode 100644 index 0000000..89cb19d --- /dev/null +++ b/android/app/src/main/kotlin/com/smplayer/smplayer/SystemAudioBridge.kt @@ -0,0 +1,123 @@ +package com.smplayer.smplayer + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.media.AudioManager +import android.os.Handler +import android.os.Looper +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MethodChannel + +class SystemAudioBridge( + private val context: Context, + flutterEngine: FlutterEngine, +) { + companion object { + private const val METHOD_CHANNEL = "smplayer/system_audio" + private const val EVENT_CHANNEL = "smplayer/system_audio_events" + private const val VOLUME_CHANGED_ACTION = "android.media.VOLUME_CHANGED_ACTION" + private const val STREAM_TYPE = AudioManager.STREAM_MUSIC + } + + private val audioManager: AudioManager = + context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + + private val methodChannel = MethodChannel( + flutterEngine.dartExecutor.binaryMessenger, METHOD_CHANNEL + ) + private val eventChannel = EventChannel( + flutterEngine.dartExecutor.binaryMessenger, EVENT_CHANNEL + ) + + private var eventSink: EventChannel.EventSink? = null + private val mainHandler = Handler(Looper.getMainLooper()) + + @Volatile + var keyInterceptEnabled: Boolean = false + private set + + private val volumeReceiver = object : BroadcastReceiver() { + override fun onReceive(ctx: Context?, intent: Intent?) { + if (intent?.action == VOLUME_CHANGED_ACTION) { + val streamType = intent.getIntExtra( + "android.media.EXTRA_VOLUME_STREAM_TYPE", -1 + ) + if (streamType == STREAM_TYPE) { + postVolumeEvent() + } + } + } + } + + init { + methodChannel.setMethodCallHandler { call, result -> + when (call.method) { + "getVolume" -> result.success(currentVolumeMap()) + "adjustVolume" -> { + val delta = (call.argument("delta") ?: 0) + adjustVolume(delta, showSystemUi = false) + result.success(currentVolumeMap()) + } + "setKeyIntercept" -> { + keyInterceptEnabled = call.argument("enabled") ?: false + result.success(null) + } + else -> result.notImplemented() + } + } + eventChannel.setStreamHandler(object : EventChannel.StreamHandler { + override fun onListen(arguments: Any?, sink: EventChannel.EventSink?) { + eventSink = sink + context.registerReceiver( + volumeReceiver, IntentFilter(VOLUME_CHANGED_ACTION) + ) + } + + override fun onCancel(arguments: Any?) { + try { + context.unregisterReceiver(volumeReceiver) + } catch (_: IllegalArgumentException) { + } + eventSink = null + } + }) + } + + fun adjustVolume(delta: Int, showSystemUi: Boolean) { + val direction = when { + delta > 0 -> AudioManager.ADJUST_RAISE + delta < 0 -> AudioManager.ADJUST_LOWER + else -> return + } + val flag = if (showSystemUi) AudioManager.FLAG_SHOW_UI else 0 + repeat(kotlin.math.abs(delta).coerceAtMost(20)) { + audioManager.adjustStreamVolume(STREAM_TYPE, direction, flag) + } + postVolumeEvent() + } + + private fun currentVolumeMap(): Map = mapOf( + "current" to audioManager.getStreamVolume(STREAM_TYPE), + "max" to audioManager.getStreamMaxVolume(STREAM_TYPE), + ) + + private fun postVolumeEvent() { + val payload = currentVolumeMap() + mainHandler.post { + eventSink?.success(payload) + } + } + + fun dispose() { + try { + context.unregisterReceiver(volumeReceiver) + } catch (_: IllegalArgumentException) { + } + methodChannel.setMethodCallHandler(null) + eventChannel.setStreamHandler(null) + eventSink = null + } +} diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..d6a5dbe Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..d157fd2 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..1c0cb7c Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..e0006af Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..0cc3e4b Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/main/res/xml/file_provider_paths.xml b/android/app/src/main/res/xml/file_provider_paths.xml new file mode 100644 index 0000000..83db953 --- /dev/null +++ b/android/app/src/main/res/xml/file_provider_paths.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..a0aac2d --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,8 @@ +org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=512m -XX:ReservedCodeCacheSize=256m +# 让 Gradle 直连、不读 macOS 系统代理,避免 Reqable/Charles 等抓包代理残留导致依赖下载挂起或连接被拒 +systemProp.java.net.useSystemProxies=false +android.useAndroidX=true +# This builtInKotlin flag was added automatically by Flutter migrator +android.builtInKotlin=false +# This newDsl flag was added automatically by Flutter migrator +android.newDsl=false diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e4ef43f --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip diff --git a/android/key.properties.example b/android/key.properties.example new file mode 100644 index 0000000..e71da62 --- /dev/null +++ b/android/key.properties.example @@ -0,0 +1,14 @@ +# 本地 release 签名配置。 +# 复制为 key.properties 并填入你的 keystore 信息。 +# key.properties 已被 .gitignore 忽略,不会提交到仓库。 +# +# 生成 keystore: +# keytool -genkey -v -keystore release.keystore -alias smplayer \ +# -keyalg RSA -keysize 2048 -validity 10000 +# +# storeFile 路径相对于 android/app/ 目录解析。 + +storeFile=release.keystore +storePassword= +keyAlias=smplayer +keyPassword= diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..ca7fe06 --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "8.11.1" apply false + id("org.jetbrains.kotlin.android") version "2.2.20" apply false +} + +include(":app") diff --git a/assets/fonts/NotoSansSC.ttf b/assets/fonts/NotoSansSC.ttf new file mode 100644 index 0000000..fb0637b Binary files /dev/null and b/assets/fonts/NotoSansSC.ttf differ diff --git a/assets/icon/app_icon.png b/assets/icon/app_icon.png new file mode 100644 index 0000000..f7eda79 Binary files /dev/null and b/assets/icon/app_icon.png differ diff --git a/assets/js/README.md b/assets/js/README.md new file mode 100644 index 0000000..3c2417f --- /dev/null +++ b/assets/js/README.md @@ -0,0 +1,29 @@ +# assets/js + +## cheerio.min.js + +打包好的 [cheerio](https://github.com/cheeriojs/cheerio)(含 parse5 解析器),注入到 +fjs(QuickJS)脚本组件运行环境,作为 `Widget.html.load` / `Widget.dom.*` 的实现。 +上游 ForwardWidgets 宿主的约定就是 "Widget has built-in cheerio",脚本以同步 +cheerio API 消费 DOM——fjs 的 Dart 桥(`fjs.bridge_call`)只有异步 Promise 形态, +因此 DOM 解析必须完整运行在 JS 侧,不能走宿主桥。 + +全局导出名:`__fjsCheerio`(`__fjsCheerio.load(html)`)。bundle 头部带一个 `atob` +polyfill(`entities` 包解码实体表需要,QuickJS 没有内置 atob/Buffer)。 + +### 重新构建 + +```bash +mkdir /tmp/cheerio-bundle && cd /tmp/cheerio-bundle +npm install cheerio@1.1.2 esbuild +echo "export { load } from 'cheerio';" > entry.js +npx esbuild entry.js --bundle --format=iife --global-name=__fjsCheerio \ + --platform=browser --minify --banner:js="$(cat banner.js)" \ + --outfile=cheerio.min.js +``` + +`banner.js` 为 atob polyfill,内容见当前 bundle 文件头部(第 1 段 +`if (typeof globalThis.atob === 'undefined')`)。`--platform=browser` 会选择 +cheerio 的 browser 入口(只含 load/parse,不含依赖 Buffer/undici 的 +loadBuffer/fromURL)。构建后用一个无 Node 全局的裸 `vm` 沙箱冒烟验证 +`load/text/attr/closest/Array.from` 再提交。 diff --git a/assets/js/cheerio.min.js b/assets/js/cheerio.min.js new file mode 100644 index 0000000..2097e95 --- /dev/null +++ b/assets/js/cheerio.min.js @@ -0,0 +1,20 @@ +// smplayer: cheerio bundle for QuickJS (fjs). Rebuild: see assets/js/README.md +if (typeof globalThis.atob === 'undefined') { + globalThis.atob = function (input) { + var chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; + var str = String(input).replace(/=+$/, ''); + var output = ''; + for (var bc = 0, bs = 0, buffer, idx = 0; (buffer = str.charAt(idx++)); ~buffer && (bs = bc % 4 ? bs * 64 + buffer : buffer, bc++ % 4) ? (output += String.fromCharCode(255 & (bs >> ((-2 * bc) & 6)))) : 0) { + buffer = chars.indexOf(buffer); + } + return output; + }; +} +var __fjsCheerio=(()=>{var bs=Object.create;var kt=Object.defineProperty;var Es=Object.getOwnPropertyDescriptor;var ms=Object.getOwnPropertyNames;var gs=Object.getPrototypeOf,ps=Object.prototype.hasOwnProperty;var Ts=(e,t)=>()=>{try{return t||e((t={exports:{}}).exports,t),t.exports}catch(u){throw t=0,u}},se=(e,t)=>{for(var u in t)kt(e,u,{get:t[u],enumerable:!0})},ea=(e,t,u,a)=>{if(t&&typeof t=="object"||typeof t=="function")for(let s of ms(t))!ps.call(e,s)&&s!==u&&kt(e,s,{get:()=>t[s],enumerable:!(a=Es(t,s))||a.enumerable});return e};var ye=(e,t,u)=>(u=e!=null?bs(gs(e)):{},ea(t||!e||!e.__esModule?kt(u,"default",{value:e,enumerable:!0}):u,e)),Is=e=>ea(kt({},"__esModule",{value:!0}),e);var xe=Ts((Q1,Ga)=>{Ga.exports={trueFunc:function(){return!0},falseFunc:function(){return!1}}});var GA={};se(GA,{load:()=>hs});var r0={};se(r0,{contains:()=>jt,extract:()=>Ei,html:()=>li,merge:()=>xa,parseHTML:()=>hi,root:()=>bi,text:()=>Pe,xml:()=>fi});var Ce={};se(Ce,{DocumentPosition:()=>$,append:()=>Js,appendChild:()=>qs,compareDocumentPosition:()=>pa,existsOne:()=>ma,filter:()=>Ve,find:()=>Jt,findAll:()=>js,findOne:()=>Vt,findOneChild:()=>Xs,getAttributeValue:()=>Ys,getChildren:()=>Je,getElementById:()=>ti,getElements:()=>ei,getElementsByClassName:()=>ui,getElementsByTagName:()=>ke,getElementsByTagType:()=>ai,getFeed:()=>a0,getInnerHTML:()=>Qs,getName:()=>Ks,getOuterHTML:()=>ba,getParent:()=>Ea,getSiblings:()=>t0,getText:()=>Kt,hasAttrib:()=>Gs,hasChildren:()=>R,innerText:()=>dt,isCDATA:()=>Ke,isComment:()=>Me,isDocument:()=>z,isTag:()=>C,isText:()=>J,nextElementSibling:()=>Wt,prepend:()=>Zs,prependChild:()=>Vs,prevElementSibling:()=>qt,removeElement:()=>he,removeSubsets:()=>ri,replaceElement:()=>Ws,testElement:()=>$s,textContent:()=>fe,uniqueSort:()=>Te});var j={};se(j,{CDATA:()=>Gu,Comment:()=>Uu,Directive:()=>Hu,Doctype:()=>Ku,ElementType:()=>D,Root:()=>ku,Script:()=>vu,Style:()=>Qu,Tag:()=>Yu,Text:()=>Pu,isTag:()=>Mu});var D;(function(e){e.Root="root",e.Text="text",e.Directive="directive",e.Comment="comment",e.Script="script",e.Style="style",e.Tag="tag",e.CDATA="cdata",e.Doctype="doctype"})(D||(D={}));function Mu(e){return e.type===D.Tag||e.type===D.Script||e.type===D.Style}var ku=D.Root,Pu=D.Text,Hu=D.Directive,Uu=D.Comment,vu=D.Script,Qu=D.Style,Yu=D.Tag,Gu=D.CDATA,Ku=D.Doctype;var Pt=class{constructor(){this.parent=null,this.prev=null,this.next=null,this.startIndex=null,this.endIndex=null}get parentNode(){return this.parent}set parentNode(t){this.parent=t}get previousSibling(){return this.prev}set previousSibling(t){this.prev=t}get nextSibling(){return this.next}set nextSibling(t){this.next=t}cloneNode(t=!1){return We(this,t)}},nt=class extends Pt{constructor(t){super(),this.data=t}get nodeValue(){return this.data}set nodeValue(t){this.data=t}},oe=class extends nt{constructor(){super(...arguments),this.type=D.Text}get nodeType(){return 3}},Oe=class extends nt{constructor(){super(...arguments),this.type=D.Comment}get nodeType(){return 8}},we=class extends nt{constructor(t,u){super(u),this.name=t,this.type=D.Directive}get nodeType(){return 1}},ct=class extends Pt{constructor(t){super(),this.children=t}get firstChild(){var t;return(t=this.children[0])!==null&&t!==void 0?t:null}get lastChild(){return this.children.length>0?this.children[this.children.length-1]:null}get childNodes(){return this.children}set childNodes(t){this.children=t}},ot=class extends ct{constructor(){super(...arguments),this.type=D.CDATA}get nodeType(){return 4}},X=class extends ct{constructor(){super(...arguments),this.type=D.Root}get nodeType(){return 9}},Fe=class extends ct{constructor(t,u,a=[],s=t==="script"?D.Script:t==="style"?D.Style:D.Tag){super(a),this.name=t,this.attribs=u,this.type=s}get nodeType(){return 1}get tagName(){return this.name}set tagName(t){this.name=t}get attributes(){return Object.keys(this.attribs).map(t=>{var u,a;return{name:t,value:this.attribs[t],namespace:(u=this["x-attribsNamespace"])===null||u===void 0?void 0:u[t],prefix:(a=this["x-attribsPrefix"])===null||a===void 0?void 0:a[t]}})}};function C(e){return Mu(e)}function Ke(e){return e.type===D.CDATA}function J(e){return e.type===D.Text}function Me(e){return e.type===D.Comment}function Ht(e){return e.type===D.Directive}function z(e){return e.type===D.Root}function R(e){return Object.prototype.hasOwnProperty.call(e,"children")}function We(e,t=!1){let u;if(J(e))u=new oe(e.data);else if(Me(e))u=new Oe(e.data);else if(C(e)){let a=t?Wu(e.children):[],s=new Fe(e.name,{...e.attribs},a);a.forEach(i=>i.parent=s),e.namespace!=null&&(s.namespace=e.namespace),e["x-attribsNamespace"]&&(s["x-attribsNamespace"]={...e["x-attribsNamespace"]}),e["x-attribsPrefix"]&&(s["x-attribsPrefix"]={...e["x-attribsPrefix"]}),u=s}else if(Ke(e)){let a=t?Wu(e.children):[],s=new ot(a);a.forEach(i=>i.parent=s),u=s}else if(z(e)){let a=t?Wu(e.children):[],s=new X(a);a.forEach(i=>i.parent=s),e["x-mode"]&&(s["x-mode"]=e["x-mode"]),u=s}else if(Ht(e)){let a=new we(e.name,e.data);e["x-name"]!=null&&(a["x-name"]=e["x-name"],a["x-publicId"]=e["x-publicId"],a["x-systemId"]=e["x-systemId"]),u=a}else throw new Error(`Not implemented yet: ${e.type}`);return u.startIndex=e.startIndex,u.endIndex=e.endIndex,e.sourceCodeLocation!=null&&(u.sourceCodeLocation=e.sourceCodeLocation),u}function Wu(e){let t=e.map(u=>We(u,!0));for(let u=1;u\u403Emma\u0100;d\u05F7\u05F8\u4393;\u43DCreve;\u411E\u0180eiy\u0607\u060C\u0610dil;\u4122rc;\u411C;\u4413ot;\u4120r;\uC000\u{1D50A};\u62D9pf;\uC000\u{1D53E}eater\u0300EFGLST\u0635\u0644\u064E\u0656\u065B\u0666qual\u0100;L\u063E\u063F\u6265ess;\u62DBullEqual;\u6267reater;\u6AA2ess;\u6277lantEqual;\u6A7Eilde;\u6273cr;\uC000\u{1D4A2};\u626B\u0400Aacfiosu\u0685\u068B\u0696\u069B\u069E\u06AA\u06BE\u06CARDcy;\u442A\u0100ct\u0690\u0694ek;\u42C7;\u405Eirc;\u4124r;\u610ClbertSpace;\u610B\u01F0\u06AF\0\u06B2f;\u610DizontalLine;\u6500\u0100ct\u06C3\u06C5\xF2\u06A9rok;\u4126mp\u0144\u06D0\u06D8ownHum\xF0\u012Fqual;\u624F\u0700EJOacdfgmnostu\u06FA\u06FE\u0703\u0707\u070E\u071A\u071E\u0721\u0728\u0744\u0778\u078B\u078F\u0795cy;\u4415lig;\u4132cy;\u4401cute\u803B\xCD\u40CD\u0100iy\u0713\u0718rc\u803B\xCE\u40CE;\u4418ot;\u4130r;\u6111rave\u803B\xCC\u40CC\u0180;ap\u0720\u072F\u073F\u0100cg\u0734\u0737r;\u412AinaryI;\u6148lie\xF3\u03DD\u01F4\u0749\0\u0762\u0100;e\u074D\u074E\u622C\u0100gr\u0753\u0758ral;\u622Bsection;\u62C2isible\u0100CT\u076C\u0772omma;\u6063imes;\u6062\u0180gpt\u077F\u0783\u0788on;\u412Ef;\uC000\u{1D540}a;\u4399cr;\u6110ilde;\u4128\u01EB\u079A\0\u079Ecy;\u4406l\u803B\xCF\u40CF\u0280cfosu\u07AC\u07B7\u07BC\u07C2\u07D0\u0100iy\u07B1\u07B5rc;\u4134;\u4419r;\uC000\u{1D50D}pf;\uC000\u{1D541}\u01E3\u07C7\0\u07CCr;\uC000\u{1D4A5}rcy;\u4408kcy;\u4404\u0380HJacfos\u07E4\u07E8\u07EC\u07F1\u07FD\u0802\u0808cy;\u4425cy;\u440Cppa;\u439A\u0100ey\u07F6\u07FBdil;\u4136;\u441Ar;\uC000\u{1D50E}pf;\uC000\u{1D542}cr;\uC000\u{1D4A6}\u0580JTaceflmost\u0825\u0829\u082C\u0850\u0863\u09B3\u09B8\u09C7\u09CD\u0A37\u0A47cy;\u4409\u803B<\u403C\u0280cmnpr\u0837\u083C\u0841\u0844\u084Dute;\u4139bda;\u439Bg;\u67EAlacetrf;\u6112r;\u619E\u0180aey\u0857\u085C\u0861ron;\u413Ddil;\u413B;\u441B\u0100fs\u0868\u0970t\u0500ACDFRTUVar\u087E\u08A9\u08B1\u08E0\u08E6\u08FC\u092F\u095B\u0390\u096A\u0100nr\u0883\u088FgleBracket;\u67E8row\u0180;BR\u0899\u089A\u089E\u6190ar;\u61E4ightArrow;\u61C6eiling;\u6308o\u01F5\u08B7\0\u08C3bleBracket;\u67E6n\u01D4\u08C8\0\u08D2eeVector;\u6961ector\u0100;B\u08DB\u08DC\u61C3ar;\u6959loor;\u630Aight\u0100AV\u08EF\u08F5rrow;\u6194ector;\u694E\u0100er\u0901\u0917e\u0180;AV\u0909\u090A\u0910\u62A3rrow;\u61A4ector;\u695Aiangle\u0180;BE\u0924\u0925\u0929\u62B2ar;\u69CFqual;\u62B4p\u0180DTV\u0937\u0942\u094CownVector;\u6951eeVector;\u6960ector\u0100;B\u0956\u0957\u61BFar;\u6958ector\u0100;B\u0965\u0966\u61BCar;\u6952ight\xE1\u039Cs\u0300EFGLST\u097E\u098B\u0995\u099D\u09A2\u09ADqualGreater;\u62DAullEqual;\u6266reater;\u6276ess;\u6AA1lantEqual;\u6A7Dilde;\u6272r;\uC000\u{1D50F}\u0100;e\u09BD\u09BE\u62D8ftarrow;\u61DAidot;\u413F\u0180npw\u09D4\u0A16\u0A1Bg\u0200LRlr\u09DE\u09F7\u0A02\u0A10eft\u0100AR\u09E6\u09ECrrow;\u67F5ightArrow;\u67F7ightArrow;\u67F6eft\u0100ar\u03B3\u0A0Aight\xE1\u03BFight\xE1\u03CAf;\uC000\u{1D543}er\u0100LR\u0A22\u0A2CeftArrow;\u6199ightArrow;\u6198\u0180cht\u0A3E\u0A40\u0A42\xF2\u084C;\u61B0rok;\u4141;\u626A\u0400acefiosu\u0A5A\u0A5D\u0A60\u0A77\u0A7C\u0A85\u0A8B\u0A8Ep;\u6905y;\u441C\u0100dl\u0A65\u0A6FiumSpace;\u605Flintrf;\u6133r;\uC000\u{1D510}nusPlus;\u6213pf;\uC000\u{1D544}c\xF2\u0A76;\u439C\u0480Jacefostu\u0AA3\u0AA7\u0AAD\u0AC0\u0B14\u0B19\u0D91\u0D97\u0D9Ecy;\u440Acute;\u4143\u0180aey\u0AB4\u0AB9\u0ABEron;\u4147dil;\u4145;\u441D\u0180gsw\u0AC7\u0AF0\u0B0Eative\u0180MTV\u0AD3\u0ADF\u0AE8ediumSpace;\u600Bhi\u0100cn\u0AE6\u0AD8\xEB\u0AD9eryThi\xEE\u0AD9ted\u0100GL\u0AF8\u0B06reaterGreate\xF2\u0673essLes\xF3\u0A48Line;\u400Ar;\uC000\u{1D511}\u0200Bnpt\u0B22\u0B28\u0B37\u0B3Areak;\u6060BreakingSpace;\u40A0f;\u6115\u0680;CDEGHLNPRSTV\u0B55\u0B56\u0B6A\u0B7C\u0BA1\u0BEB\u0C04\u0C5E\u0C84\u0CA6\u0CD8\u0D61\u0D85\u6AEC\u0100ou\u0B5B\u0B64ngruent;\u6262pCap;\u626DoubleVerticalBar;\u6226\u0180lqx\u0B83\u0B8A\u0B9Bement;\u6209ual\u0100;T\u0B92\u0B93\u6260ilde;\uC000\u2242\u0338ists;\u6204reater\u0380;EFGLST\u0BB6\u0BB7\u0BBD\u0BC9\u0BD3\u0BD8\u0BE5\u626Fqual;\u6271ullEqual;\uC000\u2267\u0338reater;\uC000\u226B\u0338ess;\u6279lantEqual;\uC000\u2A7E\u0338ilde;\u6275ump\u0144\u0BF2\u0BFDownHump;\uC000\u224E\u0338qual;\uC000\u224F\u0338e\u0100fs\u0C0A\u0C27tTriangle\u0180;BE\u0C1A\u0C1B\u0C21\u62EAar;\uC000\u29CF\u0338qual;\u62ECs\u0300;EGLST\u0C35\u0C36\u0C3C\u0C44\u0C4B\u0C58\u626Equal;\u6270reater;\u6278ess;\uC000\u226A\u0338lantEqual;\uC000\u2A7D\u0338ilde;\u6274ested\u0100GL\u0C68\u0C79reaterGreater;\uC000\u2AA2\u0338essLess;\uC000\u2AA1\u0338recedes\u0180;ES\u0C92\u0C93\u0C9B\u6280qual;\uC000\u2AAF\u0338lantEqual;\u62E0\u0100ei\u0CAB\u0CB9verseElement;\u620CghtTriangle\u0180;BE\u0CCB\u0CCC\u0CD2\u62EBar;\uC000\u29D0\u0338qual;\u62ED\u0100qu\u0CDD\u0D0CuareSu\u0100bp\u0CE8\u0CF9set\u0100;E\u0CF0\u0CF3\uC000\u228F\u0338qual;\u62E2erset\u0100;E\u0D03\u0D06\uC000\u2290\u0338qual;\u62E3\u0180bcp\u0D13\u0D24\u0D4Eset\u0100;E\u0D1B\u0D1E\uC000\u2282\u20D2qual;\u6288ceeds\u0200;EST\u0D32\u0D33\u0D3B\u0D46\u6281qual;\uC000\u2AB0\u0338lantEqual;\u62E1ilde;\uC000\u227F\u0338erset\u0100;E\u0D58\u0D5B\uC000\u2283\u20D2qual;\u6289ilde\u0200;EFT\u0D6E\u0D6F\u0D75\u0D7F\u6241qual;\u6244ullEqual;\u6247ilde;\u6249erticalBar;\u6224cr;\uC000\u{1D4A9}ilde\u803B\xD1\u40D1;\u439D\u0700Eacdfgmoprstuv\u0DBD\u0DC2\u0DC9\u0DD5\u0DDB\u0DE0\u0DE7\u0DFC\u0E02\u0E20\u0E22\u0E32\u0E3F\u0E44lig;\u4152cute\u803B\xD3\u40D3\u0100iy\u0DCE\u0DD3rc\u803B\xD4\u40D4;\u441Eblac;\u4150r;\uC000\u{1D512}rave\u803B\xD2\u40D2\u0180aei\u0DEE\u0DF2\u0DF6cr;\u414Cga;\u43A9cron;\u439Fpf;\uC000\u{1D546}enCurly\u0100DQ\u0E0E\u0E1AoubleQuote;\u601Cuote;\u6018;\u6A54\u0100cl\u0E27\u0E2Cr;\uC000\u{1D4AA}ash\u803B\xD8\u40D8i\u016C\u0E37\u0E3Cde\u803B\xD5\u40D5es;\u6A37ml\u803B\xD6\u40D6er\u0100BP\u0E4B\u0E60\u0100ar\u0E50\u0E53r;\u603Eac\u0100ek\u0E5A\u0E5C;\u63DEet;\u63B4arenthesis;\u63DC\u0480acfhilors\u0E7F\u0E87\u0E8A\u0E8F\u0E92\u0E94\u0E9D\u0EB0\u0EFCrtialD;\u6202y;\u441Fr;\uC000\u{1D513}i;\u43A6;\u43A0usMinus;\u40B1\u0100ip\u0EA2\u0EADncareplan\xE5\u069Df;\u6119\u0200;eio\u0EB9\u0EBA\u0EE0\u0EE4\u6ABBcedes\u0200;EST\u0EC8\u0EC9\u0ECF\u0EDA\u627Aqual;\u6AAFlantEqual;\u627Cilde;\u627Eme;\u6033\u0100dp\u0EE9\u0EEEuct;\u620Fortion\u0100;a\u0225\u0EF9l;\u621D\u0100ci\u0F01\u0F06r;\uC000\u{1D4AB};\u43A8\u0200Ufos\u0F11\u0F16\u0F1B\u0F1FOT\u803B"\u4022r;\uC000\u{1D514}pf;\u611Acr;\uC000\u{1D4AC}\u0600BEacefhiorsu\u0F3E\u0F43\u0F47\u0F60\u0F73\u0FA7\u0FAA\u0FAD\u1096\u10A9\u10B4\u10BEarr;\u6910G\u803B\xAE\u40AE\u0180cnr\u0F4E\u0F53\u0F56ute;\u4154g;\u67EBr\u0100;t\u0F5C\u0F5D\u61A0l;\u6916\u0180aey\u0F67\u0F6C\u0F71ron;\u4158dil;\u4156;\u4420\u0100;v\u0F78\u0F79\u611Cerse\u0100EU\u0F82\u0F99\u0100lq\u0F87\u0F8Eement;\u620Builibrium;\u61CBpEquilibrium;\u696Fr\xBB\u0F79o;\u43A1ght\u0400ACDFTUVa\u0FC1\u0FEB\u0FF3\u1022\u1028\u105B\u1087\u03D8\u0100nr\u0FC6\u0FD2gleBracket;\u67E9row\u0180;BL\u0FDC\u0FDD\u0FE1\u6192ar;\u61E5eftArrow;\u61C4eiling;\u6309o\u01F5\u0FF9\0\u1005bleBracket;\u67E7n\u01D4\u100A\0\u1014eeVector;\u695Dector\u0100;B\u101D\u101E\u61C2ar;\u6955loor;\u630B\u0100er\u102D\u1043e\u0180;AV\u1035\u1036\u103C\u62A2rrow;\u61A6ector;\u695Biangle\u0180;BE\u1050\u1051\u1055\u62B3ar;\u69D0qual;\u62B5p\u0180DTV\u1063\u106E\u1078ownVector;\u694FeeVector;\u695Cector\u0100;B\u1082\u1083\u61BEar;\u6954ector\u0100;B\u1091\u1092\u61C0ar;\u6953\u0100pu\u109B\u109Ef;\u611DndImplies;\u6970ightarrow;\u61DB\u0100ch\u10B9\u10BCr;\u611B;\u61B1leDelayed;\u69F4\u0680HOacfhimoqstu\u10E4\u10F1\u10F7\u10FD\u1119\u111E\u1151\u1156\u1161\u1167\u11B5\u11BB\u11BF\u0100Cc\u10E9\u10EEHcy;\u4429y;\u4428FTcy;\u442Ccute;\u415A\u0280;aeiy\u1108\u1109\u110E\u1113\u1117\u6ABCron;\u4160dil;\u415Erc;\u415C;\u4421r;\uC000\u{1D516}ort\u0200DLRU\u112A\u1134\u113E\u1149ownArrow\xBB\u041EeftArrow\xBB\u089AightArrow\xBB\u0FDDpArrow;\u6191gma;\u43A3allCircle;\u6218pf;\uC000\u{1D54A}\u0272\u116D\0\0\u1170t;\u621Aare\u0200;ISU\u117B\u117C\u1189\u11AF\u65A1ntersection;\u6293u\u0100bp\u118F\u119Eset\u0100;E\u1197\u1198\u628Fqual;\u6291erset\u0100;E\u11A8\u11A9\u6290qual;\u6292nion;\u6294cr;\uC000\u{1D4AE}ar;\u62C6\u0200bcmp\u11C8\u11DB\u1209\u120B\u0100;s\u11CD\u11CE\u62D0et\u0100;E\u11CD\u11D5qual;\u6286\u0100ch\u11E0\u1205eeds\u0200;EST\u11ED\u11EE\u11F4\u11FF\u627Bqual;\u6AB0lantEqual;\u627Dilde;\u627FTh\xE1\u0F8C;\u6211\u0180;es\u1212\u1213\u1223\u62D1rset\u0100;E\u121C\u121D\u6283qual;\u6287et\xBB\u1213\u0580HRSacfhiors\u123E\u1244\u1249\u1255\u125E\u1271\u1276\u129F\u12C2\u12C8\u12D1ORN\u803B\xDE\u40DEADE;\u6122\u0100Hc\u124E\u1252cy;\u440By;\u4426\u0100bu\u125A\u125C;\u4009;\u43A4\u0180aey\u1265\u126A\u126Fron;\u4164dil;\u4162;\u4422r;\uC000\u{1D517}\u0100ei\u127B\u1289\u01F2\u1280\0\u1287efore;\u6234a;\u4398\u0100cn\u128E\u1298kSpace;\uC000\u205F\u200ASpace;\u6009lde\u0200;EFT\u12AB\u12AC\u12B2\u12BC\u623Cqual;\u6243ullEqual;\u6245ilde;\u6248pf;\uC000\u{1D54B}ipleDot;\u60DB\u0100ct\u12D6\u12DBr;\uC000\u{1D4AF}rok;\u4166\u0AE1\u12F7\u130E\u131A\u1326\0\u132C\u1331\0\0\0\0\0\u1338\u133D\u1377\u1385\0\u13FF\u1404\u140A\u1410\u0100cr\u12FB\u1301ute\u803B\xDA\u40DAr\u0100;o\u1307\u1308\u619Fcir;\u6949r\u01E3\u1313\0\u1316y;\u440Eve;\u416C\u0100iy\u131E\u1323rc\u803B\xDB\u40DB;\u4423blac;\u4170r;\uC000\u{1D518}rave\u803B\xD9\u40D9acr;\u416A\u0100di\u1341\u1369er\u0100BP\u1348\u135D\u0100ar\u134D\u1350r;\u405Fac\u0100ek\u1357\u1359;\u63DFet;\u63B5arenthesis;\u63DDon\u0100;P\u1370\u1371\u62C3lus;\u628E\u0100gp\u137B\u137Fon;\u4172f;\uC000\u{1D54C}\u0400ADETadps\u1395\u13AE\u13B8\u13C4\u03E8\u13D2\u13D7\u13F3rrow\u0180;BD\u1150\u13A0\u13A4ar;\u6912ownArrow;\u61C5ownArrow;\u6195quilibrium;\u696Eee\u0100;A\u13CB\u13CC\u62A5rrow;\u61A5own\xE1\u03F3er\u0100LR\u13DE\u13E8eftArrow;\u6196ightArrow;\u6197i\u0100;l\u13F9\u13FA\u43D2on;\u43A5ing;\u416Ecr;\uC000\u{1D4B0}ilde;\u4168ml\u803B\xDC\u40DC\u0480Dbcdefosv\u1427\u142C\u1430\u1433\u143E\u1485\u148A\u1490\u1496ash;\u62ABar;\u6AEBy;\u4412ash\u0100;l\u143B\u143C\u62A9;\u6AE6\u0100er\u1443\u1445;\u62C1\u0180bty\u144C\u1450\u147Aar;\u6016\u0100;i\u144F\u1455cal\u0200BLST\u1461\u1465\u146A\u1474ar;\u6223ine;\u407Ceparator;\u6758ilde;\u6240ThinSpace;\u600Ar;\uC000\u{1D519}pf;\uC000\u{1D54D}cr;\uC000\u{1D4B1}dash;\u62AA\u0280cefos\u14A7\u14AC\u14B1\u14B6\u14BCirc;\u4174dge;\u62C0r;\uC000\u{1D51A}pf;\uC000\u{1D54E}cr;\uC000\u{1D4B2}\u0200fios\u14CB\u14D0\u14D2\u14D8r;\uC000\u{1D51B};\u439Epf;\uC000\u{1D54F}cr;\uC000\u{1D4B3}\u0480AIUacfosu\u14F1\u14F5\u14F9\u14FD\u1504\u150F\u1514\u151A\u1520cy;\u442Fcy;\u4407cy;\u442Ecute\u803B\xDD\u40DD\u0100iy\u1509\u150Drc;\u4176;\u442Br;\uC000\u{1D51C}pf;\uC000\u{1D550}cr;\uC000\u{1D4B4}ml;\u4178\u0400Hacdefos\u1535\u1539\u153F\u154B\u154F\u155D\u1560\u1564cy;\u4416cute;\u4179\u0100ay\u1544\u1549ron;\u417D;\u4417ot;\u417B\u01F2\u1554\0\u155BoWidt\xE8\u0AD9a;\u4396r;\u6128pf;\u6124cr;\uC000\u{1D4B5}\u0BE1\u1583\u158A\u1590\0\u15B0\u15B6\u15BF\0\0\0\0\u15C6\u15DB\u15EB\u165F\u166D\0\u1695\u169B\u16B2\u16B9\0\u16BEcute\u803B\xE1\u40E1reve;\u4103\u0300;Ediuy\u159C\u159D\u15A1\u15A3\u15A8\u15AD\u623E;\uC000\u223E\u0333;\u623Frc\u803B\xE2\u40E2te\u80BB\xB4\u0306;\u4430lig\u803B\xE6\u40E6\u0100;r\xB2\u15BA;\uC000\u{1D51E}rave\u803B\xE0\u40E0\u0100ep\u15CA\u15D6\u0100fp\u15CF\u15D4sym;\u6135\xE8\u15D3ha;\u43B1\u0100ap\u15DFc\u0100cl\u15E4\u15E7r;\u4101g;\u6A3F\u0264\u15F0\0\0\u160A\u0280;adsv\u15FA\u15FB\u15FF\u1601\u1607\u6227nd;\u6A55;\u6A5Clope;\u6A58;\u6A5A\u0380;elmrsz\u1618\u1619\u161B\u161E\u163F\u164F\u1659\u6220;\u69A4e\xBB\u1619sd\u0100;a\u1625\u1626\u6221\u0461\u1630\u1632\u1634\u1636\u1638\u163A\u163C\u163E;\u69A8;\u69A9;\u69AA;\u69AB;\u69AC;\u69AD;\u69AE;\u69AFt\u0100;v\u1645\u1646\u621Fb\u0100;d\u164C\u164D\u62BE;\u699D\u0100pt\u1654\u1657h;\u6222\xBB\xB9arr;\u637C\u0100gp\u1663\u1667on;\u4105f;\uC000\u{1D552}\u0380;Eaeiop\u12C1\u167B\u167D\u1682\u1684\u1687\u168A;\u6A70cir;\u6A6F;\u624Ad;\u624Bs;\u4027rox\u0100;e\u12C1\u1692\xF1\u1683ing\u803B\xE5\u40E5\u0180cty\u16A1\u16A6\u16A8r;\uC000\u{1D4B6};\u402Amp\u0100;e\u12C1\u16AF\xF1\u0288ilde\u803B\xE3\u40E3ml\u803B\xE4\u40E4\u0100ci\u16C2\u16C8onin\xF4\u0272nt;\u6A11\u0800Nabcdefiklnoprsu\u16ED\u16F1\u1730\u173C\u1743\u1748\u1778\u177D\u17E0\u17E6\u1839\u1850\u170D\u193D\u1948\u1970ot;\u6AED\u0100cr\u16F6\u171Ek\u0200ceps\u1700\u1705\u170D\u1713ong;\u624Cpsilon;\u43F6rime;\u6035im\u0100;e\u171A\u171B\u623Dq;\u62CD\u0176\u1722\u1726ee;\u62BDed\u0100;g\u172C\u172D\u6305e\xBB\u172Drk\u0100;t\u135C\u1737brk;\u63B6\u0100oy\u1701\u1741;\u4431quo;\u601E\u0280cmprt\u1753\u175B\u1761\u1764\u1768aus\u0100;e\u010A\u0109ptyv;\u69B0s\xE9\u170Cno\xF5\u0113\u0180ahw\u176F\u1771\u1773;\u43B2;\u6136een;\u626Cr;\uC000\u{1D51F}g\u0380costuvw\u178D\u179D\u17B3\u17C1\u17D5\u17DB\u17DE\u0180aiu\u1794\u1796\u179A\xF0\u0760rc;\u65EFp\xBB\u1371\u0180dpt\u17A4\u17A8\u17ADot;\u6A00lus;\u6A01imes;\u6A02\u0271\u17B9\0\0\u17BEcup;\u6A06ar;\u6605riangle\u0100du\u17CD\u17D2own;\u65BDp;\u65B3plus;\u6A04e\xE5\u1444\xE5\u14ADarow;\u690D\u0180ako\u17ED\u1826\u1835\u0100cn\u17F2\u1823k\u0180lst\u17FA\u05AB\u1802ozenge;\u69EBriangle\u0200;dlr\u1812\u1813\u1818\u181D\u65B4own;\u65BEeft;\u65C2ight;\u65B8k;\u6423\u01B1\u182B\0\u1833\u01B2\u182F\0\u1831;\u6592;\u65914;\u6593ck;\u6588\u0100eo\u183E\u184D\u0100;q\u1843\u1846\uC000=\u20E5uiv;\uC000\u2261\u20E5t;\u6310\u0200ptwx\u1859\u185E\u1867\u186Cf;\uC000\u{1D553}\u0100;t\u13CB\u1863om\xBB\u13CCtie;\u62C8\u0600DHUVbdhmptuv\u1885\u1896\u18AA\u18BB\u18D7\u18DB\u18EC\u18FF\u1905\u190A\u1910\u1921\u0200LRlr\u188E\u1890\u1892\u1894;\u6557;\u6554;\u6556;\u6553\u0280;DUdu\u18A1\u18A2\u18A4\u18A6\u18A8\u6550;\u6566;\u6569;\u6564;\u6567\u0200LRlr\u18B3\u18B5\u18B7\u18B9;\u655D;\u655A;\u655C;\u6559\u0380;HLRhlr\u18CA\u18CB\u18CD\u18CF\u18D1\u18D3\u18D5\u6551;\u656C;\u6563;\u6560;\u656B;\u6562;\u655Fox;\u69C9\u0200LRlr\u18E4\u18E6\u18E8\u18EA;\u6555;\u6552;\u6510;\u650C\u0280;DUdu\u06BD\u18F7\u18F9\u18FB\u18FD;\u6565;\u6568;\u652C;\u6534inus;\u629Flus;\u629Eimes;\u62A0\u0200LRlr\u1919\u191B\u191D\u191F;\u655B;\u6558;\u6518;\u6514\u0380;HLRhlr\u1930\u1931\u1933\u1935\u1937\u1939\u193B\u6502;\u656A;\u6561;\u655E;\u653C;\u6524;\u651C\u0100ev\u0123\u1942bar\u803B\xA6\u40A6\u0200ceio\u1951\u1956\u195A\u1960r;\uC000\u{1D4B7}mi;\u604Fm\u0100;e\u171A\u171Cl\u0180;bh\u1968\u1969\u196B\u405C;\u69C5sub;\u67C8\u016C\u1974\u197El\u0100;e\u1979\u197A\u6022t\xBB\u197Ap\u0180;Ee\u012F\u1985\u1987;\u6AAE\u0100;q\u06DC\u06DB\u0CE1\u19A7\0\u19E8\u1A11\u1A15\u1A32\0\u1A37\u1A50\0\0\u1AB4\0\0\u1AC1\0\0\u1B21\u1B2E\u1B4D\u1B52\0\u1BFD\0\u1C0C\u0180cpr\u19AD\u19B2\u19DDute;\u4107\u0300;abcds\u19BF\u19C0\u19C4\u19CA\u19D5\u19D9\u6229nd;\u6A44rcup;\u6A49\u0100au\u19CF\u19D2p;\u6A4Bp;\u6A47ot;\u6A40;\uC000\u2229\uFE00\u0100eo\u19E2\u19E5t;\u6041\xEE\u0693\u0200aeiu\u19F0\u19FB\u1A01\u1A05\u01F0\u19F5\0\u19F8s;\u6A4Don;\u410Ddil\u803B\xE7\u40E7rc;\u4109ps\u0100;s\u1A0C\u1A0D\u6A4Cm;\u6A50ot;\u410B\u0180dmn\u1A1B\u1A20\u1A26il\u80BB\xB8\u01ADptyv;\u69B2t\u8100\xA2;e\u1A2D\u1A2E\u40A2r\xE4\u01B2r;\uC000\u{1D520}\u0180cei\u1A3D\u1A40\u1A4Dy;\u4447ck\u0100;m\u1A47\u1A48\u6713ark\xBB\u1A48;\u43C7r\u0380;Ecefms\u1A5F\u1A60\u1A62\u1A6B\u1AA4\u1AAA\u1AAE\u65CB;\u69C3\u0180;el\u1A69\u1A6A\u1A6D\u42C6q;\u6257e\u0261\u1A74\0\0\u1A88rrow\u0100lr\u1A7C\u1A81eft;\u61BAight;\u61BB\u0280RSacd\u1A92\u1A94\u1A96\u1A9A\u1A9F\xBB\u0F47;\u64C8st;\u629Birc;\u629Aash;\u629Dnint;\u6A10id;\u6AEFcir;\u69C2ubs\u0100;u\u1ABB\u1ABC\u6663it\xBB\u1ABC\u02EC\u1AC7\u1AD4\u1AFA\0\u1B0Aon\u0100;e\u1ACD\u1ACE\u403A\u0100;q\xC7\xC6\u026D\u1AD9\0\0\u1AE2a\u0100;t\u1ADE\u1ADF\u402C;\u4040\u0180;fl\u1AE8\u1AE9\u1AEB\u6201\xEE\u1160e\u0100mx\u1AF1\u1AF6ent\xBB\u1AE9e\xF3\u024D\u01E7\u1AFE\0\u1B07\u0100;d\u12BB\u1B02ot;\u6A6Dn\xF4\u0246\u0180fry\u1B10\u1B14\u1B17;\uC000\u{1D554}o\xE4\u0254\u8100\xA9;s\u0155\u1B1Dr;\u6117\u0100ao\u1B25\u1B29rr;\u61B5ss;\u6717\u0100cu\u1B32\u1B37r;\uC000\u{1D4B8}\u0100bp\u1B3C\u1B44\u0100;e\u1B41\u1B42\u6ACF;\u6AD1\u0100;e\u1B49\u1B4A\u6AD0;\u6AD2dot;\u62EF\u0380delprvw\u1B60\u1B6C\u1B77\u1B82\u1BAC\u1BD4\u1BF9arr\u0100lr\u1B68\u1B6A;\u6938;\u6935\u0270\u1B72\0\0\u1B75r;\u62DEc;\u62DFarr\u0100;p\u1B7F\u1B80\u61B6;\u693D\u0300;bcdos\u1B8F\u1B90\u1B96\u1BA1\u1BA5\u1BA8\u622Arcap;\u6A48\u0100au\u1B9B\u1B9Ep;\u6A46p;\u6A4Aot;\u628Dr;\u6A45;\uC000\u222A\uFE00\u0200alrv\u1BB5\u1BBF\u1BDE\u1BE3rr\u0100;m\u1BBC\u1BBD\u61B7;\u693Cy\u0180evw\u1BC7\u1BD4\u1BD8q\u0270\u1BCE\0\0\u1BD2re\xE3\u1B73u\xE3\u1B75ee;\u62CEedge;\u62CFen\u803B\xA4\u40A4earrow\u0100lr\u1BEE\u1BF3eft\xBB\u1B80ight\xBB\u1BBDe\xE4\u1BDD\u0100ci\u1C01\u1C07onin\xF4\u01F7nt;\u6231lcty;\u632D\u0980AHabcdefhijlorstuwz\u1C38\u1C3B\u1C3F\u1C5D\u1C69\u1C75\u1C8A\u1C9E\u1CAC\u1CB7\u1CFB\u1CFF\u1D0D\u1D7B\u1D91\u1DAB\u1DBB\u1DC6\u1DCDr\xF2\u0381ar;\u6965\u0200glrs\u1C48\u1C4D\u1C52\u1C54ger;\u6020eth;\u6138\xF2\u1133h\u0100;v\u1C5A\u1C5B\u6010\xBB\u090A\u016B\u1C61\u1C67arow;\u690Fa\xE3\u0315\u0100ay\u1C6E\u1C73ron;\u410F;\u4434\u0180;ao\u0332\u1C7C\u1C84\u0100gr\u02BF\u1C81r;\u61CAtseq;\u6A77\u0180glm\u1C91\u1C94\u1C98\u803B\xB0\u40B0ta;\u43B4ptyv;\u69B1\u0100ir\u1CA3\u1CA8sht;\u697F;\uC000\u{1D521}ar\u0100lr\u1CB3\u1CB5\xBB\u08DC\xBB\u101E\u0280aegsv\u1CC2\u0378\u1CD6\u1CDC\u1CE0m\u0180;os\u0326\u1CCA\u1CD4nd\u0100;s\u0326\u1CD1uit;\u6666amma;\u43DDin;\u62F2\u0180;io\u1CE7\u1CE8\u1CF8\u40F7de\u8100\xF7;o\u1CE7\u1CF0ntimes;\u62C7n\xF8\u1CF7cy;\u4452c\u026F\u1D06\0\0\u1D0Arn;\u631Eop;\u630D\u0280lptuw\u1D18\u1D1D\u1D22\u1D49\u1D55lar;\u4024f;\uC000\u{1D555}\u0280;emps\u030B\u1D2D\u1D37\u1D3D\u1D42q\u0100;d\u0352\u1D33ot;\u6251inus;\u6238lus;\u6214quare;\u62A1blebarwedg\xE5\xFAn\u0180adh\u112E\u1D5D\u1D67ownarrow\xF3\u1C83arpoon\u0100lr\u1D72\u1D76ef\xF4\u1CB4igh\xF4\u1CB6\u0162\u1D7F\u1D85karo\xF7\u0F42\u026F\u1D8A\0\0\u1D8Ern;\u631Fop;\u630C\u0180cot\u1D98\u1DA3\u1DA6\u0100ry\u1D9D\u1DA1;\uC000\u{1D4B9};\u4455l;\u69F6rok;\u4111\u0100dr\u1DB0\u1DB4ot;\u62F1i\u0100;f\u1DBA\u1816\u65BF\u0100ah\u1DC0\u1DC3r\xF2\u0429a\xF2\u0FA6angle;\u69A6\u0100ci\u1DD2\u1DD5y;\u445Fgrarr;\u67FF\u0900Dacdefglmnopqrstux\u1E01\u1E09\u1E19\u1E38\u0578\u1E3C\u1E49\u1E61\u1E7E\u1EA5\u1EAF\u1EBD\u1EE1\u1F2A\u1F37\u1F44\u1F4E\u1F5A\u0100Do\u1E06\u1D34o\xF4\u1C89\u0100cs\u1E0E\u1E14ute\u803B\xE9\u40E9ter;\u6A6E\u0200aioy\u1E22\u1E27\u1E31\u1E36ron;\u411Br\u0100;c\u1E2D\u1E2E\u6256\u803B\xEA\u40EAlon;\u6255;\u444Dot;\u4117\u0100Dr\u1E41\u1E45ot;\u6252;\uC000\u{1D522}\u0180;rs\u1E50\u1E51\u1E57\u6A9Aave\u803B\xE8\u40E8\u0100;d\u1E5C\u1E5D\u6A96ot;\u6A98\u0200;ils\u1E6A\u1E6B\u1E72\u1E74\u6A99nters;\u63E7;\u6113\u0100;d\u1E79\u1E7A\u6A95ot;\u6A97\u0180aps\u1E85\u1E89\u1E97cr;\u4113ty\u0180;sv\u1E92\u1E93\u1E95\u6205et\xBB\u1E93p\u01001;\u1E9D\u1EA4\u0133\u1EA1\u1EA3;\u6004;\u6005\u6003\u0100gs\u1EAA\u1EAC;\u414Bp;\u6002\u0100gp\u1EB4\u1EB8on;\u4119f;\uC000\u{1D556}\u0180als\u1EC4\u1ECE\u1ED2r\u0100;s\u1ECA\u1ECB\u62D5l;\u69E3us;\u6A71i\u0180;lv\u1EDA\u1EDB\u1EDF\u43B5on\xBB\u1EDB;\u43F5\u0200csuv\u1EEA\u1EF3\u1F0B\u1F23\u0100io\u1EEF\u1E31rc\xBB\u1E2E\u0269\u1EF9\0\0\u1EFB\xED\u0548ant\u0100gl\u1F02\u1F06tr\xBB\u1E5Dess\xBB\u1E7A\u0180aei\u1F12\u1F16\u1F1Als;\u403Dst;\u625Fv\u0100;D\u0235\u1F20D;\u6A78parsl;\u69E5\u0100Da\u1F2F\u1F33ot;\u6253rr;\u6971\u0180cdi\u1F3E\u1F41\u1EF8r;\u612Fo\xF4\u0352\u0100ah\u1F49\u1F4B;\u43B7\u803B\xF0\u40F0\u0100mr\u1F53\u1F57l\u803B\xEB\u40EBo;\u60AC\u0180cip\u1F61\u1F64\u1F67l;\u4021s\xF4\u056E\u0100eo\u1F6C\u1F74ctatio\xEE\u0559nential\xE5\u0579\u09E1\u1F92\0\u1F9E\0\u1FA1\u1FA7\0\0\u1FC6\u1FCC\0\u1FD3\0\u1FE6\u1FEA\u2000\0\u2008\u205Allingdotse\xF1\u1E44y;\u4444male;\u6640\u0180ilr\u1FAD\u1FB3\u1FC1lig;\u8000\uFB03\u0269\u1FB9\0\0\u1FBDg;\u8000\uFB00ig;\u8000\uFB04;\uC000\u{1D523}lig;\u8000\uFB01lig;\uC000fj\u0180alt\u1FD9\u1FDC\u1FE1t;\u666Dig;\u8000\uFB02ns;\u65B1of;\u4192\u01F0\u1FEE\0\u1FF3f;\uC000\u{1D557}\u0100ak\u05BF\u1FF7\u0100;v\u1FFC\u1FFD\u62D4;\u6AD9artint;\u6A0D\u0100ao\u200C\u2055\u0100cs\u2011\u2052\u03B1\u201A\u2030\u2038\u2045\u2048\0\u2050\u03B2\u2022\u2025\u2027\u202A\u202C\0\u202E\u803B\xBD\u40BD;\u6153\u803B\xBC\u40BC;\u6155;\u6159;\u615B\u01B3\u2034\0\u2036;\u6154;\u6156\u02B4\u203E\u2041\0\0\u2043\u803B\xBE\u40BE;\u6157;\u615C5;\u6158\u01B6\u204C\0\u204E;\u615A;\u615D8;\u615El;\u6044wn;\u6322cr;\uC000\u{1D4BB}\u0880Eabcdefgijlnorstv\u2082\u2089\u209F\u20A5\u20B0\u20B4\u20F0\u20F5\u20FA\u20FF\u2103\u2112\u2138\u0317\u213E\u2152\u219E\u0100;l\u064D\u2087;\u6A8C\u0180cmp\u2090\u2095\u209Dute;\u41F5ma\u0100;d\u209C\u1CDA\u43B3;\u6A86reve;\u411F\u0100iy\u20AA\u20AErc;\u411D;\u4433ot;\u4121\u0200;lqs\u063E\u0642\u20BD\u20C9\u0180;qs\u063E\u064C\u20C4lan\xF4\u0665\u0200;cdl\u0665\u20D2\u20D5\u20E5c;\u6AA9ot\u0100;o\u20DC\u20DD\u6A80\u0100;l\u20E2\u20E3\u6A82;\u6A84\u0100;e\u20EA\u20ED\uC000\u22DB\uFE00s;\u6A94r;\uC000\u{1D524}\u0100;g\u0673\u061Bmel;\u6137cy;\u4453\u0200;Eaj\u065A\u210C\u210E\u2110;\u6A92;\u6AA5;\u6AA4\u0200Eaes\u211B\u211D\u2129\u2134;\u6269p\u0100;p\u2123\u2124\u6A8Arox\xBB\u2124\u0100;q\u212E\u212F\u6A88\u0100;q\u212E\u211Bim;\u62E7pf;\uC000\u{1D558}\u0100ci\u2143\u2146r;\u610Am\u0180;el\u066B\u214E\u2150;\u6A8E;\u6A90\u8300>;cdlqr\u05EE\u2160\u216A\u216E\u2173\u2179\u0100ci\u2165\u2167;\u6AA7r;\u6A7Aot;\u62D7Par;\u6995uest;\u6A7C\u0280adels\u2184\u216A\u2190\u0656\u219B\u01F0\u2189\0\u218Epro\xF8\u209Er;\u6978q\u0100lq\u063F\u2196les\xF3\u2088i\xED\u066B\u0100en\u21A3\u21ADrtneqq;\uC000\u2269\uFE00\xC5\u21AA\u0500Aabcefkosy\u21C4\u21C7\u21F1\u21F5\u21FA\u2218\u221D\u222F\u2268\u227Dr\xF2\u03A0\u0200ilmr\u21D0\u21D4\u21D7\u21DBrs\xF0\u1484f\xBB\u2024il\xF4\u06A9\u0100dr\u21E0\u21E4cy;\u444A\u0180;cw\u08F4\u21EB\u21EFir;\u6948;\u61ADar;\u610Firc;\u4125\u0180alr\u2201\u220E\u2213rts\u0100;u\u2209\u220A\u6665it\xBB\u220Alip;\u6026con;\u62B9r;\uC000\u{1D525}s\u0100ew\u2223\u2229arow;\u6925arow;\u6926\u0280amopr\u223A\u223E\u2243\u225E\u2263rr;\u61FFtht;\u623Bk\u0100lr\u2249\u2253eftarrow;\u61A9ightarrow;\u61AAf;\uC000\u{1D559}bar;\u6015\u0180clt\u226F\u2274\u2278r;\uC000\u{1D4BD}as\xE8\u21F4rok;\u4127\u0100bp\u2282\u2287ull;\u6043hen\xBB\u1C5B\u0AE1\u22A3\0\u22AA\0\u22B8\u22C5\u22CE\0\u22D5\u22F3\0\0\u22F8\u2322\u2367\u2362\u237F\0\u2386\u23AA\u23B4cute\u803B\xED\u40ED\u0180;iy\u0771\u22B0\u22B5rc\u803B\xEE\u40EE;\u4438\u0100cx\u22BC\u22BFy;\u4435cl\u803B\xA1\u40A1\u0100fr\u039F\u22C9;\uC000\u{1D526}rave\u803B\xEC\u40EC\u0200;ino\u073E\u22DD\u22E9\u22EE\u0100in\u22E2\u22E6nt;\u6A0Ct;\u622Dfin;\u69DCta;\u6129lig;\u4133\u0180aop\u22FE\u231A\u231D\u0180cgt\u2305\u2308\u2317r;\u412B\u0180elp\u071F\u230F\u2313in\xE5\u078Ear\xF4\u0720h;\u4131f;\u62B7ed;\u41B5\u0280;cfot\u04F4\u232C\u2331\u233D\u2341are;\u6105in\u0100;t\u2338\u2339\u621Eie;\u69DDdo\xF4\u2319\u0280;celp\u0757\u234C\u2350\u235B\u2361al;\u62BA\u0100gr\u2355\u2359er\xF3\u1563\xE3\u234Darhk;\u6A17rod;\u6A3C\u0200cgpt\u236F\u2372\u2376\u237By;\u4451on;\u412Ff;\uC000\u{1D55A}a;\u43B9uest\u803B\xBF\u40BF\u0100ci\u238A\u238Fr;\uC000\u{1D4BE}n\u0280;Edsv\u04F4\u239B\u239D\u23A1\u04F3;\u62F9ot;\u62F5\u0100;v\u23A6\u23A7\u62F4;\u62F3\u0100;i\u0777\u23AElde;\u4129\u01EB\u23B8\0\u23BCcy;\u4456l\u803B\xEF\u40EF\u0300cfmosu\u23CC\u23D7\u23DC\u23E1\u23E7\u23F5\u0100iy\u23D1\u23D5rc;\u4135;\u4439r;\uC000\u{1D527}ath;\u4237pf;\uC000\u{1D55B}\u01E3\u23EC\0\u23F1r;\uC000\u{1D4BF}rcy;\u4458kcy;\u4454\u0400acfghjos\u240B\u2416\u2422\u2427\u242D\u2431\u2435\u243Bppa\u0100;v\u2413\u2414\u43BA;\u43F0\u0100ey\u241B\u2420dil;\u4137;\u443Ar;\uC000\u{1D528}reen;\u4138cy;\u4445cy;\u445Cpf;\uC000\u{1D55C}cr;\uC000\u{1D4C0}\u0B80ABEHabcdefghjlmnoprstuv\u2470\u2481\u2486\u248D\u2491\u250E\u253D\u255A\u2580\u264E\u265E\u2665\u2679\u267D\u269A\u26B2\u26D8\u275D\u2768\u278B\u27C0\u2801\u2812\u0180art\u2477\u247A\u247Cr\xF2\u09C6\xF2\u0395ail;\u691Barr;\u690E\u0100;g\u0994\u248B;\u6A8Bar;\u6962\u0963\u24A5\0\u24AA\0\u24B1\0\0\0\0\0\u24B5\u24BA\0\u24C6\u24C8\u24CD\0\u24F9ute;\u413Amptyv;\u69B4ra\xEE\u084Cbda;\u43BBg\u0180;dl\u088E\u24C1\u24C3;\u6991\xE5\u088E;\u6A85uo\u803B\xAB\u40ABr\u0400;bfhlpst\u0899\u24DE\u24E6\u24E9\u24EB\u24EE\u24F1\u24F5\u0100;f\u089D\u24E3s;\u691Fs;\u691D\xEB\u2252p;\u61ABl;\u6939im;\u6973l;\u61A2\u0180;ae\u24FF\u2500\u2504\u6AABil;\u6919\u0100;s\u2509\u250A\u6AAD;\uC000\u2AAD\uFE00\u0180abr\u2515\u2519\u251Drr;\u690Crk;\u6772\u0100ak\u2522\u252Cc\u0100ek\u2528\u252A;\u407B;\u405B\u0100es\u2531\u2533;\u698Bl\u0100du\u2539\u253B;\u698F;\u698D\u0200aeuy\u2546\u254B\u2556\u2558ron;\u413E\u0100di\u2550\u2554il;\u413C\xEC\u08B0\xE2\u2529;\u443B\u0200cqrs\u2563\u2566\u256D\u257Da;\u6936uo\u0100;r\u0E19\u1746\u0100du\u2572\u2577har;\u6967shar;\u694Bh;\u61B2\u0280;fgqs\u258B\u258C\u0989\u25F3\u25FF\u6264t\u0280ahlrt\u2598\u25A4\u25B7\u25C2\u25E8rrow\u0100;t\u0899\u25A1a\xE9\u24F6arpoon\u0100du\u25AF\u25B4own\xBB\u045Ap\xBB\u0966eftarrows;\u61C7ight\u0180ahs\u25CD\u25D6\u25DErrow\u0100;s\u08F4\u08A7arpoon\xF3\u0F98quigarro\xF7\u21F0hreetimes;\u62CB\u0180;qs\u258B\u0993\u25FAlan\xF4\u09AC\u0280;cdgs\u09AC\u260A\u260D\u261D\u2628c;\u6AA8ot\u0100;o\u2614\u2615\u6A7F\u0100;r\u261A\u261B\u6A81;\u6A83\u0100;e\u2622\u2625\uC000\u22DA\uFE00s;\u6A93\u0280adegs\u2633\u2639\u263D\u2649\u264Bppro\xF8\u24C6ot;\u62D6q\u0100gq\u2643\u2645\xF4\u0989gt\xF2\u248C\xF4\u099Bi\xED\u09B2\u0180ilr\u2655\u08E1\u265Asht;\u697C;\uC000\u{1D529}\u0100;E\u099C\u2663;\u6A91\u0161\u2669\u2676r\u0100du\u25B2\u266E\u0100;l\u0965\u2673;\u696Alk;\u6584cy;\u4459\u0280;acht\u0A48\u2688\u268B\u2691\u2696r\xF2\u25C1orne\xF2\u1D08ard;\u696Bri;\u65FA\u0100io\u269F\u26A4dot;\u4140ust\u0100;a\u26AC\u26AD\u63B0che\xBB\u26AD\u0200Eaes\u26BB\u26BD\u26C9\u26D4;\u6268p\u0100;p\u26C3\u26C4\u6A89rox\xBB\u26C4\u0100;q\u26CE\u26CF\u6A87\u0100;q\u26CE\u26BBim;\u62E6\u0400abnoptwz\u26E9\u26F4\u26F7\u271A\u272F\u2741\u2747\u2750\u0100nr\u26EE\u26F1g;\u67ECr;\u61FDr\xEB\u08C1g\u0180lmr\u26FF\u270D\u2714eft\u0100ar\u09E6\u2707ight\xE1\u09F2apsto;\u67FCight\xE1\u09FDparrow\u0100lr\u2725\u2729ef\xF4\u24EDight;\u61AC\u0180afl\u2736\u2739\u273Dr;\u6985;\uC000\u{1D55D}us;\u6A2Dimes;\u6A34\u0161\u274B\u274Fst;\u6217\xE1\u134E\u0180;ef\u2757\u2758\u1800\u65CAnge\xBB\u2758ar\u0100;l\u2764\u2765\u4028t;\u6993\u0280achmt\u2773\u2776\u277C\u2785\u2787r\xF2\u08A8orne\xF2\u1D8Car\u0100;d\u0F98\u2783;\u696D;\u600Eri;\u62BF\u0300achiqt\u2798\u279D\u0A40\u27A2\u27AE\u27BBquo;\u6039r;\uC000\u{1D4C1}m\u0180;eg\u09B2\u27AA\u27AC;\u6A8D;\u6A8F\u0100bu\u252A\u27B3o\u0100;r\u0E1F\u27B9;\u601Arok;\u4142\u8400<;cdhilqr\u082B\u27D2\u2639\u27DC\u27E0\u27E5\u27EA\u27F0\u0100ci\u27D7\u27D9;\u6AA6r;\u6A79re\xE5\u25F2mes;\u62C9arr;\u6976uest;\u6A7B\u0100Pi\u27F5\u27F9ar;\u6996\u0180;ef\u2800\u092D\u181B\u65C3r\u0100du\u2807\u280Dshar;\u694Ahar;\u6966\u0100en\u2817\u2821rtneqq;\uC000\u2268\uFE00\xC5\u281E\u0700Dacdefhilnopsu\u2840\u2845\u2882\u288E\u2893\u28A0\u28A5\u28A8\u28DA\u28E2\u28E4\u0A83\u28F3\u2902Dot;\u623A\u0200clpr\u284E\u2852\u2863\u287Dr\u803B\xAF\u40AF\u0100et\u2857\u2859;\u6642\u0100;e\u285E\u285F\u6720se\xBB\u285F\u0100;s\u103B\u2868to\u0200;dlu\u103B\u2873\u2877\u287Bow\xEE\u048Cef\xF4\u090F\xF0\u13D1ker;\u65AE\u0100oy\u2887\u288Cmma;\u6A29;\u443Cash;\u6014asuredangle\xBB\u1626r;\uC000\u{1D52A}o;\u6127\u0180cdn\u28AF\u28B4\u28C9ro\u803B\xB5\u40B5\u0200;acd\u1464\u28BD\u28C0\u28C4s\xF4\u16A7ir;\u6AF0ot\u80BB\xB7\u01B5us\u0180;bd\u28D2\u1903\u28D3\u6212\u0100;u\u1D3C\u28D8;\u6A2A\u0163\u28DE\u28E1p;\u6ADB\xF2\u2212\xF0\u0A81\u0100dp\u28E9\u28EEels;\u62A7f;\uC000\u{1D55E}\u0100ct\u28F8\u28FDr;\uC000\u{1D4C2}pos\xBB\u159D\u0180;lm\u2909\u290A\u290D\u43BCtimap;\u62B8\u0C00GLRVabcdefghijlmoprstuvw\u2942\u2953\u297E\u2989\u2998\u29DA\u29E9\u2A15\u2A1A\u2A58\u2A5D\u2A83\u2A95\u2AA4\u2AA8\u2B04\u2B07\u2B44\u2B7F\u2BAE\u2C34\u2C67\u2C7C\u2CE9\u0100gt\u2947\u294B;\uC000\u22D9\u0338\u0100;v\u2950\u0BCF\uC000\u226B\u20D2\u0180elt\u295A\u2972\u2976ft\u0100ar\u2961\u2967rrow;\u61CDightarrow;\u61CE;\uC000\u22D8\u0338\u0100;v\u297B\u0C47\uC000\u226A\u20D2ightarrow;\u61CF\u0100Dd\u298E\u2993ash;\u62AFash;\u62AE\u0280bcnpt\u29A3\u29A7\u29AC\u29B1\u29CCla\xBB\u02DEute;\u4144g;\uC000\u2220\u20D2\u0280;Eiop\u0D84\u29BC\u29C0\u29C5\u29C8;\uC000\u2A70\u0338d;\uC000\u224B\u0338s;\u4149ro\xF8\u0D84ur\u0100;a\u29D3\u29D4\u666El\u0100;s\u29D3\u0B38\u01F3\u29DF\0\u29E3p\u80BB\xA0\u0B37mp\u0100;e\u0BF9\u0C00\u0280aeouy\u29F4\u29FE\u2A03\u2A10\u2A13\u01F0\u29F9\0\u29FB;\u6A43on;\u4148dil;\u4146ng\u0100;d\u0D7E\u2A0Aot;\uC000\u2A6D\u0338p;\u6A42;\u443Dash;\u6013\u0380;Aadqsx\u0B92\u2A29\u2A2D\u2A3B\u2A41\u2A45\u2A50rr;\u61D7r\u0100hr\u2A33\u2A36k;\u6924\u0100;o\u13F2\u13F0ot;\uC000\u2250\u0338ui\xF6\u0B63\u0100ei\u2A4A\u2A4Ear;\u6928\xED\u0B98ist\u0100;s\u0BA0\u0B9Fr;\uC000\u{1D52B}\u0200Eest\u0BC5\u2A66\u2A79\u2A7C\u0180;qs\u0BBC\u2A6D\u0BE1\u0180;qs\u0BBC\u0BC5\u2A74lan\xF4\u0BE2i\xED\u0BEA\u0100;r\u0BB6\u2A81\xBB\u0BB7\u0180Aap\u2A8A\u2A8D\u2A91r\xF2\u2971rr;\u61AEar;\u6AF2\u0180;sv\u0F8D\u2A9C\u0F8C\u0100;d\u2AA1\u2AA2\u62FC;\u62FAcy;\u445A\u0380AEadest\u2AB7\u2ABA\u2ABE\u2AC2\u2AC5\u2AF6\u2AF9r\xF2\u2966;\uC000\u2266\u0338rr;\u619Ar;\u6025\u0200;fqs\u0C3B\u2ACE\u2AE3\u2AEFt\u0100ar\u2AD4\u2AD9rro\xF7\u2AC1ightarro\xF7\u2A90\u0180;qs\u0C3B\u2ABA\u2AEAlan\xF4\u0C55\u0100;s\u0C55\u2AF4\xBB\u0C36i\xED\u0C5D\u0100;r\u0C35\u2AFEi\u0100;e\u0C1A\u0C25i\xE4\u0D90\u0100pt\u2B0C\u2B11f;\uC000\u{1D55F}\u8180\xAC;in\u2B19\u2B1A\u2B36\u40ACn\u0200;Edv\u0B89\u2B24\u2B28\u2B2E;\uC000\u22F9\u0338ot;\uC000\u22F5\u0338\u01E1\u0B89\u2B33\u2B35;\u62F7;\u62F6i\u0100;v\u0CB8\u2B3C\u01E1\u0CB8\u2B41\u2B43;\u62FE;\u62FD\u0180aor\u2B4B\u2B63\u2B69r\u0200;ast\u0B7B\u2B55\u2B5A\u2B5Flle\xEC\u0B7Bl;\uC000\u2AFD\u20E5;\uC000\u2202\u0338lint;\u6A14\u0180;ce\u0C92\u2B70\u2B73u\xE5\u0CA5\u0100;c\u0C98\u2B78\u0100;e\u0C92\u2B7D\xF1\u0C98\u0200Aait\u2B88\u2B8B\u2B9D\u2BA7r\xF2\u2988rr\u0180;cw\u2B94\u2B95\u2B99\u619B;\uC000\u2933\u0338;\uC000\u219D\u0338ghtarrow\xBB\u2B95ri\u0100;e\u0CCB\u0CD6\u0380chimpqu\u2BBD\u2BCD\u2BD9\u2B04\u0B78\u2BE4\u2BEF\u0200;cer\u0D32\u2BC6\u0D37\u2BC9u\xE5\u0D45;\uC000\u{1D4C3}ort\u026D\u2B05\0\0\u2BD6ar\xE1\u2B56m\u0100;e\u0D6E\u2BDF\u0100;q\u0D74\u0D73su\u0100bp\u2BEB\u2BED\xE5\u0CF8\xE5\u0D0B\u0180bcp\u2BF6\u2C11\u2C19\u0200;Ees\u2BFF\u2C00\u0D22\u2C04\u6284;\uC000\u2AC5\u0338et\u0100;e\u0D1B\u2C0Bq\u0100;q\u0D23\u2C00c\u0100;e\u0D32\u2C17\xF1\u0D38\u0200;Ees\u2C22\u2C23\u0D5F\u2C27\u6285;\uC000\u2AC6\u0338et\u0100;e\u0D58\u2C2Eq\u0100;q\u0D60\u2C23\u0200gilr\u2C3D\u2C3F\u2C45\u2C47\xEC\u0BD7lde\u803B\xF1\u40F1\xE7\u0C43iangle\u0100lr\u2C52\u2C5Ceft\u0100;e\u0C1A\u2C5A\xF1\u0C26ight\u0100;e\u0CCB\u2C65\xF1\u0CD7\u0100;m\u2C6C\u2C6D\u43BD\u0180;es\u2C74\u2C75\u2C79\u4023ro;\u6116p;\u6007\u0480DHadgilrs\u2C8F\u2C94\u2C99\u2C9E\u2CA3\u2CB0\u2CB6\u2CD3\u2CE3ash;\u62ADarr;\u6904p;\uC000\u224D\u20D2ash;\u62AC\u0100et\u2CA8\u2CAC;\uC000\u2265\u20D2;\uC000>\u20D2nfin;\u69DE\u0180Aet\u2CBD\u2CC1\u2CC5rr;\u6902;\uC000\u2264\u20D2\u0100;r\u2CCA\u2CCD\uC000<\u20D2ie;\uC000\u22B4\u20D2\u0100At\u2CD8\u2CDCrr;\u6903rie;\uC000\u22B5\u20D2im;\uC000\u223C\u20D2\u0180Aan\u2CF0\u2CF4\u2D02rr;\u61D6r\u0100hr\u2CFA\u2CFDk;\u6923\u0100;o\u13E7\u13E5ear;\u6927\u1253\u1A95\0\0\0\0\0\0\0\0\0\0\0\0\0\u2D2D\0\u2D38\u2D48\u2D60\u2D65\u2D72\u2D84\u1B07\0\0\u2D8D\u2DAB\0\u2DC8\u2DCE\0\u2DDC\u2E19\u2E2B\u2E3E\u2E43\u0100cs\u2D31\u1A97ute\u803B\xF3\u40F3\u0100iy\u2D3C\u2D45r\u0100;c\u1A9E\u2D42\u803B\xF4\u40F4;\u443E\u0280abios\u1AA0\u2D52\u2D57\u01C8\u2D5Alac;\u4151v;\u6A38old;\u69BClig;\u4153\u0100cr\u2D69\u2D6Dir;\u69BF;\uC000\u{1D52C}\u036F\u2D79\0\0\u2D7C\0\u2D82n;\u42DBave\u803B\xF2\u40F2;\u69C1\u0100bm\u2D88\u0DF4ar;\u69B5\u0200acit\u2D95\u2D98\u2DA5\u2DA8r\xF2\u1A80\u0100ir\u2D9D\u2DA0r;\u69BEoss;\u69BBn\xE5\u0E52;\u69C0\u0180aei\u2DB1\u2DB5\u2DB9cr;\u414Dga;\u43C9\u0180cdn\u2DC0\u2DC5\u01CDron;\u43BF;\u69B6pf;\uC000\u{1D560}\u0180ael\u2DD4\u2DD7\u01D2r;\u69B7rp;\u69B9\u0380;adiosv\u2DEA\u2DEB\u2DEE\u2E08\u2E0D\u2E10\u2E16\u6228r\xF2\u1A86\u0200;efm\u2DF7\u2DF8\u2E02\u2E05\u6A5Dr\u0100;o\u2DFE\u2DFF\u6134f\xBB\u2DFF\u803B\xAA\u40AA\u803B\xBA\u40BAgof;\u62B6r;\u6A56lope;\u6A57;\u6A5B\u0180clo\u2E1F\u2E21\u2E27\xF2\u2E01ash\u803B\xF8\u40F8l;\u6298i\u016C\u2E2F\u2E34de\u803B\xF5\u40F5es\u0100;a\u01DB\u2E3As;\u6A36ml\u803B\xF6\u40F6bar;\u633D\u0AE1\u2E5E\0\u2E7D\0\u2E80\u2E9D\0\u2EA2\u2EB9\0\0\u2ECB\u0E9C\0\u2F13\0\0\u2F2B\u2FBC\0\u2FC8r\u0200;ast\u0403\u2E67\u2E72\u0E85\u8100\xB6;l\u2E6D\u2E6E\u40B6le\xEC\u0403\u0269\u2E78\0\0\u2E7Bm;\u6AF3;\u6AFDy;\u443Fr\u0280cimpt\u2E8B\u2E8F\u2E93\u1865\u2E97nt;\u4025od;\u402Eil;\u6030enk;\u6031r;\uC000\u{1D52D}\u0180imo\u2EA8\u2EB0\u2EB4\u0100;v\u2EAD\u2EAE\u43C6;\u43D5ma\xF4\u0A76ne;\u660E\u0180;tv\u2EBF\u2EC0\u2EC8\u43C0chfork\xBB\u1FFD;\u43D6\u0100au\u2ECF\u2EDFn\u0100ck\u2ED5\u2EDDk\u0100;h\u21F4\u2EDB;\u610E\xF6\u21F4s\u0480;abcdemst\u2EF3\u2EF4\u1908\u2EF9\u2EFD\u2F04\u2F06\u2F0A\u2F0E\u402Bcir;\u6A23ir;\u6A22\u0100ou\u1D40\u2F02;\u6A25;\u6A72n\u80BB\xB1\u0E9Dim;\u6A26wo;\u6A27\u0180ipu\u2F19\u2F20\u2F25ntint;\u6A15f;\uC000\u{1D561}nd\u803B\xA3\u40A3\u0500;Eaceinosu\u0EC8\u2F3F\u2F41\u2F44\u2F47\u2F81\u2F89\u2F92\u2F7E\u2FB6;\u6AB3p;\u6AB7u\xE5\u0ED9\u0100;c\u0ECE\u2F4C\u0300;acens\u0EC8\u2F59\u2F5F\u2F66\u2F68\u2F7Eppro\xF8\u2F43urlye\xF1\u0ED9\xF1\u0ECE\u0180aes\u2F6F\u2F76\u2F7Approx;\u6AB9qq;\u6AB5im;\u62E8i\xED\u0EDFme\u0100;s\u2F88\u0EAE\u6032\u0180Eas\u2F78\u2F90\u2F7A\xF0\u2F75\u0180dfp\u0EEC\u2F99\u2FAF\u0180als\u2FA0\u2FA5\u2FAAlar;\u632Eine;\u6312urf;\u6313\u0100;t\u0EFB\u2FB4\xEF\u0EFBrel;\u62B0\u0100ci\u2FC0\u2FC5r;\uC000\u{1D4C5};\u43C8ncsp;\u6008\u0300fiopsu\u2FDA\u22E2\u2FDF\u2FE5\u2FEB\u2FF1r;\uC000\u{1D52E}pf;\uC000\u{1D562}rime;\u6057cr;\uC000\u{1D4C6}\u0180aeo\u2FF8\u3009\u3013t\u0100ei\u2FFE\u3005rnion\xF3\u06B0nt;\u6A16st\u0100;e\u3010\u3011\u403F\xF1\u1F19\xF4\u0F14\u0A80ABHabcdefhilmnoprstux\u3040\u3051\u3055\u3059\u30E0\u310E\u312B\u3147\u3162\u3172\u318E\u3206\u3215\u3224\u3229\u3258\u326E\u3272\u3290\u32B0\u32B7\u0180art\u3047\u304A\u304Cr\xF2\u10B3\xF2\u03DDail;\u691Car\xF2\u1C65ar;\u6964\u0380cdenqrt\u3068\u3075\u3078\u307F\u308F\u3094\u30CC\u0100eu\u306D\u3071;\uC000\u223D\u0331te;\u4155i\xE3\u116Emptyv;\u69B3g\u0200;del\u0FD1\u3089\u308B\u308D;\u6992;\u69A5\xE5\u0FD1uo\u803B\xBB\u40BBr\u0580;abcfhlpstw\u0FDC\u30AC\u30AF\u30B7\u30B9\u30BC\u30BE\u30C0\u30C3\u30C7\u30CAp;\u6975\u0100;f\u0FE0\u30B4s;\u6920;\u6933s;\u691E\xEB\u225D\xF0\u272El;\u6945im;\u6974l;\u61A3;\u619D\u0100ai\u30D1\u30D5il;\u691Ao\u0100;n\u30DB\u30DC\u6236al\xF3\u0F1E\u0180abr\u30E7\u30EA\u30EEr\xF2\u17E5rk;\u6773\u0100ak\u30F3\u30FDc\u0100ek\u30F9\u30FB;\u407D;\u405D\u0100es\u3102\u3104;\u698Cl\u0100du\u310A\u310C;\u698E;\u6990\u0200aeuy\u3117\u311C\u3127\u3129ron;\u4159\u0100di\u3121\u3125il;\u4157\xEC\u0FF2\xE2\u30FA;\u4440\u0200clqs\u3134\u3137\u313D\u3144a;\u6937dhar;\u6969uo\u0100;r\u020E\u020Dh;\u61B3\u0180acg\u314E\u315F\u0F44l\u0200;ips\u0F78\u3158\u315B\u109Cn\xE5\u10BBar\xF4\u0FA9t;\u65AD\u0180ilr\u3169\u1023\u316Esht;\u697D;\uC000\u{1D52F}\u0100ao\u3177\u3186r\u0100du\u317D\u317F\xBB\u047B\u0100;l\u1091\u3184;\u696C\u0100;v\u318B\u318C\u43C1;\u43F1\u0180gns\u3195\u31F9\u31FCht\u0300ahlrst\u31A4\u31B0\u31C2\u31D8\u31E4\u31EErrow\u0100;t\u0FDC\u31ADa\xE9\u30C8arpoon\u0100du\u31BB\u31BFow\xEE\u317Ep\xBB\u1092eft\u0100ah\u31CA\u31D0rrow\xF3\u0FEAarpoon\xF3\u0551ightarrows;\u61C9quigarro\xF7\u30CBhreetimes;\u62CCg;\u42DAingdotse\xF1\u1F32\u0180ahm\u320D\u3210\u3213r\xF2\u0FEAa\xF2\u0551;\u600Foust\u0100;a\u321E\u321F\u63B1che\xBB\u321Fmid;\u6AEE\u0200abpt\u3232\u323D\u3240\u3252\u0100nr\u3237\u323Ag;\u67EDr;\u61FEr\xEB\u1003\u0180afl\u3247\u324A\u324Er;\u6986;\uC000\u{1D563}us;\u6A2Eimes;\u6A35\u0100ap\u325D\u3267r\u0100;g\u3263\u3264\u4029t;\u6994olint;\u6A12ar\xF2\u31E3\u0200achq\u327B\u3280\u10BC\u3285quo;\u603Ar;\uC000\u{1D4C7}\u0100bu\u30FB\u328Ao\u0100;r\u0214\u0213\u0180hir\u3297\u329B\u32A0re\xE5\u31F8mes;\u62CAi\u0200;efl\u32AA\u1059\u1821\u32AB\u65B9tri;\u69CEluhar;\u6968;\u611E\u0D61\u32D5\u32DB\u32DF\u332C\u3338\u3371\0\u337A\u33A4\0\0\u33EC\u33F0\0\u3428\u3448\u345A\u34AD\u34B1\u34CA\u34F1\0\u3616\0\0\u3633cute;\u415Bqu\xEF\u27BA\u0500;Eaceinpsy\u11ED\u32F3\u32F5\u32FF\u3302\u330B\u330F\u331F\u3326\u3329;\u6AB4\u01F0\u32FA\0\u32FC;\u6AB8on;\u4161u\xE5\u11FE\u0100;d\u11F3\u3307il;\u415Frc;\u415D\u0180Eas\u3316\u3318\u331B;\u6AB6p;\u6ABAim;\u62E9olint;\u6A13i\xED\u1204;\u4441ot\u0180;be\u3334\u1D47\u3335\u62C5;\u6A66\u0380Aacmstx\u3346\u334A\u3357\u335B\u335E\u3363\u336Drr;\u61D8r\u0100hr\u3350\u3352\xEB\u2228\u0100;o\u0A36\u0A34t\u803B\xA7\u40A7i;\u403Bwar;\u6929m\u0100in\u3369\xF0nu\xF3\xF1t;\u6736r\u0100;o\u3376\u2055\uC000\u{1D530}\u0200acoy\u3382\u3386\u3391\u33A0rp;\u666F\u0100hy\u338B\u338Fcy;\u4449;\u4448rt\u026D\u3399\0\0\u339Ci\xE4\u1464ara\xEC\u2E6F\u803B\xAD\u40AD\u0100gm\u33A8\u33B4ma\u0180;fv\u33B1\u33B2\u33B2\u43C3;\u43C2\u0400;deglnpr\u12AB\u33C5\u33C9\u33CE\u33D6\u33DE\u33E1\u33E6ot;\u6A6A\u0100;q\u12B1\u12B0\u0100;E\u33D3\u33D4\u6A9E;\u6AA0\u0100;E\u33DB\u33DC\u6A9D;\u6A9Fe;\u6246lus;\u6A24arr;\u6972ar\xF2\u113D\u0200aeit\u33F8\u3408\u340F\u3417\u0100ls\u33FD\u3404lsetm\xE9\u336Ahp;\u6A33parsl;\u69E4\u0100dl\u1463\u3414e;\u6323\u0100;e\u341C\u341D\u6AAA\u0100;s\u3422\u3423\u6AAC;\uC000\u2AAC\uFE00\u0180flp\u342E\u3433\u3442tcy;\u444C\u0100;b\u3438\u3439\u402F\u0100;a\u343E\u343F\u69C4r;\u633Ff;\uC000\u{1D564}a\u0100dr\u344D\u0402es\u0100;u\u3454\u3455\u6660it\xBB\u3455\u0180csu\u3460\u3479\u349F\u0100au\u3465\u346Fp\u0100;s\u1188\u346B;\uC000\u2293\uFE00p\u0100;s\u11B4\u3475;\uC000\u2294\uFE00u\u0100bp\u347F\u348F\u0180;es\u1197\u119C\u3486et\u0100;e\u1197\u348D\xF1\u119D\u0180;es\u11A8\u11AD\u3496et\u0100;e\u11A8\u349D\xF1\u11AE\u0180;af\u117B\u34A6\u05B0r\u0165\u34AB\u05B1\xBB\u117Car\xF2\u1148\u0200cemt\u34B9\u34BE\u34C2\u34C5r;\uC000\u{1D4C8}tm\xEE\xF1i\xEC\u3415ar\xE6\u11BE\u0100ar\u34CE\u34D5r\u0100;f\u34D4\u17BF\u6606\u0100an\u34DA\u34EDight\u0100ep\u34E3\u34EApsilo\xEE\u1EE0h\xE9\u2EAFs\xBB\u2852\u0280bcmnp\u34FB\u355E\u1209\u358B\u358E\u0480;Edemnprs\u350E\u350F\u3511\u3515\u351E\u3523\u352C\u3531\u3536\u6282;\u6AC5ot;\u6ABD\u0100;d\u11DA\u351Aot;\u6AC3ult;\u6AC1\u0100Ee\u3528\u352A;\u6ACB;\u628Alus;\u6ABFarr;\u6979\u0180eiu\u353D\u3552\u3555t\u0180;en\u350E\u3545\u354Bq\u0100;q\u11DA\u350Feq\u0100;q\u352B\u3528m;\u6AC7\u0100bp\u355A\u355C;\u6AD5;\u6AD3c\u0300;acens\u11ED\u356C\u3572\u3579\u357B\u3326ppro\xF8\u32FAurlye\xF1\u11FE\xF1\u11F3\u0180aes\u3582\u3588\u331Bppro\xF8\u331Aq\xF1\u3317g;\u666A\u0680123;Edehlmnps\u35A9\u35AC\u35AF\u121C\u35B2\u35B4\u35C0\u35C9\u35D5\u35DA\u35DF\u35E8\u35ED\u803B\xB9\u40B9\u803B\xB2\u40B2\u803B\xB3\u40B3;\u6AC6\u0100os\u35B9\u35BCt;\u6ABEub;\u6AD8\u0100;d\u1222\u35C5ot;\u6AC4s\u0100ou\u35CF\u35D2l;\u67C9b;\u6AD7arr;\u697Bult;\u6AC2\u0100Ee\u35E4\u35E6;\u6ACC;\u628Blus;\u6AC0\u0180eiu\u35F4\u3609\u360Ct\u0180;en\u121C\u35FC\u3602q\u0100;q\u1222\u35B2eq\u0100;q\u35E7\u35E4m;\u6AC8\u0100bp\u3611\u3613;\u6AD4;\u6AD6\u0180Aan\u361C\u3620\u362Drr;\u61D9r\u0100hr\u3626\u3628\xEB\u222E\u0100;o\u0A2B\u0A29war;\u692Alig\u803B\xDF\u40DF\u0BE1\u3651\u365D\u3660\u12CE\u3673\u3679\0\u367E\u36C2\0\0\0\0\0\u36DB\u3703\0\u3709\u376C\0\0\0\u3787\u0272\u3656\0\0\u365Bget;\u6316;\u43C4r\xEB\u0E5F\u0180aey\u3666\u366B\u3670ron;\u4165dil;\u4163;\u4442lrec;\u6315r;\uC000\u{1D531}\u0200eiko\u3686\u369D\u36B5\u36BC\u01F2\u368B\0\u3691e\u01004f\u1284\u1281a\u0180;sv\u3698\u3699\u369B\u43B8ym;\u43D1\u0100cn\u36A2\u36B2k\u0100as\u36A8\u36AEppro\xF8\u12C1im\xBB\u12ACs\xF0\u129E\u0100as\u36BA\u36AE\xF0\u12C1rn\u803B\xFE\u40FE\u01EC\u031F\u36C6\u22E7es\u8180\xD7;bd\u36CF\u36D0\u36D8\u40D7\u0100;a\u190F\u36D5r;\u6A31;\u6A30\u0180eps\u36E1\u36E3\u3700\xE1\u2A4D\u0200;bcf\u0486\u36EC\u36F0\u36F4ot;\u6336ir;\u6AF1\u0100;o\u36F9\u36FC\uC000\u{1D565}rk;\u6ADA\xE1\u3362rime;\u6034\u0180aip\u370F\u3712\u3764d\xE5\u1248\u0380adempst\u3721\u374D\u3740\u3751\u3757\u375C\u375Fngle\u0280;dlqr\u3730\u3731\u3736\u3740\u3742\u65B5own\xBB\u1DBBeft\u0100;e\u2800\u373E\xF1\u092E;\u625Cight\u0100;e\u32AA\u374B\xF1\u105Aot;\u65ECinus;\u6A3Alus;\u6A39b;\u69CDime;\u6A3Bezium;\u63E2\u0180cht\u3772\u377D\u3781\u0100ry\u3777\u377B;\uC000\u{1D4C9};\u4446cy;\u445Brok;\u4167\u0100io\u378B\u378Ex\xF4\u1777head\u0100lr\u3797\u37A0eftarro\xF7\u084Fightarrow\xBB\u0F5D\u0900AHabcdfghlmoprstuw\u37D0\u37D3\u37D7\u37E4\u37F0\u37FC\u380E\u381C\u3823\u3834\u3851\u385D\u386B\u38A9\u38CC\u38D2\u38EA\u38F6r\xF2\u03EDar;\u6963\u0100cr\u37DC\u37E2ute\u803B\xFA\u40FA\xF2\u1150r\u01E3\u37EA\0\u37EDy;\u445Eve;\u416D\u0100iy\u37F5\u37FArc\u803B\xFB\u40FB;\u4443\u0180abh\u3803\u3806\u380Br\xF2\u13ADlac;\u4171a\xF2\u13C3\u0100ir\u3813\u3818sht;\u697E;\uC000\u{1D532}rave\u803B\xF9\u40F9\u0161\u3827\u3831r\u0100lr\u382C\u382E\xBB\u0957\xBB\u1083lk;\u6580\u0100ct\u3839\u384D\u026F\u383F\0\0\u384Arn\u0100;e\u3845\u3846\u631Cr\xBB\u3846op;\u630Fri;\u65F8\u0100al\u3856\u385Acr;\u416B\u80BB\xA8\u0349\u0100gp\u3862\u3866on;\u4173f;\uC000\u{1D566}\u0300adhlsu\u114B\u3878\u387D\u1372\u3891\u38A0own\xE1\u13B3arpoon\u0100lr\u3888\u388Cef\xF4\u382Digh\xF4\u382Fi\u0180;hl\u3899\u389A\u389C\u43C5\xBB\u13FAon\xBB\u389Aparrows;\u61C8\u0180cit\u38B0\u38C4\u38C8\u026F\u38B6\0\0\u38C1rn\u0100;e\u38BC\u38BD\u631Dr\xBB\u38BDop;\u630Eng;\u416Fri;\u65F9cr;\uC000\u{1D4CA}\u0180dir\u38D9\u38DD\u38E2ot;\u62F0lde;\u4169i\u0100;f\u3730\u38E8\xBB\u1813\u0100am\u38EF\u38F2r\xF2\u38A8l\u803B\xFC\u40FCangle;\u69A7\u0780ABDacdeflnoprsz\u391C\u391F\u3929\u392D\u39B5\u39B8\u39BD\u39DF\u39E4\u39E8\u39F3\u39F9\u39FD\u3A01\u3A20r\xF2\u03F7ar\u0100;v\u3926\u3927\u6AE8;\u6AE9as\xE8\u03E1\u0100nr\u3932\u3937grt;\u699C\u0380eknprst\u34E3\u3946\u394B\u3952\u395D\u3964\u3996app\xE1\u2415othin\xE7\u1E96\u0180hir\u34EB\u2EC8\u3959op\xF4\u2FB5\u0100;h\u13B7\u3962\xEF\u318D\u0100iu\u3969\u396Dgm\xE1\u33B3\u0100bp\u3972\u3984setneq\u0100;q\u397D\u3980\uC000\u228A\uFE00;\uC000\u2ACB\uFE00setneq\u0100;q\u398F\u3992\uC000\u228B\uFE00;\uC000\u2ACC\uFE00\u0100hr\u399B\u399Fet\xE1\u369Ciangle\u0100lr\u39AA\u39AFeft\xBB\u0925ight\xBB\u1051y;\u4432ash\xBB\u1036\u0180elr\u39C4\u39D2\u39D7\u0180;be\u2DEA\u39CB\u39CFar;\u62BBq;\u625Alip;\u62EE\u0100bt\u39DC\u1468a\xF2\u1469r;\uC000\u{1D533}tr\xE9\u39AEsu\u0100bp\u39EF\u39F1\xBB\u0D1C\xBB\u0D59pf;\uC000\u{1D567}ro\xF0\u0EFBtr\xE9\u39B4\u0100cu\u3A06\u3A0Br;\uC000\u{1D4CB}\u0100bp\u3A10\u3A18n\u0100Ee\u3980\u3A16\xBB\u397En\u0100Ee\u3992\u3A1E\xBB\u3990igzag;\u699A\u0380cefoprs\u3A36\u3A3B\u3A56\u3A5B\u3A54\u3A61\u3A6Airc;\u4175\u0100di\u3A40\u3A51\u0100bg\u3A45\u3A49ar;\u6A5Fe\u0100;q\u15FA\u3A4F;\u6259erp;\u6118r;\uC000\u{1D534}pf;\uC000\u{1D568}\u0100;e\u1479\u3A66at\xE8\u1479cr;\uC000\u{1D4CC}\u0AE3\u178E\u3A87\0\u3A8B\0\u3A90\u3A9B\0\0\u3A9D\u3AA8\u3AAB\u3AAF\0\0\u3AC3\u3ACE\0\u3AD8\u17DC\u17DFtr\xE9\u17D1r;\uC000\u{1D535}\u0100Aa\u3A94\u3A97r\xF2\u03C3r\xF2\u09F6;\u43BE\u0100Aa\u3AA1\u3AA4r\xF2\u03B8r\xF2\u09EBa\xF0\u2713is;\u62FB\u0180dpt\u17A4\u3AB5\u3ABE\u0100fl\u3ABA\u17A9;\uC000\u{1D569}im\xE5\u17B2\u0100Aa\u3AC7\u3ACAr\xF2\u03CEr\xF2\u0A01\u0100cq\u3AD2\u17B8r;\uC000\u{1D4CD}\u0100pt\u17D6\u3ADCr\xE9\u17D4\u0400acefiosu\u3AF0\u3AFD\u3B08\u3B0C\u3B11\u3B15\u3B1B\u3B21c\u0100uy\u3AF6\u3AFBte\u803B\xFD\u40FD;\u444F\u0100iy\u3B02\u3B06rc;\u4177;\u444Bn\u803B\xA5\u40A5r;\uC000\u{1D536}cy;\u4457pf;\uC000\u{1D56A}cr;\uC000\u{1D4CE}\u0100cm\u3B26\u3B29y;\u444El\u803B\xFF\u40FF\u0500acdefhiosw\u3B42\u3B48\u3B54\u3B58\u3B64\u3B69\u3B6D\u3B74\u3B7A\u3B80cute;\u417A\u0100ay\u3B4D\u3B52ron;\u417E;\u4437ot;\u417C\u0100et\u3B5D\u3B61tr\xE6\u155Fa;\u43B6r;\uC000\u{1D537}cy;\u4436grarr;\u61DDpf;\uC000\u{1D56B}cr;\uC000\u{1D4CF}\u0100jn\u3B85\u3B87;\u600Dj;\u600C'.split("").map(e=>e.charCodeAt(0)));var aa=new Uint16Array("\u0200aglq \x1B\u026D\0\0p;\u4026os;\u4027t;\u403Et;\u403Cuot;\u4022".split("").map(e=>e.charCodeAt(0)));var qu,Cs=new Map([[0,65533],[128,8364],[130,8218],[131,402],[132,8222],[133,8230],[134,8224],[135,8225],[136,710],[137,8240],[138,352],[139,8249],[140,338],[142,381],[145,8216],[146,8217],[147,8220],[148,8221],[149,8226],[150,8211],[151,8212],[152,732],[153,8482],[154,353],[155,8250],[156,339],[158,382],[159,376]]),Ju=(qu=String.fromCodePoint)!==null&&qu!==void 0?qu:function(e){let t="";return e>65535&&(e-=65536,t+=String.fromCharCode(e>>>10&1023|55296),e=56320|e&1023),t+=String.fromCharCode(e),t};function Vu(e){var t;return e>=55296&&e<=57343||e>1114111?65533:(t=Cs.get(e))!==null&&t!==void 0?t:e}var H;(function(e){e[e.NUM=35]="NUM",e[e.SEMI=59]="SEMI",e[e.EQUALS=61]="EQUALS",e[e.ZERO=48]="ZERO",e[e.NINE=57]="NINE",e[e.LOWER_A=97]="LOWER_A",e[e.LOWER_F=102]="LOWER_F",e[e.LOWER_X=120]="LOWER_X",e[e.LOWER_Z=122]="LOWER_Z",e[e.UPPER_A=65]="UPPER_A",e[e.UPPER_F=70]="UPPER_F",e[e.UPPER_Z=90]="UPPER_Z"})(H||(H={}));var xs=32,pe;(function(e){e[e.VALUE_LENGTH=49152]="VALUE_LENGTH",e[e.BRANCH_LENGTH=16256]="BRANCH_LENGTH",e[e.JUMP_TABLE=127]="JUMP_TABLE"})(pe||(pe={}));function Zu(e){return e>=H.ZERO&&e<=H.NINE}function Ns(e){return e>=H.UPPER_A&&e<=H.UPPER_F||e>=H.LOWER_A&&e<=H.LOWER_F}function _s(e){return e>=H.UPPER_A&&e<=H.UPPER_Z||e>=H.LOWER_A&&e<=H.LOWER_Z||Zu(e)}function Ss(e){return e===H.EQUALS||_s(e)}var P;(function(e){e[e.EntityStart=0]="EntityStart",e[e.NumericStart=1]="NumericStart",e[e.NumericDecimal=2]="NumericDecimal",e[e.NumericHex=3]="NumericHex",e[e.NamedEntity=4]="NamedEntity"})(P||(P={}));var le;(function(e){e[e.Legacy=0]="Legacy",e[e.Strict=1]="Strict",e[e.Attribute=2]="Attribute"})(le||(le={}));var Ut=class{constructor(t,u,a){this.decodeTree=t,this.emitCodePoint=u,this.errors=a,this.state=P.EntityStart,this.consumed=1,this.result=0,this.treeIndex=0,this.excess=1,this.decodeMode=le.Strict}startEntity(t){this.decodeMode=t,this.state=P.EntityStart,this.result=0,this.treeIndex=0,this.excess=1,this.consumed=1}write(t,u){switch(this.state){case P.EntityStart:return t.charCodeAt(u)===H.NUM?(this.state=P.NumericStart,this.consumed+=1,this.stateNumericStart(t,u+1)):(this.state=P.NamedEntity,this.stateNamedEntity(t,u));case P.NumericStart:return this.stateNumericStart(t,u);case P.NumericDecimal:return this.stateNumericDecimal(t,u);case P.NumericHex:return this.stateNumericHex(t,u);case P.NamedEntity:return this.stateNamedEntity(t,u)}}stateNumericStart(t,u){return u>=t.length?-1:(t.charCodeAt(u)|xs)===H.LOWER_X?(this.state=P.NumericHex,this.consumed+=1,this.stateNumericHex(t,u+1)):(this.state=P.NumericDecimal,this.stateNumericDecimal(t,u))}addToNumericResult(t,u,a,s){if(u!==a){let i=a-u;this.result=this.result*Math.pow(s,i)+parseInt(t.substr(u,i),s),this.consumed+=i}}stateNumericHex(t,u){let a=u;for(;u>14;for(;u>14,i!==0){if(n===H.SEMI)return this.emitNamedEntityData(this.treeIndex,i,this.consumed+this.excess);this.decodeMode!==le.Strict&&(this.result=this.treeIndex,this.consumed+=this.excess,this.excess=0)}}return-1}emitNotTerminatedNamedEntity(){var t;let{result:u,decodeTree:a}=this,s=(a[u]&pe.VALUE_LENGTH)>>14;return this.emitNamedEntityData(u,s,this.consumed),(t=this.errors)===null||t===void 0||t.missingSemicolonAfterCharacterReference(),this.consumed}emitNamedEntityData(t,u,a){let{decodeTree:s}=this;return this.emitCodePoint(u===1?s[t]&~pe.VALUE_LENGTH:s[t+1],a),u===3&&this.emitCodePoint(s[t+2],a),a}end(){var t;switch(this.state){case P.NamedEntity:return this.result!==0&&(this.decodeMode!==le.Attribute||this.result===this.treeIndex)?this.emitNotTerminatedNamedEntity():0;case P.NumericDecimal:return this.emitNumericEntity(0,2);case P.NumericHex:return this.emitNumericEntity(0,3);case P.NumericStart:return(t=this.errors)===null||t===void 0||t.absenceOfDigitsInNumericCharacterReference(this.consumed),0;case P.EntityStart:return 0}}};function ra(e){let t="",u=new Ut(e,a=>t+=Ju(a));return function(s,i){let n=0,d=0;for(;(d=s.indexOf("&",d))>=0;){t+=s.slice(n,d),u.startEntity(i);let h=u.write(s,d+1);if(h<0){n=d+u.end();break}n=d+h,d=h===0?n+1:n}let l=t+s.slice(n);return t="",l}}function Ds(e,t,u,a){let s=(t&pe.BRANCH_LENGTH)>>7,i=t&pe.JUMP_TABLE;if(s===0)return i!==0&&a===i?u:-1;if(i){let l=a-i;return l<0||l>=s?-1:e[u+l]-1}let n=u,d=n+s-1;for(;n<=d;){let l=n+d>>>1,h=e[l];if(ha)d=l-1;else return e[l+s]}return-1}var rd=ra(ua),sd=ra(aa);function vt(e){for(let t=1;t$\x80-\uFFFF]/g,sa=new Map([[34,"""],[38,"&"],[39,"'"],[60,"<"],[62,">"]]),ia=String.prototype.codePointAt!=null?(e,t)=>e.codePointAt(t):(e,t)=>(e.charCodeAt(t)&64512)===55296?(e.charCodeAt(t)-55296)*1024+e.charCodeAt(t+1)-56320+65536:e.charCodeAt(t);function At(e){let t="",u=0,a;for(;(a=Xu.exec(e))!==null;){let s=a.index,i=e.charCodeAt(s),n=sa.get(i);n!==void 0?(t+=e.substring(u,s)+n,u=s+1):(t+=`${e.substring(u,s)}&#x${ia(e,s).toString(16)};`,u=Xu.lastIndex+=+((i&64512)===55296))}return t+e.substr(u)}function ju(e,t){return function(a){let s,i=0,n="";for(;s=e.exec(a);)i!==s.index&&(n+=a.substring(i,s.index)),n+=t.get(s[0].charCodeAt(0)),i=s.index+1;return n+a.substring(i)}}var na=ju(/[&<>'"]/g,sa),Qt=ju(/["&\u00A0]/g,new Map([[34,"""],[38,"&"],[160," "]])),Yt=ju(/[&<>\u00A0]/g,new Map([[38,"&"],[60,"<"],[62,">"],[160," "]]));var ca;(function(e){e[e.XML=0]="XML",e[e.HTML=1]="HTML"})(ca||(ca={}));var oa;(function(e){e[e.UTF8=0]="UTF8",e[e.ASCII=1]="ASCII",e[e.Extensive=2]="Extensive",e[e.Attribute=3]="Attribute",e[e.Text=4]="Text"})(oa||(oa={}));var la=new Map(["altGlyph","altGlyphDef","altGlyphItem","animateColor","animateMotion","animateTransform","clipPath","feBlend","feColorMatrix","feComponentTransfer","feComposite","feConvolveMatrix","feDiffuseLighting","feDisplacementMap","feDistantLight","feDropShadow","feFlood","feFuncA","feFuncB","feFuncG","feFuncR","feGaussianBlur","feImage","feMerge","feMergeNode","feMorphology","feOffset","fePointLight","feSpecularLighting","feSpotLight","feTile","feTurbulence","foreignObject","glyphRef","linearGradient","radialGradient","textPath"].map(e=>[e.toLowerCase(),e])),fa=new Map(["definitionURL","attributeName","attributeType","baseFrequency","baseProfile","calcMode","clipPathUnits","diffuseConstant","edgeMode","filterUnits","glyphRef","gradientTransform","gradientUnits","kernelMatrix","kernelUnitLength","keyPoints","keySplines","keyTimes","lengthAdjust","limitingConeAngle","markerHeight","markerUnits","markerWidth","maskContentUnits","maskUnits","numOctaves","pathLength","patternContentUnits","patternTransform","patternUnits","pointsAtX","pointsAtY","pointsAtZ","preserveAlpha","preserveAspectRatio","primitiveUnits","refX","refY","repeatCount","repeatDur","requiredExtensions","requiredFeatures","specularConstant","specularExponent","spreadMethod","startOffset","stdDeviation","stitchTiles","surfaceScale","systemLanguage","tableValues","targetX","targetY","textLength","viewBox","viewTarget","xChannelSelector","yChannelSelector","zoomAndPan"].map(e=>[e.toLowerCase(),e]));var Ls=new Set(["style","script","xmp","iframe","noembed","noframes","plaintext","noscript"]);function ys(e){return e.replace(/"/g,""")}function Os(e,t){var u;if(!e)return;let a=((u=t.encodeEntities)!==null&&u!==void 0?u:t.decodeEntities)===!1?ys:t.xmlMode||t.encodeEntities!=="utf8"?At:Qt;return Object.keys(e).map(s=>{var i,n;let d=(i=e[s])!==null&&i!==void 0?i:"";return t.xmlMode==="foreign"&&(s=(n=fa.get(s))!==null&&n!==void 0?n:s),!t.emptyAttrs&&!t.xmlMode&&d===""?s:`${s}="${a(d)}"`}).join(" ")}var ha=new Set(["area","base","basefont","br","col","command","embed","frame","hr","img","input","isindex","keygen","link","meta","param","source","track","wbr"]);function e0(e,t={}){let u="length"in e?e:[e],a="";for(let s=0;s0&&(a+=e0(e.children,t)),(t.xmlMode||!ha.has(e.name))&&(a+=``)),a}function Ps(e){return`<${e.data}>`}function Hs(e,t){var u;let a=e.data||"";return((u=t.encodeEntities)!==null&&u!==void 0?u:t.decodeEntities)!==!1&&!(!t.xmlMode&&e.parent&&Ls.has(e.parent.name))&&(a=t.xmlMode||t.encodeEntities!=="utf8"?At(a):Yt(a)),a}function Us(e){return``}function vs(e){return``}function ba(e,t){return Gt(e,t)}function Qs(e,t){return R(e)?e.children.map(u=>ba(u,t)).join(""):""}function Kt(e){return Array.isArray(e)?e.map(Kt).join(""):C(e)?e.name==="br"?` +`:Kt(e.children):Ke(e)?Kt(e.children):J(e)?e.data:""}function fe(e){return Array.isArray(e)?e.map(fe).join(""):R(e)&&!Me(e)?fe(e.children):J(e)?e.data:""}function dt(e){return Array.isArray(e)?e.map(dt).join(""):R(e)&&(e.type===D.Tag||Ke(e))?dt(e.children):J(e)?e.data:""}function Je(e){return R(e)?e.children:[]}function Ea(e){return e.parent||null}function t0(e){let t=Ea(e);if(t!=null)return Je(t);let u=[e],{prev:a,next:s}=e;for(;a!=null;)u.unshift(a),{prev:a}=a;for(;s!=null;)u.push(s),{next:s}=s;return u}function Ys(e,t){var u;return(u=e.attribs)===null||u===void 0?void 0:u[t]}function Gs(e,t){return e.attribs!=null&&Object.prototype.hasOwnProperty.call(e.attribs,t)&&e.attribs[t]!=null}function Ks(e){return e.name}function Wt(e){let{next:t}=e;for(;t!==null&&!C(t);)({next:t}=t);return t}function qt(e){let{prev:t}=e;for(;t!==null&&!C(t);)({prev:t}=t);return t}function he(e){if(e.prev&&(e.prev.next=e.next),e.next&&(e.next.prev=e.prev),e.parent){let t=e.parent.children,u=t.lastIndexOf(e);u>=0&&t.splice(u,1)}e.next=null,e.prev=null,e.parent=null}function Ws(e,t){let u=t.prev=e.prev;u&&(u.next=t);let a=t.next=e.next;a&&(a.prev=t);let s=t.parent=e.parent;if(s){let i=s.children;i[i.lastIndexOf(e)]=t,e.parent=null}}function qs(e,t){if(he(t),t.next=null,t.parent=e,e.children.push(t)>1){let u=e.children[e.children.length-2];u.next=t,t.prev=u}else t.prev=null}function Js(e,t){he(t);let{parent:u}=e,a=e.next;if(t.next=a,t.prev=e,e.next=t,t.parent=u,a){if(a.prev=t,u){let s=u.children;s.splice(s.lastIndexOf(a),0,t)}}else u&&u.children.push(t)}function Vs(e,t){if(he(t),t.parent=e,t.prev=null,e.children.unshift(t)!==1){let u=e.children[1];u.prev=t,t.next=u}else t.next=null}function Zs(e,t){he(t);let{parent:u}=e;if(u){let a=u.children;a.splice(a.indexOf(e),0,t)}e.prev&&(e.prev.next=t),t.parent=u,t.prev=e.prev,t.next=e,e.prev=t}function Ve(e,t,u=!0,a=1/0){return Jt(e,Array.isArray(t)?t:[t],u,a)}function Jt(e,t,u,a){let s=[],i=[Array.isArray(t)?t:[t]],n=[0];for(;;){if(n[0]>=i[0].length){if(n.length===1)return s;i.shift(),n.shift();continue}let d=i[0][n[0]++];if(e(d)&&(s.push(d),--a<=0))return s;u&&R(d)&&d.children.length>0&&(n.unshift(0),i.unshift(d.children))}}function Xs(e,t){return t.find(e)}function Vt(e,t,u=!0){let a=Array.isArray(t)?t:[t];for(let s=0;s0){let n=Vt(e,i.children,!0);if(n)return n}}return null}function ma(e,t){return(Array.isArray(t)?t:[t]).some(u=>C(u)&&e(u)||R(u)&&ma(e,u.children))}function js(e,t){let u=[],a=[Array.isArray(t)?t:[t]],s=[0];for(;;){if(s[0]>=a[0].length){if(a.length===1)return u;a.shift(),s.shift();continue}let i=a[0][s[0]++];C(i)&&e(i)&&u.push(i),R(i)&&i.children.length>0&&(s.unshift(0),a.unshift(i.children))}}var Zt={tag_name(e){return typeof e=="function"?t=>C(t)&&e(t.name):e==="*"?C:t=>C(t)&&t.name===e},tag_type(e){return typeof e=="function"?t=>e(t.type):t=>t.type===e},tag_contains(e){return typeof e=="function"?t=>J(t)&&e(t.data):t=>J(t)&&t.data===e}};function u0(e,t){return typeof t=="function"?u=>C(u)&&t(u.attribs[e]):u=>C(u)&&u.attribs[e]===t}function zs(e,t){return u=>e(u)||t(u)}function ga(e){let t=Object.keys(e).map(u=>{let a=e[u];return Object.prototype.hasOwnProperty.call(Zt,u)?Zt[u](a):u0(u,a)});return t.length===0?null:t.reduce(zs)}function $s(e,t){let u=ga(e);return u?u(t):!0}function ei(e,t,u,a=1/0){let s=ga(e);return s?Ve(s,t,u,a):[]}function ti(e,t,u=!0){return Array.isArray(t)||(t=[t]),Vt(u0("id",e),t,u)}function ke(e,t,u=!0,a=1/0){return Ve(Zt.tag_name(e),t,u,a)}function ui(e,t,u=!0,a=1/0){return Ve(u0("class",e),t,u,a)}function ai(e,t,u=!0,a=1/0){return Ve(Zt.tag_type(e),t,u,a)}function ri(e){let t=e.length;for(;--t>=0;){let u=e[t];if(t>0&&e.lastIndexOf(u,t-1)>=0){e.splice(t,1);continue}for(let a=u.parent;a;a=a.parent)if(e.includes(a)){e.splice(t,1);break}}return e}var $;(function(e){e[e.DISCONNECTED=1]="DISCONNECTED",e[e.PRECEDING=2]="PRECEDING",e[e.FOLLOWING=4]="FOLLOWING",e[e.CONTAINS=8]="CONTAINS",e[e.CONTAINED_BY=16]="CONTAINED_BY"})($||($={}));function pa(e,t){let u=[],a=[];if(e===t)return 0;let s=R(e)?e:e.parent;for(;s;)u.unshift(s),s=s.parent;for(s=R(t)?t:t.parent;s;)a.unshift(s),s=s.parent;let i=Math.min(u.length,a.length),n=0;for(;nl.indexOf(m)?d===t?$.FOLLOWING|$.CONTAINED_BY:$.FOLLOWING:d===e?$.PRECEDING|$.CONTAINS:$.PRECEDING}function Te(e){return e=e.filter((t,u,a)=>!a.includes(t,u+1)),e.sort((t,u)=>{let a=pa(t,u);return a&$.PRECEDING?-1:a&$.FOLLOWING?1:0}),e}function a0(e){let t=Xt(oi,e);return t?t.name==="feed"?si(t):ii(t):null}function si(e){var t;let u=e.children,a={type:"atom",items:ke("entry",u).map(n=>{var d;let{children:l}=n,h={media:Ta(l)};V(h,"id","id",l),V(h,"title","title",l);let m=(d=Xt("link",l))===null||d===void 0?void 0:d.attribs.href;m&&(h.link=m);let T=Ie("summary",l)||Ie("content",l);T&&(h.description=T);let I=Ie("updated",l);return I&&(h.pubDate=new Date(I)),h})};V(a,"id","id",u),V(a,"title","title",u);let s=(t=Xt("link",u))===null||t===void 0?void 0:t.attribs.href;s&&(a.link=s),V(a,"description","subtitle",u);let i=Ie("updated",u);return i&&(a.updated=new Date(i)),V(a,"author","email",u,!0),a}function ii(e){var t,u;let a=(u=(t=Xt("channel",e.children))===null||t===void 0?void 0:t.children)!==null&&u!==void 0?u:[],s={type:e.name.substr(0,3),id:"",items:ke("item",e.children).map(n=>{let{children:d}=n,l={media:Ta(d)};V(l,"id","guid",d),V(l,"title","title",d),V(l,"link","link",d),V(l,"description","description",d);let h=Ie("pubDate",d)||Ie("dc:date",d);return h&&(l.pubDate=new Date(h)),l})};V(s,"title","title",a),V(s,"link","link",a),V(s,"description","description",a);let i=Ie("lastBuildDate",a);return i&&(s.updated=new Date(i)),V(s,"author","managingEditor",a,!0),s}var ni=["url","type","lang"],ci=["fileSize","bitrate","framerate","samplingrate","channels","duration","height","width"];function Ta(e){return ke("media:content",e).map(t=>{let{attribs:u}=t,a={medium:u.medium,isDefault:!!u.isDefault};for(let s of ni)u[s]&&(a[s]=u[s]);for(let s of ci)u[s]&&(a[s]=parseInt(u[s],10));return u.expression&&(a.expression=u.expression),a})}function Xt(e,t){return ke(e,t,!0,1)[0]}function Ie(e,t,u=!1){return fe(ke(e,t,u,1)).trim()}function V(e,t,u,a,s=!1){let i=Ie(u,a,s);i&&(e[t]=i)}function oi(e){return e==="rss"||e==="feed"||e==="rdf:RDF"}var Ai={_useHtmlParser2:!1};function lt(e,t){if(!e)return t??Ai;let u={_useHtmlParser2:!!e.xmlMode,...t,...e};return e.xml?(u._useHtmlParser2=!0,u.xmlMode=!0,e.xml!==!0&&Object.assign(u,e.xml)):e.xmlMode&&(u._useHtmlParser2=!0),u}function Ca(e,t,u){return e?e(t??e._root.children,null,void 0,u).toString():""}function di(e,t){return!t&&typeof e=="object"&&e!=null&&!("length"in e)&&!("type"in e)}function li(e,t){let u=di(e)?(t=e,void 0):e,a={...this===null||this===void 0?void 0:this._options,...lt(t)};return Ca(this,u,a)}function fi(e){let t={...this._options,xmlMode:!0};return Ca(this,e,t)}function Pe(e){let t=e??(this?this.root():[]),u="";for(let a=0;aPa,attr:()=>Di,data:()=>yi,hasClass:()=>Fi,prop:()=>Bi,removeAttr:()=>wi,removeClass:()=>Ha,toggleClass:()=>Ua,val:()=>Oi});function ee(e){return e.cheerio!=null}function Na(e){return e.replace(/[._-](\w|$)/g,(t,u)=>u.toUpperCase())}function _a(e){return e.replace(/[A-Z]/g,"-$&").toLowerCase()}function y(e,t){let u=e.length;for(let a=0;ae.length-3)return!1;let u=e.charCodeAt(t+1);return(u>=He.LowerA&&u<=He.LowerZ||u>=He.UpperA&&u<=He.UpperZ||u===He.Exclamation)&&e.includes(">",t+2)}var s0,mi=new Map([[0,65533],[128,8364],[130,8218],[131,402],[132,8222],[133,8230],[134,8224],[135,8225],[136,710],[137,8240],[138,352],[139,8249],[140,338],[142,381],[145,8216],[146,8217],[147,8220],[148,8221],[149,8226],[150,8211],[151,8212],[152,732],[153,8482],[154,353],[155,8250],[156,339],[158,382],[159,376]]),ht=(s0=String.fromCodePoint)!==null&&s0!==void 0?s0:(e=>{let t="";return e>65535&&(e-=65536,t+=String.fromCharCode(e>>>10&1023|55296),e=56320|e&1023),t+=String.fromCharCode(e),t});function i0(e){var t;return e>=55296&&e<=57343||e>1114111?65533:(t=mi.get(e))!==null&&t!==void 0?t:e}function zt(e){let t=typeof atob=="function"?atob(e):typeof Buffer.from=="function"?Buffer.from(e,"base64").toString("binary"):new Buffer(e,"base64").toString("binary"),u=t.length&-2,a=new Uint16Array(u/2);for(let s=0,i=0;s=w.ZERO&&e<=w.NINE}function gi(e){return e>=w.UPPER_A&&e<=w.UPPER_F||e>=w.LOWER_A&&e<=w.LOWER_F}function pi(e){return e>=w.UPPER_A&&e<=w.UPPER_Z||e>=w.LOWER_A&&e<=w.LOWER_Z||n0(e)}function Ti(e){return e===w.EQUALS||pi(e)}var U;(function(e){e[e.EntityStart=0]="EntityStart",e[e.NumericStart=1]="NumericStart",e[e.NumericDecimal=2]="NumericDecimal",e[e.NumericHex=3]="NumericHex",e[e.NamedEntity=4]="NamedEntity"})(U||(U={}));var ie;(function(e){e[e.Legacy=0]="Legacy",e[e.Strict=1]="Strict",e[e.Attribute=2]="Attribute"})(ie||(ie={}));var tu=class{constructor(t,u,a){this.decodeTree=t,this.emitCodePoint=u,this.errors=a,this.state=U.EntityStart,this.consumed=1,this.result=0,this.treeIndex=0,this.excess=1,this.decodeMode=ie.Strict,this.runConsumed=0}startEntity(t){this.decodeMode=t,this.state=U.EntityStart,this.result=0,this.treeIndex=0,this.excess=1,this.consumed=1,this.runConsumed=0}write(t,u){switch(this.state){case U.EntityStart:return t.charCodeAt(u)===w.NUM?(this.state=U.NumericStart,this.consumed+=1,this.stateNumericStart(t,u+1)):(this.state=U.NamedEntity,this.stateNamedEntity(t,u));case U.NumericStart:return this.stateNumericStart(t,u);case U.NumericDecimal:return this.stateNumericDecimal(t,u);case U.NumericHex:return this.stateNumericHex(t,u);case U.NamedEntity:return this.stateNamedEntity(t,u)}}stateNumericStart(t,u){return u>=t.length?-1:(t.charCodeAt(u)|Sa)===w.LOWER_X?(this.state=U.NumericHex,this.consumed+=1,this.stateNumericHex(t,u+1)):(this.state=U.NumericDecimal,this.stateNumericDecimal(t,u))}stateNumericHex(t,u){for(;u>14;for(;u>7;if(this.runConsumed===0){let l=s&Y.JUMP_TABLE;if(t.charCodeAt(u)!==l)return this.result===0?0:this.emitNotTerminatedNamedEntity();u++,this.excess++,this.runConsumed++}for(;this.runConsumed=t.length)return-1;let l=this.runConsumed-1,h=a[this.treeIndex+1+(l>>1)],m=l%2===0?h&255:h>>8&255;if(t.charCodeAt(u)!==m)return this.runConsumed=0,this.result===0?0:this.emitNotTerminatedNamedEntity();u++,this.excess++,this.runConsumed++}this.runConsumed=0,this.treeIndex+=1+(d>>1),s=a[this.treeIndex],i=(s&Y.VALUE_LENGTH)>>14}if(u>=t.length)break;let n=t.charCodeAt(u);if(n===w.SEMI&&i!==0&&(s&Y.FLAG13)!==0)return this.emitNamedEntityData(this.treeIndex,i,this.consumed+this.excess);if(this.treeIndex=Ii(a,s,this.treeIndex+Math.max(1,i),n),this.treeIndex<0)return this.result===0||this.decodeMode===ie.Attribute&&(i===0||Ti(n))?0:this.emitNotTerminatedNamedEntity();if(s=a[this.treeIndex],i=(s&Y.VALUE_LENGTH)>>14,i!==0){if(n===w.SEMI)return this.emitNamedEntityData(this.treeIndex,i,this.consumed+this.excess);this.decodeMode!==ie.Strict&&(s&Y.FLAG13)===0&&(this.result=this.treeIndex,this.consumed+=this.excess,this.excess=0)}u++,this.excess++}return-1}emitNotTerminatedNamedEntity(){var t;let{result:u,decodeTree:a}=this,s=(a[u]&Y.VALUE_LENGTH)>>14;return this.emitNamedEntityData(u,s,this.consumed),(t=this.errors)===null||t===void 0||t.missingSemicolonAfterCharacterReference(),this.consumed}emitNamedEntityData(t,u,a){let{decodeTree:s}=this;return this.emitCodePoint(u===1?s[t]&~(Y.VALUE_LENGTH|Y.FLAG13):s[t+1],a),u===3&&this.emitCodePoint(s[t+2],a),a}end(){var t;switch(this.state){case U.NamedEntity:return this.result!==0&&(this.decodeMode!==ie.Attribute||this.result===this.treeIndex)?this.emitNotTerminatedNamedEntity():0;case U.NumericDecimal:return this.emitNumericEntity(0,2);case U.NumericHex:return this.emitNumericEntity(0,3);case U.NumericStart:return(t=this.errors)===null||t===void 0||t.absenceOfDigitsInNumericCharacterReference(this.consumed),0;case U.EntityStart:return 0}}};function Ii(e,t,u,a){let s=(t&Y.BRANCH_LENGTH)>>7,i=t&Y.JUMP_TABLE;if(s===0)return i!==0&&a===i?u:-1;if(i){let h=a-i;return h<0||h>=s?-1:e[u+h]-1}let n=s+1>>1,d=0,l=s-1;for(;d<=l;){let h=d+l>>>1,m=h>>1,I=e[u+m]>>(h&1)*8&255;if(Ia)l=h-1;else return e[u+n+h]}return-1}var _;(function(e){e[e.Tab=9]="Tab",e[e.NewLine=10]="NewLine",e[e.FormFeed=12]="FormFeed",e[e.CarriageReturn=13]="CarriageReturn",e[e.Space=32]="Space",e[e.ExclamationMark=33]="ExclamationMark",e[e.Number=35]="Number",e[e.Amp=38]="Amp",e[e.SingleQuote=39]="SingleQuote",e[e.DoubleQuote=34]="DoubleQuote",e[e.Dash=45]="Dash",e[e.Slash=47]="Slash",e[e.Zero=48]="Zero",e[e.Nine=57]="Nine",e[e.Semi=59]="Semi",e[e.Lt=60]="Lt",e[e.Eq=61]="Eq",e[e.Gt=62]="Gt",e[e.Questionmark=63]="Questionmark",e[e.UpperA=65]="UpperA",e[e.LowerA=97]="LowerA",e[e.UpperF=70]="UpperF",e[e.LowerF=102]="LowerF",e[e.UpperZ=90]="UpperZ",e[e.LowerZ=122]="LowerZ",e[e.LowerX=120]="LowerX",e[e.OpeningSquareBracket=91]="OpeningSquareBracket"})(_||(_={}));var g;(function(e){e[e.Text=1]="Text",e[e.BeforeTagName=2]="BeforeTagName",e[e.InTagName=3]="InTagName",e[e.InSelfClosingTag=4]="InSelfClosingTag",e[e.BeforeClosingTagName=5]="BeforeClosingTagName",e[e.InClosingTagName=6]="InClosingTagName",e[e.AfterClosingTagName=7]="AfterClosingTagName",e[e.BeforeAttributeName=8]="BeforeAttributeName",e[e.InAttributeName=9]="InAttributeName",e[e.AfterAttributeName=10]="AfterAttributeName",e[e.BeforeAttributeValue=11]="BeforeAttributeValue",e[e.InAttributeValueDq=12]="InAttributeValueDq",e[e.InAttributeValueSq=13]="InAttributeValueSq",e[e.InAttributeValueNq=14]="InAttributeValueNq",e[e.BeforeDeclaration=15]="BeforeDeclaration",e[e.InDeclaration=16]="InDeclaration",e[e.InProcessingInstruction=17]="InProcessingInstruction",e[e.BeforeComment=18]="BeforeComment",e[e.CDATASequence=19]="CDATASequence",e[e.InSpecialComment=20]="InSpecialComment",e[e.InCommentLike=21]="InCommentLike",e[e.BeforeSpecialS=22]="BeforeSpecialS",e[e.BeforeSpecialT=23]="BeforeSpecialT",e[e.SpecialStartSequence=24]="SpecialStartSequence",e[e.InSpecialTag=25]="InSpecialTag",e[e.InEntity=26]="InEntity"})(g||(g={}));function be(e){return e===_.Space||e===_.NewLine||e===_.Tab||e===_.FormFeed||e===_.CarriageReturn}function uu(e){return e===_.Slash||e===_.Gt||be(e)}function Ci(e){return e>=_.LowerA&&e<=_.LowerZ||e>=_.UpperA&&e<=_.UpperZ}var te;(function(e){e[e.NoValue=0]="NoValue",e[e.Unquoted=1]="Unquoted",e[e.Single=2]="Single",e[e.Double=3]="Double"})(te||(te={}));var M={Cdata:new Uint8Array([67,68,65,84,65,91]),CdataEnd:new Uint8Array([93,93,62]),CommentEnd:new Uint8Array([45,45,62]),ScriptEnd:new Uint8Array([60,47,115,99,114,105,112,116]),StyleEnd:new Uint8Array([60,47,115,116,121,108,101]),TitleEnd:new Uint8Array([60,47,116,105,116,108,101]),TextareaEnd:new Uint8Array([60,47,116,101,120,116,97,114,101,97]),XmpEnd:new Uint8Array([60,47,120,109,112])},Ze=class{constructor({xmlMode:t=!1,decodeEntities:u=!0},a){this.cbs=a,this.state=g.Text,this.buffer="",this.sectionStart=0,this.index=0,this.entityStart=0,this.baseState=g.Text,this.isSpecial=!1,this.running=!0,this.offset=0,this.currentSequence=void 0,this.sequenceIndex=0,this.xmlMode=t,this.decodeEntities=u,this.entityDecoder=new tu(t?eu:$t,(s,i)=>this.emitCodePoint(s,i))}reset(){this.state=g.Text,this.buffer="",this.sectionStart=0,this.index=0,this.baseState=g.Text,this.currentSequence=void 0,this.running=!0,this.offset=0}write(t){this.offset+=this.buffer.length,this.buffer=t,this.parse()}end(){this.running&&this.finish()}pause(){this.running=!1}resume(){this.running=!0,this.indexthis.sectionStart&&this.cbs.ontext(this.sectionStart,this.index),this.state=g.BeforeTagName,this.sectionStart=this.index):this.decodeEntities&&t===_.Amp&&this.startEntity()}stateSpecialStartSequence(t){let u=this.sequenceIndex===this.currentSequence.length;if(!(u?uu(t):(t|32)===this.currentSequence[this.sequenceIndex]))this.isSpecial=!1;else if(!u){this.sequenceIndex++;return}this.sequenceIndex=0,this.state=g.InTagName,this.stateInTagName(t)}stateInSpecialTag(t){if(this.sequenceIndex===this.currentSequence.length){if(t===_.Gt||be(t)){let u=this.index-this.currentSequence.length;if(this.sectionStart=0)this.state=this.baseState,u===0&&(this.index-=1);else{if(t=t||(this.state===g.InCommentLike?this.currentSequence===M.CdataEnd?this.cbs.oncdata(this.sectionStart,t,0):this.cbs.oncomment(this.sectionStart,t,0):this.state===g.InTagName||this.state===g.BeforeAttributeName||this.state===g.BeforeAttributeValue||this.state===g.AfterAttributeName||this.state===g.InAttributeName||this.state===g.InAttributeValueSq||this.state===g.InAttributeValueDq||this.state===g.InAttributeValueNq||this.state===g.InClosingTagName||this.cbs.ontext(this.sectionStart,t))}emitCodePoint(t,u){this.baseState!==g.Text&&this.baseState!==g.InSpecialTag?(this.sectionStart0&&n.has(this.stack[0]);){let d=this.stack.shift();(a=(u=this.cbs).onclosetag)===null||a===void 0||a.call(u,d,!0)}this.isVoidElement(t)||(this.stack.unshift(t),this.htmlMode&&(La.has(t)?this.foreignContext.unshift(!0):ya.has(t)&&this.foreignContext.unshift(!1))),(i=(s=this.cbs).onopentagname)===null||i===void 0||i.call(s,t),this.cbs.onopentag&&(this.attribs={})}endOpenTag(t){var u,a;this.startIndex=this.openTagStart,this.attribs&&((a=(u=this.cbs).onopentag)===null||a===void 0||a.call(u,this.tagname,this.attribs,t),this.attribs=null),this.cbs.onclosetag&&this.isVoidElement(this.tagname)&&this.cbs.onclosetag(this.tagname,!0),this.tagname=""}onopentagend(t){this.endIndex=t,this.endOpenTag(!1),this.startIndex=t+1}onclosetag(t,u){var a,s,i,n,d,l,h,m;this.endIndex=u;let T=this.getSlice(t,u);if(this.lowerCaseTagNames&&(T=T.toLowerCase()),this.htmlMode&&(La.has(T)||ya.has(T))&&this.foreignContext.shift(),this.isVoidElement(T))this.htmlMode&&T==="br"&&((n=(i=this.cbs).onopentagname)===null||n===void 0||n.call(i,"br"),(l=(d=this.cbs).onopentag)===null||l===void 0||l.call(d,"br",{},!0),(m=(h=this.cbs).onclosetag)===null||m===void 0||m.call(h,"br",!1));else{let I=this.stack.indexOf(T);if(I!==-1)for(let N=0;N<=I;N++){let S=this.stack.shift();(s=(a=this.cbs).onclosetag)===null||s===void 0||s.call(a,S,N!==I)}else this.htmlMode&&T==="p"&&(this.emitOpenTag("p"),this.closeCurrentTag(!0))}this.startIndex=u+1}onselfclosingtag(t){this.endIndex=t,this.recognizeSelfClosing||this.foreignContext[0]?(this.closeCurrentTag(!1),this.startIndex=t+1):this.onopentagend(t)}closeCurrentTag(t){var u,a;let s=this.tagname;this.endOpenTag(t),this.stack[0]===s&&((a=(u=this.cbs).onclosetag)===null||a===void 0||a.call(u,s,!t),this.stack.shift())}onattribname(t,u){this.startIndex=t;let a=this.getSlice(t,u);this.attribname=this.lowerCaseAttributeNames?a.toLowerCase():a}onattribdata(t,u){this.attribvalue+=this.getSlice(t,u)}onattribentity(t){this.attribvalue+=ht(t)}onattribend(t,u){var a,s;this.endIndex=u,(s=(a=this.cbs).onattribute)===null||s===void 0||s.call(a,this.attribname,this.attribvalue,t===te.Double?'"':t===te.Single?"'":t===te.NoValue?void 0:null),this.attribs&&!Object.prototype.hasOwnProperty.call(this.attribs,this.attribname)&&(this.attribs[this.attribname]=this.attribvalue),this.attribvalue=""}getInstructionName(t){let u=t.search(_i),a=u<0?t:t.substr(0,u);return this.lowerCaseTagNames&&(a=a.toLowerCase()),a}ondeclaration(t,u){this.endIndex=u;let a=this.getSlice(t,u);if(this.cbs.onprocessinginstruction){let s=this.getInstructionName(a);this.cbs.onprocessinginstruction(`!${s}`,`!${a}`)}this.startIndex=u+1}onprocessinginstruction(t,u){this.endIndex=u;let a=this.getSlice(t,u);if(this.cbs.onprocessinginstruction){let s=this.getInstructionName(a);this.cbs.onprocessinginstruction(`?${s}`,`?${a}`)}this.startIndex=u+1}oncomment(t,u,a){var s,i,n,d;this.endIndex=u,(i=(s=this.cbs).oncomment)===null||i===void 0||i.call(s,this.getSlice(t,u-a)),(d=(n=this.cbs).oncommentend)===null||d===void 0||d.call(n),this.startIndex=u+1}oncdata(t,u,a){var s,i,n,d,l,h,m,T,I,N;this.endIndex=u;let S=this.getSlice(t,u-a);!this.htmlMode||this.options.recognizeCDATA?((i=(s=this.cbs).oncdatastart)===null||i===void 0||i.call(s),(d=(n=this.cbs).ontext)===null||d===void 0||d.call(n,S),(h=(l=this.cbs).oncdataend)===null||h===void 0||h.call(l)):((T=(m=this.cbs).oncomment)===null||T===void 0||T.call(m,`[CDATA[${S}]]`),(N=(I=this.cbs).oncommentend)===null||N===void 0||N.call(I)),this.startIndex=u+1}onend(){var t,u;if(this.cbs.onclosetag){this.endIndex=this.startIndex;for(let a=0;a=this.buffers[0].length;)this.shiftBuffer();let a=this.buffers[0].slice(t-this.bufferOffset,u-this.bufferOffset);for(;u-this.bufferOffset>this.buffers[0].length;)this.shiftBuffer(),a+=this.buffers[0].slice(0,u-this.bufferOffset);return a}shiftBuffer(){this.bufferOffset+=this.buffers[0].length,this.writeIndex--,this.buffers.shift()}write(t){var u,a;if(this.ended){(a=(u=this.cbs).onerror)===null||a===void 0||a.call(u,new Error(".write() after done!"));return}this.buffers.push(t),this.tokenizer.running&&(this.tokenizer.write(t),this.writeIndex++)}end(t){var u,a;if(this.ended){(a=(u=this.cbs).onerror)===null||a===void 0||a.call(u,new Error(".end() after done!"));return}t&&this.write(t),this.ended=!0,this.tokenizer.end()}pause(){this.tokenizer.pause()}resume(){for(this.tokenizer.resume();this.tokenizer.running&&this.writeIndexObject.prototype.hasOwnProperty.call(e,t)),mt=/\s+/,A0="data-",d0=/^(?:autofocus|autoplay|async|checked|controls|defer|disabled|hidden|loop|multiple|open|readonly|required|scoped|selected)$/i,Si=/^{[^]*}$|^\[[^]*]$/;function au(e,t,u){var a;if(!(!e||!C(e))){if((a=e.attribs)!==null&&a!==void 0||(e.attribs={}),!t)return e.attribs;if(Et(e.attribs,t))return!u&&d0.test(t)?t:e.attribs[t];if(e.name==="option"&&t==="value")return Pe(e.children);if(e.name==="input"&&(e.attribs.type==="radio"||e.attribs.type==="checkbox")&&t==="value")return"on"}}function je(e,t,u){u===null?ka(e,t):e.attribs[t]=`${u}`}function Di(e,t){if(typeof e=="object"||t!==void 0){if(typeof t=="function"){if(typeof e!="string")throw new Error("Bad combination of arguments.");return y(this,(u,a)=>{C(u)&&je(u,e,t.call(u,a,u.attribs[e]))})}return y(this,u=>{if(C(u))if(typeof e=="object")for(let a of Object.keys(e)){let s=e[a];je(u,a,s)}else je(u,e,t)})}return arguments.length>1?this:au(this[0],e,this.options.xmlMode)}function wa(e,t,u){return t in e?e[t]:!u&&d0.test(t)?au(e,t,!1)!==void 0:au(e,t,u)}function o0(e,t,u,a){t in e?e[t]=u:je(e,t,!a&&d0.test(t)?u?"":null:`${u}`)}function Bi(e,t){var u;if(typeof e=="string"&&t===void 0){let a=this[0];if(!a)return;switch(e){case"style":{let s=this.css(),i=Object.keys(s);for(let n=0;n").parent().html();case"innerHTML":return this.html();default:return C(a)?wa(a,e,this.options.xmlMode):void 0}}if(typeof e=="object"||t!==void 0){if(typeof t=="function"){if(typeof e=="object")throw new TypeError("Bad combination of arguments.");return y(this,(a,s)=>{C(a)&&o0(a,e,t.call(a,s,wa(a,e,this.options.xmlMode)),this.options.xmlMode)})}return y(this,a=>{if(C(a))if(typeof e=="object")for(let s of Object.keys(e)){let i=e[s];o0(a,s,i,this.options.xmlMode)}else o0(a,e,t,this.options.xmlMode)})}}function Fa(e,t,u){var a;(a=e.data)!==null&&a!==void 0||(e.data={}),typeof t=="object"?Object.assign(e.data,t):typeof t=="string"&&u!==void 0&&(e.data[t]=u)}function Ri(e){for(let t of Object.keys(e.attribs)){if(!t.startsWith(A0))continue;let u=Na(t.slice(A0.length));Et(e.data,u)||(e.data[u]=Ma(e.attribs[t]))}return e.data}function Li(e,t){let u=A0+_a(t),a=e.data;if(Et(a,t))return a[t];if(Et(e.attribs,u))return a[t]=Ma(e.attribs[u])}function Ma(e){if(e==="null")return null;if(e==="true")return!0;if(e==="false")return!1;let t=Number(e);if(e===String(t))return t;if(Si.test(e))try{return JSON.parse(e)}catch{}return e}function yi(e,t){var u;let a=this[0];if(!a||!C(a))return;let s=a;return(u=s.data)!==null&&u!==void 0||(s.data={}),e==null?Ri(s):typeof e=="object"||t!==void 0?(y(this,i=>{C(i)&&(typeof e=="object"?Fa(i,e):Fa(i,e,t))}),this):Li(s,e)}function Oi(e){let t=arguments.length===0,u=this[0];if(!u||!C(u))return t?void 0:this;switch(u.name){case"textarea":return this.text(e);case"select":{let a=this.find("option:selected");if(!t){if(this.attr("multiple")==null&&typeof e=="object")return this;this.find("option").removeAttr("selected");let s=typeof e=="object"?e:[e];for(let i of s)this.find(`option[value="${i}"]`).attr("selected","");return this}return this.attr("multiple")?a.toArray().map(s=>Pe(s.children)):a.attr("value")}case"input":case"option":return t?this.attr("value"):this.attr("value",e)}}function ka(e,t){!e.attribs||!Et(e.attribs,t)||delete e.attribs[t]}function ru(e){return e?e.trim().split(mt):[]}function wi(e){let t=ru(e);for(let u of t)y(this,a=>{C(a)&&ka(a,u)});return this}function Fi(e){return this.toArray().some(t=>{let u=C(t)&&t.attribs.class,a=-1;if(u&&e.length>0)for(;(a=u.indexOf(e,a+1))>-1;){let s=a+e.length;if((a===0||mt.test(u[a-1]))&&(s===u.length||mt.test(u[s])))return!0}return!1})}function Pa(e){if(typeof e=="function")return y(this,(a,s)=>{if(C(a)){let i=a.attribs.class||"";Pa.call([a],e.call(a,s,i))}});if(!e||typeof e!="string")return this;let t=e.split(mt),u=this.length;for(let a=0;a{C(s)&&Ha.call([s],e.call(s,i,s.attribs.class||""))});let t=ru(e),u=t.length,a=arguments.length===0;return y(this,s=>{if(C(s))if(a)s.attribs.class="";else{let i=ru(s.attribs.class),n=!1;for(let d=0;d{C(n)&&Ua.call([n],e.call(n,d,n.attribs.class||"",t),t)});if(!e||typeof e!="string")return this;let u=e.split(mt),a=u.length,s=typeof t=="boolean"?t?1:-1:0,i=this.length;for(let n=0;n=0&&m===-1?l.push(u[h]):s<=0&&m!==-1&&l.splice(m,1)}d.attribs.class=l.join(" ")}return this}var M0={};se(M0,{_findBySelector:()=>nn,add:()=>Mn,addBack:()=>kn,children:()=>pn,closest:()=>dn,contents:()=>Tn,each:()=>In,end:()=>Fn,eq:()=>Rn,filter:()=>xn,filterArray:()=>F0,find:()=>sn,first:()=>Dn,get:()=>Ln,has:()=>Sn,index:()=>On,is:()=>Nn,last:()=>Bn,map:()=>Cn,next:()=>ln,nextAll:()=>fn,nextUntil:()=>hn,not:()=>_n,parent:()=>cn,parents:()=>on,parentsUntil:()=>An,prev:()=>bn,prevAll:()=>En,prevUntil:()=>mn,siblings:()=>gn,slice:()=>wn,toArray:()=>yn});var x;(function(e){e.Attribute="attribute",e.Pseudo="pseudo",e.PseudoElement="pseudo-element",e.Tag="tag",e.Universal="universal",e.Adjacent="adjacent",e.Child="child",e.Descendant="descendant",e.Parent="parent",e.Sibling="sibling",e.ColumnCombinator="column-combinator"})(x||(x={}));var F;(function(e){e.Any="any",e.Element="element",e.End="end",e.Equals="equals",e.Exists="exists",e.Hyphen="hyphen",e.Not="not",e.Start="start"})(F||(F={}));var va=/^[^\\#]?(?:\\(?:[\da-f]{1,6}\s?|.)|[\w\-\u00b0-\uFFFF])+/,Mi=/\\([\da-f]{1,6}\s?|(\s)|.)/gi,ki=new Map([[126,F.Element],[94,F.Start],[36,F.End],[42,F.Any],[33,F.Not],[124,F.Hyphen]]),Pi=new Set(["has","not","matches","is","where","host","host-context"]);function Ue(e){switch(e.type){case x.Adjacent:case x.Child:case x.Descendant:case x.Parent:case x.Sibling:case x.ColumnCombinator:return!0;default:return!1}}var Hi=new Set(["contains","icontains"]);function Ui(e,t,u){let a=parseInt(t,16)-65536;return a!==a||u?t:a<0?String.fromCharCode(a+65536):String.fromCharCode(a>>10|55296,a&1023|56320)}function gt(e){return e.replace(Mi,Ui)}function f0(e){return e===39||e===34}function Qa(e){return e===32||e===9||e===10||e===12||e===13}function Ee(e){let t=[],u=Ya(t,`${e}`,0);if(u0&&u0&&Ue(a[a.length-1]))throw new Error("Did not expect successive traversals.")}function h(I){if(a.length>0&&a[a.length-1].type===x.Descendant){a[a.length-1].type=I;return}l(),a.push({type:I})}function m(I,N){a.push({type:x.Attribute,name:I,action:N,value:s(1),namespace:null,ignoreCase:"quirks"})}function T(){if(a.length&&a[a.length-1].type===x.Descendant&&a.pop(),a.length===0)throw new Error("Empty sub-selector");e.push(a)}if(i(0),t.length===u)return u;e:for(;u=0&&a>=1)):e.type===x.Pseudo&&(e.data?e.name==="has"||e.name==="contains"?a=0:Array.isArray(e.data)?(a=Math.min(...e.data.map(s=>Math.min(...s.map(Wa)))),a<0&&(a=0)):a=2:a=3),a}var Tt=ye(xe(),1),Qi=/[-[\]{}()*+?.,\\^$|#\s]/g;function qa(e){return e.replace(Qi,"\\$&")}var Yi=new Set(["accept","accept-charset","align","alink","axis","bgcolor","charset","checked","clear","codetype","color","compact","declare","defer","dir","direction","disabled","enctype","face","frame","hreflang","http-equiv","lang","language","link","media","method","multiple","nohref","noresize","noshade","nowrap","readonly","rel","rev","rules","scope","scrolling","selected","shape","target","text","type","valign","valuetype","vlink"]);function ve(e,t){return typeof e.ignoreCase=="boolean"?e.ignoreCase:e.ignoreCase==="quirks"?!!t.quirksMode:!t.xmlMode&&Yi.has(e.name)}var Ja={equals(e,t,u){let{adapter:a}=u,{name:s}=t,{value:i}=t;return ve(t,u)?(i=i.toLowerCase(),n=>{let d=a.getAttributeValue(n,s);return d!=null&&d.length===i.length&&d.toLowerCase()===i&&e(n)}):n=>a.getAttributeValue(n,s)===i&&e(n)},hyphen(e,t,u){let{adapter:a}=u,{name:s}=t,{value:i}=t,n=i.length;return ve(t,u)?(i=i.toLowerCase(),function(l){let h=a.getAttributeValue(l,s);return h!=null&&(h.length===n||h.charAt(n)==="-")&&h.substr(0,n).toLowerCase()===i&&e(l)}):function(l){let h=a.getAttributeValue(l,s);return h!=null&&(h.length===n||h.charAt(n)==="-")&&h.substr(0,n)===i&&e(l)}},element(e,t,u){let{adapter:a}=u,{name:s,value:i}=t;if(/\s/.test(i))return Tt.default.falseFunc;let n=new RegExp(`(?:^|\\s)${qa(i)}(?:$|\\s)`,ve(t,u)?"i":"");return function(l){let h=a.getAttributeValue(l,s);return h!=null&&h.length>=i.length&&n.test(h)&&e(l)}},exists(e,{name:t},{adapter:u}){return a=>u.hasAttrib(a,t)&&e(a)},start(e,t,u){let{adapter:a}=u,{name:s}=t,{value:i}=t,n=i.length;return n===0?Tt.default.falseFunc:ve(t,u)?(i=i.toLowerCase(),d=>{let l=a.getAttributeValue(d,s);return l!=null&&l.length>=n&&l.substr(0,n).toLowerCase()===i&&e(d)}):d=>{var l;return!!(!((l=a.getAttributeValue(d,s))===null||l===void 0)&&l.startsWith(i))&&e(d)}},end(e,t,u){let{adapter:a}=u,{name:s}=t,{value:i}=t,n=-i.length;return n===0?Tt.default.falseFunc:ve(t,u)?(i=i.toLowerCase(),d=>{var l;return((l=a.getAttributeValue(d,s))===null||l===void 0?void 0:l.substr(n).toLowerCase())===i&&e(d)}):d=>{var l;return!!(!((l=a.getAttributeValue(d,s))===null||l===void 0)&&l.endsWith(i))&&e(d)}},any(e,t,u){let{adapter:a}=u,{name:s,value:i}=t;if(i==="")return Tt.default.falseFunc;if(ve(t,u)){let n=new RegExp(qa(i),"i");return function(l){let h=a.getAttributeValue(l,s);return h!=null&&h.length>=i.length&&n.test(h)&&e(l)}}return n=>{var d;return!!(!((d=a.getAttributeValue(n,s))===null||d===void 0)&&d.includes(i))&&e(n)}},not(e,t,u){let{adapter:a}=u,{name:s}=t,{value:i}=t;return i===""?n=>!!a.getAttributeValue(n,s)&&e(n):ve(t,u)?(i=i.toLowerCase(),n=>{let d=a.getAttributeValue(n,s);return(d==null||d.length!==i.length||d.toLowerCase()!==i)&&e(n)}):n=>a.getAttributeValue(n,s)!==i&&e(n)}};var Gi=new Set([9,10,12,13,32]),Va=48,Ki=57;function Za(e){if(e=e.trim().toLowerCase(),e==="even")return[2,0];if(e==="odd")return[2,1];let t=0,u=0,a=i(),s=n();if(t=Va&&e.charCodeAt(t)<=Ki;)h=h*10+(e.charCodeAt(t)-Va),t++;return t===l?null:h}function d(){for(;ti<=u;if(t===0)return i=>i===u;if(t===1)return u<0?b0.default.trueFunc:i=>i>=u;let a=Math.abs(t),s=(u%a+a)%a;return t>1?i=>i>=u&&i%a===s:i=>i<=u&&i%a===s}function ze(e){return Xa(Za(e))}var Z=ye(xe(),1);function su(e,t){return u=>{let a=t.getParent(u);return a!=null&&t.isTag(a)&&e(u)}}var $e={contains(e,t,{adapter:u}){return function(s){return e(s)&&u.getText(s).includes(t)}},icontains(e,t,{adapter:u}){let a=t.toLowerCase();return function(i){return e(i)&&u.getText(i).toLowerCase().includes(a)}},"nth-child"(e,t,{adapter:u,equals:a}){let s=ze(t);return s===Z.default.falseFunc?Z.default.falseFunc:s===Z.default.trueFunc?su(e,u):function(n){let d=u.getSiblings(n),l=0;for(let h=0;h=0&&!a(n,d[h]);h--)u.isTag(d[h])&&l++;return s(l)&&e(n)}},"nth-of-type"(e,t,{adapter:u,equals:a}){let s=ze(t);return s===Z.default.falseFunc?Z.default.falseFunc:s===Z.default.trueFunc?su(e,u):function(n){let d=u.getSiblings(n),l=0;for(let h=0;h=0;h--){let m=d[h];if(a(n,m))break;u.isTag(m)&&u.getName(m)===u.getName(n)&&l++}return s(l)&&e(n)}},root(e,t,{adapter:u}){return a=>{let s=u.getParent(a);return(s==null||!u.isTag(s))&&e(a)}},scope(e,t,u,a){let{equals:s}=u;return!a||a.length===0?$e.root(e,t,u):a.length===1?i=>s(a[0],i)&&e(i):i=>a.includes(i)&&e(i)},hover:E0("isHovered"),visited:E0("isVisited"),active:E0("isActive")};function E0(e){return function(u,a,{adapter:s}){let i=s[e];return typeof i!="function"?Z.default.falseFunc:function(d){return i(d)&&u(d)}}}var It={empty(e,{adapter:t}){return!t.getChildren(e).some(u=>t.isTag(u)||t.getText(u)!=="")},"first-child"(e,{adapter:t,equals:u}){if(t.prevElementSibling)return t.prevElementSibling(e)==null;let a=t.getSiblings(e).find(s=>t.isTag(s));return a!=null&&u(e,a)},"last-child"(e,{adapter:t,equals:u}){let a=t.getSiblings(e);for(let s=a.length-1;s>=0;s--){if(u(e,a[s]))return!0;if(t.isTag(a[s]))break}return!1},"first-of-type"(e,{adapter:t,equals:u}){let a=t.getSiblings(e),s=t.getName(e);for(let i=0;i=0;i--){let n=a[i];if(u(e,n))return!0;if(t.isTag(n)&&t.getName(n)===s)break}return!1},"only-of-type"(e,{adapter:t,equals:u}){let a=t.getName(e);return t.getSiblings(e).every(s=>u(e,s)||!t.isTag(s)||t.getName(s)!==a)},"only-child"(e,{adapter:t,equals:u}){return t.getSiblings(e).every(a=>u(e,a)||!t.isTag(a))}};function m0(e,t,u,a){if(u===null){if(e.length>a)throw new Error(`Pseudo-class :${t} requires an argument`)}else if(e.length===a)throw new Error(`Pseudo-class :${t} doesn't have any arguments`)}var iu={"any-link":":is(a, area, link)[href]",link:":any-link:not(:visited)",disabled:`:is( + :is(button, input, select, textarea, optgroup, option)[disabled], + optgroup[disabled] > option, + fieldset[disabled]:not(fieldset[disabled] legend:first-of-type *) + )`,enabled:":not(:disabled)",checked:":is(:is(input[type=radio], input[type=checkbox])[checked], option:selected)",required:":is(input, select, textarea)[required]",optional:":is(input, select, textarea):not([required])",selected:"option:is([selected], select:not([multiple]):not(:has(> option[selected])) > :first-of-type)",checkbox:"[type=checkbox]",file:"[type=file]",password:"[type=password]",radio:"[type=radio]",reset:"[type=reset]",image:"[type=image]",submit:"[type=submit]",parent:":not(:empty)",header:":is(h1, h2, h3, h4, h5, h6)",button:":is(button, input[type=button])",input:":is(input, textarea, select, button)",text:"input:is(:not([type!='']), [type=text])"};var ue=ye(xe(),1);var T0={};function I0(e,t){return e===ue.default.falseFunc?ue.default.falseFunc:u=>t.isTag(u)&&e(u)}function C0(e,t){let u=t.getSiblings(e);if(u.length<=1)return[];let a=u.indexOf(e);return a<0||a===u.length-1?[]:u.slice(a+1).filter(t.isTag)}function p0(e){return{xmlMode:!!e.xmlMode,lowerCaseAttributeNames:!!e.lowerCaseAttributeNames,lowerCaseTags:!!e.lowerCaseTags,quirksMode:!!e.quirksMode,cacheResults:!!e.cacheResults,pseudos:e.pseudos,adapter:e.adapter,equals:e.equals}}var g0=(e,t,u,a,s)=>{let i=s(t,p0(u),a);return i===ue.default.trueFunc?e:i===ue.default.falseFunc?ue.default.falseFunc:n=>i(n)&&e(n)},nu={is:g0,matches:g0,where:g0,not(e,t,u,a,s){let i=s(t,p0(u),a);return i===ue.default.falseFunc?e:i===ue.default.trueFunc?ue.default.falseFunc:n=>!i(n)&&e(n)},has(e,t,u,a,s){let{adapter:i}=u,n=p0(u);n.relativeSelector=!0;let d=t.some(m=>m.some(pt))?[T0]:void 0,l=s(t,n,d);if(l===ue.default.falseFunc)return ue.default.falseFunc;let h=I0(l,i);if(d&&l!==ue.default.trueFunc){let{shouldTestNextSiblings:m=!1}=l;return T=>{if(!e(T))return!1;d[0]=T;let I=i.getChildren(T),N=m?[...I,...C0(T,i)]:I;return i.existsOne(h,N)}}return m=>e(m)&&i.existsOne(h,i.getChildren(m))}};function ja(e,t,u,a,s){var i;let{name:n,data:d}=t;if(Array.isArray(d)){if(!(n in nu))throw new Error(`Unknown pseudo-class :${n}(${d})`);return nu[n](e,d,u,a,s)}let l=(i=u.pseudos)===null||i===void 0?void 0:i[n],h=typeof l=="string"?l:iu[n];if(typeof h=="string"){if(d!=null)throw new Error(`Pseudo ${n} doesn't have any arguments`);let m=Ee(h);return nu.is(e,m,u,a,s)}if(typeof l=="function")return m0(l,n,d,1),m=>l(m,d)&&e(m);if(n in $e)return $e[n](e,d,u,a);if(n in It){let m=It[n];return m0(m,n,d,2),T=>m(T,u,d)&&e(T)}throw new Error(`Unknown pseudo-class :${n}`)}function x0(e,t){let u=t.getParent(e);return u&&t.isTag(u)?u:null}function za(e,t,u,a,s){let{adapter:i,equals:n}=u;switch(t.type){case x.PseudoElement:throw new Error("Pseudo-elements are not supported by css-select");case x.ColumnCombinator:throw new Error("Column combinators are not yet supported by css-select");case x.Attribute:{if(t.namespace!=null)throw new Error("Namespaced attributes are not yet supported by css-select");return(!u.xmlMode||u.lowerCaseAttributeNames)&&(t.name=t.name.toLowerCase()),Ja[t.action](e,t,u)}case x.Pseudo:return ja(e,t,u,a,s);case x.Tag:{if(t.namespace!=null)throw new Error("Namespaced tag names are not yet supported by css-select");let{name:d}=t;return(!u.xmlMode||u.lowerCaseTags)&&(d=d.toLowerCase()),function(h){return i.getName(h)===d&&e(h)}}case x.Descendant:{if(u.cacheResults===!1||typeof WeakSet>"u")return function(h){let m=h;for(;m=x0(m,i);)if(e(m))return!0;return!1};let d=new WeakSet;return function(h){let m=h;for(;m=x0(m,i);)if(!d.has(m)){if(i.isTag(m)&&e(m))return!0;d.add(m)}return!1}}case"_flexibleDescendant":return function(l){let h=l;do if(e(h))return!0;while(h=x0(h,i));return!1};case x.Parent:return function(l){return i.getChildren(l).some(h=>i.isTag(h)&&e(h))};case x.Child:return function(l){let h=i.getParent(l);return h!=null&&i.isTag(h)&&e(h)};case x.Sibling:return function(l){let h=i.getSiblings(l);for(let m=0;mt.some(er)))}var Wi={type:x.Descendant},qi={type:"_flexibleDescendant"},Ji={type:x.Pseudo,name:"scope",data:null};function Vi(e,{adapter:t},u){let a=!!u?.every(s=>{let i=t.isTag(s)&&t.getParent(s);return s===T0||i&&t.isTag(i)});for(let s of e){if(!(s.length>0&&pt(s[0])&&s[0].type!==x.Descendant))if(a&&!s.some(er))s.unshift(Wi);else continue;s.unshift(Ji)}}function ou(e,t,u){var a;e.forEach(h0),u=(a=t.context)!==null&&a!==void 0?a:u;let s=Array.isArray(u),i=u&&(Array.isArray(u)?u:[u]);if(t.relativeSelector!==!1)Vi(e,t,i);else if(e.some(l=>l.length>0&&pt(l[0])))throw new Error("Relative selectors are not allowed when the `relativeSelector` option is disabled");let n=!1,d=e.map(l=>{if(l.length>=2){let[h,m]=l;h.type!==x.Pseudo||h.name!=="scope"||(s&&m.type===x.Descendant?l[1]=qi:(m.type===x.Adjacent||m.type===x.Sibling)&&(n=!0))}return Zi(l,t,i)}).reduce(Xi,me.default.falseFunc);return d.shouldTestNextSiblings=n,d}function Zi(e,t,u){var a;return e.reduce((s,i)=>s===me.default.falseFunc?me.default.falseFunc:za(s,i,t,u,ou),(a=t.rootFunc)!==null&&a!==void 0?a:me.default.trueFunc)}function Xi(e,t){return t===me.default.falseFunc||e===me.default.trueFunc?e:e===me.default.falseFunc||t===me.default.trueFunc?t:function(a){return e(a)||t(a)}}var tr=(e,t)=>e===t,ji={adapter:Ce,equals:tr};function ur(e){var t,u,a,s;let i=e??ji;return(t=i.adapter)!==null&&t!==void 0||(i.adapter=Ce),(u=i.equals)!==null&&u!==void 0||(i.equals=(s=(a=i.adapter)===null||a===void 0?void 0:a.equals)!==null&&s!==void 0?s:tr),i}function _0(e){return function(u,a,s){let i=ur(a);return e(u,i,s)}}var Tl=_0($a),Il=_0(cu),Au=_0(ou);function ar(e){return function(u,a,s){let i=ur(s);typeof u!="function"&&(u=cu(u,i,a));let n=du(a,i.adapter,u.shouldTestNextSiblings);return e(u,n,i)}}function du(e,t,u=!1){return u&&(e=zi(e,t)),Array.isArray(e)?t.removeSubsets(e):t.getChildren(e)}function zi(e,t){let u=Array.isArray(e)?e.slice(0):[e],a=u.length;for(let s=0;se===N0.default.falseFunc||!t||t.length===0?[]:u.adapter.findAll(e,t)),xl=ar((e,t,u)=>e===N0.default.falseFunc||!t||t.length===0?null:u.adapter.findOne(e,t));var fu=ye(xe(),1);var $i=new Set(["first","last","eq","gt","nth","lt","even","odd"]);function et(e){return e.type!=="pseudo"?!1:$i.has(e.name)?!0:e.name==="not"&&Array.isArray(e.data)?e.data.some(t=>t.some(et)):!1}function rr(e,t,u){let a=t!=null?parseInt(t,10):NaN;switch(e){case"first":return 1;case"nth":case"eq":return isFinite(a)?a>=0?a+1:1/0:0;case"lt":return isFinite(a)?a>=0?Math.min(a,u):1/0:0;case"gt":return isFinite(a)?1/0:0;case"odd":return 2*u;case"even":return 2*u-1;case"last":case"not":return 1/0}}function sr(e){for(;e.parent;)e=e.parent;return e}function lu(e){let t=[],u=[];for(let a of e)a.some(et)?t.push(a):u.push(a);return[u,t]}var en={type:x.Universal,namespace:null},tn={type:x.Pseudo,name:"scope",data:null};function B0(e,t,u={}){return R0([e],t,u)}function R0(e,t,u={}){if(typeof t=="function")return e.some(t);let[a,s]=lu(Ee(t));return a.length>0&&e.some(Au(a,u))||s.some(i=>nr(i,e,u).length>0)}function un(e,t,u,a){let s=typeof u=="string"?parseInt(u,10):NaN;switch(e){case"first":case"lt":return t;case"last":return t.length>0?[t[t.length-1]]:t;case"nth":case"eq":return isFinite(s)&&Math.abs(s)n%2===0);case"odd":return t.filter((i,n)=>n%2===1);case"not":{let i=new Set(ir(u,t,a));return t.filter(n=>!i.has(n))}}}function L0(e,t,u={}){return ir(Ee(e),t,u)}function ir(e,t,u){if(t.length===0)return[];let[a,s]=lu(e),i;if(a.length){let n=D0(t,a,u);if(s.length===0)return n;n.length&&(i=new Set(n))}for(let n=0;nC(m)&&!i.has(m)):t).length===0)break;let h=nr(d,t,u);if(h.length)if(i)h.forEach(m=>i.add(m));else{if(n===s.length-1)return h;i=new Set(h)}}return typeof i<"u"?i.size===t.length?t:t.filter(n=>i.has(n)):[]}function nr(e,t,u){var a;if(e.some(Ue)){let s=(a=u.root)!==null&&a!==void 0?a:sr(t[0]),i={...u,context:t,relativeSelector:!1};return e.push(tn),hu(s,e,i,!0,t.length)}return hu(t,e,u,!1,t.length)}function cr(e,t,u={},a=1/0){if(typeof e=="function")return or(t,e);let[s,i]=lu(Ee(e)),n=i.map(d=>hu(t,d,u,!0,a));return s.length&&n.push(S0(t,s,u,a)),n.length===0?[]:n.length===1?n[0]:Te(n.reduce((d,l)=>[...d,...l]))}function hu(e,t,u,a,s){let i=t.findIndex(et),n=t.slice(0,i),d=t[i],l=t.length-1===i?s:1/0,h=rr(d.name,d.data,l);if(h===0)return[];let T=(n.length===0&&!Array.isArray(e)?Je(e).filter(C):n.length===0?(Array.isArray(e)?e:[e]).filter(C):a||n.some(Ue)?S0(e,[n],u,h):D0(e,[n],u)).slice(0,h),I=un(d.name,T,d.data,u);if(I.length===0||t.length===i+1)return I;let N=t.slice(i+1),S=N.some(Ue);if(S){if(Ue(N[0])){let{type:k}=N[0];(k===x.Sibling||k===x.Adjacent)&&(I=du(I,Ce,!0)),N.unshift(en)}u={...u,relativeSelector:!1,rootFunc:k=>I.includes(k)}}else u.rootFunc&&u.rootFunc!==fu.trueFunc&&(u={...u,rootFunc:fu.trueFunc});return N.some(et)?hu(I,N,u,!1,s):S?S0(I,[N],u,s):D0(I,[N],u)}function S0(e,t,u,a){let s=Au(t,u,e);return or(e,s,a)}function or(e,t,u=1/0){let a=du(e,Ce,t.shouldTestNextSiblings);return Jt(s=>C(s)&&t(s),a,!0,u)}function D0(e,t,u){let a=(Array.isArray(e)?e:[e]).filter(C);if(a.length===0)return a;let s=Au(t,u);return s===fu.trueFunc?a:a.filter(s)}var rn=/^\s*[+~]/;function sn(e){if(!e)return this._make([]);if(typeof e!="string"){let t=ee(e)?e.toArray():[e],u=this.toArray();return this._make(t.filter(a=>u.some(s=>jt(s,a))))}return this._findBySelector(e,Number.POSITIVE_INFINITY)}function nn(e,t){var u;let a=this.toArray(),s=rn.test(e)?a:this.children().toArray(),i={context:a,root:(u=this._root)===null||u===void 0?void 0:u[0],xmlMode:this.options.xmlMode,lowerCaseTags:this.options.lowerCaseTags,lowerCaseAttributeNames:this.options.lowerCaseAttributeNames,pseudos:this.options.pseudos,quirksMode:this.options.quirksMode};return this._make(cr(e,s,i,t))}function y0(e){return function(t,...u){return function(a){var s;let i=e(t,this);return a&&(i=F0(i,a,this.options.xmlMode,(s=this._root)===null||s===void 0?void 0:s[0])),this._make(this.length>1&&i.length>1?u.reduce((n,d)=>d(n),i):i)}}}var Ct=y0((e,t)=>{let u=[];for(let a=0;a0&&(u=u.concat(s))}return u}),O0=y0((e,t)=>{let u=[];for(let a=0;a{let n=[];return y(i,d=>{for(let l;(l=s(d))&&!u?.(l,n.length);d=l)n.push(l)}),n})(e,...t);return function(s,i){u=typeof s=="string"?d=>B0(d,s,this.options):s?xt(s):null;let n=a.call(this,i);return u=null,n}}function tt(e){return e.length>1?Array.from(new Set(e)):e}var cn=O0(({parent:e})=>e&&!z(e)?e:null,tt),on=Ct(e=>{let t=[];for(;e.parent&&!z(e.parent);)t.push(e.parent),e=e.parent;return t},Te,e=>e.reverse()),An=w0(({parent:e})=>e&&!z(e)?e:null,Te,e=>e.reverse());function dn(e){var t;let u=[];if(!e)return this._make(u);let a={xmlMode:this.options.xmlMode,root:(t=this._root)===null||t===void 0?void 0:t[0]},s=typeof e=="string"?i=>B0(i,e,a):xt(e);return y(this,i=>{for(i&&!z(i)&&!C(i)&&(i=i.parent);i&&C(i);){if(s(i,0)){u.includes(i)||u.push(i);break}i=i.parent}}),this._make(u)}var ln=O0(e=>Wt(e)),fn=Ct(e=>{let t=[];for(;e.next;)e=e.next,C(e)&&t.push(e);return t},tt),hn=w0(e=>Wt(e),tt),bn=O0(e=>qt(e)),En=Ct(e=>{let t=[];for(;e.prev;)e=e.prev,C(e)&&t.push(e);return t},tt),mn=w0(e=>qt(e),tt),gn=Ct(e=>t0(e).filter(t=>C(t)&&t!==e),Te),pn=Ct(e=>Je(e).filter(C),tt);function Tn(){let e=this.toArray().reduce((t,u)=>R(u)?t.concat(u.children):t,[]);return this._make(e)}function In(e){let t=0,u=this.length;for(;te.call(t,u,t):ee(e)?t=>Array.prototype.includes.call(e,t):function(t){return e===t}}function xn(e){var t;return this._make(F0(this.toArray(),e,this.options.xmlMode,(t=this._root)===null||t===void 0?void 0:t[0]))}function F0(e,t,u,a){return typeof t=="string"?L0(t,e,{xmlMode:u,root:a}):e.filter(xt(t))}function Nn(e){let t=this.toArray();return typeof e=="string"?R0(t.filter(C),e,this.options):e?t.some(xt(e)):!1}function _n(e){let t=this.toArray();if(typeof e=="string"){let u=new Set(L0(e,t,this.options));t=t.filter(a=>!u.has(a))}else{let u=xt(e);t=t.filter((a,s)=>!u(a,s))}return this._make(t)}function Sn(e){return this.filter(typeof e=="string"?`:has(${e})`:(t,u)=>this._make(u).find(e).length>0)}function Dn(){return this.length>1?this._make(this[0]):this}function Bn(){return this.length>0?this._make(this[this.length-1]):this}function Rn(e){var t;return e=+e,e===0&&this.length<=1?this:(e<0&&(e=this.length+e),this._make((t=this[e])!==null&&t!==void 0?t:[]))}function Ln(e){return e==null?this.toArray():this[e<0?this.length+e:e]}function yn(){return Array.prototype.slice.call(this)}function On(e){let t,u;return e==null?(t=this.parent().children(),u=this[0]):typeof e=="string"?(t=this._make(e),u=this[0]):(t=this,u=ee(e)?e[0]:e),Array.prototype.indexOf.call(t,u)}function wn(e,t){return this._make(Array.prototype.slice.call(this,e,t))}function Fn(){var e;return(e=this.prevObject)!==null&&e!==void 0?e:this._make([])}function Mn(e,t){let u=this._make(e,t),a=Te([...this.get(),...u.get()]);return this._make(a)}function kn(e){return this.prevObject?this.add(e?this.prevObject.filter(e):this.prevObject):this}var k0={};se(k0,{_makeDomArray:()=>Pn,after:()=>qn,append:()=>vn,appendTo:()=>Hn,before:()=>Vn,clone:()=>uc,empty:()=>zn,html:()=>$n,insertAfter:()=>Jn,insertBefore:()=>Zn,prepend:()=>Qn,prependTo:()=>Un,remove:()=>Xn,replaceWith:()=>jn,text:()=>tc,toString:()=>ec,unwrap:()=>Kn,wrap:()=>Yn,wrapAll:()=>Wn,wrapInner:()=>Gn});function Ar(e){return function(u,a,s,i){if(typeof Buffer<"u"&&Buffer.isBuffer(u)&&(u=u.toString()),typeof u=="string")return e(u,a,s,i);let n=u;if(!Array.isArray(n)&&z(n))return n;let d=new X([]);return Ne(n,d),d}}function Ne(e,t){let u=Array.isArray(e)?e:[e];t?t.children=u:t=null;for(let a=0;a{if(!R(a))return;let i=typeof t[0]=="function"?t[0].call(a,s,this._render(a.children)):t,n=this._makeDomArray(i,s=e.length?null:e[t+u];for(let m=0;mS&&d[0]--)}T.parent=s,T.prev&&(T.prev.next=(i=T.next)!==null&&i!==void 0?i:null),T.next&&(T.next.prev=(n=T.prev)!==null&&n!==void 0?n:null),T.prev=m===0?l:a[m-1],T.next=m===a.length-1?h:a[m+1]}return l&&(l.next=a[0]),h&&(h.prev=a[a.length-1]),e.splice(...d)}function Hn(e){return(ee(e)?e:this._make(e)).append(this),this}function Un(e){return(ee(e)?e:this._make(e)).prepend(this),this}var vn=dr((e,t,u)=>{_e(t,t.length,0,e,u)}),Qn=dr((e,t,u)=>{_e(t,0,0,e,u)});function lr(e){return function(t){let u=this.length-1,a=this.parents().last();for(let s=0;s{let{parent:a}=e;if(!a)return;let s=a.children,i=s.indexOf(e);Ne([e],t),_e(s,i,0,u,a)}),Gn=lr((e,t,u)=>{R(e)&&(Ne(e.children,t),Ne(u,e))});function Kn(e){return this.parent(e).not("body").each((t,u)=>{this._make(u).replaceWith(u.children)}),this}function Wn(e){let t=this[0];if(t){let u=this._make(typeof e=="function"?e.call(t,0,t):e).insertBefore(t),a;for(let i=0;i{if(!R(u)||!u.parent)return;let s=u.parent.children,i=s.indexOf(u);if(i===-1)return;let n=typeof e[0]=="function"?e[0].call(u,a,this._render(u.children)):e,d=this._makeDomArray(n,a{if(!R(u)||!u.parent)return;let s=u.parent.children,i=s.indexOf(u);if(i===-1)return;let n=typeof e[0]=="function"?e[0].call(u,a,this._render(u.children)):e,d=this._makeDomArray(n,a{let s=this.clone().toArray(),{parent:i}=a;if(!i)return;let n=i.children,d=n.indexOf(a);d!==-1&&(_e(n,d,0,s,i),u.push(...s))}),this._make(u)}function Xn(e){let t=e?this.filter(e):this;return y(t,u=>{he(u),u.prev=u.next=u.parent=null}),this}function jn(e){return y(this,(t,u)=>{let{parent:a}=t;if(!a)return;let s=a.children,i=typeof e=="function"?e.call(t,u,t):e,n=this._makeDomArray(i);Ne(n,null);let d=s.indexOf(t);_e(s,d,1,n,a),n.includes(t)||(t.parent=t.prev=t.next=null)})}function zn(){return y(this,e=>{if(R(e)){for(let t of e.children)t.next=t.prev=t.parent=null;e.children.length=0}})}function $n(e){if(e===void 0){let t=this[0];return!t||!R(t)?null:this._render(t.children)}return y(this,t=>{if(!R(t))return;for(let a of t.children)a.next=a.prev=a.parent=null;let u=ee(e)?e.toArray():this._parse(`${e}`,this.options,!1,t).children;Ne(u,t)})}function ec(){return this._render(this)}function tc(e){return e===void 0?Pe(this):typeof e=="function"?y(this,(t,u)=>this._make(t).text(e.call(t,u,Pe([t])))):y(this,t=>{if(!R(t))return;for(let a of t.children)a.next=a.prev=a.parent=null;let u=new oe(`${e}`);Ne(u,t)})}function uc(){let e=Array.prototype.map.call(this.get(),u=>We(u,!0)),t=new X(e);for(let u of e)u.parent=t;return this._make(e)}var P0={};se(P0,{css:()=>ac});function ac(e,t){if(e!=null&&t!=null||typeof e=="object"&&!Array.isArray(e))return y(this,(u,a)=>{C(u)&&fr(u,e,t,a)});if(this.length!==0)return hr(this[0],e)}function fr(e,t,u,a){if(typeof t=="string"){let s=hr(e),i=typeof u=="function"?u.call(e,a,s[t]):u;i===""?delete s[t]:i!=null&&(s[t]=i),e.attribs.style=rc(s)}else if(typeof t=="object"){let s=Object.keys(t);for(let i=0;i`${t}${t?" ":""}${u}: ${e[u]};`,"")}function sc(e){if(e=(e||"").trim(),!e)return{};let t={},u;for(let a of e.split(";")){let s=a.indexOf(":");if(s<1||s===a.length-1){let i=a.trimEnd();i.length>0&&u!==void 0&&(t[u]+=`;${i}`)}else u=a.slice(0,s).trim(),t[u]=a.slice(s+1).trim()}return t}var H0={};se(H0,{serialize:()=>nc,serializeArray:()=>cc});var br="input,select,textarea,keygen",ic=/%20/g,Er=/\r?\n/g;function nc(){return this.serializeArray().map(u=>`${encodeURIComponent(u.name)}=${encodeURIComponent(u.value)}`).join("&").replace(ic,"+")}function cc(){return this.map((e,t)=>{let u=this._make(t);return C(t)&&t.name==="form"?u.find(br).toArray():u.filter(br).toArray()}).filter('[name!=""]:enabled:not(:submit, :button, :image, :reset, :file):matches([checked], :not(:checkbox, :radio))').map((e,t)=>{var u;let a=this._make(t),s=a.attr("name"),i=(u=a.val())!==null&&u!==void 0?u:"";return Array.isArray(i)?i.map(n=>({name:s,value:n.replace(Er,`\r +`)})):{name:s,value:i.replace(Er,`\r +`)}}).toArray()}var U0={};se(U0,{extract:()=>Ac});function oc(e){var t;return typeof e=="string"?{selector:e,value:"textContent"}:{selector:e.selector,value:(t=e.value)!==null&&t!==void 0?t:"textContent"}}function Ac(e){let t={};for(let u in e){let a=e[u],s=Array.isArray(a),{selector:i,value:n}=oc(s?a[0]:a),d=typeof n=="function"?n:typeof n=="string"?l=>this._make(l).prop(n):l=>this._make(l).extract(n);if(s)t[u]=this._findBySelector(i,Number.POSITIVE_INFINITY).map((l,h)=>d(h,u,t)).get();else{let l=this._findBySelector(i,1);t[u]=l.length>0?d(l[0],u,t):void 0}}return t}var Se=class{constructor(t,u,a){if(this.length=0,this.options=a,this._root=u,t){for(let s=0;s=55296&&e<=57343}function gr(e){return e>=56320&&e<=57343}function pr(e,t){return(e-55296)*1024+9216+t}function Eu(e){return e!==32&&e!==10&&e!==13&&e!==9&&e!==12&&e>=1&&e<=31||e>=127&&e<=159}function mu(e){return e>=64976&&e<=65007||lc.has(e)}var b;(function(e){e.controlCharacterInInputStream="control-character-in-input-stream",e.noncharacterInInputStream="noncharacter-in-input-stream",e.surrogateInInputStream="surrogate-in-input-stream",e.nonVoidHtmlElementStartTagWithTrailingSolidus="non-void-html-element-start-tag-with-trailing-solidus",e.endTagWithAttributes="end-tag-with-attributes",e.endTagWithTrailingSolidus="end-tag-with-trailing-solidus",e.unexpectedSolidusInTag="unexpected-solidus-in-tag",e.unexpectedNullCharacter="unexpected-null-character",e.unexpectedQuestionMarkInsteadOfTagName="unexpected-question-mark-instead-of-tag-name",e.invalidFirstCharacterOfTagName="invalid-first-character-of-tag-name",e.unexpectedEqualsSignBeforeAttributeName="unexpected-equals-sign-before-attribute-name",e.missingEndTagName="missing-end-tag-name",e.unexpectedCharacterInAttributeName="unexpected-character-in-attribute-name",e.unknownNamedCharacterReference="unknown-named-character-reference",e.missingSemicolonAfterCharacterReference="missing-semicolon-after-character-reference",e.unexpectedCharacterAfterDoctypeSystemIdentifier="unexpected-character-after-doctype-system-identifier",e.unexpectedCharacterInUnquotedAttributeValue="unexpected-character-in-unquoted-attribute-value",e.eofBeforeTagName="eof-before-tag-name",e.eofInTag="eof-in-tag",e.missingAttributeValue="missing-attribute-value",e.missingWhitespaceBetweenAttributes="missing-whitespace-between-attributes",e.missingWhitespaceAfterDoctypePublicKeyword="missing-whitespace-after-doctype-public-keyword",e.missingWhitespaceBetweenDoctypePublicAndSystemIdentifiers="missing-whitespace-between-doctype-public-and-system-identifiers",e.missingWhitespaceAfterDoctypeSystemKeyword="missing-whitespace-after-doctype-system-keyword",e.missingQuoteBeforeDoctypePublicIdentifier="missing-quote-before-doctype-public-identifier",e.missingQuoteBeforeDoctypeSystemIdentifier="missing-quote-before-doctype-system-identifier",e.missingDoctypePublicIdentifier="missing-doctype-public-identifier",e.missingDoctypeSystemIdentifier="missing-doctype-system-identifier",e.abruptDoctypePublicIdentifier="abrupt-doctype-public-identifier",e.abruptDoctypeSystemIdentifier="abrupt-doctype-system-identifier",e.cdataInHtmlContent="cdata-in-html-content",e.incorrectlyOpenedComment="incorrectly-opened-comment",e.eofInScriptHtmlCommentLikeText="eof-in-script-html-comment-like-text",e.eofInDoctype="eof-in-doctype",e.nestedComment="nested-comment",e.abruptClosingOfEmptyComment="abrupt-closing-of-empty-comment",e.eofInComment="eof-in-comment",e.incorrectlyClosedComment="incorrectly-closed-comment",e.eofInCdata="eof-in-cdata",e.absenceOfDigitsInNumericCharacterReference="absence-of-digits-in-numeric-character-reference",e.nullCharacterReference="null-character-reference",e.surrogateCharacterReference="surrogate-character-reference",e.characterReferenceOutsideUnicodeRange="character-reference-outside-unicode-range",e.controlCharacterReference="control-character-reference",e.noncharacterCharacterReference="noncharacter-character-reference",e.missingWhitespaceBeforeDoctypeName="missing-whitespace-before-doctype-name",e.missingDoctypeName="missing-doctype-name",e.invalidCharacterSequenceAfterDoctypeName="invalid-character-sequence-after-doctype-name",e.duplicateAttribute="duplicate-attribute",e.nonConformingDoctype="non-conforming-doctype",e.missingDoctype="missing-doctype",e.misplacedDoctype="misplaced-doctype",e.endTagWithoutMatchingOpenElement="end-tag-without-matching-open-element",e.closingOfElementWithOpenChildElements="closing-of-element-with-open-child-elements",e.disallowedContentInNoscriptInHead="disallowed-content-in-noscript-in-head",e.openElementsLeftAfterEof="open-elements-left-after-eof",e.abandonedHeadElementChild="abandoned-head-element-child",e.misplacedStartTagForHeadElement="misplaced-start-tag-for-head-element",e.nestedNoscriptInHead="nested-noscript-in-head",e.eofInElementThatCanContainOnlyText="eof-in-element-that-can-contain-only-text"})(b||(b={}));var hc=65536,gu=class{constructor(t){this.handler=t,this.html="",this.pos=-1,this.lastGapPos=-2,this.gapStack=[],this.skipNextNewLine=!1,this.lastChunkWritten=!1,this.endOfChunkHit=!1,this.bufferWaterline=hc,this.isEol=!1,this.lineStartPos=0,this.droppedBufferSize=0,this.line=1,this.lastErrOffset=-1}get col(){return this.pos-this.lineStartPos+ +(this.lastGapPos!==this.pos)}get offset(){return this.droppedBufferSize+this.pos}getError(t,u){let{line:a,col:s,offset:i}=this,n=s+u,d=i+u;return{code:t,startLine:a,endLine:a,startCol:n,endCol:n,startOffset:d,endOffset:d}}_err(t){this.handler.onParseError&&this.lastErrOffset!==this.offset&&(this.lastErrOffset=this.offset,this.handler.onParseError(this.getError(t,0)))}_addGap(){this.gapStack.push(this.lastGapPos),this.lastGapPos=this.pos}_processSurrogate(t){if(this.pos!==this.html.length-1){let u=this.html.charCodeAt(this.pos+1);if(gr(u))return this.pos++,this._addGap(),pr(t,u)}else if(!this.lastChunkWritten)return this.endOfChunkHit=!0,c.EOF;return this._err(b.surrogateInInputStream),t}willDropParsedChunk(){return this.pos>this.bufferWaterline}dropParsedChunk(){this.willDropParsedChunk()&&(this.html=this.html.substring(this.pos),this.lineStartPos-=this.pos,this.droppedBufferSize+=this.pos,this.pos=0,this.lastGapPos=-2,this.gapStack.length=0)}write(t,u){this.html.length>0?this.html+=t:this.html=t,this.endOfChunkHit=!1,this.lastChunkWritten=u}insertHtmlAtCurrentPos(t){this.html=this.html.substring(0,this.pos+1)+t+this.html.substring(this.pos+1),this.endOfChunkHit=!1}startsWith(t,u){if(this.pos+t.length>this.html.length)return this.endOfChunkHit=!this.lastChunkWritten,!1;if(u)return this.html.startsWith(t,this.pos);for(let a=0;a=this.html.length)return this.endOfChunkHit=!this.lastChunkWritten,c.EOF;let a=this.html.charCodeAt(u);return a===c.CARRIAGE_RETURN?c.LINE_FEED:a}advance(){if(this.pos++,this.isEol&&(this.isEol=!1,this.line++,this.lineStartPos=this.pos),this.pos>=this.html.length)return this.endOfChunkHit=!this.lastChunkWritten,c.EOF;let t=this.html.charCodeAt(this.pos);return t===c.CARRIAGE_RETURN?(this.isEol=!0,this.skipNextNewLine=!0,c.LINE_FEED):t===c.LINE_FEED&&(this.isEol=!0,this.skipNextNewLine)?(this.line--,this.skipNextNewLine=!1,this._addGap(),this.advance()):(this.skipNextNewLine=!1,bu(t)&&(t=this._processSurrogate(t)),this.handler.onParseError===null||t>31&&t<127||t===c.LINE_FEED||t===c.CARRIAGE_RETURN||t>159&&t<64976||this._checkForProblematicCharacters(t),t)}_checkForProblematicCharacters(t){Eu(t)?this._err(b.controlCharacterInInputStream):mu(t)&&this._err(b.noncharacterInInputStream)}retreat(t){for(this.pos-=t;this.pos=0;u--)if(e.attrs[u].name===t)return e.attrs[u].value;return null}var Tu=new Uint16Array('\u1D41<\xD5\u0131\u028A\u049D\u057B\u05D0\u0675\u06DE\u07A2\u07D6\u080F\u0A4A\u0A91\u0DA1\u0E6D\u0F09\u0F26\u10CA\u1228\u12E1\u1415\u149D\u14C3\u14DF\u1525\0\0\0\0\0\0\u156B\u16CD\u198D\u1C12\u1DDD\u1F7E\u2060\u21B0\u228D\u23C0\u23FB\u2442\u2824\u2912\u2D08\u2E48\u2FCE\u3016\u32BA\u3639\u37AC\u38FE\u3A28\u3A71\u3AE0\u3B2E\u0800EMabcfglmnoprstu\\bfms\x7F\x84\x8B\x90\x95\x98\xA6\xB3\xB9\xC8\xCFlig\u803B\xC6\u40C6P\u803B&\u4026cute\u803B\xC1\u40C1reve;\u4102\u0100iyx}rc\u803B\xC2\u40C2;\u4410r;\uC000\u{1D504}rave\u803B\xC0\u40C0pha;\u4391acr;\u4100d;\u6A53\u0100gp\x9D\xA1on;\u4104f;\uC000\u{1D538}plyFunction;\u6061ing\u803B\xC5\u40C5\u0100cs\xBE\xC3r;\uC000\u{1D49C}ign;\u6254ilde\u803B\xC3\u40C3ml\u803B\xC4\u40C4\u0400aceforsu\xE5\xFB\xFE\u0117\u011C\u0122\u0127\u012A\u0100cr\xEA\xF2kslash;\u6216\u0176\xF6\xF8;\u6AE7ed;\u6306y;\u4411\u0180crt\u0105\u010B\u0114ause;\u6235noullis;\u612Ca;\u4392r;\uC000\u{1D505}pf;\uC000\u{1D539}eve;\u42D8c\xF2\u0113mpeq;\u624E\u0700HOacdefhilorsu\u014D\u0151\u0156\u0180\u019E\u01A2\u01B5\u01B7\u01BA\u01DC\u0215\u0273\u0278\u027Ecy;\u4427PY\u803B\xA9\u40A9\u0180cpy\u015D\u0162\u017Aute;\u4106\u0100;i\u0167\u0168\u62D2talDifferentialD;\u6145leys;\u612D\u0200aeio\u0189\u018E\u0194\u0198ron;\u410Cdil\u803B\xC7\u40C7rc;\u4108nint;\u6230ot;\u410A\u0100dn\u01A7\u01ADilla;\u40B8terDot;\u40B7\xF2\u017Fi;\u43A7rcle\u0200DMPT\u01C7\u01CB\u01D1\u01D6ot;\u6299inus;\u6296lus;\u6295imes;\u6297o\u0100cs\u01E2\u01F8kwiseContourIntegral;\u6232eCurly\u0100DQ\u0203\u020FoubleQuote;\u601Duote;\u6019\u0200lnpu\u021E\u0228\u0247\u0255on\u0100;e\u0225\u0226\u6237;\u6A74\u0180git\u022F\u0236\u023Aruent;\u6261nt;\u622FourIntegral;\u622E\u0100fr\u024C\u024E;\u6102oduct;\u6210nterClockwiseContourIntegral;\u6233oss;\u6A2Fcr;\uC000\u{1D49E}p\u0100;C\u0284\u0285\u62D3ap;\u624D\u0580DJSZacefios\u02A0\u02AC\u02B0\u02B4\u02B8\u02CB\u02D7\u02E1\u02E6\u0333\u048D\u0100;o\u0179\u02A5trahd;\u6911cy;\u4402cy;\u4405cy;\u440F\u0180grs\u02BF\u02C4\u02C7ger;\u6021r;\u61A1hv;\u6AE4\u0100ay\u02D0\u02D5ron;\u410E;\u4414l\u0100;t\u02DD\u02DE\u6207a;\u4394r;\uC000\u{1D507}\u0100af\u02EB\u0327\u0100cm\u02F0\u0322ritical\u0200ADGT\u0300\u0306\u0316\u031Ccute;\u40B4o\u0174\u030B\u030D;\u42D9bleAcute;\u42DDrave;\u4060ilde;\u42DCond;\u62C4ferentialD;\u6146\u0470\u033D\0\0\0\u0342\u0354\0\u0405f;\uC000\u{1D53B}\u0180;DE\u0348\u0349\u034D\u40A8ot;\u60DCqual;\u6250ble\u0300CDLRUV\u0363\u0372\u0382\u03CF\u03E2\u03F8ontourIntegra\xEC\u0239o\u0274\u0379\0\0\u037B\xBB\u0349nArrow;\u61D3\u0100eo\u0387\u03A4ft\u0180ART\u0390\u0396\u03A1rrow;\u61D0ightArrow;\u61D4e\xE5\u02CAng\u0100LR\u03AB\u03C4eft\u0100AR\u03B3\u03B9rrow;\u67F8ightArrow;\u67FAightArrow;\u67F9ight\u0100AT\u03D8\u03DErrow;\u61D2ee;\u62A8p\u0241\u03E9\0\0\u03EFrrow;\u61D1ownArrow;\u61D5erticalBar;\u6225n\u0300ABLRTa\u0412\u042A\u0430\u045E\u047F\u037Crrow\u0180;BU\u041D\u041E\u0422\u6193ar;\u6913pArrow;\u61F5reve;\u4311eft\u02D2\u043A\0\u0446\0\u0450ightVector;\u6950eeVector;\u695Eector\u0100;B\u0459\u045A\u61BDar;\u6956ight\u01D4\u0467\0\u0471eeVector;\u695Fector\u0100;B\u047A\u047B\u61C1ar;\u6957ee\u0100;A\u0486\u0487\u62A4rrow;\u61A7\u0100ct\u0492\u0497r;\uC000\u{1D49F}rok;\u4110\u0800NTacdfglmopqstux\u04BD\u04C0\u04C4\u04CB\u04DE\u04E2\u04E7\u04EE\u04F5\u0521\u052F\u0536\u0552\u055D\u0560\u0565G;\u414AH\u803B\xD0\u40D0cute\u803B\xC9\u40C9\u0180aiy\u04D2\u04D7\u04DCron;\u411Arc\u803B\xCA\u40CA;\u442Dot;\u4116r;\uC000\u{1D508}rave\u803B\xC8\u40C8ement;\u6208\u0100ap\u04FA\u04FEcr;\u4112ty\u0253\u0506\0\0\u0512mallSquare;\u65FBerySmallSquare;\u65AB\u0100gp\u0526\u052Aon;\u4118f;\uC000\u{1D53C}silon;\u4395u\u0100ai\u053C\u0549l\u0100;T\u0542\u0543\u6A75ilde;\u6242librium;\u61CC\u0100ci\u0557\u055Ar;\u6130m;\u6A73a;\u4397ml\u803B\xCB\u40CB\u0100ip\u056A\u056Fsts;\u6203onentialE;\u6147\u0280cfios\u0585\u0588\u058D\u05B2\u05CCy;\u4424r;\uC000\u{1D509}lled\u0253\u0597\0\0\u05A3mallSquare;\u65FCerySmallSquare;\u65AA\u0370\u05BA\0\u05BF\0\0\u05C4f;\uC000\u{1D53D}All;\u6200riertrf;\u6131c\xF2\u05CB\u0600JTabcdfgorst\u05E8\u05EC\u05EF\u05FA\u0600\u0612\u0616\u061B\u061D\u0623\u066C\u0672cy;\u4403\u803B>\u403Emma\u0100;d\u05F7\u05F8\u4393;\u43DCreve;\u411E\u0180eiy\u0607\u060C\u0610dil;\u4122rc;\u411C;\u4413ot;\u4120r;\uC000\u{1D50A};\u62D9pf;\uC000\u{1D53E}eater\u0300EFGLST\u0635\u0644\u064E\u0656\u065B\u0666qual\u0100;L\u063E\u063F\u6265ess;\u62DBullEqual;\u6267reater;\u6AA2ess;\u6277lantEqual;\u6A7Eilde;\u6273cr;\uC000\u{1D4A2};\u626B\u0400Aacfiosu\u0685\u068B\u0696\u069B\u069E\u06AA\u06BE\u06CARDcy;\u442A\u0100ct\u0690\u0694ek;\u42C7;\u405Eirc;\u4124r;\u610ClbertSpace;\u610B\u01F0\u06AF\0\u06B2f;\u610DizontalLine;\u6500\u0100ct\u06C3\u06C5\xF2\u06A9rok;\u4126mp\u0144\u06D0\u06D8ownHum\xF0\u012Fqual;\u624F\u0700EJOacdfgmnostu\u06FA\u06FE\u0703\u0707\u070E\u071A\u071E\u0721\u0728\u0744\u0778\u078B\u078F\u0795cy;\u4415lig;\u4132cy;\u4401cute\u803B\xCD\u40CD\u0100iy\u0713\u0718rc\u803B\xCE\u40CE;\u4418ot;\u4130r;\u6111rave\u803B\xCC\u40CC\u0180;ap\u0720\u072F\u073F\u0100cg\u0734\u0737r;\u412AinaryI;\u6148lie\xF3\u03DD\u01F4\u0749\0\u0762\u0100;e\u074D\u074E\u622C\u0100gr\u0753\u0758ral;\u622Bsection;\u62C2isible\u0100CT\u076C\u0772omma;\u6063imes;\u6062\u0180gpt\u077F\u0783\u0788on;\u412Ef;\uC000\u{1D540}a;\u4399cr;\u6110ilde;\u4128\u01EB\u079A\0\u079Ecy;\u4406l\u803B\xCF\u40CF\u0280cfosu\u07AC\u07B7\u07BC\u07C2\u07D0\u0100iy\u07B1\u07B5rc;\u4134;\u4419r;\uC000\u{1D50D}pf;\uC000\u{1D541}\u01E3\u07C7\0\u07CCr;\uC000\u{1D4A5}rcy;\u4408kcy;\u4404\u0380HJacfos\u07E4\u07E8\u07EC\u07F1\u07FD\u0802\u0808cy;\u4425cy;\u440Cppa;\u439A\u0100ey\u07F6\u07FBdil;\u4136;\u441Ar;\uC000\u{1D50E}pf;\uC000\u{1D542}cr;\uC000\u{1D4A6}\u0580JTaceflmost\u0825\u0829\u082C\u0850\u0863\u09B3\u09B8\u09C7\u09CD\u0A37\u0A47cy;\u4409\u803B<\u403C\u0280cmnpr\u0837\u083C\u0841\u0844\u084Dute;\u4139bda;\u439Bg;\u67EAlacetrf;\u6112r;\u619E\u0180aey\u0857\u085C\u0861ron;\u413Ddil;\u413B;\u441B\u0100fs\u0868\u0970t\u0500ACDFRTUVar\u087E\u08A9\u08B1\u08E0\u08E6\u08FC\u092F\u095B\u0390\u096A\u0100nr\u0883\u088FgleBracket;\u67E8row\u0180;BR\u0899\u089A\u089E\u6190ar;\u61E4ightArrow;\u61C6eiling;\u6308o\u01F5\u08B7\0\u08C3bleBracket;\u67E6n\u01D4\u08C8\0\u08D2eeVector;\u6961ector\u0100;B\u08DB\u08DC\u61C3ar;\u6959loor;\u630Aight\u0100AV\u08EF\u08F5rrow;\u6194ector;\u694E\u0100er\u0901\u0917e\u0180;AV\u0909\u090A\u0910\u62A3rrow;\u61A4ector;\u695Aiangle\u0180;BE\u0924\u0925\u0929\u62B2ar;\u69CFqual;\u62B4p\u0180DTV\u0937\u0942\u094CownVector;\u6951eeVector;\u6960ector\u0100;B\u0956\u0957\u61BFar;\u6958ector\u0100;B\u0965\u0966\u61BCar;\u6952ight\xE1\u039Cs\u0300EFGLST\u097E\u098B\u0995\u099D\u09A2\u09ADqualGreater;\u62DAullEqual;\u6266reater;\u6276ess;\u6AA1lantEqual;\u6A7Dilde;\u6272r;\uC000\u{1D50F}\u0100;e\u09BD\u09BE\u62D8ftarrow;\u61DAidot;\u413F\u0180npw\u09D4\u0A16\u0A1Bg\u0200LRlr\u09DE\u09F7\u0A02\u0A10eft\u0100AR\u09E6\u09ECrrow;\u67F5ightArrow;\u67F7ightArrow;\u67F6eft\u0100ar\u03B3\u0A0Aight\xE1\u03BFight\xE1\u03CAf;\uC000\u{1D543}er\u0100LR\u0A22\u0A2CeftArrow;\u6199ightArrow;\u6198\u0180cht\u0A3E\u0A40\u0A42\xF2\u084C;\u61B0rok;\u4141;\u626A\u0400acefiosu\u0A5A\u0A5D\u0A60\u0A77\u0A7C\u0A85\u0A8B\u0A8Ep;\u6905y;\u441C\u0100dl\u0A65\u0A6FiumSpace;\u605Flintrf;\u6133r;\uC000\u{1D510}nusPlus;\u6213pf;\uC000\u{1D544}c\xF2\u0A76;\u439C\u0480Jacefostu\u0AA3\u0AA7\u0AAD\u0AC0\u0B14\u0B19\u0D91\u0D97\u0D9Ecy;\u440Acute;\u4143\u0180aey\u0AB4\u0AB9\u0ABEron;\u4147dil;\u4145;\u441D\u0180gsw\u0AC7\u0AF0\u0B0Eative\u0180MTV\u0AD3\u0ADF\u0AE8ediumSpace;\u600Bhi\u0100cn\u0AE6\u0AD8\xEB\u0AD9eryThi\xEE\u0AD9ted\u0100GL\u0AF8\u0B06reaterGreate\xF2\u0673essLes\xF3\u0A48Line;\u400Ar;\uC000\u{1D511}\u0200Bnpt\u0B22\u0B28\u0B37\u0B3Areak;\u6060BreakingSpace;\u40A0f;\u6115\u0680;CDEGHLNPRSTV\u0B55\u0B56\u0B6A\u0B7C\u0BA1\u0BEB\u0C04\u0C5E\u0C84\u0CA6\u0CD8\u0D61\u0D85\u6AEC\u0100ou\u0B5B\u0B64ngruent;\u6262pCap;\u626DoubleVerticalBar;\u6226\u0180lqx\u0B83\u0B8A\u0B9Bement;\u6209ual\u0100;T\u0B92\u0B93\u6260ilde;\uC000\u2242\u0338ists;\u6204reater\u0380;EFGLST\u0BB6\u0BB7\u0BBD\u0BC9\u0BD3\u0BD8\u0BE5\u626Fqual;\u6271ullEqual;\uC000\u2267\u0338reater;\uC000\u226B\u0338ess;\u6279lantEqual;\uC000\u2A7E\u0338ilde;\u6275ump\u0144\u0BF2\u0BFDownHump;\uC000\u224E\u0338qual;\uC000\u224F\u0338e\u0100fs\u0C0A\u0C27tTriangle\u0180;BE\u0C1A\u0C1B\u0C21\u62EAar;\uC000\u29CF\u0338qual;\u62ECs\u0300;EGLST\u0C35\u0C36\u0C3C\u0C44\u0C4B\u0C58\u626Equal;\u6270reater;\u6278ess;\uC000\u226A\u0338lantEqual;\uC000\u2A7D\u0338ilde;\u6274ested\u0100GL\u0C68\u0C79reaterGreater;\uC000\u2AA2\u0338essLess;\uC000\u2AA1\u0338recedes\u0180;ES\u0C92\u0C93\u0C9B\u6280qual;\uC000\u2AAF\u0338lantEqual;\u62E0\u0100ei\u0CAB\u0CB9verseElement;\u620CghtTriangle\u0180;BE\u0CCB\u0CCC\u0CD2\u62EBar;\uC000\u29D0\u0338qual;\u62ED\u0100qu\u0CDD\u0D0CuareSu\u0100bp\u0CE8\u0CF9set\u0100;E\u0CF0\u0CF3\uC000\u228F\u0338qual;\u62E2erset\u0100;E\u0D03\u0D06\uC000\u2290\u0338qual;\u62E3\u0180bcp\u0D13\u0D24\u0D4Eset\u0100;E\u0D1B\u0D1E\uC000\u2282\u20D2qual;\u6288ceeds\u0200;EST\u0D32\u0D33\u0D3B\u0D46\u6281qual;\uC000\u2AB0\u0338lantEqual;\u62E1ilde;\uC000\u227F\u0338erset\u0100;E\u0D58\u0D5B\uC000\u2283\u20D2qual;\u6289ilde\u0200;EFT\u0D6E\u0D6F\u0D75\u0D7F\u6241qual;\u6244ullEqual;\u6247ilde;\u6249erticalBar;\u6224cr;\uC000\u{1D4A9}ilde\u803B\xD1\u40D1;\u439D\u0700Eacdfgmoprstuv\u0DBD\u0DC2\u0DC9\u0DD5\u0DDB\u0DE0\u0DE7\u0DFC\u0E02\u0E20\u0E22\u0E32\u0E3F\u0E44lig;\u4152cute\u803B\xD3\u40D3\u0100iy\u0DCE\u0DD3rc\u803B\xD4\u40D4;\u441Eblac;\u4150r;\uC000\u{1D512}rave\u803B\xD2\u40D2\u0180aei\u0DEE\u0DF2\u0DF6cr;\u414Cga;\u43A9cron;\u439Fpf;\uC000\u{1D546}enCurly\u0100DQ\u0E0E\u0E1AoubleQuote;\u601Cuote;\u6018;\u6A54\u0100cl\u0E27\u0E2Cr;\uC000\u{1D4AA}ash\u803B\xD8\u40D8i\u016C\u0E37\u0E3Cde\u803B\xD5\u40D5es;\u6A37ml\u803B\xD6\u40D6er\u0100BP\u0E4B\u0E60\u0100ar\u0E50\u0E53r;\u603Eac\u0100ek\u0E5A\u0E5C;\u63DEet;\u63B4arenthesis;\u63DC\u0480acfhilors\u0E7F\u0E87\u0E8A\u0E8F\u0E92\u0E94\u0E9D\u0EB0\u0EFCrtialD;\u6202y;\u441Fr;\uC000\u{1D513}i;\u43A6;\u43A0usMinus;\u40B1\u0100ip\u0EA2\u0EADncareplan\xE5\u069Df;\u6119\u0200;eio\u0EB9\u0EBA\u0EE0\u0EE4\u6ABBcedes\u0200;EST\u0EC8\u0EC9\u0ECF\u0EDA\u627Aqual;\u6AAFlantEqual;\u627Cilde;\u627Eme;\u6033\u0100dp\u0EE9\u0EEEuct;\u620Fortion\u0100;a\u0225\u0EF9l;\u621D\u0100ci\u0F01\u0F06r;\uC000\u{1D4AB};\u43A8\u0200Ufos\u0F11\u0F16\u0F1B\u0F1FOT\u803B"\u4022r;\uC000\u{1D514}pf;\u611Acr;\uC000\u{1D4AC}\u0600BEacefhiorsu\u0F3E\u0F43\u0F47\u0F60\u0F73\u0FA7\u0FAA\u0FAD\u1096\u10A9\u10B4\u10BEarr;\u6910G\u803B\xAE\u40AE\u0180cnr\u0F4E\u0F53\u0F56ute;\u4154g;\u67EBr\u0100;t\u0F5C\u0F5D\u61A0l;\u6916\u0180aey\u0F67\u0F6C\u0F71ron;\u4158dil;\u4156;\u4420\u0100;v\u0F78\u0F79\u611Cerse\u0100EU\u0F82\u0F99\u0100lq\u0F87\u0F8Eement;\u620Builibrium;\u61CBpEquilibrium;\u696Fr\xBB\u0F79o;\u43A1ght\u0400ACDFTUVa\u0FC1\u0FEB\u0FF3\u1022\u1028\u105B\u1087\u03D8\u0100nr\u0FC6\u0FD2gleBracket;\u67E9row\u0180;BL\u0FDC\u0FDD\u0FE1\u6192ar;\u61E5eftArrow;\u61C4eiling;\u6309o\u01F5\u0FF9\0\u1005bleBracket;\u67E7n\u01D4\u100A\0\u1014eeVector;\u695Dector\u0100;B\u101D\u101E\u61C2ar;\u6955loor;\u630B\u0100er\u102D\u1043e\u0180;AV\u1035\u1036\u103C\u62A2rrow;\u61A6ector;\u695Biangle\u0180;BE\u1050\u1051\u1055\u62B3ar;\u69D0qual;\u62B5p\u0180DTV\u1063\u106E\u1078ownVector;\u694FeeVector;\u695Cector\u0100;B\u1082\u1083\u61BEar;\u6954ector\u0100;B\u1091\u1092\u61C0ar;\u6953\u0100pu\u109B\u109Ef;\u611DndImplies;\u6970ightarrow;\u61DB\u0100ch\u10B9\u10BCr;\u611B;\u61B1leDelayed;\u69F4\u0680HOacfhimoqstu\u10E4\u10F1\u10F7\u10FD\u1119\u111E\u1151\u1156\u1161\u1167\u11B5\u11BB\u11BF\u0100Cc\u10E9\u10EEHcy;\u4429y;\u4428FTcy;\u442Ccute;\u415A\u0280;aeiy\u1108\u1109\u110E\u1113\u1117\u6ABCron;\u4160dil;\u415Erc;\u415C;\u4421r;\uC000\u{1D516}ort\u0200DLRU\u112A\u1134\u113E\u1149ownArrow\xBB\u041EeftArrow\xBB\u089AightArrow\xBB\u0FDDpArrow;\u6191gma;\u43A3allCircle;\u6218pf;\uC000\u{1D54A}\u0272\u116D\0\0\u1170t;\u621Aare\u0200;ISU\u117B\u117C\u1189\u11AF\u65A1ntersection;\u6293u\u0100bp\u118F\u119Eset\u0100;E\u1197\u1198\u628Fqual;\u6291erset\u0100;E\u11A8\u11A9\u6290qual;\u6292nion;\u6294cr;\uC000\u{1D4AE}ar;\u62C6\u0200bcmp\u11C8\u11DB\u1209\u120B\u0100;s\u11CD\u11CE\u62D0et\u0100;E\u11CD\u11D5qual;\u6286\u0100ch\u11E0\u1205eeds\u0200;EST\u11ED\u11EE\u11F4\u11FF\u627Bqual;\u6AB0lantEqual;\u627Dilde;\u627FTh\xE1\u0F8C;\u6211\u0180;es\u1212\u1213\u1223\u62D1rset\u0100;E\u121C\u121D\u6283qual;\u6287et\xBB\u1213\u0580HRSacfhiors\u123E\u1244\u1249\u1255\u125E\u1271\u1276\u129F\u12C2\u12C8\u12D1ORN\u803B\xDE\u40DEADE;\u6122\u0100Hc\u124E\u1252cy;\u440By;\u4426\u0100bu\u125A\u125C;\u4009;\u43A4\u0180aey\u1265\u126A\u126Fron;\u4164dil;\u4162;\u4422r;\uC000\u{1D517}\u0100ei\u127B\u1289\u01F2\u1280\0\u1287efore;\u6234a;\u4398\u0100cn\u128E\u1298kSpace;\uC000\u205F\u200ASpace;\u6009lde\u0200;EFT\u12AB\u12AC\u12B2\u12BC\u623Cqual;\u6243ullEqual;\u6245ilde;\u6248pf;\uC000\u{1D54B}ipleDot;\u60DB\u0100ct\u12D6\u12DBr;\uC000\u{1D4AF}rok;\u4166\u0AE1\u12F7\u130E\u131A\u1326\0\u132C\u1331\0\0\0\0\0\u1338\u133D\u1377\u1385\0\u13FF\u1404\u140A\u1410\u0100cr\u12FB\u1301ute\u803B\xDA\u40DAr\u0100;o\u1307\u1308\u619Fcir;\u6949r\u01E3\u1313\0\u1316y;\u440Eve;\u416C\u0100iy\u131E\u1323rc\u803B\xDB\u40DB;\u4423blac;\u4170r;\uC000\u{1D518}rave\u803B\xD9\u40D9acr;\u416A\u0100di\u1341\u1369er\u0100BP\u1348\u135D\u0100ar\u134D\u1350r;\u405Fac\u0100ek\u1357\u1359;\u63DFet;\u63B5arenthesis;\u63DDon\u0100;P\u1370\u1371\u62C3lus;\u628E\u0100gp\u137B\u137Fon;\u4172f;\uC000\u{1D54C}\u0400ADETadps\u1395\u13AE\u13B8\u13C4\u03E8\u13D2\u13D7\u13F3rrow\u0180;BD\u1150\u13A0\u13A4ar;\u6912ownArrow;\u61C5ownArrow;\u6195quilibrium;\u696Eee\u0100;A\u13CB\u13CC\u62A5rrow;\u61A5own\xE1\u03F3er\u0100LR\u13DE\u13E8eftArrow;\u6196ightArrow;\u6197i\u0100;l\u13F9\u13FA\u43D2on;\u43A5ing;\u416Ecr;\uC000\u{1D4B0}ilde;\u4168ml\u803B\xDC\u40DC\u0480Dbcdefosv\u1427\u142C\u1430\u1433\u143E\u1485\u148A\u1490\u1496ash;\u62ABar;\u6AEBy;\u4412ash\u0100;l\u143B\u143C\u62A9;\u6AE6\u0100er\u1443\u1445;\u62C1\u0180bty\u144C\u1450\u147Aar;\u6016\u0100;i\u144F\u1455cal\u0200BLST\u1461\u1465\u146A\u1474ar;\u6223ine;\u407Ceparator;\u6758ilde;\u6240ThinSpace;\u600Ar;\uC000\u{1D519}pf;\uC000\u{1D54D}cr;\uC000\u{1D4B1}dash;\u62AA\u0280cefos\u14A7\u14AC\u14B1\u14B6\u14BCirc;\u4174dge;\u62C0r;\uC000\u{1D51A}pf;\uC000\u{1D54E}cr;\uC000\u{1D4B2}\u0200fios\u14CB\u14D0\u14D2\u14D8r;\uC000\u{1D51B};\u439Epf;\uC000\u{1D54F}cr;\uC000\u{1D4B3}\u0480AIUacfosu\u14F1\u14F5\u14F9\u14FD\u1504\u150F\u1514\u151A\u1520cy;\u442Fcy;\u4407cy;\u442Ecute\u803B\xDD\u40DD\u0100iy\u1509\u150Drc;\u4176;\u442Br;\uC000\u{1D51C}pf;\uC000\u{1D550}cr;\uC000\u{1D4B4}ml;\u4178\u0400Hacdefos\u1535\u1539\u153F\u154B\u154F\u155D\u1560\u1564cy;\u4416cute;\u4179\u0100ay\u1544\u1549ron;\u417D;\u4417ot;\u417B\u01F2\u1554\0\u155BoWidt\xE8\u0AD9a;\u4396r;\u6128pf;\u6124cr;\uC000\u{1D4B5}\u0BE1\u1583\u158A\u1590\0\u15B0\u15B6\u15BF\0\0\0\0\u15C6\u15DB\u15EB\u165F\u166D\0\u1695\u169B\u16B2\u16B9\0\u16BEcute\u803B\xE1\u40E1reve;\u4103\u0300;Ediuy\u159C\u159D\u15A1\u15A3\u15A8\u15AD\u623E;\uC000\u223E\u0333;\u623Frc\u803B\xE2\u40E2te\u80BB\xB4\u0306;\u4430lig\u803B\xE6\u40E6\u0100;r\xB2\u15BA;\uC000\u{1D51E}rave\u803B\xE0\u40E0\u0100ep\u15CA\u15D6\u0100fp\u15CF\u15D4sym;\u6135\xE8\u15D3ha;\u43B1\u0100ap\u15DFc\u0100cl\u15E4\u15E7r;\u4101g;\u6A3F\u0264\u15F0\0\0\u160A\u0280;adsv\u15FA\u15FB\u15FF\u1601\u1607\u6227nd;\u6A55;\u6A5Clope;\u6A58;\u6A5A\u0380;elmrsz\u1618\u1619\u161B\u161E\u163F\u164F\u1659\u6220;\u69A4e\xBB\u1619sd\u0100;a\u1625\u1626\u6221\u0461\u1630\u1632\u1634\u1636\u1638\u163A\u163C\u163E;\u69A8;\u69A9;\u69AA;\u69AB;\u69AC;\u69AD;\u69AE;\u69AFt\u0100;v\u1645\u1646\u621Fb\u0100;d\u164C\u164D\u62BE;\u699D\u0100pt\u1654\u1657h;\u6222\xBB\xB9arr;\u637C\u0100gp\u1663\u1667on;\u4105f;\uC000\u{1D552}\u0380;Eaeiop\u12C1\u167B\u167D\u1682\u1684\u1687\u168A;\u6A70cir;\u6A6F;\u624Ad;\u624Bs;\u4027rox\u0100;e\u12C1\u1692\xF1\u1683ing\u803B\xE5\u40E5\u0180cty\u16A1\u16A6\u16A8r;\uC000\u{1D4B6};\u402Amp\u0100;e\u12C1\u16AF\xF1\u0288ilde\u803B\xE3\u40E3ml\u803B\xE4\u40E4\u0100ci\u16C2\u16C8onin\xF4\u0272nt;\u6A11\u0800Nabcdefiklnoprsu\u16ED\u16F1\u1730\u173C\u1743\u1748\u1778\u177D\u17E0\u17E6\u1839\u1850\u170D\u193D\u1948\u1970ot;\u6AED\u0100cr\u16F6\u171Ek\u0200ceps\u1700\u1705\u170D\u1713ong;\u624Cpsilon;\u43F6rime;\u6035im\u0100;e\u171A\u171B\u623Dq;\u62CD\u0176\u1722\u1726ee;\u62BDed\u0100;g\u172C\u172D\u6305e\xBB\u172Drk\u0100;t\u135C\u1737brk;\u63B6\u0100oy\u1701\u1741;\u4431quo;\u601E\u0280cmprt\u1753\u175B\u1761\u1764\u1768aus\u0100;e\u010A\u0109ptyv;\u69B0s\xE9\u170Cno\xF5\u0113\u0180ahw\u176F\u1771\u1773;\u43B2;\u6136een;\u626Cr;\uC000\u{1D51F}g\u0380costuvw\u178D\u179D\u17B3\u17C1\u17D5\u17DB\u17DE\u0180aiu\u1794\u1796\u179A\xF0\u0760rc;\u65EFp\xBB\u1371\u0180dpt\u17A4\u17A8\u17ADot;\u6A00lus;\u6A01imes;\u6A02\u0271\u17B9\0\0\u17BEcup;\u6A06ar;\u6605riangle\u0100du\u17CD\u17D2own;\u65BDp;\u65B3plus;\u6A04e\xE5\u1444\xE5\u14ADarow;\u690D\u0180ako\u17ED\u1826\u1835\u0100cn\u17F2\u1823k\u0180lst\u17FA\u05AB\u1802ozenge;\u69EBriangle\u0200;dlr\u1812\u1813\u1818\u181D\u65B4own;\u65BEeft;\u65C2ight;\u65B8k;\u6423\u01B1\u182B\0\u1833\u01B2\u182F\0\u1831;\u6592;\u65914;\u6593ck;\u6588\u0100eo\u183E\u184D\u0100;q\u1843\u1846\uC000=\u20E5uiv;\uC000\u2261\u20E5t;\u6310\u0200ptwx\u1859\u185E\u1867\u186Cf;\uC000\u{1D553}\u0100;t\u13CB\u1863om\xBB\u13CCtie;\u62C8\u0600DHUVbdhmptuv\u1885\u1896\u18AA\u18BB\u18D7\u18DB\u18EC\u18FF\u1905\u190A\u1910\u1921\u0200LRlr\u188E\u1890\u1892\u1894;\u6557;\u6554;\u6556;\u6553\u0280;DUdu\u18A1\u18A2\u18A4\u18A6\u18A8\u6550;\u6566;\u6569;\u6564;\u6567\u0200LRlr\u18B3\u18B5\u18B7\u18B9;\u655D;\u655A;\u655C;\u6559\u0380;HLRhlr\u18CA\u18CB\u18CD\u18CF\u18D1\u18D3\u18D5\u6551;\u656C;\u6563;\u6560;\u656B;\u6562;\u655Fox;\u69C9\u0200LRlr\u18E4\u18E6\u18E8\u18EA;\u6555;\u6552;\u6510;\u650C\u0280;DUdu\u06BD\u18F7\u18F9\u18FB\u18FD;\u6565;\u6568;\u652C;\u6534inus;\u629Flus;\u629Eimes;\u62A0\u0200LRlr\u1919\u191B\u191D\u191F;\u655B;\u6558;\u6518;\u6514\u0380;HLRhlr\u1930\u1931\u1933\u1935\u1937\u1939\u193B\u6502;\u656A;\u6561;\u655E;\u653C;\u6524;\u651C\u0100ev\u0123\u1942bar\u803B\xA6\u40A6\u0200ceio\u1951\u1956\u195A\u1960r;\uC000\u{1D4B7}mi;\u604Fm\u0100;e\u171A\u171Cl\u0180;bh\u1968\u1969\u196B\u405C;\u69C5sub;\u67C8\u016C\u1974\u197El\u0100;e\u1979\u197A\u6022t\xBB\u197Ap\u0180;Ee\u012F\u1985\u1987;\u6AAE\u0100;q\u06DC\u06DB\u0CE1\u19A7\0\u19E8\u1A11\u1A15\u1A32\0\u1A37\u1A50\0\0\u1AB4\0\0\u1AC1\0\0\u1B21\u1B2E\u1B4D\u1B52\0\u1BFD\0\u1C0C\u0180cpr\u19AD\u19B2\u19DDute;\u4107\u0300;abcds\u19BF\u19C0\u19C4\u19CA\u19D5\u19D9\u6229nd;\u6A44rcup;\u6A49\u0100au\u19CF\u19D2p;\u6A4Bp;\u6A47ot;\u6A40;\uC000\u2229\uFE00\u0100eo\u19E2\u19E5t;\u6041\xEE\u0693\u0200aeiu\u19F0\u19FB\u1A01\u1A05\u01F0\u19F5\0\u19F8s;\u6A4Don;\u410Ddil\u803B\xE7\u40E7rc;\u4109ps\u0100;s\u1A0C\u1A0D\u6A4Cm;\u6A50ot;\u410B\u0180dmn\u1A1B\u1A20\u1A26il\u80BB\xB8\u01ADptyv;\u69B2t\u8100\xA2;e\u1A2D\u1A2E\u40A2r\xE4\u01B2r;\uC000\u{1D520}\u0180cei\u1A3D\u1A40\u1A4Dy;\u4447ck\u0100;m\u1A47\u1A48\u6713ark\xBB\u1A48;\u43C7r\u0380;Ecefms\u1A5F\u1A60\u1A62\u1A6B\u1AA4\u1AAA\u1AAE\u65CB;\u69C3\u0180;el\u1A69\u1A6A\u1A6D\u42C6q;\u6257e\u0261\u1A74\0\0\u1A88rrow\u0100lr\u1A7C\u1A81eft;\u61BAight;\u61BB\u0280RSacd\u1A92\u1A94\u1A96\u1A9A\u1A9F\xBB\u0F47;\u64C8st;\u629Birc;\u629Aash;\u629Dnint;\u6A10id;\u6AEFcir;\u69C2ubs\u0100;u\u1ABB\u1ABC\u6663it\xBB\u1ABC\u02EC\u1AC7\u1AD4\u1AFA\0\u1B0Aon\u0100;e\u1ACD\u1ACE\u403A\u0100;q\xC7\xC6\u026D\u1AD9\0\0\u1AE2a\u0100;t\u1ADE\u1ADF\u402C;\u4040\u0180;fl\u1AE8\u1AE9\u1AEB\u6201\xEE\u1160e\u0100mx\u1AF1\u1AF6ent\xBB\u1AE9e\xF3\u024D\u01E7\u1AFE\0\u1B07\u0100;d\u12BB\u1B02ot;\u6A6Dn\xF4\u0246\u0180fry\u1B10\u1B14\u1B17;\uC000\u{1D554}o\xE4\u0254\u8100\xA9;s\u0155\u1B1Dr;\u6117\u0100ao\u1B25\u1B29rr;\u61B5ss;\u6717\u0100cu\u1B32\u1B37r;\uC000\u{1D4B8}\u0100bp\u1B3C\u1B44\u0100;e\u1B41\u1B42\u6ACF;\u6AD1\u0100;e\u1B49\u1B4A\u6AD0;\u6AD2dot;\u62EF\u0380delprvw\u1B60\u1B6C\u1B77\u1B82\u1BAC\u1BD4\u1BF9arr\u0100lr\u1B68\u1B6A;\u6938;\u6935\u0270\u1B72\0\0\u1B75r;\u62DEc;\u62DFarr\u0100;p\u1B7F\u1B80\u61B6;\u693D\u0300;bcdos\u1B8F\u1B90\u1B96\u1BA1\u1BA5\u1BA8\u622Arcap;\u6A48\u0100au\u1B9B\u1B9Ep;\u6A46p;\u6A4Aot;\u628Dr;\u6A45;\uC000\u222A\uFE00\u0200alrv\u1BB5\u1BBF\u1BDE\u1BE3rr\u0100;m\u1BBC\u1BBD\u61B7;\u693Cy\u0180evw\u1BC7\u1BD4\u1BD8q\u0270\u1BCE\0\0\u1BD2re\xE3\u1B73u\xE3\u1B75ee;\u62CEedge;\u62CFen\u803B\xA4\u40A4earrow\u0100lr\u1BEE\u1BF3eft\xBB\u1B80ight\xBB\u1BBDe\xE4\u1BDD\u0100ci\u1C01\u1C07onin\xF4\u01F7nt;\u6231lcty;\u632D\u0980AHabcdefhijlorstuwz\u1C38\u1C3B\u1C3F\u1C5D\u1C69\u1C75\u1C8A\u1C9E\u1CAC\u1CB7\u1CFB\u1CFF\u1D0D\u1D7B\u1D91\u1DAB\u1DBB\u1DC6\u1DCDr\xF2\u0381ar;\u6965\u0200glrs\u1C48\u1C4D\u1C52\u1C54ger;\u6020eth;\u6138\xF2\u1133h\u0100;v\u1C5A\u1C5B\u6010\xBB\u090A\u016B\u1C61\u1C67arow;\u690Fa\xE3\u0315\u0100ay\u1C6E\u1C73ron;\u410F;\u4434\u0180;ao\u0332\u1C7C\u1C84\u0100gr\u02BF\u1C81r;\u61CAtseq;\u6A77\u0180glm\u1C91\u1C94\u1C98\u803B\xB0\u40B0ta;\u43B4ptyv;\u69B1\u0100ir\u1CA3\u1CA8sht;\u697F;\uC000\u{1D521}ar\u0100lr\u1CB3\u1CB5\xBB\u08DC\xBB\u101E\u0280aegsv\u1CC2\u0378\u1CD6\u1CDC\u1CE0m\u0180;os\u0326\u1CCA\u1CD4nd\u0100;s\u0326\u1CD1uit;\u6666amma;\u43DDin;\u62F2\u0180;io\u1CE7\u1CE8\u1CF8\u40F7de\u8100\xF7;o\u1CE7\u1CF0ntimes;\u62C7n\xF8\u1CF7cy;\u4452c\u026F\u1D06\0\0\u1D0Arn;\u631Eop;\u630D\u0280lptuw\u1D18\u1D1D\u1D22\u1D49\u1D55lar;\u4024f;\uC000\u{1D555}\u0280;emps\u030B\u1D2D\u1D37\u1D3D\u1D42q\u0100;d\u0352\u1D33ot;\u6251inus;\u6238lus;\u6214quare;\u62A1blebarwedg\xE5\xFAn\u0180adh\u112E\u1D5D\u1D67ownarrow\xF3\u1C83arpoon\u0100lr\u1D72\u1D76ef\xF4\u1CB4igh\xF4\u1CB6\u0162\u1D7F\u1D85karo\xF7\u0F42\u026F\u1D8A\0\0\u1D8Ern;\u631Fop;\u630C\u0180cot\u1D98\u1DA3\u1DA6\u0100ry\u1D9D\u1DA1;\uC000\u{1D4B9};\u4455l;\u69F6rok;\u4111\u0100dr\u1DB0\u1DB4ot;\u62F1i\u0100;f\u1DBA\u1816\u65BF\u0100ah\u1DC0\u1DC3r\xF2\u0429a\xF2\u0FA6angle;\u69A6\u0100ci\u1DD2\u1DD5y;\u445Fgrarr;\u67FF\u0900Dacdefglmnopqrstux\u1E01\u1E09\u1E19\u1E38\u0578\u1E3C\u1E49\u1E61\u1E7E\u1EA5\u1EAF\u1EBD\u1EE1\u1F2A\u1F37\u1F44\u1F4E\u1F5A\u0100Do\u1E06\u1D34o\xF4\u1C89\u0100cs\u1E0E\u1E14ute\u803B\xE9\u40E9ter;\u6A6E\u0200aioy\u1E22\u1E27\u1E31\u1E36ron;\u411Br\u0100;c\u1E2D\u1E2E\u6256\u803B\xEA\u40EAlon;\u6255;\u444Dot;\u4117\u0100Dr\u1E41\u1E45ot;\u6252;\uC000\u{1D522}\u0180;rs\u1E50\u1E51\u1E57\u6A9Aave\u803B\xE8\u40E8\u0100;d\u1E5C\u1E5D\u6A96ot;\u6A98\u0200;ils\u1E6A\u1E6B\u1E72\u1E74\u6A99nters;\u63E7;\u6113\u0100;d\u1E79\u1E7A\u6A95ot;\u6A97\u0180aps\u1E85\u1E89\u1E97cr;\u4113ty\u0180;sv\u1E92\u1E93\u1E95\u6205et\xBB\u1E93p\u01001;\u1E9D\u1EA4\u0133\u1EA1\u1EA3;\u6004;\u6005\u6003\u0100gs\u1EAA\u1EAC;\u414Bp;\u6002\u0100gp\u1EB4\u1EB8on;\u4119f;\uC000\u{1D556}\u0180als\u1EC4\u1ECE\u1ED2r\u0100;s\u1ECA\u1ECB\u62D5l;\u69E3us;\u6A71i\u0180;lv\u1EDA\u1EDB\u1EDF\u43B5on\xBB\u1EDB;\u43F5\u0200csuv\u1EEA\u1EF3\u1F0B\u1F23\u0100io\u1EEF\u1E31rc\xBB\u1E2E\u0269\u1EF9\0\0\u1EFB\xED\u0548ant\u0100gl\u1F02\u1F06tr\xBB\u1E5Dess\xBB\u1E7A\u0180aei\u1F12\u1F16\u1F1Als;\u403Dst;\u625Fv\u0100;D\u0235\u1F20D;\u6A78parsl;\u69E5\u0100Da\u1F2F\u1F33ot;\u6253rr;\u6971\u0180cdi\u1F3E\u1F41\u1EF8r;\u612Fo\xF4\u0352\u0100ah\u1F49\u1F4B;\u43B7\u803B\xF0\u40F0\u0100mr\u1F53\u1F57l\u803B\xEB\u40EBo;\u60AC\u0180cip\u1F61\u1F64\u1F67l;\u4021s\xF4\u056E\u0100eo\u1F6C\u1F74ctatio\xEE\u0559nential\xE5\u0579\u09E1\u1F92\0\u1F9E\0\u1FA1\u1FA7\0\0\u1FC6\u1FCC\0\u1FD3\0\u1FE6\u1FEA\u2000\0\u2008\u205Allingdotse\xF1\u1E44y;\u4444male;\u6640\u0180ilr\u1FAD\u1FB3\u1FC1lig;\u8000\uFB03\u0269\u1FB9\0\0\u1FBDg;\u8000\uFB00ig;\u8000\uFB04;\uC000\u{1D523}lig;\u8000\uFB01lig;\uC000fj\u0180alt\u1FD9\u1FDC\u1FE1t;\u666Dig;\u8000\uFB02ns;\u65B1of;\u4192\u01F0\u1FEE\0\u1FF3f;\uC000\u{1D557}\u0100ak\u05BF\u1FF7\u0100;v\u1FFC\u1FFD\u62D4;\u6AD9artint;\u6A0D\u0100ao\u200C\u2055\u0100cs\u2011\u2052\u03B1\u201A\u2030\u2038\u2045\u2048\0\u2050\u03B2\u2022\u2025\u2027\u202A\u202C\0\u202E\u803B\xBD\u40BD;\u6153\u803B\xBC\u40BC;\u6155;\u6159;\u615B\u01B3\u2034\0\u2036;\u6154;\u6156\u02B4\u203E\u2041\0\0\u2043\u803B\xBE\u40BE;\u6157;\u615C5;\u6158\u01B6\u204C\0\u204E;\u615A;\u615D8;\u615El;\u6044wn;\u6322cr;\uC000\u{1D4BB}\u0880Eabcdefgijlnorstv\u2082\u2089\u209F\u20A5\u20B0\u20B4\u20F0\u20F5\u20FA\u20FF\u2103\u2112\u2138\u0317\u213E\u2152\u219E\u0100;l\u064D\u2087;\u6A8C\u0180cmp\u2090\u2095\u209Dute;\u41F5ma\u0100;d\u209C\u1CDA\u43B3;\u6A86reve;\u411F\u0100iy\u20AA\u20AErc;\u411D;\u4433ot;\u4121\u0200;lqs\u063E\u0642\u20BD\u20C9\u0180;qs\u063E\u064C\u20C4lan\xF4\u0665\u0200;cdl\u0665\u20D2\u20D5\u20E5c;\u6AA9ot\u0100;o\u20DC\u20DD\u6A80\u0100;l\u20E2\u20E3\u6A82;\u6A84\u0100;e\u20EA\u20ED\uC000\u22DB\uFE00s;\u6A94r;\uC000\u{1D524}\u0100;g\u0673\u061Bmel;\u6137cy;\u4453\u0200;Eaj\u065A\u210C\u210E\u2110;\u6A92;\u6AA5;\u6AA4\u0200Eaes\u211B\u211D\u2129\u2134;\u6269p\u0100;p\u2123\u2124\u6A8Arox\xBB\u2124\u0100;q\u212E\u212F\u6A88\u0100;q\u212E\u211Bim;\u62E7pf;\uC000\u{1D558}\u0100ci\u2143\u2146r;\u610Am\u0180;el\u066B\u214E\u2150;\u6A8E;\u6A90\u8300>;cdlqr\u05EE\u2160\u216A\u216E\u2173\u2179\u0100ci\u2165\u2167;\u6AA7r;\u6A7Aot;\u62D7Par;\u6995uest;\u6A7C\u0280adels\u2184\u216A\u2190\u0656\u219B\u01F0\u2189\0\u218Epro\xF8\u209Er;\u6978q\u0100lq\u063F\u2196les\xF3\u2088i\xED\u066B\u0100en\u21A3\u21ADrtneqq;\uC000\u2269\uFE00\xC5\u21AA\u0500Aabcefkosy\u21C4\u21C7\u21F1\u21F5\u21FA\u2218\u221D\u222F\u2268\u227Dr\xF2\u03A0\u0200ilmr\u21D0\u21D4\u21D7\u21DBrs\xF0\u1484f\xBB\u2024il\xF4\u06A9\u0100dr\u21E0\u21E4cy;\u444A\u0180;cw\u08F4\u21EB\u21EFir;\u6948;\u61ADar;\u610Firc;\u4125\u0180alr\u2201\u220E\u2213rts\u0100;u\u2209\u220A\u6665it\xBB\u220Alip;\u6026con;\u62B9r;\uC000\u{1D525}s\u0100ew\u2223\u2229arow;\u6925arow;\u6926\u0280amopr\u223A\u223E\u2243\u225E\u2263rr;\u61FFtht;\u623Bk\u0100lr\u2249\u2253eftarrow;\u61A9ightarrow;\u61AAf;\uC000\u{1D559}bar;\u6015\u0180clt\u226F\u2274\u2278r;\uC000\u{1D4BD}as\xE8\u21F4rok;\u4127\u0100bp\u2282\u2287ull;\u6043hen\xBB\u1C5B\u0AE1\u22A3\0\u22AA\0\u22B8\u22C5\u22CE\0\u22D5\u22F3\0\0\u22F8\u2322\u2367\u2362\u237F\0\u2386\u23AA\u23B4cute\u803B\xED\u40ED\u0180;iy\u0771\u22B0\u22B5rc\u803B\xEE\u40EE;\u4438\u0100cx\u22BC\u22BFy;\u4435cl\u803B\xA1\u40A1\u0100fr\u039F\u22C9;\uC000\u{1D526}rave\u803B\xEC\u40EC\u0200;ino\u073E\u22DD\u22E9\u22EE\u0100in\u22E2\u22E6nt;\u6A0Ct;\u622Dfin;\u69DCta;\u6129lig;\u4133\u0180aop\u22FE\u231A\u231D\u0180cgt\u2305\u2308\u2317r;\u412B\u0180elp\u071F\u230F\u2313in\xE5\u078Ear\xF4\u0720h;\u4131f;\u62B7ed;\u41B5\u0280;cfot\u04F4\u232C\u2331\u233D\u2341are;\u6105in\u0100;t\u2338\u2339\u621Eie;\u69DDdo\xF4\u2319\u0280;celp\u0757\u234C\u2350\u235B\u2361al;\u62BA\u0100gr\u2355\u2359er\xF3\u1563\xE3\u234Darhk;\u6A17rod;\u6A3C\u0200cgpt\u236F\u2372\u2376\u237By;\u4451on;\u412Ff;\uC000\u{1D55A}a;\u43B9uest\u803B\xBF\u40BF\u0100ci\u238A\u238Fr;\uC000\u{1D4BE}n\u0280;Edsv\u04F4\u239B\u239D\u23A1\u04F3;\u62F9ot;\u62F5\u0100;v\u23A6\u23A7\u62F4;\u62F3\u0100;i\u0777\u23AElde;\u4129\u01EB\u23B8\0\u23BCcy;\u4456l\u803B\xEF\u40EF\u0300cfmosu\u23CC\u23D7\u23DC\u23E1\u23E7\u23F5\u0100iy\u23D1\u23D5rc;\u4135;\u4439r;\uC000\u{1D527}ath;\u4237pf;\uC000\u{1D55B}\u01E3\u23EC\0\u23F1r;\uC000\u{1D4BF}rcy;\u4458kcy;\u4454\u0400acfghjos\u240B\u2416\u2422\u2427\u242D\u2431\u2435\u243Bppa\u0100;v\u2413\u2414\u43BA;\u43F0\u0100ey\u241B\u2420dil;\u4137;\u443Ar;\uC000\u{1D528}reen;\u4138cy;\u4445cy;\u445Cpf;\uC000\u{1D55C}cr;\uC000\u{1D4C0}\u0B80ABEHabcdefghjlmnoprstuv\u2470\u2481\u2486\u248D\u2491\u250E\u253D\u255A\u2580\u264E\u265E\u2665\u2679\u267D\u269A\u26B2\u26D8\u275D\u2768\u278B\u27C0\u2801\u2812\u0180art\u2477\u247A\u247Cr\xF2\u09C6\xF2\u0395ail;\u691Barr;\u690E\u0100;g\u0994\u248B;\u6A8Bar;\u6962\u0963\u24A5\0\u24AA\0\u24B1\0\0\0\0\0\u24B5\u24BA\0\u24C6\u24C8\u24CD\0\u24F9ute;\u413Amptyv;\u69B4ra\xEE\u084Cbda;\u43BBg\u0180;dl\u088E\u24C1\u24C3;\u6991\xE5\u088E;\u6A85uo\u803B\xAB\u40ABr\u0400;bfhlpst\u0899\u24DE\u24E6\u24E9\u24EB\u24EE\u24F1\u24F5\u0100;f\u089D\u24E3s;\u691Fs;\u691D\xEB\u2252p;\u61ABl;\u6939im;\u6973l;\u61A2\u0180;ae\u24FF\u2500\u2504\u6AABil;\u6919\u0100;s\u2509\u250A\u6AAD;\uC000\u2AAD\uFE00\u0180abr\u2515\u2519\u251Drr;\u690Crk;\u6772\u0100ak\u2522\u252Cc\u0100ek\u2528\u252A;\u407B;\u405B\u0100es\u2531\u2533;\u698Bl\u0100du\u2539\u253B;\u698F;\u698D\u0200aeuy\u2546\u254B\u2556\u2558ron;\u413E\u0100di\u2550\u2554il;\u413C\xEC\u08B0\xE2\u2529;\u443B\u0200cqrs\u2563\u2566\u256D\u257Da;\u6936uo\u0100;r\u0E19\u1746\u0100du\u2572\u2577har;\u6967shar;\u694Bh;\u61B2\u0280;fgqs\u258B\u258C\u0989\u25F3\u25FF\u6264t\u0280ahlrt\u2598\u25A4\u25B7\u25C2\u25E8rrow\u0100;t\u0899\u25A1a\xE9\u24F6arpoon\u0100du\u25AF\u25B4own\xBB\u045Ap\xBB\u0966eftarrows;\u61C7ight\u0180ahs\u25CD\u25D6\u25DErrow\u0100;s\u08F4\u08A7arpoon\xF3\u0F98quigarro\xF7\u21F0hreetimes;\u62CB\u0180;qs\u258B\u0993\u25FAlan\xF4\u09AC\u0280;cdgs\u09AC\u260A\u260D\u261D\u2628c;\u6AA8ot\u0100;o\u2614\u2615\u6A7F\u0100;r\u261A\u261B\u6A81;\u6A83\u0100;e\u2622\u2625\uC000\u22DA\uFE00s;\u6A93\u0280adegs\u2633\u2639\u263D\u2649\u264Bppro\xF8\u24C6ot;\u62D6q\u0100gq\u2643\u2645\xF4\u0989gt\xF2\u248C\xF4\u099Bi\xED\u09B2\u0180ilr\u2655\u08E1\u265Asht;\u697C;\uC000\u{1D529}\u0100;E\u099C\u2663;\u6A91\u0161\u2669\u2676r\u0100du\u25B2\u266E\u0100;l\u0965\u2673;\u696Alk;\u6584cy;\u4459\u0280;acht\u0A48\u2688\u268B\u2691\u2696r\xF2\u25C1orne\xF2\u1D08ard;\u696Bri;\u65FA\u0100io\u269F\u26A4dot;\u4140ust\u0100;a\u26AC\u26AD\u63B0che\xBB\u26AD\u0200Eaes\u26BB\u26BD\u26C9\u26D4;\u6268p\u0100;p\u26C3\u26C4\u6A89rox\xBB\u26C4\u0100;q\u26CE\u26CF\u6A87\u0100;q\u26CE\u26BBim;\u62E6\u0400abnoptwz\u26E9\u26F4\u26F7\u271A\u272F\u2741\u2747\u2750\u0100nr\u26EE\u26F1g;\u67ECr;\u61FDr\xEB\u08C1g\u0180lmr\u26FF\u270D\u2714eft\u0100ar\u09E6\u2707ight\xE1\u09F2apsto;\u67FCight\xE1\u09FDparrow\u0100lr\u2725\u2729ef\xF4\u24EDight;\u61AC\u0180afl\u2736\u2739\u273Dr;\u6985;\uC000\u{1D55D}us;\u6A2Dimes;\u6A34\u0161\u274B\u274Fst;\u6217\xE1\u134E\u0180;ef\u2757\u2758\u1800\u65CAnge\xBB\u2758ar\u0100;l\u2764\u2765\u4028t;\u6993\u0280achmt\u2773\u2776\u277C\u2785\u2787r\xF2\u08A8orne\xF2\u1D8Car\u0100;d\u0F98\u2783;\u696D;\u600Eri;\u62BF\u0300achiqt\u2798\u279D\u0A40\u27A2\u27AE\u27BBquo;\u6039r;\uC000\u{1D4C1}m\u0180;eg\u09B2\u27AA\u27AC;\u6A8D;\u6A8F\u0100bu\u252A\u27B3o\u0100;r\u0E1F\u27B9;\u601Arok;\u4142\u8400<;cdhilqr\u082B\u27D2\u2639\u27DC\u27E0\u27E5\u27EA\u27F0\u0100ci\u27D7\u27D9;\u6AA6r;\u6A79re\xE5\u25F2mes;\u62C9arr;\u6976uest;\u6A7B\u0100Pi\u27F5\u27F9ar;\u6996\u0180;ef\u2800\u092D\u181B\u65C3r\u0100du\u2807\u280Dshar;\u694Ahar;\u6966\u0100en\u2817\u2821rtneqq;\uC000\u2268\uFE00\xC5\u281E\u0700Dacdefhilnopsu\u2840\u2845\u2882\u288E\u2893\u28A0\u28A5\u28A8\u28DA\u28E2\u28E4\u0A83\u28F3\u2902Dot;\u623A\u0200clpr\u284E\u2852\u2863\u287Dr\u803B\xAF\u40AF\u0100et\u2857\u2859;\u6642\u0100;e\u285E\u285F\u6720se\xBB\u285F\u0100;s\u103B\u2868to\u0200;dlu\u103B\u2873\u2877\u287Bow\xEE\u048Cef\xF4\u090F\xF0\u13D1ker;\u65AE\u0100oy\u2887\u288Cmma;\u6A29;\u443Cash;\u6014asuredangle\xBB\u1626r;\uC000\u{1D52A}o;\u6127\u0180cdn\u28AF\u28B4\u28C9ro\u803B\xB5\u40B5\u0200;acd\u1464\u28BD\u28C0\u28C4s\xF4\u16A7ir;\u6AF0ot\u80BB\xB7\u01B5us\u0180;bd\u28D2\u1903\u28D3\u6212\u0100;u\u1D3C\u28D8;\u6A2A\u0163\u28DE\u28E1p;\u6ADB\xF2\u2212\xF0\u0A81\u0100dp\u28E9\u28EEels;\u62A7f;\uC000\u{1D55E}\u0100ct\u28F8\u28FDr;\uC000\u{1D4C2}pos\xBB\u159D\u0180;lm\u2909\u290A\u290D\u43BCtimap;\u62B8\u0C00GLRVabcdefghijlmoprstuvw\u2942\u2953\u297E\u2989\u2998\u29DA\u29E9\u2A15\u2A1A\u2A58\u2A5D\u2A83\u2A95\u2AA4\u2AA8\u2B04\u2B07\u2B44\u2B7F\u2BAE\u2C34\u2C67\u2C7C\u2CE9\u0100gt\u2947\u294B;\uC000\u22D9\u0338\u0100;v\u2950\u0BCF\uC000\u226B\u20D2\u0180elt\u295A\u2972\u2976ft\u0100ar\u2961\u2967rrow;\u61CDightarrow;\u61CE;\uC000\u22D8\u0338\u0100;v\u297B\u0C47\uC000\u226A\u20D2ightarrow;\u61CF\u0100Dd\u298E\u2993ash;\u62AFash;\u62AE\u0280bcnpt\u29A3\u29A7\u29AC\u29B1\u29CCla\xBB\u02DEute;\u4144g;\uC000\u2220\u20D2\u0280;Eiop\u0D84\u29BC\u29C0\u29C5\u29C8;\uC000\u2A70\u0338d;\uC000\u224B\u0338s;\u4149ro\xF8\u0D84ur\u0100;a\u29D3\u29D4\u666El\u0100;s\u29D3\u0B38\u01F3\u29DF\0\u29E3p\u80BB\xA0\u0B37mp\u0100;e\u0BF9\u0C00\u0280aeouy\u29F4\u29FE\u2A03\u2A10\u2A13\u01F0\u29F9\0\u29FB;\u6A43on;\u4148dil;\u4146ng\u0100;d\u0D7E\u2A0Aot;\uC000\u2A6D\u0338p;\u6A42;\u443Dash;\u6013\u0380;Aadqsx\u0B92\u2A29\u2A2D\u2A3B\u2A41\u2A45\u2A50rr;\u61D7r\u0100hr\u2A33\u2A36k;\u6924\u0100;o\u13F2\u13F0ot;\uC000\u2250\u0338ui\xF6\u0B63\u0100ei\u2A4A\u2A4Ear;\u6928\xED\u0B98ist\u0100;s\u0BA0\u0B9Fr;\uC000\u{1D52B}\u0200Eest\u0BC5\u2A66\u2A79\u2A7C\u0180;qs\u0BBC\u2A6D\u0BE1\u0180;qs\u0BBC\u0BC5\u2A74lan\xF4\u0BE2i\xED\u0BEA\u0100;r\u0BB6\u2A81\xBB\u0BB7\u0180Aap\u2A8A\u2A8D\u2A91r\xF2\u2971rr;\u61AEar;\u6AF2\u0180;sv\u0F8D\u2A9C\u0F8C\u0100;d\u2AA1\u2AA2\u62FC;\u62FAcy;\u445A\u0380AEadest\u2AB7\u2ABA\u2ABE\u2AC2\u2AC5\u2AF6\u2AF9r\xF2\u2966;\uC000\u2266\u0338rr;\u619Ar;\u6025\u0200;fqs\u0C3B\u2ACE\u2AE3\u2AEFt\u0100ar\u2AD4\u2AD9rro\xF7\u2AC1ightarro\xF7\u2A90\u0180;qs\u0C3B\u2ABA\u2AEAlan\xF4\u0C55\u0100;s\u0C55\u2AF4\xBB\u0C36i\xED\u0C5D\u0100;r\u0C35\u2AFEi\u0100;e\u0C1A\u0C25i\xE4\u0D90\u0100pt\u2B0C\u2B11f;\uC000\u{1D55F}\u8180\xAC;in\u2B19\u2B1A\u2B36\u40ACn\u0200;Edv\u0B89\u2B24\u2B28\u2B2E;\uC000\u22F9\u0338ot;\uC000\u22F5\u0338\u01E1\u0B89\u2B33\u2B35;\u62F7;\u62F6i\u0100;v\u0CB8\u2B3C\u01E1\u0CB8\u2B41\u2B43;\u62FE;\u62FD\u0180aor\u2B4B\u2B63\u2B69r\u0200;ast\u0B7B\u2B55\u2B5A\u2B5Flle\xEC\u0B7Bl;\uC000\u2AFD\u20E5;\uC000\u2202\u0338lint;\u6A14\u0180;ce\u0C92\u2B70\u2B73u\xE5\u0CA5\u0100;c\u0C98\u2B78\u0100;e\u0C92\u2B7D\xF1\u0C98\u0200Aait\u2B88\u2B8B\u2B9D\u2BA7r\xF2\u2988rr\u0180;cw\u2B94\u2B95\u2B99\u619B;\uC000\u2933\u0338;\uC000\u219D\u0338ghtarrow\xBB\u2B95ri\u0100;e\u0CCB\u0CD6\u0380chimpqu\u2BBD\u2BCD\u2BD9\u2B04\u0B78\u2BE4\u2BEF\u0200;cer\u0D32\u2BC6\u0D37\u2BC9u\xE5\u0D45;\uC000\u{1D4C3}ort\u026D\u2B05\0\0\u2BD6ar\xE1\u2B56m\u0100;e\u0D6E\u2BDF\u0100;q\u0D74\u0D73su\u0100bp\u2BEB\u2BED\xE5\u0CF8\xE5\u0D0B\u0180bcp\u2BF6\u2C11\u2C19\u0200;Ees\u2BFF\u2C00\u0D22\u2C04\u6284;\uC000\u2AC5\u0338et\u0100;e\u0D1B\u2C0Bq\u0100;q\u0D23\u2C00c\u0100;e\u0D32\u2C17\xF1\u0D38\u0200;Ees\u2C22\u2C23\u0D5F\u2C27\u6285;\uC000\u2AC6\u0338et\u0100;e\u0D58\u2C2Eq\u0100;q\u0D60\u2C23\u0200gilr\u2C3D\u2C3F\u2C45\u2C47\xEC\u0BD7lde\u803B\xF1\u40F1\xE7\u0C43iangle\u0100lr\u2C52\u2C5Ceft\u0100;e\u0C1A\u2C5A\xF1\u0C26ight\u0100;e\u0CCB\u2C65\xF1\u0CD7\u0100;m\u2C6C\u2C6D\u43BD\u0180;es\u2C74\u2C75\u2C79\u4023ro;\u6116p;\u6007\u0480DHadgilrs\u2C8F\u2C94\u2C99\u2C9E\u2CA3\u2CB0\u2CB6\u2CD3\u2CE3ash;\u62ADarr;\u6904p;\uC000\u224D\u20D2ash;\u62AC\u0100et\u2CA8\u2CAC;\uC000\u2265\u20D2;\uC000>\u20D2nfin;\u69DE\u0180Aet\u2CBD\u2CC1\u2CC5rr;\u6902;\uC000\u2264\u20D2\u0100;r\u2CCA\u2CCD\uC000<\u20D2ie;\uC000\u22B4\u20D2\u0100At\u2CD8\u2CDCrr;\u6903rie;\uC000\u22B5\u20D2im;\uC000\u223C\u20D2\u0180Aan\u2CF0\u2CF4\u2D02rr;\u61D6r\u0100hr\u2CFA\u2CFDk;\u6923\u0100;o\u13E7\u13E5ear;\u6927\u1253\u1A95\0\0\0\0\0\0\0\0\0\0\0\0\0\u2D2D\0\u2D38\u2D48\u2D60\u2D65\u2D72\u2D84\u1B07\0\0\u2D8D\u2DAB\0\u2DC8\u2DCE\0\u2DDC\u2E19\u2E2B\u2E3E\u2E43\u0100cs\u2D31\u1A97ute\u803B\xF3\u40F3\u0100iy\u2D3C\u2D45r\u0100;c\u1A9E\u2D42\u803B\xF4\u40F4;\u443E\u0280abios\u1AA0\u2D52\u2D57\u01C8\u2D5Alac;\u4151v;\u6A38old;\u69BClig;\u4153\u0100cr\u2D69\u2D6Dir;\u69BF;\uC000\u{1D52C}\u036F\u2D79\0\0\u2D7C\0\u2D82n;\u42DBave\u803B\xF2\u40F2;\u69C1\u0100bm\u2D88\u0DF4ar;\u69B5\u0200acit\u2D95\u2D98\u2DA5\u2DA8r\xF2\u1A80\u0100ir\u2D9D\u2DA0r;\u69BEoss;\u69BBn\xE5\u0E52;\u69C0\u0180aei\u2DB1\u2DB5\u2DB9cr;\u414Dga;\u43C9\u0180cdn\u2DC0\u2DC5\u01CDron;\u43BF;\u69B6pf;\uC000\u{1D560}\u0180ael\u2DD4\u2DD7\u01D2r;\u69B7rp;\u69B9\u0380;adiosv\u2DEA\u2DEB\u2DEE\u2E08\u2E0D\u2E10\u2E16\u6228r\xF2\u1A86\u0200;efm\u2DF7\u2DF8\u2E02\u2E05\u6A5Dr\u0100;o\u2DFE\u2DFF\u6134f\xBB\u2DFF\u803B\xAA\u40AA\u803B\xBA\u40BAgof;\u62B6r;\u6A56lope;\u6A57;\u6A5B\u0180clo\u2E1F\u2E21\u2E27\xF2\u2E01ash\u803B\xF8\u40F8l;\u6298i\u016C\u2E2F\u2E34de\u803B\xF5\u40F5es\u0100;a\u01DB\u2E3As;\u6A36ml\u803B\xF6\u40F6bar;\u633D\u0AE1\u2E5E\0\u2E7D\0\u2E80\u2E9D\0\u2EA2\u2EB9\0\0\u2ECB\u0E9C\0\u2F13\0\0\u2F2B\u2FBC\0\u2FC8r\u0200;ast\u0403\u2E67\u2E72\u0E85\u8100\xB6;l\u2E6D\u2E6E\u40B6le\xEC\u0403\u0269\u2E78\0\0\u2E7Bm;\u6AF3;\u6AFDy;\u443Fr\u0280cimpt\u2E8B\u2E8F\u2E93\u1865\u2E97nt;\u4025od;\u402Eil;\u6030enk;\u6031r;\uC000\u{1D52D}\u0180imo\u2EA8\u2EB0\u2EB4\u0100;v\u2EAD\u2EAE\u43C6;\u43D5ma\xF4\u0A76ne;\u660E\u0180;tv\u2EBF\u2EC0\u2EC8\u43C0chfork\xBB\u1FFD;\u43D6\u0100au\u2ECF\u2EDFn\u0100ck\u2ED5\u2EDDk\u0100;h\u21F4\u2EDB;\u610E\xF6\u21F4s\u0480;abcdemst\u2EF3\u2EF4\u1908\u2EF9\u2EFD\u2F04\u2F06\u2F0A\u2F0E\u402Bcir;\u6A23ir;\u6A22\u0100ou\u1D40\u2F02;\u6A25;\u6A72n\u80BB\xB1\u0E9Dim;\u6A26wo;\u6A27\u0180ipu\u2F19\u2F20\u2F25ntint;\u6A15f;\uC000\u{1D561}nd\u803B\xA3\u40A3\u0500;Eaceinosu\u0EC8\u2F3F\u2F41\u2F44\u2F47\u2F81\u2F89\u2F92\u2F7E\u2FB6;\u6AB3p;\u6AB7u\xE5\u0ED9\u0100;c\u0ECE\u2F4C\u0300;acens\u0EC8\u2F59\u2F5F\u2F66\u2F68\u2F7Eppro\xF8\u2F43urlye\xF1\u0ED9\xF1\u0ECE\u0180aes\u2F6F\u2F76\u2F7Approx;\u6AB9qq;\u6AB5im;\u62E8i\xED\u0EDFme\u0100;s\u2F88\u0EAE\u6032\u0180Eas\u2F78\u2F90\u2F7A\xF0\u2F75\u0180dfp\u0EEC\u2F99\u2FAF\u0180als\u2FA0\u2FA5\u2FAAlar;\u632Eine;\u6312urf;\u6313\u0100;t\u0EFB\u2FB4\xEF\u0EFBrel;\u62B0\u0100ci\u2FC0\u2FC5r;\uC000\u{1D4C5};\u43C8ncsp;\u6008\u0300fiopsu\u2FDA\u22E2\u2FDF\u2FE5\u2FEB\u2FF1r;\uC000\u{1D52E}pf;\uC000\u{1D562}rime;\u6057cr;\uC000\u{1D4C6}\u0180aeo\u2FF8\u3009\u3013t\u0100ei\u2FFE\u3005rnion\xF3\u06B0nt;\u6A16st\u0100;e\u3010\u3011\u403F\xF1\u1F19\xF4\u0F14\u0A80ABHabcdefhilmnoprstux\u3040\u3051\u3055\u3059\u30E0\u310E\u312B\u3147\u3162\u3172\u318E\u3206\u3215\u3224\u3229\u3258\u326E\u3272\u3290\u32B0\u32B7\u0180art\u3047\u304A\u304Cr\xF2\u10B3\xF2\u03DDail;\u691Car\xF2\u1C65ar;\u6964\u0380cdenqrt\u3068\u3075\u3078\u307F\u308F\u3094\u30CC\u0100eu\u306D\u3071;\uC000\u223D\u0331te;\u4155i\xE3\u116Emptyv;\u69B3g\u0200;del\u0FD1\u3089\u308B\u308D;\u6992;\u69A5\xE5\u0FD1uo\u803B\xBB\u40BBr\u0580;abcfhlpstw\u0FDC\u30AC\u30AF\u30B7\u30B9\u30BC\u30BE\u30C0\u30C3\u30C7\u30CAp;\u6975\u0100;f\u0FE0\u30B4s;\u6920;\u6933s;\u691E\xEB\u225D\xF0\u272El;\u6945im;\u6974l;\u61A3;\u619D\u0100ai\u30D1\u30D5il;\u691Ao\u0100;n\u30DB\u30DC\u6236al\xF3\u0F1E\u0180abr\u30E7\u30EA\u30EEr\xF2\u17E5rk;\u6773\u0100ak\u30F3\u30FDc\u0100ek\u30F9\u30FB;\u407D;\u405D\u0100es\u3102\u3104;\u698Cl\u0100du\u310A\u310C;\u698E;\u6990\u0200aeuy\u3117\u311C\u3127\u3129ron;\u4159\u0100di\u3121\u3125il;\u4157\xEC\u0FF2\xE2\u30FA;\u4440\u0200clqs\u3134\u3137\u313D\u3144a;\u6937dhar;\u6969uo\u0100;r\u020E\u020Dh;\u61B3\u0180acg\u314E\u315F\u0F44l\u0200;ips\u0F78\u3158\u315B\u109Cn\xE5\u10BBar\xF4\u0FA9t;\u65AD\u0180ilr\u3169\u1023\u316Esht;\u697D;\uC000\u{1D52F}\u0100ao\u3177\u3186r\u0100du\u317D\u317F\xBB\u047B\u0100;l\u1091\u3184;\u696C\u0100;v\u318B\u318C\u43C1;\u43F1\u0180gns\u3195\u31F9\u31FCht\u0300ahlrst\u31A4\u31B0\u31C2\u31D8\u31E4\u31EErrow\u0100;t\u0FDC\u31ADa\xE9\u30C8arpoon\u0100du\u31BB\u31BFow\xEE\u317Ep\xBB\u1092eft\u0100ah\u31CA\u31D0rrow\xF3\u0FEAarpoon\xF3\u0551ightarrows;\u61C9quigarro\xF7\u30CBhreetimes;\u62CCg;\u42DAingdotse\xF1\u1F32\u0180ahm\u320D\u3210\u3213r\xF2\u0FEAa\xF2\u0551;\u600Foust\u0100;a\u321E\u321F\u63B1che\xBB\u321Fmid;\u6AEE\u0200abpt\u3232\u323D\u3240\u3252\u0100nr\u3237\u323Ag;\u67EDr;\u61FEr\xEB\u1003\u0180afl\u3247\u324A\u324Er;\u6986;\uC000\u{1D563}us;\u6A2Eimes;\u6A35\u0100ap\u325D\u3267r\u0100;g\u3263\u3264\u4029t;\u6994olint;\u6A12ar\xF2\u31E3\u0200achq\u327B\u3280\u10BC\u3285quo;\u603Ar;\uC000\u{1D4C7}\u0100bu\u30FB\u328Ao\u0100;r\u0214\u0213\u0180hir\u3297\u329B\u32A0re\xE5\u31F8mes;\u62CAi\u0200;efl\u32AA\u1059\u1821\u32AB\u65B9tri;\u69CEluhar;\u6968;\u611E\u0D61\u32D5\u32DB\u32DF\u332C\u3338\u3371\0\u337A\u33A4\0\0\u33EC\u33F0\0\u3428\u3448\u345A\u34AD\u34B1\u34CA\u34F1\0\u3616\0\0\u3633cute;\u415Bqu\xEF\u27BA\u0500;Eaceinpsy\u11ED\u32F3\u32F5\u32FF\u3302\u330B\u330F\u331F\u3326\u3329;\u6AB4\u01F0\u32FA\0\u32FC;\u6AB8on;\u4161u\xE5\u11FE\u0100;d\u11F3\u3307il;\u415Frc;\u415D\u0180Eas\u3316\u3318\u331B;\u6AB6p;\u6ABAim;\u62E9olint;\u6A13i\xED\u1204;\u4441ot\u0180;be\u3334\u1D47\u3335\u62C5;\u6A66\u0380Aacmstx\u3346\u334A\u3357\u335B\u335E\u3363\u336Drr;\u61D8r\u0100hr\u3350\u3352\xEB\u2228\u0100;o\u0A36\u0A34t\u803B\xA7\u40A7i;\u403Bwar;\u6929m\u0100in\u3369\xF0nu\xF3\xF1t;\u6736r\u0100;o\u3376\u2055\uC000\u{1D530}\u0200acoy\u3382\u3386\u3391\u33A0rp;\u666F\u0100hy\u338B\u338Fcy;\u4449;\u4448rt\u026D\u3399\0\0\u339Ci\xE4\u1464ara\xEC\u2E6F\u803B\xAD\u40AD\u0100gm\u33A8\u33B4ma\u0180;fv\u33B1\u33B2\u33B2\u43C3;\u43C2\u0400;deglnpr\u12AB\u33C5\u33C9\u33CE\u33D6\u33DE\u33E1\u33E6ot;\u6A6A\u0100;q\u12B1\u12B0\u0100;E\u33D3\u33D4\u6A9E;\u6AA0\u0100;E\u33DB\u33DC\u6A9D;\u6A9Fe;\u6246lus;\u6A24arr;\u6972ar\xF2\u113D\u0200aeit\u33F8\u3408\u340F\u3417\u0100ls\u33FD\u3404lsetm\xE9\u336Ahp;\u6A33parsl;\u69E4\u0100dl\u1463\u3414e;\u6323\u0100;e\u341C\u341D\u6AAA\u0100;s\u3422\u3423\u6AAC;\uC000\u2AAC\uFE00\u0180flp\u342E\u3433\u3442tcy;\u444C\u0100;b\u3438\u3439\u402F\u0100;a\u343E\u343F\u69C4r;\u633Ff;\uC000\u{1D564}a\u0100dr\u344D\u0402es\u0100;u\u3454\u3455\u6660it\xBB\u3455\u0180csu\u3460\u3479\u349F\u0100au\u3465\u346Fp\u0100;s\u1188\u346B;\uC000\u2293\uFE00p\u0100;s\u11B4\u3475;\uC000\u2294\uFE00u\u0100bp\u347F\u348F\u0180;es\u1197\u119C\u3486et\u0100;e\u1197\u348D\xF1\u119D\u0180;es\u11A8\u11AD\u3496et\u0100;e\u11A8\u349D\xF1\u11AE\u0180;af\u117B\u34A6\u05B0r\u0165\u34AB\u05B1\xBB\u117Car\xF2\u1148\u0200cemt\u34B9\u34BE\u34C2\u34C5r;\uC000\u{1D4C8}tm\xEE\xF1i\xEC\u3415ar\xE6\u11BE\u0100ar\u34CE\u34D5r\u0100;f\u34D4\u17BF\u6606\u0100an\u34DA\u34EDight\u0100ep\u34E3\u34EApsilo\xEE\u1EE0h\xE9\u2EAFs\xBB\u2852\u0280bcmnp\u34FB\u355E\u1209\u358B\u358E\u0480;Edemnprs\u350E\u350F\u3511\u3515\u351E\u3523\u352C\u3531\u3536\u6282;\u6AC5ot;\u6ABD\u0100;d\u11DA\u351Aot;\u6AC3ult;\u6AC1\u0100Ee\u3528\u352A;\u6ACB;\u628Alus;\u6ABFarr;\u6979\u0180eiu\u353D\u3552\u3555t\u0180;en\u350E\u3545\u354Bq\u0100;q\u11DA\u350Feq\u0100;q\u352B\u3528m;\u6AC7\u0100bp\u355A\u355C;\u6AD5;\u6AD3c\u0300;acens\u11ED\u356C\u3572\u3579\u357B\u3326ppro\xF8\u32FAurlye\xF1\u11FE\xF1\u11F3\u0180aes\u3582\u3588\u331Bppro\xF8\u331Aq\xF1\u3317g;\u666A\u0680123;Edehlmnps\u35A9\u35AC\u35AF\u121C\u35B2\u35B4\u35C0\u35C9\u35D5\u35DA\u35DF\u35E8\u35ED\u803B\xB9\u40B9\u803B\xB2\u40B2\u803B\xB3\u40B3;\u6AC6\u0100os\u35B9\u35BCt;\u6ABEub;\u6AD8\u0100;d\u1222\u35C5ot;\u6AC4s\u0100ou\u35CF\u35D2l;\u67C9b;\u6AD7arr;\u697Bult;\u6AC2\u0100Ee\u35E4\u35E6;\u6ACC;\u628Blus;\u6AC0\u0180eiu\u35F4\u3609\u360Ct\u0180;en\u121C\u35FC\u3602q\u0100;q\u1222\u35B2eq\u0100;q\u35E7\u35E4m;\u6AC8\u0100bp\u3611\u3613;\u6AD4;\u6AD6\u0180Aan\u361C\u3620\u362Drr;\u61D9r\u0100hr\u3626\u3628\xEB\u222E\u0100;o\u0A2B\u0A29war;\u692Alig\u803B\xDF\u40DF\u0BE1\u3651\u365D\u3660\u12CE\u3673\u3679\0\u367E\u36C2\0\0\0\0\0\u36DB\u3703\0\u3709\u376C\0\0\0\u3787\u0272\u3656\0\0\u365Bget;\u6316;\u43C4r\xEB\u0E5F\u0180aey\u3666\u366B\u3670ron;\u4165dil;\u4163;\u4442lrec;\u6315r;\uC000\u{1D531}\u0200eiko\u3686\u369D\u36B5\u36BC\u01F2\u368B\0\u3691e\u01004f\u1284\u1281a\u0180;sv\u3698\u3699\u369B\u43B8ym;\u43D1\u0100cn\u36A2\u36B2k\u0100as\u36A8\u36AEppro\xF8\u12C1im\xBB\u12ACs\xF0\u129E\u0100as\u36BA\u36AE\xF0\u12C1rn\u803B\xFE\u40FE\u01EC\u031F\u36C6\u22E7es\u8180\xD7;bd\u36CF\u36D0\u36D8\u40D7\u0100;a\u190F\u36D5r;\u6A31;\u6A30\u0180eps\u36E1\u36E3\u3700\xE1\u2A4D\u0200;bcf\u0486\u36EC\u36F0\u36F4ot;\u6336ir;\u6AF1\u0100;o\u36F9\u36FC\uC000\u{1D565}rk;\u6ADA\xE1\u3362rime;\u6034\u0180aip\u370F\u3712\u3764d\xE5\u1248\u0380adempst\u3721\u374D\u3740\u3751\u3757\u375C\u375Fngle\u0280;dlqr\u3730\u3731\u3736\u3740\u3742\u65B5own\xBB\u1DBBeft\u0100;e\u2800\u373E\xF1\u092E;\u625Cight\u0100;e\u32AA\u374B\xF1\u105Aot;\u65ECinus;\u6A3Alus;\u6A39b;\u69CDime;\u6A3Bezium;\u63E2\u0180cht\u3772\u377D\u3781\u0100ry\u3777\u377B;\uC000\u{1D4C9};\u4446cy;\u445Brok;\u4167\u0100io\u378B\u378Ex\xF4\u1777head\u0100lr\u3797\u37A0eftarro\xF7\u084Fightarrow\xBB\u0F5D\u0900AHabcdfghlmoprstuw\u37D0\u37D3\u37D7\u37E4\u37F0\u37FC\u380E\u381C\u3823\u3834\u3851\u385D\u386B\u38A9\u38CC\u38D2\u38EA\u38F6r\xF2\u03EDar;\u6963\u0100cr\u37DC\u37E2ute\u803B\xFA\u40FA\xF2\u1150r\u01E3\u37EA\0\u37EDy;\u445Eve;\u416D\u0100iy\u37F5\u37FArc\u803B\xFB\u40FB;\u4443\u0180abh\u3803\u3806\u380Br\xF2\u13ADlac;\u4171a\xF2\u13C3\u0100ir\u3813\u3818sht;\u697E;\uC000\u{1D532}rave\u803B\xF9\u40F9\u0161\u3827\u3831r\u0100lr\u382C\u382E\xBB\u0957\xBB\u1083lk;\u6580\u0100ct\u3839\u384D\u026F\u383F\0\0\u384Arn\u0100;e\u3845\u3846\u631Cr\xBB\u3846op;\u630Fri;\u65F8\u0100al\u3856\u385Acr;\u416B\u80BB\xA8\u0349\u0100gp\u3862\u3866on;\u4173f;\uC000\u{1D566}\u0300adhlsu\u114B\u3878\u387D\u1372\u3891\u38A0own\xE1\u13B3arpoon\u0100lr\u3888\u388Cef\xF4\u382Digh\xF4\u382Fi\u0180;hl\u3899\u389A\u389C\u43C5\xBB\u13FAon\xBB\u389Aparrows;\u61C8\u0180cit\u38B0\u38C4\u38C8\u026F\u38B6\0\0\u38C1rn\u0100;e\u38BC\u38BD\u631Dr\xBB\u38BDop;\u630Eng;\u416Fri;\u65F9cr;\uC000\u{1D4CA}\u0180dir\u38D9\u38DD\u38E2ot;\u62F0lde;\u4169i\u0100;f\u3730\u38E8\xBB\u1813\u0100am\u38EF\u38F2r\xF2\u38A8l\u803B\xFC\u40FCangle;\u69A7\u0780ABDacdeflnoprsz\u391C\u391F\u3929\u392D\u39B5\u39B8\u39BD\u39DF\u39E4\u39E8\u39F3\u39F9\u39FD\u3A01\u3A20r\xF2\u03F7ar\u0100;v\u3926\u3927\u6AE8;\u6AE9as\xE8\u03E1\u0100nr\u3932\u3937grt;\u699C\u0380eknprst\u34E3\u3946\u394B\u3952\u395D\u3964\u3996app\xE1\u2415othin\xE7\u1E96\u0180hir\u34EB\u2EC8\u3959op\xF4\u2FB5\u0100;h\u13B7\u3962\xEF\u318D\u0100iu\u3969\u396Dgm\xE1\u33B3\u0100bp\u3972\u3984setneq\u0100;q\u397D\u3980\uC000\u228A\uFE00;\uC000\u2ACB\uFE00setneq\u0100;q\u398F\u3992\uC000\u228B\uFE00;\uC000\u2ACC\uFE00\u0100hr\u399B\u399Fet\xE1\u369Ciangle\u0100lr\u39AA\u39AFeft\xBB\u0925ight\xBB\u1051y;\u4432ash\xBB\u1036\u0180elr\u39C4\u39D2\u39D7\u0180;be\u2DEA\u39CB\u39CFar;\u62BBq;\u625Alip;\u62EE\u0100bt\u39DC\u1468a\xF2\u1469r;\uC000\u{1D533}tr\xE9\u39AEsu\u0100bp\u39EF\u39F1\xBB\u0D1C\xBB\u0D59pf;\uC000\u{1D567}ro\xF0\u0EFBtr\xE9\u39B4\u0100cu\u3A06\u3A0Br;\uC000\u{1D4CB}\u0100bp\u3A10\u3A18n\u0100Ee\u3980\u3A16\xBB\u397En\u0100Ee\u3992\u3A1E\xBB\u3990igzag;\u699A\u0380cefoprs\u3A36\u3A3B\u3A56\u3A5B\u3A54\u3A61\u3A6Airc;\u4175\u0100di\u3A40\u3A51\u0100bg\u3A45\u3A49ar;\u6A5Fe\u0100;q\u15FA\u3A4F;\u6259erp;\u6118r;\uC000\u{1D534}pf;\uC000\u{1D568}\u0100;e\u1479\u3A66at\xE8\u1479cr;\uC000\u{1D4CC}\u0AE3\u178E\u3A87\0\u3A8B\0\u3A90\u3A9B\0\0\u3A9D\u3AA8\u3AAB\u3AAF\0\0\u3AC3\u3ACE\0\u3AD8\u17DC\u17DFtr\xE9\u17D1r;\uC000\u{1D535}\u0100Aa\u3A94\u3A97r\xF2\u03C3r\xF2\u09F6;\u43BE\u0100Aa\u3AA1\u3AA4r\xF2\u03B8r\xF2\u09EBa\xF0\u2713is;\u62FB\u0180dpt\u17A4\u3AB5\u3ABE\u0100fl\u3ABA\u17A9;\uC000\u{1D569}im\xE5\u17B2\u0100Aa\u3AC7\u3ACAr\xF2\u03CEr\xF2\u0A01\u0100cq\u3AD2\u17B8r;\uC000\u{1D4CD}\u0100pt\u17D6\u3ADCr\xE9\u17D4\u0400acefiosu\u3AF0\u3AFD\u3B08\u3B0C\u3B11\u3B15\u3B1B\u3B21c\u0100uy\u3AF6\u3AFBte\u803B\xFD\u40FD;\u444F\u0100iy\u3B02\u3B06rc;\u4177;\u444Bn\u803B\xA5\u40A5r;\uC000\u{1D536}cy;\u4457pf;\uC000\u{1D56A}cr;\uC000\u{1D4CE}\u0100cm\u3B26\u3B29y;\u444El\u803B\xFF\u40FF\u0500acdefhiosw\u3B42\u3B48\u3B54\u3B58\u3B64\u3B69\u3B6D\u3B74\u3B7A\u3B80cute;\u417A\u0100ay\u3B4D\u3B52ron;\u417E;\u4437ot;\u417C\u0100et\u3B5D\u3B61tr\xE6\u155Fa;\u43B6r;\uC000\u{1D537}cy;\u4436grarr;\u61DDpf;\uC000\u{1D56B}cr;\uC000\u{1D4CF}\u0100jn\u3B85\u3B87;\u600Dj;\u600C'.split("").map(e=>e.charCodeAt(0)));var v0,Ec=new Map([[0,65533],[128,8364],[130,8218],[131,402],[132,8222],[133,8230],[134,8224],[135,8225],[136,710],[137,8240],[138,352],[139,8249],[140,338],[142,381],[145,8216],[146,8217],[147,8220],[148,8221],[149,8226],[150,8211],[151,8212],[152,732],[153,8482],[154,353],[155,8250],[156,339],[158,382],[159,376]]),Tr=(v0=String.fromCodePoint)!==null&&v0!==void 0?v0:function(e){let t="";return e>65535&&(e-=65536,t+=String.fromCharCode(e>>>10&1023|55296),e=56320|e&1023),t+=String.fromCharCode(e),t};function Q0(e){var t;return e>=55296&&e<=57343||e>1114111?65533:(t=Ec.get(e))!==null&&t!==void 0?t:e}var Q;(function(e){e[e.NUM=35]="NUM",e[e.SEMI=59]="SEMI",e[e.EQUALS=61]="EQUALS",e[e.ZERO=48]="ZERO",e[e.NINE=57]="NINE",e[e.LOWER_A=97]="LOWER_A",e[e.LOWER_F=102]="LOWER_F",e[e.LOWER_X=120]="LOWER_X",e[e.LOWER_Z=122]="LOWER_Z",e[e.UPPER_A=65]="UPPER_A",e[e.UPPER_F=70]="UPPER_F",e[e.UPPER_Z=90]="UPPER_Z"})(Q||(Q={}));var gc=32,De;(function(e){e[e.VALUE_LENGTH=49152]="VALUE_LENGTH",e[e.BRANCH_LENGTH=16256]="BRANCH_LENGTH",e[e.JUMP_TABLE=127]="JUMP_TABLE"})(De||(De={}));function Y0(e){return e>=Q.ZERO&&e<=Q.NINE}function pc(e){return e>=Q.UPPER_A&&e<=Q.UPPER_F||e>=Q.LOWER_A&&e<=Q.LOWER_F}function Tc(e){return e>=Q.UPPER_A&&e<=Q.UPPER_Z||e>=Q.LOWER_A&&e<=Q.LOWER_Z||Y0(e)}function Ic(e){return e===Q.EQUALS||Tc(e)}var v;(function(e){e[e.EntityStart=0]="EntityStart",e[e.NumericStart=1]="NumericStart",e[e.NumericDecimal=2]="NumericDecimal",e[e.NumericHex=3]="NumericHex",e[e.NamedEntity=4]="NamedEntity"})(v||(v={}));var Ae;(function(e){e[e.Legacy=0]="Legacy",e[e.Strict=1]="Strict",e[e.Attribute=2]="Attribute"})(Ae||(Ae={}));var Iu=class{constructor(t,u,a){this.decodeTree=t,this.emitCodePoint=u,this.errors=a,this.state=v.EntityStart,this.consumed=1,this.result=0,this.treeIndex=0,this.excess=1,this.decodeMode=Ae.Strict}startEntity(t){this.decodeMode=t,this.state=v.EntityStart,this.result=0,this.treeIndex=0,this.excess=1,this.consumed=1}write(t,u){switch(this.state){case v.EntityStart:return t.charCodeAt(u)===Q.NUM?(this.state=v.NumericStart,this.consumed+=1,this.stateNumericStart(t,u+1)):(this.state=v.NamedEntity,this.stateNamedEntity(t,u));case v.NumericStart:return this.stateNumericStart(t,u);case v.NumericDecimal:return this.stateNumericDecimal(t,u);case v.NumericHex:return this.stateNumericHex(t,u);case v.NamedEntity:return this.stateNamedEntity(t,u)}}stateNumericStart(t,u){return u>=t.length?-1:(t.charCodeAt(u)|gc)===Q.LOWER_X?(this.state=v.NumericHex,this.consumed+=1,this.stateNumericHex(t,u+1)):(this.state=v.NumericDecimal,this.stateNumericDecimal(t,u))}addToNumericResult(t,u,a,s){if(u!==a){let i=a-u;this.result=this.result*Math.pow(s,i)+Number.parseInt(t.substr(u,i),s),this.consumed+=i}}stateNumericHex(t,u){let a=u;for(;u>14;for(;u>14,i!==0){if(n===Q.SEMI)return this.emitNamedEntityData(this.treeIndex,i,this.consumed+this.excess);this.decodeMode!==Ae.Strict&&(this.result=this.treeIndex,this.consumed+=this.excess,this.excess=0)}}return-1}emitNotTerminatedNamedEntity(){var t;let{result:u,decodeTree:a}=this,s=(a[u]&De.VALUE_LENGTH)>>14;return this.emitNamedEntityData(u,s,this.consumed),(t=this.errors)===null||t===void 0||t.missingSemicolonAfterCharacterReference(),this.consumed}emitNamedEntityData(t,u,a){let{decodeTree:s}=this;return this.emitCodePoint(u===1?s[t]&~De.VALUE_LENGTH:s[t+1],a),u===3&&this.emitCodePoint(s[t+2],a),a}end(){var t;switch(this.state){case v.NamedEntity:return this.result!==0&&(this.decodeMode!==Ae.Attribute||this.result===this.treeIndex)?this.emitNotTerminatedNamedEntity():0;case v.NumericDecimal:return this.emitNumericEntity(0,2);case v.NumericHex:return this.emitNumericEntity(0,3);case v.NumericStart:return(t=this.errors)===null||t===void 0||t.absenceOfDigitsInNumericCharacterReference(this.consumed),0;case v.EntityStart:return 0}}};function Cc(e,t,u,a){let s=(t&De.BRANCH_LENGTH)>>7,i=t&De.JUMP_TABLE;if(s===0)return i!==0&&a===i?u:-1;if(i){let l=a-i;return l<0||l>=s?-1:e[u+l]-1}let n=u,d=n+s-1;for(;n<=d;){let l=n+d>>>1,h=e[l];if(ha)d=l-1;else return e[l+s]}return-1}var Cu={};se(Cu,{ATTRS:()=>de,DOCUMENT_MODE:()=>G,NS:()=>E,NUMBERED_HEADERS:()=>ut,SPECIAL_ELEMENTS:()=>G0,TAG_ID:()=>r,TAG_NAMES:()=>f,getTagID:()=>Be,hasUnescapedText:()=>K0});var E;(function(e){e.HTML="http://www.w3.org/1999/xhtml",e.MATHML="http://www.w3.org/1998/Math/MathML",e.SVG="http://www.w3.org/2000/svg",e.XLINK="http://www.w3.org/1999/xlink",e.XML="http://www.w3.org/XML/1998/namespace",e.XMLNS="http://www.w3.org/2000/xmlns/"})(E||(E={}));var de;(function(e){e.TYPE="type",e.ACTION="action",e.ENCODING="encoding",e.PROMPT="prompt",e.NAME="name",e.COLOR="color",e.FACE="face",e.SIZE="size"})(de||(de={}));var G;(function(e){e.NO_QUIRKS="no-quirks",e.QUIRKS="quirks",e.LIMITED_QUIRKS="limited-quirks"})(G||(G={}));var f;(function(e){e.A="a",e.ADDRESS="address",e.ANNOTATION_XML="annotation-xml",e.APPLET="applet",e.AREA="area",e.ARTICLE="article",e.ASIDE="aside",e.B="b",e.BASE="base",e.BASEFONT="basefont",e.BGSOUND="bgsound",e.BIG="big",e.BLOCKQUOTE="blockquote",e.BODY="body",e.BR="br",e.BUTTON="button",e.CAPTION="caption",e.CENTER="center",e.CODE="code",e.COL="col",e.COLGROUP="colgroup",e.DD="dd",e.DESC="desc",e.DETAILS="details",e.DIALOG="dialog",e.DIR="dir",e.DIV="div",e.DL="dl",e.DT="dt",e.EM="em",e.EMBED="embed",e.FIELDSET="fieldset",e.FIGCAPTION="figcaption",e.FIGURE="figure",e.FONT="font",e.FOOTER="footer",e.FOREIGN_OBJECT="foreignObject",e.FORM="form",e.FRAME="frame",e.FRAMESET="frameset",e.H1="h1",e.H2="h2",e.H3="h3",e.H4="h4",e.H5="h5",e.H6="h6",e.HEAD="head",e.HEADER="header",e.HGROUP="hgroup",e.HR="hr",e.HTML="html",e.I="i",e.IMG="img",e.IMAGE="image",e.INPUT="input",e.IFRAME="iframe",e.KEYGEN="keygen",e.LABEL="label",e.LI="li",e.LINK="link",e.LISTING="listing",e.MAIN="main",e.MALIGNMARK="malignmark",e.MARQUEE="marquee",e.MATH="math",e.MENU="menu",e.META="meta",e.MGLYPH="mglyph",e.MI="mi",e.MO="mo",e.MN="mn",e.MS="ms",e.MTEXT="mtext",e.NAV="nav",e.NOBR="nobr",e.NOFRAMES="noframes",e.NOEMBED="noembed",e.NOSCRIPT="noscript",e.OBJECT="object",e.OL="ol",e.OPTGROUP="optgroup",e.OPTION="option",e.P="p",e.PARAM="param",e.PLAINTEXT="plaintext",e.PRE="pre",e.RB="rb",e.RP="rp",e.RT="rt",e.RTC="rtc",e.RUBY="ruby",e.S="s",e.SCRIPT="script",e.SEARCH="search",e.SECTION="section",e.SELECT="select",e.SOURCE="source",e.SMALL="small",e.SPAN="span",e.STRIKE="strike",e.STRONG="strong",e.STYLE="style",e.SUB="sub",e.SUMMARY="summary",e.SUP="sup",e.TABLE="table",e.TBODY="tbody",e.TEMPLATE="template",e.TEXTAREA="textarea",e.TFOOT="tfoot",e.TD="td",e.TH="th",e.THEAD="thead",e.TITLE="title",e.TR="tr",e.TRACK="track",e.TT="tt",e.U="u",e.UL="ul",e.SVG="svg",e.VAR="var",e.WBR="wbr",e.XMP="xmp"})(f||(f={}));var r;(function(e){e[e.UNKNOWN=0]="UNKNOWN",e[e.A=1]="A",e[e.ADDRESS=2]="ADDRESS",e[e.ANNOTATION_XML=3]="ANNOTATION_XML",e[e.APPLET=4]="APPLET",e[e.AREA=5]="AREA",e[e.ARTICLE=6]="ARTICLE",e[e.ASIDE=7]="ASIDE",e[e.B=8]="B",e[e.BASE=9]="BASE",e[e.BASEFONT=10]="BASEFONT",e[e.BGSOUND=11]="BGSOUND",e[e.BIG=12]="BIG",e[e.BLOCKQUOTE=13]="BLOCKQUOTE",e[e.BODY=14]="BODY",e[e.BR=15]="BR",e[e.BUTTON=16]="BUTTON",e[e.CAPTION=17]="CAPTION",e[e.CENTER=18]="CENTER",e[e.CODE=19]="CODE",e[e.COL=20]="COL",e[e.COLGROUP=21]="COLGROUP",e[e.DD=22]="DD",e[e.DESC=23]="DESC",e[e.DETAILS=24]="DETAILS",e[e.DIALOG=25]="DIALOG",e[e.DIR=26]="DIR",e[e.DIV=27]="DIV",e[e.DL=28]="DL",e[e.DT=29]="DT",e[e.EM=30]="EM",e[e.EMBED=31]="EMBED",e[e.FIELDSET=32]="FIELDSET",e[e.FIGCAPTION=33]="FIGCAPTION",e[e.FIGURE=34]="FIGURE",e[e.FONT=35]="FONT",e[e.FOOTER=36]="FOOTER",e[e.FOREIGN_OBJECT=37]="FOREIGN_OBJECT",e[e.FORM=38]="FORM",e[e.FRAME=39]="FRAME",e[e.FRAMESET=40]="FRAMESET",e[e.H1=41]="H1",e[e.H2=42]="H2",e[e.H3=43]="H3",e[e.H4=44]="H4",e[e.H5=45]="H5",e[e.H6=46]="H6",e[e.HEAD=47]="HEAD",e[e.HEADER=48]="HEADER",e[e.HGROUP=49]="HGROUP",e[e.HR=50]="HR",e[e.HTML=51]="HTML",e[e.I=52]="I",e[e.IMG=53]="IMG",e[e.IMAGE=54]="IMAGE",e[e.INPUT=55]="INPUT",e[e.IFRAME=56]="IFRAME",e[e.KEYGEN=57]="KEYGEN",e[e.LABEL=58]="LABEL",e[e.LI=59]="LI",e[e.LINK=60]="LINK",e[e.LISTING=61]="LISTING",e[e.MAIN=62]="MAIN",e[e.MALIGNMARK=63]="MALIGNMARK",e[e.MARQUEE=64]="MARQUEE",e[e.MATH=65]="MATH",e[e.MENU=66]="MENU",e[e.META=67]="META",e[e.MGLYPH=68]="MGLYPH",e[e.MI=69]="MI",e[e.MO=70]="MO",e[e.MN=71]="MN",e[e.MS=72]="MS",e[e.MTEXT=73]="MTEXT",e[e.NAV=74]="NAV",e[e.NOBR=75]="NOBR",e[e.NOFRAMES=76]="NOFRAMES",e[e.NOEMBED=77]="NOEMBED",e[e.NOSCRIPT=78]="NOSCRIPT",e[e.OBJECT=79]="OBJECT",e[e.OL=80]="OL",e[e.OPTGROUP=81]="OPTGROUP",e[e.OPTION=82]="OPTION",e[e.P=83]="P",e[e.PARAM=84]="PARAM",e[e.PLAINTEXT=85]="PLAINTEXT",e[e.PRE=86]="PRE",e[e.RB=87]="RB",e[e.RP=88]="RP",e[e.RT=89]="RT",e[e.RTC=90]="RTC",e[e.RUBY=91]="RUBY",e[e.S=92]="S",e[e.SCRIPT=93]="SCRIPT",e[e.SEARCH=94]="SEARCH",e[e.SECTION=95]="SECTION",e[e.SELECT=96]="SELECT",e[e.SOURCE=97]="SOURCE",e[e.SMALL=98]="SMALL",e[e.SPAN=99]="SPAN",e[e.STRIKE=100]="STRIKE",e[e.STRONG=101]="STRONG",e[e.STYLE=102]="STYLE",e[e.SUB=103]="SUB",e[e.SUMMARY=104]="SUMMARY",e[e.SUP=105]="SUP",e[e.TABLE=106]="TABLE",e[e.TBODY=107]="TBODY",e[e.TEMPLATE=108]="TEMPLATE",e[e.TEXTAREA=109]="TEXTAREA",e[e.TFOOT=110]="TFOOT",e[e.TD=111]="TD",e[e.TH=112]="TH",e[e.THEAD=113]="THEAD",e[e.TITLE=114]="TITLE",e[e.TR=115]="TR",e[e.TRACK=116]="TRACK",e[e.TT=117]="TT",e[e.U=118]="U",e[e.UL=119]="UL",e[e.SVG=120]="SVG",e[e.VAR=121]="VAR",e[e.WBR=122]="WBR",e[e.XMP=123]="XMP"})(r||(r={}));var xc=new Map([[f.A,r.A],[f.ADDRESS,r.ADDRESS],[f.ANNOTATION_XML,r.ANNOTATION_XML],[f.APPLET,r.APPLET],[f.AREA,r.AREA],[f.ARTICLE,r.ARTICLE],[f.ASIDE,r.ASIDE],[f.B,r.B],[f.BASE,r.BASE],[f.BASEFONT,r.BASEFONT],[f.BGSOUND,r.BGSOUND],[f.BIG,r.BIG],[f.BLOCKQUOTE,r.BLOCKQUOTE],[f.BODY,r.BODY],[f.BR,r.BR],[f.BUTTON,r.BUTTON],[f.CAPTION,r.CAPTION],[f.CENTER,r.CENTER],[f.CODE,r.CODE],[f.COL,r.COL],[f.COLGROUP,r.COLGROUP],[f.DD,r.DD],[f.DESC,r.DESC],[f.DETAILS,r.DETAILS],[f.DIALOG,r.DIALOG],[f.DIR,r.DIR],[f.DIV,r.DIV],[f.DL,r.DL],[f.DT,r.DT],[f.EM,r.EM],[f.EMBED,r.EMBED],[f.FIELDSET,r.FIELDSET],[f.FIGCAPTION,r.FIGCAPTION],[f.FIGURE,r.FIGURE],[f.FONT,r.FONT],[f.FOOTER,r.FOOTER],[f.FOREIGN_OBJECT,r.FOREIGN_OBJECT],[f.FORM,r.FORM],[f.FRAME,r.FRAME],[f.FRAMESET,r.FRAMESET],[f.H1,r.H1],[f.H2,r.H2],[f.H3,r.H3],[f.H4,r.H4],[f.H5,r.H5],[f.H6,r.H6],[f.HEAD,r.HEAD],[f.HEADER,r.HEADER],[f.HGROUP,r.HGROUP],[f.HR,r.HR],[f.HTML,r.HTML],[f.I,r.I],[f.IMG,r.IMG],[f.IMAGE,r.IMAGE],[f.INPUT,r.INPUT],[f.IFRAME,r.IFRAME],[f.KEYGEN,r.KEYGEN],[f.LABEL,r.LABEL],[f.LI,r.LI],[f.LINK,r.LINK],[f.LISTING,r.LISTING],[f.MAIN,r.MAIN],[f.MALIGNMARK,r.MALIGNMARK],[f.MARQUEE,r.MARQUEE],[f.MATH,r.MATH],[f.MENU,r.MENU],[f.META,r.META],[f.MGLYPH,r.MGLYPH],[f.MI,r.MI],[f.MO,r.MO],[f.MN,r.MN],[f.MS,r.MS],[f.MTEXT,r.MTEXT],[f.NAV,r.NAV],[f.NOBR,r.NOBR],[f.NOFRAMES,r.NOFRAMES],[f.NOEMBED,r.NOEMBED],[f.NOSCRIPT,r.NOSCRIPT],[f.OBJECT,r.OBJECT],[f.OL,r.OL],[f.OPTGROUP,r.OPTGROUP],[f.OPTION,r.OPTION],[f.P,r.P],[f.PARAM,r.PARAM],[f.PLAINTEXT,r.PLAINTEXT],[f.PRE,r.PRE],[f.RB,r.RB],[f.RP,r.RP],[f.RT,r.RT],[f.RTC,r.RTC],[f.RUBY,r.RUBY],[f.S,r.S],[f.SCRIPT,r.SCRIPT],[f.SEARCH,r.SEARCH],[f.SECTION,r.SECTION],[f.SELECT,r.SELECT],[f.SOURCE,r.SOURCE],[f.SMALL,r.SMALL],[f.SPAN,r.SPAN],[f.STRIKE,r.STRIKE],[f.STRONG,r.STRONG],[f.STYLE,r.STYLE],[f.SUB,r.SUB],[f.SUMMARY,r.SUMMARY],[f.SUP,r.SUP],[f.TABLE,r.TABLE],[f.TBODY,r.TBODY],[f.TEMPLATE,r.TEMPLATE],[f.TEXTAREA,r.TEXTAREA],[f.TFOOT,r.TFOOT],[f.TD,r.TD],[f.TH,r.TH],[f.THEAD,r.THEAD],[f.TITLE,r.TITLE],[f.TR,r.TR],[f.TRACK,r.TRACK],[f.TT,r.TT],[f.U,r.U],[f.UL,r.UL],[f.SVG,r.SVG],[f.VAR,r.VAR],[f.WBR,r.WBR],[f.XMP,r.XMP]]);function Be(e){var t;return(t=xc.get(e))!==null&&t!==void 0?t:r.UNKNOWN}var p=r,G0={[E.HTML]:new Set([p.ADDRESS,p.APPLET,p.AREA,p.ARTICLE,p.ASIDE,p.BASE,p.BASEFONT,p.BGSOUND,p.BLOCKQUOTE,p.BODY,p.BR,p.BUTTON,p.CAPTION,p.CENTER,p.COL,p.COLGROUP,p.DD,p.DETAILS,p.DIR,p.DIV,p.DL,p.DT,p.EMBED,p.FIELDSET,p.FIGCAPTION,p.FIGURE,p.FOOTER,p.FORM,p.FRAME,p.FRAMESET,p.H1,p.H2,p.H3,p.H4,p.H5,p.H6,p.HEAD,p.HEADER,p.HGROUP,p.HR,p.HTML,p.IFRAME,p.IMG,p.INPUT,p.LI,p.LINK,p.LISTING,p.MAIN,p.MARQUEE,p.MENU,p.META,p.NAV,p.NOEMBED,p.NOFRAMES,p.NOSCRIPT,p.OBJECT,p.OL,p.P,p.PARAM,p.PLAINTEXT,p.PRE,p.SCRIPT,p.SECTION,p.SELECT,p.SOURCE,p.STYLE,p.SUMMARY,p.TABLE,p.TBODY,p.TD,p.TEMPLATE,p.TEXTAREA,p.TFOOT,p.TH,p.THEAD,p.TITLE,p.TR,p.TRACK,p.UL,p.WBR,p.XMP]),[E.MATHML]:new Set([p.MI,p.MO,p.MN,p.MS,p.MTEXT,p.ANNOTATION_XML]),[E.SVG]:new Set([p.TITLE,p.FOREIGN_OBJECT,p.DESC]),[E.XLINK]:new Set,[E.XML]:new Set,[E.XMLNS]:new Set},ut=new Set([p.H1,p.H2,p.H3,p.H4,p.H5,p.H6]),Nc=new Set([f.STYLE,f.SCRIPT,f.XMP,f.IFRAME,f.NOEMBED,f.NOFRAMES,f.PLAINTEXT]);function K0(e,t){return Nc.has(e)||t&&e===f.NOSCRIPT}var o;(function(e){e[e.DATA=0]="DATA",e[e.RCDATA=1]="RCDATA",e[e.RAWTEXT=2]="RAWTEXT",e[e.SCRIPT_DATA=3]="SCRIPT_DATA",e[e.PLAINTEXT=4]="PLAINTEXT",e[e.TAG_OPEN=5]="TAG_OPEN",e[e.END_TAG_OPEN=6]="END_TAG_OPEN",e[e.TAG_NAME=7]="TAG_NAME",e[e.RCDATA_LESS_THAN_SIGN=8]="RCDATA_LESS_THAN_SIGN",e[e.RCDATA_END_TAG_OPEN=9]="RCDATA_END_TAG_OPEN",e[e.RCDATA_END_TAG_NAME=10]="RCDATA_END_TAG_NAME",e[e.RAWTEXT_LESS_THAN_SIGN=11]="RAWTEXT_LESS_THAN_SIGN",e[e.RAWTEXT_END_TAG_OPEN=12]="RAWTEXT_END_TAG_OPEN",e[e.RAWTEXT_END_TAG_NAME=13]="RAWTEXT_END_TAG_NAME",e[e.SCRIPT_DATA_LESS_THAN_SIGN=14]="SCRIPT_DATA_LESS_THAN_SIGN",e[e.SCRIPT_DATA_END_TAG_OPEN=15]="SCRIPT_DATA_END_TAG_OPEN",e[e.SCRIPT_DATA_END_TAG_NAME=16]="SCRIPT_DATA_END_TAG_NAME",e[e.SCRIPT_DATA_ESCAPE_START=17]="SCRIPT_DATA_ESCAPE_START",e[e.SCRIPT_DATA_ESCAPE_START_DASH=18]="SCRIPT_DATA_ESCAPE_START_DASH",e[e.SCRIPT_DATA_ESCAPED=19]="SCRIPT_DATA_ESCAPED",e[e.SCRIPT_DATA_ESCAPED_DASH=20]="SCRIPT_DATA_ESCAPED_DASH",e[e.SCRIPT_DATA_ESCAPED_DASH_DASH=21]="SCRIPT_DATA_ESCAPED_DASH_DASH",e[e.SCRIPT_DATA_ESCAPED_LESS_THAN_SIGN=22]="SCRIPT_DATA_ESCAPED_LESS_THAN_SIGN",e[e.SCRIPT_DATA_ESCAPED_END_TAG_OPEN=23]="SCRIPT_DATA_ESCAPED_END_TAG_OPEN",e[e.SCRIPT_DATA_ESCAPED_END_TAG_NAME=24]="SCRIPT_DATA_ESCAPED_END_TAG_NAME",e[e.SCRIPT_DATA_DOUBLE_ESCAPE_START=25]="SCRIPT_DATA_DOUBLE_ESCAPE_START",e[e.SCRIPT_DATA_DOUBLE_ESCAPED=26]="SCRIPT_DATA_DOUBLE_ESCAPED",e[e.SCRIPT_DATA_DOUBLE_ESCAPED_DASH=27]="SCRIPT_DATA_DOUBLE_ESCAPED_DASH",e[e.SCRIPT_DATA_DOUBLE_ESCAPED_DASH_DASH=28]="SCRIPT_DATA_DOUBLE_ESCAPED_DASH_DASH",e[e.SCRIPT_DATA_DOUBLE_ESCAPED_LESS_THAN_SIGN=29]="SCRIPT_DATA_DOUBLE_ESCAPED_LESS_THAN_SIGN",e[e.SCRIPT_DATA_DOUBLE_ESCAPE_END=30]="SCRIPT_DATA_DOUBLE_ESCAPE_END",e[e.BEFORE_ATTRIBUTE_NAME=31]="BEFORE_ATTRIBUTE_NAME",e[e.ATTRIBUTE_NAME=32]="ATTRIBUTE_NAME",e[e.AFTER_ATTRIBUTE_NAME=33]="AFTER_ATTRIBUTE_NAME",e[e.BEFORE_ATTRIBUTE_VALUE=34]="BEFORE_ATTRIBUTE_VALUE",e[e.ATTRIBUTE_VALUE_DOUBLE_QUOTED=35]="ATTRIBUTE_VALUE_DOUBLE_QUOTED",e[e.ATTRIBUTE_VALUE_SINGLE_QUOTED=36]="ATTRIBUTE_VALUE_SINGLE_QUOTED",e[e.ATTRIBUTE_VALUE_UNQUOTED=37]="ATTRIBUTE_VALUE_UNQUOTED",e[e.AFTER_ATTRIBUTE_VALUE_QUOTED=38]="AFTER_ATTRIBUTE_VALUE_QUOTED",e[e.SELF_CLOSING_START_TAG=39]="SELF_CLOSING_START_TAG",e[e.BOGUS_COMMENT=40]="BOGUS_COMMENT",e[e.MARKUP_DECLARATION_OPEN=41]="MARKUP_DECLARATION_OPEN",e[e.COMMENT_START=42]="COMMENT_START",e[e.COMMENT_START_DASH=43]="COMMENT_START_DASH",e[e.COMMENT=44]="COMMENT",e[e.COMMENT_LESS_THAN_SIGN=45]="COMMENT_LESS_THAN_SIGN",e[e.COMMENT_LESS_THAN_SIGN_BANG=46]="COMMENT_LESS_THAN_SIGN_BANG",e[e.COMMENT_LESS_THAN_SIGN_BANG_DASH=47]="COMMENT_LESS_THAN_SIGN_BANG_DASH",e[e.COMMENT_LESS_THAN_SIGN_BANG_DASH_DASH=48]="COMMENT_LESS_THAN_SIGN_BANG_DASH_DASH",e[e.COMMENT_END_DASH=49]="COMMENT_END_DASH",e[e.COMMENT_END=50]="COMMENT_END",e[e.COMMENT_END_BANG=51]="COMMENT_END_BANG",e[e.DOCTYPE=52]="DOCTYPE",e[e.BEFORE_DOCTYPE_NAME=53]="BEFORE_DOCTYPE_NAME",e[e.DOCTYPE_NAME=54]="DOCTYPE_NAME",e[e.AFTER_DOCTYPE_NAME=55]="AFTER_DOCTYPE_NAME",e[e.AFTER_DOCTYPE_PUBLIC_KEYWORD=56]="AFTER_DOCTYPE_PUBLIC_KEYWORD",e[e.BEFORE_DOCTYPE_PUBLIC_IDENTIFIER=57]="BEFORE_DOCTYPE_PUBLIC_IDENTIFIER",e[e.DOCTYPE_PUBLIC_IDENTIFIER_DOUBLE_QUOTED=58]="DOCTYPE_PUBLIC_IDENTIFIER_DOUBLE_QUOTED",e[e.DOCTYPE_PUBLIC_IDENTIFIER_SINGLE_QUOTED=59]="DOCTYPE_PUBLIC_IDENTIFIER_SINGLE_QUOTED",e[e.AFTER_DOCTYPE_PUBLIC_IDENTIFIER=60]="AFTER_DOCTYPE_PUBLIC_IDENTIFIER",e[e.BETWEEN_DOCTYPE_PUBLIC_AND_SYSTEM_IDENTIFIERS=61]="BETWEEN_DOCTYPE_PUBLIC_AND_SYSTEM_IDENTIFIERS",e[e.AFTER_DOCTYPE_SYSTEM_KEYWORD=62]="AFTER_DOCTYPE_SYSTEM_KEYWORD",e[e.BEFORE_DOCTYPE_SYSTEM_IDENTIFIER=63]="BEFORE_DOCTYPE_SYSTEM_IDENTIFIER",e[e.DOCTYPE_SYSTEM_IDENTIFIER_DOUBLE_QUOTED=64]="DOCTYPE_SYSTEM_IDENTIFIER_DOUBLE_QUOTED",e[e.DOCTYPE_SYSTEM_IDENTIFIER_SINGLE_QUOTED=65]="DOCTYPE_SYSTEM_IDENTIFIER_SINGLE_QUOTED",e[e.AFTER_DOCTYPE_SYSTEM_IDENTIFIER=66]="AFTER_DOCTYPE_SYSTEM_IDENTIFIER",e[e.BOGUS_DOCTYPE=67]="BOGUS_DOCTYPE",e[e.CDATA_SECTION=68]="CDATA_SECTION",e[e.CDATA_SECTION_BRACKET=69]="CDATA_SECTION_BRACKET",e[e.CDATA_SECTION_END=70]="CDATA_SECTION_END",e[e.CHARACTER_REFERENCE=71]="CHARACTER_REFERENCE",e[e.AMBIGUOUS_AMPERSAND=72]="AMBIGUOUS_AMPERSAND"})(o||(o={}));var K={DATA:o.DATA,RCDATA:o.RCDATA,RAWTEXT:o.RAWTEXT,SCRIPT_DATA:o.SCRIPT_DATA,PLAINTEXT:o.PLAINTEXT,CDATA_SECTION:o.CDATA_SECTION};function _c(e){return e>=c.DIGIT_0&&e<=c.DIGIT_9}function Nt(e){return e>=c.LATIN_CAPITAL_A&&e<=c.LATIN_CAPITAL_Z}function Sc(e){return e>=c.LATIN_SMALL_A&&e<=c.LATIN_SMALL_Z}function Re(e){return Sc(e)||Nt(e)}function Ir(e){return Re(e)||_c(e)}function xu(e){return e+32}function xr(e){return e===c.SPACE||e===c.LINE_FEED||e===c.TABULATION||e===c.FORM_FEED}function Cr(e){return xr(e)||e===c.SOLIDUS||e===c.GREATER_THAN_SIGN}function Dc(e){return e===c.NULL?b.nullCharacterReference:e>1114111?b.characterReferenceOutsideUnicodeRange:bu(e)?b.surrogateCharacterReference:mu(e)?b.noncharacterCharacterReference:Eu(e)||e===c.CARRIAGE_RETURN?b.controlCharacterReference:null}var _t=class{constructor(t,u){this.options=t,this.handler=u,this.paused=!1,this.inLoop=!1,this.inForeignNode=!1,this.lastStartTagName="",this.active=!1,this.state=o.DATA,this.returnState=o.DATA,this.entityStartPos=0,this.consumedAfterSnapshot=-1,this.currentCharacterToken=null,this.currentToken=null,this.currentAttr={name:"",value:""},this.preprocessor=new gu(u),this.currentLocation=this.getCurrentLocation(-1),this.entityDecoder=new Iu(Tu,(a,s)=>{this.preprocessor.pos=this.entityStartPos+s-1,this._flushCodePointConsumedAsCharacterReference(a)},u.onParseError?{missingSemicolonAfterCharacterReference:()=>{this._err(b.missingSemicolonAfterCharacterReference,1)},absenceOfDigitsInNumericCharacterReference:a=>{this._err(b.absenceOfDigitsInNumericCharacterReference,this.entityStartPos-this.preprocessor.pos+a)},validateNumericCharacterReference:a=>{let s=Dc(a);s&&this._err(s,1)}}:void 0)}_err(t,u=0){var a,s;(s=(a=this.handler).onParseError)===null||s===void 0||s.call(a,this.preprocessor.getError(t,u))}getCurrentLocation(t){return this.options.sourceCodeLocationInfo?{startLine:this.preprocessor.line,startCol:this.preprocessor.col-t,startOffset:this.preprocessor.offset-t,endLine:-1,endCol:-1,endOffset:-1}:null}_runParsingLoop(){if(!this.inLoop){for(this.inLoop=!0;this.active&&!this.paused;){this.consumedAfterSnapshot=0;let t=this._consume();this._ensureHibernation()||this._callState(t)}this.inLoop=!1}}pause(){this.paused=!0}resume(t){if(!this.paused)throw new Error("Parser was already resumed");this.paused=!1,!this.inLoop&&(this._runParsingLoop(),this.paused||t?.())}write(t,u,a){this.active=!0,this.preprocessor.write(t,u),this._runParsingLoop(),this.paused||a?.()}insertHtmlAtCurrentPos(t){this.active=!0,this.preprocessor.insertHtmlAtCurrentPos(t),this._runParsingLoop()}_ensureHibernation(){return this.preprocessor.endOfChunkHit?(this.preprocessor.retreat(this.consumedAfterSnapshot),this.consumedAfterSnapshot=0,this.active=!1,!0):!1}_consume(){return this.consumedAfterSnapshot++,this.preprocessor.advance()}_advanceBy(t){this.consumedAfterSnapshot+=t;for(let u=0;u0&&this._err(b.endTagWithAttributes),t.selfClosing&&this._err(b.endTagWithTrailingSolidus),this.handler.onEndTag(t)),this.preprocessor.dropParsedChunk()}emitCurrentComment(t){this.prepareToken(t),this.handler.onComment(t),this.preprocessor.dropParsedChunk()}emitCurrentDoctype(t){this.prepareToken(t),this.handler.onDoctype(t),this.preprocessor.dropParsedChunk()}_emitCurrentCharacterToken(t){if(this.currentCharacterToken){switch(t&&this.currentCharacterToken.location&&(this.currentCharacterToken.location.endLine=t.startLine,this.currentCharacterToken.location.endCol=t.startCol,this.currentCharacterToken.location.endOffset=t.startOffset),this.currentCharacterToken.type){case B.CHARACTER:{this.handler.onCharacter(this.currentCharacterToken);break}case B.NULL_CHARACTER:{this.handler.onNullCharacter(this.currentCharacterToken);break}case B.WHITESPACE_CHARACTER:{this.handler.onWhitespaceCharacter(this.currentCharacterToken);break}}this.currentCharacterToken=null}}_emitEOFToken(){let t=this.getCurrentLocation(0);t&&(t.endLine=t.startLine,t.endCol=t.startCol,t.endOffset=t.startOffset),this._emitCurrentCharacterToken(t),this.handler.onEof({type:B.EOF,location:t}),this.active=!1}_appendCharToCurrentCharacterToken(t,u){if(this.currentCharacterToken)if(this.currentCharacterToken.type===t){this.currentCharacterToken.chars+=u;return}else this.currentLocation=this.getCurrentLocation(0),this._emitCurrentCharacterToken(this.currentLocation),this.preprocessor.dropParsedChunk();this._createCharacterToken(t,u)}_emitCodePoint(t){let u=xr(t)?B.WHITESPACE_CHARACTER:t===c.NULL?B.NULL_CHARACTER:B.CHARACTER;this._appendCharToCurrentCharacterToken(u,String.fromCodePoint(t))}_emitChars(t){this._appendCharToCurrentCharacterToken(B.CHARACTER,t)}_startCharacterReference(){this.returnState=this.state,this.state=o.CHARACTER_REFERENCE,this.entityStartPos=this.preprocessor.pos,this.entityDecoder.startEntity(this._isCharacterReferenceInAttribute()?Ae.Attribute:Ae.Legacy)}_isCharacterReferenceInAttribute(){return this.returnState===o.ATTRIBUTE_VALUE_DOUBLE_QUOTED||this.returnState===o.ATTRIBUTE_VALUE_SINGLE_QUOTED||this.returnState===o.ATTRIBUTE_VALUE_UNQUOTED}_flushCodePointConsumedAsCharacterReference(t){this._isCharacterReferenceInAttribute()?this.currentAttr.value+=String.fromCodePoint(t):this._emitCodePoint(t)}_callState(t){switch(this.state){case o.DATA:{this._stateData(t);break}case o.RCDATA:{this._stateRcdata(t);break}case o.RAWTEXT:{this._stateRawtext(t);break}case o.SCRIPT_DATA:{this._stateScriptData(t);break}case o.PLAINTEXT:{this._statePlaintext(t);break}case o.TAG_OPEN:{this._stateTagOpen(t);break}case o.END_TAG_OPEN:{this._stateEndTagOpen(t);break}case o.TAG_NAME:{this._stateTagName(t);break}case o.RCDATA_LESS_THAN_SIGN:{this._stateRcdataLessThanSign(t);break}case o.RCDATA_END_TAG_OPEN:{this._stateRcdataEndTagOpen(t);break}case o.RCDATA_END_TAG_NAME:{this._stateRcdataEndTagName(t);break}case o.RAWTEXT_LESS_THAN_SIGN:{this._stateRawtextLessThanSign(t);break}case o.RAWTEXT_END_TAG_OPEN:{this._stateRawtextEndTagOpen(t);break}case o.RAWTEXT_END_TAG_NAME:{this._stateRawtextEndTagName(t);break}case o.SCRIPT_DATA_LESS_THAN_SIGN:{this._stateScriptDataLessThanSign(t);break}case o.SCRIPT_DATA_END_TAG_OPEN:{this._stateScriptDataEndTagOpen(t);break}case o.SCRIPT_DATA_END_TAG_NAME:{this._stateScriptDataEndTagName(t);break}case o.SCRIPT_DATA_ESCAPE_START:{this._stateScriptDataEscapeStart(t);break}case o.SCRIPT_DATA_ESCAPE_START_DASH:{this._stateScriptDataEscapeStartDash(t);break}case o.SCRIPT_DATA_ESCAPED:{this._stateScriptDataEscaped(t);break}case o.SCRIPT_DATA_ESCAPED_DASH:{this._stateScriptDataEscapedDash(t);break}case o.SCRIPT_DATA_ESCAPED_DASH_DASH:{this._stateScriptDataEscapedDashDash(t);break}case o.SCRIPT_DATA_ESCAPED_LESS_THAN_SIGN:{this._stateScriptDataEscapedLessThanSign(t);break}case o.SCRIPT_DATA_ESCAPED_END_TAG_OPEN:{this._stateScriptDataEscapedEndTagOpen(t);break}case o.SCRIPT_DATA_ESCAPED_END_TAG_NAME:{this._stateScriptDataEscapedEndTagName(t);break}case o.SCRIPT_DATA_DOUBLE_ESCAPE_START:{this._stateScriptDataDoubleEscapeStart(t);break}case o.SCRIPT_DATA_DOUBLE_ESCAPED:{this._stateScriptDataDoubleEscaped(t);break}case o.SCRIPT_DATA_DOUBLE_ESCAPED_DASH:{this._stateScriptDataDoubleEscapedDash(t);break}case o.SCRIPT_DATA_DOUBLE_ESCAPED_DASH_DASH:{this._stateScriptDataDoubleEscapedDashDash(t);break}case o.SCRIPT_DATA_DOUBLE_ESCAPED_LESS_THAN_SIGN:{this._stateScriptDataDoubleEscapedLessThanSign(t);break}case o.SCRIPT_DATA_DOUBLE_ESCAPE_END:{this._stateScriptDataDoubleEscapeEnd(t);break}case o.BEFORE_ATTRIBUTE_NAME:{this._stateBeforeAttributeName(t);break}case o.ATTRIBUTE_NAME:{this._stateAttributeName(t);break}case o.AFTER_ATTRIBUTE_NAME:{this._stateAfterAttributeName(t);break}case o.BEFORE_ATTRIBUTE_VALUE:{this._stateBeforeAttributeValue(t);break}case o.ATTRIBUTE_VALUE_DOUBLE_QUOTED:{this._stateAttributeValueDoubleQuoted(t);break}case o.ATTRIBUTE_VALUE_SINGLE_QUOTED:{this._stateAttributeValueSingleQuoted(t);break}case o.ATTRIBUTE_VALUE_UNQUOTED:{this._stateAttributeValueUnquoted(t);break}case o.AFTER_ATTRIBUTE_VALUE_QUOTED:{this._stateAfterAttributeValueQuoted(t);break}case o.SELF_CLOSING_START_TAG:{this._stateSelfClosingStartTag(t);break}case o.BOGUS_COMMENT:{this._stateBogusComment(t);break}case o.MARKUP_DECLARATION_OPEN:{this._stateMarkupDeclarationOpen(t);break}case o.COMMENT_START:{this._stateCommentStart(t);break}case o.COMMENT_START_DASH:{this._stateCommentStartDash(t);break}case o.COMMENT:{this._stateComment(t);break}case o.COMMENT_LESS_THAN_SIGN:{this._stateCommentLessThanSign(t);break}case o.COMMENT_LESS_THAN_SIGN_BANG:{this._stateCommentLessThanSignBang(t);break}case o.COMMENT_LESS_THAN_SIGN_BANG_DASH:{this._stateCommentLessThanSignBangDash(t);break}case o.COMMENT_LESS_THAN_SIGN_BANG_DASH_DASH:{this._stateCommentLessThanSignBangDashDash(t);break}case o.COMMENT_END_DASH:{this._stateCommentEndDash(t);break}case o.COMMENT_END:{this._stateCommentEnd(t);break}case o.COMMENT_END_BANG:{this._stateCommentEndBang(t);break}case o.DOCTYPE:{this._stateDoctype(t);break}case o.BEFORE_DOCTYPE_NAME:{this._stateBeforeDoctypeName(t);break}case o.DOCTYPE_NAME:{this._stateDoctypeName(t);break}case o.AFTER_DOCTYPE_NAME:{this._stateAfterDoctypeName(t);break}case o.AFTER_DOCTYPE_PUBLIC_KEYWORD:{this._stateAfterDoctypePublicKeyword(t);break}case o.BEFORE_DOCTYPE_PUBLIC_IDENTIFIER:{this._stateBeforeDoctypePublicIdentifier(t);break}case o.DOCTYPE_PUBLIC_IDENTIFIER_DOUBLE_QUOTED:{this._stateDoctypePublicIdentifierDoubleQuoted(t);break}case o.DOCTYPE_PUBLIC_IDENTIFIER_SINGLE_QUOTED:{this._stateDoctypePublicIdentifierSingleQuoted(t);break}case o.AFTER_DOCTYPE_PUBLIC_IDENTIFIER:{this._stateAfterDoctypePublicIdentifier(t);break}case o.BETWEEN_DOCTYPE_PUBLIC_AND_SYSTEM_IDENTIFIERS:{this._stateBetweenDoctypePublicAndSystemIdentifiers(t);break}case o.AFTER_DOCTYPE_SYSTEM_KEYWORD:{this._stateAfterDoctypeSystemKeyword(t);break}case o.BEFORE_DOCTYPE_SYSTEM_IDENTIFIER:{this._stateBeforeDoctypeSystemIdentifier(t);break}case o.DOCTYPE_SYSTEM_IDENTIFIER_DOUBLE_QUOTED:{this._stateDoctypeSystemIdentifierDoubleQuoted(t);break}case o.DOCTYPE_SYSTEM_IDENTIFIER_SINGLE_QUOTED:{this._stateDoctypeSystemIdentifierSingleQuoted(t);break}case o.AFTER_DOCTYPE_SYSTEM_IDENTIFIER:{this._stateAfterDoctypeSystemIdentifier(t);break}case o.BOGUS_DOCTYPE:{this._stateBogusDoctype(t);break}case o.CDATA_SECTION:{this._stateCdataSection(t);break}case o.CDATA_SECTION_BRACKET:{this._stateCdataSectionBracket(t);break}case o.CDATA_SECTION_END:{this._stateCdataSectionEnd(t);break}case o.CHARACTER_REFERENCE:{this._stateCharacterReference();break}case o.AMBIGUOUS_AMPERSAND:{this._stateAmbiguousAmpersand(t);break}default:throw new Error("Unknown state")}}_stateData(t){switch(t){case c.LESS_THAN_SIGN:{this.state=o.TAG_OPEN;break}case c.AMPERSAND:{this._startCharacterReference();break}case c.NULL:{this._err(b.unexpectedNullCharacter),this._emitCodePoint(t);break}case c.EOF:{this._emitEOFToken();break}default:this._emitCodePoint(t)}}_stateRcdata(t){switch(t){case c.AMPERSAND:{this._startCharacterReference();break}case c.LESS_THAN_SIGN:{this.state=o.RCDATA_LESS_THAN_SIGN;break}case c.NULL:{this._err(b.unexpectedNullCharacter),this._emitChars(O);break}case c.EOF:{this._emitEOFToken();break}default:this._emitCodePoint(t)}}_stateRawtext(t){switch(t){case c.LESS_THAN_SIGN:{this.state=o.RAWTEXT_LESS_THAN_SIGN;break}case c.NULL:{this._err(b.unexpectedNullCharacter),this._emitChars(O);break}case c.EOF:{this._emitEOFToken();break}default:this._emitCodePoint(t)}}_stateScriptData(t){switch(t){case c.LESS_THAN_SIGN:{this.state=o.SCRIPT_DATA_LESS_THAN_SIGN;break}case c.NULL:{this._err(b.unexpectedNullCharacter),this._emitChars(O);break}case c.EOF:{this._emitEOFToken();break}default:this._emitCodePoint(t)}}_statePlaintext(t){switch(t){case c.NULL:{this._err(b.unexpectedNullCharacter),this._emitChars(O);break}case c.EOF:{this._emitEOFToken();break}default:this._emitCodePoint(t)}}_stateTagOpen(t){if(Re(t))this._createStartTagToken(),this.state=o.TAG_NAME,this._stateTagName(t);else switch(t){case c.EXCLAMATION_MARK:{this.state=o.MARKUP_DECLARATION_OPEN;break}case c.SOLIDUS:{this.state=o.END_TAG_OPEN;break}case c.QUESTION_MARK:{this._err(b.unexpectedQuestionMarkInsteadOfTagName),this._createCommentToken(1),this.state=o.BOGUS_COMMENT,this._stateBogusComment(t);break}case c.EOF:{this._err(b.eofBeforeTagName),this._emitChars("<"),this._emitEOFToken();break}default:this._err(b.invalidFirstCharacterOfTagName),this._emitChars("<"),this.state=o.DATA,this._stateData(t)}}_stateEndTagOpen(t){if(Re(t))this._createEndTagToken(),this.state=o.TAG_NAME,this._stateTagName(t);else switch(t){case c.GREATER_THAN_SIGN:{this._err(b.missingEndTagName),this.state=o.DATA;break}case c.EOF:{this._err(b.eofBeforeTagName),this._emitChars("");break}case c.NULL:{this._err(b.unexpectedNullCharacter),this.state=o.SCRIPT_DATA_ESCAPED,this._emitChars(O);break}case c.EOF:{this._err(b.eofInScriptHtmlCommentLikeText),this._emitEOFToken();break}default:this.state=o.SCRIPT_DATA_ESCAPED,this._emitCodePoint(t)}}_stateScriptDataEscapedLessThanSign(t){t===c.SOLIDUS?this.state=o.SCRIPT_DATA_ESCAPED_END_TAG_OPEN:Re(t)?(this._emitChars("<"),this.state=o.SCRIPT_DATA_DOUBLE_ESCAPE_START,this._stateScriptDataDoubleEscapeStart(t)):(this._emitChars("<"),this.state=o.SCRIPT_DATA_ESCAPED,this._stateScriptDataEscaped(t))}_stateScriptDataEscapedEndTagOpen(t){Re(t)?(this.state=o.SCRIPT_DATA_ESCAPED_END_TAG_NAME,this._stateScriptDataEscapedEndTagName(t)):(this._emitChars("");break}case c.NULL:{this._err(b.unexpectedNullCharacter),this.state=o.SCRIPT_DATA_DOUBLE_ESCAPED,this._emitChars(O);break}case c.EOF:{this._err(b.eofInScriptHtmlCommentLikeText),this._emitEOFToken();break}default:this.state=o.SCRIPT_DATA_DOUBLE_ESCAPED,this._emitCodePoint(t)}}_stateScriptDataDoubleEscapedLessThanSign(t){t===c.SOLIDUS?(this.state=o.SCRIPT_DATA_DOUBLE_ESCAPE_END,this._emitChars("/")):(this.state=o.SCRIPT_DATA_DOUBLE_ESCAPED,this._stateScriptDataDoubleEscaped(t))}_stateScriptDataDoubleEscapeEnd(t){if(this.preprocessor.startsWith(q.SCRIPT,!1)&&Cr(this.preprocessor.peek(q.SCRIPT.length))){this._emitCodePoint(t);for(let u=0;u0&&this._isInTemplate()&&this.tmplCount--,this.stackTop--,this._updateCurrentElement(),this.handler.onItemPop(t,!0)}replace(t,u){let a=this._indexOf(t);this.items[a]=u,a===this.stackTop&&(this.current=u)}insertAfter(t,u,a){let s=this._indexOf(t)+1;this.items.splice(s,0,u),this.tagIDs.splice(s,0,a),this.stackTop++,s===this.stackTop&&this._updateCurrentElement(),this.current&&this.currentTagId!==void 0&&this.handler.onItemPush(this.current,this.currentTagId,s===this.stackTop)}popUntilTagNamePopped(t){let u=this.stackTop+1;do u=this.tagIDs.lastIndexOf(t,u-1);while(u>0&&this.treeAdapter.getNamespaceURI(this.items[u])!==E.HTML);this.shortenToLength(Math.max(u,0))}shortenToLength(t){for(;this.stackTop>=t;){let u=this.current;this.tmplCount>0&&this._isInTemplate()&&(this.tmplCount-=1),this.stackTop--,this._updateCurrentElement(),this.handler.onItemPop(u,this.stackTop=0;a--)if(t.has(this.tagIDs[a])&&this.treeAdapter.getNamespaceURI(this.items[a])===u)return a;return-1}clearBackTo(t,u){let a=this._indexOfTagNames(t,u);this.shortenToLength(a+1)}clearBackToTableContext(){this.clearBackTo(Oc,E.HTML)}clearBackToTableBodyContext(){this.clearBackTo(yc,E.HTML)}clearBackToTableRowContext(){this.clearBackTo(Lc,E.HTML)}remove(t){let u=this._indexOf(t);u>=0&&(u===this.stackTop?this.pop():(this.items.splice(u,1),this.tagIDs.splice(u,1),this.stackTop--,this._updateCurrentElement(),this.handler.onItemPop(t,!1)))}tryPeekProperlyNestedBodyElement(){return this.stackTop>=1&&this.tagIDs[1]===r.BODY?this.items[1]:null}contains(t){return this._indexOf(t)>-1}getCommonAncestor(t){let u=this._indexOf(t)-1;return u>=0?this.items[u]:null}isRootHtmlElementCurrent(){return this.stackTop===0&&this.tagIDs[0]===r.HTML}hasInDynamicScope(t,u){for(let a=this.stackTop;a>=0;a--){let s=this.tagIDs[a];switch(this.treeAdapter.getNamespaceURI(this.items[a])){case E.HTML:{if(s===t)return!0;if(u.has(s))return!1;break}case E.SVG:{if(Sr.has(s))return!1;break}case E.MATHML:{if(_r.has(s))return!1;break}}}return!0}hasInScope(t){return this.hasInDynamicScope(t,Nu)}hasInListItemScope(t){return this.hasInDynamicScope(t,Bc)}hasInButtonScope(t){return this.hasInDynamicScope(t,Rc)}hasNumberedHeaderInScope(){for(let t=this.stackTop;t>=0;t--){let u=this.tagIDs[t];switch(this.treeAdapter.getNamespaceURI(this.items[t])){case E.HTML:{if(ut.has(u))return!0;if(Nu.has(u))return!1;break}case E.SVG:{if(Sr.has(u))return!1;break}case E.MATHML:{if(_r.has(u))return!1;break}}}return!0}hasInTableScope(t){for(let u=this.stackTop;u>=0;u--)if(this.treeAdapter.getNamespaceURI(this.items[u])===E.HTML)switch(this.tagIDs[u]){case t:return!0;case r.TABLE:case r.HTML:return!1}return!0}hasTableBodyContextInTableScope(){for(let t=this.stackTop;t>=0;t--)if(this.treeAdapter.getNamespaceURI(this.items[t])===E.HTML)switch(this.tagIDs[t]){case r.TBODY:case r.THEAD:case r.TFOOT:return!0;case r.TABLE:case r.HTML:return!1}return!0}hasInSelectScope(t){for(let u=this.stackTop;u>=0;u--)if(this.treeAdapter.getNamespaceURI(this.items[u])===E.HTML)switch(this.tagIDs[u]){case t:return!0;case r.OPTION:case r.OPTGROUP:break;default:return!1}return!0}generateImpliedEndTags(){for(;this.currentTagId!==void 0&&Dr.has(this.currentTagId);)this.pop()}generateImpliedEndTagsThoroughly(){for(;this.currentTagId!==void 0&&Nr.has(this.currentTagId);)this.pop()}generateImpliedEndTagsWithExclusion(t){for(;this.currentTagId!==void 0&&this.currentTagId!==t&&Nr.has(this.currentTagId);)this.pop()}};var ne;(function(e){e[e.Marker=0]="Marker",e[e.Element=1]="Element"})(ne||(ne={}));var Br={type:ne.Marker},Su=class{constructor(t){this.treeAdapter=t,this.entries=[],this.bookmark=null}_getNoahArkConditionCandidates(t,u){let a=[],s=u.length,i=this.treeAdapter.getTagName(t),n=this.treeAdapter.getNamespaceURI(t);for(let d=0;d[n.name,n.value])),i=0;for(let n=0;ns.get(l.name)===l.value)&&(i+=1,i>=3&&this.entries.splice(d.idx,1))}}insertMarker(){this.entries.unshift(Br)}pushElement(t,u){this._ensureNoahArkCondition(t),this.entries.unshift({type:ne.Element,element:t,token:u})}insertElementAfterBookmark(t,u){let a=this.entries.indexOf(this.bookmark);this.entries.splice(a,0,{type:ne.Element,element:t,token:u})}removeEntry(t){let u=this.entries.indexOf(t);u!==-1&&this.entries.splice(u,1)}clearToLastMarker(){let t=this.entries.indexOf(Br);t===-1?this.entries.length=0:this.entries.splice(0,t+1)}getElementEntryInScopeWithTagName(t){let u=this.entries.find(a=>a.type===ne.Marker||this.treeAdapter.getTagName(a.element)===t);return u&&u.type===ne.Element?u:null}getElementEntry(t){return this.entries.find(u=>u.type===ne.Element&&u.element===t)}};var ae={createDocument(){return{nodeName:"#document",mode:G.NO_QUIRKS,childNodes:[]}},createDocumentFragment(){return{nodeName:"#document-fragment",childNodes:[]}},createElement(e,t,u){return{nodeName:e,tagName:e,attrs:u,namespaceURI:t,childNodes:[],parentNode:null}},createCommentNode(e){return{nodeName:"#comment",data:e,parentNode:null}},createTextNode(e){return{nodeName:"#text",value:e,parentNode:null}},appendChild(e,t){e.childNodes.push(t),t.parentNode=e},insertBefore(e,t,u){let a=e.childNodes.indexOf(u);e.childNodes.splice(a,0,t),t.parentNode=e},setTemplateContent(e,t){e.content=t},getTemplateContent(e){return e.content},setDocumentType(e,t,u,a){let s=e.childNodes.find(i=>i.nodeName==="#documentType");if(s)s.name=t,s.publicId=u,s.systemId=a;else{let i={nodeName:"#documentType",name:t,publicId:u,systemId:a,parentNode:null};ae.appendChild(e,i)}},setDocumentMode(e,t){e.mode=t},getDocumentMode(e){return e.mode},detachNode(e){if(e.parentNode){let t=e.parentNode.childNodes.indexOf(e);e.parentNode.childNodes.splice(t,1),e.parentNode=null}},insertText(e,t){if(e.childNodes.length>0){let u=e.childNodes[e.childNodes.length-1];if(ae.isTextNode(u)){u.value+=t;return}}ae.appendChild(e,ae.createTextNode(t))},insertTextBefore(e,t,u){let a=e.childNodes[e.childNodes.indexOf(u)-1];a&&ae.isTextNode(a)?a.value+=t:ae.insertBefore(e,ae.createTextNode(t),u)},adoptAttributes(e,t){let u=new Set(e.attrs.map(a=>a.name));for(let a=0;ae.startsWith(u))}function wr(e){return e.name===Lr&&e.publicId===null&&(e.systemId===null||e.systemId===Fc)}function Fr(e){if(e.name!==Lr)return G.QUIRKS;let{systemId:t}=e;if(t&&t.toLowerCase()===Mc)return G.QUIRKS;let{publicId:u}=e;if(u!==null){if(u=u.toLowerCase(),Pc.has(u))return G.QUIRKS;let a=t===null?kc:yr;if(Rr(u,a))return G.QUIRKS;if(a=t===null?Or:Hc,Rr(u,a))return G.LIMITED_QUIRKS}return G.NO_QUIRKS}var Mr={TEXT_HTML:"text/html",APPLICATION_XML:"application/xhtml+xml"},vc="definitionurl",Qc="definitionURL",Yc=new Map(["attributeName","attributeType","baseFrequency","baseProfile","calcMode","clipPathUnits","diffuseConstant","edgeMode","filterUnits","glyphRef","gradientTransform","gradientUnits","kernelMatrix","kernelUnitLength","keyPoints","keySplines","keyTimes","lengthAdjust","limitingConeAngle","markerHeight","markerUnits","markerWidth","maskContentUnits","maskUnits","numOctaves","pathLength","patternContentUnits","patternTransform","patternUnits","pointsAtX","pointsAtY","pointsAtZ","preserveAlpha","preserveAspectRatio","primitiveUnits","refX","refY","repeatCount","repeatDur","requiredExtensions","requiredFeatures","specularConstant","specularExponent","spreadMethod","startOffset","stdDeviation","stitchTiles","surfaceScale","systemLanguage","tableValues","targetX","targetY","textLength","viewBox","viewTarget","xChannelSelector","yChannelSelector","zoomAndPan"].map(e=>[e.toLowerCase(),e])),Gc=new Map([["xlink:actuate",{prefix:"xlink",name:"actuate",namespace:E.XLINK}],["xlink:arcrole",{prefix:"xlink",name:"arcrole",namespace:E.XLINK}],["xlink:href",{prefix:"xlink",name:"href",namespace:E.XLINK}],["xlink:role",{prefix:"xlink",name:"role",namespace:E.XLINK}],["xlink:show",{prefix:"xlink",name:"show",namespace:E.XLINK}],["xlink:title",{prefix:"xlink",name:"title",namespace:E.XLINK}],["xlink:type",{prefix:"xlink",name:"type",namespace:E.XLINK}],["xml:lang",{prefix:"xml",name:"lang",namespace:E.XML}],["xml:space",{prefix:"xml",name:"space",namespace:E.XML}],["xmlns",{prefix:"",name:"xmlns",namespace:E.XMLNS}],["xmlns:xlink",{prefix:"xmlns",name:"xlink",namespace:E.XMLNS}]]),Kc=new Map(["altGlyph","altGlyphDef","altGlyphItem","animateColor","animateMotion","animateTransform","clipPath","feBlend","feColorMatrix","feComponentTransfer","feComposite","feConvolveMatrix","feDiffuseLighting","feDisplacementMap","feDistantLight","feFlood","feFuncA","feFuncB","feFuncG","feFuncR","feGaussianBlur","feImage","feMerge","feMergeNode","feMorphology","feOffset","fePointLight","feSpecularLighting","feSpotLight","feTile","feTurbulence","foreignObject","glyphRef","linearGradient","radialGradient","textPath"].map(e=>[e.toLowerCase(),e])),Wc=new Set([r.B,r.BIG,r.BLOCKQUOTE,r.BODY,r.BR,r.CENTER,r.CODE,r.DD,r.DIV,r.DL,r.DT,r.EM,r.EMBED,r.H1,r.H2,r.H3,r.H4,r.H5,r.H6,r.HEAD,r.HR,r.I,r.IMG,r.LI,r.LISTING,r.MENU,r.META,r.NOBR,r.OL,r.P,r.PRE,r.RUBY,r.S,r.SMALL,r.SPAN,r.STRONG,r.STRIKE,r.SUB,r.SUP,r.TABLE,r.TT,r.U,r.UL,r.VAR]);function kr(e){let t=e.tagID;return t===r.FONT&&e.attrs.some(({name:a})=>a===de.COLOR||a===de.SIZE||a===de.FACE)||Wc.has(t)}function W0(e){for(let t=0;t0&&this._setContextModes(t,u)}onItemPop(t,u){var a,s;if(this.options.sourceCodeLocationInfo&&this._setEndLocation(t,this.currentToken),(s=(a=this.treeAdapter).onItemPop)===null||s===void 0||s.call(a,t,this.openElements.current),u){let i,n;this.openElements.stackTop===0&&this.fragmentContext?(i=this.fragmentContext,n=this.fragmentContextID):{current:i,currentTagId:n}=this.openElements,this._setContextModes(i,n)}}_setContextModes(t,u){let a=t===this.document||t&&this.treeAdapter.getNamespaceURI(t)===E.HTML;this.currentNotInHTML=!a,this.tokenizer.inForeignNode=!a&&t!==void 0&&u!==void 0&&!this._isIntegrationPoint(u,t)}_switchToTextParsing(t,u){this._insertElement(t,E.HTML),this.tokenizer.state=u,this.originalInsertionMode=this.insertionMode,this.insertionMode=A.TEXT}switchToPlaintextParsing(){this.insertionMode=A.TEXT,this.originalInsertionMode=A.IN_BODY,this.tokenizer.state=K.PLAINTEXT}_getAdjustedCurrentElement(){return this.openElements.stackTop===0&&this.fragmentContext?this.fragmentContext:this.openElements.current}_findFormInFragmentContext(){let t=this.fragmentContext;for(;t;){if(this.treeAdapter.getTagName(t)===f.FORM){this.formElement=t;break}t=this.treeAdapter.getParentNode(t)}}_initTokenizerForFragmentParsing(){if(!(!this.fragmentContext||this.treeAdapter.getNamespaceURI(this.fragmentContext)!==E.HTML))switch(this.fragmentContextID){case r.TITLE:case r.TEXTAREA:{this.tokenizer.state=K.RCDATA;break}case r.STYLE:case r.XMP:case r.IFRAME:case r.NOEMBED:case r.NOFRAMES:case r.NOSCRIPT:{this.tokenizer.state=K.RAWTEXT;break}case r.SCRIPT:{this.tokenizer.state=K.SCRIPT_DATA;break}case r.PLAINTEXT:{this.tokenizer.state=K.PLAINTEXT;break}default:}}_setDocumentType(t){let u=t.name||"",a=t.publicId||"",s=t.systemId||"";if(this.treeAdapter.setDocumentType(this.document,u,a,s),t.location){let n=this.treeAdapter.getChildNodes(this.document).find(d=>this.treeAdapter.isDocumentTypeNode(d));n&&this.treeAdapter.setNodeSourceCodeLocation(n,t.location)}}_attachElementToTree(t,u){if(this.options.sourceCodeLocationInfo){let a=u&&{...u,startTag:u};this.treeAdapter.setNodeSourceCodeLocation(t,a)}if(this._shouldFosterParentOnInsertion())this._fosterParentElement(t);else{let a=this.openElements.currentTmplContentOrNode;this.treeAdapter.appendChild(a??this.document,t)}}_appendElement(t,u){let a=this.treeAdapter.createElement(t.tagName,u,t.attrs);this._attachElementToTree(a,t.location)}_insertElement(t,u){let a=this.treeAdapter.createElement(t.tagName,u,t.attrs);this._attachElementToTree(a,t.location),this.openElements.push(a,t.tagID)}_insertFakeElement(t,u){let a=this.treeAdapter.createElement(t,E.HTML,[]);this._attachElementToTree(a,null),this.openElements.push(a,u)}_insertTemplate(t){let u=this.treeAdapter.createElement(t.tagName,E.HTML,t.attrs),a=this.treeAdapter.createDocumentFragment();this.treeAdapter.setTemplateContent(u,a),this._attachElementToTree(u,t.location),this.openElements.push(u,t.tagID),this.options.sourceCodeLocationInfo&&this.treeAdapter.setNodeSourceCodeLocation(a,null)}_insertFakeRootElement(){let t=this.treeAdapter.createElement(f.HTML,E.HTML,[]);this.options.sourceCodeLocationInfo&&this.treeAdapter.setNodeSourceCodeLocation(t,null),this.treeAdapter.appendChild(this.openElements.current,t),this.openElements.push(t,r.HTML)}_appendCommentNode(t,u){let a=this.treeAdapter.createCommentNode(t.data);this.treeAdapter.appendChild(u,a),this.options.sourceCodeLocationInfo&&this.treeAdapter.setNodeSourceCodeLocation(a,t.location)}_insertCharacters(t){let u,a;if(this._shouldFosterParentOnInsertion()?({parent:u,beforeElement:a}=this._findFosterParentingLocation(),a?this.treeAdapter.insertTextBefore(u,t.chars,a):this.treeAdapter.insertText(u,t.chars)):(u=this.openElements.currentTmplContentOrNode,this.treeAdapter.insertText(u,t.chars)),!t.location)return;let s=this.treeAdapter.getChildNodes(u),i=a?s.lastIndexOf(a):s.length,n=s[i-1];if(this.treeAdapter.getNodeSourceCodeLocation(n)){let{endLine:l,endCol:h,endOffset:m}=t.location;this.treeAdapter.updateNodeSourceCodeLocation(n,{endLine:l,endCol:h,endOffset:m})}else this.options.sourceCodeLocationInfo&&this.treeAdapter.setNodeSourceCodeLocation(n,t.location)}_adoptNodes(t,u){for(let a=this.treeAdapter.getFirstChild(t);a;a=this.treeAdapter.getFirstChild(t))this.treeAdapter.detachNode(a),this.treeAdapter.appendChild(u,a)}_setEndLocation(t,u){if(this.treeAdapter.getNodeSourceCodeLocation(t)&&u.location){let a=u.location,s=this.treeAdapter.getTagName(t),i=u.type===B.END_TAG&&s===u.tagName?{endTag:{...a},endLine:a.endLine,endCol:a.endCol,endOffset:a.endOffset}:{endLine:a.startLine,endCol:a.startCol,endOffset:a.startOffset};this.treeAdapter.updateNodeSourceCodeLocation(t,i)}}shouldProcessStartTagTokenInForeignContent(t){if(!this.currentNotInHTML)return!1;let u,a;return this.openElements.stackTop===0&&this.fragmentContext?(u=this.fragmentContext,a=this.fragmentContextID):{current:u,currentTagId:a}=this.openElements,t.tagID===r.SVG&&this.treeAdapter.getTagName(u)===f.ANNOTATION_XML&&this.treeAdapter.getNamespaceURI(u)===E.MATHML?!1:this.tokenizer.inForeignNode||(t.tagID===r.MGLYPH||t.tagID===r.MALIGNMARK)&&a!==void 0&&!this._isIntegrationPoint(a,u,E.HTML)}_processToken(t){switch(t.type){case B.CHARACTER:{this.onCharacter(t);break}case B.NULL_CHARACTER:{this.onNullCharacter(t);break}case B.COMMENT:{this.onComment(t);break}case B.DOCTYPE:{this.onDoctype(t);break}case B.START_TAG:{this._processStartTag(t);break}case B.END_TAG:{this.onEndTag(t);break}case B.EOF:{this.onEof(t);break}case B.WHITESPACE_CHARACTER:{this.onWhitespaceCharacter(t);break}}}_isIntegrationPoint(t,u,a){let s=this.treeAdapter.getNamespaceURI(u),i=this.treeAdapter.getAttrList(u);return Hr(t,s,i,a)}_reconstructActiveFormattingElements(){let t=this.activeFormattingElements.entries.length;if(t){let u=this.activeFormattingElements.entries.findIndex(s=>s.type===ne.Marker||this.openElements.contains(s.element)),a=u===-1?t-1:u-1;for(let s=a;s>=0;s--){let i=this.activeFormattingElements.entries[s];this._insertElement(i.token,this.treeAdapter.getNamespaceURI(i.element)),i.element=this.openElements.current}}}_closeTableCell(){this.openElements.generateImpliedEndTags(),this.openElements.popUntilTableCellPopped(),this.activeFormattingElements.clearToLastMarker(),this.insertionMode=A.IN_ROW}_closePElement(){this.openElements.generateImpliedEndTagsWithExclusion(r.P),this.openElements.popUntilTagNamePopped(r.P)}_resetInsertionMode(){for(let t=this.openElements.stackTop;t>=0;t--)switch(t===0&&this.fragmentContext?this.fragmentContextID:this.openElements.tagIDs[t]){case r.TR:{this.insertionMode=A.IN_ROW;return}case r.TBODY:case r.THEAD:case r.TFOOT:{this.insertionMode=A.IN_TABLE_BODY;return}case r.CAPTION:{this.insertionMode=A.IN_CAPTION;return}case r.COLGROUP:{this.insertionMode=A.IN_COLUMN_GROUP;return}case r.TABLE:{this.insertionMode=A.IN_TABLE;return}case r.BODY:{this.insertionMode=A.IN_BODY;return}case r.FRAMESET:{this.insertionMode=A.IN_FRAMESET;return}case r.SELECT:{this._resetInsertionModeForSelect(t);return}case r.TEMPLATE:{this.insertionMode=this.tmplInsertionModeStack[0];return}case r.HTML:{this.insertionMode=this.headElement?A.AFTER_HEAD:A.BEFORE_HEAD;return}case r.TD:case r.TH:{if(t>0){this.insertionMode=A.IN_CELL;return}break}case r.HEAD:{if(t>0){this.insertionMode=A.IN_HEAD;return}break}}this.insertionMode=A.IN_BODY}_resetInsertionModeForSelect(t){if(t>0)for(let u=t-1;u>0;u--){let a=this.openElements.tagIDs[u];if(a===r.TEMPLATE)break;if(a===r.TABLE){this.insertionMode=A.IN_SELECT_IN_TABLE;return}}this.insertionMode=A.IN_SELECT}_isElementCausesFosterParenting(t){return Gr.has(t)}_shouldFosterParentOnInsertion(){return this.fosterParentingEnabled&&this.openElements.currentTagId!==void 0&&this._isElementCausesFosterParenting(this.openElements.currentTagId)}_findFosterParentingLocation(){for(let t=this.openElements.stackTop;t>=0;t--){let u=this.openElements.items[t];switch(this.openElements.tagIDs[t]){case r.TEMPLATE:{if(this.treeAdapter.getNamespaceURI(u)===E.HTML)return{parent:this.treeAdapter.getTemplateContent(u),beforeElement:null};break}case r.TABLE:{let a=this.treeAdapter.getParentNode(u);return a?{parent:a,beforeElement:u}:{parent:this.openElements.items[t-1],beforeElement:null}}default:}}return{parent:this.openElements.items[0],beforeElement:null}}_fosterParentElement(t){let u=this._findFosterParentingLocation();u.beforeElement?this.treeAdapter.insertBefore(u.parent,t,u.beforeElement):this.treeAdapter.appendChild(u.parent,t)}_isSpecialElement(t,u){let a=this.treeAdapter.getNamespaceURI(t);return G0[a].has(u)}onCharacter(t){if(this.skipNextNewLine=!1,this.tokenizer.inForeignNode){BA(this,t);return}switch(this.insertionMode){case A.INITIAL:{St(this,t);break}case A.BEFORE_HTML:{Bt(this,t);break}case A.BEFORE_HEAD:{Rt(this,t);break}case A.IN_HEAD:{Lt(this,t);break}case A.IN_HEAD_NO_SCRIPT:{yt(this,t);break}case A.AFTER_HEAD:{Ot(this,t);break}case A.IN_BODY:case A.IN_CAPTION:case A.IN_CELL:case A.IN_TEMPLATE:{Wr(this,t);break}case A.TEXT:case A.IN_SELECT:case A.IN_SELECT_IN_TABLE:{this._insertCharacters(t);break}case A.IN_TABLE:case A.IN_TABLE_BODY:case A.IN_ROW:{J0(this,t);break}case A.IN_TABLE_TEXT:{jr(this,t);break}case A.IN_COLUMN_GROUP:{Ru(this,t);break}case A.AFTER_BODY:{Lu(this,t);break}case A.AFTER_AFTER_BODY:{Bu(this,t);break}default:}}onNullCharacter(t){if(this.skipNextNewLine=!1,this.tokenizer.inForeignNode){DA(this,t);return}switch(this.insertionMode){case A.INITIAL:{St(this,t);break}case A.BEFORE_HTML:{Bt(this,t);break}case A.BEFORE_HEAD:{Rt(this,t);break}case A.IN_HEAD:{Lt(this,t);break}case A.IN_HEAD_NO_SCRIPT:{yt(this,t);break}case A.AFTER_HEAD:{Ot(this,t);break}case A.TEXT:{this._insertCharacters(t);break}case A.IN_TABLE:case A.IN_TABLE_BODY:case A.IN_ROW:{J0(this,t);break}case A.IN_COLUMN_GROUP:{Ru(this,t);break}case A.AFTER_BODY:{Lu(this,t);break}case A.AFTER_AFTER_BODY:{Bu(this,t);break}default:}}onComment(t){if(this.skipNextNewLine=!1,this.currentNotInHTML){V0(this,t);return}switch(this.insertionMode){case A.INITIAL:case A.BEFORE_HTML:case A.BEFORE_HEAD:case A.IN_HEAD:case A.IN_HEAD_NO_SCRIPT:case A.AFTER_HEAD:case A.IN_BODY:case A.IN_TABLE:case A.IN_CAPTION:case A.IN_COLUMN_GROUP:case A.IN_TABLE_BODY:case A.IN_ROW:case A.IN_CELL:case A.IN_SELECT:case A.IN_SELECT_IN_TABLE:case A.IN_TEMPLATE:case A.IN_FRAMESET:case A.AFTER_FRAMESET:{V0(this,t);break}case A.IN_TABLE_TEXT:{Dt(this,t);break}case A.AFTER_BODY:{ro(this,t);break}case A.AFTER_AFTER_BODY:case A.AFTER_AFTER_FRAMESET:{so(this,t);break}default:}}onDoctype(t){switch(this.skipNextNewLine=!1,this.insertionMode){case A.INITIAL:{io(this,t);break}case A.BEFORE_HEAD:case A.IN_HEAD:case A.IN_HEAD_NO_SCRIPT:case A.AFTER_HEAD:{this._err(t,b.misplacedDoctype);break}case A.IN_TABLE_TEXT:{Dt(this,t);break}default:}}onStartTag(t){this.skipNextNewLine=!1,this.currentToken=t,this._processStartTag(t),t.selfClosing&&!t.ackSelfClosing&&this._err(t,b.nonVoidHtmlElementStartTagWithTrailingSolidus)}_processStartTag(t){this.shouldProcessStartTagTokenInForeignContent(t)?RA(this,t):this._startTagOutsideForeignContent(t)}_startTagOutsideForeignContent(t){switch(this.insertionMode){case A.INITIAL:{St(this,t);break}case A.BEFORE_HTML:{no(this,t);break}case A.BEFORE_HEAD:{oo(this,t);break}case A.IN_HEAD:{ce(this,t);break}case A.IN_HEAD_NO_SCRIPT:{fo(this,t);break}case A.AFTER_HEAD:{bo(this,t);break}case A.IN_BODY:{W(this,t);break}case A.IN_TABLE:{rt(this,t);break}case A.IN_TABLE_TEXT:{Dt(this,t);break}case A.IN_CAPTION:{dA(this,t);break}case A.IN_COLUMN_GROUP:{z0(this,t);break}case A.IN_TABLE_BODY:{wu(this,t);break}case A.IN_ROW:{Fu(this,t);break}case A.IN_CELL:{hA(this,t);break}case A.IN_SELECT:{es(this,t);break}case A.IN_SELECT_IN_TABLE:{EA(this,t);break}case A.IN_TEMPLATE:{gA(this,t);break}case A.AFTER_BODY:{TA(this,t);break}case A.IN_FRAMESET:{IA(this,t);break}case A.AFTER_FRAMESET:{xA(this,t);break}case A.AFTER_AFTER_BODY:{_A(this,t);break}case A.AFTER_AFTER_FRAMESET:{SA(this,t);break}default:}}onEndTag(t){this.skipNextNewLine=!1,this.currentToken=t,this.currentNotInHTML?LA(this,t):this._endTagOutsideForeignContent(t)}_endTagOutsideForeignContent(t){switch(this.insertionMode){case A.INITIAL:{St(this,t);break}case A.BEFORE_HTML:{co(this,t);break}case A.BEFORE_HEAD:{Ao(this,t);break}case A.IN_HEAD:{lo(this,t);break}case A.IN_HEAD_NO_SCRIPT:{ho(this,t);break}case A.AFTER_HEAD:{Eo(this,t);break}case A.IN_BODY:{Ou(this,t);break}case A.TEXT:{tA(this,t);break}case A.IN_TABLE:{wt(this,t);break}case A.IN_TABLE_TEXT:{Dt(this,t);break}case A.IN_CAPTION:{lA(this,t);break}case A.IN_COLUMN_GROUP:{fA(this,t);break}case A.IN_TABLE_BODY:{Z0(this,t);break}case A.IN_ROW:{$r(this,t);break}case A.IN_CELL:{bA(this,t);break}case A.IN_SELECT:{ts(this,t);break}case A.IN_SELECT_IN_TABLE:{mA(this,t);break}case A.IN_TEMPLATE:{pA(this,t);break}case A.AFTER_BODY:{as(this,t);break}case A.IN_FRAMESET:{CA(this,t);break}case A.AFTER_FRAMESET:{NA(this,t);break}case A.AFTER_AFTER_BODY:{Bu(this,t);break}default:}}onEof(t){switch(this.insertionMode){case A.INITIAL:{St(this,t);break}case A.BEFORE_HTML:{Bt(this,t);break}case A.BEFORE_HEAD:{Rt(this,t);break}case A.IN_HEAD:{Lt(this,t);break}case A.IN_HEAD_NO_SCRIPT:{yt(this,t);break}case A.AFTER_HEAD:{Ot(this,t);break}case A.IN_BODY:case A.IN_TABLE:case A.IN_CAPTION:case A.IN_COLUMN_GROUP:case A.IN_TABLE_BODY:case A.IN_ROW:case A.IN_CELL:case A.IN_SELECT:case A.IN_SELECT_IN_TABLE:{Zr(this,t);break}case A.TEXT:{uA(this,t);break}case A.IN_TABLE_TEXT:{Dt(this,t);break}case A.IN_TEMPLATE:{us(this,t);break}case A.AFTER_BODY:case A.IN_FRAMESET:case A.AFTER_FRAMESET:case A.AFTER_AFTER_BODY:case A.AFTER_AFTER_FRAMESET:{j0(this,t);break}default:}}onWhitespaceCharacter(t){if(this.skipNextNewLine&&(this.skipNextNewLine=!1,t.chars.charCodeAt(0)===c.LINE_FEED)){if(t.chars.length===1)return;t.chars=t.chars.substr(1)}if(this.tokenizer.inForeignNode){this._insertCharacters(t);return}switch(this.insertionMode){case A.IN_HEAD:case A.IN_HEAD_NO_SCRIPT:case A.AFTER_HEAD:case A.TEXT:case A.IN_COLUMN_GROUP:case A.IN_SELECT:case A.IN_SELECT_IN_TABLE:case A.IN_FRAMESET:case A.AFTER_FRAMESET:{this._insertCharacters(t);break}case A.IN_BODY:case A.IN_CAPTION:case A.IN_CELL:case A.IN_TEMPLATE:case A.AFTER_BODY:case A.AFTER_AFTER_BODY:case A.AFTER_AFTER_FRAMESET:{Kr(this,t);break}case A.IN_TABLE:case A.IN_TABLE_BODY:case A.IN_ROW:{J0(this,t);break}case A.IN_TABLE_TEXT:{Xr(this,t);break}default:}}};function zc(e,t){let u=e.activeFormattingElements.getElementEntryInScopeWithTagName(t.tagName);return u?e.openElements.contains(u.element)?e.openElements.hasInScope(t.tagID)||(u=null):(e.activeFormattingElements.removeEntry(u),u=null):Vr(e,t),u}function $c(e,t){let u=null,a=e.openElements.stackTop;for(;a>=0;a--){let s=e.openElements.items[a];if(s===t.element)break;e._isSpecialElement(s,e.openElements.tagIDs[a])&&(u=s)}return u||(e.openElements.shortenToLength(Math.max(a,0)),e.activeFormattingElements.removeEntry(t)),u}function eo(e,t,u){let a=t,s=e.openElements.getCommonAncestor(t);for(let i=0,n=s;n!==u;i++,n=s){s=e.openElements.getCommonAncestor(n);let d=e.activeFormattingElements.getElementEntry(n),l=d&&i>=Xc;!d||l?(l&&e.activeFormattingElements.removeEntry(d),e.openElements.remove(n)):(n=to(e,d),a===t&&(e.activeFormattingElements.bookmark=d),e.treeAdapter.detachNode(a),e.treeAdapter.appendChild(n,a),a=n)}return a}function to(e,t){let u=e.treeAdapter.getNamespaceURI(t.element),a=e.treeAdapter.createElement(t.token.tagName,u,t.token.attrs);return e.openElements.replace(t.element,a),t.element=a,a}function uo(e,t,u){let a=e.treeAdapter.getTagName(t),s=Be(a);if(e._isElementCausesFosterParenting(s))e._fosterParentElement(u);else{let i=e.treeAdapter.getNamespaceURI(t);s===r.TEMPLATE&&i===E.HTML&&(t=e.treeAdapter.getTemplateContent(t)),e.treeAdapter.appendChild(t,u)}}function ao(e,t,u){let a=e.treeAdapter.getNamespaceURI(u.element),{token:s}=u,i=e.treeAdapter.createElement(s.tagName,a,s.attrs);e._adoptNodes(t,i),e.treeAdapter.appendChild(t,i),e.activeFormattingElements.insertElementAfterBookmark(i,s),e.activeFormattingElements.removeEntry(u),e.openElements.remove(u.element),e.openElements.insertAfter(t,i,s.tagID)}function X0(e,t){for(let u=0;u=u;a--)e._setEndLocation(e.openElements.items[a],t);if(!e.fragmentContext&&e.openElements.stackTop>=0){let a=e.openElements.items[0],s=e.treeAdapter.getNodeSourceCodeLocation(a);if(s&&!s.endTag&&(e._setEndLocation(a,t),e.openElements.stackTop>=1)){let i=e.openElements.items[1],n=e.treeAdapter.getNodeSourceCodeLocation(i);n&&!n.endTag&&e._setEndLocation(i,t)}}}}function io(e,t){e._setDocumentType(t);let u=t.forceQuirks?G.QUIRKS:Fr(t);wr(t)||e._err(t,b.nonConformingDoctype),e.treeAdapter.setDocumentMode(e.document,u),e.insertionMode=A.BEFORE_HTML}function St(e,t){e._err(t,b.missingDoctype,!0),e.treeAdapter.setDocumentMode(e.document,G.QUIRKS),e.insertionMode=A.BEFORE_HTML,e._processToken(t)}function no(e,t){t.tagID===r.HTML?(e._insertElement(t,E.HTML),e.insertionMode=A.BEFORE_HEAD):Bt(e,t)}function co(e,t){let u=t.tagID;(u===r.HTML||u===r.HEAD||u===r.BODY||u===r.BR)&&Bt(e,t)}function Bt(e,t){e._insertFakeRootElement(),e.insertionMode=A.BEFORE_HEAD,e._processToken(t)}function oo(e,t){switch(t.tagID){case r.HTML:{W(e,t);break}case r.HEAD:{e._insertElement(t,E.HTML),e.headElement=e.openElements.current,e.insertionMode=A.IN_HEAD;break}default:Rt(e,t)}}function Ao(e,t){let u=t.tagID;u===r.HEAD||u===r.BODY||u===r.HTML||u===r.BR?Rt(e,t):e._err(t,b.endTagWithoutMatchingOpenElement)}function Rt(e,t){e._insertFakeElement(f.HEAD,r.HEAD),e.headElement=e.openElements.current,e.insertionMode=A.IN_HEAD,e._processToken(t)}function ce(e,t){switch(t.tagID){case r.HTML:{W(e,t);break}case r.BASE:case r.BASEFONT:case r.BGSOUND:case r.LINK:case r.META:{e._appendElement(t,E.HTML),t.ackSelfClosing=!0;break}case r.TITLE:{e._switchToTextParsing(t,K.RCDATA);break}case r.NOSCRIPT:{e.options.scriptingEnabled?e._switchToTextParsing(t,K.RAWTEXT):(e._insertElement(t,E.HTML),e.insertionMode=A.IN_HEAD_NO_SCRIPT);break}case r.NOFRAMES:case r.STYLE:{e._switchToTextParsing(t,K.RAWTEXT);break}case r.SCRIPT:{e._switchToTextParsing(t,K.SCRIPT_DATA);break}case r.TEMPLATE:{e._insertTemplate(t),e.activeFormattingElements.insertMarker(),e.framesetOk=!1,e.insertionMode=A.IN_TEMPLATE,e.tmplInsertionModeStack.unshift(A.IN_TEMPLATE);break}case r.HEAD:{e._err(t,b.misplacedStartTagForHeadElement);break}default:Lt(e,t)}}function lo(e,t){switch(t.tagID){case r.HEAD:{e.openElements.pop(),e.insertionMode=A.AFTER_HEAD;break}case r.BODY:case r.BR:case r.HTML:{Lt(e,t);break}case r.TEMPLATE:{Qe(e,t);break}default:e._err(t,b.endTagWithoutMatchingOpenElement)}}function Qe(e,t){e.openElements.tmplCount>0?(e.openElements.generateImpliedEndTagsThoroughly(),e.openElements.currentTagId!==r.TEMPLATE&&e._err(t,b.closingOfElementWithOpenChildElements),e.openElements.popUntilTagNamePopped(r.TEMPLATE),e.activeFormattingElements.clearToLastMarker(),e.tmplInsertionModeStack.shift(),e._resetInsertionMode()):e._err(t,b.endTagWithoutMatchingOpenElement)}function Lt(e,t){e.openElements.pop(),e.insertionMode=A.AFTER_HEAD,e._processToken(t)}function fo(e,t){switch(t.tagID){case r.HTML:{W(e,t);break}case r.BASEFONT:case r.BGSOUND:case r.HEAD:case r.LINK:case r.META:case r.NOFRAMES:case r.STYLE:{ce(e,t);break}case r.NOSCRIPT:{e._err(t,b.nestedNoscriptInHead);break}default:yt(e,t)}}function ho(e,t){switch(t.tagID){case r.NOSCRIPT:{e.openElements.pop(),e.insertionMode=A.IN_HEAD;break}case r.BR:{yt(e,t);break}default:e._err(t,b.endTagWithoutMatchingOpenElement)}}function yt(e,t){let u=t.type===B.EOF?b.openElementsLeftAfterEof:b.disallowedContentInNoscriptInHead;e._err(t,u),e.openElements.pop(),e.insertionMode=A.IN_HEAD,e._processToken(t)}function bo(e,t){switch(t.tagID){case r.HTML:{W(e,t);break}case r.BODY:{e._insertElement(t,E.HTML),e.framesetOk=!1,e.insertionMode=A.IN_BODY;break}case r.FRAMESET:{e._insertElement(t,E.HTML),e.insertionMode=A.IN_FRAMESET;break}case r.BASE:case r.BASEFONT:case r.BGSOUND:case r.LINK:case r.META:case r.NOFRAMES:case r.SCRIPT:case r.STYLE:case r.TEMPLATE:case r.TITLE:{e._err(t,b.abandonedHeadElementChild),e.openElements.push(e.headElement,r.HEAD),ce(e,t),e.openElements.remove(e.headElement);break}case r.HEAD:{e._err(t,b.misplacedStartTagForHeadElement);break}default:Ot(e,t)}}function Eo(e,t){switch(t.tagID){case r.BODY:case r.HTML:case r.BR:{Ot(e,t);break}case r.TEMPLATE:{Qe(e,t);break}default:e._err(t,b.endTagWithoutMatchingOpenElement)}}function Ot(e,t){e._insertFakeElement(f.BODY,r.BODY),e.insertionMode=A.IN_BODY,yu(e,t)}function yu(e,t){switch(t.type){case B.CHARACTER:{Wr(e,t);break}case B.WHITESPACE_CHARACTER:{Kr(e,t);break}case B.COMMENT:{V0(e,t);break}case B.START_TAG:{W(e,t);break}case B.END_TAG:{Ou(e,t);break}case B.EOF:{Zr(e,t);break}default:}}function Kr(e,t){e._reconstructActiveFormattingElements(),e._insertCharacters(t)}function Wr(e,t){e._reconstructActiveFormattingElements(),e._insertCharacters(t),e.framesetOk=!1}function mo(e,t){e.openElements.tmplCount===0&&e.treeAdapter.adoptAttributes(e.openElements.items[0],t.attrs)}function go(e,t){let u=e.openElements.tryPeekProperlyNestedBodyElement();u&&e.openElements.tmplCount===0&&(e.framesetOk=!1,e.treeAdapter.adoptAttributes(u,t.attrs))}function po(e,t){let u=e.openElements.tryPeekProperlyNestedBodyElement();e.framesetOk&&u&&(e.treeAdapter.detachNode(u),e.openElements.popAllUpToHtmlElement(),e._insertElement(t,E.HTML),e.insertionMode=A.IN_FRAMESET)}function To(e,t){e.openElements.hasInButtonScope(r.P)&&e._closePElement(),e._insertElement(t,E.HTML)}function Io(e,t){e.openElements.hasInButtonScope(r.P)&&e._closePElement(),e.openElements.currentTagId!==void 0&&ut.has(e.openElements.currentTagId)&&e.openElements.pop(),e._insertElement(t,E.HTML)}function Co(e,t){e.openElements.hasInButtonScope(r.P)&&e._closePElement(),e._insertElement(t,E.HTML),e.skipNextNewLine=!0,e.framesetOk=!1}function xo(e,t){let u=e.openElements.tmplCount>0;(!e.formElement||u)&&(e.openElements.hasInButtonScope(r.P)&&e._closePElement(),e._insertElement(t,E.HTML),u||(e.formElement=e.openElements.current))}function No(e,t){e.framesetOk=!1;let u=t.tagID;for(let a=e.openElements.stackTop;a>=0;a--){let s=e.openElements.tagIDs[a];if(u===r.LI&&s===r.LI||(u===r.DD||u===r.DT)&&(s===r.DD||s===r.DT)){e.openElements.generateImpliedEndTagsWithExclusion(s),e.openElements.popUntilTagNamePopped(s);break}if(s!==r.ADDRESS&&s!==r.DIV&&s!==r.P&&e._isSpecialElement(e.openElements.items[a],s))break}e.openElements.hasInButtonScope(r.P)&&e._closePElement(),e._insertElement(t,E.HTML)}function _o(e,t){e.openElements.hasInButtonScope(r.P)&&e._closePElement(),e._insertElement(t,E.HTML),e.tokenizer.state=K.PLAINTEXT}function So(e,t){e.openElements.hasInScope(r.BUTTON)&&(e.openElements.generateImpliedEndTags(),e.openElements.popUntilTagNamePopped(r.BUTTON)),e._reconstructActiveFormattingElements(),e._insertElement(t,E.HTML),e.framesetOk=!1}function Do(e,t){let u=e.activeFormattingElements.getElementEntryInScopeWithTagName(f.A);u&&(X0(e,t),e.openElements.remove(u.element),e.activeFormattingElements.removeEntry(u)),e._reconstructActiveFormattingElements(),e._insertElement(t,E.HTML),e.activeFormattingElements.pushElement(e.openElements.current,t)}function Bo(e,t){e._reconstructActiveFormattingElements(),e._insertElement(t,E.HTML),e.activeFormattingElements.pushElement(e.openElements.current,t)}function Ro(e,t){e._reconstructActiveFormattingElements(),e.openElements.hasInScope(r.NOBR)&&(X0(e,t),e._reconstructActiveFormattingElements()),e._insertElement(t,E.HTML),e.activeFormattingElements.pushElement(e.openElements.current,t)}function Lo(e,t){e._reconstructActiveFormattingElements(),e._insertElement(t,E.HTML),e.activeFormattingElements.insertMarker(),e.framesetOk=!1}function yo(e,t){e.treeAdapter.getDocumentMode(e.document)!==G.QUIRKS&&e.openElements.hasInButtonScope(r.P)&&e._closePElement(),e._insertElement(t,E.HTML),e.framesetOk=!1,e.insertionMode=A.IN_TABLE}function qr(e,t){e._reconstructActiveFormattingElements(),e._appendElement(t,E.HTML),e.framesetOk=!1,t.ackSelfClosing=!0}function Jr(e){let t=pu(e,de.TYPE);return t!=null&&t.toLowerCase()===Vc}function Oo(e,t){e._reconstructActiveFormattingElements(),e._appendElement(t,E.HTML),Jr(t)||(e.framesetOk=!1),t.ackSelfClosing=!0}function wo(e,t){e._appendElement(t,E.HTML),t.ackSelfClosing=!0}function Fo(e,t){e.openElements.hasInButtonScope(r.P)&&e._closePElement(),e._appendElement(t,E.HTML),e.framesetOk=!1,t.ackSelfClosing=!0}function Mo(e,t){t.tagName=f.IMG,t.tagID=r.IMG,qr(e,t)}function ko(e,t){e._insertElement(t,E.HTML),e.skipNextNewLine=!0,e.tokenizer.state=K.RCDATA,e.originalInsertionMode=e.insertionMode,e.framesetOk=!1,e.insertionMode=A.TEXT}function Po(e,t){e.openElements.hasInButtonScope(r.P)&&e._closePElement(),e._reconstructActiveFormattingElements(),e.framesetOk=!1,e._switchToTextParsing(t,K.RAWTEXT)}function Ho(e,t){e.framesetOk=!1,e._switchToTextParsing(t,K.RAWTEXT)}function Qr(e,t){e._switchToTextParsing(t,K.RAWTEXT)}function Uo(e,t){e._reconstructActiveFormattingElements(),e._insertElement(t,E.HTML),e.framesetOk=!1,e.insertionMode=e.insertionMode===A.IN_TABLE||e.insertionMode===A.IN_CAPTION||e.insertionMode===A.IN_TABLE_BODY||e.insertionMode===A.IN_ROW||e.insertionMode===A.IN_CELL?A.IN_SELECT_IN_TABLE:A.IN_SELECT}function vo(e,t){e.openElements.currentTagId===r.OPTION&&e.openElements.pop(),e._reconstructActiveFormattingElements(),e._insertElement(t,E.HTML)}function Qo(e,t){e.openElements.hasInScope(r.RUBY)&&e.openElements.generateImpliedEndTags(),e._insertElement(t,E.HTML)}function Yo(e,t){e.openElements.hasInScope(r.RUBY)&&e.openElements.generateImpliedEndTagsWithExclusion(r.RTC),e._insertElement(t,E.HTML)}function Go(e,t){e._reconstructActiveFormattingElements(),W0(t),Du(t),t.selfClosing?e._appendElement(t,E.MATHML):e._insertElement(t,E.MATHML),t.ackSelfClosing=!0}function Ko(e,t){e._reconstructActiveFormattingElements(),q0(t),Du(t),t.selfClosing?e._appendElement(t,E.SVG):e._insertElement(t,E.SVG),t.ackSelfClosing=!0}function Yr(e,t){e._reconstructActiveFormattingElements(),e._insertElement(t,E.HTML)}function W(e,t){switch(t.tagID){case r.I:case r.S:case r.B:case r.U:case r.EM:case r.TT:case r.BIG:case r.CODE:case r.FONT:case r.SMALL:case r.STRIKE:case r.STRONG:{Bo(e,t);break}case r.A:{Do(e,t);break}case r.H1:case r.H2:case r.H3:case r.H4:case r.H5:case r.H6:{Io(e,t);break}case r.P:case r.DL:case r.OL:case r.UL:case r.DIV:case r.DIR:case r.NAV:case r.MAIN:case r.MENU:case r.ASIDE:case r.CENTER:case r.FIGURE:case r.FOOTER:case r.HEADER:case r.HGROUP:case r.DIALOG:case r.DETAILS:case r.ADDRESS:case r.ARTICLE:case r.SEARCH:case r.SECTION:case r.SUMMARY:case r.FIELDSET:case r.BLOCKQUOTE:case r.FIGCAPTION:{To(e,t);break}case r.LI:case r.DD:case r.DT:{No(e,t);break}case r.BR:case r.IMG:case r.WBR:case r.AREA:case r.EMBED:case r.KEYGEN:{qr(e,t);break}case r.HR:{Fo(e,t);break}case r.RB:case r.RTC:{Qo(e,t);break}case r.RT:case r.RP:{Yo(e,t);break}case r.PRE:case r.LISTING:{Co(e,t);break}case r.XMP:{Po(e,t);break}case r.SVG:{Ko(e,t);break}case r.HTML:{mo(e,t);break}case r.BASE:case r.LINK:case r.META:case r.STYLE:case r.TITLE:case r.SCRIPT:case r.BGSOUND:case r.BASEFONT:case r.TEMPLATE:{ce(e,t);break}case r.BODY:{go(e,t);break}case r.FORM:{xo(e,t);break}case r.NOBR:{Ro(e,t);break}case r.MATH:{Go(e,t);break}case r.TABLE:{yo(e,t);break}case r.INPUT:{Oo(e,t);break}case r.PARAM:case r.TRACK:case r.SOURCE:{wo(e,t);break}case r.IMAGE:{Mo(e,t);break}case r.BUTTON:{So(e,t);break}case r.APPLET:case r.OBJECT:case r.MARQUEE:{Lo(e,t);break}case r.IFRAME:{Ho(e,t);break}case r.SELECT:{Uo(e,t);break}case r.OPTION:case r.OPTGROUP:{vo(e,t);break}case r.NOEMBED:case r.NOFRAMES:{Qr(e,t);break}case r.FRAMESET:{po(e,t);break}case r.TEXTAREA:{ko(e,t);break}case r.NOSCRIPT:{e.options.scriptingEnabled?Qr(e,t):Yr(e,t);break}case r.PLAINTEXT:{_o(e,t);break}case r.COL:case r.TH:case r.TD:case r.TR:case r.HEAD:case r.FRAME:case r.TBODY:case r.TFOOT:case r.THEAD:case r.CAPTION:case r.COLGROUP:break;default:Yr(e,t)}}function Wo(e,t){if(e.openElements.hasInScope(r.BODY)&&(e.insertionMode=A.AFTER_BODY,e.options.sourceCodeLocationInfo)){let u=e.openElements.tryPeekProperlyNestedBodyElement();u&&e._setEndLocation(u,t)}}function qo(e,t){e.openElements.hasInScope(r.BODY)&&(e.insertionMode=A.AFTER_BODY,as(e,t))}function Jo(e,t){let u=t.tagID;e.openElements.hasInScope(u)&&(e.openElements.generateImpliedEndTags(),e.openElements.popUntilTagNamePopped(u))}function Vo(e){let t=e.openElements.tmplCount>0,{formElement:u}=e;t||(e.formElement=null),(u||t)&&e.openElements.hasInScope(r.FORM)&&(e.openElements.generateImpliedEndTags(),t?e.openElements.popUntilTagNamePopped(r.FORM):u&&e.openElements.remove(u))}function Zo(e){e.openElements.hasInButtonScope(r.P)||e._insertFakeElement(f.P,r.P),e._closePElement()}function Xo(e){e.openElements.hasInListItemScope(r.LI)&&(e.openElements.generateImpliedEndTagsWithExclusion(r.LI),e.openElements.popUntilTagNamePopped(r.LI))}function jo(e,t){let u=t.tagID;e.openElements.hasInScope(u)&&(e.openElements.generateImpliedEndTagsWithExclusion(u),e.openElements.popUntilTagNamePopped(u))}function zo(e){e.openElements.hasNumberedHeaderInScope()&&(e.openElements.generateImpliedEndTags(),e.openElements.popUntilNumberedHeaderPopped())}function $o(e,t){let u=t.tagID;e.openElements.hasInScope(u)&&(e.openElements.generateImpliedEndTags(),e.openElements.popUntilTagNamePopped(u),e.activeFormattingElements.clearToLastMarker())}function eA(e){e._reconstructActiveFormattingElements(),e._insertFakeElement(f.BR,r.BR),e.openElements.pop(),e.framesetOk=!1}function Vr(e,t){let u=t.tagName,a=t.tagID;for(let s=e.openElements.stackTop;s>0;s--){let i=e.openElements.items[s],n=e.openElements.tagIDs[s];if(a===n&&(a!==r.UNKNOWN||e.treeAdapter.getTagName(i)===u)){e.openElements.generateImpliedEndTagsWithExclusion(a),e.openElements.stackTop>=s&&e.openElements.shortenToLength(s);break}if(e._isSpecialElement(i,n))break}}function Ou(e,t){switch(t.tagID){case r.A:case r.B:case r.I:case r.S:case r.U:case r.EM:case r.TT:case r.BIG:case r.CODE:case r.FONT:case r.NOBR:case r.SMALL:case r.STRIKE:case r.STRONG:{X0(e,t);break}case r.P:{Zo(e);break}case r.DL:case r.UL:case r.OL:case r.DIR:case r.DIV:case r.NAV:case r.PRE:case r.MAIN:case r.MENU:case r.ASIDE:case r.BUTTON:case r.CENTER:case r.FIGURE:case r.FOOTER:case r.HEADER:case r.HGROUP:case r.DIALOG:case r.ADDRESS:case r.ARTICLE:case r.DETAILS:case r.SEARCH:case r.SECTION:case r.SUMMARY:case r.LISTING:case r.FIELDSET:case r.BLOCKQUOTE:case r.FIGCAPTION:{Jo(e,t);break}case r.LI:{Xo(e);break}case r.DD:case r.DT:{jo(e,t);break}case r.H1:case r.H2:case r.H3:case r.H4:case r.H5:case r.H6:{zo(e);break}case r.BR:{eA(e);break}case r.BODY:{Wo(e,t);break}case r.HTML:{qo(e,t);break}case r.FORM:{Vo(e);break}case r.APPLET:case r.OBJECT:case r.MARQUEE:{$o(e,t);break}case r.TEMPLATE:{Qe(e,t);break}default:Vr(e,t)}}function Zr(e,t){e.tmplInsertionModeStack.length>0?us(e,t):j0(e,t)}function tA(e,t){var u;t.tagID===r.SCRIPT&&((u=e.scriptHandler)===null||u===void 0||u.call(e,e.openElements.current)),e.openElements.pop(),e.insertionMode=e.originalInsertionMode}function uA(e,t){e._err(t,b.eofInElementThatCanContainOnlyText),e.openElements.pop(),e.insertionMode=e.originalInsertionMode,e.onEof(t)}function J0(e,t){if(e.openElements.currentTagId!==void 0&&Gr.has(e.openElements.currentTagId))switch(e.pendingCharacterTokens.length=0,e.hasNonWhitespacePendingCharacterToken=!1,e.originalInsertionMode=e.insertionMode,e.insertionMode=A.IN_TABLE_TEXT,t.type){case B.CHARACTER:{jr(e,t);break}case B.WHITESPACE_CHARACTER:{Xr(e,t);break}}else Ft(e,t)}function aA(e,t){e.openElements.clearBackToTableContext(),e.activeFormattingElements.insertMarker(),e._insertElement(t,E.HTML),e.insertionMode=A.IN_CAPTION}function rA(e,t){e.openElements.clearBackToTableContext(),e._insertElement(t,E.HTML),e.insertionMode=A.IN_COLUMN_GROUP}function sA(e,t){e.openElements.clearBackToTableContext(),e._insertFakeElement(f.COLGROUP,r.COLGROUP),e.insertionMode=A.IN_COLUMN_GROUP,z0(e,t)}function iA(e,t){e.openElements.clearBackToTableContext(),e._insertElement(t,E.HTML),e.insertionMode=A.IN_TABLE_BODY}function nA(e,t){e.openElements.clearBackToTableContext(),e._insertFakeElement(f.TBODY,r.TBODY),e.insertionMode=A.IN_TABLE_BODY,wu(e,t)}function cA(e,t){e.openElements.hasInTableScope(r.TABLE)&&(e.openElements.popUntilTagNamePopped(r.TABLE),e._resetInsertionMode(),e._processStartTag(t))}function oA(e,t){Jr(t)?e._appendElement(t,E.HTML):Ft(e,t),t.ackSelfClosing=!0}function AA(e,t){!e.formElement&&e.openElements.tmplCount===0&&(e._insertElement(t,E.HTML),e.formElement=e.openElements.current,e.openElements.pop())}function rt(e,t){switch(t.tagID){case r.TD:case r.TH:case r.TR:{nA(e,t);break}case r.STYLE:case r.SCRIPT:case r.TEMPLATE:{ce(e,t);break}case r.COL:{sA(e,t);break}case r.FORM:{AA(e,t);break}case r.TABLE:{cA(e,t);break}case r.TBODY:case r.TFOOT:case r.THEAD:{iA(e,t);break}case r.INPUT:{oA(e,t);break}case r.CAPTION:{aA(e,t);break}case r.COLGROUP:{rA(e,t);break}default:Ft(e,t)}}function wt(e,t){switch(t.tagID){case r.TABLE:{e.openElements.hasInTableScope(r.TABLE)&&(e.openElements.popUntilTagNamePopped(r.TABLE),e._resetInsertionMode());break}case r.TEMPLATE:{Qe(e,t);break}case r.BODY:case r.CAPTION:case r.COL:case r.COLGROUP:case r.HTML:case r.TBODY:case r.TD:case r.TFOOT:case r.TH:case r.THEAD:case r.TR:break;default:Ft(e,t)}}function Ft(e,t){let u=e.fosterParentingEnabled;e.fosterParentingEnabled=!0,yu(e,t),e.fosterParentingEnabled=u}function Xr(e,t){e.pendingCharacterTokens.push(t)}function jr(e,t){e.pendingCharacterTokens.push(t),e.hasNonWhitespacePendingCharacterToken=!0}function Dt(e,t){let u=0;if(e.hasNonWhitespacePendingCharacterToken)for(;u0&&e.openElements.currentTagId===r.OPTION&&e.openElements.tagIDs[e.openElements.stackTop-1]===r.OPTGROUP&&e.openElements.pop(),e.openElements.currentTagId===r.OPTGROUP&&e.openElements.pop();break}case r.OPTION:{e.openElements.currentTagId===r.OPTION&&e.openElements.pop();break}case r.SELECT:{e.openElements.hasInSelectScope(r.SELECT)&&(e.openElements.popUntilTagNamePopped(r.SELECT),e._resetInsertionMode());break}case r.TEMPLATE:{Qe(e,t);break}default:}}function EA(e,t){let u=t.tagID;u===r.CAPTION||u===r.TABLE||u===r.TBODY||u===r.TFOOT||u===r.THEAD||u===r.TR||u===r.TD||u===r.TH?(e.openElements.popUntilTagNamePopped(r.SELECT),e._resetInsertionMode(),e._processStartTag(t)):es(e,t)}function mA(e,t){let u=t.tagID;u===r.CAPTION||u===r.TABLE||u===r.TBODY||u===r.TFOOT||u===r.THEAD||u===r.TR||u===r.TD||u===r.TH?e.openElements.hasInTableScope(u)&&(e.openElements.popUntilTagNamePopped(r.SELECT),e._resetInsertionMode(),e.onEndTag(t)):ts(e,t)}function gA(e,t){switch(t.tagID){case r.BASE:case r.BASEFONT:case r.BGSOUND:case r.LINK:case r.META:case r.NOFRAMES:case r.SCRIPT:case r.STYLE:case r.TEMPLATE:case r.TITLE:{ce(e,t);break}case r.CAPTION:case r.COLGROUP:case r.TBODY:case r.TFOOT:case r.THEAD:{e.tmplInsertionModeStack[0]=A.IN_TABLE,e.insertionMode=A.IN_TABLE,rt(e,t);break}case r.COL:{e.tmplInsertionModeStack[0]=A.IN_COLUMN_GROUP,e.insertionMode=A.IN_COLUMN_GROUP,z0(e,t);break}case r.TR:{e.tmplInsertionModeStack[0]=A.IN_TABLE_BODY,e.insertionMode=A.IN_TABLE_BODY,wu(e,t);break}case r.TD:case r.TH:{e.tmplInsertionModeStack[0]=A.IN_ROW,e.insertionMode=A.IN_ROW,Fu(e,t);break}default:e.tmplInsertionModeStack[0]=A.IN_BODY,e.insertionMode=A.IN_BODY,W(e,t)}}function pA(e,t){t.tagID===r.TEMPLATE&&Qe(e,t)}function us(e,t){e.openElements.tmplCount>0?(e.openElements.popUntilTagNamePopped(r.TEMPLATE),e.activeFormattingElements.clearToLastMarker(),e.tmplInsertionModeStack.shift(),e._resetInsertionMode(),e.onEof(t)):j0(e,t)}function TA(e,t){t.tagID===r.HTML?W(e,t):Lu(e,t)}function as(e,t){var u;if(t.tagID===r.HTML){if(e.fragmentContext||(e.insertionMode=A.AFTER_AFTER_BODY),e.options.sourceCodeLocationInfo&&e.openElements.tagIDs[0]===r.HTML){e._setEndLocation(e.openElements.items[0],t);let a=e.openElements.items[1];a&&!(!((u=e.treeAdapter.getNodeSourceCodeLocation(a))===null||u===void 0)&&u.endTag)&&e._setEndLocation(a,t)}}else Lu(e,t)}function Lu(e,t){e.insertionMode=A.IN_BODY,yu(e,t)}function IA(e,t){switch(t.tagID){case r.HTML:{W(e,t);break}case r.FRAMESET:{e._insertElement(t,E.HTML);break}case r.FRAME:{e._appendElement(t,E.HTML),t.ackSelfClosing=!0;break}case r.NOFRAMES:{ce(e,t);break}default:}}function CA(e,t){t.tagID===r.FRAMESET&&!e.openElements.isRootHtmlElementCurrent()&&(e.openElements.pop(),!e.fragmentContext&&e.openElements.currentTagId!==r.FRAMESET&&(e.insertionMode=A.AFTER_FRAMESET))}function xA(e,t){switch(t.tagID){case r.HTML:{W(e,t);break}case r.NOFRAMES:{ce(e,t);break}default:}}function NA(e,t){t.tagID===r.HTML&&(e.insertionMode=A.AFTER_AFTER_FRAMESET)}function _A(e,t){t.tagID===r.HTML?W(e,t):Bu(e,t)}function Bu(e,t){e.insertionMode=A.IN_BODY,yu(e,t)}function SA(e,t){switch(t.tagID){case r.HTML:{W(e,t);break}case r.NOFRAMES:{ce(e,t);break}default:}}function DA(e,t){t.chars=O,e._insertCharacters(t)}function BA(e,t){e._insertCharacters(t),e.framesetOk=!1}function rs(e){for(;e.treeAdapter.getNamespaceURI(e.openElements.current)!==E.HTML&&e.openElements.currentTagId!==void 0&&!e._isIntegrationPoint(e.openElements.currentTagId,e.openElements.current);)e.openElements.pop()}function RA(e,t){if(kr(t))rs(e),e._startTagOutsideForeignContent(t);else{let u=e._getAdjustedCurrentElement(),a=e.treeAdapter.getNamespaceURI(u);a===E.MATHML?W0(t):a===E.SVG&&(Pr(t),q0(t)),Du(t),t.selfClosing?e._appendElement(t,a):e._insertElement(t,a),t.ackSelfClosing=!0}}function LA(e,t){if(t.tagID===r.P||t.tagID===r.BR){rs(e),e._endTagOutsideForeignContent(t);return}for(let u=e.openElements.stackTop;u>0;u--){let a=e.openElements.items[u];if(e.treeAdapter.getNamespaceURI(a)===E.HTML){e._endTagOutsideForeignContent(t);break}let s=e.treeAdapter.getTagName(a);if(s.toLowerCase()===t.tagName){t.tagName=s,e.openElements.shortenToLength(u);break}}}var vf=String.prototype.codePointAt==null?(e,t)=>(e.charCodeAt(t)&64512)===55296?(e.charCodeAt(t)-55296)*1024+e.charCodeAt(t+1)-56320+65536:e.charCodeAt(t):(e,t)=>e.codePointAt(t);function ss(e,t){return function(a){let s,i=0,n="";for(;s=e.exec(a);)i!==s.index&&(n+=a.substring(i,s.index)),n+=t.get(s[0].charCodeAt(0)),i=s.index+1;return n+a.substring(i)}}var is=ss(/["&\u00A0]/g,new Map([[34,"""],[38,"&"],[160," "]])),ns=ss(/[&<>\u00A0]/g,new Map([[38,"&"],[60,"<"],[62,">"],[160," "]]));var yA=new Set([f.AREA,f.BASE,f.BASEFONT,f.BGSOUND,f.BR,f.COL,f.EMBED,f.FRAME,f.HR,f.IMG,f.INPUT,f.KEYGEN,f.LINK,f.META,f.PARAM,f.SOURCE,f.TRACK,f.WBR]);function OA(e,t){return t.treeAdapter.isElementNode(e)&&t.treeAdapter.getNamespaceURI(e)===E.HTML&&yA.has(t.treeAdapter.getTagName(e))}var wA={treeAdapter:ae,scriptingEnabled:!0};function $0(e,t){let u={...wA,...t};return cs(e,u)}function FA(e,t){let u="",a=t.treeAdapter.isElementNode(e)&&t.treeAdapter.getTagName(e)===f.TEMPLATE&&t.treeAdapter.getNamespaceURI(e)===E.HTML?t.treeAdapter.getTemplateContent(e):e,s=t.treeAdapter.getChildNodes(a);if(s)for(let i of s)u+=cs(i,t);return u}function cs(e,t){return t.treeAdapter.isElementNode(e)?MA(e,t):t.treeAdapter.isTextNode(e)?PA(e,t):t.treeAdapter.isCommentNode(e)?HA(e,t):t.treeAdapter.isDocumentTypeNode(e)?UA(e,t):""}function MA(e,t){let u=t.treeAdapter.getTagName(e);return`<${u}${kA(e,t)}>${OA(e,t)?"":`${FA(e,t)}`}`}function kA(e,{treeAdapter:t}){let u="";for(let a of t.getAttrList(e)){if(u+=" ",a.namespace)switch(a.namespace){case E.XML:{u+=`xml:${a.name}`;break}case E.XMLNS:{a.name!=="xmlns"&&(u+="xmlns:"),u+=a.name;break}case E.XLINK:{u+=`xlink:${a.name}`;break}default:u+=`${a.prefix}:${a.name}`}else u+=a.name;u+=`="${is(a.value)}"`}return u}function PA(e,t){let{treeAdapter:u}=t,a=u.getTextNodeContent(e),s=u.getParentNode(e),i=s&&u.isElementNode(s)&&u.getTagName(s);return i&&u.getNamespaceURI(s)===E.HTML&&K0(i,t.scriptingEnabled)?a:ns(a)}function HA(e,{treeAdapter:t}){return``}function UA(e,{treeAdapter:t}){return``}function os(e,t){return at.parse(e,t)}function As(e,t,u){typeof e=="string"&&(u=t,t=e,e=null);let a=at.getFragmentParser(e,u);return a.tokenizer.write(t,!0),a.getFragment()}function ds(e){let t=e.includes('"')?"'":'"';return t+e+t}function vA(e,t,u){let a="!DOCTYPE ";return e&&(a+=e),t?a+=` PUBLIC ${ds(t)}`:u&&(a+=" SYSTEM"),u&&(a+=` ${ds(u)}`),a}var ge={isCommentNode:Me,isElementNode:C,isTextNode:J,createDocument(){let e=new X([]);return e["x-mode"]=Cu.DOCUMENT_MODE.NO_QUIRKS,e},createDocumentFragment(){return new X([])},createElement(e,t,u){let a=Object.create(null),s=Object.create(null),i=Object.create(null);for(let d=0;dHt(n)&&n.name==="!doctype");i?i.data=s??null:(i=new we("!doctype",s),ge.appendChild(e,i)),i["x-name"]=t,i["x-publicId"]=u,i["x-systemId"]=a},setDocumentMode(e,t){e["x-mode"]=t},getDocumentMode(e){return e["x-mode"]},detachNode(e){if(e.parent){let t=e.parent.children.indexOf(e),{prev:u,next:a}=e;e.prev=null,e.next=null,u&&(u.next=a),a&&(a.prev=u),e.parent.children.splice(t,1),e.parent=null}},insertText(e,t){let u=e.children[e.children.length-1];u&&J(u)?u.data+=t:ge.appendChild(e,ge.createTextNode(t))},insertTextBefore(e,t,u){let a=e.children[e.children.indexOf(u)-1];a&&J(a)?a.data+=t:ge.insertBefore(e,ge.createTextNode(t),u)},adoptAttributes(e,t){for(let u=0;ut._useHtmlParser2?Oa(e,t):ls(e,t,u,a)),hs=mr(YA,(e,t)=>t._useHtmlParser2?Gt(e,t):fs(e));return Is(GA);})(); diff --git a/cargokit_options.yaml b/cargokit_options.yaml new file mode 100644 index 0000000..9b35a0d --- /dev/null +++ b/cargokit_options.yaml @@ -0,0 +1,5 @@ +# fjs(脚本组件 JS 引擎)的原生 Rust 库 libfjs 经 cargokit 构建。 +# 本机装有 rustup 时 cargokit 默认从源码编译——但 libfjs 的依赖链要求较新的 +# rustc(1.88 已不满足)。这里显式改用 fjs 官方 GitHub Release 的签名预编译 +# 产物(公钥见 fjs 包内 libfjs/cargokit.yaml),构建应用无需 Rust 工具链。 +use_precompiled_binaries: true diff --git a/installer/lang/ChineseSimplified.isl b/installer/lang/ChineseSimplified.isl new file mode 100644 index 0000000..d6a11c4 --- /dev/null +++ b/installer/lang/ChineseSimplified.isl @@ -0,0 +1,418 @@ +; *** Inno Setup version 6.5.0+ Chinese Simplified messages *** +; +; To download user-contributed translations of this file, go to: +; https://jrsoftware.org/files/istrans/ +; +; Note: When translating this text, do not add periods (.) to the end of +; messages that didn't have them already, because on those messages Inno +; Setup adds the periods automatically (appending a period would result in +; two periods being displayed). +; +; Maintained by Zhenghan Yang +; Email: 847320916@QQ.com +; Translation based on network resource +; The latest Translation is on https://github.com/kira-96/Inno-Setup-Chinese-Simplified-Translation +; + +[LangOptions] +; The following three entries are very important. Be sure to read and +; understand the '[LangOptions] section' topic in the help file. +LanguageName=简体中文 +; If Language Name display incorrect, uncomment next line +; LanguageName=<7B80><4F53><4E2D><6587> +; About LanguageID, to reference link: +; https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-lcid/a9eac961-e77d-41a6-90a5-ce1a8b0cdb9c +LanguageID=$0804 +; About CodePage, to reference link: +; https://docs.microsoft.com/en-us/windows/win32/intl/code-page-identifiers +LanguageCodePage=936 +; If the language you are translating to requires special font faces or +; sizes, uncomment any of the following entries and change them accordingly. +;DialogFontName= +;DialogFontSize=9 +;DialogFontBaseScaleWidth=7 +;DialogFontBaseScaleHeight=15 +;WelcomeFontName=Segoe UI +;WelcomeFontSize=14 + +[Messages] + +; *** 应用程序标题 +SetupAppTitle=安装 +SetupWindowTitle=安装 - %1 +UninstallAppTitle=卸载 +UninstallAppFullTitle=%1 卸载 + +; *** Misc. common +InformationTitle=信息 +ConfirmTitle=确认 +ErrorTitle=错误 + +; *** SetupLdr messages +SetupLdrStartupMessage=现在将安装 %1。您想要继续吗? +LdrCannotCreateTemp=无法创建临时文件。安装程序已中止 +LdrCannotExecTemp=无法执行临时目录中的文件。安装程序已中止 +HelpTextNote= + +; *** 启动错误消息 +LastErrorMessage=%1。%n%n错误 %2: %3 +SetupFileMissing=安装目录中缺少文件 %1。请修正这个问题或者获取程序的新副本。 +SetupFileCorrupt=安装文件已损坏。请获取程序的新副本。 +SetupFileCorruptOrWrongVer=安装文件已损坏,或是与这个安装程序的版本不兼容。请修正这个问题或获取新的程序副本。 +InvalidParameter=无效的命令行参数:%n%n%1 +SetupAlreadyRunning=安装程序正在运行。 +WindowsVersionNotSupported=此程序不支持当前计算机运行的 Windows 版本。 +WindowsServicePackRequired=此程序需要 %1 服务包 %2 或更高版本。 +NotOnThisPlatform=此程序不能在 %1 上运行。 +OnlyOnThisPlatform=此程序只能在 %1 上运行。 +OnlyOnTheseArchitectures=此程序只能安装到为下列处理器架构设计的 Windows 版本中:%n%n%1 +WinVersionTooLowError=此程序需要 %1 版本 %2 或更高。 +WinVersionTooHighError=此程序不能安装于 %1 版本 %2 或更高。 +AdminPrivilegesRequired=在安装此程序时您必须以管理员身份登录。 +PowerUserPrivilegesRequired=在安装此程序时您必须以管理员身份或有权限的用户组身份登录。 +SetupAppRunningError=安装程序发现 %1 当前正在运行。%n%n请先关闭正在运行的程序,然后点击“确定”继续,或点击“取消”退出。 +UninstallAppRunningError=卸载程序发现 %1 当前正在运行。%n%n请先关闭正在运行的程序,然后点击“确定”继续,或点击“取消”退出。 + +; *** 启动问题 +PrivilegesRequiredOverrideTitle=选择安装程序模式 +PrivilegesRequiredOverrideInstruction=选择安装模式 +PrivilegesRequiredOverrideText1=%1 可以为所有用户安装(需要管理员权限),或仅为您安装。 +PrivilegesRequiredOverrideText2=%1 可以仅为您安装,或为所有用户安装(需要管理员权限)。 +PrivilegesRequiredOverrideAllUsers=为所有用户安装(&A) +PrivilegesRequiredOverrideAllUsersRecommended=为所有用户安装(&A) (建议选项) +PrivilegesRequiredOverrideCurrentUser=仅为我安装(&M) +PrivilegesRequiredOverrideCurrentUserRecommended=仅为我安装(&M) (建议选项) + +; *** 其他错误 +ErrorCreatingDir=安装程序无法创建目录“%1” +ErrorTooManyFilesInDir=无法在目录“%1”中创建文件,因为里面包含太多文件 + +; *** 安装程序公共消息 +ExitSetupTitle=退出安装程序 +ExitSetupMessage=安装程序尚未完成。如果现在退出,将不会安装该程序。%n%n您之后可以再次运行安装程序完成安装。%n%n现在退出安装程序吗? +AboutSetupMenuItem=关于安装程序(&A)... +AboutSetupTitle=关于安装程序 +AboutSetupMessage=%1 版本 %2%n%3%n%n%1 主页:%n%4 +AboutSetupNote= +TranslatorNote=简体中文翻译由Kira(847320916@qq.com)维护。项目地址:https://github.com/kira-96/Inno-Setup-Chinese-Simplified-Translation + +; *** 按钮 +ButtonBack=< 上一步(&B) +ButtonNext=下一步(&N) > +ButtonInstall=安装(&I) +ButtonOK=确定 +ButtonCancel=取消 +ButtonYes=是(&Y) +ButtonYesToAll=全是(&A) +ButtonNo=否(&N) +ButtonNoToAll=全否(&O) +ButtonFinish=完成(&F) +ButtonBrowse=浏览(&B)... +ButtonWizardBrowse=浏览(&R)... +ButtonNewFolder=新建文件夹(&M) + +; *** “选择语言”对话框消息 +SelectLanguageTitle=选择安装语言 +SelectLanguageLabel=选择安装时使用的语言。 + +; *** 公共向导文字 +ClickNext=点击“下一步”继续,或点击“取消”退出安装程序。 +BeveledLabel= +BrowseDialogTitle=浏览文件夹 +BrowseDialogLabel=在下面的列表中选择一个文件夹,然后点击“确定”。 +NewFolderName=新建文件夹 + +; *** “欢迎”向导页 +WelcomeLabel1=欢迎使用 [name] 安装向导 +WelcomeLabel2=现在将安装 [name/ver] 到您的电脑中。%n%n建议您在继续安装前关闭所有其他应用程序。 + +; *** “密码”向导页 +WizardPassword=密码 +PasswordLabel1=这个安装程序有密码保护。 +PasswordLabel3=请输入密码,然后点击“下一步”继续。密码区分大小写。 +PasswordEditLabel=密码(&P): +IncorrectPassword=您输入的密码不正确,请重新输入。 + +; *** “许可协议”向导页 +WizardLicense=许可协议 +LicenseLabel=请在继续安装前阅读以下重要信息。 +LicenseLabel3=请仔细阅读下列许可协议。在继续安装前您必须同意这些协议条款。 +LicenseAccepted=我同意此协议(&A) +LicenseNotAccepted=我不同意此协议(&D) + +; *** “信息”向导页 +WizardInfoBefore=信息 +InfoBeforeLabel=请在继续安装前阅读以下重要信息。 +InfoBeforeClickLabel=准备好继续安装后,点击“下一步”。 +WizardInfoAfter=信息 +InfoAfterLabel=请在继续安装前阅读以下重要信息。 +InfoAfterClickLabel=准备好继续安装后,点击“下一步”。 + +; *** “用户信息”向导页 +WizardUserInfo=用户信息 +UserInfoDesc=请输入您的信息。 +UserInfoName=用户名(&U): +UserInfoOrg=组织(&O): +UserInfoSerial=序列号(&S): +UserInfoNameRequired=您必须输入用户名。 + +; *** “选择目标目录”向导页 +WizardSelectDir=选择目标位置 +SelectDirDesc=您想将 [name] 安装在哪里? +SelectDirLabel3=安装程序将安装 [name] 到下面的文件夹中。 +SelectDirBrowseLabel=点击“下一步”继续。如果您想选择其他文件夹,点击“浏览”。 +DiskSpaceGBLabel=至少需要有 [gb] GB 的可用磁盘空间。 +DiskSpaceMBLabel=至少需要有 [mb] MB 的可用磁盘空间。 +CannotInstallToNetworkDrive=安装程序无法安装到一个网络驱动器。 +CannotInstallToUNCPath=安装程序无法安装到一个 UNC 路径。 +InvalidPath=您必须输入一个带驱动器卷标的完整路径,例如:%n%nC:\APP%n%n或UNC路径:%n%n\\server\share +InvalidDrive=您选定的驱动器或 UNC 共享不存在或不能访问。请选择其他位置。 +DiskSpaceWarningTitle=磁盘空间不足 +DiskSpaceWarning=安装程序至少需要 %1 KB 的可用空间才能安装,但选定驱动器只有 %2 KB 的可用空间。%n%n您一定要继续吗? +DirNameTooLong=文件夹名称或路径太长。 +InvalidDirName=文件夹名称无效。 +BadDirName32=文件夹名称不能包含下列任何字符:%n%n%1 +DirExistsTitle=文件夹已存在 +DirExists=文件夹:%n%n%1%n%n已经存在。您一定要安装到这个文件夹中吗? +DirDoesntExistTitle=文件夹不存在 +DirDoesntExist=文件夹:%n%n%1%n%n不存在。您想要创建此文件夹吗? + +; *** “选择组件”向导页 +WizardSelectComponents=选择组件 +SelectComponentsDesc=您想安装哪些程序组件? +SelectComponentsLabel2=选中您想安装的组件;取消您不想安装的组件。然后点击“下一步”继续。 +FullInstallation=完全安装 +; if possible don't translate 'Compact' as 'Minimal' (I mean 'Minimal' in your language) +CompactInstallation=简洁安装 +CustomInstallation=自定义安装 +NoUninstallWarningTitle=组件已存在 +NoUninstallWarning=安装程序检测到下列组件已安装在您的电脑中:%n%n%1%n%n取消选中这些组件不会卸载它们。%n%n确定要继续吗? +ComponentSize1=%1 KB +ComponentSize2=%1 MB +ComponentsDiskSpaceGBLabel=当前选择的组件需要至少 [gb] GB 的磁盘空间。 +ComponentsDiskSpaceMBLabel=当前选择的组件需要至少 [mb] MB 的磁盘空间。 + +; *** “选择附加任务”向导页 +WizardSelectTasks=选择附加任务 +SelectTasksDesc=您想要安装程序执行哪些附加任务? +SelectTasksLabel2=选择您想要安装程序在安装 [name] 时执行的附加任务,然后点击“下一步”。 + +; *** “选择开始菜单文件夹”向导页 +WizardSelectProgramGroup=选择开始菜单文件夹 +SelectStartMenuFolderDesc=安装程序应该在哪里放置程序的快捷方式? +SelectStartMenuFolderLabel3=安装程序将在下列“开始”菜单文件夹中创建程序的快捷方式。 +SelectStartMenuFolderBrowseLabel=点击“下一步”继续。如果您想选择其他文件夹,点击“浏览”。 +MustEnterGroupName=您必须输入一个文件夹名。 +GroupNameTooLong=文件夹名或路径太长。 +InvalidGroupName=无效的文件夹名字。 +BadGroupName=文件夹名不能包含下列任何字符:%n%n%1 +NoProgramGroupCheck2=不创建开始菜单文件夹(&D) + +; *** “准备安装”向导页 +WizardReady=准备安装 +ReadyLabel1=安装程序准备就绪,现在可以开始安装 [name] 到您的电脑。 +ReadyLabel2a=点击“安装”继续此安装程序。如果您想重新考虑或修改任何设置,点击“上一步”。 +ReadyLabel2b=点击“安装”继续此安装程序。 +ReadyMemoUserInfo=用户信息: +ReadyMemoDir=目标位置: +ReadyMemoType=安装类型: +ReadyMemoComponents=已选择组件: +ReadyMemoGroup=开始菜单文件夹: +ReadyMemoTasks=附加任务: + +; *** TExtractionWizardPage 向导页面与 ExtractArchive +ExtractingLabel=正在解压文件... +ButtonStopExtraction=停止解压(&S) +StopExtraction=您确定要停止解压吗? +ErrorExtractionAborted=解压已中止 +ErrorExtractionFailed=解压失败:%1 + +; *** 压缩文件解压失败详情 +ArchiveIncorrectPassword=压缩文件密码不正确 +ArchiveIsCorrupted=压缩文件已损坏 +ArchiveUnsupportedFormat=不支持的压缩文件格式 + +; *** TDownloadWizardPage 向导页面和 DownloadTemporaryFile +DownloadingLabel2=正在下载文件... +ButtonStopDownload=停止下载(&S) +StopDownload=您确定要停止下载吗? +ErrorDownloadAborted=下载已中止 +ErrorDownloadFailed=下载失败:%1 %2 +ErrorDownloadSizeFailed=获取下载大小失败:%1 %2 +ErrorProgress=无效的进度:%1 / %2 +ErrorFileSize=文件大小错误:预期 %1,实际 %2 + +; *** “正在准备安装”向导页 +WizardPreparing=正在准备安装 +PreparingDesc=安装程序正在准备安装 [name] 到您的电脑。 +PreviousInstallNotCompleted=先前的程序安装或卸载未完成,您需要重启您的电脑以完成。%n%n在重启电脑后,再次运行安装程序以完成 [name] 的安装。 +CannotContinue=安装程序不能继续。请点击“取消”退出。 +ApplicationsFound=以下应用程序正在使用将由安装程序更新的文件。建议您允许安装程序自动关闭这些应用程序。 +ApplicationsFound2=以下应用程序正在使用将由安装程序更新的文件。建议您允许安装程序自动关闭这些应用程序。安装完成后,安装程序将尝试重新启动这些应用程序。 +CloseApplications=自动关闭应用程序(&A) +DontCloseApplications=不要关闭应用程序(&D) +ErrorCloseApplications=安装程序无法自动关闭所有应用程序。建议您在继续之前,关闭所有在使用需要由安装程序更新的文件的应用程序。 +PrepareToInstallNeedsRestart=安装程序必须重启您的计算机。计算机重启后,请再次运行安装程序以完成 [name] 的安装。%n%n是否立即重新启动? + +; *** “正在安装”向导页 +WizardInstalling=正在安装 +InstallingLabel=安装程序正在安装 [name] 到您的电脑,请稍候。 + +; *** “安装完成”向导页 +FinishedHeadingLabel=[name] 安装完成 +FinishedLabelNoIcons=安装程序已在您的电脑中安装了 [name]。 +FinishedLabel=安装程序已在您的电脑中安装了 [name]。您可以通过已安装的快捷方式运行此应用程序。 +ClickFinish=点击“完成”退出安装程序。 +FinishedRestartLabel=为完成 [name] 的安装,安装程序必须重新启动您的电脑。要立即重启吗? +FinishedRestartMessage=为完成 [name] 的安装,安装程序必须重新启动您的电脑。%n%n要立即重启吗? +ShowReadmeCheck=是,我想查阅自述文件 +YesRadio=是,立即重启电脑(&Y) +NoRadio=否,稍后重启电脑(&N) +; used for example as 'Run MyProg.exe' +RunEntryExec=运行 %1 +; used for example as 'View Readme.txt' +RunEntryShellExec=查阅 %1 + +; *** “安装程序需要下一张磁盘”提示 +ChangeDiskTitle=安装程序需要下一张磁盘 +SelectDiskLabel2=请插入磁盘 %1 并点击“确定”。%n%n如果这个磁盘中的文件可以在下列文件夹之外的文件夹中找到,请输入正确的路径或点击“浏览”。 +PathLabel=路径(&P): +FileNotInDir2=“%2”中找不到文件“%1”。请插入正确的磁盘或选择其他文件夹。 +SelectDirectoryLabel=请指定下一张磁盘的位置。 + +; *** 安装阶段消息 +SetupAborted=安装程序未完成安装。%n%n请修正这个问题并重新运行安装程序。 +AbortRetryIgnoreSelectAction=选择操作 +AbortRetryIgnoreRetry=重试(&T) +AbortRetryIgnoreIgnore=忽略错误并继续(&I) +AbortRetryIgnoreCancel=关闭安装程序 +RetryCancelSelectAction=选择操作 +RetryCancelRetry=重试(&T) +RetryCancelCancel=取消(&C) + +; *** 安装状态消息 +StatusClosingApplications=正在关闭应用程序... +StatusCreateDirs=正在创建目录... +StatusExtractFiles=正在提取文件... +StatusDownloadFiles=正在下载文件... +StatusCreateIcons=正在创建快捷方式... +StatusCreateIniEntries=正在创建 INI 条目... +StatusCreateRegistryEntries=正在创建注册表条目... +StatusRegisterFiles=正在注册文件... +StatusSavingUninstall=正在保存卸载信息... +StatusRunProgram=正在完成安装... +StatusRestartingApplications=正在重启应用程序... +StatusRollback=正在撤销更改... + +; *** 其他错误 +ErrorInternal2=内部错误:%1 +ErrorFunctionFailedNoCode=%1 失败 +ErrorFunctionFailed=%1 失败;错误代码 %2 +ErrorFunctionFailedWithMessage=%1 失败;错误代码 %2.%n%3 +ErrorExecutingProgram=无法执行文件:%n%1 + +; *** 注册表错误 +ErrorRegOpenKey=打开注册表项时出错:%n%1\%2 +ErrorRegCreateKey=创建注册表项时出错:%n%1\%2 +ErrorRegWriteKey=写入注册表项时出错:%n%1\%2 + +; *** INI 错误 +ErrorIniEntry=在文件“%1”中创建 INI 条目时出错。 + +; *** 文件复制错误 +FileAbortRetryIgnoreSkipNotRecommended=跳过此文件(&S) (不推荐) +FileAbortRetryIgnoreIgnoreNotRecommended=忽略错误并继续(&I) (不推荐) +SourceIsCorrupted=源文件已损坏 +SourceDoesntExist=源文件“%1”不存在 +SourceVerificationFailed=源文件验证失败: %1 +VerificationSignatureDoesntExist=签名文件“%1”不存在 +VerificationSignatureInvalid=签名文件“%1”无效 +VerificationKeyNotFound=签名文件“%1”使用了未知密钥 +VerificationFileNameIncorrect=文件名不正确 +VerificationFileTagIncorrect=文件标签不正确 +VerificationFileSizeIncorrect=文件大小不正确 +VerificationFileHashIncorrect=文件哈希值不正确 +ExistingFileReadOnly2=无法替换现有文件,它是只读的。 +ExistingFileReadOnlyRetry=移除只读属性并重试(&R) +ExistingFileReadOnlyKeepExisting=保留现有文件(&K) +ErrorReadingExistingDest=尝试读取现有文件时出错: +FileExistsSelectAction=选择操作 +FileExists2=文件已经存在。 +FileExistsOverwriteExisting=覆盖已存在的文件(&O) +FileExistsKeepExisting=保留现有的文件(&K) +FileExistsOverwriteOrKeepAll=为所有冲突文件执行此操作(&D) +ExistingFileNewerSelectAction=选择操作 +ExistingFileNewer2=现有的文件比安装程序将要安装的文件还要新。 +ExistingFileNewerOverwriteExisting=覆盖已存在的文件(&O) +ExistingFileNewerKeepExisting=保留现有的文件(&K) (推荐) +ExistingFileNewerOverwriteOrKeepAll=为所有冲突文件执行此操作(&D) +ErrorChangingAttr=尝试更改下列现有文件的属性时出错: +ErrorCreatingTemp=尝试在目标目录创建文件时出错: +ErrorReadingSource=尝试读取下列源文件时出错: +ErrorCopying=尝试复制下列文件时出错: +ErrorDownloading=下载文件时出错: +ErrorExtracting=解压压缩文件时出错: +ErrorReplacingExistingFile=尝试替换现有文件时出错: +ErrorRestartReplace=重启并替换失败: +ErrorRenamingTemp=尝试重命名下列目标目录中的一个文件时出错: +ErrorRegisterServer=无法注册 DLL/OCX:%1 +ErrorRegSvr32Failed=RegSvr32 失败;退出代码 %1 +ErrorRegisterTypeLib=无法注册类库:%1 + +; *** 卸载显示名字标记 +; used for example as 'My Program (32-bit)' +UninstallDisplayNameMark=%1 (%2) +; used for example as 'My Program (32-bit, All users)' +UninstallDisplayNameMarks=%1 (%2, %3) +UninstallDisplayNameMark32Bit=32 位 +UninstallDisplayNameMark64Bit=64 位 +UninstallDisplayNameMarkAllUsers=所有用户 +UninstallDisplayNameMarkCurrentUser=当前用户 + +; *** 安装后错误 +ErrorOpeningReadme=尝试打开自述文件时出错。 +ErrorRestartingComputer=安装程序无法重启电脑,请手动重启。 + +; *** 卸载消息 +UninstallNotFound=文件“%1”不存在。无法卸载。 +UninstallOpenError=文件“%1”不能被打开。无法卸载。 +UninstallUnsupportedVer=此版本的卸载程序无法识别卸载日志文件“%1”的格式。无法卸载 +UninstallUnknownEntry=卸载日志中遇到一个未知条目 (%1) +ConfirmUninstall=您确认要完全移除 %1 及其所有组件吗? +UninstallOnlyOnWin64=仅允许在 64 位 Windows 中卸载此程序。 +OnlyAdminCanUninstall=仅使用管理员权限的用户能完成此卸载。 +UninstallStatusLabel=正在从您的电脑中移除 %1,请稍候。 +UninstalledAll=已顺利从您的电脑中移除 %1。 +UninstalledMost=%1 卸载完成。%n%n有部分内容未能被删除,但您可以手动删除它们。 +UninstalledAndNeedsRestart=为完成 %1 的卸载,需要重启您的电脑。%n%n立即重启电脑吗? +UninstallDataCorrupted=文件“%1”已损坏。无法卸载 + +; *** 卸载状态消息 +ConfirmDeleteSharedFileTitle=删除共享的文件吗? +ConfirmDeleteSharedFile2=系统表示下列共享的文件已不有其他程序使用。您希望卸载程序删除这些共享的文件吗?%n%n如果删除这些文件,但仍有程序在使用这些文件,则这些程序可能出现异常。如果您不能确定,请选择“否”,在系统中保留这些文件以免引发问题。 +SharedFileNameLabel=文件名: +SharedFileLocationLabel=位置: +WizardUninstalling=卸载状态 +StatusUninstalling=正在卸载 %1... + +; *** Shutdown block reasons +ShutdownBlockReasonInstallingApp=正在安装 %1。 +ShutdownBlockReasonUninstallingApp=正在卸载 %1。 + +; The custom messages below aren't used by Setup itself, but if you make +; use of them in your scripts, you'll want to translate them. + +[CustomMessages] + +NameAndVersion=%1 版本 %2 +AdditionalIcons=附加快捷方式: +CreateDesktopIcon=创建桌面快捷方式(&D) +CreateQuickLaunchIcon=创建快速启动栏快捷方式(&Q) +ProgramOnTheWeb=%1 网站 +UninstallProgram=卸载 %1 +LaunchProgram=运行 %1 +AssocFileExtension=将 %2 文件扩展名与 %1 建立关联(&A) +AssocingFileExtension=正在将 %2 文件扩展名与 %1 建立关联... +AutoStartProgramGroupDescription=启动: +AutoStartProgram=自动启动 %1 +AddonHostProgramNotFound=您选择的文件夹中无法找到 %1。%n%n您要继续吗? diff --git a/installer/sm_emby.iss b/installer/sm_emby.iss new file mode 100644 index 0000000..a6cb785 --- /dev/null +++ b/installer/sm_emby.iss @@ -0,0 +1,90 @@ +; ============================================================================ +; smPlayer Windows Installer (Inno Setup 6) +; ---------------------------------------------------------------------------- +; 用法: +; 1. 先执行:flutter build windows --release +; 2. 再执行:iscc installer\sm_emby.iss +; 或一键: powershell -ExecutionPolicy Bypass -File scripts\build-installer.ps1 +; +; 命令行可覆盖版本号:iscc /DAppVersion=1.2.3 installer\sm_emby.iss +; ============================================================================ + +#define AppName "smPlayer" +#define AppPublisher "smPlayer" +#define AppExeName "smplayer.exe" +#define AppId "{{A8E3F1C2-7D5B-4A6E-9C8D-3F2E1B0A9D7C}" + +#ifndef AppVersion + #define AppVersion "0.0.15" +#endif + +#define ProjectRoot SourcePath + ".." +#define BuildDir ProjectRoot + "\build\windows\x64\runner\Release" +#define IconFile ProjectRoot + "\windows\runner\resources\app_icon.ico" +#define OutputDirAbs ProjectRoot + "\build\installer" + +[Setup] +AppId={#AppId} +AppName={#AppName} +AppVersion={#AppVersion} +AppVerName={#AppName} {#AppVersion} +AppPublisher={#AppPublisher} +DefaultDirName={autopf}\{#AppName} +DefaultGroupName={#AppName} +DisableProgramGroupPage=yes +DisableDirPage=no +ArchitecturesInstallIn64BitMode=x64compatible +ArchitecturesAllowed=x64compatible +OutputDir={#OutputDirAbs} +OutputBaseFilename=smplayer-setup-{#AppVersion}-x64 +SetupIconFile={#IconFile} +UninstallDisplayIcon={app}\{#AppExeName} +Compression=lzma2/ultra64 +SolidCompression=yes +WizardStyle=modern +PrivilegesRequired=admin +PrivilegesRequiredOverridesAllowed=dialog +CloseApplications=force +RestartApplications=no +MinVersion=10.0 + +[Languages] +; ChineseSimplified.isl 来自 https://github.com/jrsoftware/issrc (Files/Languages/Unofficial) +; 已随仓库提交至 installer/lang/ 以避免依赖系统级 Inno Setup 安装 +Name: "chinesesimp"; MessagesFile: "lang\ChineseSimplified.isl" +Name: "english"; MessagesFile: "compiler:Default.isl" + +[Tasks] +Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked + +[Files] +; 主可执行文件 +Source: "{#BuildDir}\{#AppExeName}"; DestDir: "{app}"; Flags: ignoreversion +; 所有 DLL(Flutter 引擎、fvp/MDK、media_kit/mpv、插件等) +Source: "{#BuildDir}\*.dll"; DestDir: "{app}"; Flags: ignoreversion +; native_assets 清单 +Source: "{#BuildDir}\native_assets.json"; DestDir: "{app}"; Flags: ignoreversion skipifsourcedoesntexist +; data 目录(flutter_assets + icudtl.dat + app.so) +Source: "{#BuildDir}\data\*"; DestDir: "{app}\data"; Flags: ignoreversion recursesubdirs createallsubdirs + +[Icons] +Name: "{group}\{#AppName}"; Filename: "{app}\{#AppExeName}" +Name: "{group}\{cm:UninstallProgram,{#AppName}}"; Filename: "{uninstallexe}" +Name: "{autodesktop}\{#AppName}"; Filename: "{app}\{#AppExeName}"; Tasks: desktopicon + +[Registry] +; 自定义 URL scheme:smplayer:// → 本程序(Trakt OAuth 回跳 smplayer://oauth-trakt)。 +; 安装到 HKLM\Software\Classes,正式安装走管理员权限写入。 +; app 已运行时点链接会拉起第二个进程,由 windows/runner/main.cpp 的 SendAppLinkToInstance +; 把深链转发给已存在窗口后立即退出(app_links 官方单实例范式),不会出现孤立的第二实例。 +Root: HKLM; Subkey: "Software\Classes\smplayer"; ValueType: string; ValueName: ""; ValueData: "URL:smplayer Protocol"; Flags: uninsdeletekey +Root: HKLM; Subkey: "Software\Classes\smplayer"; ValueType: string; ValueName: "URL Protocol"; ValueData: "" +Root: HKLM; Subkey: "Software\Classes\smplayer\DefaultIcon"; ValueType: string; ValueName: ""; ValueData: "{app}\{#AppExeName},0" +Root: HKLM; Subkey: "Software\Classes\smplayer\shell\open\command"; ValueType: string; ValueName: ""; ValueData: """{app}\{#AppExeName}"" ""%1""" + +[Run] +Filename: "{app}\{#AppExeName}"; Description: "{cm:LaunchProgram,{#AppName}}"; Flags: nowait postinstall skipifsilent + +[UninstallDelete] +; 清理用户数据目录(可选;如需保留用户配置可注释掉) +; Type: filesandordirs; Name: "{userappdata}\sm_emby" diff --git a/lib/app.dart b/lib/app.dart new file mode 100644 index 0000000..db1f024 --- /dev/null +++ b/lib/app.dart @@ -0,0 +1,127 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart' + show defaultTargetPlatform, TargetPlatform; +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:forui/forui.dart'; + +import 'features/update/update_dialog.dart'; +import 'providers/appearance_provider.dart'; +import 'providers/di_providers.dart'; +import 'providers/server_sync_provider.dart'; +import 'providers/sm_account_provider.dart'; +import 'providers/update_provider.dart'; +import 'router/app_router.dart' show appRouterProvider, rootNavigatorKey; +import 'shared/theme/app_scroll_behavior.dart'; +import 'shared/theme/app_theme.dart'; +import 'shared/utils/app_logger.dart'; + +class SmPlayerApp extends ConsumerStatefulWidget { + const SmPlayerApp({super.key}); + + @override + ConsumerState createState() => _SmPlayerAppState(); +} + +class _SmPlayerAppState extends ConsumerState { + bool _updateDialogShown = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) async { + try { + final queue = await ref.read(traktSyncQueueProvider.future); + await queue.drain(); + } catch (e) { + AppLogger.debug('Trakt', 'startup drain skipped', e); + } + unawaited(ref.read(updateProvider.notifier).silentCheck()); + unawaited(_autoSyncPull()); + }); + } + + Future _autoSyncPull() async { + try { + final account = await ref.read(smAccountProvider.future); + if (!account.hasVip) return; + final outcome = await ref.read(serverSyncProvider.notifier).pull(); + if (outcome.isError) { + AppLogger.debug('CloudSync', 'auto-pull failed: ${outcome.message}'); + } + } catch (e) { + AppLogger.debug('CloudSync', 'auto-pull skipped', e); + } + } + + @override + Widget build(BuildContext context) { + final router = ref.watch(appRouterProvider); + final appearance = ref.watch(appearanceProvider); + final flutterMode = appearance.maybeWhen( + data: (d) => d.themeMode, + orElse: () => ThemeMode.system, + ); + + return MaterialApp.router( + title: 'smPlayer', + debugShowCheckedModeBanner: false, + locale: const Locale('zh', 'CN'), + supportedLocales: const [Locale('zh', 'CN'), Locale('en')], + localizationsDelegates: const [ + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ], + theme: AppTheme.light(), + darkTheme: AppTheme.dark(), + themeMode: flutterMode, + scrollBehavior: const AppScrollBehavior(), + routerConfig: router, + builder: (context, child) { + final zinc = Theme.of(context).brightness == Brightness.dark + ? FThemes.zinc.dark + : FThemes.zinc.light; + return FTheme( + data: _isTouchPlatform ? zinc.touch : zinc.desktop, + child: FToaster( + child: Consumer( + builder: (context, ref, _) { + ref.listen(updateProvider, (previous, next) { + if (_updateDialogShown || + next.status != UpdateStatus.available || + !next.silent) { + return; + } + _updateDialogShown = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + final navContext = rootNavigatorKey.currentContext; + if (navContext == null) return; + showFDialog( + context: navContext, + builder: (_, _, _) => const UpdateDialog(), + ); + }); + }); + + return child ?? const SizedBox(); + }, + child: child, + ), + ), + ); + }, + ); + } +} + + +bool get _isTouchPlatform => switch (defaultTargetPlatform) { + TargetPlatform.android || + TargetPlatform.iOS || + TargetPlatform.fuchsia => true, + _ => false, +}; diff --git a/lib/core/contracts/app_update.dart b/lib/core/contracts/app_update.dart new file mode 100644 index 0000000..6f55535 --- /dev/null +++ b/lib/core/contracts/app_update.dart @@ -0,0 +1,82 @@ +class AppUpdateAsset { + final String name; + final String downloadUrl; + final int size; + final String? sha256; + + + final String? version; + + const AppUpdateAsset({ + required this.name, + required this.downloadUrl, + required this.size, + this.sha256, + this.version, + }); + + factory AppUpdateAsset.fromJson(Map json) { + return AppUpdateAsset( + name: json['name']?.toString() ?? '', + downloadUrl: + json['browser_download_url']?.toString() ?? + json['browserDownloadUrl']?.toString() ?? + json['url']?.toString() ?? + '', + size: json['size'] is int ? json['size'] as int : 0, + sha256: (json['sha256']?.toString().trim().isEmpty ?? true) + ? null + : json['sha256'].toString().trim().toLowerCase(), + version: (json['version']?.toString().trim().isEmpty ?? true) + ? null + : json['version'].toString().trim(), + ); + } +} + +class AppUpdateInfo { + final String version; + final String releaseNotes; + final Uri htmlUrl; + final List assets; + final Map platformAssets; + final AppUpdateAsset? selectedAsset; + final bool noAssetForPlatform; + + const AppUpdateInfo({ + required this.version, + required this.releaseNotes, + required this.htmlUrl, + required this.assets, + this.platformAssets = const {}, + this.selectedAsset, + this.noAssetForPlatform = false, + }); + + AppUpdateInfo copyWith({ + String? version, + String? releaseNotes, + Uri? htmlUrl, + List? assets, + Map? platformAssets, + AppUpdateAsset? selectedAsset, + bool? noAssetForPlatform, + }) { + return AppUpdateInfo( + version: version ?? this.version, + releaseNotes: releaseNotes ?? this.releaseNotes, + htmlUrl: htmlUrl ?? this.htmlUrl, + assets: assets ?? this.assets, + platformAssets: platformAssets ?? this.platformAssets, + selectedAsset: selectedAsset ?? this.selectedAsset, + noAssetForPlatform: noAssetForPlatform ?? this.noAssetForPlatform, + ); + } +} + +class UpdateCheckResult { + final bool hasUpdate; + final AppUpdateInfo? info; + + const UpdateCheckResult({required this.hasUpdate, this.info}); +} diff --git a/lib/core/contracts/auth.dart b/lib/core/contracts/auth.dart new file mode 100644 index 0000000..9e48a2c --- /dev/null +++ b/lib/core/contracts/auth.dart @@ -0,0 +1,61 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'auth.freezed.dart'; +part 'auth.g.dart'; + +@freezed +abstract class AuthByNameReq with _$AuthByNameReq { + const factory AuthByNameReq({ + required String serverId, + required String username, + required String password, + }) = _AuthByNameReq; + + factory AuthByNameReq.fromJson(Map json) => + _$AuthByNameReqFromJson(json); +} + +@freezed +abstract class SessionData with _$SessionData { + const factory SessionData({ + required String serverId, + required String token, + required String userId, + required String userName, + @JsonKey(includeIfNull: false) String? password, + required String createdAt, + }) = _SessionData; + + factory SessionData.fromJson(Map json) => + _$SessionDataFromJson(json); +} + +@freezed +abstract class AuthedSession with _$AuthedSession { + const factory AuthedSession({ + required String serverId, + required String serverName, + required String serverUrl, + required String token, + required String userId, + required String userName, + @JsonKey(includeIfNull: false) String? password, + required bool isActive, + }) = _AuthedSession; + + factory AuthedSession.fromJson(Map json) => + _$AuthedSessionFromJson(json); +} + +@freezed +abstract class SessionListRes with _$SessionListRes { + const factory SessionListRes({ + required List sessions, + + + String? activeServerId, + }) = _SessionListRes; + + factory SessionListRes.fromJson(Map json) => + _$SessionListResFromJson(json); +} diff --git a/lib/core/contracts/auth.freezed.dart b/lib/core/contracts/auth.freezed.dart new file mode 100644 index 0000000..a810d07 --- /dev/null +++ b/lib/core/contracts/auth.freezed.dart @@ -0,0 +1,1119 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'auth.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$AuthByNameReq { + + String get serverId; String get username; String get password; +/// Create a copy of AuthByNameReq +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$AuthByNameReqCopyWith get copyWith => _$AuthByNameReqCopyWithImpl(this as AuthByNameReq, _$identity); + + /// Serializes this AuthByNameReq to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is AuthByNameReq&&(identical(other.serverId, serverId) || other.serverId == serverId)&&(identical(other.username, username) || other.username == username)&&(identical(other.password, password) || other.password == password)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,serverId,username,password); + +@override +String toString() { + return 'AuthByNameReq(serverId: $serverId, username: $username, password: $password)'; +} + + +} + +/// @nodoc +abstract mixin class $AuthByNameReqCopyWith<$Res> { + factory $AuthByNameReqCopyWith(AuthByNameReq value, $Res Function(AuthByNameReq) _then) = _$AuthByNameReqCopyWithImpl; +@useResult +$Res call({ + String serverId, String username, String password +}); + + + + +} +/// @nodoc +class _$AuthByNameReqCopyWithImpl<$Res> + implements $AuthByNameReqCopyWith<$Res> { + _$AuthByNameReqCopyWithImpl(this._self, this._then); + + final AuthByNameReq _self; + final $Res Function(AuthByNameReq) _then; + +/// Create a copy of AuthByNameReq +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? serverId = null,Object? username = null,Object? password = null,}) { + return _then(_self.copyWith( +serverId: null == serverId ? _self.serverId : serverId // ignore: cast_nullable_to_non_nullable +as String,username: null == username ? _self.username : username // ignore: cast_nullable_to_non_nullable +as String,password: null == password ? _self.password : password // ignore: cast_nullable_to_non_nullable +as String, + )); +} + +} + + +/// Adds pattern-matching-related methods to [AuthByNameReq]. +extension AuthByNameReqPatterns on AuthByNameReq { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _AuthByNameReq value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _AuthByNameReq() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _AuthByNameReq value) $default,){ +final _that = this; +switch (_that) { +case _AuthByNameReq(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _AuthByNameReq value)? $default,){ +final _that = this; +switch (_that) { +case _AuthByNameReq() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String serverId, String username, String password)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _AuthByNameReq() when $default != null: +return $default(_that.serverId,_that.username,_that.password);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String serverId, String username, String password) $default,) {final _that = this; +switch (_that) { +case _AuthByNameReq(): +return $default(_that.serverId,_that.username,_that.password);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String serverId, String username, String password)? $default,) {final _that = this; +switch (_that) { +case _AuthByNameReq() when $default != null: +return $default(_that.serverId,_that.username,_that.password);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _AuthByNameReq implements AuthByNameReq { + const _AuthByNameReq({required this.serverId, required this.username, required this.password}); + factory _AuthByNameReq.fromJson(Map json) => _$AuthByNameReqFromJson(json); + +@override final String serverId; +@override final String username; +@override final String password; + +/// Create a copy of AuthByNameReq +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$AuthByNameReqCopyWith<_AuthByNameReq> get copyWith => __$AuthByNameReqCopyWithImpl<_AuthByNameReq>(this, _$identity); + +@override +Map toJson() { + return _$AuthByNameReqToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _AuthByNameReq&&(identical(other.serverId, serverId) || other.serverId == serverId)&&(identical(other.username, username) || other.username == username)&&(identical(other.password, password) || other.password == password)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,serverId,username,password); + +@override +String toString() { + return 'AuthByNameReq(serverId: $serverId, username: $username, password: $password)'; +} + + +} + +/// @nodoc +abstract mixin class _$AuthByNameReqCopyWith<$Res> implements $AuthByNameReqCopyWith<$Res> { + factory _$AuthByNameReqCopyWith(_AuthByNameReq value, $Res Function(_AuthByNameReq) _then) = __$AuthByNameReqCopyWithImpl; +@override @useResult +$Res call({ + String serverId, String username, String password +}); + + + + +} +/// @nodoc +class __$AuthByNameReqCopyWithImpl<$Res> + implements _$AuthByNameReqCopyWith<$Res> { + __$AuthByNameReqCopyWithImpl(this._self, this._then); + + final _AuthByNameReq _self; + final $Res Function(_AuthByNameReq) _then; + +/// Create a copy of AuthByNameReq +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? serverId = null,Object? username = null,Object? password = null,}) { + return _then(_AuthByNameReq( +serverId: null == serverId ? _self.serverId : serverId // ignore: cast_nullable_to_non_nullable +as String,username: null == username ? _self.username : username // ignore: cast_nullable_to_non_nullable +as String,password: null == password ? _self.password : password // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + + +/// @nodoc +mixin _$SessionData { + + String get serverId; String get token; String get userId; String get userName;@JsonKey(includeIfNull: false) String? get password; String get createdAt; +/// Create a copy of SessionData +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$SessionDataCopyWith get copyWith => _$SessionDataCopyWithImpl(this as SessionData, _$identity); + + /// Serializes this SessionData to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is SessionData&&(identical(other.serverId, serverId) || other.serverId == serverId)&&(identical(other.token, token) || other.token == token)&&(identical(other.userId, userId) || other.userId == userId)&&(identical(other.userName, userName) || other.userName == userName)&&(identical(other.password, password) || other.password == password)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,serverId,token,userId,userName,password,createdAt); + +@override +String toString() { + return 'SessionData(serverId: $serverId, token: $token, userId: $userId, userName: $userName, password: $password, createdAt: $createdAt)'; +} + + +} + +/// @nodoc +abstract mixin class $SessionDataCopyWith<$Res> { + factory $SessionDataCopyWith(SessionData value, $Res Function(SessionData) _then) = _$SessionDataCopyWithImpl; +@useResult +$Res call({ + String serverId, String token, String userId, String userName,@JsonKey(includeIfNull: false) String? password, String createdAt +}); + + + + +} +/// @nodoc +class _$SessionDataCopyWithImpl<$Res> + implements $SessionDataCopyWith<$Res> { + _$SessionDataCopyWithImpl(this._self, this._then); + + final SessionData _self; + final $Res Function(SessionData) _then; + +/// Create a copy of SessionData +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? serverId = null,Object? token = null,Object? userId = null,Object? userName = null,Object? password = freezed,Object? createdAt = null,}) { + return _then(_self.copyWith( +serverId: null == serverId ? _self.serverId : serverId // ignore: cast_nullable_to_non_nullable +as String,token: null == token ? _self.token : token // ignore: cast_nullable_to_non_nullable +as String,userId: null == userId ? _self.userId : userId // ignore: cast_nullable_to_non_nullable +as String,userName: null == userName ? _self.userName : userName // ignore: cast_nullable_to_non_nullable +as String,password: freezed == password ? _self.password : password // ignore: cast_nullable_to_non_nullable +as String?,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable +as String, + )); +} + +} + + +/// Adds pattern-matching-related methods to [SessionData]. +extension SessionDataPatterns on SessionData { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _SessionData value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _SessionData() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _SessionData value) $default,){ +final _that = this; +switch (_that) { +case _SessionData(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _SessionData value)? $default,){ +final _that = this; +switch (_that) { +case _SessionData() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String serverId, String token, String userId, String userName, @JsonKey(includeIfNull: false) String? password, String createdAt)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _SessionData() when $default != null: +return $default(_that.serverId,_that.token,_that.userId,_that.userName,_that.password,_that.createdAt);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String serverId, String token, String userId, String userName, @JsonKey(includeIfNull: false) String? password, String createdAt) $default,) {final _that = this; +switch (_that) { +case _SessionData(): +return $default(_that.serverId,_that.token,_that.userId,_that.userName,_that.password,_that.createdAt);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String serverId, String token, String userId, String userName, @JsonKey(includeIfNull: false) String? password, String createdAt)? $default,) {final _that = this; +switch (_that) { +case _SessionData() when $default != null: +return $default(_that.serverId,_that.token,_that.userId,_that.userName,_that.password,_that.createdAt);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _SessionData implements SessionData { + const _SessionData({required this.serverId, required this.token, required this.userId, required this.userName, @JsonKey(includeIfNull: false) this.password, required this.createdAt}); + factory _SessionData.fromJson(Map json) => _$SessionDataFromJson(json); + +@override final String serverId; +@override final String token; +@override final String userId; +@override final String userName; +@override@JsonKey(includeIfNull: false) final String? password; +@override final String createdAt; + +/// Create a copy of SessionData +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$SessionDataCopyWith<_SessionData> get copyWith => __$SessionDataCopyWithImpl<_SessionData>(this, _$identity); + +@override +Map toJson() { + return _$SessionDataToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _SessionData&&(identical(other.serverId, serverId) || other.serverId == serverId)&&(identical(other.token, token) || other.token == token)&&(identical(other.userId, userId) || other.userId == userId)&&(identical(other.userName, userName) || other.userName == userName)&&(identical(other.password, password) || other.password == password)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,serverId,token,userId,userName,password,createdAt); + +@override +String toString() { + return 'SessionData(serverId: $serverId, token: $token, userId: $userId, userName: $userName, password: $password, createdAt: $createdAt)'; +} + + +} + +/// @nodoc +abstract mixin class _$SessionDataCopyWith<$Res> implements $SessionDataCopyWith<$Res> { + factory _$SessionDataCopyWith(_SessionData value, $Res Function(_SessionData) _then) = __$SessionDataCopyWithImpl; +@override @useResult +$Res call({ + String serverId, String token, String userId, String userName,@JsonKey(includeIfNull: false) String? password, String createdAt +}); + + + + +} +/// @nodoc +class __$SessionDataCopyWithImpl<$Res> + implements _$SessionDataCopyWith<$Res> { + __$SessionDataCopyWithImpl(this._self, this._then); + + final _SessionData _self; + final $Res Function(_SessionData) _then; + +/// Create a copy of SessionData +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? serverId = null,Object? token = null,Object? userId = null,Object? userName = null,Object? password = freezed,Object? createdAt = null,}) { + return _then(_SessionData( +serverId: null == serverId ? _self.serverId : serverId // ignore: cast_nullable_to_non_nullable +as String,token: null == token ? _self.token : token // ignore: cast_nullable_to_non_nullable +as String,userId: null == userId ? _self.userId : userId // ignore: cast_nullable_to_non_nullable +as String,userName: null == userName ? _self.userName : userName // ignore: cast_nullable_to_non_nullable +as String,password: freezed == password ? _self.password : password // ignore: cast_nullable_to_non_nullable +as String?,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + + +/// @nodoc +mixin _$AuthedSession { + + String get serverId; String get serverName; String get serverUrl; String get token; String get userId; String get userName;@JsonKey(includeIfNull: false) String? get password; bool get isActive; +/// Create a copy of AuthedSession +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$AuthedSessionCopyWith get copyWith => _$AuthedSessionCopyWithImpl(this as AuthedSession, _$identity); + + /// Serializes this AuthedSession to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is AuthedSession&&(identical(other.serverId, serverId) || other.serverId == serverId)&&(identical(other.serverName, serverName) || other.serverName == serverName)&&(identical(other.serverUrl, serverUrl) || other.serverUrl == serverUrl)&&(identical(other.token, token) || other.token == token)&&(identical(other.userId, userId) || other.userId == userId)&&(identical(other.userName, userName) || other.userName == userName)&&(identical(other.password, password) || other.password == password)&&(identical(other.isActive, isActive) || other.isActive == isActive)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,serverId,serverName,serverUrl,token,userId,userName,password,isActive); + +@override +String toString() { + return 'AuthedSession(serverId: $serverId, serverName: $serverName, serverUrl: $serverUrl, token: $token, userId: $userId, userName: $userName, password: $password, isActive: $isActive)'; +} + + +} + +/// @nodoc +abstract mixin class $AuthedSessionCopyWith<$Res> { + factory $AuthedSessionCopyWith(AuthedSession value, $Res Function(AuthedSession) _then) = _$AuthedSessionCopyWithImpl; +@useResult +$Res call({ + String serverId, String serverName, String serverUrl, String token, String userId, String userName,@JsonKey(includeIfNull: false) String? password, bool isActive +}); + + + + +} +/// @nodoc +class _$AuthedSessionCopyWithImpl<$Res> + implements $AuthedSessionCopyWith<$Res> { + _$AuthedSessionCopyWithImpl(this._self, this._then); + + final AuthedSession _self; + final $Res Function(AuthedSession) _then; + +/// Create a copy of AuthedSession +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? serverId = null,Object? serverName = null,Object? serverUrl = null,Object? token = null,Object? userId = null,Object? userName = null,Object? password = freezed,Object? isActive = null,}) { + return _then(_self.copyWith( +serverId: null == serverId ? _self.serverId : serverId // ignore: cast_nullable_to_non_nullable +as String,serverName: null == serverName ? _self.serverName : serverName // ignore: cast_nullable_to_non_nullable +as String,serverUrl: null == serverUrl ? _self.serverUrl : serverUrl // ignore: cast_nullable_to_non_nullable +as String,token: null == token ? _self.token : token // ignore: cast_nullable_to_non_nullable +as String,userId: null == userId ? _self.userId : userId // ignore: cast_nullable_to_non_nullable +as String,userName: null == userName ? _self.userName : userName // ignore: cast_nullable_to_non_nullable +as String,password: freezed == password ? _self.password : password // ignore: cast_nullable_to_non_nullable +as String?,isActive: null == isActive ? _self.isActive : isActive // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + +} + + +/// Adds pattern-matching-related methods to [AuthedSession]. +extension AuthedSessionPatterns on AuthedSession { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _AuthedSession value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _AuthedSession() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _AuthedSession value) $default,){ +final _that = this; +switch (_that) { +case _AuthedSession(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _AuthedSession value)? $default,){ +final _that = this; +switch (_that) { +case _AuthedSession() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String serverId, String serverName, String serverUrl, String token, String userId, String userName, @JsonKey(includeIfNull: false) String? password, bool isActive)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _AuthedSession() when $default != null: +return $default(_that.serverId,_that.serverName,_that.serverUrl,_that.token,_that.userId,_that.userName,_that.password,_that.isActive);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String serverId, String serverName, String serverUrl, String token, String userId, String userName, @JsonKey(includeIfNull: false) String? password, bool isActive) $default,) {final _that = this; +switch (_that) { +case _AuthedSession(): +return $default(_that.serverId,_that.serverName,_that.serverUrl,_that.token,_that.userId,_that.userName,_that.password,_that.isActive);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String serverId, String serverName, String serverUrl, String token, String userId, String userName, @JsonKey(includeIfNull: false) String? password, bool isActive)? $default,) {final _that = this; +switch (_that) { +case _AuthedSession() when $default != null: +return $default(_that.serverId,_that.serverName,_that.serverUrl,_that.token,_that.userId,_that.userName,_that.password,_that.isActive);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _AuthedSession implements AuthedSession { + const _AuthedSession({required this.serverId, required this.serverName, required this.serverUrl, required this.token, required this.userId, required this.userName, @JsonKey(includeIfNull: false) this.password, required this.isActive}); + factory _AuthedSession.fromJson(Map json) => _$AuthedSessionFromJson(json); + +@override final String serverId; +@override final String serverName; +@override final String serverUrl; +@override final String token; +@override final String userId; +@override final String userName; +@override@JsonKey(includeIfNull: false) final String? password; +@override final bool isActive; + +/// Create a copy of AuthedSession +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$AuthedSessionCopyWith<_AuthedSession> get copyWith => __$AuthedSessionCopyWithImpl<_AuthedSession>(this, _$identity); + +@override +Map toJson() { + return _$AuthedSessionToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _AuthedSession&&(identical(other.serverId, serverId) || other.serverId == serverId)&&(identical(other.serverName, serverName) || other.serverName == serverName)&&(identical(other.serverUrl, serverUrl) || other.serverUrl == serverUrl)&&(identical(other.token, token) || other.token == token)&&(identical(other.userId, userId) || other.userId == userId)&&(identical(other.userName, userName) || other.userName == userName)&&(identical(other.password, password) || other.password == password)&&(identical(other.isActive, isActive) || other.isActive == isActive)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,serverId,serverName,serverUrl,token,userId,userName,password,isActive); + +@override +String toString() { + return 'AuthedSession(serverId: $serverId, serverName: $serverName, serverUrl: $serverUrl, token: $token, userId: $userId, userName: $userName, password: $password, isActive: $isActive)'; +} + + +} + +/// @nodoc +abstract mixin class _$AuthedSessionCopyWith<$Res> implements $AuthedSessionCopyWith<$Res> { + factory _$AuthedSessionCopyWith(_AuthedSession value, $Res Function(_AuthedSession) _then) = __$AuthedSessionCopyWithImpl; +@override @useResult +$Res call({ + String serverId, String serverName, String serverUrl, String token, String userId, String userName,@JsonKey(includeIfNull: false) String? password, bool isActive +}); + + + + +} +/// @nodoc +class __$AuthedSessionCopyWithImpl<$Res> + implements _$AuthedSessionCopyWith<$Res> { + __$AuthedSessionCopyWithImpl(this._self, this._then); + + final _AuthedSession _self; + final $Res Function(_AuthedSession) _then; + +/// Create a copy of AuthedSession +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? serverId = null,Object? serverName = null,Object? serverUrl = null,Object? token = null,Object? userId = null,Object? userName = null,Object? password = freezed,Object? isActive = null,}) { + return _then(_AuthedSession( +serverId: null == serverId ? _self.serverId : serverId // ignore: cast_nullable_to_non_nullable +as String,serverName: null == serverName ? _self.serverName : serverName // ignore: cast_nullable_to_non_nullable +as String,serverUrl: null == serverUrl ? _self.serverUrl : serverUrl // ignore: cast_nullable_to_non_nullable +as String,token: null == token ? _self.token : token // ignore: cast_nullable_to_non_nullable +as String,userId: null == userId ? _self.userId : userId // ignore: cast_nullable_to_non_nullable +as String,userName: null == userName ? _self.userName : userName // ignore: cast_nullable_to_non_nullable +as String,password: freezed == password ? _self.password : password // ignore: cast_nullable_to_non_nullable +as String?,isActive: null == isActive ? _self.isActive : isActive // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + + +} + + +/// @nodoc +mixin _$SessionListRes { + + List get sessions;/// 与原手写实现保持一致:activeServerId 为 null 时仍序列化为 `null`,不省略。 + String? get activeServerId; +/// Create a copy of SessionListRes +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$SessionListResCopyWith get copyWith => _$SessionListResCopyWithImpl(this as SessionListRes, _$identity); + + /// Serializes this SessionListRes to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is SessionListRes&&const DeepCollectionEquality().equals(other.sessions, sessions)&&(identical(other.activeServerId, activeServerId) || other.activeServerId == activeServerId)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(sessions),activeServerId); + +@override +String toString() { + return 'SessionListRes(sessions: $sessions, activeServerId: $activeServerId)'; +} + + +} + +/// @nodoc +abstract mixin class $SessionListResCopyWith<$Res> { + factory $SessionListResCopyWith(SessionListRes value, $Res Function(SessionListRes) _then) = _$SessionListResCopyWithImpl; +@useResult +$Res call({ + List sessions, String? activeServerId +}); + + + + +} +/// @nodoc +class _$SessionListResCopyWithImpl<$Res> + implements $SessionListResCopyWith<$Res> { + _$SessionListResCopyWithImpl(this._self, this._then); + + final SessionListRes _self; + final $Res Function(SessionListRes) _then; + +/// Create a copy of SessionListRes +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? sessions = null,Object? activeServerId = freezed,}) { + return _then(_self.copyWith( +sessions: null == sessions ? _self.sessions : sessions // ignore: cast_nullable_to_non_nullable +as List,activeServerId: freezed == activeServerId ? _self.activeServerId : activeServerId // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [SessionListRes]. +extension SessionListResPatterns on SessionListRes { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _SessionListRes value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _SessionListRes() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _SessionListRes value) $default,){ +final _that = this; +switch (_that) { +case _SessionListRes(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _SessionListRes value)? $default,){ +final _that = this; +switch (_that) { +case _SessionListRes() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( List sessions, String? activeServerId)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _SessionListRes() when $default != null: +return $default(_that.sessions,_that.activeServerId);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( List sessions, String? activeServerId) $default,) {final _that = this; +switch (_that) { +case _SessionListRes(): +return $default(_that.sessions,_that.activeServerId);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( List sessions, String? activeServerId)? $default,) {final _that = this; +switch (_that) { +case _SessionListRes() when $default != null: +return $default(_that.sessions,_that.activeServerId);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _SessionListRes implements SessionListRes { + const _SessionListRes({required final List sessions, this.activeServerId}): _sessions = sessions; + factory _SessionListRes.fromJson(Map json) => _$SessionListResFromJson(json); + + final List _sessions; +@override List get sessions { + if (_sessions is EqualUnmodifiableListView) return _sessions; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_sessions); +} + +/// 与原手写实现保持一致:activeServerId 为 null 时仍序列化为 `null`,不省略。 +@override final String? activeServerId; + +/// Create a copy of SessionListRes +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$SessionListResCopyWith<_SessionListRes> get copyWith => __$SessionListResCopyWithImpl<_SessionListRes>(this, _$identity); + +@override +Map toJson() { + return _$SessionListResToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _SessionListRes&&const DeepCollectionEquality().equals(other._sessions, _sessions)&&(identical(other.activeServerId, activeServerId) || other.activeServerId == activeServerId)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_sessions),activeServerId); + +@override +String toString() { + return 'SessionListRes(sessions: $sessions, activeServerId: $activeServerId)'; +} + + +} + +/// @nodoc +abstract mixin class _$SessionListResCopyWith<$Res> implements $SessionListResCopyWith<$Res> { + factory _$SessionListResCopyWith(_SessionListRes value, $Res Function(_SessionListRes) _then) = __$SessionListResCopyWithImpl; +@override @useResult +$Res call({ + List sessions, String? activeServerId +}); + + + + +} +/// @nodoc +class __$SessionListResCopyWithImpl<$Res> + implements _$SessionListResCopyWith<$Res> { + __$SessionListResCopyWithImpl(this._self, this._then); + + final _SessionListRes _self; + final $Res Function(_SessionListRes) _then; + +/// Create a copy of SessionListRes +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? sessions = null,Object? activeServerId = freezed,}) { + return _then(_SessionListRes( +sessions: null == sessions ? _self._sessions : sessions // ignore: cast_nullable_to_non_nullable +as List,activeServerId: freezed == activeServerId ? _self.activeServerId : activeServerId // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + + +} + +// dart format on diff --git a/lib/core/contracts/auth.g.dart b/lib/core/contracts/auth.g.dart new file mode 100644 index 0000000..82d4705 --- /dev/null +++ b/lib/core/contracts/auth.g.dart @@ -0,0 +1,78 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'auth.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_AuthByNameReq _$AuthByNameReqFromJson(Map json) => + _AuthByNameReq( + serverId: json['serverId'] as String, + username: json['username'] as String, + password: json['password'] as String, + ); + +Map _$AuthByNameReqToJson(_AuthByNameReq instance) => + { + 'serverId': instance.serverId, + 'username': instance.username, + 'password': instance.password, + }; + +_SessionData _$SessionDataFromJson(Map json) => _SessionData( + serverId: json['serverId'] as String, + token: json['token'] as String, + userId: json['userId'] as String, + userName: json['userName'] as String, + password: json['password'] as String?, + createdAt: json['createdAt'] as String, +); + +Map _$SessionDataToJson(_SessionData instance) => + { + 'serverId': instance.serverId, + 'token': instance.token, + 'userId': instance.userId, + 'userName': instance.userName, + 'password': ?instance.password, + 'createdAt': instance.createdAt, + }; + +_AuthedSession _$AuthedSessionFromJson(Map json) => + _AuthedSession( + serverId: json['serverId'] as String, + serverName: json['serverName'] as String, + serverUrl: json['serverUrl'] as String, + token: json['token'] as String, + userId: json['userId'] as String, + userName: json['userName'] as String, + password: json['password'] as String?, + isActive: json['isActive'] as bool, + ); + +Map _$AuthedSessionToJson(_AuthedSession instance) => + { + 'serverId': instance.serverId, + 'serverName': instance.serverName, + 'serverUrl': instance.serverUrl, + 'token': instance.token, + 'userId': instance.userId, + 'userName': instance.userName, + 'password': ?instance.password, + 'isActive': instance.isActive, + }; + +_SessionListRes _$SessionListResFromJson(Map json) => + _SessionListRes( + sessions: (json['sessions'] as List) + .map((e) => AuthedSession.fromJson(e as Map)) + .toList(), + activeServerId: json['activeServerId'] as String?, + ); + +Map _$SessionListResToJson(_SessionListRes instance) => + { + 'sessions': instance.sessions, + 'activeServerId': instance.activeServerId, + }; diff --git a/lib/core/contracts/backup.dart b/lib/core/contracts/backup.dart new file mode 100644 index 0000000..ce3d3e9 --- /dev/null +++ b/lib/core/contracts/backup.dart @@ -0,0 +1,49 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +import 'auth.dart'; +import 'danmaku.dart'; +import 'script_widget.dart'; +import 'server.dart'; + +part 'backup.freezed.dart'; +part 'backup.g.dart'; + + +@freezed +abstract class BackupBundle with _$BackupBundle { + const factory BackupBundle({ + required int version, + required String exportedAt, + required List servers, + required List sessions, + @JsonKey(fromJson: _danmakuSourcesFromJson, toJson: _danmakuSourcesToJson) + @Default([]) + List danmakuSources, + @Default([]) + List scriptWidgets, + @Default([]) + List scriptWidgetSubscriptions, + }) = _BackupBundle; + + factory BackupBundle.fromJson(Map json) => + _$BackupBundleFromJson(json); +} + +List _danmakuSourcesFromJson(Object? raw) { + if (raw is! List) return const []; + final out = []; + for (final item in raw) { + if (item is Map) { + final source = DanmakuSource.fromJson(item); + if (source.url.isNotEmpty) out.add(source); + } + } + return out; +} + +List> _danmakuSourcesToJson(List sources) { + return [ + for (final s in sources) + if (s.url.trim().isNotEmpty) {'url': s.url.trim(), 'name': s.name}, + ]; +} diff --git a/lib/core/contracts/backup.freezed.dart b/lib/core/contracts/backup.freezed.dart new file mode 100644 index 0000000..78fc47d --- /dev/null +++ b/lib/core/contracts/backup.freezed.dart @@ -0,0 +1,325 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'backup.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$BackupBundle { + + int get version; String get exportedAt; List get servers; List get sessions;@JsonKey(fromJson: _danmakuSourcesFromJson, toJson: _danmakuSourcesToJson) List get danmakuSources; List get scriptWidgets; List get scriptWidgetSubscriptions; +/// Create a copy of BackupBundle +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$BackupBundleCopyWith get copyWith => _$BackupBundleCopyWithImpl(this as BackupBundle, _$identity); + + /// Serializes this BackupBundle to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is BackupBundle&&(identical(other.version, version) || other.version == version)&&(identical(other.exportedAt, exportedAt) || other.exportedAt == exportedAt)&&const DeepCollectionEquality().equals(other.servers, servers)&&const DeepCollectionEquality().equals(other.sessions, sessions)&&const DeepCollectionEquality().equals(other.danmakuSources, danmakuSources)&&const DeepCollectionEquality().equals(other.scriptWidgets, scriptWidgets)&&const DeepCollectionEquality().equals(other.scriptWidgetSubscriptions, scriptWidgetSubscriptions)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,version,exportedAt,const DeepCollectionEquality().hash(servers),const DeepCollectionEquality().hash(sessions),const DeepCollectionEquality().hash(danmakuSources),const DeepCollectionEquality().hash(scriptWidgets),const DeepCollectionEquality().hash(scriptWidgetSubscriptions)); + +@override +String toString() { + return 'BackupBundle(version: $version, exportedAt: $exportedAt, servers: $servers, sessions: $sessions, danmakuSources: $danmakuSources, scriptWidgets: $scriptWidgets, scriptWidgetSubscriptions: $scriptWidgetSubscriptions)'; +} + + +} + +/// @nodoc +abstract mixin class $BackupBundleCopyWith<$Res> { + factory $BackupBundleCopyWith(BackupBundle value, $Res Function(BackupBundle) _then) = _$BackupBundleCopyWithImpl; +@useResult +$Res call({ + int version, String exportedAt, List servers, List sessions,@JsonKey(fromJson: _danmakuSourcesFromJson, toJson: _danmakuSourcesToJson) List danmakuSources, List scriptWidgets, List scriptWidgetSubscriptions +}); + + + + +} +/// @nodoc +class _$BackupBundleCopyWithImpl<$Res> + implements $BackupBundleCopyWith<$Res> { + _$BackupBundleCopyWithImpl(this._self, this._then); + + final BackupBundle _self; + final $Res Function(BackupBundle) _then; + +/// Create a copy of BackupBundle +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? version = null,Object? exportedAt = null,Object? servers = null,Object? sessions = null,Object? danmakuSources = null,Object? scriptWidgets = null,Object? scriptWidgetSubscriptions = null,}) { + return _then(_self.copyWith( +version: null == version ? _self.version : version // ignore: cast_nullable_to_non_nullable +as int,exportedAt: null == exportedAt ? _self.exportedAt : exportedAt // ignore: cast_nullable_to_non_nullable +as String,servers: null == servers ? _self.servers : servers // ignore: cast_nullable_to_non_nullable +as List,sessions: null == sessions ? _self.sessions : sessions // ignore: cast_nullable_to_non_nullable +as List,danmakuSources: null == danmakuSources ? _self.danmakuSources : danmakuSources // ignore: cast_nullable_to_non_nullable +as List,scriptWidgets: null == scriptWidgets ? _self.scriptWidgets : scriptWidgets // ignore: cast_nullable_to_non_nullable +as List,scriptWidgetSubscriptions: null == scriptWidgetSubscriptions ? _self.scriptWidgetSubscriptions : scriptWidgetSubscriptions // ignore: cast_nullable_to_non_nullable +as List, + )); +} + +} + + +/// Adds pattern-matching-related methods to [BackupBundle]. +extension BackupBundlePatterns on BackupBundle { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _BackupBundle value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _BackupBundle() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _BackupBundle value) $default,){ +final _that = this; +switch (_that) { +case _BackupBundle(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _BackupBundle value)? $default,){ +final _that = this; +switch (_that) { +case _BackupBundle() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( int version, String exportedAt, List servers, List sessions, @JsonKey(fromJson: _danmakuSourcesFromJson, toJson: _danmakuSourcesToJson) List danmakuSources, List scriptWidgets, List scriptWidgetSubscriptions)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _BackupBundle() when $default != null: +return $default(_that.version,_that.exportedAt,_that.servers,_that.sessions,_that.danmakuSources,_that.scriptWidgets,_that.scriptWidgetSubscriptions);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( int version, String exportedAt, List servers, List sessions, @JsonKey(fromJson: _danmakuSourcesFromJson, toJson: _danmakuSourcesToJson) List danmakuSources, List scriptWidgets, List scriptWidgetSubscriptions) $default,) {final _that = this; +switch (_that) { +case _BackupBundle(): +return $default(_that.version,_that.exportedAt,_that.servers,_that.sessions,_that.danmakuSources,_that.scriptWidgets,_that.scriptWidgetSubscriptions);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( int version, String exportedAt, List servers, List sessions, @JsonKey(fromJson: _danmakuSourcesFromJson, toJson: _danmakuSourcesToJson) List danmakuSources, List scriptWidgets, List scriptWidgetSubscriptions)? $default,) {final _that = this; +switch (_that) { +case _BackupBundle() when $default != null: +return $default(_that.version,_that.exportedAt,_that.servers,_that.sessions,_that.danmakuSources,_that.scriptWidgets,_that.scriptWidgetSubscriptions);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _BackupBundle implements BackupBundle { + const _BackupBundle({required this.version, required this.exportedAt, required final List servers, required final List sessions, @JsonKey(fromJson: _danmakuSourcesFromJson, toJson: _danmakuSourcesToJson) final List danmakuSources = const [], final List scriptWidgets = const [], final List scriptWidgetSubscriptions = const []}): _servers = servers,_sessions = sessions,_danmakuSources = danmakuSources,_scriptWidgets = scriptWidgets,_scriptWidgetSubscriptions = scriptWidgetSubscriptions; + factory _BackupBundle.fromJson(Map json) => _$BackupBundleFromJson(json); + +@override final int version; +@override final String exportedAt; + final List _servers; +@override List get servers { + if (_servers is EqualUnmodifiableListView) return _servers; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_servers); +} + + final List _sessions; +@override List get sessions { + if (_sessions is EqualUnmodifiableListView) return _sessions; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_sessions); +} + + final List _danmakuSources; +@override@JsonKey(fromJson: _danmakuSourcesFromJson, toJson: _danmakuSourcesToJson) List get danmakuSources { + if (_danmakuSources is EqualUnmodifiableListView) return _danmakuSources; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_danmakuSources); +} + + final List _scriptWidgets; +@override@JsonKey() List get scriptWidgets { + if (_scriptWidgets is EqualUnmodifiableListView) return _scriptWidgets; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_scriptWidgets); +} + + final List _scriptWidgetSubscriptions; +@override@JsonKey() List get scriptWidgetSubscriptions { + if (_scriptWidgetSubscriptions is EqualUnmodifiableListView) return _scriptWidgetSubscriptions; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_scriptWidgetSubscriptions); +} + + +/// Create a copy of BackupBundle +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$BackupBundleCopyWith<_BackupBundle> get copyWith => __$BackupBundleCopyWithImpl<_BackupBundle>(this, _$identity); + +@override +Map toJson() { + return _$BackupBundleToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _BackupBundle&&(identical(other.version, version) || other.version == version)&&(identical(other.exportedAt, exportedAt) || other.exportedAt == exportedAt)&&const DeepCollectionEquality().equals(other._servers, _servers)&&const DeepCollectionEquality().equals(other._sessions, _sessions)&&const DeepCollectionEquality().equals(other._danmakuSources, _danmakuSources)&&const DeepCollectionEquality().equals(other._scriptWidgets, _scriptWidgets)&&const DeepCollectionEquality().equals(other._scriptWidgetSubscriptions, _scriptWidgetSubscriptions)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,version,exportedAt,const DeepCollectionEquality().hash(_servers),const DeepCollectionEquality().hash(_sessions),const DeepCollectionEquality().hash(_danmakuSources),const DeepCollectionEquality().hash(_scriptWidgets),const DeepCollectionEquality().hash(_scriptWidgetSubscriptions)); + +@override +String toString() { + return 'BackupBundle(version: $version, exportedAt: $exportedAt, servers: $servers, sessions: $sessions, danmakuSources: $danmakuSources, scriptWidgets: $scriptWidgets, scriptWidgetSubscriptions: $scriptWidgetSubscriptions)'; +} + + +} + +/// @nodoc +abstract mixin class _$BackupBundleCopyWith<$Res> implements $BackupBundleCopyWith<$Res> { + factory _$BackupBundleCopyWith(_BackupBundle value, $Res Function(_BackupBundle) _then) = __$BackupBundleCopyWithImpl; +@override @useResult +$Res call({ + int version, String exportedAt, List servers, List sessions,@JsonKey(fromJson: _danmakuSourcesFromJson, toJson: _danmakuSourcesToJson) List danmakuSources, List scriptWidgets, List scriptWidgetSubscriptions +}); + + + + +} +/// @nodoc +class __$BackupBundleCopyWithImpl<$Res> + implements _$BackupBundleCopyWith<$Res> { + __$BackupBundleCopyWithImpl(this._self, this._then); + + final _BackupBundle _self; + final $Res Function(_BackupBundle) _then; + +/// Create a copy of BackupBundle +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? version = null,Object? exportedAt = null,Object? servers = null,Object? sessions = null,Object? danmakuSources = null,Object? scriptWidgets = null,Object? scriptWidgetSubscriptions = null,}) { + return _then(_BackupBundle( +version: null == version ? _self.version : version // ignore: cast_nullable_to_non_nullable +as int,exportedAt: null == exportedAt ? _self.exportedAt : exportedAt // ignore: cast_nullable_to_non_nullable +as String,servers: null == servers ? _self._servers : servers // ignore: cast_nullable_to_non_nullable +as List,sessions: null == sessions ? _self._sessions : sessions // ignore: cast_nullable_to_non_nullable +as List,danmakuSources: null == danmakuSources ? _self._danmakuSources : danmakuSources // ignore: cast_nullable_to_non_nullable +as List,scriptWidgets: null == scriptWidgets ? _self._scriptWidgets : scriptWidgets // ignore: cast_nullable_to_non_nullable +as List,scriptWidgetSubscriptions: null == scriptWidgetSubscriptions ? _self._scriptWidgetSubscriptions : scriptWidgetSubscriptions // ignore: cast_nullable_to_non_nullable +as List, + )); +} + + +} + +// dart format on diff --git a/lib/core/contracts/backup.g.dart b/lib/core/contracts/backup.g.dart new file mode 100644 index 0000000..c91639a --- /dev/null +++ b/lib/core/contracts/backup.g.dart @@ -0,0 +1,50 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'backup.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_BackupBundle _$BackupBundleFromJson(Map json) => + _BackupBundle( + version: (json['version'] as num).toInt(), + exportedAt: json['exportedAt'] as String, + servers: (json['servers'] as List) + .map((e) => EmbyServer.fromJson(e as Map)) + .toList(), + sessions: (json['sessions'] as List) + .map((e) => SessionData.fromJson(e as Map)) + .toList(), + danmakuSources: json['danmakuSources'] == null + ? const [] + : _danmakuSourcesFromJson(json['danmakuSources']), + scriptWidgets: + (json['scriptWidgets'] as List?) + ?.map( + (e) => + InstalledScriptWidget.fromJson(e as Map), + ) + .toList() ?? + const [], + scriptWidgetSubscriptions: + (json['scriptWidgetSubscriptions'] as List?) + ?.map( + (e) => ScriptWidgetSubscription.fromJson( + e as Map, + ), + ) + .toList() ?? + const [], + ); + +Map _$BackupBundleToJson(_BackupBundle instance) => + { + 'version': instance.version, + 'exportedAt': instance.exportedAt, + 'servers': instance.servers, + 'sessions': instance.sessions, + 'danmakuSources': _danmakuSourcesToJson(instance.danmakuSources), + 'scriptWidgets': instance.scriptWidgets, + 'scriptWidgetSubscriptions': instance.scriptWidgetSubscriptions, + }; diff --git a/lib/core/contracts/cancellation.dart b/lib/core/contracts/cancellation.dart new file mode 100644 index 0000000..d861f81 --- /dev/null +++ b/lib/core/contracts/cancellation.dart @@ -0,0 +1,28 @@ +import 'dart:async'; + +class CancelledException implements Exception { + final String? reason; + const CancelledException([this.reason]); + @override + String toString() => + reason == null ? 'CancelledException' : 'CancelledException: $reason'; +} + +class CancellationToken { + final Completer _completer = Completer(); + String? _reason; + + bool get isCancelled => _completer.isCompleted; + String? get reason => _reason; + Future get whenCancelled => _completer.future; + + void cancel([String? reason]) { + if (_completer.isCompleted) return; + _reason = reason; + _completer.complete(); + } + + void throwIfCancelled() { + if (isCancelled) throw CancelledException(_reason); + } +} diff --git a/lib/core/contracts/contracts.dart b/lib/core/contracts/contracts.dart new file mode 100644 index 0000000..4d14786 --- /dev/null +++ b/lib/core/contracts/contracts.dart @@ -0,0 +1,10 @@ +export 'auth.dart'; +export 'backup.dart'; +export 'cancellation.dart'; +export 'error.dart'; +export 'library.dart'; +export 'playback.dart'; +export 'server.dart'; +export 'tmdb.dart'; +export 'danmaku.dart'; +export 'icon_library.dart'; diff --git a/lib/core/contracts/danmaku.dart b/lib/core/contracts/danmaku.dart new file mode 100644 index 0000000..98af20b --- /dev/null +++ b/lib/core/contracts/danmaku.dart @@ -0,0 +1,354 @@ +import 'package:flutter/painting.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'danmaku.freezed.dart'; +part 'danmaku.g.dart'; + + +class DanmakuPanelSettings { + final bool enabled; + final double opacity; + final double area; + final double fontSize; + final double speed; + final double offset; + final bool showTop; + final bool showScroll; + final bool showBottom; + final bool colored; + final double strokeWidth; + final bool bold; + final String? fontFamily; + final double lineHeight; + final bool fixedToScroll; + final int maxRows; + final bool heatmapEnabled; + + const DanmakuPanelSettings({ + this.enabled = true, + this.opacity = 1.0, + this.area = 0.85, + this.fontSize = 20, + this.speed = 1.0, + this.offset = 0.0, + this.showTop = true, + this.showScroll = true, + this.showBottom = false, + this.colored = true, + this.strokeWidth = 1.5, + this.bold = false, + this.fontFamily, + this.lineHeight = 1.6, + this.fixedToScroll = false, + this.maxRows = 0, + this.heatmapEnabled = true, + }); + + DanmakuPanelSettings copyWith({ + bool? enabled, + double? opacity, + double? area, + double? fontSize, + double? speed, + double? offset, + bool? showTop, + bool? showScroll, + bool? showBottom, + bool? colored, + double? strokeWidth, + bool? bold, + Object? fontFamily = _sentinel, + double? lineHeight, + bool? fixedToScroll, + int? maxRows, + bool? heatmapEnabled, + }) => DanmakuPanelSettings( + enabled: enabled ?? this.enabled, + opacity: opacity ?? this.opacity, + area: area ?? this.area, + fontSize: fontSize ?? this.fontSize, + speed: speed ?? this.speed, + offset: offset ?? this.offset, + showTop: showTop ?? this.showTop, + showScroll: showScroll ?? this.showScroll, + showBottom: showBottom ?? this.showBottom, + colored: colored ?? this.colored, + strokeWidth: strokeWidth ?? this.strokeWidth, + bold: bold ?? this.bold, + fontFamily: fontFamily == _sentinel + ? this.fontFamily + : fontFamily as String?, + lineHeight: lineHeight ?? this.lineHeight, + fixedToScroll: fixedToScroll ?? this.fixedToScroll, + maxRows: maxRows ?? this.maxRows, + heatmapEnabled: heatmapEnabled ?? this.heatmapEnabled, + ); + + @override + bool operator ==(Object other) { + return identical(this, other) || + other is DanmakuPanelSettings && + enabled == other.enabled && + opacity == other.opacity && + area == other.area && + fontSize == other.fontSize && + speed == other.speed && + offset == other.offset && + showTop == other.showTop && + showScroll == other.showScroll && + showBottom == other.showBottom && + colored == other.colored && + strokeWidth == other.strokeWidth && + bold == other.bold && + fontFamily == other.fontFamily && + lineHeight == other.lineHeight && + fixedToScroll == other.fixedToScroll && + maxRows == other.maxRows && + heatmapEnabled == other.heatmapEnabled; + } + + @override + int get hashCode => Object.hash( + enabled, + opacity, + area, + fontSize, + speed, + offset, + showTop, + showScroll, + showBottom, + colored, + strokeWidth, + bold, + fontFamily, + lineHeight, + fixedToScroll, + maxRows, + heatmapEnabled, + ); +} + +const Object _sentinel = Object(); + +class DanmakuSource { + final String id; + final String name; + final String url; + final bool enabled; + + const DanmakuSource({ + required this.id, + required this.name, + required this.url, + this.enabled = true, + }); + + DanmakuSource copyWith({ + String? id, + String? name, + String? url, + bool? enabled, + }) => DanmakuSource( + id: id ?? this.id, + name: name ?? this.name, + url: url ?? this.url, + enabled: enabled ?? this.enabled, + ); + + String get displayName { + final trimmed = name.trim(); + if (trimmed.isNotEmpty) return trimmed; + return url; + } + + Map toJson() => { + 'id': id, + 'name': name, + 'url': url, + 'enabled': enabled, + }; + + factory DanmakuSource.fromJson(Map json) { + final url = (json['url'] ?? '').toString().trim(); + return DanmakuSource( + id: (json['id'] ?? url).toString(), + name: (json['name'] ?? '').toString(), + url: url, + enabled: json['enabled'] is bool ? json['enabled'] as bool : true, + ); + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + other is DanmakuSource && + id == other.id && + name == other.name && + url == other.url && + enabled == other.enabled; + } + + @override + int get hashCode => Object.hash(id, name, url, enabled); +} + + +enum DanmakuCommentStatus { loading, ready, failed } + + +sealed class DanmakuSessionState { + const DanmakuSessionState(); +} + + +class DanmakuSessionIdle extends DanmakuSessionState { + const DanmakuSessionIdle(); +} + + +class DanmakuSessionMatching extends DanmakuSessionState { + const DanmakuSessionMatching(); +} + + +class DanmakuSessionMatched extends DanmakuSessionState { + final List matches; + final int selectedIndex; + final DanmakuCommentStatus commentStatus; + final List comments; + + const DanmakuSessionMatched({ + required this.matches, + required this.selectedIndex, + required this.commentStatus, + this.comments = const [], + }); + + DanmakuSessionMatched copyWith({ + List? matches, + int? selectedIndex, + DanmakuCommentStatus? commentStatus, + List? comments, + }) => DanmakuSessionMatched( + matches: matches ?? this.matches, + selectedIndex: selectedIndex ?? this.selectedIndex, + commentStatus: commentStatus ?? this.commentStatus, + comments: comments ?? this.comments, + ); +} + + +class DanmakuSessionEmpty extends DanmakuSessionState { + const DanmakuSessionEmpty(); +} + + +class DanmakuSessionFailed extends DanmakuSessionState { + final String message; + const DanmakuSessionFailed(this.message); +} + + +@freezed +abstract class DanmakuMatchContext with _$DanmakuMatchContext { + const factory DanmakuMatchContext({ + required String itemId, + required String name, + required String? type, + required String? seriesName, + required int? season, + required int? episode, + required int durationSec, + }) = _DanmakuMatchContext; +} + +@freezed +abstract class DanmakuMatch with _$DanmakuMatch { + const factory DanmakuMatch({ + @JsonKey(fromJson: _intRequired) required int episodeId, + @JsonKey(fromJson: _intOrZero) @Default(0) int animeId, + @Default('') String animeTitle, + @Default('') String episodeTitle, + }) = _DanmakuMatch; + + factory DanmakuMatch.fromJson(Map json) => + _$DanmakuMatchFromJson(json); +} + +class DanmakuMatchCandidate { + final DanmakuSource source; + final DanmakuMatch match; + + const DanmakuMatchCandidate({required this.source, required this.match}); + + int get episodeId => match.episodeId; + int get animeId => match.animeId; + String get animeTitle => match.animeTitle; + String get episodeTitle => match.episodeTitle; + + @override + bool operator ==(Object other) { + return identical(this, other) || + other is DanmakuMatchCandidate && + source == other.source && + match == other.match; + } + + @override + int get hashCode => Object.hash(source, match); +} + + +@freezed +abstract class DanmakuBangumiEpisode with _$DanmakuBangumiEpisode { + const factory DanmakuBangumiEpisode({ + @JsonKey(fromJson: _intRequired) required int episodeId, + @Default('') String episodeTitle, + @JsonKey(fromJson: _stringFromAny) @Default('') String episodeNumber, + }) = _DanmakuBangumiEpisode; + + factory DanmakuBangumiEpisode.fromJson(Map json) => + _$DanmakuBangumiEpisodeFromJson(json); +} + +int _intRequired(Object? value) => (value as num).toInt(); + +int _intOrZero(Object? value) => (value as num?)?.toInt() ?? 0; + +String _stringFromAny(Object? value) => value?.toString() ?? ''; + +class DanmakuComment { + final int cid; + final double time; + final int type; + final Color color; + final String text; + int lane; + + DanmakuComment({ + required this.cid, + required this.time, + required this.type, + required this.color, + required this.text, + this.lane = 0, + }); + + + factory DanmakuComment.fromRaw(int cid, String p, String m) { + final parts = p.split(','); + final time = parts.isNotEmpty ? (double.tryParse(parts[0]) ?? 0.0) : 0.0; + final type = parts.length > 1 ? (int.tryParse(parts[1]) ?? 1) : 1; + final colorInt = parts.length > 2 + ? (int.tryParse(parts[2]) ?? 0xFFFFFF) + : 0xFFFFFF; + return DanmakuComment( + cid: cid, + time: time, + type: type, + color: Color(0xFF000000 | (colorInt & 0xFFFFFF)), + text: m, + ); + } +} diff --git a/lib/core/contracts/danmaku.freezed.dart b/lib/core/contracts/danmaku.freezed.dart new file mode 100644 index 0000000..9cc3eb0 --- /dev/null +++ b/lib/core/contracts/danmaku.freezed.dart @@ -0,0 +1,830 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'danmaku.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$DanmakuMatchContext { + + String get itemId; String get name; String? get type; String? get seriesName; int? get season; int? get episode; int get durationSec; +/// Create a copy of DanmakuMatchContext +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$DanmakuMatchContextCopyWith get copyWith => _$DanmakuMatchContextCopyWithImpl(this as DanmakuMatchContext, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is DanmakuMatchContext&&(identical(other.itemId, itemId) || other.itemId == itemId)&&(identical(other.name, name) || other.name == name)&&(identical(other.type, type) || other.type == type)&&(identical(other.seriesName, seriesName) || other.seriesName == seriesName)&&(identical(other.season, season) || other.season == season)&&(identical(other.episode, episode) || other.episode == episode)&&(identical(other.durationSec, durationSec) || other.durationSec == durationSec)); +} + + +@override +int get hashCode => Object.hash(runtimeType,itemId,name,type,seriesName,season,episode,durationSec); + +@override +String toString() { + return 'DanmakuMatchContext(itemId: $itemId, name: $name, type: $type, seriesName: $seriesName, season: $season, episode: $episode, durationSec: $durationSec)'; +} + + +} + +/// @nodoc +abstract mixin class $DanmakuMatchContextCopyWith<$Res> { + factory $DanmakuMatchContextCopyWith(DanmakuMatchContext value, $Res Function(DanmakuMatchContext) _then) = _$DanmakuMatchContextCopyWithImpl; +@useResult +$Res call({ + String itemId, String name, String? type, String? seriesName, int? season, int? episode, int durationSec +}); + + + + +} +/// @nodoc +class _$DanmakuMatchContextCopyWithImpl<$Res> + implements $DanmakuMatchContextCopyWith<$Res> { + _$DanmakuMatchContextCopyWithImpl(this._self, this._then); + + final DanmakuMatchContext _self; + final $Res Function(DanmakuMatchContext) _then; + +/// Create a copy of DanmakuMatchContext +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? itemId = null,Object? name = null,Object? type = freezed,Object? seriesName = freezed,Object? season = freezed,Object? episode = freezed,Object? durationSec = null,}) { + return _then(_self.copyWith( +itemId: null == itemId ? _self.itemId : itemId // ignore: cast_nullable_to_non_nullable +as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,type: freezed == type ? _self.type : type // ignore: cast_nullable_to_non_nullable +as String?,seriesName: freezed == seriesName ? _self.seriesName : seriesName // ignore: cast_nullable_to_non_nullable +as String?,season: freezed == season ? _self.season : season // ignore: cast_nullable_to_non_nullable +as int?,episode: freezed == episode ? _self.episode : episode // ignore: cast_nullable_to_non_nullable +as int?,durationSec: null == durationSec ? _self.durationSec : durationSec // ignore: cast_nullable_to_non_nullable +as int, + )); +} + +} + + +/// Adds pattern-matching-related methods to [DanmakuMatchContext]. +extension DanmakuMatchContextPatterns on DanmakuMatchContext { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _DanmakuMatchContext value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _DanmakuMatchContext() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _DanmakuMatchContext value) $default,){ +final _that = this; +switch (_that) { +case _DanmakuMatchContext(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _DanmakuMatchContext value)? $default,){ +final _that = this; +switch (_that) { +case _DanmakuMatchContext() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String itemId, String name, String? type, String? seriesName, int? season, int? episode, int durationSec)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _DanmakuMatchContext() when $default != null: +return $default(_that.itemId,_that.name,_that.type,_that.seriesName,_that.season,_that.episode,_that.durationSec);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String itemId, String name, String? type, String? seriesName, int? season, int? episode, int durationSec) $default,) {final _that = this; +switch (_that) { +case _DanmakuMatchContext(): +return $default(_that.itemId,_that.name,_that.type,_that.seriesName,_that.season,_that.episode,_that.durationSec);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String itemId, String name, String? type, String? seriesName, int? season, int? episode, int durationSec)? $default,) {final _that = this; +switch (_that) { +case _DanmakuMatchContext() when $default != null: +return $default(_that.itemId,_that.name,_that.type,_that.seriesName,_that.season,_that.episode,_that.durationSec);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _DanmakuMatchContext implements DanmakuMatchContext { + const _DanmakuMatchContext({required this.itemId, required this.name, required this.type, required this.seriesName, required this.season, required this.episode, required this.durationSec}); + + +@override final String itemId; +@override final String name; +@override final String? type; +@override final String? seriesName; +@override final int? season; +@override final int? episode; +@override final int durationSec; + +/// Create a copy of DanmakuMatchContext +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$DanmakuMatchContextCopyWith<_DanmakuMatchContext> get copyWith => __$DanmakuMatchContextCopyWithImpl<_DanmakuMatchContext>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _DanmakuMatchContext&&(identical(other.itemId, itemId) || other.itemId == itemId)&&(identical(other.name, name) || other.name == name)&&(identical(other.type, type) || other.type == type)&&(identical(other.seriesName, seriesName) || other.seriesName == seriesName)&&(identical(other.season, season) || other.season == season)&&(identical(other.episode, episode) || other.episode == episode)&&(identical(other.durationSec, durationSec) || other.durationSec == durationSec)); +} + + +@override +int get hashCode => Object.hash(runtimeType,itemId,name,type,seriesName,season,episode,durationSec); + +@override +String toString() { + return 'DanmakuMatchContext(itemId: $itemId, name: $name, type: $type, seriesName: $seriesName, season: $season, episode: $episode, durationSec: $durationSec)'; +} + + +} + +/// @nodoc +abstract mixin class _$DanmakuMatchContextCopyWith<$Res> implements $DanmakuMatchContextCopyWith<$Res> { + factory _$DanmakuMatchContextCopyWith(_DanmakuMatchContext value, $Res Function(_DanmakuMatchContext) _then) = __$DanmakuMatchContextCopyWithImpl; +@override @useResult +$Res call({ + String itemId, String name, String? type, String? seriesName, int? season, int? episode, int durationSec +}); + + + + +} +/// @nodoc +class __$DanmakuMatchContextCopyWithImpl<$Res> + implements _$DanmakuMatchContextCopyWith<$Res> { + __$DanmakuMatchContextCopyWithImpl(this._self, this._then); + + final _DanmakuMatchContext _self; + final $Res Function(_DanmakuMatchContext) _then; + +/// Create a copy of DanmakuMatchContext +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? itemId = null,Object? name = null,Object? type = freezed,Object? seriesName = freezed,Object? season = freezed,Object? episode = freezed,Object? durationSec = null,}) { + return _then(_DanmakuMatchContext( +itemId: null == itemId ? _self.itemId : itemId // ignore: cast_nullable_to_non_nullable +as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,type: freezed == type ? _self.type : type // ignore: cast_nullable_to_non_nullable +as String?,seriesName: freezed == seriesName ? _self.seriesName : seriesName // ignore: cast_nullable_to_non_nullable +as String?,season: freezed == season ? _self.season : season // ignore: cast_nullable_to_non_nullable +as int?,episode: freezed == episode ? _self.episode : episode // ignore: cast_nullable_to_non_nullable +as int?,durationSec: null == durationSec ? _self.durationSec : durationSec // ignore: cast_nullable_to_non_nullable +as int, + )); +} + + +} + + +/// @nodoc +mixin _$DanmakuMatch { + +@JsonKey(fromJson: _intRequired) int get episodeId;@JsonKey(fromJson: _intOrZero) int get animeId; String get animeTitle; String get episodeTitle; +/// Create a copy of DanmakuMatch +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$DanmakuMatchCopyWith get copyWith => _$DanmakuMatchCopyWithImpl(this as DanmakuMatch, _$identity); + + /// Serializes this DanmakuMatch to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is DanmakuMatch&&(identical(other.episodeId, episodeId) || other.episodeId == episodeId)&&(identical(other.animeId, animeId) || other.animeId == animeId)&&(identical(other.animeTitle, animeTitle) || other.animeTitle == animeTitle)&&(identical(other.episodeTitle, episodeTitle) || other.episodeTitle == episodeTitle)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,episodeId,animeId,animeTitle,episodeTitle); + +@override +String toString() { + return 'DanmakuMatch(episodeId: $episodeId, animeId: $animeId, animeTitle: $animeTitle, episodeTitle: $episodeTitle)'; +} + + +} + +/// @nodoc +abstract mixin class $DanmakuMatchCopyWith<$Res> { + factory $DanmakuMatchCopyWith(DanmakuMatch value, $Res Function(DanmakuMatch) _then) = _$DanmakuMatchCopyWithImpl; +@useResult +$Res call({ +@JsonKey(fromJson: _intRequired) int episodeId,@JsonKey(fromJson: _intOrZero) int animeId, String animeTitle, String episodeTitle +}); + + + + +} +/// @nodoc +class _$DanmakuMatchCopyWithImpl<$Res> + implements $DanmakuMatchCopyWith<$Res> { + _$DanmakuMatchCopyWithImpl(this._self, this._then); + + final DanmakuMatch _self; + final $Res Function(DanmakuMatch) _then; + +/// Create a copy of DanmakuMatch +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? episodeId = null,Object? animeId = null,Object? animeTitle = null,Object? episodeTitle = null,}) { + return _then(_self.copyWith( +episodeId: null == episodeId ? _self.episodeId : episodeId // ignore: cast_nullable_to_non_nullable +as int,animeId: null == animeId ? _self.animeId : animeId // ignore: cast_nullable_to_non_nullable +as int,animeTitle: null == animeTitle ? _self.animeTitle : animeTitle // ignore: cast_nullable_to_non_nullable +as String,episodeTitle: null == episodeTitle ? _self.episodeTitle : episodeTitle // ignore: cast_nullable_to_non_nullable +as String, + )); +} + +} + + +/// Adds pattern-matching-related methods to [DanmakuMatch]. +extension DanmakuMatchPatterns on DanmakuMatch { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _DanmakuMatch value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _DanmakuMatch() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _DanmakuMatch value) $default,){ +final _that = this; +switch (_that) { +case _DanmakuMatch(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _DanmakuMatch value)? $default,){ +final _that = this; +switch (_that) { +case _DanmakuMatch() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function(@JsonKey(fromJson: _intRequired) int episodeId, @JsonKey(fromJson: _intOrZero) int animeId, String animeTitle, String episodeTitle)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _DanmakuMatch() when $default != null: +return $default(_that.episodeId,_that.animeId,_that.animeTitle,_that.episodeTitle);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function(@JsonKey(fromJson: _intRequired) int episodeId, @JsonKey(fromJson: _intOrZero) int animeId, String animeTitle, String episodeTitle) $default,) {final _that = this; +switch (_that) { +case _DanmakuMatch(): +return $default(_that.episodeId,_that.animeId,_that.animeTitle,_that.episodeTitle);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function(@JsonKey(fromJson: _intRequired) int episodeId, @JsonKey(fromJson: _intOrZero) int animeId, String animeTitle, String episodeTitle)? $default,) {final _that = this; +switch (_that) { +case _DanmakuMatch() when $default != null: +return $default(_that.episodeId,_that.animeId,_that.animeTitle,_that.episodeTitle);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _DanmakuMatch implements DanmakuMatch { + const _DanmakuMatch({@JsonKey(fromJson: _intRequired) required this.episodeId, @JsonKey(fromJson: _intOrZero) this.animeId = 0, this.animeTitle = '', this.episodeTitle = ''}); + factory _DanmakuMatch.fromJson(Map json) => _$DanmakuMatchFromJson(json); + +@override@JsonKey(fromJson: _intRequired) final int episodeId; +@override@JsonKey(fromJson: _intOrZero) final int animeId; +@override@JsonKey() final String animeTitle; +@override@JsonKey() final String episodeTitle; + +/// Create a copy of DanmakuMatch +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$DanmakuMatchCopyWith<_DanmakuMatch> get copyWith => __$DanmakuMatchCopyWithImpl<_DanmakuMatch>(this, _$identity); + +@override +Map toJson() { + return _$DanmakuMatchToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _DanmakuMatch&&(identical(other.episodeId, episodeId) || other.episodeId == episodeId)&&(identical(other.animeId, animeId) || other.animeId == animeId)&&(identical(other.animeTitle, animeTitle) || other.animeTitle == animeTitle)&&(identical(other.episodeTitle, episodeTitle) || other.episodeTitle == episodeTitle)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,episodeId,animeId,animeTitle,episodeTitle); + +@override +String toString() { + return 'DanmakuMatch(episodeId: $episodeId, animeId: $animeId, animeTitle: $animeTitle, episodeTitle: $episodeTitle)'; +} + + +} + +/// @nodoc +abstract mixin class _$DanmakuMatchCopyWith<$Res> implements $DanmakuMatchCopyWith<$Res> { + factory _$DanmakuMatchCopyWith(_DanmakuMatch value, $Res Function(_DanmakuMatch) _then) = __$DanmakuMatchCopyWithImpl; +@override @useResult +$Res call({ +@JsonKey(fromJson: _intRequired) int episodeId,@JsonKey(fromJson: _intOrZero) int animeId, String animeTitle, String episodeTitle +}); + + + + +} +/// @nodoc +class __$DanmakuMatchCopyWithImpl<$Res> + implements _$DanmakuMatchCopyWith<$Res> { + __$DanmakuMatchCopyWithImpl(this._self, this._then); + + final _DanmakuMatch _self; + final $Res Function(_DanmakuMatch) _then; + +/// Create a copy of DanmakuMatch +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? episodeId = null,Object? animeId = null,Object? animeTitle = null,Object? episodeTitle = null,}) { + return _then(_DanmakuMatch( +episodeId: null == episodeId ? _self.episodeId : episodeId // ignore: cast_nullable_to_non_nullable +as int,animeId: null == animeId ? _self.animeId : animeId // ignore: cast_nullable_to_non_nullable +as int,animeTitle: null == animeTitle ? _self.animeTitle : animeTitle // ignore: cast_nullable_to_non_nullable +as String,episodeTitle: null == episodeTitle ? _self.episodeTitle : episodeTitle // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + + +/// @nodoc +mixin _$DanmakuBangumiEpisode { + +@JsonKey(fromJson: _intRequired) int get episodeId; String get episodeTitle;@JsonKey(fromJson: _stringFromAny) String get episodeNumber; +/// Create a copy of DanmakuBangumiEpisode +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$DanmakuBangumiEpisodeCopyWith get copyWith => _$DanmakuBangumiEpisodeCopyWithImpl(this as DanmakuBangumiEpisode, _$identity); + + /// Serializes this DanmakuBangumiEpisode to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is DanmakuBangumiEpisode&&(identical(other.episodeId, episodeId) || other.episodeId == episodeId)&&(identical(other.episodeTitle, episodeTitle) || other.episodeTitle == episodeTitle)&&(identical(other.episodeNumber, episodeNumber) || other.episodeNumber == episodeNumber)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,episodeId,episodeTitle,episodeNumber); + +@override +String toString() { + return 'DanmakuBangumiEpisode(episodeId: $episodeId, episodeTitle: $episodeTitle, episodeNumber: $episodeNumber)'; +} + + +} + +/// @nodoc +abstract mixin class $DanmakuBangumiEpisodeCopyWith<$Res> { + factory $DanmakuBangumiEpisodeCopyWith(DanmakuBangumiEpisode value, $Res Function(DanmakuBangumiEpisode) _then) = _$DanmakuBangumiEpisodeCopyWithImpl; +@useResult +$Res call({ +@JsonKey(fromJson: _intRequired) int episodeId, String episodeTitle,@JsonKey(fromJson: _stringFromAny) String episodeNumber +}); + + + + +} +/// @nodoc +class _$DanmakuBangumiEpisodeCopyWithImpl<$Res> + implements $DanmakuBangumiEpisodeCopyWith<$Res> { + _$DanmakuBangumiEpisodeCopyWithImpl(this._self, this._then); + + final DanmakuBangumiEpisode _self; + final $Res Function(DanmakuBangumiEpisode) _then; + +/// Create a copy of DanmakuBangumiEpisode +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? episodeId = null,Object? episodeTitle = null,Object? episodeNumber = null,}) { + return _then(_self.copyWith( +episodeId: null == episodeId ? _self.episodeId : episodeId // ignore: cast_nullable_to_non_nullable +as int,episodeTitle: null == episodeTitle ? _self.episodeTitle : episodeTitle // ignore: cast_nullable_to_non_nullable +as String,episodeNumber: null == episodeNumber ? _self.episodeNumber : episodeNumber // ignore: cast_nullable_to_non_nullable +as String, + )); +} + +} + + +/// Adds pattern-matching-related methods to [DanmakuBangumiEpisode]. +extension DanmakuBangumiEpisodePatterns on DanmakuBangumiEpisode { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _DanmakuBangumiEpisode value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _DanmakuBangumiEpisode() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _DanmakuBangumiEpisode value) $default,){ +final _that = this; +switch (_that) { +case _DanmakuBangumiEpisode(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _DanmakuBangumiEpisode value)? $default,){ +final _that = this; +switch (_that) { +case _DanmakuBangumiEpisode() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function(@JsonKey(fromJson: _intRequired) int episodeId, String episodeTitle, @JsonKey(fromJson: _stringFromAny) String episodeNumber)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _DanmakuBangumiEpisode() when $default != null: +return $default(_that.episodeId,_that.episodeTitle,_that.episodeNumber);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function(@JsonKey(fromJson: _intRequired) int episodeId, String episodeTitle, @JsonKey(fromJson: _stringFromAny) String episodeNumber) $default,) {final _that = this; +switch (_that) { +case _DanmakuBangumiEpisode(): +return $default(_that.episodeId,_that.episodeTitle,_that.episodeNumber);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function(@JsonKey(fromJson: _intRequired) int episodeId, String episodeTitle, @JsonKey(fromJson: _stringFromAny) String episodeNumber)? $default,) {final _that = this; +switch (_that) { +case _DanmakuBangumiEpisode() when $default != null: +return $default(_that.episodeId,_that.episodeTitle,_that.episodeNumber);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _DanmakuBangumiEpisode implements DanmakuBangumiEpisode { + const _DanmakuBangumiEpisode({@JsonKey(fromJson: _intRequired) required this.episodeId, this.episodeTitle = '', @JsonKey(fromJson: _stringFromAny) this.episodeNumber = ''}); + factory _DanmakuBangumiEpisode.fromJson(Map json) => _$DanmakuBangumiEpisodeFromJson(json); + +@override@JsonKey(fromJson: _intRequired) final int episodeId; +@override@JsonKey() final String episodeTitle; +@override@JsonKey(fromJson: _stringFromAny) final String episodeNumber; + +/// Create a copy of DanmakuBangumiEpisode +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$DanmakuBangumiEpisodeCopyWith<_DanmakuBangumiEpisode> get copyWith => __$DanmakuBangumiEpisodeCopyWithImpl<_DanmakuBangumiEpisode>(this, _$identity); + +@override +Map toJson() { + return _$DanmakuBangumiEpisodeToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _DanmakuBangumiEpisode&&(identical(other.episodeId, episodeId) || other.episodeId == episodeId)&&(identical(other.episodeTitle, episodeTitle) || other.episodeTitle == episodeTitle)&&(identical(other.episodeNumber, episodeNumber) || other.episodeNumber == episodeNumber)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,episodeId,episodeTitle,episodeNumber); + +@override +String toString() { + return 'DanmakuBangumiEpisode(episodeId: $episodeId, episodeTitle: $episodeTitle, episodeNumber: $episodeNumber)'; +} + + +} + +/// @nodoc +abstract mixin class _$DanmakuBangumiEpisodeCopyWith<$Res> implements $DanmakuBangumiEpisodeCopyWith<$Res> { + factory _$DanmakuBangumiEpisodeCopyWith(_DanmakuBangumiEpisode value, $Res Function(_DanmakuBangumiEpisode) _then) = __$DanmakuBangumiEpisodeCopyWithImpl; +@override @useResult +$Res call({ +@JsonKey(fromJson: _intRequired) int episodeId, String episodeTitle,@JsonKey(fromJson: _stringFromAny) String episodeNumber +}); + + + + +} +/// @nodoc +class __$DanmakuBangumiEpisodeCopyWithImpl<$Res> + implements _$DanmakuBangumiEpisodeCopyWith<$Res> { + __$DanmakuBangumiEpisodeCopyWithImpl(this._self, this._then); + + final _DanmakuBangumiEpisode _self; + final $Res Function(_DanmakuBangumiEpisode) _then; + +/// Create a copy of DanmakuBangumiEpisode +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? episodeId = null,Object? episodeTitle = null,Object? episodeNumber = null,}) { + return _then(_DanmakuBangumiEpisode( +episodeId: null == episodeId ? _self.episodeId : episodeId // ignore: cast_nullable_to_non_nullable +as int,episodeTitle: null == episodeTitle ? _self.episodeTitle : episodeTitle // ignore: cast_nullable_to_non_nullable +as String,episodeNumber: null == episodeNumber ? _self.episodeNumber : episodeNumber // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + +// dart format on diff --git a/lib/core/contracts/danmaku.g.dart b/lib/core/contracts/danmaku.g.dart new file mode 100644 index 0000000..53b05a3 --- /dev/null +++ b/lib/core/contracts/danmaku.g.dart @@ -0,0 +1,41 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'danmaku.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_DanmakuMatch _$DanmakuMatchFromJson(Map json) => + _DanmakuMatch( + episodeId: _intRequired(json['episodeId']), + animeId: json['animeId'] == null ? 0 : _intOrZero(json['animeId']), + animeTitle: json['animeTitle'] as String? ?? '', + episodeTitle: json['episodeTitle'] as String? ?? '', + ); + +Map _$DanmakuMatchToJson(_DanmakuMatch instance) => + { + 'episodeId': instance.episodeId, + 'animeId': instance.animeId, + 'animeTitle': instance.animeTitle, + 'episodeTitle': instance.episodeTitle, + }; + +_DanmakuBangumiEpisode _$DanmakuBangumiEpisodeFromJson( + Map json, +) => _DanmakuBangumiEpisode( + episodeId: _intRequired(json['episodeId']), + episodeTitle: json['episodeTitle'] as String? ?? '', + episodeNumber: json['episodeNumber'] == null + ? '' + : _stringFromAny(json['episodeNumber']), +); + +Map _$DanmakuBangumiEpisodeToJson( + _DanmakuBangumiEpisode instance, +) => { + 'episodeId': instance.episodeId, + 'episodeTitle': instance.episodeTitle, + 'episodeNumber': instance.episodeNumber, +}; diff --git a/lib/core/contracts/error.dart b/lib/core/contracts/error.dart new file mode 100644 index 0000000..b841aa6 --- /dev/null +++ b/lib/core/contracts/error.dart @@ -0,0 +1,14 @@ +enum ErrorCode { + invalidParams('INVALID_PARAMS'), + serverUnreachable('SERVER_UNREACHABLE'), + serverTimeout('SERVER_TIMEOUT'), + serverHttpError('SERVER_HTTP_ERROR'), + authInvalidCredentials('AUTH_INVALID_CREDENTIALS'), + authForbidden('AUTH_FORBIDDEN'), + storeConflict('STORE_CONFLICT'), + storeNotFound('STORE_NOT_FOUND'), + internalError('INTERNAL_ERROR'); + + final String value; + const ErrorCode(this.value); +} diff --git a/lib/core/contracts/icon_library.dart b/lib/core/contracts/icon_library.dart new file mode 100644 index 0000000..a87c36d --- /dev/null +++ b/lib/core/contracts/icon_library.dart @@ -0,0 +1,49 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'icon_library.freezed.dart'; +part 'icon_library.g.dart'; + +@freezed +abstract class IconEntry with _$IconEntry { + const factory IconEntry({ + @JsonKey(fromJson: _stringOrEmpty) @Default('') String name, + @JsonKey(fromJson: _stringOrEmpty) @Default('') String url, + }) = _IconEntry; + + factory IconEntry.fromJson(Map json) => + _$IconEntryFromJson(json); +} + +@freezed +abstract class IconLibrary with _$IconLibrary { + const factory IconLibrary({ + + required String sourceUrl, + @JsonKey(fromJson: _stringOrEmpty) @Default('') String name, + @JsonKey(fromJson: _stringOrEmpty) @Default('') String description, + @Default([]) List icons, + }) = _IconLibrary; + + factory IconLibrary.fromJson(Map json) => + _$IconLibraryFromJson(json); + + + static IconLibrary parse(String sourceUrl, Map json) { + final raw = json['icons']; + final iconsList = raw is List + ? raw + .whereType>() + .map(IconEntry.fromJson) + .where((e) => e.url.isNotEmpty) + .toList() + : const []; + return IconLibrary( + sourceUrl: sourceUrl, + name: (json['name'] as String?) ?? '', + description: (json['description'] as String?) ?? '', + icons: iconsList, + ); + } +} + +String _stringOrEmpty(Object? value) => value?.toString() ?? ''; diff --git a/lib/core/contracts/icon_library.freezed.dart b/lib/core/contracts/icon_library.freezed.dart new file mode 100644 index 0000000..9580141 --- /dev/null +++ b/lib/core/contracts/icon_library.freezed.dart @@ -0,0 +1,560 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'icon_library.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$IconEntry { + +@JsonKey(fromJson: _stringOrEmpty) String get name;@JsonKey(fromJson: _stringOrEmpty) String get url; +/// Create a copy of IconEntry +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$IconEntryCopyWith get copyWith => _$IconEntryCopyWithImpl(this as IconEntry, _$identity); + + /// Serializes this IconEntry to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is IconEntry&&(identical(other.name, name) || other.name == name)&&(identical(other.url, url) || other.url == url)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,name,url); + +@override +String toString() { + return 'IconEntry(name: $name, url: $url)'; +} + + +} + +/// @nodoc +abstract mixin class $IconEntryCopyWith<$Res> { + factory $IconEntryCopyWith(IconEntry value, $Res Function(IconEntry) _then) = _$IconEntryCopyWithImpl; +@useResult +$Res call({ +@JsonKey(fromJson: _stringOrEmpty) String name,@JsonKey(fromJson: _stringOrEmpty) String url +}); + + + + +} +/// @nodoc +class _$IconEntryCopyWithImpl<$Res> + implements $IconEntryCopyWith<$Res> { + _$IconEntryCopyWithImpl(this._self, this._then); + + final IconEntry _self; + final $Res Function(IconEntry) _then; + +/// Create a copy of IconEntry +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? name = null,Object? url = null,}) { + return _then(_self.copyWith( +name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,url: null == url ? _self.url : url // ignore: cast_nullable_to_non_nullable +as String, + )); +} + +} + + +/// Adds pattern-matching-related methods to [IconEntry]. +extension IconEntryPatterns on IconEntry { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _IconEntry value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _IconEntry() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _IconEntry value) $default,){ +final _that = this; +switch (_that) { +case _IconEntry(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _IconEntry value)? $default,){ +final _that = this; +switch (_that) { +case _IconEntry() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function(@JsonKey(fromJson: _stringOrEmpty) String name, @JsonKey(fromJson: _stringOrEmpty) String url)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _IconEntry() when $default != null: +return $default(_that.name,_that.url);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function(@JsonKey(fromJson: _stringOrEmpty) String name, @JsonKey(fromJson: _stringOrEmpty) String url) $default,) {final _that = this; +switch (_that) { +case _IconEntry(): +return $default(_that.name,_that.url);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function(@JsonKey(fromJson: _stringOrEmpty) String name, @JsonKey(fromJson: _stringOrEmpty) String url)? $default,) {final _that = this; +switch (_that) { +case _IconEntry() when $default != null: +return $default(_that.name,_that.url);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _IconEntry implements IconEntry { + const _IconEntry({@JsonKey(fromJson: _stringOrEmpty) this.name = '', @JsonKey(fromJson: _stringOrEmpty) this.url = ''}); + factory _IconEntry.fromJson(Map json) => _$IconEntryFromJson(json); + +@override@JsonKey(fromJson: _stringOrEmpty) final String name; +@override@JsonKey(fromJson: _stringOrEmpty) final String url; + +/// Create a copy of IconEntry +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$IconEntryCopyWith<_IconEntry> get copyWith => __$IconEntryCopyWithImpl<_IconEntry>(this, _$identity); + +@override +Map toJson() { + return _$IconEntryToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _IconEntry&&(identical(other.name, name) || other.name == name)&&(identical(other.url, url) || other.url == url)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,name,url); + +@override +String toString() { + return 'IconEntry(name: $name, url: $url)'; +} + + +} + +/// @nodoc +abstract mixin class _$IconEntryCopyWith<$Res> implements $IconEntryCopyWith<$Res> { + factory _$IconEntryCopyWith(_IconEntry value, $Res Function(_IconEntry) _then) = __$IconEntryCopyWithImpl; +@override @useResult +$Res call({ +@JsonKey(fromJson: _stringOrEmpty) String name,@JsonKey(fromJson: _stringOrEmpty) String url +}); + + + + +} +/// @nodoc +class __$IconEntryCopyWithImpl<$Res> + implements _$IconEntryCopyWith<$Res> { + __$IconEntryCopyWithImpl(this._self, this._then); + + final _IconEntry _self; + final $Res Function(_IconEntry) _then; + +/// Create a copy of IconEntry +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? name = null,Object? url = null,}) { + return _then(_IconEntry( +name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,url: null == url ? _self.url : url // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + + +/// @nodoc +mixin _$IconLibrary { + +/// 来源 URL(去除尾随斜杠后的标准化字符串) + String get sourceUrl;@JsonKey(fromJson: _stringOrEmpty) String get name;@JsonKey(fromJson: _stringOrEmpty) String get description; List get icons; +/// Create a copy of IconLibrary +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$IconLibraryCopyWith get copyWith => _$IconLibraryCopyWithImpl(this as IconLibrary, _$identity); + + /// Serializes this IconLibrary to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is IconLibrary&&(identical(other.sourceUrl, sourceUrl) || other.sourceUrl == sourceUrl)&&(identical(other.name, name) || other.name == name)&&(identical(other.description, description) || other.description == description)&&const DeepCollectionEquality().equals(other.icons, icons)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,sourceUrl,name,description,const DeepCollectionEquality().hash(icons)); + +@override +String toString() { + return 'IconLibrary(sourceUrl: $sourceUrl, name: $name, description: $description, icons: $icons)'; +} + + +} + +/// @nodoc +abstract mixin class $IconLibraryCopyWith<$Res> { + factory $IconLibraryCopyWith(IconLibrary value, $Res Function(IconLibrary) _then) = _$IconLibraryCopyWithImpl; +@useResult +$Res call({ + String sourceUrl,@JsonKey(fromJson: _stringOrEmpty) String name,@JsonKey(fromJson: _stringOrEmpty) String description, List icons +}); + + + + +} +/// @nodoc +class _$IconLibraryCopyWithImpl<$Res> + implements $IconLibraryCopyWith<$Res> { + _$IconLibraryCopyWithImpl(this._self, this._then); + + final IconLibrary _self; + final $Res Function(IconLibrary) _then; + +/// Create a copy of IconLibrary +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? sourceUrl = null,Object? name = null,Object? description = null,Object? icons = null,}) { + return _then(_self.copyWith( +sourceUrl: null == sourceUrl ? _self.sourceUrl : sourceUrl // ignore: cast_nullable_to_non_nullable +as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,description: null == description ? _self.description : description // ignore: cast_nullable_to_non_nullable +as String,icons: null == icons ? _self.icons : icons // ignore: cast_nullable_to_non_nullable +as List, + )); +} + +} + + +/// Adds pattern-matching-related methods to [IconLibrary]. +extension IconLibraryPatterns on IconLibrary { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _IconLibrary value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _IconLibrary() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _IconLibrary value) $default,){ +final _that = this; +switch (_that) { +case _IconLibrary(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _IconLibrary value)? $default,){ +final _that = this; +switch (_that) { +case _IconLibrary() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String sourceUrl, @JsonKey(fromJson: _stringOrEmpty) String name, @JsonKey(fromJson: _stringOrEmpty) String description, List icons)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _IconLibrary() when $default != null: +return $default(_that.sourceUrl,_that.name,_that.description,_that.icons);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String sourceUrl, @JsonKey(fromJson: _stringOrEmpty) String name, @JsonKey(fromJson: _stringOrEmpty) String description, List icons) $default,) {final _that = this; +switch (_that) { +case _IconLibrary(): +return $default(_that.sourceUrl,_that.name,_that.description,_that.icons);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String sourceUrl, @JsonKey(fromJson: _stringOrEmpty) String name, @JsonKey(fromJson: _stringOrEmpty) String description, List icons)? $default,) {final _that = this; +switch (_that) { +case _IconLibrary() when $default != null: +return $default(_that.sourceUrl,_that.name,_that.description,_that.icons);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _IconLibrary implements IconLibrary { + const _IconLibrary({required this.sourceUrl, @JsonKey(fromJson: _stringOrEmpty) this.name = '', @JsonKey(fromJson: _stringOrEmpty) this.description = '', final List icons = const []}): _icons = icons; + factory _IconLibrary.fromJson(Map json) => _$IconLibraryFromJson(json); + +/// 来源 URL(去除尾随斜杠后的标准化字符串) +@override final String sourceUrl; +@override@JsonKey(fromJson: _stringOrEmpty) final String name; +@override@JsonKey(fromJson: _stringOrEmpty) final String description; + final List _icons; +@override@JsonKey() List get icons { + if (_icons is EqualUnmodifiableListView) return _icons; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_icons); +} + + +/// Create a copy of IconLibrary +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$IconLibraryCopyWith<_IconLibrary> get copyWith => __$IconLibraryCopyWithImpl<_IconLibrary>(this, _$identity); + +@override +Map toJson() { + return _$IconLibraryToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _IconLibrary&&(identical(other.sourceUrl, sourceUrl) || other.sourceUrl == sourceUrl)&&(identical(other.name, name) || other.name == name)&&(identical(other.description, description) || other.description == description)&&const DeepCollectionEquality().equals(other._icons, _icons)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,sourceUrl,name,description,const DeepCollectionEquality().hash(_icons)); + +@override +String toString() { + return 'IconLibrary(sourceUrl: $sourceUrl, name: $name, description: $description, icons: $icons)'; +} + + +} + +/// @nodoc +abstract mixin class _$IconLibraryCopyWith<$Res> implements $IconLibraryCopyWith<$Res> { + factory _$IconLibraryCopyWith(_IconLibrary value, $Res Function(_IconLibrary) _then) = __$IconLibraryCopyWithImpl; +@override @useResult +$Res call({ + String sourceUrl,@JsonKey(fromJson: _stringOrEmpty) String name,@JsonKey(fromJson: _stringOrEmpty) String description, List icons +}); + + + + +} +/// @nodoc +class __$IconLibraryCopyWithImpl<$Res> + implements _$IconLibraryCopyWith<$Res> { + __$IconLibraryCopyWithImpl(this._self, this._then); + + final _IconLibrary _self; + final $Res Function(_IconLibrary) _then; + +/// Create a copy of IconLibrary +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? sourceUrl = null,Object? name = null,Object? description = null,Object? icons = null,}) { + return _then(_IconLibrary( +sourceUrl: null == sourceUrl ? _self.sourceUrl : sourceUrl // ignore: cast_nullable_to_non_nullable +as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,description: null == description ? _self.description : description // ignore: cast_nullable_to_non_nullable +as String,icons: null == icons ? _self._icons : icons // ignore: cast_nullable_to_non_nullable +as List, + )); +} + + +} + +// dart format on diff --git a/lib/core/contracts/icon_library.g.dart b/lib/core/contracts/icon_library.g.dart new file mode 100644 index 0000000..b380ead --- /dev/null +++ b/lib/core/contracts/icon_library.g.dart @@ -0,0 +1,36 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'icon_library.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_IconEntry _$IconEntryFromJson(Map json) => _IconEntry( + name: json['name'] == null ? '' : _stringOrEmpty(json['name']), + url: json['url'] == null ? '' : _stringOrEmpty(json['url']), +); + +Map _$IconEntryToJson(_IconEntry instance) => + {'name': instance.name, 'url': instance.url}; + +_IconLibrary _$IconLibraryFromJson(Map json) => _IconLibrary( + sourceUrl: json['sourceUrl'] as String, + name: json['name'] == null ? '' : _stringOrEmpty(json['name']), + description: json['description'] == null + ? '' + : _stringOrEmpty(json['description']), + icons: + (json['icons'] as List?) + ?.map((e) => IconEntry.fromJson(e as Map)) + .toList() ?? + const [], +); + +Map _$IconLibraryToJson(_IconLibrary instance) => + { + 'sourceUrl': instance.sourceUrl, + 'name': instance.name, + 'description': instance.description, + 'icons': instance.icons, + }; diff --git a/lib/core/contracts/library.dart b/lib/core/contracts/library.dart new file mode 100644 index 0000000..3af33ed --- /dev/null +++ b/lib/core/contracts/library.dart @@ -0,0 +1,722 @@ + + +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'library.freezed.dart'; +part 'library.g.dart'; + +@freezed +abstract class EmbyRawUserData with _$EmbyRawUserData { + const factory EmbyRawUserData({ + @JsonKey(fromJson: _intOrNull, includeIfNull: false) + int? PlaybackPositionTicks, + @JsonKey(fromJson: _intOrNull, includeIfNull: false) int? PlayCount, + @JsonKey(fromJson: _intOrNull, includeIfNull: false) int? UnplayedItemCount, + @JsonKey(includeIfNull: false) bool? IsFavorite, + @JsonKey(includeIfNull: false) bool? Played, + @JsonKey(includeFromJson: false, includeToJson: false) + @Default({}) + Map extra, + }) = _EmbyRawUserData; + + factory EmbyRawUserData.fromJson(Map json) => + _$EmbyRawUserDataFromJson(json).copyWith(extra: json); +} + +@freezed +abstract class EmbyRawItem with _$EmbyRawItem { + const factory EmbyRawItem({ + required String Id, + required String Name, + @JsonKey(includeIfNull: false) String? Type, + @JsonKey(includeIfNull: false) String? Overview, + @JsonKey(fromJson: _intOrNull, includeIfNull: false) int? ProductionYear, + @JsonKey(fromJson: _doubleOrNull, includeIfNull: false) + double? CommunityRating, + @JsonKey(includeIfNull: false) String? PremiereDate, + @JsonKey(includeIfNull: false) String? SeriesName, + @JsonKey(includeIfNull: false) String? SeriesId, + @JsonKey(includeIfNull: false) String? SeasonId, + @JsonKey(includeIfNull: false) String? SeasonName, + @JsonKey(fromJson: _intOrNull, includeIfNull: false) int? IndexNumber, + @JsonKey(includeIfNull: false) String? ParentId, + @JsonKey(includeIfNull: false) String? PrimaryImageTag, + @JsonKey(fromJson: _stringMapOrNull, includeIfNull: false) + Map? ImageTags, + @JsonKey(fromJson: _stringListOrNull, includeIfNull: false) + List? BackdropImageTags, + @JsonKey(fromJson: _intOrNull, includeIfNull: false) int? RunTimeTicks, + @JsonKey(includeIfNull: false) EmbyRawUserData? UserData, + @JsonKey(includeFromJson: false, includeToJson: false) + @Default({}) + Map extra, + }) = _EmbyRawItem; + + factory EmbyRawItem.fromJson(Map json) => + _$EmbyRawItemFromJson(json).copyWith(extra: json); +} + +@freezed +abstract class EmbyRawSeason with _$EmbyRawSeason { + const factory EmbyRawSeason({ + required String Id, + required String Name, + @JsonKey(fromJson: _intOrNull, includeIfNull: false) int? IndexNumber, + @JsonKey(fromJson: _intOrNull, includeIfNull: false) int? ProductionYear, + @JsonKey(fromJson: _intOrNull, includeIfNull: false) int? ChildCount, + @JsonKey(includeIfNull: false) String? PrimaryImageTag, + @JsonKey(fromJson: _stringMapOrNull, includeIfNull: false) + Map? ImageTags, + }) = _EmbyRawSeason; + + factory EmbyRawSeason.fromJson(Map json) => + _$EmbyRawSeasonFromJson(json); +} + +@freezed +abstract class EmbyRawLibrary with _$EmbyRawLibrary { + const factory EmbyRawLibrary({ + required String Id, + required String Name, + @JsonKey(includeIfNull: false) String? CollectionType, + @JsonKey(fromJson: _stringMapOrNull, includeIfNull: false) + Map? ImageTags, + }) = _EmbyRawLibrary; + + factory EmbyRawLibrary.fromJson(Map json) => + _$EmbyRawLibraryFromJson(json); +} + +@freezed +abstract class EmbyRawLibraries with _$EmbyRawLibraries { + const factory EmbyRawLibraries({required List Items}) = + _EmbyRawLibraries; + + factory EmbyRawLibraries.fromJson(Map json) => + _$EmbyRawLibrariesFromJson(json); +} + +@freezed +abstract class EmbyRawMediaStream with _$EmbyRawMediaStream { + const factory EmbyRawMediaStream({ + @JsonKey(fromJson: _intOrNull, includeIfNull: false) int? Index, + @JsonKey(includeIfNull: false) String? Type, + @JsonKey(includeIfNull: false) String? Language, + @JsonKey(includeIfNull: false) String? Codec, + @JsonKey(fromJson: _intOrNull, includeIfNull: false) int? Channels, + @JsonKey(includeIfNull: false) String? DisplayTitle, + @JsonKey(includeIfNull: false) String? Title, + @JsonKey(includeIfNull: false) bool? IsDefault, + @JsonKey(includeIfNull: false) bool? IsForced, + @JsonKey(includeIfNull: false) bool? IsExternal, + @JsonKey(includeIfNull: false) String? DeliveryMethod, + @JsonKey(includeIfNull: false) String? DeliveryUrl, + @JsonKey(includeFromJson: false, includeToJson: false) + @Default({}) + Map extra, + }) = _EmbyRawMediaStream; + + factory EmbyRawMediaStream.fromJson(Map json) => + _$EmbyRawMediaStreamFromJson(json).copyWith(extra: json); +} + +@freezed +abstract class EmbyRawMediaSource with _$EmbyRawMediaSource { + const factory EmbyRawMediaSource({ + @JsonKey(includeIfNull: false) String? Id, + @JsonKey(includeIfNull: false) String? Name, + @JsonKey(includeIfNull: false) String? Container, + @JsonKey(fromJson: _intOrNull, includeIfNull: false) int? Bitrate, + @JsonKey(fromJson: _intOrNull, includeIfNull: false) int? Size, + @JsonKey(fromJson: _intOrNull, includeIfNull: false) int? Width, + @JsonKey(fromJson: _intOrNull, includeIfNull: false) int? Height, + @JsonKey(fromJson: _intOrNull, includeIfNull: false) + int? DefaultSubtitleStreamIndex, + @JsonKey(fromJson: _intOrNull, includeIfNull: false) + int? DefaultAudioStreamIndex, + @JsonKey(includeIfNull: false) List? MediaStreams, + }) = _EmbyRawMediaSource; + + factory EmbyRawMediaSource.fromJson(Map json) => + _$EmbyRawMediaSourceFromJson(json); +} + +@freezed +abstract class LibraryLatestReq with _$LibraryLatestReq { + const LibraryLatestReq._(); + const factory LibraryLatestReq({required String parentId, int? limit}) = + _LibraryLatestReq; + + Map toJson() => { + 'parentId': parentId, + if (limit != null) 'limit': limit, + }; +} + +@freezed +abstract class LibraryLatestRes with _$LibraryLatestRes { + const factory LibraryLatestRes({ + required String parentId, + required List items, + @Default(0) int totalCount, + }) = _LibraryLatestRes; + + factory LibraryLatestRes.fromJson(Map json) => + _$LibraryLatestResFromJson(json); +} + +@freezed +abstract class LibraryResumeRes with _$LibraryResumeRes { + const factory LibraryResumeRes({required List Items}) = + _LibraryResumeRes; + + factory LibraryResumeRes.fromJson(Map json) => + _$LibraryResumeResFromJson(json); +} + +typedef LibraryViewsRes = EmbyRawLibraries; + +@freezed +abstract class MediaDetailReq with _$MediaDetailReq { + const MediaDetailReq._(); + const factory MediaDetailReq({required String itemId}) = _MediaDetailReq; + + Map toJson() => {'itemId': itemId}; +} + +@freezed +abstract class EmbyRawMediaDetailSeriesExtra + with _$EmbyRawMediaDetailSeriesExtra { + const factory EmbyRawMediaDetailSeriesExtra({ + required List nextUp, + required List seasons, + }) = _EmbyRawMediaDetailSeriesExtra; +} + +@freezed +abstract class EmbyRawMediaDetailSeriesContext + with _$EmbyRawMediaDetailSeriesContext { + const factory EmbyRawMediaDetailSeriesContext({ + required EmbyRawItem seriesBase, + required EmbyRawMediaDetailSeriesExtra seriesExtra, + }) = _EmbyRawMediaDetailSeriesContext; +} + +@freezed +abstract class MediaDetailRes with _$MediaDetailRes { + const factory MediaDetailRes({ + required EmbyRawItem base, + EmbyRawMediaDetailSeriesExtra? seriesExtra, + EmbyRawMediaDetailSeriesContext? seriesContext, + List? similarItems, + List? boxSetItems, + }) = _MediaDetailRes; +} + +@freezed +abstract class FavoriteSetReq with _$FavoriteSetReq { + const FavoriteSetReq._(); + const factory FavoriteSetReq({ + required String itemId, + required bool isFavorite, + }) = _FavoriteSetReq; + + Map toJson() => {'itemId': itemId, 'isFavorite': isFavorite}; +} + +@freezed +abstract class FavoriteSetRes with _$FavoriteSetRes { + const factory FavoriteSetRes({ + required String itemId, + required bool isFavorite, + }) = _FavoriteSetRes; + + factory FavoriteSetRes.fromJson(Map json) => + _$FavoriteSetResFromJson(json); +} + +@freezed +abstract class PlayedSetReq with _$PlayedSetReq { + const PlayedSetReq._(); + const factory PlayedSetReq({required String itemId, required bool played}) = + _PlayedSetReq; + + Map toJson() => {'itemId': itemId, 'played': played}; +} + +@freezed +abstract class PlayedSetRes with _$PlayedSetRes { + const factory PlayedSetRes({required String itemId, required bool played}) = + _PlayedSetRes; + + factory PlayedSetRes.fromJson(Map json) => + _$PlayedSetResFromJson(json); +} + +@freezed +abstract class HideFromResumeReq with _$HideFromResumeReq { + const HideFromResumeReq._(); + const factory HideFromResumeReq({ + required String itemId, + required bool hide, + }) = _HideFromResumeReq; + + Map toJson() => {'itemId': itemId, 'hide': hide}; +} + +@freezed +abstract class HideFromResumeRes with _$HideFromResumeRes { + const factory HideFromResumeRes({ + required String itemId, + required bool hide, + }) = _HideFromResumeRes; + + factory HideFromResumeRes.fromJson(Map json) => + _$HideFromResumeResFromJson(json); +} + +@freezed +abstract class MediaEpisodesReq with _$MediaEpisodesReq { + const MediaEpisodesReq._(); + const factory MediaEpisodesReq({ + required String seriesId, + required String seasonId, + }) = _MediaEpisodesReq; + + Map toJson() => {'seriesId': seriesId, 'seasonId': seasonId}; +} + +@freezed +abstract class MediaEpisodesRes with _$MediaEpisodesRes { + const factory MediaEpisodesRes({ + required String seriesId, + required String seasonId, + required List items, + }) = _MediaEpisodesRes; +} + +@freezed +abstract class SeriesSeasonsReq with _$SeriesSeasonsReq { + const SeriesSeasonsReq._(); + const factory SeriesSeasonsReq({required String seriesId}) = + _SeriesSeasonsReq; + + Map toJson() => {'seriesId': seriesId}; +} + +@freezed +abstract class SeriesSeasonsRes with _$SeriesSeasonsRes { + const factory SeriesSeasonsRes({ + required String seriesId, + required List items, + }) = _SeriesSeasonsRes; +} + +@freezed +abstract class SimilarItemsReq with _$SimilarItemsReq { + const SimilarItemsReq._(); + const factory SimilarItemsReq({required String itemId, int? limit}) = + _SimilarItemsReq; + + Map toJson() => { + 'itemId': itemId, + if (limit != null) 'limit': limit, + }; +} + +@freezed +abstract class SimilarItemsRes with _$SimilarItemsRes { + const factory SimilarItemsRes({ + required String itemId, + required List items, + }) = _SimilarItemsRes; +} + +@freezed +abstract class AdditionalPartsReq with _$AdditionalPartsReq { + const factory AdditionalPartsReq({required String itemId}) = + _AdditionalPartsReq; +} + +@freezed +abstract class AdditionalPartsRes with _$AdditionalPartsRes { + const factory AdditionalPartsRes({ + required String itemId, + required List items, + }) = _AdditionalPartsRes; +} + +@freezed +abstract class CrossServerSourceCard with _$CrossServerSourceCard { + const factory CrossServerSourceCard({ + required String serverId, + required String serverName, + required String serverUrl, + required String token, + required String userId, + required String landingItemId, + required String mediaSourceId, + required EmbyRawItem item, + required EmbyRawMediaSource mediaSource, + MediaQualityInfo? quality, + }) = _CrossServerSourceCard; +} + +@freezed +abstract class MediaQualityInfo with _$MediaQualityInfo { + const MediaQualityInfo._(); + const factory MediaQualityInfo({ + int? height, + int? width, + String? videoCodec, + String? container, + String? videoRange, + String? videoRangeType, + int? bitDepth, + double? fps, + }) = _MediaQualityInfo; + + factory MediaQualityInfo.fromMediaSource(EmbyRawMediaSource source) { + final streams = source.MediaStreams ?? const []; + for (final stream in streams) { + if (stream.Type != 'Video') continue; + final ex = stream.extra; + final realFps = ex['RealFrameRate'] as num?; + final avgFps = ex['AverageFrameRate'] as num?; + return MediaQualityInfo( + height: (ex['Height'] as num?)?.toInt() ?? source.Height, + width: (ex['Width'] as num?)?.toInt() ?? source.Width, + videoCodec: stream.Codec, + container: source.Container, + videoRange: ex['VideoRange'] as String?, + videoRangeType: ex['VideoRangeType'] as String?, + bitDepth: (ex['BitDepth'] as num?)?.toInt(), + fps: realFps?.toDouble() ?? avgFps?.toDouble(), + ); + } + final nameFallback = fromSourceName( + source.Name, + fallbackHeight: source.Height, + container: source.Container, + ); + if (nameFallback != null) return nameFallback; + return MediaQualityInfo( + height: source.Height, + width: source.Width, + container: source.Container, + ); + } + + + static MediaQualityInfo? fromSourceName( + String? name, { + int? fallbackHeight, + String? container, + }) { + final height = _heightFromName(name) ?? fallbackHeight; + final range = _rangeFromName(name); + if (height == null && range == null) return null; + return MediaQualityInfo( + height: height, + container: container, + videoRange: range, + ); + } + + static int? _heightFromName(String? name) { + if (name == null || name.isEmpty) return null; + final lower = name.toLowerCase(); + if (RegExp(r'(?:^|[^0-9])2160(?![0-9])p?|\b4k\b|\buhd\b').hasMatch(lower)) { + return 2160; + } + if (RegExp(r'(?:^|[^0-9])1440(?![0-9])p?|\b2k\b').hasMatch(lower)) { + return 1440; + } + if (RegExp(r'(?:^|[^0-9])1080(?![0-9])p?|\bfhd\b').hasMatch(lower)) { + return 1080; + } + if (RegExp(r'(?:^|[^0-9])720(?![0-9])p?|\bhd\b').hasMatch(lower)) { + return 720; + } + if (RegExp(r'(?:^|[^0-9])480(?![0-9])p?').hasMatch(lower)) return 480; + return null; + } + + static String? _rangeFromName(String? name) { + if (name == null || name.isEmpty) return null; + final lower = name.toLowerCase(); + if (RegExp(r'\bdv\b|dolby[\s._-]*vision|dovi').hasMatch(lower)) { + return 'DolbyVision'; + } + if (RegExp(r'hdr10\+|hdr10plus').hasMatch(lower)) return 'HDR10Plus'; + if (RegExp(r'\bhlg\b').hasMatch(lower)) return 'HLG'; + if (RegExp(r'hdr10').hasMatch(lower)) return 'HDR10'; + if (RegExp(r'\bhdr\b').hasMatch(lower)) return 'HDR'; + return null; + } + + String get resolutionLabel { + final h = height; + if (h == null) return ''; + if (h >= 2160) return '4K'; + if (h >= 1440) return '2K'; + if (h >= 1080) return '1080P'; + if (h >= 720) return '720P'; + return '${h}P'; + } + + String get dynamicRangeLabel { + final typeKnown = _canonicalDynamicRange(videoRangeType); + if (typeKnown != null) return typeKnown; + final rangeKnown = _canonicalDynamicRange(videoRange); + if (rangeKnown != null) return rangeKnown; + final typeRaw = _rawDynamicRange(videoRangeType); + if (typeRaw != null) return typeRaw; + final rangeRaw = _rawDynamicRange(videoRange); + if (rangeRaw != null) return rangeRaw; + return 'SDR'; + } + + + static String? _canonicalDynamicRange(String? raw) { + if (raw == null) return null; + final compact = raw.replaceAll(' ', ''); + if (compact.isEmpty) return null; + final upper = compact.toUpperCase(); + if (upper.contains('DOVI') || upper.contains('DOLBYVISION')) { + return 'Dolby Vision'; + } + if (upper == 'HDR10PLUS' || upper == 'HDR10+') return 'HDR10+'; + if (upper == 'HDR10') return 'HDR10'; + if (upper == 'HLG') return 'HLG'; + if (upper == 'HDR') return 'HDR'; + if (upper == 'SDR') return 'SDR'; + return null; + } + + + static String? _rawDynamicRange(String? raw) { + if (raw == null) return null; + final trimmed = raw.trim(); + return trimmed.isEmpty ? null : trimmed; + } + + bool get isDolbyVision => dynamicRangeLabel == 'Dolby Vision'; + bool get isHDR => dynamicRangeLabel != 'SDR'; + + String get codecLabel { + final c = videoCodec?.toLowerCase(); + if (c == null || c.isEmpty) return ''; + if (c == 'hevc' || c == 'h265') return 'H265'; + if (c == 'h264' || c == 'avc') return 'H264'; + if (c == 'av1') return 'AV1'; + if (c == 'vp9') return 'VP9'; + return c.toUpperCase(); + } + + String get bitDepthLabel { + if (bitDepth == null) return ''; + return '${bitDepth}bit'; + } + + String get fpsLabel { + if (fps == null) return ''; + final f = fps!; + if ((f - f.roundToDouble()).abs() < 0.1) return '${f.round()}fps'; + return '${f.toStringAsFixed(1)}fps'; + } + + String get detailLine { + final parts = [ + codecLabel, + bitDepthLabel, + fpsLabel, + ].where((s) => s.isNotEmpty).toList(); + return parts.join(' '); + } + + String get summary { + final parts = []; + final res = resolutionLabel; + if (res.isNotEmpty) parts.add(res); + final dr = dynamicRangeLabel; + if (dr.isNotEmpty) parts.add(dr); + if (codecLabel.isNotEmpty) parts.add(codecLabel); + return parts.join(' · '); + } +} + +@freezed +abstract class CrossServerSearchReq with _$CrossServerSearchReq { + const factory CrossServerSearchReq({ + required String anchorType, + required String itemName, + required Map providerIds, + Map? seriesProviderIds, + int? parentIndexNumber, + int? indexNumber, + + + String? excludedServerId, + }) = _CrossServerSearchReq; +} + + +@freezed +abstract class TmdbAvailabilityReq with _$TmdbAvailabilityReq { + const factory TmdbAvailabilityReq({ + required String tmdbId, + required String mediaType, + + + String? imdbId, + }) = _TmdbAvailabilityReq; +} + + +@freezed +abstract class TmdbLibraryHit with _$TmdbLibraryHit { + const factory TmdbLibraryHit({ + required String serverId, + required String serverName, + required String itemId, + required String itemType, + required String name, + int? productionYear, + + + int? playbackPositionTicks, + + + int? runTimeTicks, + }) = _TmdbLibraryHit; +} + +enum SortOrder { + ascending('Ascending'), + descending('Descending'); + + final String value; + const SortOrder(this.value); + + static SortOrder? fromString(String? value) { + if (value == null) return null; + return SortOrder.values.firstWhere( + (e) => e.value == value, + orElse: () => SortOrder.ascending, + ); + } +} + +@freezed +abstract class LibraryItemsReq with _$LibraryItemsReq { + const factory LibraryItemsReq({ + String? parentId, + String? includeItemTypes, + List? genreIds, + String? searchTerm, + String? anyProviderIdEquals, + String? fields, + String? enableImageTypes, + bool? groupProgramsBySeries, + int? imageTypeLimit, + int? startIndex, + int? limit, + bool? recursive, + String? sortBy, + SortOrder? sortOrder, + }) = _LibraryItemsReq; +} + +@freezed +abstract class LibraryItemsRes with _$LibraryItemsRes { + const factory LibraryItemsRes({ + required String parentId, + required List items, + }) = _LibraryItemsRes; +} + +@freezed +abstract class ItemCountsRes with _$ItemCountsRes { + const factory ItemCountsRes({ + @JsonKey(name: 'MovieCount', fromJson: _intOrZero) required int movieCount, + @JsonKey(name: 'EpisodeCount', fromJson: _intOrZero) + required int episodeCount, + }) = _ItemCountsRes; + + factory ItemCountsRes.fromJson(Map json) => + _$ItemCountsResFromJson(json); +} + +@freezed +abstract class GlobalSearchReq with _$GlobalSearchReq { + const factory GlobalSearchReq({ + required String keyword, + String? includeItemTypes, + int? limitPerServer, + String? serverId, + }) = _GlobalSearchReq; +} + +@freezed +abstract class GlobalSearchServerResult with _$GlobalSearchServerResult { + const factory GlobalSearchServerResult({ + required String serverId, + required String serverName, + required String serverUrl, + required String token, + required String userId, + required List items, + }) = _GlobalSearchServerResult; +} + +int? _intOrNull(Object? value) => (value as num?)?.toInt(); +double? _doubleOrNull(Object? value) => (value as num?)?.toDouble(); +int _intOrZero(Object? value) => (value as num?)?.toInt() ?? 0; + + +List mediaSourcesOfItem(EmbyRawItem? item) { + final raw = item?.extra['MediaSources']; + if (raw is! List) return const []; + return raw + .whereType>() + .map(EmbyRawMediaSource.fromJson) + .toList(growable: false); +} + + +Map providerIdsOfItem(EmbyRawItem item) { + final raw = item.extra['ProviderIds']; + if (raw is! Map) return const {}; + final ids = {}; + for (final entry in raw.entries) { + final key = entry.key?.toString() ?? ''; + final value = entry.value?.toString() ?? ''; + if (key.isNotEmpty && value.isNotEmpty) ids[key] = value; + } + return ids; +} + +Map normalizeProviderIds(Map raw) { + if (raw.isEmpty) return const {}; + final out = {}; + for (final e in raw.entries) { + final k = e.key.trim().toLowerCase(); + final v = e.value.trim(); + if (k.isNotEmpty && v.isNotEmpty) out[k] = v; + } + return out; +} + +Map normalizedProviderIdsOfItem(EmbyRawItem item) => + normalizeProviderIds(providerIdsOfItem(item)); + +Map? _stringMapOrNull(Object? value) { + if (value is! Map) return null; + return value.map((k, v) => MapEntry(k as String, v as String)); +} + +List? _stringListOrNull(Object? value) { + if (value is! List) return null; + return value.cast(); +} diff --git a/lib/core/contracts/library.freezed.dart b/lib/core/contracts/library.freezed.dart new file mode 100644 index 0000000..bb65d0c --- /dev/null +++ b/lib/core/contracts/library.freezed.dart @@ -0,0 +1,10615 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'library.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$EmbyRawUserData { + +@JsonKey(fromJson: _intOrNull, includeIfNull: false) int? get PlaybackPositionTicks;@JsonKey(fromJson: _intOrNull, includeIfNull: false) int? get PlayCount;@JsonKey(fromJson: _intOrNull, includeIfNull: false) int? get UnplayedItemCount;@JsonKey(includeIfNull: false) bool? get IsFavorite;@JsonKey(includeIfNull: false) bool? get Played;@JsonKey(includeFromJson: false, includeToJson: false) Map get extra; +/// Create a copy of EmbyRawUserData +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$EmbyRawUserDataCopyWith get copyWith => _$EmbyRawUserDataCopyWithImpl(this as EmbyRawUserData, _$identity); + + /// Serializes this EmbyRawUserData to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is EmbyRawUserData&&(identical(other.PlaybackPositionTicks, PlaybackPositionTicks) || other.PlaybackPositionTicks == PlaybackPositionTicks)&&(identical(other.PlayCount, PlayCount) || other.PlayCount == PlayCount)&&(identical(other.UnplayedItemCount, UnplayedItemCount) || other.UnplayedItemCount == UnplayedItemCount)&&(identical(other.IsFavorite, IsFavorite) || other.IsFavorite == IsFavorite)&&(identical(other.Played, Played) || other.Played == Played)&&const DeepCollectionEquality().equals(other.extra, extra)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,PlaybackPositionTicks,PlayCount,UnplayedItemCount,IsFavorite,Played,const DeepCollectionEquality().hash(extra)); + +@override +String toString() { + return 'EmbyRawUserData(PlaybackPositionTicks: $PlaybackPositionTicks, PlayCount: $PlayCount, UnplayedItemCount: $UnplayedItemCount, IsFavorite: $IsFavorite, Played: $Played, extra: $extra)'; +} + + +} + +/// @nodoc +abstract mixin class $EmbyRawUserDataCopyWith<$Res> { + factory $EmbyRawUserDataCopyWith(EmbyRawUserData value, $Res Function(EmbyRawUserData) _then) = _$EmbyRawUserDataCopyWithImpl; +@useResult +$Res call({ +@JsonKey(fromJson: _intOrNull, includeIfNull: false) int? PlaybackPositionTicks,@JsonKey(fromJson: _intOrNull, includeIfNull: false) int? PlayCount,@JsonKey(fromJson: _intOrNull, includeIfNull: false) int? UnplayedItemCount,@JsonKey(includeIfNull: false) bool? IsFavorite,@JsonKey(includeIfNull: false) bool? Played,@JsonKey(includeFromJson: false, includeToJson: false) Map extra +}); + + + + +} +/// @nodoc +class _$EmbyRawUserDataCopyWithImpl<$Res> + implements $EmbyRawUserDataCopyWith<$Res> { + _$EmbyRawUserDataCopyWithImpl(this._self, this._then); + + final EmbyRawUserData _self; + final $Res Function(EmbyRawUserData) _then; + +/// Create a copy of EmbyRawUserData +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? PlaybackPositionTicks = freezed,Object? PlayCount = freezed,Object? UnplayedItemCount = freezed,Object? IsFavorite = freezed,Object? Played = freezed,Object? extra = null,}) { + return _then(_self.copyWith( +PlaybackPositionTicks: freezed == PlaybackPositionTicks ? _self.PlaybackPositionTicks : PlaybackPositionTicks // ignore: cast_nullable_to_non_nullable +as int?,PlayCount: freezed == PlayCount ? _self.PlayCount : PlayCount // ignore: cast_nullable_to_non_nullable +as int?,UnplayedItemCount: freezed == UnplayedItemCount ? _self.UnplayedItemCount : UnplayedItemCount // ignore: cast_nullable_to_non_nullable +as int?,IsFavorite: freezed == IsFavorite ? _self.IsFavorite : IsFavorite // ignore: cast_nullable_to_non_nullable +as bool?,Played: freezed == Played ? _self.Played : Played // ignore: cast_nullable_to_non_nullable +as bool?,extra: null == extra ? _self.extra : extra // ignore: cast_nullable_to_non_nullable +as Map, + )); +} + +} + + +/// Adds pattern-matching-related methods to [EmbyRawUserData]. +extension EmbyRawUserDataPatterns on EmbyRawUserData { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _EmbyRawUserData value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _EmbyRawUserData() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _EmbyRawUserData value) $default,){ +final _that = this; +switch (_that) { +case _EmbyRawUserData(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _EmbyRawUserData value)? $default,){ +final _that = this; +switch (_that) { +case _EmbyRawUserData() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function(@JsonKey(fromJson: _intOrNull, includeIfNull: false) int? PlaybackPositionTicks, @JsonKey(fromJson: _intOrNull, includeIfNull: false) int? PlayCount, @JsonKey(fromJson: _intOrNull, includeIfNull: false) int? UnplayedItemCount, @JsonKey(includeIfNull: false) bool? IsFavorite, @JsonKey(includeIfNull: false) bool? Played, @JsonKey(includeFromJson: false, includeToJson: false) Map extra)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _EmbyRawUserData() when $default != null: +return $default(_that.PlaybackPositionTicks,_that.PlayCount,_that.UnplayedItemCount,_that.IsFavorite,_that.Played,_that.extra);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function(@JsonKey(fromJson: _intOrNull, includeIfNull: false) int? PlaybackPositionTicks, @JsonKey(fromJson: _intOrNull, includeIfNull: false) int? PlayCount, @JsonKey(fromJson: _intOrNull, includeIfNull: false) int? UnplayedItemCount, @JsonKey(includeIfNull: false) bool? IsFavorite, @JsonKey(includeIfNull: false) bool? Played, @JsonKey(includeFromJson: false, includeToJson: false) Map extra) $default,) {final _that = this; +switch (_that) { +case _EmbyRawUserData(): +return $default(_that.PlaybackPositionTicks,_that.PlayCount,_that.UnplayedItemCount,_that.IsFavorite,_that.Played,_that.extra);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function(@JsonKey(fromJson: _intOrNull, includeIfNull: false) int? PlaybackPositionTicks, @JsonKey(fromJson: _intOrNull, includeIfNull: false) int? PlayCount, @JsonKey(fromJson: _intOrNull, includeIfNull: false) int? UnplayedItemCount, @JsonKey(includeIfNull: false) bool? IsFavorite, @JsonKey(includeIfNull: false) bool? Played, @JsonKey(includeFromJson: false, includeToJson: false) Map extra)? $default,) {final _that = this; +switch (_that) { +case _EmbyRawUserData() when $default != null: +return $default(_that.PlaybackPositionTicks,_that.PlayCount,_that.UnplayedItemCount,_that.IsFavorite,_that.Played,_that.extra);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _EmbyRawUserData implements EmbyRawUserData { + const _EmbyRawUserData({@JsonKey(fromJson: _intOrNull, includeIfNull: false) this.PlaybackPositionTicks, @JsonKey(fromJson: _intOrNull, includeIfNull: false) this.PlayCount, @JsonKey(fromJson: _intOrNull, includeIfNull: false) this.UnplayedItemCount, @JsonKey(includeIfNull: false) this.IsFavorite, @JsonKey(includeIfNull: false) this.Played, @JsonKey(includeFromJson: false, includeToJson: false) final Map extra = const {}}): _extra = extra; + factory _EmbyRawUserData.fromJson(Map json) => _$EmbyRawUserDataFromJson(json); + +@override@JsonKey(fromJson: _intOrNull, includeIfNull: false) final int? PlaybackPositionTicks; +@override@JsonKey(fromJson: _intOrNull, includeIfNull: false) final int? PlayCount; +@override@JsonKey(fromJson: _intOrNull, includeIfNull: false) final int? UnplayedItemCount; +@override@JsonKey(includeIfNull: false) final bool? IsFavorite; +@override@JsonKey(includeIfNull: false) final bool? Played; + final Map _extra; +@override@JsonKey(includeFromJson: false, includeToJson: false) Map get extra { + if (_extra is EqualUnmodifiableMapView) return _extra; + // ignore: implicit_dynamic_type + return EqualUnmodifiableMapView(_extra); +} + + +/// Create a copy of EmbyRawUserData +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$EmbyRawUserDataCopyWith<_EmbyRawUserData> get copyWith => __$EmbyRawUserDataCopyWithImpl<_EmbyRawUserData>(this, _$identity); + +@override +Map toJson() { + return _$EmbyRawUserDataToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _EmbyRawUserData&&(identical(other.PlaybackPositionTicks, PlaybackPositionTicks) || other.PlaybackPositionTicks == PlaybackPositionTicks)&&(identical(other.PlayCount, PlayCount) || other.PlayCount == PlayCount)&&(identical(other.UnplayedItemCount, UnplayedItemCount) || other.UnplayedItemCount == UnplayedItemCount)&&(identical(other.IsFavorite, IsFavorite) || other.IsFavorite == IsFavorite)&&(identical(other.Played, Played) || other.Played == Played)&&const DeepCollectionEquality().equals(other._extra, _extra)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,PlaybackPositionTicks,PlayCount,UnplayedItemCount,IsFavorite,Played,const DeepCollectionEquality().hash(_extra)); + +@override +String toString() { + return 'EmbyRawUserData(PlaybackPositionTicks: $PlaybackPositionTicks, PlayCount: $PlayCount, UnplayedItemCount: $UnplayedItemCount, IsFavorite: $IsFavorite, Played: $Played, extra: $extra)'; +} + + +} + +/// @nodoc +abstract mixin class _$EmbyRawUserDataCopyWith<$Res> implements $EmbyRawUserDataCopyWith<$Res> { + factory _$EmbyRawUserDataCopyWith(_EmbyRawUserData value, $Res Function(_EmbyRawUserData) _then) = __$EmbyRawUserDataCopyWithImpl; +@override @useResult +$Res call({ +@JsonKey(fromJson: _intOrNull, includeIfNull: false) int? PlaybackPositionTicks,@JsonKey(fromJson: _intOrNull, includeIfNull: false) int? PlayCount,@JsonKey(fromJson: _intOrNull, includeIfNull: false) int? UnplayedItemCount,@JsonKey(includeIfNull: false) bool? IsFavorite,@JsonKey(includeIfNull: false) bool? Played,@JsonKey(includeFromJson: false, includeToJson: false) Map extra +}); + + + + +} +/// @nodoc +class __$EmbyRawUserDataCopyWithImpl<$Res> + implements _$EmbyRawUserDataCopyWith<$Res> { + __$EmbyRawUserDataCopyWithImpl(this._self, this._then); + + final _EmbyRawUserData _self; + final $Res Function(_EmbyRawUserData) _then; + +/// Create a copy of EmbyRawUserData +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? PlaybackPositionTicks = freezed,Object? PlayCount = freezed,Object? UnplayedItemCount = freezed,Object? IsFavorite = freezed,Object? Played = freezed,Object? extra = null,}) { + return _then(_EmbyRawUserData( +PlaybackPositionTicks: freezed == PlaybackPositionTicks ? _self.PlaybackPositionTicks : PlaybackPositionTicks // ignore: cast_nullable_to_non_nullable +as int?,PlayCount: freezed == PlayCount ? _self.PlayCount : PlayCount // ignore: cast_nullable_to_non_nullable +as int?,UnplayedItemCount: freezed == UnplayedItemCount ? _self.UnplayedItemCount : UnplayedItemCount // ignore: cast_nullable_to_non_nullable +as int?,IsFavorite: freezed == IsFavorite ? _self.IsFavorite : IsFavorite // ignore: cast_nullable_to_non_nullable +as bool?,Played: freezed == Played ? _self.Played : Played // ignore: cast_nullable_to_non_nullable +as bool?,extra: null == extra ? _self._extra : extra // ignore: cast_nullable_to_non_nullable +as Map, + )); +} + + +} + + +/// @nodoc +mixin _$EmbyRawItem { + + String get Id; String get Name;@JsonKey(includeIfNull: false) String? get Type;@JsonKey(includeIfNull: false) String? get Overview;@JsonKey(fromJson: _intOrNull, includeIfNull: false) int? get ProductionYear;@JsonKey(fromJson: _doubleOrNull, includeIfNull: false) double? get CommunityRating;@JsonKey(includeIfNull: false) String? get PremiereDate;@JsonKey(includeIfNull: false) String? get SeriesName;@JsonKey(includeIfNull: false) String? get SeriesId;@JsonKey(includeIfNull: false) String? get SeasonId;@JsonKey(includeIfNull: false) String? get SeasonName;@JsonKey(fromJson: _intOrNull, includeIfNull: false) int? get IndexNumber;@JsonKey(includeIfNull: false) String? get ParentId;@JsonKey(includeIfNull: false) String? get PrimaryImageTag;@JsonKey(fromJson: _stringMapOrNull, includeIfNull: false) Map? get ImageTags;@JsonKey(fromJson: _stringListOrNull, includeIfNull: false) List? get BackdropImageTags;@JsonKey(fromJson: _intOrNull, includeIfNull: false) int? get RunTimeTicks;@JsonKey(includeIfNull: false) EmbyRawUserData? get UserData;@JsonKey(includeFromJson: false, includeToJson: false) Map get extra; +/// Create a copy of EmbyRawItem +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$EmbyRawItemCopyWith get copyWith => _$EmbyRawItemCopyWithImpl(this as EmbyRawItem, _$identity); + + /// Serializes this EmbyRawItem to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is EmbyRawItem&&(identical(other.Id, Id) || other.Id == Id)&&(identical(other.Name, Name) || other.Name == Name)&&(identical(other.Type, Type) || other.Type == Type)&&(identical(other.Overview, Overview) || other.Overview == Overview)&&(identical(other.ProductionYear, ProductionYear) || other.ProductionYear == ProductionYear)&&(identical(other.CommunityRating, CommunityRating) || other.CommunityRating == CommunityRating)&&(identical(other.PremiereDate, PremiereDate) || other.PremiereDate == PremiereDate)&&(identical(other.SeriesName, SeriesName) || other.SeriesName == SeriesName)&&(identical(other.SeriesId, SeriesId) || other.SeriesId == SeriesId)&&(identical(other.SeasonId, SeasonId) || other.SeasonId == SeasonId)&&(identical(other.SeasonName, SeasonName) || other.SeasonName == SeasonName)&&(identical(other.IndexNumber, IndexNumber) || other.IndexNumber == IndexNumber)&&(identical(other.ParentId, ParentId) || other.ParentId == ParentId)&&(identical(other.PrimaryImageTag, PrimaryImageTag) || other.PrimaryImageTag == PrimaryImageTag)&&const DeepCollectionEquality().equals(other.ImageTags, ImageTags)&&const DeepCollectionEquality().equals(other.BackdropImageTags, BackdropImageTags)&&(identical(other.RunTimeTicks, RunTimeTicks) || other.RunTimeTicks == RunTimeTicks)&&(identical(other.UserData, UserData) || other.UserData == UserData)&&const DeepCollectionEquality().equals(other.extra, extra)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hashAll([runtimeType,Id,Name,Type,Overview,ProductionYear,CommunityRating,PremiereDate,SeriesName,SeriesId,SeasonId,SeasonName,IndexNumber,ParentId,PrimaryImageTag,const DeepCollectionEquality().hash(ImageTags),const DeepCollectionEquality().hash(BackdropImageTags),RunTimeTicks,UserData,const DeepCollectionEquality().hash(extra)]); + +@override +String toString() { + return 'EmbyRawItem(Id: $Id, Name: $Name, Type: $Type, Overview: $Overview, ProductionYear: $ProductionYear, CommunityRating: $CommunityRating, PremiereDate: $PremiereDate, SeriesName: $SeriesName, SeriesId: $SeriesId, SeasonId: $SeasonId, SeasonName: $SeasonName, IndexNumber: $IndexNumber, ParentId: $ParentId, PrimaryImageTag: $PrimaryImageTag, ImageTags: $ImageTags, BackdropImageTags: $BackdropImageTags, RunTimeTicks: $RunTimeTicks, UserData: $UserData, extra: $extra)'; +} + + +} + +/// @nodoc +abstract mixin class $EmbyRawItemCopyWith<$Res> { + factory $EmbyRawItemCopyWith(EmbyRawItem value, $Res Function(EmbyRawItem) _then) = _$EmbyRawItemCopyWithImpl; +@useResult +$Res call({ + String Id, String Name,@JsonKey(includeIfNull: false) String? Type,@JsonKey(includeIfNull: false) String? Overview,@JsonKey(fromJson: _intOrNull, includeIfNull: false) int? ProductionYear,@JsonKey(fromJson: _doubleOrNull, includeIfNull: false) double? CommunityRating,@JsonKey(includeIfNull: false) String? PremiereDate,@JsonKey(includeIfNull: false) String? SeriesName,@JsonKey(includeIfNull: false) String? SeriesId,@JsonKey(includeIfNull: false) String? SeasonId,@JsonKey(includeIfNull: false) String? SeasonName,@JsonKey(fromJson: _intOrNull, includeIfNull: false) int? IndexNumber,@JsonKey(includeIfNull: false) String? ParentId,@JsonKey(includeIfNull: false) String? PrimaryImageTag,@JsonKey(fromJson: _stringMapOrNull, includeIfNull: false) Map? ImageTags,@JsonKey(fromJson: _stringListOrNull, includeIfNull: false) List? BackdropImageTags,@JsonKey(fromJson: _intOrNull, includeIfNull: false) int? RunTimeTicks,@JsonKey(includeIfNull: false) EmbyRawUserData? UserData,@JsonKey(includeFromJson: false, includeToJson: false) Map extra +}); + + +$EmbyRawUserDataCopyWith<$Res>? get UserData; + +} +/// @nodoc +class _$EmbyRawItemCopyWithImpl<$Res> + implements $EmbyRawItemCopyWith<$Res> { + _$EmbyRawItemCopyWithImpl(this._self, this._then); + + final EmbyRawItem _self; + final $Res Function(EmbyRawItem) _then; + +/// Create a copy of EmbyRawItem +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? Id = null,Object? Name = null,Object? Type = freezed,Object? Overview = freezed,Object? ProductionYear = freezed,Object? CommunityRating = freezed,Object? PremiereDate = freezed,Object? SeriesName = freezed,Object? SeriesId = freezed,Object? SeasonId = freezed,Object? SeasonName = freezed,Object? IndexNumber = freezed,Object? ParentId = freezed,Object? PrimaryImageTag = freezed,Object? ImageTags = freezed,Object? BackdropImageTags = freezed,Object? RunTimeTicks = freezed,Object? UserData = freezed,Object? extra = null,}) { + return _then(_self.copyWith( +Id: null == Id ? _self.Id : Id // ignore: cast_nullable_to_non_nullable +as String,Name: null == Name ? _self.Name : Name // ignore: cast_nullable_to_non_nullable +as String,Type: freezed == Type ? _self.Type : Type // ignore: cast_nullable_to_non_nullable +as String?,Overview: freezed == Overview ? _self.Overview : Overview // ignore: cast_nullable_to_non_nullable +as String?,ProductionYear: freezed == ProductionYear ? _self.ProductionYear : ProductionYear // ignore: cast_nullable_to_non_nullable +as int?,CommunityRating: freezed == CommunityRating ? _self.CommunityRating : CommunityRating // ignore: cast_nullable_to_non_nullable +as double?,PremiereDate: freezed == PremiereDate ? _self.PremiereDate : PremiereDate // ignore: cast_nullable_to_non_nullable +as String?,SeriesName: freezed == SeriesName ? _self.SeriesName : SeriesName // ignore: cast_nullable_to_non_nullable +as String?,SeriesId: freezed == SeriesId ? _self.SeriesId : SeriesId // ignore: cast_nullable_to_non_nullable +as String?,SeasonId: freezed == SeasonId ? _self.SeasonId : SeasonId // ignore: cast_nullable_to_non_nullable +as String?,SeasonName: freezed == SeasonName ? _self.SeasonName : SeasonName // ignore: cast_nullable_to_non_nullable +as String?,IndexNumber: freezed == IndexNumber ? _self.IndexNumber : IndexNumber // ignore: cast_nullable_to_non_nullable +as int?,ParentId: freezed == ParentId ? _self.ParentId : ParentId // ignore: cast_nullable_to_non_nullable +as String?,PrimaryImageTag: freezed == PrimaryImageTag ? _self.PrimaryImageTag : PrimaryImageTag // ignore: cast_nullable_to_non_nullable +as String?,ImageTags: freezed == ImageTags ? _self.ImageTags : ImageTags // ignore: cast_nullable_to_non_nullable +as Map?,BackdropImageTags: freezed == BackdropImageTags ? _self.BackdropImageTags : BackdropImageTags // ignore: cast_nullable_to_non_nullable +as List?,RunTimeTicks: freezed == RunTimeTicks ? _self.RunTimeTicks : RunTimeTicks // ignore: cast_nullable_to_non_nullable +as int?,UserData: freezed == UserData ? _self.UserData : UserData // ignore: cast_nullable_to_non_nullable +as EmbyRawUserData?,extra: null == extra ? _self.extra : extra // ignore: cast_nullable_to_non_nullable +as Map, + )); +} +/// Create a copy of EmbyRawItem +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$EmbyRawUserDataCopyWith<$Res>? get UserData { + if (_self.UserData == null) { + return null; + } + + return $EmbyRawUserDataCopyWith<$Res>(_self.UserData!, (value) { + return _then(_self.copyWith(UserData: value)); + }); +} +} + + +/// Adds pattern-matching-related methods to [EmbyRawItem]. +extension EmbyRawItemPatterns on EmbyRawItem { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _EmbyRawItem value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _EmbyRawItem() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _EmbyRawItem value) $default,){ +final _that = this; +switch (_that) { +case _EmbyRawItem(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _EmbyRawItem value)? $default,){ +final _that = this; +switch (_that) { +case _EmbyRawItem() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String Id, String Name, @JsonKey(includeIfNull: false) String? Type, @JsonKey(includeIfNull: false) String? Overview, @JsonKey(fromJson: _intOrNull, includeIfNull: false) int? ProductionYear, @JsonKey(fromJson: _doubleOrNull, includeIfNull: false) double? CommunityRating, @JsonKey(includeIfNull: false) String? PremiereDate, @JsonKey(includeIfNull: false) String? SeriesName, @JsonKey(includeIfNull: false) String? SeriesId, @JsonKey(includeIfNull: false) String? SeasonId, @JsonKey(includeIfNull: false) String? SeasonName, @JsonKey(fromJson: _intOrNull, includeIfNull: false) int? IndexNumber, @JsonKey(includeIfNull: false) String? ParentId, @JsonKey(includeIfNull: false) String? PrimaryImageTag, @JsonKey(fromJson: _stringMapOrNull, includeIfNull: false) Map? ImageTags, @JsonKey(fromJson: _stringListOrNull, includeIfNull: false) List? BackdropImageTags, @JsonKey(fromJson: _intOrNull, includeIfNull: false) int? RunTimeTicks, @JsonKey(includeIfNull: false) EmbyRawUserData? UserData, @JsonKey(includeFromJson: false, includeToJson: false) Map extra)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _EmbyRawItem() when $default != null: +return $default(_that.Id,_that.Name,_that.Type,_that.Overview,_that.ProductionYear,_that.CommunityRating,_that.PremiereDate,_that.SeriesName,_that.SeriesId,_that.SeasonId,_that.SeasonName,_that.IndexNumber,_that.ParentId,_that.PrimaryImageTag,_that.ImageTags,_that.BackdropImageTags,_that.RunTimeTicks,_that.UserData,_that.extra);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String Id, String Name, @JsonKey(includeIfNull: false) String? Type, @JsonKey(includeIfNull: false) String? Overview, @JsonKey(fromJson: _intOrNull, includeIfNull: false) int? ProductionYear, @JsonKey(fromJson: _doubleOrNull, includeIfNull: false) double? CommunityRating, @JsonKey(includeIfNull: false) String? PremiereDate, @JsonKey(includeIfNull: false) String? SeriesName, @JsonKey(includeIfNull: false) String? SeriesId, @JsonKey(includeIfNull: false) String? SeasonId, @JsonKey(includeIfNull: false) String? SeasonName, @JsonKey(fromJson: _intOrNull, includeIfNull: false) int? IndexNumber, @JsonKey(includeIfNull: false) String? ParentId, @JsonKey(includeIfNull: false) String? PrimaryImageTag, @JsonKey(fromJson: _stringMapOrNull, includeIfNull: false) Map? ImageTags, @JsonKey(fromJson: _stringListOrNull, includeIfNull: false) List? BackdropImageTags, @JsonKey(fromJson: _intOrNull, includeIfNull: false) int? RunTimeTicks, @JsonKey(includeIfNull: false) EmbyRawUserData? UserData, @JsonKey(includeFromJson: false, includeToJson: false) Map extra) $default,) {final _that = this; +switch (_that) { +case _EmbyRawItem(): +return $default(_that.Id,_that.Name,_that.Type,_that.Overview,_that.ProductionYear,_that.CommunityRating,_that.PremiereDate,_that.SeriesName,_that.SeriesId,_that.SeasonId,_that.SeasonName,_that.IndexNumber,_that.ParentId,_that.PrimaryImageTag,_that.ImageTags,_that.BackdropImageTags,_that.RunTimeTicks,_that.UserData,_that.extra);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String Id, String Name, @JsonKey(includeIfNull: false) String? Type, @JsonKey(includeIfNull: false) String? Overview, @JsonKey(fromJson: _intOrNull, includeIfNull: false) int? ProductionYear, @JsonKey(fromJson: _doubleOrNull, includeIfNull: false) double? CommunityRating, @JsonKey(includeIfNull: false) String? PremiereDate, @JsonKey(includeIfNull: false) String? SeriesName, @JsonKey(includeIfNull: false) String? SeriesId, @JsonKey(includeIfNull: false) String? SeasonId, @JsonKey(includeIfNull: false) String? SeasonName, @JsonKey(fromJson: _intOrNull, includeIfNull: false) int? IndexNumber, @JsonKey(includeIfNull: false) String? ParentId, @JsonKey(includeIfNull: false) String? PrimaryImageTag, @JsonKey(fromJson: _stringMapOrNull, includeIfNull: false) Map? ImageTags, @JsonKey(fromJson: _stringListOrNull, includeIfNull: false) List? BackdropImageTags, @JsonKey(fromJson: _intOrNull, includeIfNull: false) int? RunTimeTicks, @JsonKey(includeIfNull: false) EmbyRawUserData? UserData, @JsonKey(includeFromJson: false, includeToJson: false) Map extra)? $default,) {final _that = this; +switch (_that) { +case _EmbyRawItem() when $default != null: +return $default(_that.Id,_that.Name,_that.Type,_that.Overview,_that.ProductionYear,_that.CommunityRating,_that.PremiereDate,_that.SeriesName,_that.SeriesId,_that.SeasonId,_that.SeasonName,_that.IndexNumber,_that.ParentId,_that.PrimaryImageTag,_that.ImageTags,_that.BackdropImageTags,_that.RunTimeTicks,_that.UserData,_that.extra);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _EmbyRawItem implements EmbyRawItem { + const _EmbyRawItem({required this.Id, required this.Name, @JsonKey(includeIfNull: false) this.Type, @JsonKey(includeIfNull: false) this.Overview, @JsonKey(fromJson: _intOrNull, includeIfNull: false) this.ProductionYear, @JsonKey(fromJson: _doubleOrNull, includeIfNull: false) this.CommunityRating, @JsonKey(includeIfNull: false) this.PremiereDate, @JsonKey(includeIfNull: false) this.SeriesName, @JsonKey(includeIfNull: false) this.SeriesId, @JsonKey(includeIfNull: false) this.SeasonId, @JsonKey(includeIfNull: false) this.SeasonName, @JsonKey(fromJson: _intOrNull, includeIfNull: false) this.IndexNumber, @JsonKey(includeIfNull: false) this.ParentId, @JsonKey(includeIfNull: false) this.PrimaryImageTag, @JsonKey(fromJson: _stringMapOrNull, includeIfNull: false) final Map? ImageTags, @JsonKey(fromJson: _stringListOrNull, includeIfNull: false) final List? BackdropImageTags, @JsonKey(fromJson: _intOrNull, includeIfNull: false) this.RunTimeTicks, @JsonKey(includeIfNull: false) this.UserData, @JsonKey(includeFromJson: false, includeToJson: false) final Map extra = const {}}): _ImageTags = ImageTags,_BackdropImageTags = BackdropImageTags,_extra = extra; + factory _EmbyRawItem.fromJson(Map json) => _$EmbyRawItemFromJson(json); + +@override final String Id; +@override final String Name; +@override@JsonKey(includeIfNull: false) final String? Type; +@override@JsonKey(includeIfNull: false) final String? Overview; +@override@JsonKey(fromJson: _intOrNull, includeIfNull: false) final int? ProductionYear; +@override@JsonKey(fromJson: _doubleOrNull, includeIfNull: false) final double? CommunityRating; +@override@JsonKey(includeIfNull: false) final String? PremiereDate; +@override@JsonKey(includeIfNull: false) final String? SeriesName; +@override@JsonKey(includeIfNull: false) final String? SeriesId; +@override@JsonKey(includeIfNull: false) final String? SeasonId; +@override@JsonKey(includeIfNull: false) final String? SeasonName; +@override@JsonKey(fromJson: _intOrNull, includeIfNull: false) final int? IndexNumber; +@override@JsonKey(includeIfNull: false) final String? ParentId; +@override@JsonKey(includeIfNull: false) final String? PrimaryImageTag; + final Map? _ImageTags; +@override@JsonKey(fromJson: _stringMapOrNull, includeIfNull: false) Map? get ImageTags { + final value = _ImageTags; + if (value == null) return null; + if (_ImageTags is EqualUnmodifiableMapView) return _ImageTags; + // ignore: implicit_dynamic_type + return EqualUnmodifiableMapView(value); +} + + final List? _BackdropImageTags; +@override@JsonKey(fromJson: _stringListOrNull, includeIfNull: false) List? get BackdropImageTags { + final value = _BackdropImageTags; + if (value == null) return null; + if (_BackdropImageTags is EqualUnmodifiableListView) return _BackdropImageTags; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(value); +} + +@override@JsonKey(fromJson: _intOrNull, includeIfNull: false) final int? RunTimeTicks; +@override@JsonKey(includeIfNull: false) final EmbyRawUserData? UserData; + final Map _extra; +@override@JsonKey(includeFromJson: false, includeToJson: false) Map get extra { + if (_extra is EqualUnmodifiableMapView) return _extra; + // ignore: implicit_dynamic_type + return EqualUnmodifiableMapView(_extra); +} + + +/// Create a copy of EmbyRawItem +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$EmbyRawItemCopyWith<_EmbyRawItem> get copyWith => __$EmbyRawItemCopyWithImpl<_EmbyRawItem>(this, _$identity); + +@override +Map toJson() { + return _$EmbyRawItemToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _EmbyRawItem&&(identical(other.Id, Id) || other.Id == Id)&&(identical(other.Name, Name) || other.Name == Name)&&(identical(other.Type, Type) || other.Type == Type)&&(identical(other.Overview, Overview) || other.Overview == Overview)&&(identical(other.ProductionYear, ProductionYear) || other.ProductionYear == ProductionYear)&&(identical(other.CommunityRating, CommunityRating) || other.CommunityRating == CommunityRating)&&(identical(other.PremiereDate, PremiereDate) || other.PremiereDate == PremiereDate)&&(identical(other.SeriesName, SeriesName) || other.SeriesName == SeriesName)&&(identical(other.SeriesId, SeriesId) || other.SeriesId == SeriesId)&&(identical(other.SeasonId, SeasonId) || other.SeasonId == SeasonId)&&(identical(other.SeasonName, SeasonName) || other.SeasonName == SeasonName)&&(identical(other.IndexNumber, IndexNumber) || other.IndexNumber == IndexNumber)&&(identical(other.ParentId, ParentId) || other.ParentId == ParentId)&&(identical(other.PrimaryImageTag, PrimaryImageTag) || other.PrimaryImageTag == PrimaryImageTag)&&const DeepCollectionEquality().equals(other._ImageTags, _ImageTags)&&const DeepCollectionEquality().equals(other._BackdropImageTags, _BackdropImageTags)&&(identical(other.RunTimeTicks, RunTimeTicks) || other.RunTimeTicks == RunTimeTicks)&&(identical(other.UserData, UserData) || other.UserData == UserData)&&const DeepCollectionEquality().equals(other._extra, _extra)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hashAll([runtimeType,Id,Name,Type,Overview,ProductionYear,CommunityRating,PremiereDate,SeriesName,SeriesId,SeasonId,SeasonName,IndexNumber,ParentId,PrimaryImageTag,const DeepCollectionEquality().hash(_ImageTags),const DeepCollectionEquality().hash(_BackdropImageTags),RunTimeTicks,UserData,const DeepCollectionEquality().hash(_extra)]); + +@override +String toString() { + return 'EmbyRawItem(Id: $Id, Name: $Name, Type: $Type, Overview: $Overview, ProductionYear: $ProductionYear, CommunityRating: $CommunityRating, PremiereDate: $PremiereDate, SeriesName: $SeriesName, SeriesId: $SeriesId, SeasonId: $SeasonId, SeasonName: $SeasonName, IndexNumber: $IndexNumber, ParentId: $ParentId, PrimaryImageTag: $PrimaryImageTag, ImageTags: $ImageTags, BackdropImageTags: $BackdropImageTags, RunTimeTicks: $RunTimeTicks, UserData: $UserData, extra: $extra)'; +} + + +} + +/// @nodoc +abstract mixin class _$EmbyRawItemCopyWith<$Res> implements $EmbyRawItemCopyWith<$Res> { + factory _$EmbyRawItemCopyWith(_EmbyRawItem value, $Res Function(_EmbyRawItem) _then) = __$EmbyRawItemCopyWithImpl; +@override @useResult +$Res call({ + String Id, String Name,@JsonKey(includeIfNull: false) String? Type,@JsonKey(includeIfNull: false) String? Overview,@JsonKey(fromJson: _intOrNull, includeIfNull: false) int? ProductionYear,@JsonKey(fromJson: _doubleOrNull, includeIfNull: false) double? CommunityRating,@JsonKey(includeIfNull: false) String? PremiereDate,@JsonKey(includeIfNull: false) String? SeriesName,@JsonKey(includeIfNull: false) String? SeriesId,@JsonKey(includeIfNull: false) String? SeasonId,@JsonKey(includeIfNull: false) String? SeasonName,@JsonKey(fromJson: _intOrNull, includeIfNull: false) int? IndexNumber,@JsonKey(includeIfNull: false) String? ParentId,@JsonKey(includeIfNull: false) String? PrimaryImageTag,@JsonKey(fromJson: _stringMapOrNull, includeIfNull: false) Map? ImageTags,@JsonKey(fromJson: _stringListOrNull, includeIfNull: false) List? BackdropImageTags,@JsonKey(fromJson: _intOrNull, includeIfNull: false) int? RunTimeTicks,@JsonKey(includeIfNull: false) EmbyRawUserData? UserData,@JsonKey(includeFromJson: false, includeToJson: false) Map extra +}); + + +@override $EmbyRawUserDataCopyWith<$Res>? get UserData; + +} +/// @nodoc +class __$EmbyRawItemCopyWithImpl<$Res> + implements _$EmbyRawItemCopyWith<$Res> { + __$EmbyRawItemCopyWithImpl(this._self, this._then); + + final _EmbyRawItem _self; + final $Res Function(_EmbyRawItem) _then; + +/// Create a copy of EmbyRawItem +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? Id = null,Object? Name = null,Object? Type = freezed,Object? Overview = freezed,Object? ProductionYear = freezed,Object? CommunityRating = freezed,Object? PremiereDate = freezed,Object? SeriesName = freezed,Object? SeriesId = freezed,Object? SeasonId = freezed,Object? SeasonName = freezed,Object? IndexNumber = freezed,Object? ParentId = freezed,Object? PrimaryImageTag = freezed,Object? ImageTags = freezed,Object? BackdropImageTags = freezed,Object? RunTimeTicks = freezed,Object? UserData = freezed,Object? extra = null,}) { + return _then(_EmbyRawItem( +Id: null == Id ? _self.Id : Id // ignore: cast_nullable_to_non_nullable +as String,Name: null == Name ? _self.Name : Name // ignore: cast_nullable_to_non_nullable +as String,Type: freezed == Type ? _self.Type : Type // ignore: cast_nullable_to_non_nullable +as String?,Overview: freezed == Overview ? _self.Overview : Overview // ignore: cast_nullable_to_non_nullable +as String?,ProductionYear: freezed == ProductionYear ? _self.ProductionYear : ProductionYear // ignore: cast_nullable_to_non_nullable +as int?,CommunityRating: freezed == CommunityRating ? _self.CommunityRating : CommunityRating // ignore: cast_nullable_to_non_nullable +as double?,PremiereDate: freezed == PremiereDate ? _self.PremiereDate : PremiereDate // ignore: cast_nullable_to_non_nullable +as String?,SeriesName: freezed == SeriesName ? _self.SeriesName : SeriesName // ignore: cast_nullable_to_non_nullable +as String?,SeriesId: freezed == SeriesId ? _self.SeriesId : SeriesId // ignore: cast_nullable_to_non_nullable +as String?,SeasonId: freezed == SeasonId ? _self.SeasonId : SeasonId // ignore: cast_nullable_to_non_nullable +as String?,SeasonName: freezed == SeasonName ? _self.SeasonName : SeasonName // ignore: cast_nullable_to_non_nullable +as String?,IndexNumber: freezed == IndexNumber ? _self.IndexNumber : IndexNumber // ignore: cast_nullable_to_non_nullable +as int?,ParentId: freezed == ParentId ? _self.ParentId : ParentId // ignore: cast_nullable_to_non_nullable +as String?,PrimaryImageTag: freezed == PrimaryImageTag ? _self.PrimaryImageTag : PrimaryImageTag // ignore: cast_nullable_to_non_nullable +as String?,ImageTags: freezed == ImageTags ? _self._ImageTags : ImageTags // ignore: cast_nullable_to_non_nullable +as Map?,BackdropImageTags: freezed == BackdropImageTags ? _self._BackdropImageTags : BackdropImageTags // ignore: cast_nullable_to_non_nullable +as List?,RunTimeTicks: freezed == RunTimeTicks ? _self.RunTimeTicks : RunTimeTicks // ignore: cast_nullable_to_non_nullable +as int?,UserData: freezed == UserData ? _self.UserData : UserData // ignore: cast_nullable_to_non_nullable +as EmbyRawUserData?,extra: null == extra ? _self._extra : extra // ignore: cast_nullable_to_non_nullable +as Map, + )); +} + +/// Create a copy of EmbyRawItem +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$EmbyRawUserDataCopyWith<$Res>? get UserData { + if (_self.UserData == null) { + return null; + } + + return $EmbyRawUserDataCopyWith<$Res>(_self.UserData!, (value) { + return _then(_self.copyWith(UserData: value)); + }); +} +} + + +/// @nodoc +mixin _$EmbyRawSeason { + + String get Id; String get Name;@JsonKey(fromJson: _intOrNull, includeIfNull: false) int? get IndexNumber;@JsonKey(fromJson: _intOrNull, includeIfNull: false) int? get ProductionYear;@JsonKey(fromJson: _intOrNull, includeIfNull: false) int? get ChildCount;@JsonKey(includeIfNull: false) String? get PrimaryImageTag;@JsonKey(fromJson: _stringMapOrNull, includeIfNull: false) Map? get ImageTags; +/// Create a copy of EmbyRawSeason +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$EmbyRawSeasonCopyWith get copyWith => _$EmbyRawSeasonCopyWithImpl(this as EmbyRawSeason, _$identity); + + /// Serializes this EmbyRawSeason to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is EmbyRawSeason&&(identical(other.Id, Id) || other.Id == Id)&&(identical(other.Name, Name) || other.Name == Name)&&(identical(other.IndexNumber, IndexNumber) || other.IndexNumber == IndexNumber)&&(identical(other.ProductionYear, ProductionYear) || other.ProductionYear == ProductionYear)&&(identical(other.ChildCount, ChildCount) || other.ChildCount == ChildCount)&&(identical(other.PrimaryImageTag, PrimaryImageTag) || other.PrimaryImageTag == PrimaryImageTag)&&const DeepCollectionEquality().equals(other.ImageTags, ImageTags)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,Id,Name,IndexNumber,ProductionYear,ChildCount,PrimaryImageTag,const DeepCollectionEquality().hash(ImageTags)); + +@override +String toString() { + return 'EmbyRawSeason(Id: $Id, Name: $Name, IndexNumber: $IndexNumber, ProductionYear: $ProductionYear, ChildCount: $ChildCount, PrimaryImageTag: $PrimaryImageTag, ImageTags: $ImageTags)'; +} + + +} + +/// @nodoc +abstract mixin class $EmbyRawSeasonCopyWith<$Res> { + factory $EmbyRawSeasonCopyWith(EmbyRawSeason value, $Res Function(EmbyRawSeason) _then) = _$EmbyRawSeasonCopyWithImpl; +@useResult +$Res call({ + String Id, String Name,@JsonKey(fromJson: _intOrNull, includeIfNull: false) int? IndexNumber,@JsonKey(fromJson: _intOrNull, includeIfNull: false) int? ProductionYear,@JsonKey(fromJson: _intOrNull, includeIfNull: false) int? ChildCount,@JsonKey(includeIfNull: false) String? PrimaryImageTag,@JsonKey(fromJson: _stringMapOrNull, includeIfNull: false) Map? ImageTags +}); + + + + +} +/// @nodoc +class _$EmbyRawSeasonCopyWithImpl<$Res> + implements $EmbyRawSeasonCopyWith<$Res> { + _$EmbyRawSeasonCopyWithImpl(this._self, this._then); + + final EmbyRawSeason _self; + final $Res Function(EmbyRawSeason) _then; + +/// Create a copy of EmbyRawSeason +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? Id = null,Object? Name = null,Object? IndexNumber = freezed,Object? ProductionYear = freezed,Object? ChildCount = freezed,Object? PrimaryImageTag = freezed,Object? ImageTags = freezed,}) { + return _then(_self.copyWith( +Id: null == Id ? _self.Id : Id // ignore: cast_nullable_to_non_nullable +as String,Name: null == Name ? _self.Name : Name // ignore: cast_nullable_to_non_nullable +as String,IndexNumber: freezed == IndexNumber ? _self.IndexNumber : IndexNumber // ignore: cast_nullable_to_non_nullable +as int?,ProductionYear: freezed == ProductionYear ? _self.ProductionYear : ProductionYear // ignore: cast_nullable_to_non_nullable +as int?,ChildCount: freezed == ChildCount ? _self.ChildCount : ChildCount // ignore: cast_nullable_to_non_nullable +as int?,PrimaryImageTag: freezed == PrimaryImageTag ? _self.PrimaryImageTag : PrimaryImageTag // ignore: cast_nullable_to_non_nullable +as String?,ImageTags: freezed == ImageTags ? _self.ImageTags : ImageTags // ignore: cast_nullable_to_non_nullable +as Map?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [EmbyRawSeason]. +extension EmbyRawSeasonPatterns on EmbyRawSeason { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _EmbyRawSeason value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _EmbyRawSeason() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _EmbyRawSeason value) $default,){ +final _that = this; +switch (_that) { +case _EmbyRawSeason(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _EmbyRawSeason value)? $default,){ +final _that = this; +switch (_that) { +case _EmbyRawSeason() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String Id, String Name, @JsonKey(fromJson: _intOrNull, includeIfNull: false) int? IndexNumber, @JsonKey(fromJson: _intOrNull, includeIfNull: false) int? ProductionYear, @JsonKey(fromJson: _intOrNull, includeIfNull: false) int? ChildCount, @JsonKey(includeIfNull: false) String? PrimaryImageTag, @JsonKey(fromJson: _stringMapOrNull, includeIfNull: false) Map? ImageTags)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _EmbyRawSeason() when $default != null: +return $default(_that.Id,_that.Name,_that.IndexNumber,_that.ProductionYear,_that.ChildCount,_that.PrimaryImageTag,_that.ImageTags);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String Id, String Name, @JsonKey(fromJson: _intOrNull, includeIfNull: false) int? IndexNumber, @JsonKey(fromJson: _intOrNull, includeIfNull: false) int? ProductionYear, @JsonKey(fromJson: _intOrNull, includeIfNull: false) int? ChildCount, @JsonKey(includeIfNull: false) String? PrimaryImageTag, @JsonKey(fromJson: _stringMapOrNull, includeIfNull: false) Map? ImageTags) $default,) {final _that = this; +switch (_that) { +case _EmbyRawSeason(): +return $default(_that.Id,_that.Name,_that.IndexNumber,_that.ProductionYear,_that.ChildCount,_that.PrimaryImageTag,_that.ImageTags);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String Id, String Name, @JsonKey(fromJson: _intOrNull, includeIfNull: false) int? IndexNumber, @JsonKey(fromJson: _intOrNull, includeIfNull: false) int? ProductionYear, @JsonKey(fromJson: _intOrNull, includeIfNull: false) int? ChildCount, @JsonKey(includeIfNull: false) String? PrimaryImageTag, @JsonKey(fromJson: _stringMapOrNull, includeIfNull: false) Map? ImageTags)? $default,) {final _that = this; +switch (_that) { +case _EmbyRawSeason() when $default != null: +return $default(_that.Id,_that.Name,_that.IndexNumber,_that.ProductionYear,_that.ChildCount,_that.PrimaryImageTag,_that.ImageTags);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _EmbyRawSeason implements EmbyRawSeason { + const _EmbyRawSeason({required this.Id, required this.Name, @JsonKey(fromJson: _intOrNull, includeIfNull: false) this.IndexNumber, @JsonKey(fromJson: _intOrNull, includeIfNull: false) this.ProductionYear, @JsonKey(fromJson: _intOrNull, includeIfNull: false) this.ChildCount, @JsonKey(includeIfNull: false) this.PrimaryImageTag, @JsonKey(fromJson: _stringMapOrNull, includeIfNull: false) final Map? ImageTags}): _ImageTags = ImageTags; + factory _EmbyRawSeason.fromJson(Map json) => _$EmbyRawSeasonFromJson(json); + +@override final String Id; +@override final String Name; +@override@JsonKey(fromJson: _intOrNull, includeIfNull: false) final int? IndexNumber; +@override@JsonKey(fromJson: _intOrNull, includeIfNull: false) final int? ProductionYear; +@override@JsonKey(fromJson: _intOrNull, includeIfNull: false) final int? ChildCount; +@override@JsonKey(includeIfNull: false) final String? PrimaryImageTag; + final Map? _ImageTags; +@override@JsonKey(fromJson: _stringMapOrNull, includeIfNull: false) Map? get ImageTags { + final value = _ImageTags; + if (value == null) return null; + if (_ImageTags is EqualUnmodifiableMapView) return _ImageTags; + // ignore: implicit_dynamic_type + return EqualUnmodifiableMapView(value); +} + + +/// Create a copy of EmbyRawSeason +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$EmbyRawSeasonCopyWith<_EmbyRawSeason> get copyWith => __$EmbyRawSeasonCopyWithImpl<_EmbyRawSeason>(this, _$identity); + +@override +Map toJson() { + return _$EmbyRawSeasonToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _EmbyRawSeason&&(identical(other.Id, Id) || other.Id == Id)&&(identical(other.Name, Name) || other.Name == Name)&&(identical(other.IndexNumber, IndexNumber) || other.IndexNumber == IndexNumber)&&(identical(other.ProductionYear, ProductionYear) || other.ProductionYear == ProductionYear)&&(identical(other.ChildCount, ChildCount) || other.ChildCount == ChildCount)&&(identical(other.PrimaryImageTag, PrimaryImageTag) || other.PrimaryImageTag == PrimaryImageTag)&&const DeepCollectionEquality().equals(other._ImageTags, _ImageTags)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,Id,Name,IndexNumber,ProductionYear,ChildCount,PrimaryImageTag,const DeepCollectionEquality().hash(_ImageTags)); + +@override +String toString() { + return 'EmbyRawSeason(Id: $Id, Name: $Name, IndexNumber: $IndexNumber, ProductionYear: $ProductionYear, ChildCount: $ChildCount, PrimaryImageTag: $PrimaryImageTag, ImageTags: $ImageTags)'; +} + + +} + +/// @nodoc +abstract mixin class _$EmbyRawSeasonCopyWith<$Res> implements $EmbyRawSeasonCopyWith<$Res> { + factory _$EmbyRawSeasonCopyWith(_EmbyRawSeason value, $Res Function(_EmbyRawSeason) _then) = __$EmbyRawSeasonCopyWithImpl; +@override @useResult +$Res call({ + String Id, String Name,@JsonKey(fromJson: _intOrNull, includeIfNull: false) int? IndexNumber,@JsonKey(fromJson: _intOrNull, includeIfNull: false) int? ProductionYear,@JsonKey(fromJson: _intOrNull, includeIfNull: false) int? ChildCount,@JsonKey(includeIfNull: false) String? PrimaryImageTag,@JsonKey(fromJson: _stringMapOrNull, includeIfNull: false) Map? ImageTags +}); + + + + +} +/// @nodoc +class __$EmbyRawSeasonCopyWithImpl<$Res> + implements _$EmbyRawSeasonCopyWith<$Res> { + __$EmbyRawSeasonCopyWithImpl(this._self, this._then); + + final _EmbyRawSeason _self; + final $Res Function(_EmbyRawSeason) _then; + +/// Create a copy of EmbyRawSeason +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? Id = null,Object? Name = null,Object? IndexNumber = freezed,Object? ProductionYear = freezed,Object? ChildCount = freezed,Object? PrimaryImageTag = freezed,Object? ImageTags = freezed,}) { + return _then(_EmbyRawSeason( +Id: null == Id ? _self.Id : Id // ignore: cast_nullable_to_non_nullable +as String,Name: null == Name ? _self.Name : Name // ignore: cast_nullable_to_non_nullable +as String,IndexNumber: freezed == IndexNumber ? _self.IndexNumber : IndexNumber // ignore: cast_nullable_to_non_nullable +as int?,ProductionYear: freezed == ProductionYear ? _self.ProductionYear : ProductionYear // ignore: cast_nullable_to_non_nullable +as int?,ChildCount: freezed == ChildCount ? _self.ChildCount : ChildCount // ignore: cast_nullable_to_non_nullable +as int?,PrimaryImageTag: freezed == PrimaryImageTag ? _self.PrimaryImageTag : PrimaryImageTag // ignore: cast_nullable_to_non_nullable +as String?,ImageTags: freezed == ImageTags ? _self._ImageTags : ImageTags // ignore: cast_nullable_to_non_nullable +as Map?, + )); +} + + +} + + +/// @nodoc +mixin _$EmbyRawLibrary { + + String get Id; String get Name;@JsonKey(includeIfNull: false) String? get CollectionType;@JsonKey(fromJson: _stringMapOrNull, includeIfNull: false) Map? get ImageTags; +/// Create a copy of EmbyRawLibrary +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$EmbyRawLibraryCopyWith get copyWith => _$EmbyRawLibraryCopyWithImpl(this as EmbyRawLibrary, _$identity); + + /// Serializes this EmbyRawLibrary to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is EmbyRawLibrary&&(identical(other.Id, Id) || other.Id == Id)&&(identical(other.Name, Name) || other.Name == Name)&&(identical(other.CollectionType, CollectionType) || other.CollectionType == CollectionType)&&const DeepCollectionEquality().equals(other.ImageTags, ImageTags)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,Id,Name,CollectionType,const DeepCollectionEquality().hash(ImageTags)); + +@override +String toString() { + return 'EmbyRawLibrary(Id: $Id, Name: $Name, CollectionType: $CollectionType, ImageTags: $ImageTags)'; +} + + +} + +/// @nodoc +abstract mixin class $EmbyRawLibraryCopyWith<$Res> { + factory $EmbyRawLibraryCopyWith(EmbyRawLibrary value, $Res Function(EmbyRawLibrary) _then) = _$EmbyRawLibraryCopyWithImpl; +@useResult +$Res call({ + String Id, String Name,@JsonKey(includeIfNull: false) String? CollectionType,@JsonKey(fromJson: _stringMapOrNull, includeIfNull: false) Map? ImageTags +}); + + + + +} +/// @nodoc +class _$EmbyRawLibraryCopyWithImpl<$Res> + implements $EmbyRawLibraryCopyWith<$Res> { + _$EmbyRawLibraryCopyWithImpl(this._self, this._then); + + final EmbyRawLibrary _self; + final $Res Function(EmbyRawLibrary) _then; + +/// Create a copy of EmbyRawLibrary +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? Id = null,Object? Name = null,Object? CollectionType = freezed,Object? ImageTags = freezed,}) { + return _then(_self.copyWith( +Id: null == Id ? _self.Id : Id // ignore: cast_nullable_to_non_nullable +as String,Name: null == Name ? _self.Name : Name // ignore: cast_nullable_to_non_nullable +as String,CollectionType: freezed == CollectionType ? _self.CollectionType : CollectionType // ignore: cast_nullable_to_non_nullable +as String?,ImageTags: freezed == ImageTags ? _self.ImageTags : ImageTags // ignore: cast_nullable_to_non_nullable +as Map?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [EmbyRawLibrary]. +extension EmbyRawLibraryPatterns on EmbyRawLibrary { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _EmbyRawLibrary value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _EmbyRawLibrary() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _EmbyRawLibrary value) $default,){ +final _that = this; +switch (_that) { +case _EmbyRawLibrary(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _EmbyRawLibrary value)? $default,){ +final _that = this; +switch (_that) { +case _EmbyRawLibrary() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String Id, String Name, @JsonKey(includeIfNull: false) String? CollectionType, @JsonKey(fromJson: _stringMapOrNull, includeIfNull: false) Map? ImageTags)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _EmbyRawLibrary() when $default != null: +return $default(_that.Id,_that.Name,_that.CollectionType,_that.ImageTags);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String Id, String Name, @JsonKey(includeIfNull: false) String? CollectionType, @JsonKey(fromJson: _stringMapOrNull, includeIfNull: false) Map? ImageTags) $default,) {final _that = this; +switch (_that) { +case _EmbyRawLibrary(): +return $default(_that.Id,_that.Name,_that.CollectionType,_that.ImageTags);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String Id, String Name, @JsonKey(includeIfNull: false) String? CollectionType, @JsonKey(fromJson: _stringMapOrNull, includeIfNull: false) Map? ImageTags)? $default,) {final _that = this; +switch (_that) { +case _EmbyRawLibrary() when $default != null: +return $default(_that.Id,_that.Name,_that.CollectionType,_that.ImageTags);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _EmbyRawLibrary implements EmbyRawLibrary { + const _EmbyRawLibrary({required this.Id, required this.Name, @JsonKey(includeIfNull: false) this.CollectionType, @JsonKey(fromJson: _stringMapOrNull, includeIfNull: false) final Map? ImageTags}): _ImageTags = ImageTags; + factory _EmbyRawLibrary.fromJson(Map json) => _$EmbyRawLibraryFromJson(json); + +@override final String Id; +@override final String Name; +@override@JsonKey(includeIfNull: false) final String? CollectionType; + final Map? _ImageTags; +@override@JsonKey(fromJson: _stringMapOrNull, includeIfNull: false) Map? get ImageTags { + final value = _ImageTags; + if (value == null) return null; + if (_ImageTags is EqualUnmodifiableMapView) return _ImageTags; + // ignore: implicit_dynamic_type + return EqualUnmodifiableMapView(value); +} + + +/// Create a copy of EmbyRawLibrary +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$EmbyRawLibraryCopyWith<_EmbyRawLibrary> get copyWith => __$EmbyRawLibraryCopyWithImpl<_EmbyRawLibrary>(this, _$identity); + +@override +Map toJson() { + return _$EmbyRawLibraryToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _EmbyRawLibrary&&(identical(other.Id, Id) || other.Id == Id)&&(identical(other.Name, Name) || other.Name == Name)&&(identical(other.CollectionType, CollectionType) || other.CollectionType == CollectionType)&&const DeepCollectionEquality().equals(other._ImageTags, _ImageTags)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,Id,Name,CollectionType,const DeepCollectionEquality().hash(_ImageTags)); + +@override +String toString() { + return 'EmbyRawLibrary(Id: $Id, Name: $Name, CollectionType: $CollectionType, ImageTags: $ImageTags)'; +} + + +} + +/// @nodoc +abstract mixin class _$EmbyRawLibraryCopyWith<$Res> implements $EmbyRawLibraryCopyWith<$Res> { + factory _$EmbyRawLibraryCopyWith(_EmbyRawLibrary value, $Res Function(_EmbyRawLibrary) _then) = __$EmbyRawLibraryCopyWithImpl; +@override @useResult +$Res call({ + String Id, String Name,@JsonKey(includeIfNull: false) String? CollectionType,@JsonKey(fromJson: _stringMapOrNull, includeIfNull: false) Map? ImageTags +}); + + + + +} +/// @nodoc +class __$EmbyRawLibraryCopyWithImpl<$Res> + implements _$EmbyRawLibraryCopyWith<$Res> { + __$EmbyRawLibraryCopyWithImpl(this._self, this._then); + + final _EmbyRawLibrary _self; + final $Res Function(_EmbyRawLibrary) _then; + +/// Create a copy of EmbyRawLibrary +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? Id = null,Object? Name = null,Object? CollectionType = freezed,Object? ImageTags = freezed,}) { + return _then(_EmbyRawLibrary( +Id: null == Id ? _self.Id : Id // ignore: cast_nullable_to_non_nullable +as String,Name: null == Name ? _self.Name : Name // ignore: cast_nullable_to_non_nullable +as String,CollectionType: freezed == CollectionType ? _self.CollectionType : CollectionType // ignore: cast_nullable_to_non_nullable +as String?,ImageTags: freezed == ImageTags ? _self._ImageTags : ImageTags // ignore: cast_nullable_to_non_nullable +as Map?, + )); +} + + +} + + +/// @nodoc +mixin _$EmbyRawLibraries { + + List get Items; +/// Create a copy of EmbyRawLibraries +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$EmbyRawLibrariesCopyWith get copyWith => _$EmbyRawLibrariesCopyWithImpl(this as EmbyRawLibraries, _$identity); + + /// Serializes this EmbyRawLibraries to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is EmbyRawLibraries&&const DeepCollectionEquality().equals(other.Items, Items)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(Items)); + +@override +String toString() { + return 'EmbyRawLibraries(Items: $Items)'; +} + + +} + +/// @nodoc +abstract mixin class $EmbyRawLibrariesCopyWith<$Res> { + factory $EmbyRawLibrariesCopyWith(EmbyRawLibraries value, $Res Function(EmbyRawLibraries) _then) = _$EmbyRawLibrariesCopyWithImpl; +@useResult +$Res call({ + List Items +}); + + + + +} +/// @nodoc +class _$EmbyRawLibrariesCopyWithImpl<$Res> + implements $EmbyRawLibrariesCopyWith<$Res> { + _$EmbyRawLibrariesCopyWithImpl(this._self, this._then); + + final EmbyRawLibraries _self; + final $Res Function(EmbyRawLibraries) _then; + +/// Create a copy of EmbyRawLibraries +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? Items = null,}) { + return _then(_self.copyWith( +Items: null == Items ? _self.Items : Items // ignore: cast_nullable_to_non_nullable +as List, + )); +} + +} + + +/// Adds pattern-matching-related methods to [EmbyRawLibraries]. +extension EmbyRawLibrariesPatterns on EmbyRawLibraries { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _EmbyRawLibraries value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _EmbyRawLibraries() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _EmbyRawLibraries value) $default,){ +final _that = this; +switch (_that) { +case _EmbyRawLibraries(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _EmbyRawLibraries value)? $default,){ +final _that = this; +switch (_that) { +case _EmbyRawLibraries() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( List Items)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _EmbyRawLibraries() when $default != null: +return $default(_that.Items);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( List Items) $default,) {final _that = this; +switch (_that) { +case _EmbyRawLibraries(): +return $default(_that.Items);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( List Items)? $default,) {final _that = this; +switch (_that) { +case _EmbyRawLibraries() when $default != null: +return $default(_that.Items);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _EmbyRawLibraries implements EmbyRawLibraries { + const _EmbyRawLibraries({required final List Items}): _Items = Items; + factory _EmbyRawLibraries.fromJson(Map json) => _$EmbyRawLibrariesFromJson(json); + + final List _Items; +@override List get Items { + if (_Items is EqualUnmodifiableListView) return _Items; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_Items); +} + + +/// Create a copy of EmbyRawLibraries +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$EmbyRawLibrariesCopyWith<_EmbyRawLibraries> get copyWith => __$EmbyRawLibrariesCopyWithImpl<_EmbyRawLibraries>(this, _$identity); + +@override +Map toJson() { + return _$EmbyRawLibrariesToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _EmbyRawLibraries&&const DeepCollectionEquality().equals(other._Items, _Items)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_Items)); + +@override +String toString() { + return 'EmbyRawLibraries(Items: $Items)'; +} + + +} + +/// @nodoc +abstract mixin class _$EmbyRawLibrariesCopyWith<$Res> implements $EmbyRawLibrariesCopyWith<$Res> { + factory _$EmbyRawLibrariesCopyWith(_EmbyRawLibraries value, $Res Function(_EmbyRawLibraries) _then) = __$EmbyRawLibrariesCopyWithImpl; +@override @useResult +$Res call({ + List Items +}); + + + + +} +/// @nodoc +class __$EmbyRawLibrariesCopyWithImpl<$Res> + implements _$EmbyRawLibrariesCopyWith<$Res> { + __$EmbyRawLibrariesCopyWithImpl(this._self, this._then); + + final _EmbyRawLibraries _self; + final $Res Function(_EmbyRawLibraries) _then; + +/// Create a copy of EmbyRawLibraries +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? Items = null,}) { + return _then(_EmbyRawLibraries( +Items: null == Items ? _self._Items : Items // ignore: cast_nullable_to_non_nullable +as List, + )); +} + + +} + + +/// @nodoc +mixin _$EmbyRawMediaStream { + +@JsonKey(fromJson: _intOrNull, includeIfNull: false) int? get Index;@JsonKey(includeIfNull: false) String? get Type;@JsonKey(includeIfNull: false) String? get Language;@JsonKey(includeIfNull: false) String? get Codec;@JsonKey(fromJson: _intOrNull, includeIfNull: false) int? get Channels;@JsonKey(includeIfNull: false) String? get DisplayTitle;@JsonKey(includeIfNull: false) String? get Title;@JsonKey(includeIfNull: false) bool? get IsDefault;@JsonKey(includeIfNull: false) bool? get IsForced;@JsonKey(includeIfNull: false) bool? get IsExternal;@JsonKey(includeIfNull: false) String? get DeliveryMethod;@JsonKey(includeIfNull: false) String? get DeliveryUrl;@JsonKey(includeFromJson: false, includeToJson: false) Map get extra; +/// Create a copy of EmbyRawMediaStream +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$EmbyRawMediaStreamCopyWith get copyWith => _$EmbyRawMediaStreamCopyWithImpl(this as EmbyRawMediaStream, _$identity); + + /// Serializes this EmbyRawMediaStream to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is EmbyRawMediaStream&&(identical(other.Index, Index) || other.Index == Index)&&(identical(other.Type, Type) || other.Type == Type)&&(identical(other.Language, Language) || other.Language == Language)&&(identical(other.Codec, Codec) || other.Codec == Codec)&&(identical(other.Channels, Channels) || other.Channels == Channels)&&(identical(other.DisplayTitle, DisplayTitle) || other.DisplayTitle == DisplayTitle)&&(identical(other.Title, Title) || other.Title == Title)&&(identical(other.IsDefault, IsDefault) || other.IsDefault == IsDefault)&&(identical(other.IsForced, IsForced) || other.IsForced == IsForced)&&(identical(other.IsExternal, IsExternal) || other.IsExternal == IsExternal)&&(identical(other.DeliveryMethod, DeliveryMethod) || other.DeliveryMethod == DeliveryMethod)&&(identical(other.DeliveryUrl, DeliveryUrl) || other.DeliveryUrl == DeliveryUrl)&&const DeepCollectionEquality().equals(other.extra, extra)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,Index,Type,Language,Codec,Channels,DisplayTitle,Title,IsDefault,IsForced,IsExternal,DeliveryMethod,DeliveryUrl,const DeepCollectionEquality().hash(extra)); + +@override +String toString() { + return 'EmbyRawMediaStream(Index: $Index, Type: $Type, Language: $Language, Codec: $Codec, Channels: $Channels, DisplayTitle: $DisplayTitle, Title: $Title, IsDefault: $IsDefault, IsForced: $IsForced, IsExternal: $IsExternal, DeliveryMethod: $DeliveryMethod, DeliveryUrl: $DeliveryUrl, extra: $extra)'; +} + + +} + +/// @nodoc +abstract mixin class $EmbyRawMediaStreamCopyWith<$Res> { + factory $EmbyRawMediaStreamCopyWith(EmbyRawMediaStream value, $Res Function(EmbyRawMediaStream) _then) = _$EmbyRawMediaStreamCopyWithImpl; +@useResult +$Res call({ +@JsonKey(fromJson: _intOrNull, includeIfNull: false) int? Index,@JsonKey(includeIfNull: false) String? Type,@JsonKey(includeIfNull: false) String? Language,@JsonKey(includeIfNull: false) String? Codec,@JsonKey(fromJson: _intOrNull, includeIfNull: false) int? Channels,@JsonKey(includeIfNull: false) String? DisplayTitle,@JsonKey(includeIfNull: false) String? Title,@JsonKey(includeIfNull: false) bool? IsDefault,@JsonKey(includeIfNull: false) bool? IsForced,@JsonKey(includeIfNull: false) bool? IsExternal,@JsonKey(includeIfNull: false) String? DeliveryMethod,@JsonKey(includeIfNull: false) String? DeliveryUrl,@JsonKey(includeFromJson: false, includeToJson: false) Map extra +}); + + + + +} +/// @nodoc +class _$EmbyRawMediaStreamCopyWithImpl<$Res> + implements $EmbyRawMediaStreamCopyWith<$Res> { + _$EmbyRawMediaStreamCopyWithImpl(this._self, this._then); + + final EmbyRawMediaStream _self; + final $Res Function(EmbyRawMediaStream) _then; + +/// Create a copy of EmbyRawMediaStream +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? Index = freezed,Object? Type = freezed,Object? Language = freezed,Object? Codec = freezed,Object? Channels = freezed,Object? DisplayTitle = freezed,Object? Title = freezed,Object? IsDefault = freezed,Object? IsForced = freezed,Object? IsExternal = freezed,Object? DeliveryMethod = freezed,Object? DeliveryUrl = freezed,Object? extra = null,}) { + return _then(_self.copyWith( +Index: freezed == Index ? _self.Index : Index // ignore: cast_nullable_to_non_nullable +as int?,Type: freezed == Type ? _self.Type : Type // ignore: cast_nullable_to_non_nullable +as String?,Language: freezed == Language ? _self.Language : Language // ignore: cast_nullable_to_non_nullable +as String?,Codec: freezed == Codec ? _self.Codec : Codec // ignore: cast_nullable_to_non_nullable +as String?,Channels: freezed == Channels ? _self.Channels : Channels // ignore: cast_nullable_to_non_nullable +as int?,DisplayTitle: freezed == DisplayTitle ? _self.DisplayTitle : DisplayTitle // ignore: cast_nullable_to_non_nullable +as String?,Title: freezed == Title ? _self.Title : Title // ignore: cast_nullable_to_non_nullable +as String?,IsDefault: freezed == IsDefault ? _self.IsDefault : IsDefault // ignore: cast_nullable_to_non_nullable +as bool?,IsForced: freezed == IsForced ? _self.IsForced : IsForced // ignore: cast_nullable_to_non_nullable +as bool?,IsExternal: freezed == IsExternal ? _self.IsExternal : IsExternal // ignore: cast_nullable_to_non_nullable +as bool?,DeliveryMethod: freezed == DeliveryMethod ? _self.DeliveryMethod : DeliveryMethod // ignore: cast_nullable_to_non_nullable +as String?,DeliveryUrl: freezed == DeliveryUrl ? _self.DeliveryUrl : DeliveryUrl // ignore: cast_nullable_to_non_nullable +as String?,extra: null == extra ? _self.extra : extra // ignore: cast_nullable_to_non_nullable +as Map, + )); +} + +} + + +/// Adds pattern-matching-related methods to [EmbyRawMediaStream]. +extension EmbyRawMediaStreamPatterns on EmbyRawMediaStream { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _EmbyRawMediaStream value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _EmbyRawMediaStream() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _EmbyRawMediaStream value) $default,){ +final _that = this; +switch (_that) { +case _EmbyRawMediaStream(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _EmbyRawMediaStream value)? $default,){ +final _that = this; +switch (_that) { +case _EmbyRawMediaStream() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function(@JsonKey(fromJson: _intOrNull, includeIfNull: false) int? Index, @JsonKey(includeIfNull: false) String? Type, @JsonKey(includeIfNull: false) String? Language, @JsonKey(includeIfNull: false) String? Codec, @JsonKey(fromJson: _intOrNull, includeIfNull: false) int? Channels, @JsonKey(includeIfNull: false) String? DisplayTitle, @JsonKey(includeIfNull: false) String? Title, @JsonKey(includeIfNull: false) bool? IsDefault, @JsonKey(includeIfNull: false) bool? IsForced, @JsonKey(includeIfNull: false) bool? IsExternal, @JsonKey(includeIfNull: false) String? DeliveryMethod, @JsonKey(includeIfNull: false) String? DeliveryUrl, @JsonKey(includeFromJson: false, includeToJson: false) Map extra)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _EmbyRawMediaStream() when $default != null: +return $default(_that.Index,_that.Type,_that.Language,_that.Codec,_that.Channels,_that.DisplayTitle,_that.Title,_that.IsDefault,_that.IsForced,_that.IsExternal,_that.DeliveryMethod,_that.DeliveryUrl,_that.extra);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function(@JsonKey(fromJson: _intOrNull, includeIfNull: false) int? Index, @JsonKey(includeIfNull: false) String? Type, @JsonKey(includeIfNull: false) String? Language, @JsonKey(includeIfNull: false) String? Codec, @JsonKey(fromJson: _intOrNull, includeIfNull: false) int? Channels, @JsonKey(includeIfNull: false) String? DisplayTitle, @JsonKey(includeIfNull: false) String? Title, @JsonKey(includeIfNull: false) bool? IsDefault, @JsonKey(includeIfNull: false) bool? IsForced, @JsonKey(includeIfNull: false) bool? IsExternal, @JsonKey(includeIfNull: false) String? DeliveryMethod, @JsonKey(includeIfNull: false) String? DeliveryUrl, @JsonKey(includeFromJson: false, includeToJson: false) Map extra) $default,) {final _that = this; +switch (_that) { +case _EmbyRawMediaStream(): +return $default(_that.Index,_that.Type,_that.Language,_that.Codec,_that.Channels,_that.DisplayTitle,_that.Title,_that.IsDefault,_that.IsForced,_that.IsExternal,_that.DeliveryMethod,_that.DeliveryUrl,_that.extra);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function(@JsonKey(fromJson: _intOrNull, includeIfNull: false) int? Index, @JsonKey(includeIfNull: false) String? Type, @JsonKey(includeIfNull: false) String? Language, @JsonKey(includeIfNull: false) String? Codec, @JsonKey(fromJson: _intOrNull, includeIfNull: false) int? Channels, @JsonKey(includeIfNull: false) String? DisplayTitle, @JsonKey(includeIfNull: false) String? Title, @JsonKey(includeIfNull: false) bool? IsDefault, @JsonKey(includeIfNull: false) bool? IsForced, @JsonKey(includeIfNull: false) bool? IsExternal, @JsonKey(includeIfNull: false) String? DeliveryMethod, @JsonKey(includeIfNull: false) String? DeliveryUrl, @JsonKey(includeFromJson: false, includeToJson: false) Map extra)? $default,) {final _that = this; +switch (_that) { +case _EmbyRawMediaStream() when $default != null: +return $default(_that.Index,_that.Type,_that.Language,_that.Codec,_that.Channels,_that.DisplayTitle,_that.Title,_that.IsDefault,_that.IsForced,_that.IsExternal,_that.DeliveryMethod,_that.DeliveryUrl,_that.extra);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _EmbyRawMediaStream implements EmbyRawMediaStream { + const _EmbyRawMediaStream({@JsonKey(fromJson: _intOrNull, includeIfNull: false) this.Index, @JsonKey(includeIfNull: false) this.Type, @JsonKey(includeIfNull: false) this.Language, @JsonKey(includeIfNull: false) this.Codec, @JsonKey(fromJson: _intOrNull, includeIfNull: false) this.Channels, @JsonKey(includeIfNull: false) this.DisplayTitle, @JsonKey(includeIfNull: false) this.Title, @JsonKey(includeIfNull: false) this.IsDefault, @JsonKey(includeIfNull: false) this.IsForced, @JsonKey(includeIfNull: false) this.IsExternal, @JsonKey(includeIfNull: false) this.DeliveryMethod, @JsonKey(includeIfNull: false) this.DeliveryUrl, @JsonKey(includeFromJson: false, includeToJson: false) final Map extra = const {}}): _extra = extra; + factory _EmbyRawMediaStream.fromJson(Map json) => _$EmbyRawMediaStreamFromJson(json); + +@override@JsonKey(fromJson: _intOrNull, includeIfNull: false) final int? Index; +@override@JsonKey(includeIfNull: false) final String? Type; +@override@JsonKey(includeIfNull: false) final String? Language; +@override@JsonKey(includeIfNull: false) final String? Codec; +@override@JsonKey(fromJson: _intOrNull, includeIfNull: false) final int? Channels; +@override@JsonKey(includeIfNull: false) final String? DisplayTitle; +@override@JsonKey(includeIfNull: false) final String? Title; +@override@JsonKey(includeIfNull: false) final bool? IsDefault; +@override@JsonKey(includeIfNull: false) final bool? IsForced; +@override@JsonKey(includeIfNull: false) final bool? IsExternal; +@override@JsonKey(includeIfNull: false) final String? DeliveryMethod; +@override@JsonKey(includeIfNull: false) final String? DeliveryUrl; + final Map _extra; +@override@JsonKey(includeFromJson: false, includeToJson: false) Map get extra { + if (_extra is EqualUnmodifiableMapView) return _extra; + // ignore: implicit_dynamic_type + return EqualUnmodifiableMapView(_extra); +} + + +/// Create a copy of EmbyRawMediaStream +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$EmbyRawMediaStreamCopyWith<_EmbyRawMediaStream> get copyWith => __$EmbyRawMediaStreamCopyWithImpl<_EmbyRawMediaStream>(this, _$identity); + +@override +Map toJson() { + return _$EmbyRawMediaStreamToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _EmbyRawMediaStream&&(identical(other.Index, Index) || other.Index == Index)&&(identical(other.Type, Type) || other.Type == Type)&&(identical(other.Language, Language) || other.Language == Language)&&(identical(other.Codec, Codec) || other.Codec == Codec)&&(identical(other.Channels, Channels) || other.Channels == Channels)&&(identical(other.DisplayTitle, DisplayTitle) || other.DisplayTitle == DisplayTitle)&&(identical(other.Title, Title) || other.Title == Title)&&(identical(other.IsDefault, IsDefault) || other.IsDefault == IsDefault)&&(identical(other.IsForced, IsForced) || other.IsForced == IsForced)&&(identical(other.IsExternal, IsExternal) || other.IsExternal == IsExternal)&&(identical(other.DeliveryMethod, DeliveryMethod) || other.DeliveryMethod == DeliveryMethod)&&(identical(other.DeliveryUrl, DeliveryUrl) || other.DeliveryUrl == DeliveryUrl)&&const DeepCollectionEquality().equals(other._extra, _extra)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,Index,Type,Language,Codec,Channels,DisplayTitle,Title,IsDefault,IsForced,IsExternal,DeliveryMethod,DeliveryUrl,const DeepCollectionEquality().hash(_extra)); + +@override +String toString() { + return 'EmbyRawMediaStream(Index: $Index, Type: $Type, Language: $Language, Codec: $Codec, Channels: $Channels, DisplayTitle: $DisplayTitle, Title: $Title, IsDefault: $IsDefault, IsForced: $IsForced, IsExternal: $IsExternal, DeliveryMethod: $DeliveryMethod, DeliveryUrl: $DeliveryUrl, extra: $extra)'; +} + + +} + +/// @nodoc +abstract mixin class _$EmbyRawMediaStreamCopyWith<$Res> implements $EmbyRawMediaStreamCopyWith<$Res> { + factory _$EmbyRawMediaStreamCopyWith(_EmbyRawMediaStream value, $Res Function(_EmbyRawMediaStream) _then) = __$EmbyRawMediaStreamCopyWithImpl; +@override @useResult +$Res call({ +@JsonKey(fromJson: _intOrNull, includeIfNull: false) int? Index,@JsonKey(includeIfNull: false) String? Type,@JsonKey(includeIfNull: false) String? Language,@JsonKey(includeIfNull: false) String? Codec,@JsonKey(fromJson: _intOrNull, includeIfNull: false) int? Channels,@JsonKey(includeIfNull: false) String? DisplayTitle,@JsonKey(includeIfNull: false) String? Title,@JsonKey(includeIfNull: false) bool? IsDefault,@JsonKey(includeIfNull: false) bool? IsForced,@JsonKey(includeIfNull: false) bool? IsExternal,@JsonKey(includeIfNull: false) String? DeliveryMethod,@JsonKey(includeIfNull: false) String? DeliveryUrl,@JsonKey(includeFromJson: false, includeToJson: false) Map extra +}); + + + + +} +/// @nodoc +class __$EmbyRawMediaStreamCopyWithImpl<$Res> + implements _$EmbyRawMediaStreamCopyWith<$Res> { + __$EmbyRawMediaStreamCopyWithImpl(this._self, this._then); + + final _EmbyRawMediaStream _self; + final $Res Function(_EmbyRawMediaStream) _then; + +/// Create a copy of EmbyRawMediaStream +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? Index = freezed,Object? Type = freezed,Object? Language = freezed,Object? Codec = freezed,Object? Channels = freezed,Object? DisplayTitle = freezed,Object? Title = freezed,Object? IsDefault = freezed,Object? IsForced = freezed,Object? IsExternal = freezed,Object? DeliveryMethod = freezed,Object? DeliveryUrl = freezed,Object? extra = null,}) { + return _then(_EmbyRawMediaStream( +Index: freezed == Index ? _self.Index : Index // ignore: cast_nullable_to_non_nullable +as int?,Type: freezed == Type ? _self.Type : Type // ignore: cast_nullable_to_non_nullable +as String?,Language: freezed == Language ? _self.Language : Language // ignore: cast_nullable_to_non_nullable +as String?,Codec: freezed == Codec ? _self.Codec : Codec // ignore: cast_nullable_to_non_nullable +as String?,Channels: freezed == Channels ? _self.Channels : Channels // ignore: cast_nullable_to_non_nullable +as int?,DisplayTitle: freezed == DisplayTitle ? _self.DisplayTitle : DisplayTitle // ignore: cast_nullable_to_non_nullable +as String?,Title: freezed == Title ? _self.Title : Title // ignore: cast_nullable_to_non_nullable +as String?,IsDefault: freezed == IsDefault ? _self.IsDefault : IsDefault // ignore: cast_nullable_to_non_nullable +as bool?,IsForced: freezed == IsForced ? _self.IsForced : IsForced // ignore: cast_nullable_to_non_nullable +as bool?,IsExternal: freezed == IsExternal ? _self.IsExternal : IsExternal // ignore: cast_nullable_to_non_nullable +as bool?,DeliveryMethod: freezed == DeliveryMethod ? _self.DeliveryMethod : DeliveryMethod // ignore: cast_nullable_to_non_nullable +as String?,DeliveryUrl: freezed == DeliveryUrl ? _self.DeliveryUrl : DeliveryUrl // ignore: cast_nullable_to_non_nullable +as String?,extra: null == extra ? _self._extra : extra // ignore: cast_nullable_to_non_nullable +as Map, + )); +} + + +} + + +/// @nodoc +mixin _$EmbyRawMediaSource { + +@JsonKey(includeIfNull: false) String? get Id;@JsonKey(includeIfNull: false) String? get Name;@JsonKey(includeIfNull: false) String? get Container;@JsonKey(fromJson: _intOrNull, includeIfNull: false) int? get Bitrate;@JsonKey(fromJson: _intOrNull, includeIfNull: false) int? get Size;@JsonKey(fromJson: _intOrNull, includeIfNull: false) int? get Width;@JsonKey(fromJson: _intOrNull, includeIfNull: false) int? get Height;@JsonKey(fromJson: _intOrNull, includeIfNull: false) int? get DefaultSubtitleStreamIndex;@JsonKey(fromJson: _intOrNull, includeIfNull: false) int? get DefaultAudioStreamIndex;@JsonKey(includeIfNull: false) List? get MediaStreams; +/// Create a copy of EmbyRawMediaSource +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$EmbyRawMediaSourceCopyWith get copyWith => _$EmbyRawMediaSourceCopyWithImpl(this as EmbyRawMediaSource, _$identity); + + /// Serializes this EmbyRawMediaSource to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is EmbyRawMediaSource&&(identical(other.Id, Id) || other.Id == Id)&&(identical(other.Name, Name) || other.Name == Name)&&(identical(other.Container, Container) || other.Container == Container)&&(identical(other.Bitrate, Bitrate) || other.Bitrate == Bitrate)&&(identical(other.Size, Size) || other.Size == Size)&&(identical(other.Width, Width) || other.Width == Width)&&(identical(other.Height, Height) || other.Height == Height)&&(identical(other.DefaultSubtitleStreamIndex, DefaultSubtitleStreamIndex) || other.DefaultSubtitleStreamIndex == DefaultSubtitleStreamIndex)&&(identical(other.DefaultAudioStreamIndex, DefaultAudioStreamIndex) || other.DefaultAudioStreamIndex == DefaultAudioStreamIndex)&&const DeepCollectionEquality().equals(other.MediaStreams, MediaStreams)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,Id,Name,Container,Bitrate,Size,Width,Height,DefaultSubtitleStreamIndex,DefaultAudioStreamIndex,const DeepCollectionEquality().hash(MediaStreams)); + +@override +String toString() { + return 'EmbyRawMediaSource(Id: $Id, Name: $Name, Container: $Container, Bitrate: $Bitrate, Size: $Size, Width: $Width, Height: $Height, DefaultSubtitleStreamIndex: $DefaultSubtitleStreamIndex, DefaultAudioStreamIndex: $DefaultAudioStreamIndex, MediaStreams: $MediaStreams)'; +} + + +} + +/// @nodoc +abstract mixin class $EmbyRawMediaSourceCopyWith<$Res> { + factory $EmbyRawMediaSourceCopyWith(EmbyRawMediaSource value, $Res Function(EmbyRawMediaSource) _then) = _$EmbyRawMediaSourceCopyWithImpl; +@useResult +$Res call({ +@JsonKey(includeIfNull: false) String? Id,@JsonKey(includeIfNull: false) String? Name,@JsonKey(includeIfNull: false) String? Container,@JsonKey(fromJson: _intOrNull, includeIfNull: false) int? Bitrate,@JsonKey(fromJson: _intOrNull, includeIfNull: false) int? Size,@JsonKey(fromJson: _intOrNull, includeIfNull: false) int? Width,@JsonKey(fromJson: _intOrNull, includeIfNull: false) int? Height,@JsonKey(fromJson: _intOrNull, includeIfNull: false) int? DefaultSubtitleStreamIndex,@JsonKey(fromJson: _intOrNull, includeIfNull: false) int? DefaultAudioStreamIndex,@JsonKey(includeIfNull: false) List? MediaStreams +}); + + + + +} +/// @nodoc +class _$EmbyRawMediaSourceCopyWithImpl<$Res> + implements $EmbyRawMediaSourceCopyWith<$Res> { + _$EmbyRawMediaSourceCopyWithImpl(this._self, this._then); + + final EmbyRawMediaSource _self; + final $Res Function(EmbyRawMediaSource) _then; + +/// Create a copy of EmbyRawMediaSource +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? Id = freezed,Object? Name = freezed,Object? Container = freezed,Object? Bitrate = freezed,Object? Size = freezed,Object? Width = freezed,Object? Height = freezed,Object? DefaultSubtitleStreamIndex = freezed,Object? DefaultAudioStreamIndex = freezed,Object? MediaStreams = freezed,}) { + return _then(_self.copyWith( +Id: freezed == Id ? _self.Id : Id // ignore: cast_nullable_to_non_nullable +as String?,Name: freezed == Name ? _self.Name : Name // ignore: cast_nullable_to_non_nullable +as String?,Container: freezed == Container ? _self.Container : Container // ignore: cast_nullable_to_non_nullable +as String?,Bitrate: freezed == Bitrate ? _self.Bitrate : Bitrate // ignore: cast_nullable_to_non_nullable +as int?,Size: freezed == Size ? _self.Size : Size // ignore: cast_nullable_to_non_nullable +as int?,Width: freezed == Width ? _self.Width : Width // ignore: cast_nullable_to_non_nullable +as int?,Height: freezed == Height ? _self.Height : Height // ignore: cast_nullable_to_non_nullable +as int?,DefaultSubtitleStreamIndex: freezed == DefaultSubtitleStreamIndex ? _self.DefaultSubtitleStreamIndex : DefaultSubtitleStreamIndex // ignore: cast_nullable_to_non_nullable +as int?,DefaultAudioStreamIndex: freezed == DefaultAudioStreamIndex ? _self.DefaultAudioStreamIndex : DefaultAudioStreamIndex // ignore: cast_nullable_to_non_nullable +as int?,MediaStreams: freezed == MediaStreams ? _self.MediaStreams : MediaStreams // ignore: cast_nullable_to_non_nullable +as List?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [EmbyRawMediaSource]. +extension EmbyRawMediaSourcePatterns on EmbyRawMediaSource { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _EmbyRawMediaSource value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _EmbyRawMediaSource() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _EmbyRawMediaSource value) $default,){ +final _that = this; +switch (_that) { +case _EmbyRawMediaSource(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _EmbyRawMediaSource value)? $default,){ +final _that = this; +switch (_that) { +case _EmbyRawMediaSource() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function(@JsonKey(includeIfNull: false) String? Id, @JsonKey(includeIfNull: false) String? Name, @JsonKey(includeIfNull: false) String? Container, @JsonKey(fromJson: _intOrNull, includeIfNull: false) int? Bitrate, @JsonKey(fromJson: _intOrNull, includeIfNull: false) int? Size, @JsonKey(fromJson: _intOrNull, includeIfNull: false) int? Width, @JsonKey(fromJson: _intOrNull, includeIfNull: false) int? Height, @JsonKey(fromJson: _intOrNull, includeIfNull: false) int? DefaultSubtitleStreamIndex, @JsonKey(fromJson: _intOrNull, includeIfNull: false) int? DefaultAudioStreamIndex, @JsonKey(includeIfNull: false) List? MediaStreams)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _EmbyRawMediaSource() when $default != null: +return $default(_that.Id,_that.Name,_that.Container,_that.Bitrate,_that.Size,_that.Width,_that.Height,_that.DefaultSubtitleStreamIndex,_that.DefaultAudioStreamIndex,_that.MediaStreams);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function(@JsonKey(includeIfNull: false) String? Id, @JsonKey(includeIfNull: false) String? Name, @JsonKey(includeIfNull: false) String? Container, @JsonKey(fromJson: _intOrNull, includeIfNull: false) int? Bitrate, @JsonKey(fromJson: _intOrNull, includeIfNull: false) int? Size, @JsonKey(fromJson: _intOrNull, includeIfNull: false) int? Width, @JsonKey(fromJson: _intOrNull, includeIfNull: false) int? Height, @JsonKey(fromJson: _intOrNull, includeIfNull: false) int? DefaultSubtitleStreamIndex, @JsonKey(fromJson: _intOrNull, includeIfNull: false) int? DefaultAudioStreamIndex, @JsonKey(includeIfNull: false) List? MediaStreams) $default,) {final _that = this; +switch (_that) { +case _EmbyRawMediaSource(): +return $default(_that.Id,_that.Name,_that.Container,_that.Bitrate,_that.Size,_that.Width,_that.Height,_that.DefaultSubtitleStreamIndex,_that.DefaultAudioStreamIndex,_that.MediaStreams);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function(@JsonKey(includeIfNull: false) String? Id, @JsonKey(includeIfNull: false) String? Name, @JsonKey(includeIfNull: false) String? Container, @JsonKey(fromJson: _intOrNull, includeIfNull: false) int? Bitrate, @JsonKey(fromJson: _intOrNull, includeIfNull: false) int? Size, @JsonKey(fromJson: _intOrNull, includeIfNull: false) int? Width, @JsonKey(fromJson: _intOrNull, includeIfNull: false) int? Height, @JsonKey(fromJson: _intOrNull, includeIfNull: false) int? DefaultSubtitleStreamIndex, @JsonKey(fromJson: _intOrNull, includeIfNull: false) int? DefaultAudioStreamIndex, @JsonKey(includeIfNull: false) List? MediaStreams)? $default,) {final _that = this; +switch (_that) { +case _EmbyRawMediaSource() when $default != null: +return $default(_that.Id,_that.Name,_that.Container,_that.Bitrate,_that.Size,_that.Width,_that.Height,_that.DefaultSubtitleStreamIndex,_that.DefaultAudioStreamIndex,_that.MediaStreams);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _EmbyRawMediaSource implements EmbyRawMediaSource { + const _EmbyRawMediaSource({@JsonKey(includeIfNull: false) this.Id, @JsonKey(includeIfNull: false) this.Name, @JsonKey(includeIfNull: false) this.Container, @JsonKey(fromJson: _intOrNull, includeIfNull: false) this.Bitrate, @JsonKey(fromJson: _intOrNull, includeIfNull: false) this.Size, @JsonKey(fromJson: _intOrNull, includeIfNull: false) this.Width, @JsonKey(fromJson: _intOrNull, includeIfNull: false) this.Height, @JsonKey(fromJson: _intOrNull, includeIfNull: false) this.DefaultSubtitleStreamIndex, @JsonKey(fromJson: _intOrNull, includeIfNull: false) this.DefaultAudioStreamIndex, @JsonKey(includeIfNull: false) final List? MediaStreams}): _MediaStreams = MediaStreams; + factory _EmbyRawMediaSource.fromJson(Map json) => _$EmbyRawMediaSourceFromJson(json); + +@override@JsonKey(includeIfNull: false) final String? Id; +@override@JsonKey(includeIfNull: false) final String? Name; +@override@JsonKey(includeIfNull: false) final String? Container; +@override@JsonKey(fromJson: _intOrNull, includeIfNull: false) final int? Bitrate; +@override@JsonKey(fromJson: _intOrNull, includeIfNull: false) final int? Size; +@override@JsonKey(fromJson: _intOrNull, includeIfNull: false) final int? Width; +@override@JsonKey(fromJson: _intOrNull, includeIfNull: false) final int? Height; +@override@JsonKey(fromJson: _intOrNull, includeIfNull: false) final int? DefaultSubtitleStreamIndex; +@override@JsonKey(fromJson: _intOrNull, includeIfNull: false) final int? DefaultAudioStreamIndex; + final List? _MediaStreams; +@override@JsonKey(includeIfNull: false) List? get MediaStreams { + final value = _MediaStreams; + if (value == null) return null; + if (_MediaStreams is EqualUnmodifiableListView) return _MediaStreams; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(value); +} + + +/// Create a copy of EmbyRawMediaSource +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$EmbyRawMediaSourceCopyWith<_EmbyRawMediaSource> get copyWith => __$EmbyRawMediaSourceCopyWithImpl<_EmbyRawMediaSource>(this, _$identity); + +@override +Map toJson() { + return _$EmbyRawMediaSourceToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _EmbyRawMediaSource&&(identical(other.Id, Id) || other.Id == Id)&&(identical(other.Name, Name) || other.Name == Name)&&(identical(other.Container, Container) || other.Container == Container)&&(identical(other.Bitrate, Bitrate) || other.Bitrate == Bitrate)&&(identical(other.Size, Size) || other.Size == Size)&&(identical(other.Width, Width) || other.Width == Width)&&(identical(other.Height, Height) || other.Height == Height)&&(identical(other.DefaultSubtitleStreamIndex, DefaultSubtitleStreamIndex) || other.DefaultSubtitleStreamIndex == DefaultSubtitleStreamIndex)&&(identical(other.DefaultAudioStreamIndex, DefaultAudioStreamIndex) || other.DefaultAudioStreamIndex == DefaultAudioStreamIndex)&&const DeepCollectionEquality().equals(other._MediaStreams, _MediaStreams)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,Id,Name,Container,Bitrate,Size,Width,Height,DefaultSubtitleStreamIndex,DefaultAudioStreamIndex,const DeepCollectionEquality().hash(_MediaStreams)); + +@override +String toString() { + return 'EmbyRawMediaSource(Id: $Id, Name: $Name, Container: $Container, Bitrate: $Bitrate, Size: $Size, Width: $Width, Height: $Height, DefaultSubtitleStreamIndex: $DefaultSubtitleStreamIndex, DefaultAudioStreamIndex: $DefaultAudioStreamIndex, MediaStreams: $MediaStreams)'; +} + + +} + +/// @nodoc +abstract mixin class _$EmbyRawMediaSourceCopyWith<$Res> implements $EmbyRawMediaSourceCopyWith<$Res> { + factory _$EmbyRawMediaSourceCopyWith(_EmbyRawMediaSource value, $Res Function(_EmbyRawMediaSource) _then) = __$EmbyRawMediaSourceCopyWithImpl; +@override @useResult +$Res call({ +@JsonKey(includeIfNull: false) String? Id,@JsonKey(includeIfNull: false) String? Name,@JsonKey(includeIfNull: false) String? Container,@JsonKey(fromJson: _intOrNull, includeIfNull: false) int? Bitrate,@JsonKey(fromJson: _intOrNull, includeIfNull: false) int? Size,@JsonKey(fromJson: _intOrNull, includeIfNull: false) int? Width,@JsonKey(fromJson: _intOrNull, includeIfNull: false) int? Height,@JsonKey(fromJson: _intOrNull, includeIfNull: false) int? DefaultSubtitleStreamIndex,@JsonKey(fromJson: _intOrNull, includeIfNull: false) int? DefaultAudioStreamIndex,@JsonKey(includeIfNull: false) List? MediaStreams +}); + + + + +} +/// @nodoc +class __$EmbyRawMediaSourceCopyWithImpl<$Res> + implements _$EmbyRawMediaSourceCopyWith<$Res> { + __$EmbyRawMediaSourceCopyWithImpl(this._self, this._then); + + final _EmbyRawMediaSource _self; + final $Res Function(_EmbyRawMediaSource) _then; + +/// Create a copy of EmbyRawMediaSource +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? Id = freezed,Object? Name = freezed,Object? Container = freezed,Object? Bitrate = freezed,Object? Size = freezed,Object? Width = freezed,Object? Height = freezed,Object? DefaultSubtitleStreamIndex = freezed,Object? DefaultAudioStreamIndex = freezed,Object? MediaStreams = freezed,}) { + return _then(_EmbyRawMediaSource( +Id: freezed == Id ? _self.Id : Id // ignore: cast_nullable_to_non_nullable +as String?,Name: freezed == Name ? _self.Name : Name // ignore: cast_nullable_to_non_nullable +as String?,Container: freezed == Container ? _self.Container : Container // ignore: cast_nullable_to_non_nullable +as String?,Bitrate: freezed == Bitrate ? _self.Bitrate : Bitrate // ignore: cast_nullable_to_non_nullable +as int?,Size: freezed == Size ? _self.Size : Size // ignore: cast_nullable_to_non_nullable +as int?,Width: freezed == Width ? _self.Width : Width // ignore: cast_nullable_to_non_nullable +as int?,Height: freezed == Height ? _self.Height : Height // ignore: cast_nullable_to_non_nullable +as int?,DefaultSubtitleStreamIndex: freezed == DefaultSubtitleStreamIndex ? _self.DefaultSubtitleStreamIndex : DefaultSubtitleStreamIndex // ignore: cast_nullable_to_non_nullable +as int?,DefaultAudioStreamIndex: freezed == DefaultAudioStreamIndex ? _self.DefaultAudioStreamIndex : DefaultAudioStreamIndex // ignore: cast_nullable_to_non_nullable +as int?,MediaStreams: freezed == MediaStreams ? _self._MediaStreams : MediaStreams // ignore: cast_nullable_to_non_nullable +as List?, + )); +} + + +} + +/// @nodoc +mixin _$LibraryLatestReq { + + String get parentId; int? get limit; +/// Create a copy of LibraryLatestReq +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$LibraryLatestReqCopyWith get copyWith => _$LibraryLatestReqCopyWithImpl(this as LibraryLatestReq, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is LibraryLatestReq&&(identical(other.parentId, parentId) || other.parentId == parentId)&&(identical(other.limit, limit) || other.limit == limit)); +} + + +@override +int get hashCode => Object.hash(runtimeType,parentId,limit); + +@override +String toString() { + return 'LibraryLatestReq(parentId: $parentId, limit: $limit)'; +} + + +} + +/// @nodoc +abstract mixin class $LibraryLatestReqCopyWith<$Res> { + factory $LibraryLatestReqCopyWith(LibraryLatestReq value, $Res Function(LibraryLatestReq) _then) = _$LibraryLatestReqCopyWithImpl; +@useResult +$Res call({ + String parentId, int? limit +}); + + + + +} +/// @nodoc +class _$LibraryLatestReqCopyWithImpl<$Res> + implements $LibraryLatestReqCopyWith<$Res> { + _$LibraryLatestReqCopyWithImpl(this._self, this._then); + + final LibraryLatestReq _self; + final $Res Function(LibraryLatestReq) _then; + +/// Create a copy of LibraryLatestReq +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? parentId = null,Object? limit = freezed,}) { + return _then(_self.copyWith( +parentId: null == parentId ? _self.parentId : parentId // ignore: cast_nullable_to_non_nullable +as String,limit: freezed == limit ? _self.limit : limit // ignore: cast_nullable_to_non_nullable +as int?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [LibraryLatestReq]. +extension LibraryLatestReqPatterns on LibraryLatestReq { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _LibraryLatestReq value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _LibraryLatestReq() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _LibraryLatestReq value) $default,){ +final _that = this; +switch (_that) { +case _LibraryLatestReq(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _LibraryLatestReq value)? $default,){ +final _that = this; +switch (_that) { +case _LibraryLatestReq() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String parentId, int? limit)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _LibraryLatestReq() when $default != null: +return $default(_that.parentId,_that.limit);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String parentId, int? limit) $default,) {final _that = this; +switch (_that) { +case _LibraryLatestReq(): +return $default(_that.parentId,_that.limit);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String parentId, int? limit)? $default,) {final _that = this; +switch (_that) { +case _LibraryLatestReq() when $default != null: +return $default(_that.parentId,_that.limit);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _LibraryLatestReq extends LibraryLatestReq { + const _LibraryLatestReq({required this.parentId, this.limit}): super._(); + + +@override final String parentId; +@override final int? limit; + +/// Create a copy of LibraryLatestReq +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$LibraryLatestReqCopyWith<_LibraryLatestReq> get copyWith => __$LibraryLatestReqCopyWithImpl<_LibraryLatestReq>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _LibraryLatestReq&&(identical(other.parentId, parentId) || other.parentId == parentId)&&(identical(other.limit, limit) || other.limit == limit)); +} + + +@override +int get hashCode => Object.hash(runtimeType,parentId,limit); + +@override +String toString() { + return 'LibraryLatestReq(parentId: $parentId, limit: $limit)'; +} + + +} + +/// @nodoc +abstract mixin class _$LibraryLatestReqCopyWith<$Res> implements $LibraryLatestReqCopyWith<$Res> { + factory _$LibraryLatestReqCopyWith(_LibraryLatestReq value, $Res Function(_LibraryLatestReq) _then) = __$LibraryLatestReqCopyWithImpl; +@override @useResult +$Res call({ + String parentId, int? limit +}); + + + + +} +/// @nodoc +class __$LibraryLatestReqCopyWithImpl<$Res> + implements _$LibraryLatestReqCopyWith<$Res> { + __$LibraryLatestReqCopyWithImpl(this._self, this._then); + + final _LibraryLatestReq _self; + final $Res Function(_LibraryLatestReq) _then; + +/// Create a copy of LibraryLatestReq +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? parentId = null,Object? limit = freezed,}) { + return _then(_LibraryLatestReq( +parentId: null == parentId ? _self.parentId : parentId // ignore: cast_nullable_to_non_nullable +as String,limit: freezed == limit ? _self.limit : limit // ignore: cast_nullable_to_non_nullable +as int?, + )); +} + + +} + + +/// @nodoc +mixin _$LibraryLatestRes { + + String get parentId; List get items; int get totalCount; +/// Create a copy of LibraryLatestRes +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$LibraryLatestResCopyWith get copyWith => _$LibraryLatestResCopyWithImpl(this as LibraryLatestRes, _$identity); + + /// Serializes this LibraryLatestRes to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is LibraryLatestRes&&(identical(other.parentId, parentId) || other.parentId == parentId)&&const DeepCollectionEquality().equals(other.items, items)&&(identical(other.totalCount, totalCount) || other.totalCount == totalCount)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,parentId,const DeepCollectionEquality().hash(items),totalCount); + +@override +String toString() { + return 'LibraryLatestRes(parentId: $parentId, items: $items, totalCount: $totalCount)'; +} + + +} + +/// @nodoc +abstract mixin class $LibraryLatestResCopyWith<$Res> { + factory $LibraryLatestResCopyWith(LibraryLatestRes value, $Res Function(LibraryLatestRes) _then) = _$LibraryLatestResCopyWithImpl; +@useResult +$Res call({ + String parentId, List items, int totalCount +}); + + + + +} +/// @nodoc +class _$LibraryLatestResCopyWithImpl<$Res> + implements $LibraryLatestResCopyWith<$Res> { + _$LibraryLatestResCopyWithImpl(this._self, this._then); + + final LibraryLatestRes _self; + final $Res Function(LibraryLatestRes) _then; + +/// Create a copy of LibraryLatestRes +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? parentId = null,Object? items = null,Object? totalCount = null,}) { + return _then(_self.copyWith( +parentId: null == parentId ? _self.parentId : parentId // ignore: cast_nullable_to_non_nullable +as String,items: null == items ? _self.items : items // ignore: cast_nullable_to_non_nullable +as List,totalCount: null == totalCount ? _self.totalCount : totalCount // ignore: cast_nullable_to_non_nullable +as int, + )); +} + +} + + +/// Adds pattern-matching-related methods to [LibraryLatestRes]. +extension LibraryLatestResPatterns on LibraryLatestRes { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _LibraryLatestRes value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _LibraryLatestRes() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _LibraryLatestRes value) $default,){ +final _that = this; +switch (_that) { +case _LibraryLatestRes(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _LibraryLatestRes value)? $default,){ +final _that = this; +switch (_that) { +case _LibraryLatestRes() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String parentId, List items, int totalCount)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _LibraryLatestRes() when $default != null: +return $default(_that.parentId,_that.items,_that.totalCount);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String parentId, List items, int totalCount) $default,) {final _that = this; +switch (_that) { +case _LibraryLatestRes(): +return $default(_that.parentId,_that.items,_that.totalCount);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String parentId, List items, int totalCount)? $default,) {final _that = this; +switch (_that) { +case _LibraryLatestRes() when $default != null: +return $default(_that.parentId,_that.items,_that.totalCount);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _LibraryLatestRes implements LibraryLatestRes { + const _LibraryLatestRes({required this.parentId, required final List items, this.totalCount = 0}): _items = items; + factory _LibraryLatestRes.fromJson(Map json) => _$LibraryLatestResFromJson(json); + +@override final String parentId; + final List _items; +@override List get items { + if (_items is EqualUnmodifiableListView) return _items; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_items); +} + +@override@JsonKey() final int totalCount; + +/// Create a copy of LibraryLatestRes +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$LibraryLatestResCopyWith<_LibraryLatestRes> get copyWith => __$LibraryLatestResCopyWithImpl<_LibraryLatestRes>(this, _$identity); + +@override +Map toJson() { + return _$LibraryLatestResToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _LibraryLatestRes&&(identical(other.parentId, parentId) || other.parentId == parentId)&&const DeepCollectionEquality().equals(other._items, _items)&&(identical(other.totalCount, totalCount) || other.totalCount == totalCount)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,parentId,const DeepCollectionEquality().hash(_items),totalCount); + +@override +String toString() { + return 'LibraryLatestRes(parentId: $parentId, items: $items, totalCount: $totalCount)'; +} + + +} + +/// @nodoc +abstract mixin class _$LibraryLatestResCopyWith<$Res> implements $LibraryLatestResCopyWith<$Res> { + factory _$LibraryLatestResCopyWith(_LibraryLatestRes value, $Res Function(_LibraryLatestRes) _then) = __$LibraryLatestResCopyWithImpl; +@override @useResult +$Res call({ + String parentId, List items, int totalCount +}); + + + + +} +/// @nodoc +class __$LibraryLatestResCopyWithImpl<$Res> + implements _$LibraryLatestResCopyWith<$Res> { + __$LibraryLatestResCopyWithImpl(this._self, this._then); + + final _LibraryLatestRes _self; + final $Res Function(_LibraryLatestRes) _then; + +/// Create a copy of LibraryLatestRes +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? parentId = null,Object? items = null,Object? totalCount = null,}) { + return _then(_LibraryLatestRes( +parentId: null == parentId ? _self.parentId : parentId // ignore: cast_nullable_to_non_nullable +as String,items: null == items ? _self._items : items // ignore: cast_nullable_to_non_nullable +as List,totalCount: null == totalCount ? _self.totalCount : totalCount // ignore: cast_nullable_to_non_nullable +as int, + )); +} + + +} + + +/// @nodoc +mixin _$LibraryResumeRes { + + List get Items; +/// Create a copy of LibraryResumeRes +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$LibraryResumeResCopyWith get copyWith => _$LibraryResumeResCopyWithImpl(this as LibraryResumeRes, _$identity); + + /// Serializes this LibraryResumeRes to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is LibraryResumeRes&&const DeepCollectionEquality().equals(other.Items, Items)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(Items)); + +@override +String toString() { + return 'LibraryResumeRes(Items: $Items)'; +} + + +} + +/// @nodoc +abstract mixin class $LibraryResumeResCopyWith<$Res> { + factory $LibraryResumeResCopyWith(LibraryResumeRes value, $Res Function(LibraryResumeRes) _then) = _$LibraryResumeResCopyWithImpl; +@useResult +$Res call({ + List Items +}); + + + + +} +/// @nodoc +class _$LibraryResumeResCopyWithImpl<$Res> + implements $LibraryResumeResCopyWith<$Res> { + _$LibraryResumeResCopyWithImpl(this._self, this._then); + + final LibraryResumeRes _self; + final $Res Function(LibraryResumeRes) _then; + +/// Create a copy of LibraryResumeRes +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? Items = null,}) { + return _then(_self.copyWith( +Items: null == Items ? _self.Items : Items // ignore: cast_nullable_to_non_nullable +as List, + )); +} + +} + + +/// Adds pattern-matching-related methods to [LibraryResumeRes]. +extension LibraryResumeResPatterns on LibraryResumeRes { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _LibraryResumeRes value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _LibraryResumeRes() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _LibraryResumeRes value) $default,){ +final _that = this; +switch (_that) { +case _LibraryResumeRes(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _LibraryResumeRes value)? $default,){ +final _that = this; +switch (_that) { +case _LibraryResumeRes() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( List Items)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _LibraryResumeRes() when $default != null: +return $default(_that.Items);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( List Items) $default,) {final _that = this; +switch (_that) { +case _LibraryResumeRes(): +return $default(_that.Items);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( List Items)? $default,) {final _that = this; +switch (_that) { +case _LibraryResumeRes() when $default != null: +return $default(_that.Items);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _LibraryResumeRes implements LibraryResumeRes { + const _LibraryResumeRes({required final List Items}): _Items = Items; + factory _LibraryResumeRes.fromJson(Map json) => _$LibraryResumeResFromJson(json); + + final List _Items; +@override List get Items { + if (_Items is EqualUnmodifiableListView) return _Items; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_Items); +} + + +/// Create a copy of LibraryResumeRes +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$LibraryResumeResCopyWith<_LibraryResumeRes> get copyWith => __$LibraryResumeResCopyWithImpl<_LibraryResumeRes>(this, _$identity); + +@override +Map toJson() { + return _$LibraryResumeResToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _LibraryResumeRes&&const DeepCollectionEquality().equals(other._Items, _Items)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_Items)); + +@override +String toString() { + return 'LibraryResumeRes(Items: $Items)'; +} + + +} + +/// @nodoc +abstract mixin class _$LibraryResumeResCopyWith<$Res> implements $LibraryResumeResCopyWith<$Res> { + factory _$LibraryResumeResCopyWith(_LibraryResumeRes value, $Res Function(_LibraryResumeRes) _then) = __$LibraryResumeResCopyWithImpl; +@override @useResult +$Res call({ + List Items +}); + + + + +} +/// @nodoc +class __$LibraryResumeResCopyWithImpl<$Res> + implements _$LibraryResumeResCopyWith<$Res> { + __$LibraryResumeResCopyWithImpl(this._self, this._then); + + final _LibraryResumeRes _self; + final $Res Function(_LibraryResumeRes) _then; + +/// Create a copy of LibraryResumeRes +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? Items = null,}) { + return _then(_LibraryResumeRes( +Items: null == Items ? _self._Items : Items // ignore: cast_nullable_to_non_nullable +as List, + )); +} + + +} + +/// @nodoc +mixin _$MediaDetailReq { + + String get itemId; +/// Create a copy of MediaDetailReq +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$MediaDetailReqCopyWith get copyWith => _$MediaDetailReqCopyWithImpl(this as MediaDetailReq, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is MediaDetailReq&&(identical(other.itemId, itemId) || other.itemId == itemId)); +} + + +@override +int get hashCode => Object.hash(runtimeType,itemId); + +@override +String toString() { + return 'MediaDetailReq(itemId: $itemId)'; +} + + +} + +/// @nodoc +abstract mixin class $MediaDetailReqCopyWith<$Res> { + factory $MediaDetailReqCopyWith(MediaDetailReq value, $Res Function(MediaDetailReq) _then) = _$MediaDetailReqCopyWithImpl; +@useResult +$Res call({ + String itemId +}); + + + + +} +/// @nodoc +class _$MediaDetailReqCopyWithImpl<$Res> + implements $MediaDetailReqCopyWith<$Res> { + _$MediaDetailReqCopyWithImpl(this._self, this._then); + + final MediaDetailReq _self; + final $Res Function(MediaDetailReq) _then; + +/// Create a copy of MediaDetailReq +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? itemId = null,}) { + return _then(_self.copyWith( +itemId: null == itemId ? _self.itemId : itemId // ignore: cast_nullable_to_non_nullable +as String, + )); +} + +} + + +/// Adds pattern-matching-related methods to [MediaDetailReq]. +extension MediaDetailReqPatterns on MediaDetailReq { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _MediaDetailReq value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _MediaDetailReq() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _MediaDetailReq value) $default,){ +final _that = this; +switch (_that) { +case _MediaDetailReq(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _MediaDetailReq value)? $default,){ +final _that = this; +switch (_that) { +case _MediaDetailReq() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String itemId)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _MediaDetailReq() when $default != null: +return $default(_that.itemId);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String itemId) $default,) {final _that = this; +switch (_that) { +case _MediaDetailReq(): +return $default(_that.itemId);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String itemId)? $default,) {final _that = this; +switch (_that) { +case _MediaDetailReq() when $default != null: +return $default(_that.itemId);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _MediaDetailReq extends MediaDetailReq { + const _MediaDetailReq({required this.itemId}): super._(); + + +@override final String itemId; + +/// Create a copy of MediaDetailReq +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$MediaDetailReqCopyWith<_MediaDetailReq> get copyWith => __$MediaDetailReqCopyWithImpl<_MediaDetailReq>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _MediaDetailReq&&(identical(other.itemId, itemId) || other.itemId == itemId)); +} + + +@override +int get hashCode => Object.hash(runtimeType,itemId); + +@override +String toString() { + return 'MediaDetailReq(itemId: $itemId)'; +} + + +} + +/// @nodoc +abstract mixin class _$MediaDetailReqCopyWith<$Res> implements $MediaDetailReqCopyWith<$Res> { + factory _$MediaDetailReqCopyWith(_MediaDetailReq value, $Res Function(_MediaDetailReq) _then) = __$MediaDetailReqCopyWithImpl; +@override @useResult +$Res call({ + String itemId +}); + + + + +} +/// @nodoc +class __$MediaDetailReqCopyWithImpl<$Res> + implements _$MediaDetailReqCopyWith<$Res> { + __$MediaDetailReqCopyWithImpl(this._self, this._then); + + final _MediaDetailReq _self; + final $Res Function(_MediaDetailReq) _then; + +/// Create a copy of MediaDetailReq +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? itemId = null,}) { + return _then(_MediaDetailReq( +itemId: null == itemId ? _self.itemId : itemId // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + +/// @nodoc +mixin _$EmbyRawMediaDetailSeriesExtra { + + List get nextUp; List get seasons; +/// Create a copy of EmbyRawMediaDetailSeriesExtra +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$EmbyRawMediaDetailSeriesExtraCopyWith get copyWith => _$EmbyRawMediaDetailSeriesExtraCopyWithImpl(this as EmbyRawMediaDetailSeriesExtra, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is EmbyRawMediaDetailSeriesExtra&&const DeepCollectionEquality().equals(other.nextUp, nextUp)&&const DeepCollectionEquality().equals(other.seasons, seasons)); +} + + +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(nextUp),const DeepCollectionEquality().hash(seasons)); + +@override +String toString() { + return 'EmbyRawMediaDetailSeriesExtra(nextUp: $nextUp, seasons: $seasons)'; +} + + +} + +/// @nodoc +abstract mixin class $EmbyRawMediaDetailSeriesExtraCopyWith<$Res> { + factory $EmbyRawMediaDetailSeriesExtraCopyWith(EmbyRawMediaDetailSeriesExtra value, $Res Function(EmbyRawMediaDetailSeriesExtra) _then) = _$EmbyRawMediaDetailSeriesExtraCopyWithImpl; +@useResult +$Res call({ + List nextUp, List seasons +}); + + + + +} +/// @nodoc +class _$EmbyRawMediaDetailSeriesExtraCopyWithImpl<$Res> + implements $EmbyRawMediaDetailSeriesExtraCopyWith<$Res> { + _$EmbyRawMediaDetailSeriesExtraCopyWithImpl(this._self, this._then); + + final EmbyRawMediaDetailSeriesExtra _self; + final $Res Function(EmbyRawMediaDetailSeriesExtra) _then; + +/// Create a copy of EmbyRawMediaDetailSeriesExtra +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? nextUp = null,Object? seasons = null,}) { + return _then(_self.copyWith( +nextUp: null == nextUp ? _self.nextUp : nextUp // ignore: cast_nullable_to_non_nullable +as List,seasons: null == seasons ? _self.seasons : seasons // ignore: cast_nullable_to_non_nullable +as List, + )); +} + +} + + +/// Adds pattern-matching-related methods to [EmbyRawMediaDetailSeriesExtra]. +extension EmbyRawMediaDetailSeriesExtraPatterns on EmbyRawMediaDetailSeriesExtra { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _EmbyRawMediaDetailSeriesExtra value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _EmbyRawMediaDetailSeriesExtra() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _EmbyRawMediaDetailSeriesExtra value) $default,){ +final _that = this; +switch (_that) { +case _EmbyRawMediaDetailSeriesExtra(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _EmbyRawMediaDetailSeriesExtra value)? $default,){ +final _that = this; +switch (_that) { +case _EmbyRawMediaDetailSeriesExtra() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( List nextUp, List seasons)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _EmbyRawMediaDetailSeriesExtra() when $default != null: +return $default(_that.nextUp,_that.seasons);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( List nextUp, List seasons) $default,) {final _that = this; +switch (_that) { +case _EmbyRawMediaDetailSeriesExtra(): +return $default(_that.nextUp,_that.seasons);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( List nextUp, List seasons)? $default,) {final _that = this; +switch (_that) { +case _EmbyRawMediaDetailSeriesExtra() when $default != null: +return $default(_that.nextUp,_that.seasons);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _EmbyRawMediaDetailSeriesExtra implements EmbyRawMediaDetailSeriesExtra { + const _EmbyRawMediaDetailSeriesExtra({required final List nextUp, required final List seasons}): _nextUp = nextUp,_seasons = seasons; + + + final List _nextUp; +@override List get nextUp { + if (_nextUp is EqualUnmodifiableListView) return _nextUp; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_nextUp); +} + + final List _seasons; +@override List get seasons { + if (_seasons is EqualUnmodifiableListView) return _seasons; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_seasons); +} + + +/// Create a copy of EmbyRawMediaDetailSeriesExtra +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$EmbyRawMediaDetailSeriesExtraCopyWith<_EmbyRawMediaDetailSeriesExtra> get copyWith => __$EmbyRawMediaDetailSeriesExtraCopyWithImpl<_EmbyRawMediaDetailSeriesExtra>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _EmbyRawMediaDetailSeriesExtra&&const DeepCollectionEquality().equals(other._nextUp, _nextUp)&&const DeepCollectionEquality().equals(other._seasons, _seasons)); +} + + +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_nextUp),const DeepCollectionEquality().hash(_seasons)); + +@override +String toString() { + return 'EmbyRawMediaDetailSeriesExtra(nextUp: $nextUp, seasons: $seasons)'; +} + + +} + +/// @nodoc +abstract mixin class _$EmbyRawMediaDetailSeriesExtraCopyWith<$Res> implements $EmbyRawMediaDetailSeriesExtraCopyWith<$Res> { + factory _$EmbyRawMediaDetailSeriesExtraCopyWith(_EmbyRawMediaDetailSeriesExtra value, $Res Function(_EmbyRawMediaDetailSeriesExtra) _then) = __$EmbyRawMediaDetailSeriesExtraCopyWithImpl; +@override @useResult +$Res call({ + List nextUp, List seasons +}); + + + + +} +/// @nodoc +class __$EmbyRawMediaDetailSeriesExtraCopyWithImpl<$Res> + implements _$EmbyRawMediaDetailSeriesExtraCopyWith<$Res> { + __$EmbyRawMediaDetailSeriesExtraCopyWithImpl(this._self, this._then); + + final _EmbyRawMediaDetailSeriesExtra _self; + final $Res Function(_EmbyRawMediaDetailSeriesExtra) _then; + +/// Create a copy of EmbyRawMediaDetailSeriesExtra +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? nextUp = null,Object? seasons = null,}) { + return _then(_EmbyRawMediaDetailSeriesExtra( +nextUp: null == nextUp ? _self._nextUp : nextUp // ignore: cast_nullable_to_non_nullable +as List,seasons: null == seasons ? _self._seasons : seasons // ignore: cast_nullable_to_non_nullable +as List, + )); +} + + +} + +/// @nodoc +mixin _$EmbyRawMediaDetailSeriesContext { + + EmbyRawItem get seriesBase; EmbyRawMediaDetailSeriesExtra get seriesExtra; +/// Create a copy of EmbyRawMediaDetailSeriesContext +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$EmbyRawMediaDetailSeriesContextCopyWith get copyWith => _$EmbyRawMediaDetailSeriesContextCopyWithImpl(this as EmbyRawMediaDetailSeriesContext, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is EmbyRawMediaDetailSeriesContext&&(identical(other.seriesBase, seriesBase) || other.seriesBase == seriesBase)&&(identical(other.seriesExtra, seriesExtra) || other.seriesExtra == seriesExtra)); +} + + +@override +int get hashCode => Object.hash(runtimeType,seriesBase,seriesExtra); + +@override +String toString() { + return 'EmbyRawMediaDetailSeriesContext(seriesBase: $seriesBase, seriesExtra: $seriesExtra)'; +} + + +} + +/// @nodoc +abstract mixin class $EmbyRawMediaDetailSeriesContextCopyWith<$Res> { + factory $EmbyRawMediaDetailSeriesContextCopyWith(EmbyRawMediaDetailSeriesContext value, $Res Function(EmbyRawMediaDetailSeriesContext) _then) = _$EmbyRawMediaDetailSeriesContextCopyWithImpl; +@useResult +$Res call({ + EmbyRawItem seriesBase, EmbyRawMediaDetailSeriesExtra seriesExtra +}); + + +$EmbyRawItemCopyWith<$Res> get seriesBase;$EmbyRawMediaDetailSeriesExtraCopyWith<$Res> get seriesExtra; + +} +/// @nodoc +class _$EmbyRawMediaDetailSeriesContextCopyWithImpl<$Res> + implements $EmbyRawMediaDetailSeriesContextCopyWith<$Res> { + _$EmbyRawMediaDetailSeriesContextCopyWithImpl(this._self, this._then); + + final EmbyRawMediaDetailSeriesContext _self; + final $Res Function(EmbyRawMediaDetailSeriesContext) _then; + +/// Create a copy of EmbyRawMediaDetailSeriesContext +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? seriesBase = null,Object? seriesExtra = null,}) { + return _then(_self.copyWith( +seriesBase: null == seriesBase ? _self.seriesBase : seriesBase // ignore: cast_nullable_to_non_nullable +as EmbyRawItem,seriesExtra: null == seriesExtra ? _self.seriesExtra : seriesExtra // ignore: cast_nullable_to_non_nullable +as EmbyRawMediaDetailSeriesExtra, + )); +} +/// Create a copy of EmbyRawMediaDetailSeriesContext +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$EmbyRawItemCopyWith<$Res> get seriesBase { + + return $EmbyRawItemCopyWith<$Res>(_self.seriesBase, (value) { + return _then(_self.copyWith(seriesBase: value)); + }); +}/// Create a copy of EmbyRawMediaDetailSeriesContext +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$EmbyRawMediaDetailSeriesExtraCopyWith<$Res> get seriesExtra { + + return $EmbyRawMediaDetailSeriesExtraCopyWith<$Res>(_self.seriesExtra, (value) { + return _then(_self.copyWith(seriesExtra: value)); + }); +} +} + + +/// Adds pattern-matching-related methods to [EmbyRawMediaDetailSeriesContext]. +extension EmbyRawMediaDetailSeriesContextPatterns on EmbyRawMediaDetailSeriesContext { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _EmbyRawMediaDetailSeriesContext value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _EmbyRawMediaDetailSeriesContext() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _EmbyRawMediaDetailSeriesContext value) $default,){ +final _that = this; +switch (_that) { +case _EmbyRawMediaDetailSeriesContext(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _EmbyRawMediaDetailSeriesContext value)? $default,){ +final _that = this; +switch (_that) { +case _EmbyRawMediaDetailSeriesContext() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( EmbyRawItem seriesBase, EmbyRawMediaDetailSeriesExtra seriesExtra)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _EmbyRawMediaDetailSeriesContext() when $default != null: +return $default(_that.seriesBase,_that.seriesExtra);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( EmbyRawItem seriesBase, EmbyRawMediaDetailSeriesExtra seriesExtra) $default,) {final _that = this; +switch (_that) { +case _EmbyRawMediaDetailSeriesContext(): +return $default(_that.seriesBase,_that.seriesExtra);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( EmbyRawItem seriesBase, EmbyRawMediaDetailSeriesExtra seriesExtra)? $default,) {final _that = this; +switch (_that) { +case _EmbyRawMediaDetailSeriesContext() when $default != null: +return $default(_that.seriesBase,_that.seriesExtra);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _EmbyRawMediaDetailSeriesContext implements EmbyRawMediaDetailSeriesContext { + const _EmbyRawMediaDetailSeriesContext({required this.seriesBase, required this.seriesExtra}); + + +@override final EmbyRawItem seriesBase; +@override final EmbyRawMediaDetailSeriesExtra seriesExtra; + +/// Create a copy of EmbyRawMediaDetailSeriesContext +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$EmbyRawMediaDetailSeriesContextCopyWith<_EmbyRawMediaDetailSeriesContext> get copyWith => __$EmbyRawMediaDetailSeriesContextCopyWithImpl<_EmbyRawMediaDetailSeriesContext>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _EmbyRawMediaDetailSeriesContext&&(identical(other.seriesBase, seriesBase) || other.seriesBase == seriesBase)&&(identical(other.seriesExtra, seriesExtra) || other.seriesExtra == seriesExtra)); +} + + +@override +int get hashCode => Object.hash(runtimeType,seriesBase,seriesExtra); + +@override +String toString() { + return 'EmbyRawMediaDetailSeriesContext(seriesBase: $seriesBase, seriesExtra: $seriesExtra)'; +} + + +} + +/// @nodoc +abstract mixin class _$EmbyRawMediaDetailSeriesContextCopyWith<$Res> implements $EmbyRawMediaDetailSeriesContextCopyWith<$Res> { + factory _$EmbyRawMediaDetailSeriesContextCopyWith(_EmbyRawMediaDetailSeriesContext value, $Res Function(_EmbyRawMediaDetailSeriesContext) _then) = __$EmbyRawMediaDetailSeriesContextCopyWithImpl; +@override @useResult +$Res call({ + EmbyRawItem seriesBase, EmbyRawMediaDetailSeriesExtra seriesExtra +}); + + +@override $EmbyRawItemCopyWith<$Res> get seriesBase;@override $EmbyRawMediaDetailSeriesExtraCopyWith<$Res> get seriesExtra; + +} +/// @nodoc +class __$EmbyRawMediaDetailSeriesContextCopyWithImpl<$Res> + implements _$EmbyRawMediaDetailSeriesContextCopyWith<$Res> { + __$EmbyRawMediaDetailSeriesContextCopyWithImpl(this._self, this._then); + + final _EmbyRawMediaDetailSeriesContext _self; + final $Res Function(_EmbyRawMediaDetailSeriesContext) _then; + +/// Create a copy of EmbyRawMediaDetailSeriesContext +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? seriesBase = null,Object? seriesExtra = null,}) { + return _then(_EmbyRawMediaDetailSeriesContext( +seriesBase: null == seriesBase ? _self.seriesBase : seriesBase // ignore: cast_nullable_to_non_nullable +as EmbyRawItem,seriesExtra: null == seriesExtra ? _self.seriesExtra : seriesExtra // ignore: cast_nullable_to_non_nullable +as EmbyRawMediaDetailSeriesExtra, + )); +} + +/// Create a copy of EmbyRawMediaDetailSeriesContext +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$EmbyRawItemCopyWith<$Res> get seriesBase { + + return $EmbyRawItemCopyWith<$Res>(_self.seriesBase, (value) { + return _then(_self.copyWith(seriesBase: value)); + }); +}/// Create a copy of EmbyRawMediaDetailSeriesContext +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$EmbyRawMediaDetailSeriesExtraCopyWith<$Res> get seriesExtra { + + return $EmbyRawMediaDetailSeriesExtraCopyWith<$Res>(_self.seriesExtra, (value) { + return _then(_self.copyWith(seriesExtra: value)); + }); +} +} + +/// @nodoc +mixin _$MediaDetailRes { + + EmbyRawItem get base; EmbyRawMediaDetailSeriesExtra? get seriesExtra; EmbyRawMediaDetailSeriesContext? get seriesContext; List? get similarItems; List? get boxSetItems; +/// Create a copy of MediaDetailRes +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$MediaDetailResCopyWith get copyWith => _$MediaDetailResCopyWithImpl(this as MediaDetailRes, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is MediaDetailRes&&(identical(other.base, base) || other.base == base)&&(identical(other.seriesExtra, seriesExtra) || other.seriesExtra == seriesExtra)&&(identical(other.seriesContext, seriesContext) || other.seriesContext == seriesContext)&&const DeepCollectionEquality().equals(other.similarItems, similarItems)&&const DeepCollectionEquality().equals(other.boxSetItems, boxSetItems)); +} + + +@override +int get hashCode => Object.hash(runtimeType,base,seriesExtra,seriesContext,const DeepCollectionEquality().hash(similarItems),const DeepCollectionEquality().hash(boxSetItems)); + +@override +String toString() { + return 'MediaDetailRes(base: $base, seriesExtra: $seriesExtra, seriesContext: $seriesContext, similarItems: $similarItems, boxSetItems: $boxSetItems)'; +} + + +} + +/// @nodoc +abstract mixin class $MediaDetailResCopyWith<$Res> { + factory $MediaDetailResCopyWith(MediaDetailRes value, $Res Function(MediaDetailRes) _then) = _$MediaDetailResCopyWithImpl; +@useResult +$Res call({ + EmbyRawItem base, EmbyRawMediaDetailSeriesExtra? seriesExtra, EmbyRawMediaDetailSeriesContext? seriesContext, List? similarItems, List? boxSetItems +}); + + +$EmbyRawItemCopyWith<$Res> get base;$EmbyRawMediaDetailSeriesExtraCopyWith<$Res>? get seriesExtra;$EmbyRawMediaDetailSeriesContextCopyWith<$Res>? get seriesContext; + +} +/// @nodoc +class _$MediaDetailResCopyWithImpl<$Res> + implements $MediaDetailResCopyWith<$Res> { + _$MediaDetailResCopyWithImpl(this._self, this._then); + + final MediaDetailRes _self; + final $Res Function(MediaDetailRes) _then; + +/// Create a copy of MediaDetailRes +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? base = null,Object? seriesExtra = freezed,Object? seriesContext = freezed,Object? similarItems = freezed,Object? boxSetItems = freezed,}) { + return _then(_self.copyWith( +base: null == base ? _self.base : base // ignore: cast_nullable_to_non_nullable +as EmbyRawItem,seriesExtra: freezed == seriesExtra ? _self.seriesExtra : seriesExtra // ignore: cast_nullable_to_non_nullable +as EmbyRawMediaDetailSeriesExtra?,seriesContext: freezed == seriesContext ? _self.seriesContext : seriesContext // ignore: cast_nullable_to_non_nullable +as EmbyRawMediaDetailSeriesContext?,similarItems: freezed == similarItems ? _self.similarItems : similarItems // ignore: cast_nullable_to_non_nullable +as List?,boxSetItems: freezed == boxSetItems ? _self.boxSetItems : boxSetItems // ignore: cast_nullable_to_non_nullable +as List?, + )); +} +/// Create a copy of MediaDetailRes +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$EmbyRawItemCopyWith<$Res> get base { + + return $EmbyRawItemCopyWith<$Res>(_self.base, (value) { + return _then(_self.copyWith(base: value)); + }); +}/// Create a copy of MediaDetailRes +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$EmbyRawMediaDetailSeriesExtraCopyWith<$Res>? get seriesExtra { + if (_self.seriesExtra == null) { + return null; + } + + return $EmbyRawMediaDetailSeriesExtraCopyWith<$Res>(_self.seriesExtra!, (value) { + return _then(_self.copyWith(seriesExtra: value)); + }); +}/// Create a copy of MediaDetailRes +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$EmbyRawMediaDetailSeriesContextCopyWith<$Res>? get seriesContext { + if (_self.seriesContext == null) { + return null; + } + + return $EmbyRawMediaDetailSeriesContextCopyWith<$Res>(_self.seriesContext!, (value) { + return _then(_self.copyWith(seriesContext: value)); + }); +} +} + + +/// Adds pattern-matching-related methods to [MediaDetailRes]. +extension MediaDetailResPatterns on MediaDetailRes { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _MediaDetailRes value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _MediaDetailRes() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _MediaDetailRes value) $default,){ +final _that = this; +switch (_that) { +case _MediaDetailRes(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _MediaDetailRes value)? $default,){ +final _that = this; +switch (_that) { +case _MediaDetailRes() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( EmbyRawItem base, EmbyRawMediaDetailSeriesExtra? seriesExtra, EmbyRawMediaDetailSeriesContext? seriesContext, List? similarItems, List? boxSetItems)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _MediaDetailRes() when $default != null: +return $default(_that.base,_that.seriesExtra,_that.seriesContext,_that.similarItems,_that.boxSetItems);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( EmbyRawItem base, EmbyRawMediaDetailSeriesExtra? seriesExtra, EmbyRawMediaDetailSeriesContext? seriesContext, List? similarItems, List? boxSetItems) $default,) {final _that = this; +switch (_that) { +case _MediaDetailRes(): +return $default(_that.base,_that.seriesExtra,_that.seriesContext,_that.similarItems,_that.boxSetItems);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( EmbyRawItem base, EmbyRawMediaDetailSeriesExtra? seriesExtra, EmbyRawMediaDetailSeriesContext? seriesContext, List? similarItems, List? boxSetItems)? $default,) {final _that = this; +switch (_that) { +case _MediaDetailRes() when $default != null: +return $default(_that.base,_that.seriesExtra,_that.seriesContext,_that.similarItems,_that.boxSetItems);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _MediaDetailRes implements MediaDetailRes { + const _MediaDetailRes({required this.base, this.seriesExtra, this.seriesContext, final List? similarItems, final List? boxSetItems}): _similarItems = similarItems,_boxSetItems = boxSetItems; + + +@override final EmbyRawItem base; +@override final EmbyRawMediaDetailSeriesExtra? seriesExtra; +@override final EmbyRawMediaDetailSeriesContext? seriesContext; + final List? _similarItems; +@override List? get similarItems { + final value = _similarItems; + if (value == null) return null; + if (_similarItems is EqualUnmodifiableListView) return _similarItems; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(value); +} + + final List? _boxSetItems; +@override List? get boxSetItems { + final value = _boxSetItems; + if (value == null) return null; + if (_boxSetItems is EqualUnmodifiableListView) return _boxSetItems; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(value); +} + + +/// Create a copy of MediaDetailRes +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$MediaDetailResCopyWith<_MediaDetailRes> get copyWith => __$MediaDetailResCopyWithImpl<_MediaDetailRes>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _MediaDetailRes&&(identical(other.base, base) || other.base == base)&&(identical(other.seriesExtra, seriesExtra) || other.seriesExtra == seriesExtra)&&(identical(other.seriesContext, seriesContext) || other.seriesContext == seriesContext)&&const DeepCollectionEquality().equals(other._similarItems, _similarItems)&&const DeepCollectionEquality().equals(other._boxSetItems, _boxSetItems)); +} + + +@override +int get hashCode => Object.hash(runtimeType,base,seriesExtra,seriesContext,const DeepCollectionEquality().hash(_similarItems),const DeepCollectionEquality().hash(_boxSetItems)); + +@override +String toString() { + return 'MediaDetailRes(base: $base, seriesExtra: $seriesExtra, seriesContext: $seriesContext, similarItems: $similarItems, boxSetItems: $boxSetItems)'; +} + + +} + +/// @nodoc +abstract mixin class _$MediaDetailResCopyWith<$Res> implements $MediaDetailResCopyWith<$Res> { + factory _$MediaDetailResCopyWith(_MediaDetailRes value, $Res Function(_MediaDetailRes) _then) = __$MediaDetailResCopyWithImpl; +@override @useResult +$Res call({ + EmbyRawItem base, EmbyRawMediaDetailSeriesExtra? seriesExtra, EmbyRawMediaDetailSeriesContext? seriesContext, List? similarItems, List? boxSetItems +}); + + +@override $EmbyRawItemCopyWith<$Res> get base;@override $EmbyRawMediaDetailSeriesExtraCopyWith<$Res>? get seriesExtra;@override $EmbyRawMediaDetailSeriesContextCopyWith<$Res>? get seriesContext; + +} +/// @nodoc +class __$MediaDetailResCopyWithImpl<$Res> + implements _$MediaDetailResCopyWith<$Res> { + __$MediaDetailResCopyWithImpl(this._self, this._then); + + final _MediaDetailRes _self; + final $Res Function(_MediaDetailRes) _then; + +/// Create a copy of MediaDetailRes +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? base = null,Object? seriesExtra = freezed,Object? seriesContext = freezed,Object? similarItems = freezed,Object? boxSetItems = freezed,}) { + return _then(_MediaDetailRes( +base: null == base ? _self.base : base // ignore: cast_nullable_to_non_nullable +as EmbyRawItem,seriesExtra: freezed == seriesExtra ? _self.seriesExtra : seriesExtra // ignore: cast_nullable_to_non_nullable +as EmbyRawMediaDetailSeriesExtra?,seriesContext: freezed == seriesContext ? _self.seriesContext : seriesContext // ignore: cast_nullable_to_non_nullable +as EmbyRawMediaDetailSeriesContext?,similarItems: freezed == similarItems ? _self._similarItems : similarItems // ignore: cast_nullable_to_non_nullable +as List?,boxSetItems: freezed == boxSetItems ? _self._boxSetItems : boxSetItems // ignore: cast_nullable_to_non_nullable +as List?, + )); +} + +/// Create a copy of MediaDetailRes +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$EmbyRawItemCopyWith<$Res> get base { + + return $EmbyRawItemCopyWith<$Res>(_self.base, (value) { + return _then(_self.copyWith(base: value)); + }); +}/// Create a copy of MediaDetailRes +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$EmbyRawMediaDetailSeriesExtraCopyWith<$Res>? get seriesExtra { + if (_self.seriesExtra == null) { + return null; + } + + return $EmbyRawMediaDetailSeriesExtraCopyWith<$Res>(_self.seriesExtra!, (value) { + return _then(_self.copyWith(seriesExtra: value)); + }); +}/// Create a copy of MediaDetailRes +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$EmbyRawMediaDetailSeriesContextCopyWith<$Res>? get seriesContext { + if (_self.seriesContext == null) { + return null; + } + + return $EmbyRawMediaDetailSeriesContextCopyWith<$Res>(_self.seriesContext!, (value) { + return _then(_self.copyWith(seriesContext: value)); + }); +} +} + +/// @nodoc +mixin _$FavoriteSetReq { + + String get itemId; bool get isFavorite; +/// Create a copy of FavoriteSetReq +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$FavoriteSetReqCopyWith get copyWith => _$FavoriteSetReqCopyWithImpl(this as FavoriteSetReq, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is FavoriteSetReq&&(identical(other.itemId, itemId) || other.itemId == itemId)&&(identical(other.isFavorite, isFavorite) || other.isFavorite == isFavorite)); +} + + +@override +int get hashCode => Object.hash(runtimeType,itemId,isFavorite); + +@override +String toString() { + return 'FavoriteSetReq(itemId: $itemId, isFavorite: $isFavorite)'; +} + + +} + +/// @nodoc +abstract mixin class $FavoriteSetReqCopyWith<$Res> { + factory $FavoriteSetReqCopyWith(FavoriteSetReq value, $Res Function(FavoriteSetReq) _then) = _$FavoriteSetReqCopyWithImpl; +@useResult +$Res call({ + String itemId, bool isFavorite +}); + + + + +} +/// @nodoc +class _$FavoriteSetReqCopyWithImpl<$Res> + implements $FavoriteSetReqCopyWith<$Res> { + _$FavoriteSetReqCopyWithImpl(this._self, this._then); + + final FavoriteSetReq _self; + final $Res Function(FavoriteSetReq) _then; + +/// Create a copy of FavoriteSetReq +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? itemId = null,Object? isFavorite = null,}) { + return _then(_self.copyWith( +itemId: null == itemId ? _self.itemId : itemId // ignore: cast_nullable_to_non_nullable +as String,isFavorite: null == isFavorite ? _self.isFavorite : isFavorite // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + +} + + +/// Adds pattern-matching-related methods to [FavoriteSetReq]. +extension FavoriteSetReqPatterns on FavoriteSetReq { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _FavoriteSetReq value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _FavoriteSetReq() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _FavoriteSetReq value) $default,){ +final _that = this; +switch (_that) { +case _FavoriteSetReq(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _FavoriteSetReq value)? $default,){ +final _that = this; +switch (_that) { +case _FavoriteSetReq() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String itemId, bool isFavorite)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _FavoriteSetReq() when $default != null: +return $default(_that.itemId,_that.isFavorite);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String itemId, bool isFavorite) $default,) {final _that = this; +switch (_that) { +case _FavoriteSetReq(): +return $default(_that.itemId,_that.isFavorite);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String itemId, bool isFavorite)? $default,) {final _that = this; +switch (_that) { +case _FavoriteSetReq() when $default != null: +return $default(_that.itemId,_that.isFavorite);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _FavoriteSetReq extends FavoriteSetReq { + const _FavoriteSetReq({required this.itemId, required this.isFavorite}): super._(); + + +@override final String itemId; +@override final bool isFavorite; + +/// Create a copy of FavoriteSetReq +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$FavoriteSetReqCopyWith<_FavoriteSetReq> get copyWith => __$FavoriteSetReqCopyWithImpl<_FavoriteSetReq>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _FavoriteSetReq&&(identical(other.itemId, itemId) || other.itemId == itemId)&&(identical(other.isFavorite, isFavorite) || other.isFavorite == isFavorite)); +} + + +@override +int get hashCode => Object.hash(runtimeType,itemId,isFavorite); + +@override +String toString() { + return 'FavoriteSetReq(itemId: $itemId, isFavorite: $isFavorite)'; +} + + +} + +/// @nodoc +abstract mixin class _$FavoriteSetReqCopyWith<$Res> implements $FavoriteSetReqCopyWith<$Res> { + factory _$FavoriteSetReqCopyWith(_FavoriteSetReq value, $Res Function(_FavoriteSetReq) _then) = __$FavoriteSetReqCopyWithImpl; +@override @useResult +$Res call({ + String itemId, bool isFavorite +}); + + + + +} +/// @nodoc +class __$FavoriteSetReqCopyWithImpl<$Res> + implements _$FavoriteSetReqCopyWith<$Res> { + __$FavoriteSetReqCopyWithImpl(this._self, this._then); + + final _FavoriteSetReq _self; + final $Res Function(_FavoriteSetReq) _then; + +/// Create a copy of FavoriteSetReq +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? itemId = null,Object? isFavorite = null,}) { + return _then(_FavoriteSetReq( +itemId: null == itemId ? _self.itemId : itemId // ignore: cast_nullable_to_non_nullable +as String,isFavorite: null == isFavorite ? _self.isFavorite : isFavorite // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + + +} + + +/// @nodoc +mixin _$FavoriteSetRes { + + String get itemId; bool get isFavorite; +/// Create a copy of FavoriteSetRes +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$FavoriteSetResCopyWith get copyWith => _$FavoriteSetResCopyWithImpl(this as FavoriteSetRes, _$identity); + + /// Serializes this FavoriteSetRes to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is FavoriteSetRes&&(identical(other.itemId, itemId) || other.itemId == itemId)&&(identical(other.isFavorite, isFavorite) || other.isFavorite == isFavorite)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,itemId,isFavorite); + +@override +String toString() { + return 'FavoriteSetRes(itemId: $itemId, isFavorite: $isFavorite)'; +} + + +} + +/// @nodoc +abstract mixin class $FavoriteSetResCopyWith<$Res> { + factory $FavoriteSetResCopyWith(FavoriteSetRes value, $Res Function(FavoriteSetRes) _then) = _$FavoriteSetResCopyWithImpl; +@useResult +$Res call({ + String itemId, bool isFavorite +}); + + + + +} +/// @nodoc +class _$FavoriteSetResCopyWithImpl<$Res> + implements $FavoriteSetResCopyWith<$Res> { + _$FavoriteSetResCopyWithImpl(this._self, this._then); + + final FavoriteSetRes _self; + final $Res Function(FavoriteSetRes) _then; + +/// Create a copy of FavoriteSetRes +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? itemId = null,Object? isFavorite = null,}) { + return _then(_self.copyWith( +itemId: null == itemId ? _self.itemId : itemId // ignore: cast_nullable_to_non_nullable +as String,isFavorite: null == isFavorite ? _self.isFavorite : isFavorite // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + +} + + +/// Adds pattern-matching-related methods to [FavoriteSetRes]. +extension FavoriteSetResPatterns on FavoriteSetRes { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _FavoriteSetRes value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _FavoriteSetRes() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _FavoriteSetRes value) $default,){ +final _that = this; +switch (_that) { +case _FavoriteSetRes(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _FavoriteSetRes value)? $default,){ +final _that = this; +switch (_that) { +case _FavoriteSetRes() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String itemId, bool isFavorite)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _FavoriteSetRes() when $default != null: +return $default(_that.itemId,_that.isFavorite);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String itemId, bool isFavorite) $default,) {final _that = this; +switch (_that) { +case _FavoriteSetRes(): +return $default(_that.itemId,_that.isFavorite);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String itemId, bool isFavorite)? $default,) {final _that = this; +switch (_that) { +case _FavoriteSetRes() when $default != null: +return $default(_that.itemId,_that.isFavorite);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _FavoriteSetRes implements FavoriteSetRes { + const _FavoriteSetRes({required this.itemId, required this.isFavorite}); + factory _FavoriteSetRes.fromJson(Map json) => _$FavoriteSetResFromJson(json); + +@override final String itemId; +@override final bool isFavorite; + +/// Create a copy of FavoriteSetRes +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$FavoriteSetResCopyWith<_FavoriteSetRes> get copyWith => __$FavoriteSetResCopyWithImpl<_FavoriteSetRes>(this, _$identity); + +@override +Map toJson() { + return _$FavoriteSetResToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _FavoriteSetRes&&(identical(other.itemId, itemId) || other.itemId == itemId)&&(identical(other.isFavorite, isFavorite) || other.isFavorite == isFavorite)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,itemId,isFavorite); + +@override +String toString() { + return 'FavoriteSetRes(itemId: $itemId, isFavorite: $isFavorite)'; +} + + +} + +/// @nodoc +abstract mixin class _$FavoriteSetResCopyWith<$Res> implements $FavoriteSetResCopyWith<$Res> { + factory _$FavoriteSetResCopyWith(_FavoriteSetRes value, $Res Function(_FavoriteSetRes) _then) = __$FavoriteSetResCopyWithImpl; +@override @useResult +$Res call({ + String itemId, bool isFavorite +}); + + + + +} +/// @nodoc +class __$FavoriteSetResCopyWithImpl<$Res> + implements _$FavoriteSetResCopyWith<$Res> { + __$FavoriteSetResCopyWithImpl(this._self, this._then); + + final _FavoriteSetRes _self; + final $Res Function(_FavoriteSetRes) _then; + +/// Create a copy of FavoriteSetRes +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? itemId = null,Object? isFavorite = null,}) { + return _then(_FavoriteSetRes( +itemId: null == itemId ? _self.itemId : itemId // ignore: cast_nullable_to_non_nullable +as String,isFavorite: null == isFavorite ? _self.isFavorite : isFavorite // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + + +} + +/// @nodoc +mixin _$PlayedSetReq { + + String get itemId; bool get played; +/// Create a copy of PlayedSetReq +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$PlayedSetReqCopyWith get copyWith => _$PlayedSetReqCopyWithImpl(this as PlayedSetReq, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is PlayedSetReq&&(identical(other.itemId, itemId) || other.itemId == itemId)&&(identical(other.played, played) || other.played == played)); +} + + +@override +int get hashCode => Object.hash(runtimeType,itemId,played); + +@override +String toString() { + return 'PlayedSetReq(itemId: $itemId, played: $played)'; +} + + +} + +/// @nodoc +abstract mixin class $PlayedSetReqCopyWith<$Res> { + factory $PlayedSetReqCopyWith(PlayedSetReq value, $Res Function(PlayedSetReq) _then) = _$PlayedSetReqCopyWithImpl; +@useResult +$Res call({ + String itemId, bool played +}); + + + + +} +/// @nodoc +class _$PlayedSetReqCopyWithImpl<$Res> + implements $PlayedSetReqCopyWith<$Res> { + _$PlayedSetReqCopyWithImpl(this._self, this._then); + + final PlayedSetReq _self; + final $Res Function(PlayedSetReq) _then; + +/// Create a copy of PlayedSetReq +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? itemId = null,Object? played = null,}) { + return _then(_self.copyWith( +itemId: null == itemId ? _self.itemId : itemId // ignore: cast_nullable_to_non_nullable +as String,played: null == played ? _self.played : played // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + +} + + +/// Adds pattern-matching-related methods to [PlayedSetReq]. +extension PlayedSetReqPatterns on PlayedSetReq { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _PlayedSetReq value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _PlayedSetReq() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _PlayedSetReq value) $default,){ +final _that = this; +switch (_that) { +case _PlayedSetReq(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _PlayedSetReq value)? $default,){ +final _that = this; +switch (_that) { +case _PlayedSetReq() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String itemId, bool played)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _PlayedSetReq() when $default != null: +return $default(_that.itemId,_that.played);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String itemId, bool played) $default,) {final _that = this; +switch (_that) { +case _PlayedSetReq(): +return $default(_that.itemId,_that.played);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String itemId, bool played)? $default,) {final _that = this; +switch (_that) { +case _PlayedSetReq() when $default != null: +return $default(_that.itemId,_that.played);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _PlayedSetReq extends PlayedSetReq { + const _PlayedSetReq({required this.itemId, required this.played}): super._(); + + +@override final String itemId; +@override final bool played; + +/// Create a copy of PlayedSetReq +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$PlayedSetReqCopyWith<_PlayedSetReq> get copyWith => __$PlayedSetReqCopyWithImpl<_PlayedSetReq>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _PlayedSetReq&&(identical(other.itemId, itemId) || other.itemId == itemId)&&(identical(other.played, played) || other.played == played)); +} + + +@override +int get hashCode => Object.hash(runtimeType,itemId,played); + +@override +String toString() { + return 'PlayedSetReq(itemId: $itemId, played: $played)'; +} + + +} + +/// @nodoc +abstract mixin class _$PlayedSetReqCopyWith<$Res> implements $PlayedSetReqCopyWith<$Res> { + factory _$PlayedSetReqCopyWith(_PlayedSetReq value, $Res Function(_PlayedSetReq) _then) = __$PlayedSetReqCopyWithImpl; +@override @useResult +$Res call({ + String itemId, bool played +}); + + + + +} +/// @nodoc +class __$PlayedSetReqCopyWithImpl<$Res> + implements _$PlayedSetReqCopyWith<$Res> { + __$PlayedSetReqCopyWithImpl(this._self, this._then); + + final _PlayedSetReq _self; + final $Res Function(_PlayedSetReq) _then; + +/// Create a copy of PlayedSetReq +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? itemId = null,Object? played = null,}) { + return _then(_PlayedSetReq( +itemId: null == itemId ? _self.itemId : itemId // ignore: cast_nullable_to_non_nullable +as String,played: null == played ? _self.played : played // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + + +} + + +/// @nodoc +mixin _$PlayedSetRes { + + String get itemId; bool get played; +/// Create a copy of PlayedSetRes +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$PlayedSetResCopyWith get copyWith => _$PlayedSetResCopyWithImpl(this as PlayedSetRes, _$identity); + + /// Serializes this PlayedSetRes to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is PlayedSetRes&&(identical(other.itemId, itemId) || other.itemId == itemId)&&(identical(other.played, played) || other.played == played)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,itemId,played); + +@override +String toString() { + return 'PlayedSetRes(itemId: $itemId, played: $played)'; +} + + +} + +/// @nodoc +abstract mixin class $PlayedSetResCopyWith<$Res> { + factory $PlayedSetResCopyWith(PlayedSetRes value, $Res Function(PlayedSetRes) _then) = _$PlayedSetResCopyWithImpl; +@useResult +$Res call({ + String itemId, bool played +}); + + + + +} +/// @nodoc +class _$PlayedSetResCopyWithImpl<$Res> + implements $PlayedSetResCopyWith<$Res> { + _$PlayedSetResCopyWithImpl(this._self, this._then); + + final PlayedSetRes _self; + final $Res Function(PlayedSetRes) _then; + +/// Create a copy of PlayedSetRes +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? itemId = null,Object? played = null,}) { + return _then(_self.copyWith( +itemId: null == itemId ? _self.itemId : itemId // ignore: cast_nullable_to_non_nullable +as String,played: null == played ? _self.played : played // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + +} + + +/// Adds pattern-matching-related methods to [PlayedSetRes]. +extension PlayedSetResPatterns on PlayedSetRes { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _PlayedSetRes value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _PlayedSetRes() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _PlayedSetRes value) $default,){ +final _that = this; +switch (_that) { +case _PlayedSetRes(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _PlayedSetRes value)? $default,){ +final _that = this; +switch (_that) { +case _PlayedSetRes() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String itemId, bool played)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _PlayedSetRes() when $default != null: +return $default(_that.itemId,_that.played);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String itemId, bool played) $default,) {final _that = this; +switch (_that) { +case _PlayedSetRes(): +return $default(_that.itemId,_that.played);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String itemId, bool played)? $default,) {final _that = this; +switch (_that) { +case _PlayedSetRes() when $default != null: +return $default(_that.itemId,_that.played);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _PlayedSetRes implements PlayedSetRes { + const _PlayedSetRes({required this.itemId, required this.played}); + factory _PlayedSetRes.fromJson(Map json) => _$PlayedSetResFromJson(json); + +@override final String itemId; +@override final bool played; + +/// Create a copy of PlayedSetRes +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$PlayedSetResCopyWith<_PlayedSetRes> get copyWith => __$PlayedSetResCopyWithImpl<_PlayedSetRes>(this, _$identity); + +@override +Map toJson() { + return _$PlayedSetResToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _PlayedSetRes&&(identical(other.itemId, itemId) || other.itemId == itemId)&&(identical(other.played, played) || other.played == played)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,itemId,played); + +@override +String toString() { + return 'PlayedSetRes(itemId: $itemId, played: $played)'; +} + + +} + +/// @nodoc +abstract mixin class _$PlayedSetResCopyWith<$Res> implements $PlayedSetResCopyWith<$Res> { + factory _$PlayedSetResCopyWith(_PlayedSetRes value, $Res Function(_PlayedSetRes) _then) = __$PlayedSetResCopyWithImpl; +@override @useResult +$Res call({ + String itemId, bool played +}); + + + + +} +/// @nodoc +class __$PlayedSetResCopyWithImpl<$Res> + implements _$PlayedSetResCopyWith<$Res> { + __$PlayedSetResCopyWithImpl(this._self, this._then); + + final _PlayedSetRes _self; + final $Res Function(_PlayedSetRes) _then; + +/// Create a copy of PlayedSetRes +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? itemId = null,Object? played = null,}) { + return _then(_PlayedSetRes( +itemId: null == itemId ? _self.itemId : itemId // ignore: cast_nullable_to_non_nullable +as String,played: null == played ? _self.played : played // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + + +} + +/// @nodoc +mixin _$HideFromResumeReq { + + String get itemId; bool get hide; +/// Create a copy of HideFromResumeReq +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$HideFromResumeReqCopyWith get copyWith => _$HideFromResumeReqCopyWithImpl(this as HideFromResumeReq, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is HideFromResumeReq&&(identical(other.itemId, itemId) || other.itemId == itemId)&&(identical(other.hide, hide) || other.hide == hide)); +} + + +@override +int get hashCode => Object.hash(runtimeType,itemId,hide); + +@override +String toString() { + return 'HideFromResumeReq(itemId: $itemId, hide: $hide)'; +} + + +} + +/// @nodoc +abstract mixin class $HideFromResumeReqCopyWith<$Res> { + factory $HideFromResumeReqCopyWith(HideFromResumeReq value, $Res Function(HideFromResumeReq) _then) = _$HideFromResumeReqCopyWithImpl; +@useResult +$Res call({ + String itemId, bool hide +}); + + + + +} +/// @nodoc +class _$HideFromResumeReqCopyWithImpl<$Res> + implements $HideFromResumeReqCopyWith<$Res> { + _$HideFromResumeReqCopyWithImpl(this._self, this._then); + + final HideFromResumeReq _self; + final $Res Function(HideFromResumeReq) _then; + +/// Create a copy of HideFromResumeReq +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? itemId = null,Object? hide = null,}) { + return _then(_self.copyWith( +itemId: null == itemId ? _self.itemId : itemId // ignore: cast_nullable_to_non_nullable +as String,hide: null == hide ? _self.hide : hide // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + +} + + +/// Adds pattern-matching-related methods to [HideFromResumeReq]. +extension HideFromResumeReqPatterns on HideFromResumeReq { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _HideFromResumeReq value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _HideFromResumeReq() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _HideFromResumeReq value) $default,){ +final _that = this; +switch (_that) { +case _HideFromResumeReq(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _HideFromResumeReq value)? $default,){ +final _that = this; +switch (_that) { +case _HideFromResumeReq() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String itemId, bool hide)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _HideFromResumeReq() when $default != null: +return $default(_that.itemId,_that.hide);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String itemId, bool hide) $default,) {final _that = this; +switch (_that) { +case _HideFromResumeReq(): +return $default(_that.itemId,_that.hide);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String itemId, bool hide)? $default,) {final _that = this; +switch (_that) { +case _HideFromResumeReq() when $default != null: +return $default(_that.itemId,_that.hide);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _HideFromResumeReq extends HideFromResumeReq { + const _HideFromResumeReq({required this.itemId, required this.hide}): super._(); + + +@override final String itemId; +@override final bool hide; + +/// Create a copy of HideFromResumeReq +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$HideFromResumeReqCopyWith<_HideFromResumeReq> get copyWith => __$HideFromResumeReqCopyWithImpl<_HideFromResumeReq>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _HideFromResumeReq&&(identical(other.itemId, itemId) || other.itemId == itemId)&&(identical(other.hide, hide) || other.hide == hide)); +} + + +@override +int get hashCode => Object.hash(runtimeType,itemId,hide); + +@override +String toString() { + return 'HideFromResumeReq(itemId: $itemId, hide: $hide)'; +} + + +} + +/// @nodoc +abstract mixin class _$HideFromResumeReqCopyWith<$Res> implements $HideFromResumeReqCopyWith<$Res> { + factory _$HideFromResumeReqCopyWith(_HideFromResumeReq value, $Res Function(_HideFromResumeReq) _then) = __$HideFromResumeReqCopyWithImpl; +@override @useResult +$Res call({ + String itemId, bool hide +}); + + + + +} +/// @nodoc +class __$HideFromResumeReqCopyWithImpl<$Res> + implements _$HideFromResumeReqCopyWith<$Res> { + __$HideFromResumeReqCopyWithImpl(this._self, this._then); + + final _HideFromResumeReq _self; + final $Res Function(_HideFromResumeReq) _then; + +/// Create a copy of HideFromResumeReq +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? itemId = null,Object? hide = null,}) { + return _then(_HideFromResumeReq( +itemId: null == itemId ? _self.itemId : itemId // ignore: cast_nullable_to_non_nullable +as String,hide: null == hide ? _self.hide : hide // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + + +} + + +/// @nodoc +mixin _$HideFromResumeRes { + + String get itemId; bool get hide; +/// Create a copy of HideFromResumeRes +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$HideFromResumeResCopyWith get copyWith => _$HideFromResumeResCopyWithImpl(this as HideFromResumeRes, _$identity); + + /// Serializes this HideFromResumeRes to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is HideFromResumeRes&&(identical(other.itemId, itemId) || other.itemId == itemId)&&(identical(other.hide, hide) || other.hide == hide)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,itemId,hide); + +@override +String toString() { + return 'HideFromResumeRes(itemId: $itemId, hide: $hide)'; +} + + +} + +/// @nodoc +abstract mixin class $HideFromResumeResCopyWith<$Res> { + factory $HideFromResumeResCopyWith(HideFromResumeRes value, $Res Function(HideFromResumeRes) _then) = _$HideFromResumeResCopyWithImpl; +@useResult +$Res call({ + String itemId, bool hide +}); + + + + +} +/// @nodoc +class _$HideFromResumeResCopyWithImpl<$Res> + implements $HideFromResumeResCopyWith<$Res> { + _$HideFromResumeResCopyWithImpl(this._self, this._then); + + final HideFromResumeRes _self; + final $Res Function(HideFromResumeRes) _then; + +/// Create a copy of HideFromResumeRes +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? itemId = null,Object? hide = null,}) { + return _then(_self.copyWith( +itemId: null == itemId ? _self.itemId : itemId // ignore: cast_nullable_to_non_nullable +as String,hide: null == hide ? _self.hide : hide // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + +} + + +/// Adds pattern-matching-related methods to [HideFromResumeRes]. +extension HideFromResumeResPatterns on HideFromResumeRes { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _HideFromResumeRes value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _HideFromResumeRes() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _HideFromResumeRes value) $default,){ +final _that = this; +switch (_that) { +case _HideFromResumeRes(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _HideFromResumeRes value)? $default,){ +final _that = this; +switch (_that) { +case _HideFromResumeRes() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String itemId, bool hide)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _HideFromResumeRes() when $default != null: +return $default(_that.itemId,_that.hide);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String itemId, bool hide) $default,) {final _that = this; +switch (_that) { +case _HideFromResumeRes(): +return $default(_that.itemId,_that.hide);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String itemId, bool hide)? $default,) {final _that = this; +switch (_that) { +case _HideFromResumeRes() when $default != null: +return $default(_that.itemId,_that.hide);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _HideFromResumeRes implements HideFromResumeRes { + const _HideFromResumeRes({required this.itemId, required this.hide}); + factory _HideFromResumeRes.fromJson(Map json) => _$HideFromResumeResFromJson(json); + +@override final String itemId; +@override final bool hide; + +/// Create a copy of HideFromResumeRes +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$HideFromResumeResCopyWith<_HideFromResumeRes> get copyWith => __$HideFromResumeResCopyWithImpl<_HideFromResumeRes>(this, _$identity); + +@override +Map toJson() { + return _$HideFromResumeResToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _HideFromResumeRes&&(identical(other.itemId, itemId) || other.itemId == itemId)&&(identical(other.hide, hide) || other.hide == hide)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,itemId,hide); + +@override +String toString() { + return 'HideFromResumeRes(itemId: $itemId, hide: $hide)'; +} + + +} + +/// @nodoc +abstract mixin class _$HideFromResumeResCopyWith<$Res> implements $HideFromResumeResCopyWith<$Res> { + factory _$HideFromResumeResCopyWith(_HideFromResumeRes value, $Res Function(_HideFromResumeRes) _then) = __$HideFromResumeResCopyWithImpl; +@override @useResult +$Res call({ + String itemId, bool hide +}); + + + + +} +/// @nodoc +class __$HideFromResumeResCopyWithImpl<$Res> + implements _$HideFromResumeResCopyWith<$Res> { + __$HideFromResumeResCopyWithImpl(this._self, this._then); + + final _HideFromResumeRes _self; + final $Res Function(_HideFromResumeRes) _then; + +/// Create a copy of HideFromResumeRes +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? itemId = null,Object? hide = null,}) { + return _then(_HideFromResumeRes( +itemId: null == itemId ? _self.itemId : itemId // ignore: cast_nullable_to_non_nullable +as String,hide: null == hide ? _self.hide : hide // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + + +} + +/// @nodoc +mixin _$MediaEpisodesReq { + + String get seriesId; String get seasonId; +/// Create a copy of MediaEpisodesReq +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$MediaEpisodesReqCopyWith get copyWith => _$MediaEpisodesReqCopyWithImpl(this as MediaEpisodesReq, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is MediaEpisodesReq&&(identical(other.seriesId, seriesId) || other.seriesId == seriesId)&&(identical(other.seasonId, seasonId) || other.seasonId == seasonId)); +} + + +@override +int get hashCode => Object.hash(runtimeType,seriesId,seasonId); + +@override +String toString() { + return 'MediaEpisodesReq(seriesId: $seriesId, seasonId: $seasonId)'; +} + + +} + +/// @nodoc +abstract mixin class $MediaEpisodesReqCopyWith<$Res> { + factory $MediaEpisodesReqCopyWith(MediaEpisodesReq value, $Res Function(MediaEpisodesReq) _then) = _$MediaEpisodesReqCopyWithImpl; +@useResult +$Res call({ + String seriesId, String seasonId +}); + + + + +} +/// @nodoc +class _$MediaEpisodesReqCopyWithImpl<$Res> + implements $MediaEpisodesReqCopyWith<$Res> { + _$MediaEpisodesReqCopyWithImpl(this._self, this._then); + + final MediaEpisodesReq _self; + final $Res Function(MediaEpisodesReq) _then; + +/// Create a copy of MediaEpisodesReq +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? seriesId = null,Object? seasonId = null,}) { + return _then(_self.copyWith( +seriesId: null == seriesId ? _self.seriesId : seriesId // ignore: cast_nullable_to_non_nullable +as String,seasonId: null == seasonId ? _self.seasonId : seasonId // ignore: cast_nullable_to_non_nullable +as String, + )); +} + +} + + +/// Adds pattern-matching-related methods to [MediaEpisodesReq]. +extension MediaEpisodesReqPatterns on MediaEpisodesReq { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _MediaEpisodesReq value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _MediaEpisodesReq() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _MediaEpisodesReq value) $default,){ +final _that = this; +switch (_that) { +case _MediaEpisodesReq(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _MediaEpisodesReq value)? $default,){ +final _that = this; +switch (_that) { +case _MediaEpisodesReq() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String seriesId, String seasonId)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _MediaEpisodesReq() when $default != null: +return $default(_that.seriesId,_that.seasonId);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String seriesId, String seasonId) $default,) {final _that = this; +switch (_that) { +case _MediaEpisodesReq(): +return $default(_that.seriesId,_that.seasonId);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String seriesId, String seasonId)? $default,) {final _that = this; +switch (_that) { +case _MediaEpisodesReq() when $default != null: +return $default(_that.seriesId,_that.seasonId);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _MediaEpisodesReq extends MediaEpisodesReq { + const _MediaEpisodesReq({required this.seriesId, required this.seasonId}): super._(); + + +@override final String seriesId; +@override final String seasonId; + +/// Create a copy of MediaEpisodesReq +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$MediaEpisodesReqCopyWith<_MediaEpisodesReq> get copyWith => __$MediaEpisodesReqCopyWithImpl<_MediaEpisodesReq>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _MediaEpisodesReq&&(identical(other.seriesId, seriesId) || other.seriesId == seriesId)&&(identical(other.seasonId, seasonId) || other.seasonId == seasonId)); +} + + +@override +int get hashCode => Object.hash(runtimeType,seriesId,seasonId); + +@override +String toString() { + return 'MediaEpisodesReq(seriesId: $seriesId, seasonId: $seasonId)'; +} + + +} + +/// @nodoc +abstract mixin class _$MediaEpisodesReqCopyWith<$Res> implements $MediaEpisodesReqCopyWith<$Res> { + factory _$MediaEpisodesReqCopyWith(_MediaEpisodesReq value, $Res Function(_MediaEpisodesReq) _then) = __$MediaEpisodesReqCopyWithImpl; +@override @useResult +$Res call({ + String seriesId, String seasonId +}); + + + + +} +/// @nodoc +class __$MediaEpisodesReqCopyWithImpl<$Res> + implements _$MediaEpisodesReqCopyWith<$Res> { + __$MediaEpisodesReqCopyWithImpl(this._self, this._then); + + final _MediaEpisodesReq _self; + final $Res Function(_MediaEpisodesReq) _then; + +/// Create a copy of MediaEpisodesReq +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? seriesId = null,Object? seasonId = null,}) { + return _then(_MediaEpisodesReq( +seriesId: null == seriesId ? _self.seriesId : seriesId // ignore: cast_nullable_to_non_nullable +as String,seasonId: null == seasonId ? _self.seasonId : seasonId // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + +/// @nodoc +mixin _$MediaEpisodesRes { + + String get seriesId; String get seasonId; List get items; +/// Create a copy of MediaEpisodesRes +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$MediaEpisodesResCopyWith get copyWith => _$MediaEpisodesResCopyWithImpl(this as MediaEpisodesRes, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is MediaEpisodesRes&&(identical(other.seriesId, seriesId) || other.seriesId == seriesId)&&(identical(other.seasonId, seasonId) || other.seasonId == seasonId)&&const DeepCollectionEquality().equals(other.items, items)); +} + + +@override +int get hashCode => Object.hash(runtimeType,seriesId,seasonId,const DeepCollectionEquality().hash(items)); + +@override +String toString() { + return 'MediaEpisodesRes(seriesId: $seriesId, seasonId: $seasonId, items: $items)'; +} + + +} + +/// @nodoc +abstract mixin class $MediaEpisodesResCopyWith<$Res> { + factory $MediaEpisodesResCopyWith(MediaEpisodesRes value, $Res Function(MediaEpisodesRes) _then) = _$MediaEpisodesResCopyWithImpl; +@useResult +$Res call({ + String seriesId, String seasonId, List items +}); + + + + +} +/// @nodoc +class _$MediaEpisodesResCopyWithImpl<$Res> + implements $MediaEpisodesResCopyWith<$Res> { + _$MediaEpisodesResCopyWithImpl(this._self, this._then); + + final MediaEpisodesRes _self; + final $Res Function(MediaEpisodesRes) _then; + +/// Create a copy of MediaEpisodesRes +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? seriesId = null,Object? seasonId = null,Object? items = null,}) { + return _then(_self.copyWith( +seriesId: null == seriesId ? _self.seriesId : seriesId // ignore: cast_nullable_to_non_nullable +as String,seasonId: null == seasonId ? _self.seasonId : seasonId // ignore: cast_nullable_to_non_nullable +as String,items: null == items ? _self.items : items // ignore: cast_nullable_to_non_nullable +as List, + )); +} + +} + + +/// Adds pattern-matching-related methods to [MediaEpisodesRes]. +extension MediaEpisodesResPatterns on MediaEpisodesRes { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _MediaEpisodesRes value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _MediaEpisodesRes() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _MediaEpisodesRes value) $default,){ +final _that = this; +switch (_that) { +case _MediaEpisodesRes(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _MediaEpisodesRes value)? $default,){ +final _that = this; +switch (_that) { +case _MediaEpisodesRes() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String seriesId, String seasonId, List items)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _MediaEpisodesRes() when $default != null: +return $default(_that.seriesId,_that.seasonId,_that.items);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String seriesId, String seasonId, List items) $default,) {final _that = this; +switch (_that) { +case _MediaEpisodesRes(): +return $default(_that.seriesId,_that.seasonId,_that.items);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String seriesId, String seasonId, List items)? $default,) {final _that = this; +switch (_that) { +case _MediaEpisodesRes() when $default != null: +return $default(_that.seriesId,_that.seasonId,_that.items);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _MediaEpisodesRes implements MediaEpisodesRes { + const _MediaEpisodesRes({required this.seriesId, required this.seasonId, required final List items}): _items = items; + + +@override final String seriesId; +@override final String seasonId; + final List _items; +@override List get items { + if (_items is EqualUnmodifiableListView) return _items; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_items); +} + + +/// Create a copy of MediaEpisodesRes +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$MediaEpisodesResCopyWith<_MediaEpisodesRes> get copyWith => __$MediaEpisodesResCopyWithImpl<_MediaEpisodesRes>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _MediaEpisodesRes&&(identical(other.seriesId, seriesId) || other.seriesId == seriesId)&&(identical(other.seasonId, seasonId) || other.seasonId == seasonId)&&const DeepCollectionEquality().equals(other._items, _items)); +} + + +@override +int get hashCode => Object.hash(runtimeType,seriesId,seasonId,const DeepCollectionEquality().hash(_items)); + +@override +String toString() { + return 'MediaEpisodesRes(seriesId: $seriesId, seasonId: $seasonId, items: $items)'; +} + + +} + +/// @nodoc +abstract mixin class _$MediaEpisodesResCopyWith<$Res> implements $MediaEpisodesResCopyWith<$Res> { + factory _$MediaEpisodesResCopyWith(_MediaEpisodesRes value, $Res Function(_MediaEpisodesRes) _then) = __$MediaEpisodesResCopyWithImpl; +@override @useResult +$Res call({ + String seriesId, String seasonId, List items +}); + + + + +} +/// @nodoc +class __$MediaEpisodesResCopyWithImpl<$Res> + implements _$MediaEpisodesResCopyWith<$Res> { + __$MediaEpisodesResCopyWithImpl(this._self, this._then); + + final _MediaEpisodesRes _self; + final $Res Function(_MediaEpisodesRes) _then; + +/// Create a copy of MediaEpisodesRes +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? seriesId = null,Object? seasonId = null,Object? items = null,}) { + return _then(_MediaEpisodesRes( +seriesId: null == seriesId ? _self.seriesId : seriesId // ignore: cast_nullable_to_non_nullable +as String,seasonId: null == seasonId ? _self.seasonId : seasonId // ignore: cast_nullable_to_non_nullable +as String,items: null == items ? _self._items : items // ignore: cast_nullable_to_non_nullable +as List, + )); +} + + +} + +/// @nodoc +mixin _$SeriesSeasonsReq { + + String get seriesId; +/// Create a copy of SeriesSeasonsReq +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$SeriesSeasonsReqCopyWith get copyWith => _$SeriesSeasonsReqCopyWithImpl(this as SeriesSeasonsReq, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is SeriesSeasonsReq&&(identical(other.seriesId, seriesId) || other.seriesId == seriesId)); +} + + +@override +int get hashCode => Object.hash(runtimeType,seriesId); + +@override +String toString() { + return 'SeriesSeasonsReq(seriesId: $seriesId)'; +} + + +} + +/// @nodoc +abstract mixin class $SeriesSeasonsReqCopyWith<$Res> { + factory $SeriesSeasonsReqCopyWith(SeriesSeasonsReq value, $Res Function(SeriesSeasonsReq) _then) = _$SeriesSeasonsReqCopyWithImpl; +@useResult +$Res call({ + String seriesId +}); + + + + +} +/// @nodoc +class _$SeriesSeasonsReqCopyWithImpl<$Res> + implements $SeriesSeasonsReqCopyWith<$Res> { + _$SeriesSeasonsReqCopyWithImpl(this._self, this._then); + + final SeriesSeasonsReq _self; + final $Res Function(SeriesSeasonsReq) _then; + +/// Create a copy of SeriesSeasonsReq +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? seriesId = null,}) { + return _then(_self.copyWith( +seriesId: null == seriesId ? _self.seriesId : seriesId // ignore: cast_nullable_to_non_nullable +as String, + )); +} + +} + + +/// Adds pattern-matching-related methods to [SeriesSeasonsReq]. +extension SeriesSeasonsReqPatterns on SeriesSeasonsReq { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _SeriesSeasonsReq value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _SeriesSeasonsReq() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _SeriesSeasonsReq value) $default,){ +final _that = this; +switch (_that) { +case _SeriesSeasonsReq(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _SeriesSeasonsReq value)? $default,){ +final _that = this; +switch (_that) { +case _SeriesSeasonsReq() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String seriesId)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _SeriesSeasonsReq() when $default != null: +return $default(_that.seriesId);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String seriesId) $default,) {final _that = this; +switch (_that) { +case _SeriesSeasonsReq(): +return $default(_that.seriesId);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String seriesId)? $default,) {final _that = this; +switch (_that) { +case _SeriesSeasonsReq() when $default != null: +return $default(_that.seriesId);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _SeriesSeasonsReq extends SeriesSeasonsReq { + const _SeriesSeasonsReq({required this.seriesId}): super._(); + + +@override final String seriesId; + +/// Create a copy of SeriesSeasonsReq +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$SeriesSeasonsReqCopyWith<_SeriesSeasonsReq> get copyWith => __$SeriesSeasonsReqCopyWithImpl<_SeriesSeasonsReq>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _SeriesSeasonsReq&&(identical(other.seriesId, seriesId) || other.seriesId == seriesId)); +} + + +@override +int get hashCode => Object.hash(runtimeType,seriesId); + +@override +String toString() { + return 'SeriesSeasonsReq(seriesId: $seriesId)'; +} + + +} + +/// @nodoc +abstract mixin class _$SeriesSeasonsReqCopyWith<$Res> implements $SeriesSeasonsReqCopyWith<$Res> { + factory _$SeriesSeasonsReqCopyWith(_SeriesSeasonsReq value, $Res Function(_SeriesSeasonsReq) _then) = __$SeriesSeasonsReqCopyWithImpl; +@override @useResult +$Res call({ + String seriesId +}); + + + + +} +/// @nodoc +class __$SeriesSeasonsReqCopyWithImpl<$Res> + implements _$SeriesSeasonsReqCopyWith<$Res> { + __$SeriesSeasonsReqCopyWithImpl(this._self, this._then); + + final _SeriesSeasonsReq _self; + final $Res Function(_SeriesSeasonsReq) _then; + +/// Create a copy of SeriesSeasonsReq +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? seriesId = null,}) { + return _then(_SeriesSeasonsReq( +seriesId: null == seriesId ? _self.seriesId : seriesId // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + +/// @nodoc +mixin _$SeriesSeasonsRes { + + String get seriesId; List get items; +/// Create a copy of SeriesSeasonsRes +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$SeriesSeasonsResCopyWith get copyWith => _$SeriesSeasonsResCopyWithImpl(this as SeriesSeasonsRes, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is SeriesSeasonsRes&&(identical(other.seriesId, seriesId) || other.seriesId == seriesId)&&const DeepCollectionEquality().equals(other.items, items)); +} + + +@override +int get hashCode => Object.hash(runtimeType,seriesId,const DeepCollectionEquality().hash(items)); + +@override +String toString() { + return 'SeriesSeasonsRes(seriesId: $seriesId, items: $items)'; +} + + +} + +/// @nodoc +abstract mixin class $SeriesSeasonsResCopyWith<$Res> { + factory $SeriesSeasonsResCopyWith(SeriesSeasonsRes value, $Res Function(SeriesSeasonsRes) _then) = _$SeriesSeasonsResCopyWithImpl; +@useResult +$Res call({ + String seriesId, List items +}); + + + + +} +/// @nodoc +class _$SeriesSeasonsResCopyWithImpl<$Res> + implements $SeriesSeasonsResCopyWith<$Res> { + _$SeriesSeasonsResCopyWithImpl(this._self, this._then); + + final SeriesSeasonsRes _self; + final $Res Function(SeriesSeasonsRes) _then; + +/// Create a copy of SeriesSeasonsRes +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? seriesId = null,Object? items = null,}) { + return _then(_self.copyWith( +seriesId: null == seriesId ? _self.seriesId : seriesId // ignore: cast_nullable_to_non_nullable +as String,items: null == items ? _self.items : items // ignore: cast_nullable_to_non_nullable +as List, + )); +} + +} + + +/// Adds pattern-matching-related methods to [SeriesSeasonsRes]. +extension SeriesSeasonsResPatterns on SeriesSeasonsRes { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _SeriesSeasonsRes value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _SeriesSeasonsRes() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _SeriesSeasonsRes value) $default,){ +final _that = this; +switch (_that) { +case _SeriesSeasonsRes(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _SeriesSeasonsRes value)? $default,){ +final _that = this; +switch (_that) { +case _SeriesSeasonsRes() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String seriesId, List items)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _SeriesSeasonsRes() when $default != null: +return $default(_that.seriesId,_that.items);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String seriesId, List items) $default,) {final _that = this; +switch (_that) { +case _SeriesSeasonsRes(): +return $default(_that.seriesId,_that.items);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String seriesId, List items)? $default,) {final _that = this; +switch (_that) { +case _SeriesSeasonsRes() when $default != null: +return $default(_that.seriesId,_that.items);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _SeriesSeasonsRes implements SeriesSeasonsRes { + const _SeriesSeasonsRes({required this.seriesId, required final List items}): _items = items; + + +@override final String seriesId; + final List _items; +@override List get items { + if (_items is EqualUnmodifiableListView) return _items; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_items); +} + + +/// Create a copy of SeriesSeasonsRes +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$SeriesSeasonsResCopyWith<_SeriesSeasonsRes> get copyWith => __$SeriesSeasonsResCopyWithImpl<_SeriesSeasonsRes>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _SeriesSeasonsRes&&(identical(other.seriesId, seriesId) || other.seriesId == seriesId)&&const DeepCollectionEquality().equals(other._items, _items)); +} + + +@override +int get hashCode => Object.hash(runtimeType,seriesId,const DeepCollectionEquality().hash(_items)); + +@override +String toString() { + return 'SeriesSeasonsRes(seriesId: $seriesId, items: $items)'; +} + + +} + +/// @nodoc +abstract mixin class _$SeriesSeasonsResCopyWith<$Res> implements $SeriesSeasonsResCopyWith<$Res> { + factory _$SeriesSeasonsResCopyWith(_SeriesSeasonsRes value, $Res Function(_SeriesSeasonsRes) _then) = __$SeriesSeasonsResCopyWithImpl; +@override @useResult +$Res call({ + String seriesId, List items +}); + + + + +} +/// @nodoc +class __$SeriesSeasonsResCopyWithImpl<$Res> + implements _$SeriesSeasonsResCopyWith<$Res> { + __$SeriesSeasonsResCopyWithImpl(this._self, this._then); + + final _SeriesSeasonsRes _self; + final $Res Function(_SeriesSeasonsRes) _then; + +/// Create a copy of SeriesSeasonsRes +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? seriesId = null,Object? items = null,}) { + return _then(_SeriesSeasonsRes( +seriesId: null == seriesId ? _self.seriesId : seriesId // ignore: cast_nullable_to_non_nullable +as String,items: null == items ? _self._items : items // ignore: cast_nullable_to_non_nullable +as List, + )); +} + + +} + +/// @nodoc +mixin _$SimilarItemsReq { + + String get itemId; int? get limit; +/// Create a copy of SimilarItemsReq +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$SimilarItemsReqCopyWith get copyWith => _$SimilarItemsReqCopyWithImpl(this as SimilarItemsReq, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is SimilarItemsReq&&(identical(other.itemId, itemId) || other.itemId == itemId)&&(identical(other.limit, limit) || other.limit == limit)); +} + + +@override +int get hashCode => Object.hash(runtimeType,itemId,limit); + +@override +String toString() { + return 'SimilarItemsReq(itemId: $itemId, limit: $limit)'; +} + + +} + +/// @nodoc +abstract mixin class $SimilarItemsReqCopyWith<$Res> { + factory $SimilarItemsReqCopyWith(SimilarItemsReq value, $Res Function(SimilarItemsReq) _then) = _$SimilarItemsReqCopyWithImpl; +@useResult +$Res call({ + String itemId, int? limit +}); + + + + +} +/// @nodoc +class _$SimilarItemsReqCopyWithImpl<$Res> + implements $SimilarItemsReqCopyWith<$Res> { + _$SimilarItemsReqCopyWithImpl(this._self, this._then); + + final SimilarItemsReq _self; + final $Res Function(SimilarItemsReq) _then; + +/// Create a copy of SimilarItemsReq +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? itemId = null,Object? limit = freezed,}) { + return _then(_self.copyWith( +itemId: null == itemId ? _self.itemId : itemId // ignore: cast_nullable_to_non_nullable +as String,limit: freezed == limit ? _self.limit : limit // ignore: cast_nullable_to_non_nullable +as int?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [SimilarItemsReq]. +extension SimilarItemsReqPatterns on SimilarItemsReq { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _SimilarItemsReq value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _SimilarItemsReq() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _SimilarItemsReq value) $default,){ +final _that = this; +switch (_that) { +case _SimilarItemsReq(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _SimilarItemsReq value)? $default,){ +final _that = this; +switch (_that) { +case _SimilarItemsReq() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String itemId, int? limit)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _SimilarItemsReq() when $default != null: +return $default(_that.itemId,_that.limit);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String itemId, int? limit) $default,) {final _that = this; +switch (_that) { +case _SimilarItemsReq(): +return $default(_that.itemId,_that.limit);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String itemId, int? limit)? $default,) {final _that = this; +switch (_that) { +case _SimilarItemsReq() when $default != null: +return $default(_that.itemId,_that.limit);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _SimilarItemsReq extends SimilarItemsReq { + const _SimilarItemsReq({required this.itemId, this.limit}): super._(); + + +@override final String itemId; +@override final int? limit; + +/// Create a copy of SimilarItemsReq +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$SimilarItemsReqCopyWith<_SimilarItemsReq> get copyWith => __$SimilarItemsReqCopyWithImpl<_SimilarItemsReq>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _SimilarItemsReq&&(identical(other.itemId, itemId) || other.itemId == itemId)&&(identical(other.limit, limit) || other.limit == limit)); +} + + +@override +int get hashCode => Object.hash(runtimeType,itemId,limit); + +@override +String toString() { + return 'SimilarItemsReq(itemId: $itemId, limit: $limit)'; +} + + +} + +/// @nodoc +abstract mixin class _$SimilarItemsReqCopyWith<$Res> implements $SimilarItemsReqCopyWith<$Res> { + factory _$SimilarItemsReqCopyWith(_SimilarItemsReq value, $Res Function(_SimilarItemsReq) _then) = __$SimilarItemsReqCopyWithImpl; +@override @useResult +$Res call({ + String itemId, int? limit +}); + + + + +} +/// @nodoc +class __$SimilarItemsReqCopyWithImpl<$Res> + implements _$SimilarItemsReqCopyWith<$Res> { + __$SimilarItemsReqCopyWithImpl(this._self, this._then); + + final _SimilarItemsReq _self; + final $Res Function(_SimilarItemsReq) _then; + +/// Create a copy of SimilarItemsReq +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? itemId = null,Object? limit = freezed,}) { + return _then(_SimilarItemsReq( +itemId: null == itemId ? _self.itemId : itemId // ignore: cast_nullable_to_non_nullable +as String,limit: freezed == limit ? _self.limit : limit // ignore: cast_nullable_to_non_nullable +as int?, + )); +} + + +} + +/// @nodoc +mixin _$SimilarItemsRes { + + String get itemId; List get items; +/// Create a copy of SimilarItemsRes +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$SimilarItemsResCopyWith get copyWith => _$SimilarItemsResCopyWithImpl(this as SimilarItemsRes, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is SimilarItemsRes&&(identical(other.itemId, itemId) || other.itemId == itemId)&&const DeepCollectionEquality().equals(other.items, items)); +} + + +@override +int get hashCode => Object.hash(runtimeType,itemId,const DeepCollectionEquality().hash(items)); + +@override +String toString() { + return 'SimilarItemsRes(itemId: $itemId, items: $items)'; +} + + +} + +/// @nodoc +abstract mixin class $SimilarItemsResCopyWith<$Res> { + factory $SimilarItemsResCopyWith(SimilarItemsRes value, $Res Function(SimilarItemsRes) _then) = _$SimilarItemsResCopyWithImpl; +@useResult +$Res call({ + String itemId, List items +}); + + + + +} +/// @nodoc +class _$SimilarItemsResCopyWithImpl<$Res> + implements $SimilarItemsResCopyWith<$Res> { + _$SimilarItemsResCopyWithImpl(this._self, this._then); + + final SimilarItemsRes _self; + final $Res Function(SimilarItemsRes) _then; + +/// Create a copy of SimilarItemsRes +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? itemId = null,Object? items = null,}) { + return _then(_self.copyWith( +itemId: null == itemId ? _self.itemId : itemId // ignore: cast_nullable_to_non_nullable +as String,items: null == items ? _self.items : items // ignore: cast_nullable_to_non_nullable +as List, + )); +} + +} + + +/// Adds pattern-matching-related methods to [SimilarItemsRes]. +extension SimilarItemsResPatterns on SimilarItemsRes { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _SimilarItemsRes value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _SimilarItemsRes() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _SimilarItemsRes value) $default,){ +final _that = this; +switch (_that) { +case _SimilarItemsRes(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _SimilarItemsRes value)? $default,){ +final _that = this; +switch (_that) { +case _SimilarItemsRes() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String itemId, List items)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _SimilarItemsRes() when $default != null: +return $default(_that.itemId,_that.items);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String itemId, List items) $default,) {final _that = this; +switch (_that) { +case _SimilarItemsRes(): +return $default(_that.itemId,_that.items);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String itemId, List items)? $default,) {final _that = this; +switch (_that) { +case _SimilarItemsRes() when $default != null: +return $default(_that.itemId,_that.items);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _SimilarItemsRes implements SimilarItemsRes { + const _SimilarItemsRes({required this.itemId, required final List items}): _items = items; + + +@override final String itemId; + final List _items; +@override List get items { + if (_items is EqualUnmodifiableListView) return _items; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_items); +} + + +/// Create a copy of SimilarItemsRes +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$SimilarItemsResCopyWith<_SimilarItemsRes> get copyWith => __$SimilarItemsResCopyWithImpl<_SimilarItemsRes>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _SimilarItemsRes&&(identical(other.itemId, itemId) || other.itemId == itemId)&&const DeepCollectionEquality().equals(other._items, _items)); +} + + +@override +int get hashCode => Object.hash(runtimeType,itemId,const DeepCollectionEquality().hash(_items)); + +@override +String toString() { + return 'SimilarItemsRes(itemId: $itemId, items: $items)'; +} + + +} + +/// @nodoc +abstract mixin class _$SimilarItemsResCopyWith<$Res> implements $SimilarItemsResCopyWith<$Res> { + factory _$SimilarItemsResCopyWith(_SimilarItemsRes value, $Res Function(_SimilarItemsRes) _then) = __$SimilarItemsResCopyWithImpl; +@override @useResult +$Res call({ + String itemId, List items +}); + + + + +} +/// @nodoc +class __$SimilarItemsResCopyWithImpl<$Res> + implements _$SimilarItemsResCopyWith<$Res> { + __$SimilarItemsResCopyWithImpl(this._self, this._then); + + final _SimilarItemsRes _self; + final $Res Function(_SimilarItemsRes) _then; + +/// Create a copy of SimilarItemsRes +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? itemId = null,Object? items = null,}) { + return _then(_SimilarItemsRes( +itemId: null == itemId ? _self.itemId : itemId // ignore: cast_nullable_to_non_nullable +as String,items: null == items ? _self._items : items // ignore: cast_nullable_to_non_nullable +as List, + )); +} + + +} + +/// @nodoc +mixin _$AdditionalPartsReq { + + String get itemId; +/// Create a copy of AdditionalPartsReq +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$AdditionalPartsReqCopyWith get copyWith => _$AdditionalPartsReqCopyWithImpl(this as AdditionalPartsReq, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is AdditionalPartsReq&&(identical(other.itemId, itemId) || other.itemId == itemId)); +} + + +@override +int get hashCode => Object.hash(runtimeType,itemId); + +@override +String toString() { + return 'AdditionalPartsReq(itemId: $itemId)'; +} + + +} + +/// @nodoc +abstract mixin class $AdditionalPartsReqCopyWith<$Res> { + factory $AdditionalPartsReqCopyWith(AdditionalPartsReq value, $Res Function(AdditionalPartsReq) _then) = _$AdditionalPartsReqCopyWithImpl; +@useResult +$Res call({ + String itemId +}); + + + + +} +/// @nodoc +class _$AdditionalPartsReqCopyWithImpl<$Res> + implements $AdditionalPartsReqCopyWith<$Res> { + _$AdditionalPartsReqCopyWithImpl(this._self, this._then); + + final AdditionalPartsReq _self; + final $Res Function(AdditionalPartsReq) _then; + +/// Create a copy of AdditionalPartsReq +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? itemId = null,}) { + return _then(_self.copyWith( +itemId: null == itemId ? _self.itemId : itemId // ignore: cast_nullable_to_non_nullable +as String, + )); +} + +} + + +/// Adds pattern-matching-related methods to [AdditionalPartsReq]. +extension AdditionalPartsReqPatterns on AdditionalPartsReq { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _AdditionalPartsReq value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _AdditionalPartsReq() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _AdditionalPartsReq value) $default,){ +final _that = this; +switch (_that) { +case _AdditionalPartsReq(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _AdditionalPartsReq value)? $default,){ +final _that = this; +switch (_that) { +case _AdditionalPartsReq() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String itemId)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _AdditionalPartsReq() when $default != null: +return $default(_that.itemId);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String itemId) $default,) {final _that = this; +switch (_that) { +case _AdditionalPartsReq(): +return $default(_that.itemId);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String itemId)? $default,) {final _that = this; +switch (_that) { +case _AdditionalPartsReq() when $default != null: +return $default(_that.itemId);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _AdditionalPartsReq implements AdditionalPartsReq { + const _AdditionalPartsReq({required this.itemId}); + + +@override final String itemId; + +/// Create a copy of AdditionalPartsReq +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$AdditionalPartsReqCopyWith<_AdditionalPartsReq> get copyWith => __$AdditionalPartsReqCopyWithImpl<_AdditionalPartsReq>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _AdditionalPartsReq&&(identical(other.itemId, itemId) || other.itemId == itemId)); +} + + +@override +int get hashCode => Object.hash(runtimeType,itemId); + +@override +String toString() { + return 'AdditionalPartsReq(itemId: $itemId)'; +} + + +} + +/// @nodoc +abstract mixin class _$AdditionalPartsReqCopyWith<$Res> implements $AdditionalPartsReqCopyWith<$Res> { + factory _$AdditionalPartsReqCopyWith(_AdditionalPartsReq value, $Res Function(_AdditionalPartsReq) _then) = __$AdditionalPartsReqCopyWithImpl; +@override @useResult +$Res call({ + String itemId +}); + + + + +} +/// @nodoc +class __$AdditionalPartsReqCopyWithImpl<$Res> + implements _$AdditionalPartsReqCopyWith<$Res> { + __$AdditionalPartsReqCopyWithImpl(this._self, this._then); + + final _AdditionalPartsReq _self; + final $Res Function(_AdditionalPartsReq) _then; + +/// Create a copy of AdditionalPartsReq +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? itemId = null,}) { + return _then(_AdditionalPartsReq( +itemId: null == itemId ? _self.itemId : itemId // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + +/// @nodoc +mixin _$AdditionalPartsRes { + + String get itemId; List get items; +/// Create a copy of AdditionalPartsRes +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$AdditionalPartsResCopyWith get copyWith => _$AdditionalPartsResCopyWithImpl(this as AdditionalPartsRes, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is AdditionalPartsRes&&(identical(other.itemId, itemId) || other.itemId == itemId)&&const DeepCollectionEquality().equals(other.items, items)); +} + + +@override +int get hashCode => Object.hash(runtimeType,itemId,const DeepCollectionEquality().hash(items)); + +@override +String toString() { + return 'AdditionalPartsRes(itemId: $itemId, items: $items)'; +} + + +} + +/// @nodoc +abstract mixin class $AdditionalPartsResCopyWith<$Res> { + factory $AdditionalPartsResCopyWith(AdditionalPartsRes value, $Res Function(AdditionalPartsRes) _then) = _$AdditionalPartsResCopyWithImpl; +@useResult +$Res call({ + String itemId, List items +}); + + + + +} +/// @nodoc +class _$AdditionalPartsResCopyWithImpl<$Res> + implements $AdditionalPartsResCopyWith<$Res> { + _$AdditionalPartsResCopyWithImpl(this._self, this._then); + + final AdditionalPartsRes _self; + final $Res Function(AdditionalPartsRes) _then; + +/// Create a copy of AdditionalPartsRes +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? itemId = null,Object? items = null,}) { + return _then(_self.copyWith( +itemId: null == itemId ? _self.itemId : itemId // ignore: cast_nullable_to_non_nullable +as String,items: null == items ? _self.items : items // ignore: cast_nullable_to_non_nullable +as List, + )); +} + +} + + +/// Adds pattern-matching-related methods to [AdditionalPartsRes]. +extension AdditionalPartsResPatterns on AdditionalPartsRes { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _AdditionalPartsRes value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _AdditionalPartsRes() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _AdditionalPartsRes value) $default,){ +final _that = this; +switch (_that) { +case _AdditionalPartsRes(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _AdditionalPartsRes value)? $default,){ +final _that = this; +switch (_that) { +case _AdditionalPartsRes() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String itemId, List items)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _AdditionalPartsRes() when $default != null: +return $default(_that.itemId,_that.items);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String itemId, List items) $default,) {final _that = this; +switch (_that) { +case _AdditionalPartsRes(): +return $default(_that.itemId,_that.items);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String itemId, List items)? $default,) {final _that = this; +switch (_that) { +case _AdditionalPartsRes() when $default != null: +return $default(_that.itemId,_that.items);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _AdditionalPartsRes implements AdditionalPartsRes { + const _AdditionalPartsRes({required this.itemId, required final List items}): _items = items; + + +@override final String itemId; + final List _items; +@override List get items { + if (_items is EqualUnmodifiableListView) return _items; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_items); +} + + +/// Create a copy of AdditionalPartsRes +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$AdditionalPartsResCopyWith<_AdditionalPartsRes> get copyWith => __$AdditionalPartsResCopyWithImpl<_AdditionalPartsRes>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _AdditionalPartsRes&&(identical(other.itemId, itemId) || other.itemId == itemId)&&const DeepCollectionEquality().equals(other._items, _items)); +} + + +@override +int get hashCode => Object.hash(runtimeType,itemId,const DeepCollectionEquality().hash(_items)); + +@override +String toString() { + return 'AdditionalPartsRes(itemId: $itemId, items: $items)'; +} + + +} + +/// @nodoc +abstract mixin class _$AdditionalPartsResCopyWith<$Res> implements $AdditionalPartsResCopyWith<$Res> { + factory _$AdditionalPartsResCopyWith(_AdditionalPartsRes value, $Res Function(_AdditionalPartsRes) _then) = __$AdditionalPartsResCopyWithImpl; +@override @useResult +$Res call({ + String itemId, List items +}); + + + + +} +/// @nodoc +class __$AdditionalPartsResCopyWithImpl<$Res> + implements _$AdditionalPartsResCopyWith<$Res> { + __$AdditionalPartsResCopyWithImpl(this._self, this._then); + + final _AdditionalPartsRes _self; + final $Res Function(_AdditionalPartsRes) _then; + +/// Create a copy of AdditionalPartsRes +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? itemId = null,Object? items = null,}) { + return _then(_AdditionalPartsRes( +itemId: null == itemId ? _self.itemId : itemId // ignore: cast_nullable_to_non_nullable +as String,items: null == items ? _self._items : items // ignore: cast_nullable_to_non_nullable +as List, + )); +} + + +} + +/// @nodoc +mixin _$CrossServerSourceCard { + + String get serverId; String get serverName; String get serverUrl; String get token; String get userId; String get landingItemId; String get mediaSourceId; EmbyRawItem get item; EmbyRawMediaSource get mediaSource; MediaQualityInfo? get quality; +/// Create a copy of CrossServerSourceCard +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$CrossServerSourceCardCopyWith get copyWith => _$CrossServerSourceCardCopyWithImpl(this as CrossServerSourceCard, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is CrossServerSourceCard&&(identical(other.serverId, serverId) || other.serverId == serverId)&&(identical(other.serverName, serverName) || other.serverName == serverName)&&(identical(other.serverUrl, serverUrl) || other.serverUrl == serverUrl)&&(identical(other.token, token) || other.token == token)&&(identical(other.userId, userId) || other.userId == userId)&&(identical(other.landingItemId, landingItemId) || other.landingItemId == landingItemId)&&(identical(other.mediaSourceId, mediaSourceId) || other.mediaSourceId == mediaSourceId)&&(identical(other.item, item) || other.item == item)&&(identical(other.mediaSource, mediaSource) || other.mediaSource == mediaSource)&&(identical(other.quality, quality) || other.quality == quality)); +} + + +@override +int get hashCode => Object.hash(runtimeType,serverId,serverName,serverUrl,token,userId,landingItemId,mediaSourceId,item,mediaSource,quality); + +@override +String toString() { + return 'CrossServerSourceCard(serverId: $serverId, serverName: $serverName, serverUrl: $serverUrl, token: $token, userId: $userId, landingItemId: $landingItemId, mediaSourceId: $mediaSourceId, item: $item, mediaSource: $mediaSource, quality: $quality)'; +} + + +} + +/// @nodoc +abstract mixin class $CrossServerSourceCardCopyWith<$Res> { + factory $CrossServerSourceCardCopyWith(CrossServerSourceCard value, $Res Function(CrossServerSourceCard) _then) = _$CrossServerSourceCardCopyWithImpl; +@useResult +$Res call({ + String serverId, String serverName, String serverUrl, String token, String userId, String landingItemId, String mediaSourceId, EmbyRawItem item, EmbyRawMediaSource mediaSource, MediaQualityInfo? quality +}); + + +$EmbyRawItemCopyWith<$Res> get item;$EmbyRawMediaSourceCopyWith<$Res> get mediaSource;$MediaQualityInfoCopyWith<$Res>? get quality; + +} +/// @nodoc +class _$CrossServerSourceCardCopyWithImpl<$Res> + implements $CrossServerSourceCardCopyWith<$Res> { + _$CrossServerSourceCardCopyWithImpl(this._self, this._then); + + final CrossServerSourceCard _self; + final $Res Function(CrossServerSourceCard) _then; + +/// Create a copy of CrossServerSourceCard +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? serverId = null,Object? serverName = null,Object? serverUrl = null,Object? token = null,Object? userId = null,Object? landingItemId = null,Object? mediaSourceId = null,Object? item = null,Object? mediaSource = null,Object? quality = freezed,}) { + return _then(_self.copyWith( +serverId: null == serverId ? _self.serverId : serverId // ignore: cast_nullable_to_non_nullable +as String,serverName: null == serverName ? _self.serverName : serverName // ignore: cast_nullable_to_non_nullable +as String,serverUrl: null == serverUrl ? _self.serverUrl : serverUrl // ignore: cast_nullable_to_non_nullable +as String,token: null == token ? _self.token : token // ignore: cast_nullable_to_non_nullable +as String,userId: null == userId ? _self.userId : userId // ignore: cast_nullable_to_non_nullable +as String,landingItemId: null == landingItemId ? _self.landingItemId : landingItemId // ignore: cast_nullable_to_non_nullable +as String,mediaSourceId: null == mediaSourceId ? _self.mediaSourceId : mediaSourceId // ignore: cast_nullable_to_non_nullable +as String,item: null == item ? _self.item : item // ignore: cast_nullable_to_non_nullable +as EmbyRawItem,mediaSource: null == mediaSource ? _self.mediaSource : mediaSource // ignore: cast_nullable_to_non_nullable +as EmbyRawMediaSource,quality: freezed == quality ? _self.quality : quality // ignore: cast_nullable_to_non_nullable +as MediaQualityInfo?, + )); +} +/// Create a copy of CrossServerSourceCard +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$EmbyRawItemCopyWith<$Res> get item { + + return $EmbyRawItemCopyWith<$Res>(_self.item, (value) { + return _then(_self.copyWith(item: value)); + }); +}/// Create a copy of CrossServerSourceCard +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$EmbyRawMediaSourceCopyWith<$Res> get mediaSource { + + return $EmbyRawMediaSourceCopyWith<$Res>(_self.mediaSource, (value) { + return _then(_self.copyWith(mediaSource: value)); + }); +}/// Create a copy of CrossServerSourceCard +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$MediaQualityInfoCopyWith<$Res>? get quality { + if (_self.quality == null) { + return null; + } + + return $MediaQualityInfoCopyWith<$Res>(_self.quality!, (value) { + return _then(_self.copyWith(quality: value)); + }); +} +} + + +/// Adds pattern-matching-related methods to [CrossServerSourceCard]. +extension CrossServerSourceCardPatterns on CrossServerSourceCard { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _CrossServerSourceCard value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _CrossServerSourceCard() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _CrossServerSourceCard value) $default,){ +final _that = this; +switch (_that) { +case _CrossServerSourceCard(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _CrossServerSourceCard value)? $default,){ +final _that = this; +switch (_that) { +case _CrossServerSourceCard() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String serverId, String serverName, String serverUrl, String token, String userId, String landingItemId, String mediaSourceId, EmbyRawItem item, EmbyRawMediaSource mediaSource, MediaQualityInfo? quality)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _CrossServerSourceCard() when $default != null: +return $default(_that.serverId,_that.serverName,_that.serverUrl,_that.token,_that.userId,_that.landingItemId,_that.mediaSourceId,_that.item,_that.mediaSource,_that.quality);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String serverId, String serverName, String serverUrl, String token, String userId, String landingItemId, String mediaSourceId, EmbyRawItem item, EmbyRawMediaSource mediaSource, MediaQualityInfo? quality) $default,) {final _that = this; +switch (_that) { +case _CrossServerSourceCard(): +return $default(_that.serverId,_that.serverName,_that.serverUrl,_that.token,_that.userId,_that.landingItemId,_that.mediaSourceId,_that.item,_that.mediaSource,_that.quality);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String serverId, String serverName, String serverUrl, String token, String userId, String landingItemId, String mediaSourceId, EmbyRawItem item, EmbyRawMediaSource mediaSource, MediaQualityInfo? quality)? $default,) {final _that = this; +switch (_that) { +case _CrossServerSourceCard() when $default != null: +return $default(_that.serverId,_that.serverName,_that.serverUrl,_that.token,_that.userId,_that.landingItemId,_that.mediaSourceId,_that.item,_that.mediaSource,_that.quality);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _CrossServerSourceCard implements CrossServerSourceCard { + const _CrossServerSourceCard({required this.serverId, required this.serverName, required this.serverUrl, required this.token, required this.userId, required this.landingItemId, required this.mediaSourceId, required this.item, required this.mediaSource, this.quality}); + + +@override final String serverId; +@override final String serverName; +@override final String serverUrl; +@override final String token; +@override final String userId; +@override final String landingItemId; +@override final String mediaSourceId; +@override final EmbyRawItem item; +@override final EmbyRawMediaSource mediaSource; +@override final MediaQualityInfo? quality; + +/// Create a copy of CrossServerSourceCard +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$CrossServerSourceCardCopyWith<_CrossServerSourceCard> get copyWith => __$CrossServerSourceCardCopyWithImpl<_CrossServerSourceCard>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _CrossServerSourceCard&&(identical(other.serverId, serverId) || other.serverId == serverId)&&(identical(other.serverName, serverName) || other.serverName == serverName)&&(identical(other.serverUrl, serverUrl) || other.serverUrl == serverUrl)&&(identical(other.token, token) || other.token == token)&&(identical(other.userId, userId) || other.userId == userId)&&(identical(other.landingItemId, landingItemId) || other.landingItemId == landingItemId)&&(identical(other.mediaSourceId, mediaSourceId) || other.mediaSourceId == mediaSourceId)&&(identical(other.item, item) || other.item == item)&&(identical(other.mediaSource, mediaSource) || other.mediaSource == mediaSource)&&(identical(other.quality, quality) || other.quality == quality)); +} + + +@override +int get hashCode => Object.hash(runtimeType,serverId,serverName,serverUrl,token,userId,landingItemId,mediaSourceId,item,mediaSource,quality); + +@override +String toString() { + return 'CrossServerSourceCard(serverId: $serverId, serverName: $serverName, serverUrl: $serverUrl, token: $token, userId: $userId, landingItemId: $landingItemId, mediaSourceId: $mediaSourceId, item: $item, mediaSource: $mediaSource, quality: $quality)'; +} + + +} + +/// @nodoc +abstract mixin class _$CrossServerSourceCardCopyWith<$Res> implements $CrossServerSourceCardCopyWith<$Res> { + factory _$CrossServerSourceCardCopyWith(_CrossServerSourceCard value, $Res Function(_CrossServerSourceCard) _then) = __$CrossServerSourceCardCopyWithImpl; +@override @useResult +$Res call({ + String serverId, String serverName, String serverUrl, String token, String userId, String landingItemId, String mediaSourceId, EmbyRawItem item, EmbyRawMediaSource mediaSource, MediaQualityInfo? quality +}); + + +@override $EmbyRawItemCopyWith<$Res> get item;@override $EmbyRawMediaSourceCopyWith<$Res> get mediaSource;@override $MediaQualityInfoCopyWith<$Res>? get quality; + +} +/// @nodoc +class __$CrossServerSourceCardCopyWithImpl<$Res> + implements _$CrossServerSourceCardCopyWith<$Res> { + __$CrossServerSourceCardCopyWithImpl(this._self, this._then); + + final _CrossServerSourceCard _self; + final $Res Function(_CrossServerSourceCard) _then; + +/// Create a copy of CrossServerSourceCard +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? serverId = null,Object? serverName = null,Object? serverUrl = null,Object? token = null,Object? userId = null,Object? landingItemId = null,Object? mediaSourceId = null,Object? item = null,Object? mediaSource = null,Object? quality = freezed,}) { + return _then(_CrossServerSourceCard( +serverId: null == serverId ? _self.serverId : serverId // ignore: cast_nullable_to_non_nullable +as String,serverName: null == serverName ? _self.serverName : serverName // ignore: cast_nullable_to_non_nullable +as String,serverUrl: null == serverUrl ? _self.serverUrl : serverUrl // ignore: cast_nullable_to_non_nullable +as String,token: null == token ? _self.token : token // ignore: cast_nullable_to_non_nullable +as String,userId: null == userId ? _self.userId : userId // ignore: cast_nullable_to_non_nullable +as String,landingItemId: null == landingItemId ? _self.landingItemId : landingItemId // ignore: cast_nullable_to_non_nullable +as String,mediaSourceId: null == mediaSourceId ? _self.mediaSourceId : mediaSourceId // ignore: cast_nullable_to_non_nullable +as String,item: null == item ? _self.item : item // ignore: cast_nullable_to_non_nullable +as EmbyRawItem,mediaSource: null == mediaSource ? _self.mediaSource : mediaSource // ignore: cast_nullable_to_non_nullable +as EmbyRawMediaSource,quality: freezed == quality ? _self.quality : quality // ignore: cast_nullable_to_non_nullable +as MediaQualityInfo?, + )); +} + +/// Create a copy of CrossServerSourceCard +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$EmbyRawItemCopyWith<$Res> get item { + + return $EmbyRawItemCopyWith<$Res>(_self.item, (value) { + return _then(_self.copyWith(item: value)); + }); +}/// Create a copy of CrossServerSourceCard +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$EmbyRawMediaSourceCopyWith<$Res> get mediaSource { + + return $EmbyRawMediaSourceCopyWith<$Res>(_self.mediaSource, (value) { + return _then(_self.copyWith(mediaSource: value)); + }); +}/// Create a copy of CrossServerSourceCard +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$MediaQualityInfoCopyWith<$Res>? get quality { + if (_self.quality == null) { + return null; + } + + return $MediaQualityInfoCopyWith<$Res>(_self.quality!, (value) { + return _then(_self.copyWith(quality: value)); + }); +} +} + +/// @nodoc +mixin _$MediaQualityInfo { + + int? get height; int? get width; String? get videoCodec; String? get container; String? get videoRange; String? get videoRangeType; int? get bitDepth; double? get fps; +/// Create a copy of MediaQualityInfo +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$MediaQualityInfoCopyWith get copyWith => _$MediaQualityInfoCopyWithImpl(this as MediaQualityInfo, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is MediaQualityInfo&&(identical(other.height, height) || other.height == height)&&(identical(other.width, width) || other.width == width)&&(identical(other.videoCodec, videoCodec) || other.videoCodec == videoCodec)&&(identical(other.container, container) || other.container == container)&&(identical(other.videoRange, videoRange) || other.videoRange == videoRange)&&(identical(other.videoRangeType, videoRangeType) || other.videoRangeType == videoRangeType)&&(identical(other.bitDepth, bitDepth) || other.bitDepth == bitDepth)&&(identical(other.fps, fps) || other.fps == fps)); +} + + +@override +int get hashCode => Object.hash(runtimeType,height,width,videoCodec,container,videoRange,videoRangeType,bitDepth,fps); + +@override +String toString() { + return 'MediaQualityInfo(height: $height, width: $width, videoCodec: $videoCodec, container: $container, videoRange: $videoRange, videoRangeType: $videoRangeType, bitDepth: $bitDepth, fps: $fps)'; +} + + +} + +/// @nodoc +abstract mixin class $MediaQualityInfoCopyWith<$Res> { + factory $MediaQualityInfoCopyWith(MediaQualityInfo value, $Res Function(MediaQualityInfo) _then) = _$MediaQualityInfoCopyWithImpl; +@useResult +$Res call({ + int? height, int? width, String? videoCodec, String? container, String? videoRange, String? videoRangeType, int? bitDepth, double? fps +}); + + + + +} +/// @nodoc +class _$MediaQualityInfoCopyWithImpl<$Res> + implements $MediaQualityInfoCopyWith<$Res> { + _$MediaQualityInfoCopyWithImpl(this._self, this._then); + + final MediaQualityInfo _self; + final $Res Function(MediaQualityInfo) _then; + +/// Create a copy of MediaQualityInfo +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? height = freezed,Object? width = freezed,Object? videoCodec = freezed,Object? container = freezed,Object? videoRange = freezed,Object? videoRangeType = freezed,Object? bitDepth = freezed,Object? fps = freezed,}) { + return _then(_self.copyWith( +height: freezed == height ? _self.height : height // ignore: cast_nullable_to_non_nullable +as int?,width: freezed == width ? _self.width : width // ignore: cast_nullable_to_non_nullable +as int?,videoCodec: freezed == videoCodec ? _self.videoCodec : videoCodec // ignore: cast_nullable_to_non_nullable +as String?,container: freezed == container ? _self.container : container // ignore: cast_nullable_to_non_nullable +as String?,videoRange: freezed == videoRange ? _self.videoRange : videoRange // ignore: cast_nullable_to_non_nullable +as String?,videoRangeType: freezed == videoRangeType ? _self.videoRangeType : videoRangeType // ignore: cast_nullable_to_non_nullable +as String?,bitDepth: freezed == bitDepth ? _self.bitDepth : bitDepth // ignore: cast_nullable_to_non_nullable +as int?,fps: freezed == fps ? _self.fps : fps // ignore: cast_nullable_to_non_nullable +as double?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [MediaQualityInfo]. +extension MediaQualityInfoPatterns on MediaQualityInfo { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _MediaQualityInfo value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _MediaQualityInfo() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _MediaQualityInfo value) $default,){ +final _that = this; +switch (_that) { +case _MediaQualityInfo(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _MediaQualityInfo value)? $default,){ +final _that = this; +switch (_that) { +case _MediaQualityInfo() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( int? height, int? width, String? videoCodec, String? container, String? videoRange, String? videoRangeType, int? bitDepth, double? fps)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _MediaQualityInfo() when $default != null: +return $default(_that.height,_that.width,_that.videoCodec,_that.container,_that.videoRange,_that.videoRangeType,_that.bitDepth,_that.fps);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( int? height, int? width, String? videoCodec, String? container, String? videoRange, String? videoRangeType, int? bitDepth, double? fps) $default,) {final _that = this; +switch (_that) { +case _MediaQualityInfo(): +return $default(_that.height,_that.width,_that.videoCodec,_that.container,_that.videoRange,_that.videoRangeType,_that.bitDepth,_that.fps);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( int? height, int? width, String? videoCodec, String? container, String? videoRange, String? videoRangeType, int? bitDepth, double? fps)? $default,) {final _that = this; +switch (_that) { +case _MediaQualityInfo() when $default != null: +return $default(_that.height,_that.width,_that.videoCodec,_that.container,_that.videoRange,_that.videoRangeType,_that.bitDepth,_that.fps);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _MediaQualityInfo extends MediaQualityInfo { + const _MediaQualityInfo({this.height, this.width, this.videoCodec, this.container, this.videoRange, this.videoRangeType, this.bitDepth, this.fps}): super._(); + + +@override final int? height; +@override final int? width; +@override final String? videoCodec; +@override final String? container; +@override final String? videoRange; +@override final String? videoRangeType; +@override final int? bitDepth; +@override final double? fps; + +/// Create a copy of MediaQualityInfo +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$MediaQualityInfoCopyWith<_MediaQualityInfo> get copyWith => __$MediaQualityInfoCopyWithImpl<_MediaQualityInfo>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _MediaQualityInfo&&(identical(other.height, height) || other.height == height)&&(identical(other.width, width) || other.width == width)&&(identical(other.videoCodec, videoCodec) || other.videoCodec == videoCodec)&&(identical(other.container, container) || other.container == container)&&(identical(other.videoRange, videoRange) || other.videoRange == videoRange)&&(identical(other.videoRangeType, videoRangeType) || other.videoRangeType == videoRangeType)&&(identical(other.bitDepth, bitDepth) || other.bitDepth == bitDepth)&&(identical(other.fps, fps) || other.fps == fps)); +} + + +@override +int get hashCode => Object.hash(runtimeType,height,width,videoCodec,container,videoRange,videoRangeType,bitDepth,fps); + +@override +String toString() { + return 'MediaQualityInfo(height: $height, width: $width, videoCodec: $videoCodec, container: $container, videoRange: $videoRange, videoRangeType: $videoRangeType, bitDepth: $bitDepth, fps: $fps)'; +} + + +} + +/// @nodoc +abstract mixin class _$MediaQualityInfoCopyWith<$Res> implements $MediaQualityInfoCopyWith<$Res> { + factory _$MediaQualityInfoCopyWith(_MediaQualityInfo value, $Res Function(_MediaQualityInfo) _then) = __$MediaQualityInfoCopyWithImpl; +@override @useResult +$Res call({ + int? height, int? width, String? videoCodec, String? container, String? videoRange, String? videoRangeType, int? bitDepth, double? fps +}); + + + + +} +/// @nodoc +class __$MediaQualityInfoCopyWithImpl<$Res> + implements _$MediaQualityInfoCopyWith<$Res> { + __$MediaQualityInfoCopyWithImpl(this._self, this._then); + + final _MediaQualityInfo _self; + final $Res Function(_MediaQualityInfo) _then; + +/// Create a copy of MediaQualityInfo +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? height = freezed,Object? width = freezed,Object? videoCodec = freezed,Object? container = freezed,Object? videoRange = freezed,Object? videoRangeType = freezed,Object? bitDepth = freezed,Object? fps = freezed,}) { + return _then(_MediaQualityInfo( +height: freezed == height ? _self.height : height // ignore: cast_nullable_to_non_nullable +as int?,width: freezed == width ? _self.width : width // ignore: cast_nullable_to_non_nullable +as int?,videoCodec: freezed == videoCodec ? _self.videoCodec : videoCodec // ignore: cast_nullable_to_non_nullable +as String?,container: freezed == container ? _self.container : container // ignore: cast_nullable_to_non_nullable +as String?,videoRange: freezed == videoRange ? _self.videoRange : videoRange // ignore: cast_nullable_to_non_nullable +as String?,videoRangeType: freezed == videoRangeType ? _self.videoRangeType : videoRangeType // ignore: cast_nullable_to_non_nullable +as String?,bitDepth: freezed == bitDepth ? _self.bitDepth : bitDepth // ignore: cast_nullable_to_non_nullable +as int?,fps: freezed == fps ? _self.fps : fps // ignore: cast_nullable_to_non_nullable +as double?, + )); +} + + +} + +/// @nodoc +mixin _$CrossServerSearchReq { + + String get anchorType; String get itemName; Map get providerIds; Map? get seriesProviderIds; int? get parentIndexNumber; int? get indexNumber;/// 要从跨服搜索中排除的服务器(见 Decision 9)。为 null 时 `executeStream` +/// 回退到排除当前活跃服务器(EmbyDetailView / PlayerPage 的既有语义);TMDB +/// 命中态传入 `primaryHit.serverId`,使「其它服务器的版本」不与「在「X」的版本」 +/// 重复。 + String? get excludedServerId; +/// Create a copy of CrossServerSearchReq +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$CrossServerSearchReqCopyWith get copyWith => _$CrossServerSearchReqCopyWithImpl(this as CrossServerSearchReq, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is CrossServerSearchReq&&(identical(other.anchorType, anchorType) || other.anchorType == anchorType)&&(identical(other.itemName, itemName) || other.itemName == itemName)&&const DeepCollectionEquality().equals(other.providerIds, providerIds)&&const DeepCollectionEquality().equals(other.seriesProviderIds, seriesProviderIds)&&(identical(other.parentIndexNumber, parentIndexNumber) || other.parentIndexNumber == parentIndexNumber)&&(identical(other.indexNumber, indexNumber) || other.indexNumber == indexNumber)&&(identical(other.excludedServerId, excludedServerId) || other.excludedServerId == excludedServerId)); +} + + +@override +int get hashCode => Object.hash(runtimeType,anchorType,itemName,const DeepCollectionEquality().hash(providerIds),const DeepCollectionEquality().hash(seriesProviderIds),parentIndexNumber,indexNumber,excludedServerId); + +@override +String toString() { + return 'CrossServerSearchReq(anchorType: $anchorType, itemName: $itemName, providerIds: $providerIds, seriesProviderIds: $seriesProviderIds, parentIndexNumber: $parentIndexNumber, indexNumber: $indexNumber, excludedServerId: $excludedServerId)'; +} + + +} + +/// @nodoc +abstract mixin class $CrossServerSearchReqCopyWith<$Res> { + factory $CrossServerSearchReqCopyWith(CrossServerSearchReq value, $Res Function(CrossServerSearchReq) _then) = _$CrossServerSearchReqCopyWithImpl; +@useResult +$Res call({ + String anchorType, String itemName, Map providerIds, Map? seriesProviderIds, int? parentIndexNumber, int? indexNumber, String? excludedServerId +}); + + + + +} +/// @nodoc +class _$CrossServerSearchReqCopyWithImpl<$Res> + implements $CrossServerSearchReqCopyWith<$Res> { + _$CrossServerSearchReqCopyWithImpl(this._self, this._then); + + final CrossServerSearchReq _self; + final $Res Function(CrossServerSearchReq) _then; + +/// Create a copy of CrossServerSearchReq +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? anchorType = null,Object? itemName = null,Object? providerIds = null,Object? seriesProviderIds = freezed,Object? parentIndexNumber = freezed,Object? indexNumber = freezed,Object? excludedServerId = freezed,}) { + return _then(_self.copyWith( +anchorType: null == anchorType ? _self.anchorType : anchorType // ignore: cast_nullable_to_non_nullable +as String,itemName: null == itemName ? _self.itemName : itemName // ignore: cast_nullable_to_non_nullable +as String,providerIds: null == providerIds ? _self.providerIds : providerIds // ignore: cast_nullable_to_non_nullable +as Map,seriesProviderIds: freezed == seriesProviderIds ? _self.seriesProviderIds : seriesProviderIds // ignore: cast_nullable_to_non_nullable +as Map?,parentIndexNumber: freezed == parentIndexNumber ? _self.parentIndexNumber : parentIndexNumber // ignore: cast_nullable_to_non_nullable +as int?,indexNumber: freezed == indexNumber ? _self.indexNumber : indexNumber // ignore: cast_nullable_to_non_nullable +as int?,excludedServerId: freezed == excludedServerId ? _self.excludedServerId : excludedServerId // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [CrossServerSearchReq]. +extension CrossServerSearchReqPatterns on CrossServerSearchReq { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _CrossServerSearchReq value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _CrossServerSearchReq() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _CrossServerSearchReq value) $default,){ +final _that = this; +switch (_that) { +case _CrossServerSearchReq(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _CrossServerSearchReq value)? $default,){ +final _that = this; +switch (_that) { +case _CrossServerSearchReq() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String anchorType, String itemName, Map providerIds, Map? seriesProviderIds, int? parentIndexNumber, int? indexNumber, String? excludedServerId)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _CrossServerSearchReq() when $default != null: +return $default(_that.anchorType,_that.itemName,_that.providerIds,_that.seriesProviderIds,_that.parentIndexNumber,_that.indexNumber,_that.excludedServerId);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String anchorType, String itemName, Map providerIds, Map? seriesProviderIds, int? parentIndexNumber, int? indexNumber, String? excludedServerId) $default,) {final _that = this; +switch (_that) { +case _CrossServerSearchReq(): +return $default(_that.anchorType,_that.itemName,_that.providerIds,_that.seriesProviderIds,_that.parentIndexNumber,_that.indexNumber,_that.excludedServerId);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String anchorType, String itemName, Map providerIds, Map? seriesProviderIds, int? parentIndexNumber, int? indexNumber, String? excludedServerId)? $default,) {final _that = this; +switch (_that) { +case _CrossServerSearchReq() when $default != null: +return $default(_that.anchorType,_that.itemName,_that.providerIds,_that.seriesProviderIds,_that.parentIndexNumber,_that.indexNumber,_that.excludedServerId);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _CrossServerSearchReq implements CrossServerSearchReq { + const _CrossServerSearchReq({required this.anchorType, required this.itemName, required final Map providerIds, final Map? seriesProviderIds, this.parentIndexNumber, this.indexNumber, this.excludedServerId}): _providerIds = providerIds,_seriesProviderIds = seriesProviderIds; + + +@override final String anchorType; +@override final String itemName; + final Map _providerIds; +@override Map get providerIds { + if (_providerIds is EqualUnmodifiableMapView) return _providerIds; + // ignore: implicit_dynamic_type + return EqualUnmodifiableMapView(_providerIds); +} + + final Map? _seriesProviderIds; +@override Map? get seriesProviderIds { + final value = _seriesProviderIds; + if (value == null) return null; + if (_seriesProviderIds is EqualUnmodifiableMapView) return _seriesProviderIds; + // ignore: implicit_dynamic_type + return EqualUnmodifiableMapView(value); +} + +@override final int? parentIndexNumber; +@override final int? indexNumber; +/// 要从跨服搜索中排除的服务器(见 Decision 9)。为 null 时 `executeStream` +/// 回退到排除当前活跃服务器(EmbyDetailView / PlayerPage 的既有语义);TMDB +/// 命中态传入 `primaryHit.serverId`,使「其它服务器的版本」不与「在「X」的版本」 +/// 重复。 +@override final String? excludedServerId; + +/// Create a copy of CrossServerSearchReq +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$CrossServerSearchReqCopyWith<_CrossServerSearchReq> get copyWith => __$CrossServerSearchReqCopyWithImpl<_CrossServerSearchReq>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _CrossServerSearchReq&&(identical(other.anchorType, anchorType) || other.anchorType == anchorType)&&(identical(other.itemName, itemName) || other.itemName == itemName)&&const DeepCollectionEquality().equals(other._providerIds, _providerIds)&&const DeepCollectionEquality().equals(other._seriesProviderIds, _seriesProviderIds)&&(identical(other.parentIndexNumber, parentIndexNumber) || other.parentIndexNumber == parentIndexNumber)&&(identical(other.indexNumber, indexNumber) || other.indexNumber == indexNumber)&&(identical(other.excludedServerId, excludedServerId) || other.excludedServerId == excludedServerId)); +} + + +@override +int get hashCode => Object.hash(runtimeType,anchorType,itemName,const DeepCollectionEquality().hash(_providerIds),const DeepCollectionEquality().hash(_seriesProviderIds),parentIndexNumber,indexNumber,excludedServerId); + +@override +String toString() { + return 'CrossServerSearchReq(anchorType: $anchorType, itemName: $itemName, providerIds: $providerIds, seriesProviderIds: $seriesProviderIds, parentIndexNumber: $parentIndexNumber, indexNumber: $indexNumber, excludedServerId: $excludedServerId)'; +} + + +} + +/// @nodoc +abstract mixin class _$CrossServerSearchReqCopyWith<$Res> implements $CrossServerSearchReqCopyWith<$Res> { + factory _$CrossServerSearchReqCopyWith(_CrossServerSearchReq value, $Res Function(_CrossServerSearchReq) _then) = __$CrossServerSearchReqCopyWithImpl; +@override @useResult +$Res call({ + String anchorType, String itemName, Map providerIds, Map? seriesProviderIds, int? parentIndexNumber, int? indexNumber, String? excludedServerId +}); + + + + +} +/// @nodoc +class __$CrossServerSearchReqCopyWithImpl<$Res> + implements _$CrossServerSearchReqCopyWith<$Res> { + __$CrossServerSearchReqCopyWithImpl(this._self, this._then); + + final _CrossServerSearchReq _self; + final $Res Function(_CrossServerSearchReq) _then; + +/// Create a copy of CrossServerSearchReq +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? anchorType = null,Object? itemName = null,Object? providerIds = null,Object? seriesProviderIds = freezed,Object? parentIndexNumber = freezed,Object? indexNumber = freezed,Object? excludedServerId = freezed,}) { + return _then(_CrossServerSearchReq( +anchorType: null == anchorType ? _self.anchorType : anchorType // ignore: cast_nullable_to_non_nullable +as String,itemName: null == itemName ? _self.itemName : itemName // ignore: cast_nullable_to_non_nullable +as String,providerIds: null == providerIds ? _self._providerIds : providerIds // ignore: cast_nullable_to_non_nullable +as Map,seriesProviderIds: freezed == seriesProviderIds ? _self._seriesProviderIds : seriesProviderIds // ignore: cast_nullable_to_non_nullable +as Map?,parentIndexNumber: freezed == parentIndexNumber ? _self.parentIndexNumber : parentIndexNumber // ignore: cast_nullable_to_non_nullable +as int?,indexNumber: freezed == indexNumber ? _self.indexNumber : indexNumber // ignore: cast_nullable_to_non_nullable +as int?,excludedServerId: freezed == excludedServerId ? _self.excludedServerId : excludedServerId // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + + +} + +/// @nodoc +mixin _$TmdbAvailabilityReq { + + String get tmdbId; String get mediaType;/// imdb id 兜底(如 `tt0137523`),仅对 movie 生效;提供后反查口径为 +/// `Tmdb.x,Imdb.y`,提升只刮削了 imdb 的影片命中率(见 R1)。 + String? get imdbId; +/// Create a copy of TmdbAvailabilityReq +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$TmdbAvailabilityReqCopyWith get copyWith => _$TmdbAvailabilityReqCopyWithImpl(this as TmdbAvailabilityReq, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is TmdbAvailabilityReq&&(identical(other.tmdbId, tmdbId) || other.tmdbId == tmdbId)&&(identical(other.mediaType, mediaType) || other.mediaType == mediaType)&&(identical(other.imdbId, imdbId) || other.imdbId == imdbId)); +} + + +@override +int get hashCode => Object.hash(runtimeType,tmdbId,mediaType,imdbId); + +@override +String toString() { + return 'TmdbAvailabilityReq(tmdbId: $tmdbId, mediaType: $mediaType, imdbId: $imdbId)'; +} + + +} + +/// @nodoc +abstract mixin class $TmdbAvailabilityReqCopyWith<$Res> { + factory $TmdbAvailabilityReqCopyWith(TmdbAvailabilityReq value, $Res Function(TmdbAvailabilityReq) _then) = _$TmdbAvailabilityReqCopyWithImpl; +@useResult +$Res call({ + String tmdbId, String mediaType, String? imdbId +}); + + + + +} +/// @nodoc +class _$TmdbAvailabilityReqCopyWithImpl<$Res> + implements $TmdbAvailabilityReqCopyWith<$Res> { + _$TmdbAvailabilityReqCopyWithImpl(this._self, this._then); + + final TmdbAvailabilityReq _self; + final $Res Function(TmdbAvailabilityReq) _then; + +/// Create a copy of TmdbAvailabilityReq +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? tmdbId = null,Object? mediaType = null,Object? imdbId = freezed,}) { + return _then(_self.copyWith( +tmdbId: null == tmdbId ? _self.tmdbId : tmdbId // ignore: cast_nullable_to_non_nullable +as String,mediaType: null == mediaType ? _self.mediaType : mediaType // ignore: cast_nullable_to_non_nullable +as String,imdbId: freezed == imdbId ? _self.imdbId : imdbId // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [TmdbAvailabilityReq]. +extension TmdbAvailabilityReqPatterns on TmdbAvailabilityReq { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _TmdbAvailabilityReq value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _TmdbAvailabilityReq() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _TmdbAvailabilityReq value) $default,){ +final _that = this; +switch (_that) { +case _TmdbAvailabilityReq(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _TmdbAvailabilityReq value)? $default,){ +final _that = this; +switch (_that) { +case _TmdbAvailabilityReq() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String tmdbId, String mediaType, String? imdbId)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _TmdbAvailabilityReq() when $default != null: +return $default(_that.tmdbId,_that.mediaType,_that.imdbId);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String tmdbId, String mediaType, String? imdbId) $default,) {final _that = this; +switch (_that) { +case _TmdbAvailabilityReq(): +return $default(_that.tmdbId,_that.mediaType,_that.imdbId);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String tmdbId, String mediaType, String? imdbId)? $default,) {final _that = this; +switch (_that) { +case _TmdbAvailabilityReq() when $default != null: +return $default(_that.tmdbId,_that.mediaType,_that.imdbId);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _TmdbAvailabilityReq implements TmdbAvailabilityReq { + const _TmdbAvailabilityReq({required this.tmdbId, required this.mediaType, this.imdbId}); + + +@override final String tmdbId; +@override final String mediaType; +/// imdb id 兜底(如 `tt0137523`),仅对 movie 生效;提供后反查口径为 +/// `Tmdb.x,Imdb.y`,提升只刮削了 imdb 的影片命中率(见 R1)。 +@override final String? imdbId; + +/// Create a copy of TmdbAvailabilityReq +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$TmdbAvailabilityReqCopyWith<_TmdbAvailabilityReq> get copyWith => __$TmdbAvailabilityReqCopyWithImpl<_TmdbAvailabilityReq>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _TmdbAvailabilityReq&&(identical(other.tmdbId, tmdbId) || other.tmdbId == tmdbId)&&(identical(other.mediaType, mediaType) || other.mediaType == mediaType)&&(identical(other.imdbId, imdbId) || other.imdbId == imdbId)); +} + + +@override +int get hashCode => Object.hash(runtimeType,tmdbId,mediaType,imdbId); + +@override +String toString() { + return 'TmdbAvailabilityReq(tmdbId: $tmdbId, mediaType: $mediaType, imdbId: $imdbId)'; +} + + +} + +/// @nodoc +abstract mixin class _$TmdbAvailabilityReqCopyWith<$Res> implements $TmdbAvailabilityReqCopyWith<$Res> { + factory _$TmdbAvailabilityReqCopyWith(_TmdbAvailabilityReq value, $Res Function(_TmdbAvailabilityReq) _then) = __$TmdbAvailabilityReqCopyWithImpl; +@override @useResult +$Res call({ + String tmdbId, String mediaType, String? imdbId +}); + + + + +} +/// @nodoc +class __$TmdbAvailabilityReqCopyWithImpl<$Res> + implements _$TmdbAvailabilityReqCopyWith<$Res> { + __$TmdbAvailabilityReqCopyWithImpl(this._self, this._then); + + final _TmdbAvailabilityReq _self; + final $Res Function(_TmdbAvailabilityReq) _then; + +/// Create a copy of TmdbAvailabilityReq +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? tmdbId = null,Object? mediaType = null,Object? imdbId = freezed,}) { + return _then(_TmdbAvailabilityReq( +tmdbId: null == tmdbId ? _self.tmdbId : tmdbId // ignore: cast_nullable_to_non_nullable +as String,mediaType: null == mediaType ? _self.mediaType : mediaType // ignore: cast_nullable_to_non_nullable +as String,imdbId: freezed == imdbId ? _self.imdbId : imdbId // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + + +} + +/// @nodoc +mixin _$TmdbLibraryHit { + + String get serverId; String get serverName; String get itemId; String get itemType; String get name; int? get productionYear;/// 该服务器上此条目的续播位置(ticks,1 tick = 100ns);用于「有进度优先」 +/// 选主命中(见 Decision 11)与 CTA「继续播放」文案。无 UserData / 未观看为 +/// null 或 0。随可用性反查的同一次 GET 折叠取回(Fields 含 UserData),不额外 +/// 发请求。 + int? get playbackPositionTicks;/// 该条目总时长(ticks),用于 CTA 进度展示与「看到 mm:ss」。 + int? get runTimeTicks; +/// Create a copy of TmdbLibraryHit +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$TmdbLibraryHitCopyWith get copyWith => _$TmdbLibraryHitCopyWithImpl(this as TmdbLibraryHit, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is TmdbLibraryHit&&(identical(other.serverId, serverId) || other.serverId == serverId)&&(identical(other.serverName, serverName) || other.serverName == serverName)&&(identical(other.itemId, itemId) || other.itemId == itemId)&&(identical(other.itemType, itemType) || other.itemType == itemType)&&(identical(other.name, name) || other.name == name)&&(identical(other.productionYear, productionYear) || other.productionYear == productionYear)&&(identical(other.playbackPositionTicks, playbackPositionTicks) || other.playbackPositionTicks == playbackPositionTicks)&&(identical(other.runTimeTicks, runTimeTicks) || other.runTimeTicks == runTimeTicks)); +} + + +@override +int get hashCode => Object.hash(runtimeType,serverId,serverName,itemId,itemType,name,productionYear,playbackPositionTicks,runTimeTicks); + +@override +String toString() { + return 'TmdbLibraryHit(serverId: $serverId, serverName: $serverName, itemId: $itemId, itemType: $itemType, name: $name, productionYear: $productionYear, playbackPositionTicks: $playbackPositionTicks, runTimeTicks: $runTimeTicks)'; +} + + +} + +/// @nodoc +abstract mixin class $TmdbLibraryHitCopyWith<$Res> { + factory $TmdbLibraryHitCopyWith(TmdbLibraryHit value, $Res Function(TmdbLibraryHit) _then) = _$TmdbLibraryHitCopyWithImpl; +@useResult +$Res call({ + String serverId, String serverName, String itemId, String itemType, String name, int? productionYear, int? playbackPositionTicks, int? runTimeTicks +}); + + + + +} +/// @nodoc +class _$TmdbLibraryHitCopyWithImpl<$Res> + implements $TmdbLibraryHitCopyWith<$Res> { + _$TmdbLibraryHitCopyWithImpl(this._self, this._then); + + final TmdbLibraryHit _self; + final $Res Function(TmdbLibraryHit) _then; + +/// Create a copy of TmdbLibraryHit +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? serverId = null,Object? serverName = null,Object? itemId = null,Object? itemType = null,Object? name = null,Object? productionYear = freezed,Object? playbackPositionTicks = freezed,Object? runTimeTicks = freezed,}) { + return _then(_self.copyWith( +serverId: null == serverId ? _self.serverId : serverId // ignore: cast_nullable_to_non_nullable +as String,serverName: null == serverName ? _self.serverName : serverName // ignore: cast_nullable_to_non_nullable +as String,itemId: null == itemId ? _self.itemId : itemId // ignore: cast_nullable_to_non_nullable +as String,itemType: null == itemType ? _self.itemType : itemType // ignore: cast_nullable_to_non_nullable +as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,productionYear: freezed == productionYear ? _self.productionYear : productionYear // ignore: cast_nullable_to_non_nullable +as int?,playbackPositionTicks: freezed == playbackPositionTicks ? _self.playbackPositionTicks : playbackPositionTicks // ignore: cast_nullable_to_non_nullable +as int?,runTimeTicks: freezed == runTimeTicks ? _self.runTimeTicks : runTimeTicks // ignore: cast_nullable_to_non_nullable +as int?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [TmdbLibraryHit]. +extension TmdbLibraryHitPatterns on TmdbLibraryHit { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _TmdbLibraryHit value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _TmdbLibraryHit() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _TmdbLibraryHit value) $default,){ +final _that = this; +switch (_that) { +case _TmdbLibraryHit(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _TmdbLibraryHit value)? $default,){ +final _that = this; +switch (_that) { +case _TmdbLibraryHit() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String serverId, String serverName, String itemId, String itemType, String name, int? productionYear, int? playbackPositionTicks, int? runTimeTicks)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _TmdbLibraryHit() when $default != null: +return $default(_that.serverId,_that.serverName,_that.itemId,_that.itemType,_that.name,_that.productionYear,_that.playbackPositionTicks,_that.runTimeTicks);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String serverId, String serverName, String itemId, String itemType, String name, int? productionYear, int? playbackPositionTicks, int? runTimeTicks) $default,) {final _that = this; +switch (_that) { +case _TmdbLibraryHit(): +return $default(_that.serverId,_that.serverName,_that.itemId,_that.itemType,_that.name,_that.productionYear,_that.playbackPositionTicks,_that.runTimeTicks);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String serverId, String serverName, String itemId, String itemType, String name, int? productionYear, int? playbackPositionTicks, int? runTimeTicks)? $default,) {final _that = this; +switch (_that) { +case _TmdbLibraryHit() when $default != null: +return $default(_that.serverId,_that.serverName,_that.itemId,_that.itemType,_that.name,_that.productionYear,_that.playbackPositionTicks,_that.runTimeTicks);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _TmdbLibraryHit implements TmdbLibraryHit { + const _TmdbLibraryHit({required this.serverId, required this.serverName, required this.itemId, required this.itemType, required this.name, this.productionYear, this.playbackPositionTicks, this.runTimeTicks}); + + +@override final String serverId; +@override final String serverName; +@override final String itemId; +@override final String itemType; +@override final String name; +@override final int? productionYear; +/// 该服务器上此条目的续播位置(ticks,1 tick = 100ns);用于「有进度优先」 +/// 选主命中(见 Decision 11)与 CTA「继续播放」文案。无 UserData / 未观看为 +/// null 或 0。随可用性反查的同一次 GET 折叠取回(Fields 含 UserData),不额外 +/// 发请求。 +@override final int? playbackPositionTicks; +/// 该条目总时长(ticks),用于 CTA 进度展示与「看到 mm:ss」。 +@override final int? runTimeTicks; + +/// Create a copy of TmdbLibraryHit +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$TmdbLibraryHitCopyWith<_TmdbLibraryHit> get copyWith => __$TmdbLibraryHitCopyWithImpl<_TmdbLibraryHit>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _TmdbLibraryHit&&(identical(other.serverId, serverId) || other.serverId == serverId)&&(identical(other.serverName, serverName) || other.serverName == serverName)&&(identical(other.itemId, itemId) || other.itemId == itemId)&&(identical(other.itemType, itemType) || other.itemType == itemType)&&(identical(other.name, name) || other.name == name)&&(identical(other.productionYear, productionYear) || other.productionYear == productionYear)&&(identical(other.playbackPositionTicks, playbackPositionTicks) || other.playbackPositionTicks == playbackPositionTicks)&&(identical(other.runTimeTicks, runTimeTicks) || other.runTimeTicks == runTimeTicks)); +} + + +@override +int get hashCode => Object.hash(runtimeType,serverId,serverName,itemId,itemType,name,productionYear,playbackPositionTicks,runTimeTicks); + +@override +String toString() { + return 'TmdbLibraryHit(serverId: $serverId, serverName: $serverName, itemId: $itemId, itemType: $itemType, name: $name, productionYear: $productionYear, playbackPositionTicks: $playbackPositionTicks, runTimeTicks: $runTimeTicks)'; +} + + +} + +/// @nodoc +abstract mixin class _$TmdbLibraryHitCopyWith<$Res> implements $TmdbLibraryHitCopyWith<$Res> { + factory _$TmdbLibraryHitCopyWith(_TmdbLibraryHit value, $Res Function(_TmdbLibraryHit) _then) = __$TmdbLibraryHitCopyWithImpl; +@override @useResult +$Res call({ + String serverId, String serverName, String itemId, String itemType, String name, int? productionYear, int? playbackPositionTicks, int? runTimeTicks +}); + + + + +} +/// @nodoc +class __$TmdbLibraryHitCopyWithImpl<$Res> + implements _$TmdbLibraryHitCopyWith<$Res> { + __$TmdbLibraryHitCopyWithImpl(this._self, this._then); + + final _TmdbLibraryHit _self; + final $Res Function(_TmdbLibraryHit) _then; + +/// Create a copy of TmdbLibraryHit +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? serverId = null,Object? serverName = null,Object? itemId = null,Object? itemType = null,Object? name = null,Object? productionYear = freezed,Object? playbackPositionTicks = freezed,Object? runTimeTicks = freezed,}) { + return _then(_TmdbLibraryHit( +serverId: null == serverId ? _self.serverId : serverId // ignore: cast_nullable_to_non_nullable +as String,serverName: null == serverName ? _self.serverName : serverName // ignore: cast_nullable_to_non_nullable +as String,itemId: null == itemId ? _self.itemId : itemId // ignore: cast_nullable_to_non_nullable +as String,itemType: null == itemType ? _self.itemType : itemType // ignore: cast_nullable_to_non_nullable +as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,productionYear: freezed == productionYear ? _self.productionYear : productionYear // ignore: cast_nullable_to_non_nullable +as int?,playbackPositionTicks: freezed == playbackPositionTicks ? _self.playbackPositionTicks : playbackPositionTicks // ignore: cast_nullable_to_non_nullable +as int?,runTimeTicks: freezed == runTimeTicks ? _self.runTimeTicks : runTimeTicks // ignore: cast_nullable_to_non_nullable +as int?, + )); +} + + +} + +/// @nodoc +mixin _$LibraryItemsReq { + + String? get parentId; String? get includeItemTypes; List? get genreIds; String? get searchTerm; String? get anyProviderIdEquals; String? get fields; String? get enableImageTypes; bool? get groupProgramsBySeries; int? get imageTypeLimit; int? get startIndex; int? get limit; bool? get recursive; String? get sortBy; SortOrder? get sortOrder; +/// Create a copy of LibraryItemsReq +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$LibraryItemsReqCopyWith get copyWith => _$LibraryItemsReqCopyWithImpl(this as LibraryItemsReq, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is LibraryItemsReq&&(identical(other.parentId, parentId) || other.parentId == parentId)&&(identical(other.includeItemTypes, includeItemTypes) || other.includeItemTypes == includeItemTypes)&&const DeepCollectionEquality().equals(other.genreIds, genreIds)&&(identical(other.searchTerm, searchTerm) || other.searchTerm == searchTerm)&&(identical(other.anyProviderIdEquals, anyProviderIdEquals) || other.anyProviderIdEquals == anyProviderIdEquals)&&(identical(other.fields, fields) || other.fields == fields)&&(identical(other.enableImageTypes, enableImageTypes) || other.enableImageTypes == enableImageTypes)&&(identical(other.groupProgramsBySeries, groupProgramsBySeries) || other.groupProgramsBySeries == groupProgramsBySeries)&&(identical(other.imageTypeLimit, imageTypeLimit) || other.imageTypeLimit == imageTypeLimit)&&(identical(other.startIndex, startIndex) || other.startIndex == startIndex)&&(identical(other.limit, limit) || other.limit == limit)&&(identical(other.recursive, recursive) || other.recursive == recursive)&&(identical(other.sortBy, sortBy) || other.sortBy == sortBy)&&(identical(other.sortOrder, sortOrder) || other.sortOrder == sortOrder)); +} + + +@override +int get hashCode => Object.hash(runtimeType,parentId,includeItemTypes,const DeepCollectionEquality().hash(genreIds),searchTerm,anyProviderIdEquals,fields,enableImageTypes,groupProgramsBySeries,imageTypeLimit,startIndex,limit,recursive,sortBy,sortOrder); + +@override +String toString() { + return 'LibraryItemsReq(parentId: $parentId, includeItemTypes: $includeItemTypes, genreIds: $genreIds, searchTerm: $searchTerm, anyProviderIdEquals: $anyProviderIdEquals, fields: $fields, enableImageTypes: $enableImageTypes, groupProgramsBySeries: $groupProgramsBySeries, imageTypeLimit: $imageTypeLimit, startIndex: $startIndex, limit: $limit, recursive: $recursive, sortBy: $sortBy, sortOrder: $sortOrder)'; +} + + +} + +/// @nodoc +abstract mixin class $LibraryItemsReqCopyWith<$Res> { + factory $LibraryItemsReqCopyWith(LibraryItemsReq value, $Res Function(LibraryItemsReq) _then) = _$LibraryItemsReqCopyWithImpl; +@useResult +$Res call({ + String? parentId, String? includeItemTypes, List? genreIds, String? searchTerm, String? anyProviderIdEquals, String? fields, String? enableImageTypes, bool? groupProgramsBySeries, int? imageTypeLimit, int? startIndex, int? limit, bool? recursive, String? sortBy, SortOrder? sortOrder +}); + + + + +} +/// @nodoc +class _$LibraryItemsReqCopyWithImpl<$Res> + implements $LibraryItemsReqCopyWith<$Res> { + _$LibraryItemsReqCopyWithImpl(this._self, this._then); + + final LibraryItemsReq _self; + final $Res Function(LibraryItemsReq) _then; + +/// Create a copy of LibraryItemsReq +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? parentId = freezed,Object? includeItemTypes = freezed,Object? genreIds = freezed,Object? searchTerm = freezed,Object? anyProviderIdEquals = freezed,Object? fields = freezed,Object? enableImageTypes = freezed,Object? groupProgramsBySeries = freezed,Object? imageTypeLimit = freezed,Object? startIndex = freezed,Object? limit = freezed,Object? recursive = freezed,Object? sortBy = freezed,Object? sortOrder = freezed,}) { + return _then(_self.copyWith( +parentId: freezed == parentId ? _self.parentId : parentId // ignore: cast_nullable_to_non_nullable +as String?,includeItemTypes: freezed == includeItemTypes ? _self.includeItemTypes : includeItemTypes // ignore: cast_nullable_to_non_nullable +as String?,genreIds: freezed == genreIds ? _self.genreIds : genreIds // ignore: cast_nullable_to_non_nullable +as List?,searchTerm: freezed == searchTerm ? _self.searchTerm : searchTerm // ignore: cast_nullable_to_non_nullable +as String?,anyProviderIdEquals: freezed == anyProviderIdEquals ? _self.anyProviderIdEquals : anyProviderIdEquals // ignore: cast_nullable_to_non_nullable +as String?,fields: freezed == fields ? _self.fields : fields // ignore: cast_nullable_to_non_nullable +as String?,enableImageTypes: freezed == enableImageTypes ? _self.enableImageTypes : enableImageTypes // ignore: cast_nullable_to_non_nullable +as String?,groupProgramsBySeries: freezed == groupProgramsBySeries ? _self.groupProgramsBySeries : groupProgramsBySeries // ignore: cast_nullable_to_non_nullable +as bool?,imageTypeLimit: freezed == imageTypeLimit ? _self.imageTypeLimit : imageTypeLimit // ignore: cast_nullable_to_non_nullable +as int?,startIndex: freezed == startIndex ? _self.startIndex : startIndex // ignore: cast_nullable_to_non_nullable +as int?,limit: freezed == limit ? _self.limit : limit // ignore: cast_nullable_to_non_nullable +as int?,recursive: freezed == recursive ? _self.recursive : recursive // ignore: cast_nullable_to_non_nullable +as bool?,sortBy: freezed == sortBy ? _self.sortBy : sortBy // ignore: cast_nullable_to_non_nullable +as String?,sortOrder: freezed == sortOrder ? _self.sortOrder : sortOrder // ignore: cast_nullable_to_non_nullable +as SortOrder?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [LibraryItemsReq]. +extension LibraryItemsReqPatterns on LibraryItemsReq { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _LibraryItemsReq value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _LibraryItemsReq() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _LibraryItemsReq value) $default,){ +final _that = this; +switch (_that) { +case _LibraryItemsReq(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _LibraryItemsReq value)? $default,){ +final _that = this; +switch (_that) { +case _LibraryItemsReq() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String? parentId, String? includeItemTypes, List? genreIds, String? searchTerm, String? anyProviderIdEquals, String? fields, String? enableImageTypes, bool? groupProgramsBySeries, int? imageTypeLimit, int? startIndex, int? limit, bool? recursive, String? sortBy, SortOrder? sortOrder)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _LibraryItemsReq() when $default != null: +return $default(_that.parentId,_that.includeItemTypes,_that.genreIds,_that.searchTerm,_that.anyProviderIdEquals,_that.fields,_that.enableImageTypes,_that.groupProgramsBySeries,_that.imageTypeLimit,_that.startIndex,_that.limit,_that.recursive,_that.sortBy,_that.sortOrder);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String? parentId, String? includeItemTypes, List? genreIds, String? searchTerm, String? anyProviderIdEquals, String? fields, String? enableImageTypes, bool? groupProgramsBySeries, int? imageTypeLimit, int? startIndex, int? limit, bool? recursive, String? sortBy, SortOrder? sortOrder) $default,) {final _that = this; +switch (_that) { +case _LibraryItemsReq(): +return $default(_that.parentId,_that.includeItemTypes,_that.genreIds,_that.searchTerm,_that.anyProviderIdEquals,_that.fields,_that.enableImageTypes,_that.groupProgramsBySeries,_that.imageTypeLimit,_that.startIndex,_that.limit,_that.recursive,_that.sortBy,_that.sortOrder);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String? parentId, String? includeItemTypes, List? genreIds, String? searchTerm, String? anyProviderIdEquals, String? fields, String? enableImageTypes, bool? groupProgramsBySeries, int? imageTypeLimit, int? startIndex, int? limit, bool? recursive, String? sortBy, SortOrder? sortOrder)? $default,) {final _that = this; +switch (_that) { +case _LibraryItemsReq() when $default != null: +return $default(_that.parentId,_that.includeItemTypes,_that.genreIds,_that.searchTerm,_that.anyProviderIdEquals,_that.fields,_that.enableImageTypes,_that.groupProgramsBySeries,_that.imageTypeLimit,_that.startIndex,_that.limit,_that.recursive,_that.sortBy,_that.sortOrder);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _LibraryItemsReq implements LibraryItemsReq { + const _LibraryItemsReq({this.parentId, this.includeItemTypes, final List? genreIds, this.searchTerm, this.anyProviderIdEquals, this.fields, this.enableImageTypes, this.groupProgramsBySeries, this.imageTypeLimit, this.startIndex, this.limit, this.recursive, this.sortBy, this.sortOrder}): _genreIds = genreIds; + + +@override final String? parentId; +@override final String? includeItemTypes; + final List? _genreIds; +@override List? get genreIds { + final value = _genreIds; + if (value == null) return null; + if (_genreIds is EqualUnmodifiableListView) return _genreIds; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(value); +} + +@override final String? searchTerm; +@override final String? anyProviderIdEquals; +@override final String? fields; +@override final String? enableImageTypes; +@override final bool? groupProgramsBySeries; +@override final int? imageTypeLimit; +@override final int? startIndex; +@override final int? limit; +@override final bool? recursive; +@override final String? sortBy; +@override final SortOrder? sortOrder; + +/// Create a copy of LibraryItemsReq +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$LibraryItemsReqCopyWith<_LibraryItemsReq> get copyWith => __$LibraryItemsReqCopyWithImpl<_LibraryItemsReq>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _LibraryItemsReq&&(identical(other.parentId, parentId) || other.parentId == parentId)&&(identical(other.includeItemTypes, includeItemTypes) || other.includeItemTypes == includeItemTypes)&&const DeepCollectionEquality().equals(other._genreIds, _genreIds)&&(identical(other.searchTerm, searchTerm) || other.searchTerm == searchTerm)&&(identical(other.anyProviderIdEquals, anyProviderIdEquals) || other.anyProviderIdEquals == anyProviderIdEquals)&&(identical(other.fields, fields) || other.fields == fields)&&(identical(other.enableImageTypes, enableImageTypes) || other.enableImageTypes == enableImageTypes)&&(identical(other.groupProgramsBySeries, groupProgramsBySeries) || other.groupProgramsBySeries == groupProgramsBySeries)&&(identical(other.imageTypeLimit, imageTypeLimit) || other.imageTypeLimit == imageTypeLimit)&&(identical(other.startIndex, startIndex) || other.startIndex == startIndex)&&(identical(other.limit, limit) || other.limit == limit)&&(identical(other.recursive, recursive) || other.recursive == recursive)&&(identical(other.sortBy, sortBy) || other.sortBy == sortBy)&&(identical(other.sortOrder, sortOrder) || other.sortOrder == sortOrder)); +} + + +@override +int get hashCode => Object.hash(runtimeType,parentId,includeItemTypes,const DeepCollectionEquality().hash(_genreIds),searchTerm,anyProviderIdEquals,fields,enableImageTypes,groupProgramsBySeries,imageTypeLimit,startIndex,limit,recursive,sortBy,sortOrder); + +@override +String toString() { + return 'LibraryItemsReq(parentId: $parentId, includeItemTypes: $includeItemTypes, genreIds: $genreIds, searchTerm: $searchTerm, anyProviderIdEquals: $anyProviderIdEquals, fields: $fields, enableImageTypes: $enableImageTypes, groupProgramsBySeries: $groupProgramsBySeries, imageTypeLimit: $imageTypeLimit, startIndex: $startIndex, limit: $limit, recursive: $recursive, sortBy: $sortBy, sortOrder: $sortOrder)'; +} + + +} + +/// @nodoc +abstract mixin class _$LibraryItemsReqCopyWith<$Res> implements $LibraryItemsReqCopyWith<$Res> { + factory _$LibraryItemsReqCopyWith(_LibraryItemsReq value, $Res Function(_LibraryItemsReq) _then) = __$LibraryItemsReqCopyWithImpl; +@override @useResult +$Res call({ + String? parentId, String? includeItemTypes, List? genreIds, String? searchTerm, String? anyProviderIdEquals, String? fields, String? enableImageTypes, bool? groupProgramsBySeries, int? imageTypeLimit, int? startIndex, int? limit, bool? recursive, String? sortBy, SortOrder? sortOrder +}); + + + + +} +/// @nodoc +class __$LibraryItemsReqCopyWithImpl<$Res> + implements _$LibraryItemsReqCopyWith<$Res> { + __$LibraryItemsReqCopyWithImpl(this._self, this._then); + + final _LibraryItemsReq _self; + final $Res Function(_LibraryItemsReq) _then; + +/// Create a copy of LibraryItemsReq +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? parentId = freezed,Object? includeItemTypes = freezed,Object? genreIds = freezed,Object? searchTerm = freezed,Object? anyProviderIdEquals = freezed,Object? fields = freezed,Object? enableImageTypes = freezed,Object? groupProgramsBySeries = freezed,Object? imageTypeLimit = freezed,Object? startIndex = freezed,Object? limit = freezed,Object? recursive = freezed,Object? sortBy = freezed,Object? sortOrder = freezed,}) { + return _then(_LibraryItemsReq( +parentId: freezed == parentId ? _self.parentId : parentId // ignore: cast_nullable_to_non_nullable +as String?,includeItemTypes: freezed == includeItemTypes ? _self.includeItemTypes : includeItemTypes // ignore: cast_nullable_to_non_nullable +as String?,genreIds: freezed == genreIds ? _self._genreIds : genreIds // ignore: cast_nullable_to_non_nullable +as List?,searchTerm: freezed == searchTerm ? _self.searchTerm : searchTerm // ignore: cast_nullable_to_non_nullable +as String?,anyProviderIdEquals: freezed == anyProviderIdEquals ? _self.anyProviderIdEquals : anyProviderIdEquals // ignore: cast_nullable_to_non_nullable +as String?,fields: freezed == fields ? _self.fields : fields // ignore: cast_nullable_to_non_nullable +as String?,enableImageTypes: freezed == enableImageTypes ? _self.enableImageTypes : enableImageTypes // ignore: cast_nullable_to_non_nullable +as String?,groupProgramsBySeries: freezed == groupProgramsBySeries ? _self.groupProgramsBySeries : groupProgramsBySeries // ignore: cast_nullable_to_non_nullable +as bool?,imageTypeLimit: freezed == imageTypeLimit ? _self.imageTypeLimit : imageTypeLimit // ignore: cast_nullable_to_non_nullable +as int?,startIndex: freezed == startIndex ? _self.startIndex : startIndex // ignore: cast_nullable_to_non_nullable +as int?,limit: freezed == limit ? _self.limit : limit // ignore: cast_nullable_to_non_nullable +as int?,recursive: freezed == recursive ? _self.recursive : recursive // ignore: cast_nullable_to_non_nullable +as bool?,sortBy: freezed == sortBy ? _self.sortBy : sortBy // ignore: cast_nullable_to_non_nullable +as String?,sortOrder: freezed == sortOrder ? _self.sortOrder : sortOrder // ignore: cast_nullable_to_non_nullable +as SortOrder?, + )); +} + + +} + +/// @nodoc +mixin _$LibraryItemsRes { + + String get parentId; List get items; +/// Create a copy of LibraryItemsRes +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$LibraryItemsResCopyWith get copyWith => _$LibraryItemsResCopyWithImpl(this as LibraryItemsRes, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is LibraryItemsRes&&(identical(other.parentId, parentId) || other.parentId == parentId)&&const DeepCollectionEquality().equals(other.items, items)); +} + + +@override +int get hashCode => Object.hash(runtimeType,parentId,const DeepCollectionEquality().hash(items)); + +@override +String toString() { + return 'LibraryItemsRes(parentId: $parentId, items: $items)'; +} + + +} + +/// @nodoc +abstract mixin class $LibraryItemsResCopyWith<$Res> { + factory $LibraryItemsResCopyWith(LibraryItemsRes value, $Res Function(LibraryItemsRes) _then) = _$LibraryItemsResCopyWithImpl; +@useResult +$Res call({ + String parentId, List items +}); + + + + +} +/// @nodoc +class _$LibraryItemsResCopyWithImpl<$Res> + implements $LibraryItemsResCopyWith<$Res> { + _$LibraryItemsResCopyWithImpl(this._self, this._then); + + final LibraryItemsRes _self; + final $Res Function(LibraryItemsRes) _then; + +/// Create a copy of LibraryItemsRes +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? parentId = null,Object? items = null,}) { + return _then(_self.copyWith( +parentId: null == parentId ? _self.parentId : parentId // ignore: cast_nullable_to_non_nullable +as String,items: null == items ? _self.items : items // ignore: cast_nullable_to_non_nullable +as List, + )); +} + +} + + +/// Adds pattern-matching-related methods to [LibraryItemsRes]. +extension LibraryItemsResPatterns on LibraryItemsRes { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _LibraryItemsRes value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _LibraryItemsRes() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _LibraryItemsRes value) $default,){ +final _that = this; +switch (_that) { +case _LibraryItemsRes(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _LibraryItemsRes value)? $default,){ +final _that = this; +switch (_that) { +case _LibraryItemsRes() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String parentId, List items)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _LibraryItemsRes() when $default != null: +return $default(_that.parentId,_that.items);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String parentId, List items) $default,) {final _that = this; +switch (_that) { +case _LibraryItemsRes(): +return $default(_that.parentId,_that.items);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String parentId, List items)? $default,) {final _that = this; +switch (_that) { +case _LibraryItemsRes() when $default != null: +return $default(_that.parentId,_that.items);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _LibraryItemsRes implements LibraryItemsRes { + const _LibraryItemsRes({required this.parentId, required final List items}): _items = items; + + +@override final String parentId; + final List _items; +@override List get items { + if (_items is EqualUnmodifiableListView) return _items; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_items); +} + + +/// Create a copy of LibraryItemsRes +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$LibraryItemsResCopyWith<_LibraryItemsRes> get copyWith => __$LibraryItemsResCopyWithImpl<_LibraryItemsRes>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _LibraryItemsRes&&(identical(other.parentId, parentId) || other.parentId == parentId)&&const DeepCollectionEquality().equals(other._items, _items)); +} + + +@override +int get hashCode => Object.hash(runtimeType,parentId,const DeepCollectionEquality().hash(_items)); + +@override +String toString() { + return 'LibraryItemsRes(parentId: $parentId, items: $items)'; +} + + +} + +/// @nodoc +abstract mixin class _$LibraryItemsResCopyWith<$Res> implements $LibraryItemsResCopyWith<$Res> { + factory _$LibraryItemsResCopyWith(_LibraryItemsRes value, $Res Function(_LibraryItemsRes) _then) = __$LibraryItemsResCopyWithImpl; +@override @useResult +$Res call({ + String parentId, List items +}); + + + + +} +/// @nodoc +class __$LibraryItemsResCopyWithImpl<$Res> + implements _$LibraryItemsResCopyWith<$Res> { + __$LibraryItemsResCopyWithImpl(this._self, this._then); + + final _LibraryItemsRes _self; + final $Res Function(_LibraryItemsRes) _then; + +/// Create a copy of LibraryItemsRes +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? parentId = null,Object? items = null,}) { + return _then(_LibraryItemsRes( +parentId: null == parentId ? _self.parentId : parentId // ignore: cast_nullable_to_non_nullable +as String,items: null == items ? _self._items : items // ignore: cast_nullable_to_non_nullable +as List, + )); +} + + +} + + +/// @nodoc +mixin _$ItemCountsRes { + +@JsonKey(name: 'MovieCount', fromJson: _intOrZero) int get movieCount;@JsonKey(name: 'EpisodeCount', fromJson: _intOrZero) int get episodeCount; +/// Create a copy of ItemCountsRes +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$ItemCountsResCopyWith get copyWith => _$ItemCountsResCopyWithImpl(this as ItemCountsRes, _$identity); + + /// Serializes this ItemCountsRes to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is ItemCountsRes&&(identical(other.movieCount, movieCount) || other.movieCount == movieCount)&&(identical(other.episodeCount, episodeCount) || other.episodeCount == episodeCount)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,movieCount,episodeCount); + +@override +String toString() { + return 'ItemCountsRes(movieCount: $movieCount, episodeCount: $episodeCount)'; +} + + +} + +/// @nodoc +abstract mixin class $ItemCountsResCopyWith<$Res> { + factory $ItemCountsResCopyWith(ItemCountsRes value, $Res Function(ItemCountsRes) _then) = _$ItemCountsResCopyWithImpl; +@useResult +$Res call({ +@JsonKey(name: 'MovieCount', fromJson: _intOrZero) int movieCount,@JsonKey(name: 'EpisodeCount', fromJson: _intOrZero) int episodeCount +}); + + + + +} +/// @nodoc +class _$ItemCountsResCopyWithImpl<$Res> + implements $ItemCountsResCopyWith<$Res> { + _$ItemCountsResCopyWithImpl(this._self, this._then); + + final ItemCountsRes _self; + final $Res Function(ItemCountsRes) _then; + +/// Create a copy of ItemCountsRes +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? movieCount = null,Object? episodeCount = null,}) { + return _then(_self.copyWith( +movieCount: null == movieCount ? _self.movieCount : movieCount // ignore: cast_nullable_to_non_nullable +as int,episodeCount: null == episodeCount ? _self.episodeCount : episodeCount // ignore: cast_nullable_to_non_nullable +as int, + )); +} + +} + + +/// Adds pattern-matching-related methods to [ItemCountsRes]. +extension ItemCountsResPatterns on ItemCountsRes { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _ItemCountsRes value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _ItemCountsRes() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _ItemCountsRes value) $default,){ +final _that = this; +switch (_that) { +case _ItemCountsRes(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _ItemCountsRes value)? $default,){ +final _that = this; +switch (_that) { +case _ItemCountsRes() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function(@JsonKey(name: 'MovieCount', fromJson: _intOrZero) int movieCount, @JsonKey(name: 'EpisodeCount', fromJson: _intOrZero) int episodeCount)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _ItemCountsRes() when $default != null: +return $default(_that.movieCount,_that.episodeCount);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function(@JsonKey(name: 'MovieCount', fromJson: _intOrZero) int movieCount, @JsonKey(name: 'EpisodeCount', fromJson: _intOrZero) int episodeCount) $default,) {final _that = this; +switch (_that) { +case _ItemCountsRes(): +return $default(_that.movieCount,_that.episodeCount);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function(@JsonKey(name: 'MovieCount', fromJson: _intOrZero) int movieCount, @JsonKey(name: 'EpisodeCount', fromJson: _intOrZero) int episodeCount)? $default,) {final _that = this; +switch (_that) { +case _ItemCountsRes() when $default != null: +return $default(_that.movieCount,_that.episodeCount);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _ItemCountsRes implements ItemCountsRes { + const _ItemCountsRes({@JsonKey(name: 'MovieCount', fromJson: _intOrZero) required this.movieCount, @JsonKey(name: 'EpisodeCount', fromJson: _intOrZero) required this.episodeCount}); + factory _ItemCountsRes.fromJson(Map json) => _$ItemCountsResFromJson(json); + +@override@JsonKey(name: 'MovieCount', fromJson: _intOrZero) final int movieCount; +@override@JsonKey(name: 'EpisodeCount', fromJson: _intOrZero) final int episodeCount; + +/// Create a copy of ItemCountsRes +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$ItemCountsResCopyWith<_ItemCountsRes> get copyWith => __$ItemCountsResCopyWithImpl<_ItemCountsRes>(this, _$identity); + +@override +Map toJson() { + return _$ItemCountsResToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _ItemCountsRes&&(identical(other.movieCount, movieCount) || other.movieCount == movieCount)&&(identical(other.episodeCount, episodeCount) || other.episodeCount == episodeCount)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,movieCount,episodeCount); + +@override +String toString() { + return 'ItemCountsRes(movieCount: $movieCount, episodeCount: $episodeCount)'; +} + + +} + +/// @nodoc +abstract mixin class _$ItemCountsResCopyWith<$Res> implements $ItemCountsResCopyWith<$Res> { + factory _$ItemCountsResCopyWith(_ItemCountsRes value, $Res Function(_ItemCountsRes) _then) = __$ItemCountsResCopyWithImpl; +@override @useResult +$Res call({ +@JsonKey(name: 'MovieCount', fromJson: _intOrZero) int movieCount,@JsonKey(name: 'EpisodeCount', fromJson: _intOrZero) int episodeCount +}); + + + + +} +/// @nodoc +class __$ItemCountsResCopyWithImpl<$Res> + implements _$ItemCountsResCopyWith<$Res> { + __$ItemCountsResCopyWithImpl(this._self, this._then); + + final _ItemCountsRes _self; + final $Res Function(_ItemCountsRes) _then; + +/// Create a copy of ItemCountsRes +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? movieCount = null,Object? episodeCount = null,}) { + return _then(_ItemCountsRes( +movieCount: null == movieCount ? _self.movieCount : movieCount // ignore: cast_nullable_to_non_nullable +as int,episodeCount: null == episodeCount ? _self.episodeCount : episodeCount // ignore: cast_nullable_to_non_nullable +as int, + )); +} + + +} + +/// @nodoc +mixin _$GlobalSearchReq { + + String get keyword; String? get includeItemTypes; int? get limitPerServer; String? get serverId; +/// Create a copy of GlobalSearchReq +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$GlobalSearchReqCopyWith get copyWith => _$GlobalSearchReqCopyWithImpl(this as GlobalSearchReq, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is GlobalSearchReq&&(identical(other.keyword, keyword) || other.keyword == keyword)&&(identical(other.includeItemTypes, includeItemTypes) || other.includeItemTypes == includeItemTypes)&&(identical(other.limitPerServer, limitPerServer) || other.limitPerServer == limitPerServer)&&(identical(other.serverId, serverId) || other.serverId == serverId)); +} + + +@override +int get hashCode => Object.hash(runtimeType,keyword,includeItemTypes,limitPerServer,serverId); + +@override +String toString() { + return 'GlobalSearchReq(keyword: $keyword, includeItemTypes: $includeItemTypes, limitPerServer: $limitPerServer, serverId: $serverId)'; +} + + +} + +/// @nodoc +abstract mixin class $GlobalSearchReqCopyWith<$Res> { + factory $GlobalSearchReqCopyWith(GlobalSearchReq value, $Res Function(GlobalSearchReq) _then) = _$GlobalSearchReqCopyWithImpl; +@useResult +$Res call({ + String keyword, String? includeItemTypes, int? limitPerServer, String? serverId +}); + + + + +} +/// @nodoc +class _$GlobalSearchReqCopyWithImpl<$Res> + implements $GlobalSearchReqCopyWith<$Res> { + _$GlobalSearchReqCopyWithImpl(this._self, this._then); + + final GlobalSearchReq _self; + final $Res Function(GlobalSearchReq) _then; + +/// Create a copy of GlobalSearchReq +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? keyword = null,Object? includeItemTypes = freezed,Object? limitPerServer = freezed,Object? serverId = freezed,}) { + return _then(_self.copyWith( +keyword: null == keyword ? _self.keyword : keyword // ignore: cast_nullable_to_non_nullable +as String,includeItemTypes: freezed == includeItemTypes ? _self.includeItemTypes : includeItemTypes // ignore: cast_nullable_to_non_nullable +as String?,limitPerServer: freezed == limitPerServer ? _self.limitPerServer : limitPerServer // ignore: cast_nullable_to_non_nullable +as int?,serverId: freezed == serverId ? _self.serverId : serverId // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [GlobalSearchReq]. +extension GlobalSearchReqPatterns on GlobalSearchReq { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _GlobalSearchReq value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _GlobalSearchReq() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _GlobalSearchReq value) $default,){ +final _that = this; +switch (_that) { +case _GlobalSearchReq(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _GlobalSearchReq value)? $default,){ +final _that = this; +switch (_that) { +case _GlobalSearchReq() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String keyword, String? includeItemTypes, int? limitPerServer, String? serverId)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _GlobalSearchReq() when $default != null: +return $default(_that.keyword,_that.includeItemTypes,_that.limitPerServer,_that.serverId);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String keyword, String? includeItemTypes, int? limitPerServer, String? serverId) $default,) {final _that = this; +switch (_that) { +case _GlobalSearchReq(): +return $default(_that.keyword,_that.includeItemTypes,_that.limitPerServer,_that.serverId);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String keyword, String? includeItemTypes, int? limitPerServer, String? serverId)? $default,) {final _that = this; +switch (_that) { +case _GlobalSearchReq() when $default != null: +return $default(_that.keyword,_that.includeItemTypes,_that.limitPerServer,_that.serverId);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _GlobalSearchReq implements GlobalSearchReq { + const _GlobalSearchReq({required this.keyword, this.includeItemTypes, this.limitPerServer, this.serverId}); + + +@override final String keyword; +@override final String? includeItemTypes; +@override final int? limitPerServer; +@override final String? serverId; + +/// Create a copy of GlobalSearchReq +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$GlobalSearchReqCopyWith<_GlobalSearchReq> get copyWith => __$GlobalSearchReqCopyWithImpl<_GlobalSearchReq>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _GlobalSearchReq&&(identical(other.keyword, keyword) || other.keyword == keyword)&&(identical(other.includeItemTypes, includeItemTypes) || other.includeItemTypes == includeItemTypes)&&(identical(other.limitPerServer, limitPerServer) || other.limitPerServer == limitPerServer)&&(identical(other.serverId, serverId) || other.serverId == serverId)); +} + + +@override +int get hashCode => Object.hash(runtimeType,keyword,includeItemTypes,limitPerServer,serverId); + +@override +String toString() { + return 'GlobalSearchReq(keyword: $keyword, includeItemTypes: $includeItemTypes, limitPerServer: $limitPerServer, serverId: $serverId)'; +} + + +} + +/// @nodoc +abstract mixin class _$GlobalSearchReqCopyWith<$Res> implements $GlobalSearchReqCopyWith<$Res> { + factory _$GlobalSearchReqCopyWith(_GlobalSearchReq value, $Res Function(_GlobalSearchReq) _then) = __$GlobalSearchReqCopyWithImpl; +@override @useResult +$Res call({ + String keyword, String? includeItemTypes, int? limitPerServer, String? serverId +}); + + + + +} +/// @nodoc +class __$GlobalSearchReqCopyWithImpl<$Res> + implements _$GlobalSearchReqCopyWith<$Res> { + __$GlobalSearchReqCopyWithImpl(this._self, this._then); + + final _GlobalSearchReq _self; + final $Res Function(_GlobalSearchReq) _then; + +/// Create a copy of GlobalSearchReq +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? keyword = null,Object? includeItemTypes = freezed,Object? limitPerServer = freezed,Object? serverId = freezed,}) { + return _then(_GlobalSearchReq( +keyword: null == keyword ? _self.keyword : keyword // ignore: cast_nullable_to_non_nullable +as String,includeItemTypes: freezed == includeItemTypes ? _self.includeItemTypes : includeItemTypes // ignore: cast_nullable_to_non_nullable +as String?,limitPerServer: freezed == limitPerServer ? _self.limitPerServer : limitPerServer // ignore: cast_nullable_to_non_nullable +as int?,serverId: freezed == serverId ? _self.serverId : serverId // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + + +} + +/// @nodoc +mixin _$GlobalSearchServerResult { + + String get serverId; String get serverName; String get serverUrl; String get token; String get userId; List get items; +/// Create a copy of GlobalSearchServerResult +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$GlobalSearchServerResultCopyWith get copyWith => _$GlobalSearchServerResultCopyWithImpl(this as GlobalSearchServerResult, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is GlobalSearchServerResult&&(identical(other.serverId, serverId) || other.serverId == serverId)&&(identical(other.serverName, serverName) || other.serverName == serverName)&&(identical(other.serverUrl, serverUrl) || other.serverUrl == serverUrl)&&(identical(other.token, token) || other.token == token)&&(identical(other.userId, userId) || other.userId == userId)&&const DeepCollectionEquality().equals(other.items, items)); +} + + +@override +int get hashCode => Object.hash(runtimeType,serverId,serverName,serverUrl,token,userId,const DeepCollectionEquality().hash(items)); + +@override +String toString() { + return 'GlobalSearchServerResult(serverId: $serverId, serverName: $serverName, serverUrl: $serverUrl, token: $token, userId: $userId, items: $items)'; +} + + +} + +/// @nodoc +abstract mixin class $GlobalSearchServerResultCopyWith<$Res> { + factory $GlobalSearchServerResultCopyWith(GlobalSearchServerResult value, $Res Function(GlobalSearchServerResult) _then) = _$GlobalSearchServerResultCopyWithImpl; +@useResult +$Res call({ + String serverId, String serverName, String serverUrl, String token, String userId, List items +}); + + + + +} +/// @nodoc +class _$GlobalSearchServerResultCopyWithImpl<$Res> + implements $GlobalSearchServerResultCopyWith<$Res> { + _$GlobalSearchServerResultCopyWithImpl(this._self, this._then); + + final GlobalSearchServerResult _self; + final $Res Function(GlobalSearchServerResult) _then; + +/// Create a copy of GlobalSearchServerResult +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? serverId = null,Object? serverName = null,Object? serverUrl = null,Object? token = null,Object? userId = null,Object? items = null,}) { + return _then(_self.copyWith( +serverId: null == serverId ? _self.serverId : serverId // ignore: cast_nullable_to_non_nullable +as String,serverName: null == serverName ? _self.serverName : serverName // ignore: cast_nullable_to_non_nullable +as String,serverUrl: null == serverUrl ? _self.serverUrl : serverUrl // ignore: cast_nullable_to_non_nullable +as String,token: null == token ? _self.token : token // ignore: cast_nullable_to_non_nullable +as String,userId: null == userId ? _self.userId : userId // ignore: cast_nullable_to_non_nullable +as String,items: null == items ? _self.items : items // ignore: cast_nullable_to_non_nullable +as List, + )); +} + +} + + +/// Adds pattern-matching-related methods to [GlobalSearchServerResult]. +extension GlobalSearchServerResultPatterns on GlobalSearchServerResult { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _GlobalSearchServerResult value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _GlobalSearchServerResult() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _GlobalSearchServerResult value) $default,){ +final _that = this; +switch (_that) { +case _GlobalSearchServerResult(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _GlobalSearchServerResult value)? $default,){ +final _that = this; +switch (_that) { +case _GlobalSearchServerResult() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String serverId, String serverName, String serverUrl, String token, String userId, List items)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _GlobalSearchServerResult() when $default != null: +return $default(_that.serverId,_that.serverName,_that.serverUrl,_that.token,_that.userId,_that.items);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String serverId, String serverName, String serverUrl, String token, String userId, List items) $default,) {final _that = this; +switch (_that) { +case _GlobalSearchServerResult(): +return $default(_that.serverId,_that.serverName,_that.serverUrl,_that.token,_that.userId,_that.items);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String serverId, String serverName, String serverUrl, String token, String userId, List items)? $default,) {final _that = this; +switch (_that) { +case _GlobalSearchServerResult() when $default != null: +return $default(_that.serverId,_that.serverName,_that.serverUrl,_that.token,_that.userId,_that.items);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _GlobalSearchServerResult implements GlobalSearchServerResult { + const _GlobalSearchServerResult({required this.serverId, required this.serverName, required this.serverUrl, required this.token, required this.userId, required final List items}): _items = items; + + +@override final String serverId; +@override final String serverName; +@override final String serverUrl; +@override final String token; +@override final String userId; + final List _items; +@override List get items { + if (_items is EqualUnmodifiableListView) return _items; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_items); +} + + +/// Create a copy of GlobalSearchServerResult +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$GlobalSearchServerResultCopyWith<_GlobalSearchServerResult> get copyWith => __$GlobalSearchServerResultCopyWithImpl<_GlobalSearchServerResult>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _GlobalSearchServerResult&&(identical(other.serverId, serverId) || other.serverId == serverId)&&(identical(other.serverName, serverName) || other.serverName == serverName)&&(identical(other.serverUrl, serverUrl) || other.serverUrl == serverUrl)&&(identical(other.token, token) || other.token == token)&&(identical(other.userId, userId) || other.userId == userId)&&const DeepCollectionEquality().equals(other._items, _items)); +} + + +@override +int get hashCode => Object.hash(runtimeType,serverId,serverName,serverUrl,token,userId,const DeepCollectionEquality().hash(_items)); + +@override +String toString() { + return 'GlobalSearchServerResult(serverId: $serverId, serverName: $serverName, serverUrl: $serverUrl, token: $token, userId: $userId, items: $items)'; +} + + +} + +/// @nodoc +abstract mixin class _$GlobalSearchServerResultCopyWith<$Res> implements $GlobalSearchServerResultCopyWith<$Res> { + factory _$GlobalSearchServerResultCopyWith(_GlobalSearchServerResult value, $Res Function(_GlobalSearchServerResult) _then) = __$GlobalSearchServerResultCopyWithImpl; +@override @useResult +$Res call({ + String serverId, String serverName, String serverUrl, String token, String userId, List items +}); + + + + +} +/// @nodoc +class __$GlobalSearchServerResultCopyWithImpl<$Res> + implements _$GlobalSearchServerResultCopyWith<$Res> { + __$GlobalSearchServerResultCopyWithImpl(this._self, this._then); + + final _GlobalSearchServerResult _self; + final $Res Function(_GlobalSearchServerResult) _then; + +/// Create a copy of GlobalSearchServerResult +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? serverId = null,Object? serverName = null,Object? serverUrl = null,Object? token = null,Object? userId = null,Object? items = null,}) { + return _then(_GlobalSearchServerResult( +serverId: null == serverId ? _self.serverId : serverId // ignore: cast_nullable_to_non_nullable +as String,serverName: null == serverName ? _self.serverName : serverName // ignore: cast_nullable_to_non_nullable +as String,serverUrl: null == serverUrl ? _self.serverUrl : serverUrl // ignore: cast_nullable_to_non_nullable +as String,token: null == token ? _self.token : token // ignore: cast_nullable_to_non_nullable +as String,userId: null == userId ? _self.userId : userId // ignore: cast_nullable_to_non_nullable +as String,items: null == items ? _self._items : items // ignore: cast_nullable_to_non_nullable +as List, + )); +} + + +} + +// dart format on diff --git a/lib/core/contracts/library.g.dart b/lib/core/contracts/library.g.dart new file mode 100644 index 0000000..c6a443d --- /dev/null +++ b/lib/core/contracts/library.g.dart @@ -0,0 +1,250 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'library.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_EmbyRawUserData _$EmbyRawUserDataFromJson(Map json) => + _EmbyRawUserData( + PlaybackPositionTicks: _intOrNull(json['PlaybackPositionTicks']), + PlayCount: _intOrNull(json['PlayCount']), + UnplayedItemCount: _intOrNull(json['UnplayedItemCount']), + IsFavorite: json['IsFavorite'] as bool?, + Played: json['Played'] as bool?, + ); + +Map _$EmbyRawUserDataToJson(_EmbyRawUserData instance) => + { + 'PlaybackPositionTicks': ?instance.PlaybackPositionTicks, + 'PlayCount': ?instance.PlayCount, + 'UnplayedItemCount': ?instance.UnplayedItemCount, + 'IsFavorite': ?instance.IsFavorite, + 'Played': ?instance.Played, + }; + +_EmbyRawItem _$EmbyRawItemFromJson(Map json) => _EmbyRawItem( + Id: json['Id'] as String, + Name: json['Name'] as String, + Type: json['Type'] as String?, + Overview: json['Overview'] as String?, + ProductionYear: _intOrNull(json['ProductionYear']), + CommunityRating: _doubleOrNull(json['CommunityRating']), + PremiereDate: json['PremiereDate'] as String?, + SeriesName: json['SeriesName'] as String?, + SeriesId: json['SeriesId'] as String?, + SeasonId: json['SeasonId'] as String?, + SeasonName: json['SeasonName'] as String?, + IndexNumber: _intOrNull(json['IndexNumber']), + ParentId: json['ParentId'] as String?, + PrimaryImageTag: json['PrimaryImageTag'] as String?, + ImageTags: _stringMapOrNull(json['ImageTags']), + BackdropImageTags: _stringListOrNull(json['BackdropImageTags']), + RunTimeTicks: _intOrNull(json['RunTimeTicks']), + UserData: json['UserData'] == null + ? null + : EmbyRawUserData.fromJson(json['UserData'] as Map), +); + +Map _$EmbyRawItemToJson(_EmbyRawItem instance) => + { + 'Id': instance.Id, + 'Name': instance.Name, + 'Type': ?instance.Type, + 'Overview': ?instance.Overview, + 'ProductionYear': ?instance.ProductionYear, + 'CommunityRating': ?instance.CommunityRating, + 'PremiereDate': ?instance.PremiereDate, + 'SeriesName': ?instance.SeriesName, + 'SeriesId': ?instance.SeriesId, + 'SeasonId': ?instance.SeasonId, + 'SeasonName': ?instance.SeasonName, + 'IndexNumber': ?instance.IndexNumber, + 'ParentId': ?instance.ParentId, + 'PrimaryImageTag': ?instance.PrimaryImageTag, + 'ImageTags': ?instance.ImageTags, + 'BackdropImageTags': ?instance.BackdropImageTags, + 'RunTimeTicks': ?instance.RunTimeTicks, + 'UserData': ?instance.UserData, + }; + +_EmbyRawSeason _$EmbyRawSeasonFromJson(Map json) => + _EmbyRawSeason( + Id: json['Id'] as String, + Name: json['Name'] as String, + IndexNumber: _intOrNull(json['IndexNumber']), + ProductionYear: _intOrNull(json['ProductionYear']), + ChildCount: _intOrNull(json['ChildCount']), + PrimaryImageTag: json['PrimaryImageTag'] as String?, + ImageTags: _stringMapOrNull(json['ImageTags']), + ); + +Map _$EmbyRawSeasonToJson(_EmbyRawSeason instance) => + { + 'Id': instance.Id, + 'Name': instance.Name, + 'IndexNumber': ?instance.IndexNumber, + 'ProductionYear': ?instance.ProductionYear, + 'ChildCount': ?instance.ChildCount, + 'PrimaryImageTag': ?instance.PrimaryImageTag, + 'ImageTags': ?instance.ImageTags, + }; + +_EmbyRawLibrary _$EmbyRawLibraryFromJson(Map json) => + _EmbyRawLibrary( + Id: json['Id'] as String, + Name: json['Name'] as String, + CollectionType: json['CollectionType'] as String?, + ImageTags: _stringMapOrNull(json['ImageTags']), + ); + +Map _$EmbyRawLibraryToJson(_EmbyRawLibrary instance) => + { + 'Id': instance.Id, + 'Name': instance.Name, + 'CollectionType': ?instance.CollectionType, + 'ImageTags': ?instance.ImageTags, + }; + +_EmbyRawLibraries _$EmbyRawLibrariesFromJson(Map json) => + _EmbyRawLibraries( + Items: (json['Items'] as List) + .map((e) => EmbyRawLibrary.fromJson(e as Map)) + .toList(), + ); + +Map _$EmbyRawLibrariesToJson(_EmbyRawLibraries instance) => + {'Items': instance.Items}; + +_EmbyRawMediaStream _$EmbyRawMediaStreamFromJson(Map json) => + _EmbyRawMediaStream( + Index: _intOrNull(json['Index']), + Type: json['Type'] as String?, + Language: json['Language'] as String?, + Codec: json['Codec'] as String?, + Channels: _intOrNull(json['Channels']), + DisplayTitle: json['DisplayTitle'] as String?, + Title: json['Title'] as String?, + IsDefault: json['IsDefault'] as bool?, + IsForced: json['IsForced'] as bool?, + IsExternal: json['IsExternal'] as bool?, + DeliveryMethod: json['DeliveryMethod'] as String?, + DeliveryUrl: json['DeliveryUrl'] as String?, + ); + +Map _$EmbyRawMediaStreamToJson(_EmbyRawMediaStream instance) => + { + 'Index': ?instance.Index, + 'Type': ?instance.Type, + 'Language': ?instance.Language, + 'Codec': ?instance.Codec, + 'Channels': ?instance.Channels, + 'DisplayTitle': ?instance.DisplayTitle, + 'Title': ?instance.Title, + 'IsDefault': ?instance.IsDefault, + 'IsForced': ?instance.IsForced, + 'IsExternal': ?instance.IsExternal, + 'DeliveryMethod': ?instance.DeliveryMethod, + 'DeliveryUrl': ?instance.DeliveryUrl, + }; + +_EmbyRawMediaSource _$EmbyRawMediaSourceFromJson(Map json) => + _EmbyRawMediaSource( + Id: json['Id'] as String?, + Name: json['Name'] as String?, + Container: json['Container'] as String?, + Bitrate: _intOrNull(json['Bitrate']), + Size: _intOrNull(json['Size']), + Width: _intOrNull(json['Width']), + Height: _intOrNull(json['Height']), + DefaultSubtitleStreamIndex: _intOrNull( + json['DefaultSubtitleStreamIndex'], + ), + DefaultAudioStreamIndex: _intOrNull(json['DefaultAudioStreamIndex']), + MediaStreams: (json['MediaStreams'] as List?) + ?.map((e) => EmbyRawMediaStream.fromJson(e as Map)) + .toList(), + ); + +Map _$EmbyRawMediaSourceToJson(_EmbyRawMediaSource instance) => + { + 'Id': ?instance.Id, + 'Name': ?instance.Name, + 'Container': ?instance.Container, + 'Bitrate': ?instance.Bitrate, + 'Size': ?instance.Size, + 'Width': ?instance.Width, + 'Height': ?instance.Height, + 'DefaultSubtitleStreamIndex': ?instance.DefaultSubtitleStreamIndex, + 'DefaultAudioStreamIndex': ?instance.DefaultAudioStreamIndex, + 'MediaStreams': ?instance.MediaStreams, + }; + +_LibraryLatestRes _$LibraryLatestResFromJson(Map json) => + _LibraryLatestRes( + parentId: json['parentId'] as String, + items: (json['items'] as List) + .map((e) => EmbyRawItem.fromJson(e as Map)) + .toList(), + totalCount: (json['totalCount'] as num?)?.toInt() ?? 0, + ); + +Map _$LibraryLatestResToJson(_LibraryLatestRes instance) => + { + 'parentId': instance.parentId, + 'items': instance.items, + 'totalCount': instance.totalCount, + }; + +_LibraryResumeRes _$LibraryResumeResFromJson(Map json) => + _LibraryResumeRes( + Items: (json['Items'] as List) + .map((e) => EmbyRawItem.fromJson(e as Map)) + .toList(), + ); + +Map _$LibraryResumeResToJson(_LibraryResumeRes instance) => + {'Items': instance.Items}; + +_FavoriteSetRes _$FavoriteSetResFromJson(Map json) => + _FavoriteSetRes( + itemId: json['itemId'] as String, + isFavorite: json['isFavorite'] as bool, + ); + +Map _$FavoriteSetResToJson(_FavoriteSetRes instance) => + { + 'itemId': instance.itemId, + 'isFavorite': instance.isFavorite, + }; + +_PlayedSetRes _$PlayedSetResFromJson(Map json) => + _PlayedSetRes( + itemId: json['itemId'] as String, + played: json['played'] as bool, + ); + +Map _$PlayedSetResToJson(_PlayedSetRes instance) => + {'itemId': instance.itemId, 'played': instance.played}; + +_HideFromResumeRes _$HideFromResumeResFromJson(Map json) => + _HideFromResumeRes( + itemId: json['itemId'] as String, + hide: json['hide'] as bool, + ); + +Map _$HideFromResumeResToJson(_HideFromResumeRes instance) => + {'itemId': instance.itemId, 'hide': instance.hide}; + +_ItemCountsRes _$ItemCountsResFromJson(Map json) => + _ItemCountsRes( + movieCount: _intOrZero(json['MovieCount']), + episodeCount: _intOrZero(json['EpisodeCount']), + ); + +Map _$ItemCountsResToJson(_ItemCountsRes instance) => + { + 'MovieCount': instance.movieCount, + 'EpisodeCount': instance.episodeCount, + }; diff --git a/lib/core/contracts/playback.dart b/lib/core/contracts/playback.dart new file mode 100644 index 0000000..fcd88b1 --- /dev/null +++ b/lib/core/contracts/playback.dart @@ -0,0 +1,181 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +import 'library.dart'; + +part 'playback.freezed.dart'; +part 'playback.g.dart'; + +@freezed +abstract class PlaybackRequestPayload with _$PlaybackRequestPayload { + const factory PlaybackRequestPayload({ + required String itemId, + @JsonKey(includeIfNull: false) String? itemType, + @JsonKey(includeIfNull: false) String? mediaSourceId, + @JsonKey(includeIfNull: false) int? startPositionTicks, + @Default(false) bool playFromStart, + }) = _PlaybackRequestPayload; + + factory PlaybackRequestPayload.fromJson(Map json) => + _$PlaybackRequestPayloadFromJson(json); +} + + +@freezed +abstract class EmbySubtitleTrack with _$EmbySubtitleTrack { + const factory EmbySubtitleTrack({ + @JsonKey(fromJson: _intRequired) required int index, + @JsonKey(includeIfNull: false) String? title, + @JsonKey(includeIfNull: false) String? language, + @JsonKey(fromJson: _boolOrFalse) @Default(false) bool isDefault, + @JsonKey(fromJson: _boolOrFalse) @Default(false) bool isForced, + @JsonKey(fromJson: _boolOrFalse) @Default(false) bool isExternal, + @JsonKey(includeIfNull: false) String? deliveryUrl, + @JsonKey(includeIfNull: false) String? codec, + }) = _EmbySubtitleTrack; + + factory EmbySubtitleTrack.fromJson(Map json) => + _$EmbySubtitleTrackFromJson(json); +} + + +@freezed +abstract class EmbyAudioTrack with _$EmbyAudioTrack { + const factory EmbyAudioTrack({ + @JsonKey(fromJson: _intRequired) required int index, + @JsonKey(includeIfNull: false) String? title, + @JsonKey(includeIfNull: false) String? language, + @JsonKey(includeIfNull: false) String? codec, + @JsonKey(fromJson: _intOrNull, includeIfNull: false) int? channels, + @JsonKey(includeIfNull: false) String? displayTitle, + @JsonKey(fromJson: _boolOrFalse) @Default(false) bool isDefault, + }) = _EmbyAudioTrack; + + factory EmbyAudioTrack.fromJson(Map json) => + _$EmbyAudioTrackFromJson(json); +} + +int _intRequired(Object? value) => (value as num).toInt(); + +int? _intOrNull(Object? value) => (value as num?)?.toInt(); + +bool _boolOrFalse(Object? value) => (value as bool?) ?? false; + + +class PlaybackRequestResult { + + final String streamUrl; + + + final String? container; + + + final String? mimeType; + + + final bool directStream; + + + final EmbyRawItem item; + + + final EmbyRawMediaSource? mediaSource; + + + final double startPositionSeconds; + + + final double? durationSeconds; + + + final List subtitles; + + + final int? defaultSubtitleIndex; + + + final List audioTracks; + + + final int? defaultAudioStreamIndex; + + + final String? playSessionId; + + + final String playMethod; + + const PlaybackRequestResult({ + required this.streamUrl, + this.container, + this.mimeType, + required this.directStream, + required this.item, + this.mediaSource, + this.startPositionSeconds = 0, + this.durationSeconds, + this.subtitles = const [], + this.defaultSubtitleIndex, + this.audioTracks = const [], + this.defaultAudioStreamIndex, + this.playSessionId, + this.playMethod = 'DirectStream', + }); +} + + +class PlaybackReportPayload { + final String itemId; + final String? mediaSourceId; + final String? playSessionId; + final int positionTicks; + final int? runTimeTicks; + final String playMethod; + final bool canSeek; + final bool isPaused; + final bool isMuted; + final double playbackRate; + final int playlistIndex; + final int playlistLength; + final String repeatMode; + final String? eventName; + + const PlaybackReportPayload({ + required this.itemId, + this.mediaSourceId, + this.playSessionId, + required this.positionTicks, + this.runTimeTicks, + this.playMethod = 'DirectStream', + this.canSeek = true, + this.isPaused = false, + this.isMuted = false, + this.playbackRate = 1, + this.playlistIndex = 0, + this.playlistLength = 1, + this.repeatMode = 'RepeatNone', + this.eventName, + }); + + Map toJson({bool includeNowPlayingQueue = false}) => { + 'ItemId': itemId, + if (mediaSourceId != null && mediaSourceId!.isNotEmpty) + 'MediaSourceId': mediaSourceId, + if (playSessionId != null && playSessionId!.isNotEmpty) + 'PlaySessionId': playSessionId, + 'PositionTicks': positionTicks, + if (runTimeTicks != null) 'RunTimeTicks': runTimeTicks, + 'CanSeek': canSeek, + 'PlayMethod': playMethod, + 'PlaylistIndex': playlistIndex, + 'PlaylistLength': playlistLength, + 'PlaybackRate': playbackRate, + 'IsMuted': isMuted, + 'IsPaused': isPaused, + 'RepeatMode': repeatMode, + if (eventName != null && eventName!.isNotEmpty) 'EventName': eventName, + if (includeNowPlayingQueue) + 'NowPlayingQueue': [ + {'Id': itemId, 'PlaylistItemId': 'playlistItem$playlistIndex'}, + ], + }; +} diff --git a/lib/core/contracts/playback.freezed.dart b/lib/core/contracts/playback.freezed.dart new file mode 100644 index 0000000..7fdceb1 --- /dev/null +++ b/lib/core/contracts/playback.freezed.dart @@ -0,0 +1,854 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'playback.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$PlaybackRequestPayload { + + String get itemId;@JsonKey(includeIfNull: false) String? get itemType;@JsonKey(includeIfNull: false) String? get mediaSourceId;@JsonKey(includeIfNull: false) int? get startPositionTicks; bool get playFromStart; +/// Create a copy of PlaybackRequestPayload +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$PlaybackRequestPayloadCopyWith get copyWith => _$PlaybackRequestPayloadCopyWithImpl(this as PlaybackRequestPayload, _$identity); + + /// Serializes this PlaybackRequestPayload to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is PlaybackRequestPayload&&(identical(other.itemId, itemId) || other.itemId == itemId)&&(identical(other.itemType, itemType) || other.itemType == itemType)&&(identical(other.mediaSourceId, mediaSourceId) || other.mediaSourceId == mediaSourceId)&&(identical(other.startPositionTicks, startPositionTicks) || other.startPositionTicks == startPositionTicks)&&(identical(other.playFromStart, playFromStart) || other.playFromStart == playFromStart)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,itemId,itemType,mediaSourceId,startPositionTicks,playFromStart); + +@override +String toString() { + return 'PlaybackRequestPayload(itemId: $itemId, itemType: $itemType, mediaSourceId: $mediaSourceId, startPositionTicks: $startPositionTicks, playFromStart: $playFromStart)'; +} + + +} + +/// @nodoc +abstract mixin class $PlaybackRequestPayloadCopyWith<$Res> { + factory $PlaybackRequestPayloadCopyWith(PlaybackRequestPayload value, $Res Function(PlaybackRequestPayload) _then) = _$PlaybackRequestPayloadCopyWithImpl; +@useResult +$Res call({ + String itemId,@JsonKey(includeIfNull: false) String? itemType,@JsonKey(includeIfNull: false) String? mediaSourceId,@JsonKey(includeIfNull: false) int? startPositionTicks, bool playFromStart +}); + + + + +} +/// @nodoc +class _$PlaybackRequestPayloadCopyWithImpl<$Res> + implements $PlaybackRequestPayloadCopyWith<$Res> { + _$PlaybackRequestPayloadCopyWithImpl(this._self, this._then); + + final PlaybackRequestPayload _self; + final $Res Function(PlaybackRequestPayload) _then; + +/// Create a copy of PlaybackRequestPayload +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? itemId = null,Object? itemType = freezed,Object? mediaSourceId = freezed,Object? startPositionTicks = freezed,Object? playFromStart = null,}) { + return _then(_self.copyWith( +itemId: null == itemId ? _self.itemId : itemId // ignore: cast_nullable_to_non_nullable +as String,itemType: freezed == itemType ? _self.itemType : itemType // ignore: cast_nullable_to_non_nullable +as String?,mediaSourceId: freezed == mediaSourceId ? _self.mediaSourceId : mediaSourceId // ignore: cast_nullable_to_non_nullable +as String?,startPositionTicks: freezed == startPositionTicks ? _self.startPositionTicks : startPositionTicks // ignore: cast_nullable_to_non_nullable +as int?,playFromStart: null == playFromStart ? _self.playFromStart : playFromStart // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + +} + + +/// Adds pattern-matching-related methods to [PlaybackRequestPayload]. +extension PlaybackRequestPayloadPatterns on PlaybackRequestPayload { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _PlaybackRequestPayload value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _PlaybackRequestPayload() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _PlaybackRequestPayload value) $default,){ +final _that = this; +switch (_that) { +case _PlaybackRequestPayload(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _PlaybackRequestPayload value)? $default,){ +final _that = this; +switch (_that) { +case _PlaybackRequestPayload() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String itemId, @JsonKey(includeIfNull: false) String? itemType, @JsonKey(includeIfNull: false) String? mediaSourceId, @JsonKey(includeIfNull: false) int? startPositionTicks, bool playFromStart)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _PlaybackRequestPayload() when $default != null: +return $default(_that.itemId,_that.itemType,_that.mediaSourceId,_that.startPositionTicks,_that.playFromStart);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String itemId, @JsonKey(includeIfNull: false) String? itemType, @JsonKey(includeIfNull: false) String? mediaSourceId, @JsonKey(includeIfNull: false) int? startPositionTicks, bool playFromStart) $default,) {final _that = this; +switch (_that) { +case _PlaybackRequestPayload(): +return $default(_that.itemId,_that.itemType,_that.mediaSourceId,_that.startPositionTicks,_that.playFromStart);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String itemId, @JsonKey(includeIfNull: false) String? itemType, @JsonKey(includeIfNull: false) String? mediaSourceId, @JsonKey(includeIfNull: false) int? startPositionTicks, bool playFromStart)? $default,) {final _that = this; +switch (_that) { +case _PlaybackRequestPayload() when $default != null: +return $default(_that.itemId,_that.itemType,_that.mediaSourceId,_that.startPositionTicks,_that.playFromStart);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _PlaybackRequestPayload implements PlaybackRequestPayload { + const _PlaybackRequestPayload({required this.itemId, @JsonKey(includeIfNull: false) this.itemType, @JsonKey(includeIfNull: false) this.mediaSourceId, @JsonKey(includeIfNull: false) this.startPositionTicks, this.playFromStart = false}); + factory _PlaybackRequestPayload.fromJson(Map json) => _$PlaybackRequestPayloadFromJson(json); + +@override final String itemId; +@override@JsonKey(includeIfNull: false) final String? itemType; +@override@JsonKey(includeIfNull: false) final String? mediaSourceId; +@override@JsonKey(includeIfNull: false) final int? startPositionTicks; +@override@JsonKey() final bool playFromStart; + +/// Create a copy of PlaybackRequestPayload +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$PlaybackRequestPayloadCopyWith<_PlaybackRequestPayload> get copyWith => __$PlaybackRequestPayloadCopyWithImpl<_PlaybackRequestPayload>(this, _$identity); + +@override +Map toJson() { + return _$PlaybackRequestPayloadToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _PlaybackRequestPayload&&(identical(other.itemId, itemId) || other.itemId == itemId)&&(identical(other.itemType, itemType) || other.itemType == itemType)&&(identical(other.mediaSourceId, mediaSourceId) || other.mediaSourceId == mediaSourceId)&&(identical(other.startPositionTicks, startPositionTicks) || other.startPositionTicks == startPositionTicks)&&(identical(other.playFromStart, playFromStart) || other.playFromStart == playFromStart)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,itemId,itemType,mediaSourceId,startPositionTicks,playFromStart); + +@override +String toString() { + return 'PlaybackRequestPayload(itemId: $itemId, itemType: $itemType, mediaSourceId: $mediaSourceId, startPositionTicks: $startPositionTicks, playFromStart: $playFromStart)'; +} + + +} + +/// @nodoc +abstract mixin class _$PlaybackRequestPayloadCopyWith<$Res> implements $PlaybackRequestPayloadCopyWith<$Res> { + factory _$PlaybackRequestPayloadCopyWith(_PlaybackRequestPayload value, $Res Function(_PlaybackRequestPayload) _then) = __$PlaybackRequestPayloadCopyWithImpl; +@override @useResult +$Res call({ + String itemId,@JsonKey(includeIfNull: false) String? itemType,@JsonKey(includeIfNull: false) String? mediaSourceId,@JsonKey(includeIfNull: false) int? startPositionTicks, bool playFromStart +}); + + + + +} +/// @nodoc +class __$PlaybackRequestPayloadCopyWithImpl<$Res> + implements _$PlaybackRequestPayloadCopyWith<$Res> { + __$PlaybackRequestPayloadCopyWithImpl(this._self, this._then); + + final _PlaybackRequestPayload _self; + final $Res Function(_PlaybackRequestPayload) _then; + +/// Create a copy of PlaybackRequestPayload +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? itemId = null,Object? itemType = freezed,Object? mediaSourceId = freezed,Object? startPositionTicks = freezed,Object? playFromStart = null,}) { + return _then(_PlaybackRequestPayload( +itemId: null == itemId ? _self.itemId : itemId // ignore: cast_nullable_to_non_nullable +as String,itemType: freezed == itemType ? _self.itemType : itemType // ignore: cast_nullable_to_non_nullable +as String?,mediaSourceId: freezed == mediaSourceId ? _self.mediaSourceId : mediaSourceId // ignore: cast_nullable_to_non_nullable +as String?,startPositionTicks: freezed == startPositionTicks ? _self.startPositionTicks : startPositionTicks // ignore: cast_nullable_to_non_nullable +as int?,playFromStart: null == playFromStart ? _self.playFromStart : playFromStart // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + + +} + + +/// @nodoc +mixin _$EmbySubtitleTrack { + +@JsonKey(fromJson: _intRequired) int get index;@JsonKey(includeIfNull: false) String? get title;@JsonKey(includeIfNull: false) String? get language;@JsonKey(fromJson: _boolOrFalse) bool get isDefault;@JsonKey(fromJson: _boolOrFalse) bool get isForced;@JsonKey(fromJson: _boolOrFalse) bool get isExternal;@JsonKey(includeIfNull: false) String? get deliveryUrl;@JsonKey(includeIfNull: false) String? get codec; +/// Create a copy of EmbySubtitleTrack +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$EmbySubtitleTrackCopyWith get copyWith => _$EmbySubtitleTrackCopyWithImpl(this as EmbySubtitleTrack, _$identity); + + /// Serializes this EmbySubtitleTrack to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is EmbySubtitleTrack&&(identical(other.index, index) || other.index == index)&&(identical(other.title, title) || other.title == title)&&(identical(other.language, language) || other.language == language)&&(identical(other.isDefault, isDefault) || other.isDefault == isDefault)&&(identical(other.isForced, isForced) || other.isForced == isForced)&&(identical(other.isExternal, isExternal) || other.isExternal == isExternal)&&(identical(other.deliveryUrl, deliveryUrl) || other.deliveryUrl == deliveryUrl)&&(identical(other.codec, codec) || other.codec == codec)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,index,title,language,isDefault,isForced,isExternal,deliveryUrl,codec); + +@override +String toString() { + return 'EmbySubtitleTrack(index: $index, title: $title, language: $language, isDefault: $isDefault, isForced: $isForced, isExternal: $isExternal, deliveryUrl: $deliveryUrl, codec: $codec)'; +} + + +} + +/// @nodoc +abstract mixin class $EmbySubtitleTrackCopyWith<$Res> { + factory $EmbySubtitleTrackCopyWith(EmbySubtitleTrack value, $Res Function(EmbySubtitleTrack) _then) = _$EmbySubtitleTrackCopyWithImpl; +@useResult +$Res call({ +@JsonKey(fromJson: _intRequired) int index,@JsonKey(includeIfNull: false) String? title,@JsonKey(includeIfNull: false) String? language,@JsonKey(fromJson: _boolOrFalse) bool isDefault,@JsonKey(fromJson: _boolOrFalse) bool isForced,@JsonKey(fromJson: _boolOrFalse) bool isExternal,@JsonKey(includeIfNull: false) String? deliveryUrl,@JsonKey(includeIfNull: false) String? codec +}); + + + + +} +/// @nodoc +class _$EmbySubtitleTrackCopyWithImpl<$Res> + implements $EmbySubtitleTrackCopyWith<$Res> { + _$EmbySubtitleTrackCopyWithImpl(this._self, this._then); + + final EmbySubtitleTrack _self; + final $Res Function(EmbySubtitleTrack) _then; + +/// Create a copy of EmbySubtitleTrack +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? index = null,Object? title = freezed,Object? language = freezed,Object? isDefault = null,Object? isForced = null,Object? isExternal = null,Object? deliveryUrl = freezed,Object? codec = freezed,}) { + return _then(_self.copyWith( +index: null == index ? _self.index : index // ignore: cast_nullable_to_non_nullable +as int,title: freezed == title ? _self.title : title // ignore: cast_nullable_to_non_nullable +as String?,language: freezed == language ? _self.language : language // ignore: cast_nullable_to_non_nullable +as String?,isDefault: null == isDefault ? _self.isDefault : isDefault // ignore: cast_nullable_to_non_nullable +as bool,isForced: null == isForced ? _self.isForced : isForced // ignore: cast_nullable_to_non_nullable +as bool,isExternal: null == isExternal ? _self.isExternal : isExternal // ignore: cast_nullable_to_non_nullable +as bool,deliveryUrl: freezed == deliveryUrl ? _self.deliveryUrl : deliveryUrl // ignore: cast_nullable_to_non_nullable +as String?,codec: freezed == codec ? _self.codec : codec // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [EmbySubtitleTrack]. +extension EmbySubtitleTrackPatterns on EmbySubtitleTrack { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _EmbySubtitleTrack value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _EmbySubtitleTrack() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _EmbySubtitleTrack value) $default,){ +final _that = this; +switch (_that) { +case _EmbySubtitleTrack(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _EmbySubtitleTrack value)? $default,){ +final _that = this; +switch (_that) { +case _EmbySubtitleTrack() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function(@JsonKey(fromJson: _intRequired) int index, @JsonKey(includeIfNull: false) String? title, @JsonKey(includeIfNull: false) String? language, @JsonKey(fromJson: _boolOrFalse) bool isDefault, @JsonKey(fromJson: _boolOrFalse) bool isForced, @JsonKey(fromJson: _boolOrFalse) bool isExternal, @JsonKey(includeIfNull: false) String? deliveryUrl, @JsonKey(includeIfNull: false) String? codec)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _EmbySubtitleTrack() when $default != null: +return $default(_that.index,_that.title,_that.language,_that.isDefault,_that.isForced,_that.isExternal,_that.deliveryUrl,_that.codec);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function(@JsonKey(fromJson: _intRequired) int index, @JsonKey(includeIfNull: false) String? title, @JsonKey(includeIfNull: false) String? language, @JsonKey(fromJson: _boolOrFalse) bool isDefault, @JsonKey(fromJson: _boolOrFalse) bool isForced, @JsonKey(fromJson: _boolOrFalse) bool isExternal, @JsonKey(includeIfNull: false) String? deliveryUrl, @JsonKey(includeIfNull: false) String? codec) $default,) {final _that = this; +switch (_that) { +case _EmbySubtitleTrack(): +return $default(_that.index,_that.title,_that.language,_that.isDefault,_that.isForced,_that.isExternal,_that.deliveryUrl,_that.codec);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function(@JsonKey(fromJson: _intRequired) int index, @JsonKey(includeIfNull: false) String? title, @JsonKey(includeIfNull: false) String? language, @JsonKey(fromJson: _boolOrFalse) bool isDefault, @JsonKey(fromJson: _boolOrFalse) bool isForced, @JsonKey(fromJson: _boolOrFalse) bool isExternal, @JsonKey(includeIfNull: false) String? deliveryUrl, @JsonKey(includeIfNull: false) String? codec)? $default,) {final _that = this; +switch (_that) { +case _EmbySubtitleTrack() when $default != null: +return $default(_that.index,_that.title,_that.language,_that.isDefault,_that.isForced,_that.isExternal,_that.deliveryUrl,_that.codec);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _EmbySubtitleTrack implements EmbySubtitleTrack { + const _EmbySubtitleTrack({@JsonKey(fromJson: _intRequired) required this.index, @JsonKey(includeIfNull: false) this.title, @JsonKey(includeIfNull: false) this.language, @JsonKey(fromJson: _boolOrFalse) this.isDefault = false, @JsonKey(fromJson: _boolOrFalse) this.isForced = false, @JsonKey(fromJson: _boolOrFalse) this.isExternal = false, @JsonKey(includeIfNull: false) this.deliveryUrl, @JsonKey(includeIfNull: false) this.codec}); + factory _EmbySubtitleTrack.fromJson(Map json) => _$EmbySubtitleTrackFromJson(json); + +@override@JsonKey(fromJson: _intRequired) final int index; +@override@JsonKey(includeIfNull: false) final String? title; +@override@JsonKey(includeIfNull: false) final String? language; +@override@JsonKey(fromJson: _boolOrFalse) final bool isDefault; +@override@JsonKey(fromJson: _boolOrFalse) final bool isForced; +@override@JsonKey(fromJson: _boolOrFalse) final bool isExternal; +@override@JsonKey(includeIfNull: false) final String? deliveryUrl; +@override@JsonKey(includeIfNull: false) final String? codec; + +/// Create a copy of EmbySubtitleTrack +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$EmbySubtitleTrackCopyWith<_EmbySubtitleTrack> get copyWith => __$EmbySubtitleTrackCopyWithImpl<_EmbySubtitleTrack>(this, _$identity); + +@override +Map toJson() { + return _$EmbySubtitleTrackToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _EmbySubtitleTrack&&(identical(other.index, index) || other.index == index)&&(identical(other.title, title) || other.title == title)&&(identical(other.language, language) || other.language == language)&&(identical(other.isDefault, isDefault) || other.isDefault == isDefault)&&(identical(other.isForced, isForced) || other.isForced == isForced)&&(identical(other.isExternal, isExternal) || other.isExternal == isExternal)&&(identical(other.deliveryUrl, deliveryUrl) || other.deliveryUrl == deliveryUrl)&&(identical(other.codec, codec) || other.codec == codec)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,index,title,language,isDefault,isForced,isExternal,deliveryUrl,codec); + +@override +String toString() { + return 'EmbySubtitleTrack(index: $index, title: $title, language: $language, isDefault: $isDefault, isForced: $isForced, isExternal: $isExternal, deliveryUrl: $deliveryUrl, codec: $codec)'; +} + + +} + +/// @nodoc +abstract mixin class _$EmbySubtitleTrackCopyWith<$Res> implements $EmbySubtitleTrackCopyWith<$Res> { + factory _$EmbySubtitleTrackCopyWith(_EmbySubtitleTrack value, $Res Function(_EmbySubtitleTrack) _then) = __$EmbySubtitleTrackCopyWithImpl; +@override @useResult +$Res call({ +@JsonKey(fromJson: _intRequired) int index,@JsonKey(includeIfNull: false) String? title,@JsonKey(includeIfNull: false) String? language,@JsonKey(fromJson: _boolOrFalse) bool isDefault,@JsonKey(fromJson: _boolOrFalse) bool isForced,@JsonKey(fromJson: _boolOrFalse) bool isExternal,@JsonKey(includeIfNull: false) String? deliveryUrl,@JsonKey(includeIfNull: false) String? codec +}); + + + + +} +/// @nodoc +class __$EmbySubtitleTrackCopyWithImpl<$Res> + implements _$EmbySubtitleTrackCopyWith<$Res> { + __$EmbySubtitleTrackCopyWithImpl(this._self, this._then); + + final _EmbySubtitleTrack _self; + final $Res Function(_EmbySubtitleTrack) _then; + +/// Create a copy of EmbySubtitleTrack +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? index = null,Object? title = freezed,Object? language = freezed,Object? isDefault = null,Object? isForced = null,Object? isExternal = null,Object? deliveryUrl = freezed,Object? codec = freezed,}) { + return _then(_EmbySubtitleTrack( +index: null == index ? _self.index : index // ignore: cast_nullable_to_non_nullable +as int,title: freezed == title ? _self.title : title // ignore: cast_nullable_to_non_nullable +as String?,language: freezed == language ? _self.language : language // ignore: cast_nullable_to_non_nullable +as String?,isDefault: null == isDefault ? _self.isDefault : isDefault // ignore: cast_nullable_to_non_nullable +as bool,isForced: null == isForced ? _self.isForced : isForced // ignore: cast_nullable_to_non_nullable +as bool,isExternal: null == isExternal ? _self.isExternal : isExternal // ignore: cast_nullable_to_non_nullable +as bool,deliveryUrl: freezed == deliveryUrl ? _self.deliveryUrl : deliveryUrl // ignore: cast_nullable_to_non_nullable +as String?,codec: freezed == codec ? _self.codec : codec // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + + +} + + +/// @nodoc +mixin _$EmbyAudioTrack { + +@JsonKey(fromJson: _intRequired) int get index;@JsonKey(includeIfNull: false) String? get title;@JsonKey(includeIfNull: false) String? get language;@JsonKey(includeIfNull: false) String? get codec;@JsonKey(fromJson: _intOrNull, includeIfNull: false) int? get channels;@JsonKey(includeIfNull: false) String? get displayTitle;@JsonKey(fromJson: _boolOrFalse) bool get isDefault; +/// Create a copy of EmbyAudioTrack +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$EmbyAudioTrackCopyWith get copyWith => _$EmbyAudioTrackCopyWithImpl(this as EmbyAudioTrack, _$identity); + + /// Serializes this EmbyAudioTrack to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is EmbyAudioTrack&&(identical(other.index, index) || other.index == index)&&(identical(other.title, title) || other.title == title)&&(identical(other.language, language) || other.language == language)&&(identical(other.codec, codec) || other.codec == codec)&&(identical(other.channels, channels) || other.channels == channels)&&(identical(other.displayTitle, displayTitle) || other.displayTitle == displayTitle)&&(identical(other.isDefault, isDefault) || other.isDefault == isDefault)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,index,title,language,codec,channels,displayTitle,isDefault); + +@override +String toString() { + return 'EmbyAudioTrack(index: $index, title: $title, language: $language, codec: $codec, channels: $channels, displayTitle: $displayTitle, isDefault: $isDefault)'; +} + + +} + +/// @nodoc +abstract mixin class $EmbyAudioTrackCopyWith<$Res> { + factory $EmbyAudioTrackCopyWith(EmbyAudioTrack value, $Res Function(EmbyAudioTrack) _then) = _$EmbyAudioTrackCopyWithImpl; +@useResult +$Res call({ +@JsonKey(fromJson: _intRequired) int index,@JsonKey(includeIfNull: false) String? title,@JsonKey(includeIfNull: false) String? language,@JsonKey(includeIfNull: false) String? codec,@JsonKey(fromJson: _intOrNull, includeIfNull: false) int? channels,@JsonKey(includeIfNull: false) String? displayTitle,@JsonKey(fromJson: _boolOrFalse) bool isDefault +}); + + + + +} +/// @nodoc +class _$EmbyAudioTrackCopyWithImpl<$Res> + implements $EmbyAudioTrackCopyWith<$Res> { + _$EmbyAudioTrackCopyWithImpl(this._self, this._then); + + final EmbyAudioTrack _self; + final $Res Function(EmbyAudioTrack) _then; + +/// Create a copy of EmbyAudioTrack +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? index = null,Object? title = freezed,Object? language = freezed,Object? codec = freezed,Object? channels = freezed,Object? displayTitle = freezed,Object? isDefault = null,}) { + return _then(_self.copyWith( +index: null == index ? _self.index : index // ignore: cast_nullable_to_non_nullable +as int,title: freezed == title ? _self.title : title // ignore: cast_nullable_to_non_nullable +as String?,language: freezed == language ? _self.language : language // ignore: cast_nullable_to_non_nullable +as String?,codec: freezed == codec ? _self.codec : codec // ignore: cast_nullable_to_non_nullable +as String?,channels: freezed == channels ? _self.channels : channels // ignore: cast_nullable_to_non_nullable +as int?,displayTitle: freezed == displayTitle ? _self.displayTitle : displayTitle // ignore: cast_nullable_to_non_nullable +as String?,isDefault: null == isDefault ? _self.isDefault : isDefault // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + +} + + +/// Adds pattern-matching-related methods to [EmbyAudioTrack]. +extension EmbyAudioTrackPatterns on EmbyAudioTrack { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _EmbyAudioTrack value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _EmbyAudioTrack() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _EmbyAudioTrack value) $default,){ +final _that = this; +switch (_that) { +case _EmbyAudioTrack(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _EmbyAudioTrack value)? $default,){ +final _that = this; +switch (_that) { +case _EmbyAudioTrack() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function(@JsonKey(fromJson: _intRequired) int index, @JsonKey(includeIfNull: false) String? title, @JsonKey(includeIfNull: false) String? language, @JsonKey(includeIfNull: false) String? codec, @JsonKey(fromJson: _intOrNull, includeIfNull: false) int? channels, @JsonKey(includeIfNull: false) String? displayTitle, @JsonKey(fromJson: _boolOrFalse) bool isDefault)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _EmbyAudioTrack() when $default != null: +return $default(_that.index,_that.title,_that.language,_that.codec,_that.channels,_that.displayTitle,_that.isDefault);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function(@JsonKey(fromJson: _intRequired) int index, @JsonKey(includeIfNull: false) String? title, @JsonKey(includeIfNull: false) String? language, @JsonKey(includeIfNull: false) String? codec, @JsonKey(fromJson: _intOrNull, includeIfNull: false) int? channels, @JsonKey(includeIfNull: false) String? displayTitle, @JsonKey(fromJson: _boolOrFalse) bool isDefault) $default,) {final _that = this; +switch (_that) { +case _EmbyAudioTrack(): +return $default(_that.index,_that.title,_that.language,_that.codec,_that.channels,_that.displayTitle,_that.isDefault);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function(@JsonKey(fromJson: _intRequired) int index, @JsonKey(includeIfNull: false) String? title, @JsonKey(includeIfNull: false) String? language, @JsonKey(includeIfNull: false) String? codec, @JsonKey(fromJson: _intOrNull, includeIfNull: false) int? channels, @JsonKey(includeIfNull: false) String? displayTitle, @JsonKey(fromJson: _boolOrFalse) bool isDefault)? $default,) {final _that = this; +switch (_that) { +case _EmbyAudioTrack() when $default != null: +return $default(_that.index,_that.title,_that.language,_that.codec,_that.channels,_that.displayTitle,_that.isDefault);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _EmbyAudioTrack implements EmbyAudioTrack { + const _EmbyAudioTrack({@JsonKey(fromJson: _intRequired) required this.index, @JsonKey(includeIfNull: false) this.title, @JsonKey(includeIfNull: false) this.language, @JsonKey(includeIfNull: false) this.codec, @JsonKey(fromJson: _intOrNull, includeIfNull: false) this.channels, @JsonKey(includeIfNull: false) this.displayTitle, @JsonKey(fromJson: _boolOrFalse) this.isDefault = false}); + factory _EmbyAudioTrack.fromJson(Map json) => _$EmbyAudioTrackFromJson(json); + +@override@JsonKey(fromJson: _intRequired) final int index; +@override@JsonKey(includeIfNull: false) final String? title; +@override@JsonKey(includeIfNull: false) final String? language; +@override@JsonKey(includeIfNull: false) final String? codec; +@override@JsonKey(fromJson: _intOrNull, includeIfNull: false) final int? channels; +@override@JsonKey(includeIfNull: false) final String? displayTitle; +@override@JsonKey(fromJson: _boolOrFalse) final bool isDefault; + +/// Create a copy of EmbyAudioTrack +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$EmbyAudioTrackCopyWith<_EmbyAudioTrack> get copyWith => __$EmbyAudioTrackCopyWithImpl<_EmbyAudioTrack>(this, _$identity); + +@override +Map toJson() { + return _$EmbyAudioTrackToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _EmbyAudioTrack&&(identical(other.index, index) || other.index == index)&&(identical(other.title, title) || other.title == title)&&(identical(other.language, language) || other.language == language)&&(identical(other.codec, codec) || other.codec == codec)&&(identical(other.channels, channels) || other.channels == channels)&&(identical(other.displayTitle, displayTitle) || other.displayTitle == displayTitle)&&(identical(other.isDefault, isDefault) || other.isDefault == isDefault)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,index,title,language,codec,channels,displayTitle,isDefault); + +@override +String toString() { + return 'EmbyAudioTrack(index: $index, title: $title, language: $language, codec: $codec, channels: $channels, displayTitle: $displayTitle, isDefault: $isDefault)'; +} + + +} + +/// @nodoc +abstract mixin class _$EmbyAudioTrackCopyWith<$Res> implements $EmbyAudioTrackCopyWith<$Res> { + factory _$EmbyAudioTrackCopyWith(_EmbyAudioTrack value, $Res Function(_EmbyAudioTrack) _then) = __$EmbyAudioTrackCopyWithImpl; +@override @useResult +$Res call({ +@JsonKey(fromJson: _intRequired) int index,@JsonKey(includeIfNull: false) String? title,@JsonKey(includeIfNull: false) String? language,@JsonKey(includeIfNull: false) String? codec,@JsonKey(fromJson: _intOrNull, includeIfNull: false) int? channels,@JsonKey(includeIfNull: false) String? displayTitle,@JsonKey(fromJson: _boolOrFalse) bool isDefault +}); + + + + +} +/// @nodoc +class __$EmbyAudioTrackCopyWithImpl<$Res> + implements _$EmbyAudioTrackCopyWith<$Res> { + __$EmbyAudioTrackCopyWithImpl(this._self, this._then); + + final _EmbyAudioTrack _self; + final $Res Function(_EmbyAudioTrack) _then; + +/// Create a copy of EmbyAudioTrack +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? index = null,Object? title = freezed,Object? language = freezed,Object? codec = freezed,Object? channels = freezed,Object? displayTitle = freezed,Object? isDefault = null,}) { + return _then(_EmbyAudioTrack( +index: null == index ? _self.index : index // ignore: cast_nullable_to_non_nullable +as int,title: freezed == title ? _self.title : title // ignore: cast_nullable_to_non_nullable +as String?,language: freezed == language ? _self.language : language // ignore: cast_nullable_to_non_nullable +as String?,codec: freezed == codec ? _self.codec : codec // ignore: cast_nullable_to_non_nullable +as String?,channels: freezed == channels ? _self.channels : channels // ignore: cast_nullable_to_non_nullable +as int?,displayTitle: freezed == displayTitle ? _self.displayTitle : displayTitle // ignore: cast_nullable_to_non_nullable +as String?,isDefault: null == isDefault ? _self.isDefault : isDefault // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + + +} + +// dart format on diff --git a/lib/core/contracts/playback.g.dart b/lib/core/contracts/playback.g.dart new file mode 100644 index 0000000..80a44ce --- /dev/null +++ b/lib/core/contracts/playback.g.dart @@ -0,0 +1,81 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'playback.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_PlaybackRequestPayload _$PlaybackRequestPayloadFromJson( + Map json, +) => _PlaybackRequestPayload( + itemId: json['itemId'] as String, + itemType: json['itemType'] as String?, + mediaSourceId: json['mediaSourceId'] as String?, + startPositionTicks: (json['startPositionTicks'] as num?)?.toInt(), + playFromStart: json['playFromStart'] as bool? ?? false, +); + +Map _$PlaybackRequestPayloadToJson( + _PlaybackRequestPayload instance, +) => { + 'itemId': instance.itemId, + 'itemType': ?instance.itemType, + 'mediaSourceId': ?instance.mediaSourceId, + 'startPositionTicks': ?instance.startPositionTicks, + 'playFromStart': instance.playFromStart, +}; + +_EmbySubtitleTrack _$EmbySubtitleTrackFromJson(Map json) => + _EmbySubtitleTrack( + index: _intRequired(json['index']), + title: json['title'] as String?, + language: json['language'] as String?, + isDefault: json['isDefault'] == null + ? false + : _boolOrFalse(json['isDefault']), + isForced: json['isForced'] == null + ? false + : _boolOrFalse(json['isForced']), + isExternal: json['isExternal'] == null + ? false + : _boolOrFalse(json['isExternal']), + deliveryUrl: json['deliveryUrl'] as String?, + codec: json['codec'] as String?, + ); + +Map _$EmbySubtitleTrackToJson(_EmbySubtitleTrack instance) => + { + 'index': instance.index, + 'title': ?instance.title, + 'language': ?instance.language, + 'isDefault': instance.isDefault, + 'isForced': instance.isForced, + 'isExternal': instance.isExternal, + 'deliveryUrl': ?instance.deliveryUrl, + 'codec': ?instance.codec, + }; + +_EmbyAudioTrack _$EmbyAudioTrackFromJson(Map json) => + _EmbyAudioTrack( + index: _intRequired(json['index']), + title: json['title'] as String?, + language: json['language'] as String?, + codec: json['codec'] as String?, + channels: _intOrNull(json['channels']), + displayTitle: json['displayTitle'] as String?, + isDefault: json['isDefault'] == null + ? false + : _boolOrFalse(json['isDefault']), + ); + +Map _$EmbyAudioTrackToJson(_EmbyAudioTrack instance) => + { + 'index': instance.index, + 'title': ?instance.title, + 'language': ?instance.language, + 'codec': ?instance.codec, + 'channels': ?instance.channels, + 'displayTitle': ?instance.displayTitle, + 'isDefault': instance.isDefault, + }; diff --git a/lib/core/contracts/player_gestures.dart b/lib/core/contracts/player_gestures.dart new file mode 100644 index 0000000..d95248d --- /dev/null +++ b/lib/core/contracts/player_gestures.dart @@ -0,0 +1,19 @@ +enum GestureAction { + none, + playPause, + fullscreen, + toggleControls, + seekBackward, + seekForward, + previousItem, + nextItem, + exit, +} + +GestureAction gestureActionFromName(String? name, GestureAction fallback) { + if (name == null) return fallback; + for (final v in GestureAction.values) { + if (v.name == name) return v; + } + return fallback; +} diff --git a/lib/core/contracts/script_widget.dart b/lib/core/contracts/script_widget.dart new file mode 100644 index 0000000..c651c62 --- /dev/null +++ b/lib/core/contracts/script_widget.dart @@ -0,0 +1,280 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'script_widget.freezed.dart'; +part 'script_widget.g.dart'; + +@freezed +abstract class ScriptWidgetOption with _$ScriptWidgetOption { + const factory ScriptWidgetOption({ + @Default('') String title, + @JsonKey(fromJson: _stringFromAny) @Default('') String value, + }) = _ScriptWidgetOption; + + factory ScriptWidgetOption.fromJson(Map json) => + _$ScriptWidgetOptionFromJson(json); +} + +@freezed +abstract class ScriptWidgetBelongTo with _$ScriptWidgetBelongTo { + const factory ScriptWidgetBelongTo({ + @Default('') String paramName, + @JsonKey(fromJson: _stringListFromAny) + @Default([]) + List value, + }) = _ScriptWidgetBelongTo; + + factory ScriptWidgetBelongTo.fromJson(Map json) => + _$ScriptWidgetBelongToFromJson(json); +} + +@freezed +abstract class ScriptWidgetParam with _$ScriptWidgetParam { + const factory ScriptWidgetParam({ + required String name, + @Default('') String title, + @Default('input') String type, + @Default('') String description, + Object? value, + ScriptWidgetBelongTo? belongTo, + @Default([]) List placeholders, + @Default([]) List enumOptions, + }) = _ScriptWidgetParam; + + factory ScriptWidgetParam.fromJson(Map json) => + _$ScriptWidgetParamFromJson(json); +} + +Map buildScriptWidgetParameterDefaults( + List parameters, +) { + return { + for (final parameter in parameters) + parameter.name: _resolveScriptWidgetParameterDefault(parameter), + }; +} + +Object? _resolveScriptWidgetParameterDefault(ScriptWidgetParam parameter) { + if (parameter.value != null) return parameter.value; + if (parameter.enumOptions.isNotEmpty) { + return parameter.enumOptions.first.value; + } + if (parameter.placeholders.isNotEmpty) { + return parameter.placeholders.first.value; + } + return ''; +} + +@freezed +abstract class ScriptWidgetModule with _$ScriptWidgetModule { + const factory ScriptWidgetModule({ + @Default('') String id, + required String title, + @Default('') String description, + @Default(false) bool requiresWebView, + required String functionName, + @Default(false) bool sectionMode, + @Default(3600) int cacheDuration, + @Default('list') String type, + @Default([]) List params, + }) = _ScriptWidgetModule; + + factory ScriptWidgetModule.fromJson(Map json) => + _$ScriptWidgetModuleFromJson(json); +} + +@freezed +abstract class ScriptWidgetManifest with _$ScriptWidgetManifest { + const factory ScriptWidgetManifest({ + required String id, + required String title, + @Default('') String description, + @Default('') String author, + @Default('') String site, + @Default('0.0.0') String version, + @Default('0.0.1') String requiredVersion, + @Default(60) int detailCacheDuration, + @Default([]) List globalParams, + @Default([]) List modules, + ScriptWidgetModule? search, + }) = _ScriptWidgetManifest; + + factory ScriptWidgetManifest.fromJson(Map json) => + _$ScriptWidgetManifestFromJson(json); +} + +@freezed +abstract class ScriptVideoPerson with _$ScriptVideoPerson { + const factory ScriptVideoPerson({ + @JsonKey(fromJson: _stringFromAny) @Default('') String id, + @Default('') String title, + @Default('') String avatar, + @Default('') String role, + }) = _ScriptVideoPerson; + + factory ScriptVideoPerson.fromJson(Map json) => + _$ScriptVideoPersonFromJson(json); +} + +@freezed +abstract class ScriptVideoGenre with _$ScriptVideoGenre { + const factory ScriptVideoGenre({ + @JsonKey(fromJson: _stringFromAny) @Default('') String id, + @Default('') String title, + }) = _ScriptVideoGenre; + + factory ScriptVideoGenre.fromJson(Map json) => + _$ScriptVideoGenreFromJson(json); +} + +@freezed +abstract class ScriptVideoTrailer with _$ScriptVideoTrailer { + const factory ScriptVideoTrailer({ + @Default('') String coverUrl, + @Default('') String url, + }) = _ScriptVideoTrailer; + + factory ScriptVideoTrailer.fromJson(Map json) => + _$ScriptVideoTrailerFromJson(json); +} + +@freezed +abstract class ScriptVideoItem with _$ScriptVideoItem { + const factory ScriptVideoItem({ + @JsonKey(fromJson: _stringFromAny) required String id, + @Default('link') String type, + @Default('') String title, + @Default('') String coverUrl, + @JsonKey(readValue: _readPosterPath) @Default('') String posterPath, + @Default('') String detailPoster, + @Default('') String backdropPath, + @Default([]) List backdropPaths, + @Default('') String releaseDate, + @Default('') String mediaType, + @JsonKey(fromJson: _doubleFromAny) double? rating, + @Default('') String genreTitle, + @Default([]) List genreItems, + @Default([]) List peoples, + @JsonKey(fromJson: _intFromAny) int? duration, + @Default('') String durationText, + @Default('') String previewUrl, + @Default([]) List trailers, + @Default('') String videoUrl, + @Default('') String link, + @JsonKey(fromJson: _intFromAny) int? episode, + @Default('') String description, + @Default('app') String playerType, + @Default([]) List childItems, + @Default([]) List episodeItems, + @Default([]) List relatedItems, + }) = _ScriptVideoItem; + + factory ScriptVideoItem.fromJson(Map json) => + _$ScriptVideoItemFromJson(json); +} + +@freezed +abstract class ScriptVideoResource with _$ScriptVideoResource { + const factory ScriptVideoResource({ + @Default('') String name, + @Default('') String description, + required String url, + @JsonKey(readValue: _readResourceHeaders) + @Default({}) + Map customHeaders, + @Default('app') String playerType, + }) = _ScriptVideoResource; + + factory ScriptVideoResource.fromJson(Map json) => + _$ScriptVideoResourceFromJson(json); +} + +@freezed +abstract class InstalledScriptWidget with _$InstalledScriptWidget { + const factory InstalledScriptWidget({ + required ScriptWidgetManifest manifest, + required String scriptSource, + @Default('') String sourceUrl, + @Default(true) bool enabled, + @Default({}) Map globalParams, + required DateTime installedAt, + required DateTime updatedAt, + }) = _InstalledScriptWidget; + + factory InstalledScriptWidget.fromJson(Map json) => + _$InstalledScriptWidgetFromJson(json); +} + +@freezed +abstract class ScriptWidgetCatalogEntry with _$ScriptWidgetCatalogEntry { + const factory ScriptWidgetCatalogEntry({ + required String id, + required String title, + @Default('') String description, + @Default('') String requiredVersion, + @Default('') String version, + @Default('') String author, + required String url, + }) = _ScriptWidgetCatalogEntry; + + factory ScriptWidgetCatalogEntry.fromJson(Map json) => + _$ScriptWidgetCatalogEntryFromJson(json); +} + +@freezed +abstract class ScriptWidgetCatalog with _$ScriptWidgetCatalog { + const factory ScriptWidgetCatalog({ + required String title, + @Default('') String description, + @Default('') String icon, + @Default([]) + List widgets, + }) = _ScriptWidgetCatalog; + + factory ScriptWidgetCatalog.fromJson(Map json) => + _$ScriptWidgetCatalogFromJson(json); +} + +@freezed +abstract class ScriptWidgetSubscription with _$ScriptWidgetSubscription { + const factory ScriptWidgetSubscription({ + required String url, + required ScriptWidgetCatalog catalog, + required DateTime refreshedAt, + }) = _ScriptWidgetSubscription; + + factory ScriptWidgetSubscription.fromJson(Map json) => + _$ScriptWidgetSubscriptionFromJson(json); +} + +String _stringFromAny(Object? value) => value?.toString() ?? ''; + +int? _intFromAny(Object? value) { + if (value is num) return value.toInt(); + return int.tryParse(value?.toString() ?? ''); +} + +double? _doubleFromAny(Object? value) { + if (value is num) return value.toDouble(); + return double.tryParse(value?.toString() ?? ''); +} + +List _stringListFromAny(Object? value) { + if (value is List) { + return value.map((item) => item.toString()).toList(growable: false); + } + if (value == null) return const []; + return [value.toString()]; +} + +Object? _readPosterPath(Map json, String key) { + return json[key] ?? json['posterUrl'] ?? json['poster_url']; +} + +Object? _readResourceHeaders(Map json, String key) { + final value = json[key] ?? json['headers']; + if (value is! Map) return const {}; + return { + for (final entry in value.entries) + entry.key.toString(): entry.value.toString(), + }; +} diff --git a/lib/core/contracts/script_widget.freezed.dart b/lib/core/contracts/script_widget.freezed.dart new file mode 100644 index 0000000..e17dbd5 --- /dev/null +++ b/lib/core/contracts/script_widget.freezed.dart @@ -0,0 +1,4108 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'script_widget.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$ScriptWidgetOption { + + String get title;@JsonKey(fromJson: _stringFromAny) String get value; +/// Create a copy of ScriptWidgetOption +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$ScriptWidgetOptionCopyWith get copyWith => _$ScriptWidgetOptionCopyWithImpl(this as ScriptWidgetOption, _$identity); + + /// Serializes this ScriptWidgetOption to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is ScriptWidgetOption&&(identical(other.title, title) || other.title == title)&&(identical(other.value, value) || other.value == value)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,title,value); + +@override +String toString() { + return 'ScriptWidgetOption(title: $title, value: $value)'; +} + + +} + +/// @nodoc +abstract mixin class $ScriptWidgetOptionCopyWith<$Res> { + factory $ScriptWidgetOptionCopyWith(ScriptWidgetOption value, $Res Function(ScriptWidgetOption) _then) = _$ScriptWidgetOptionCopyWithImpl; +@useResult +$Res call({ + String title,@JsonKey(fromJson: _stringFromAny) String value +}); + + + + +} +/// @nodoc +class _$ScriptWidgetOptionCopyWithImpl<$Res> + implements $ScriptWidgetOptionCopyWith<$Res> { + _$ScriptWidgetOptionCopyWithImpl(this._self, this._then); + + final ScriptWidgetOption _self; + final $Res Function(ScriptWidgetOption) _then; + +/// Create a copy of ScriptWidgetOption +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? title = null,Object? value = null,}) { + return _then(_self.copyWith( +title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable +as String,value: null == value ? _self.value : value // ignore: cast_nullable_to_non_nullable +as String, + )); +} + +} + + +/// Adds pattern-matching-related methods to [ScriptWidgetOption]. +extension ScriptWidgetOptionPatterns on ScriptWidgetOption { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _ScriptWidgetOption value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _ScriptWidgetOption() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _ScriptWidgetOption value) $default,){ +final _that = this; +switch (_that) { +case _ScriptWidgetOption(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _ScriptWidgetOption value)? $default,){ +final _that = this; +switch (_that) { +case _ScriptWidgetOption() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String title, @JsonKey(fromJson: _stringFromAny) String value)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _ScriptWidgetOption() when $default != null: +return $default(_that.title,_that.value);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String title, @JsonKey(fromJson: _stringFromAny) String value) $default,) {final _that = this; +switch (_that) { +case _ScriptWidgetOption(): +return $default(_that.title,_that.value);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String title, @JsonKey(fromJson: _stringFromAny) String value)? $default,) {final _that = this; +switch (_that) { +case _ScriptWidgetOption() when $default != null: +return $default(_that.title,_that.value);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _ScriptWidgetOption implements ScriptWidgetOption { + const _ScriptWidgetOption({this.title = '', @JsonKey(fromJson: _stringFromAny) this.value = ''}); + factory _ScriptWidgetOption.fromJson(Map json) => _$ScriptWidgetOptionFromJson(json); + +@override@JsonKey() final String title; +@override@JsonKey(fromJson: _stringFromAny) final String value; + +/// Create a copy of ScriptWidgetOption +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$ScriptWidgetOptionCopyWith<_ScriptWidgetOption> get copyWith => __$ScriptWidgetOptionCopyWithImpl<_ScriptWidgetOption>(this, _$identity); + +@override +Map toJson() { + return _$ScriptWidgetOptionToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _ScriptWidgetOption&&(identical(other.title, title) || other.title == title)&&(identical(other.value, value) || other.value == value)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,title,value); + +@override +String toString() { + return 'ScriptWidgetOption(title: $title, value: $value)'; +} + + +} + +/// @nodoc +abstract mixin class _$ScriptWidgetOptionCopyWith<$Res> implements $ScriptWidgetOptionCopyWith<$Res> { + factory _$ScriptWidgetOptionCopyWith(_ScriptWidgetOption value, $Res Function(_ScriptWidgetOption) _then) = __$ScriptWidgetOptionCopyWithImpl; +@override @useResult +$Res call({ + String title,@JsonKey(fromJson: _stringFromAny) String value +}); + + + + +} +/// @nodoc +class __$ScriptWidgetOptionCopyWithImpl<$Res> + implements _$ScriptWidgetOptionCopyWith<$Res> { + __$ScriptWidgetOptionCopyWithImpl(this._self, this._then); + + final _ScriptWidgetOption _self; + final $Res Function(_ScriptWidgetOption) _then; + +/// Create a copy of ScriptWidgetOption +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? title = null,Object? value = null,}) { + return _then(_ScriptWidgetOption( +title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable +as String,value: null == value ? _self.value : value // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + + +/// @nodoc +mixin _$ScriptWidgetBelongTo { + + String get paramName;@JsonKey(fromJson: _stringListFromAny) List get value; +/// Create a copy of ScriptWidgetBelongTo +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$ScriptWidgetBelongToCopyWith get copyWith => _$ScriptWidgetBelongToCopyWithImpl(this as ScriptWidgetBelongTo, _$identity); + + /// Serializes this ScriptWidgetBelongTo to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is ScriptWidgetBelongTo&&(identical(other.paramName, paramName) || other.paramName == paramName)&&const DeepCollectionEquality().equals(other.value, value)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,paramName,const DeepCollectionEquality().hash(value)); + +@override +String toString() { + return 'ScriptWidgetBelongTo(paramName: $paramName, value: $value)'; +} + + +} + +/// @nodoc +abstract mixin class $ScriptWidgetBelongToCopyWith<$Res> { + factory $ScriptWidgetBelongToCopyWith(ScriptWidgetBelongTo value, $Res Function(ScriptWidgetBelongTo) _then) = _$ScriptWidgetBelongToCopyWithImpl; +@useResult +$Res call({ + String paramName,@JsonKey(fromJson: _stringListFromAny) List value +}); + + + + +} +/// @nodoc +class _$ScriptWidgetBelongToCopyWithImpl<$Res> + implements $ScriptWidgetBelongToCopyWith<$Res> { + _$ScriptWidgetBelongToCopyWithImpl(this._self, this._then); + + final ScriptWidgetBelongTo _self; + final $Res Function(ScriptWidgetBelongTo) _then; + +/// Create a copy of ScriptWidgetBelongTo +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? paramName = null,Object? value = null,}) { + return _then(_self.copyWith( +paramName: null == paramName ? _self.paramName : paramName // ignore: cast_nullable_to_non_nullable +as String,value: null == value ? _self.value : value // ignore: cast_nullable_to_non_nullable +as List, + )); +} + +} + + +/// Adds pattern-matching-related methods to [ScriptWidgetBelongTo]. +extension ScriptWidgetBelongToPatterns on ScriptWidgetBelongTo { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _ScriptWidgetBelongTo value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _ScriptWidgetBelongTo() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _ScriptWidgetBelongTo value) $default,){ +final _that = this; +switch (_that) { +case _ScriptWidgetBelongTo(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _ScriptWidgetBelongTo value)? $default,){ +final _that = this; +switch (_that) { +case _ScriptWidgetBelongTo() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String paramName, @JsonKey(fromJson: _stringListFromAny) List value)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _ScriptWidgetBelongTo() when $default != null: +return $default(_that.paramName,_that.value);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String paramName, @JsonKey(fromJson: _stringListFromAny) List value) $default,) {final _that = this; +switch (_that) { +case _ScriptWidgetBelongTo(): +return $default(_that.paramName,_that.value);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String paramName, @JsonKey(fromJson: _stringListFromAny) List value)? $default,) {final _that = this; +switch (_that) { +case _ScriptWidgetBelongTo() when $default != null: +return $default(_that.paramName,_that.value);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _ScriptWidgetBelongTo implements ScriptWidgetBelongTo { + const _ScriptWidgetBelongTo({this.paramName = '', @JsonKey(fromJson: _stringListFromAny) final List value = const []}): _value = value; + factory _ScriptWidgetBelongTo.fromJson(Map json) => _$ScriptWidgetBelongToFromJson(json); + +@override@JsonKey() final String paramName; + final List _value; +@override@JsonKey(fromJson: _stringListFromAny) List get value { + if (_value is EqualUnmodifiableListView) return _value; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_value); +} + + +/// Create a copy of ScriptWidgetBelongTo +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$ScriptWidgetBelongToCopyWith<_ScriptWidgetBelongTo> get copyWith => __$ScriptWidgetBelongToCopyWithImpl<_ScriptWidgetBelongTo>(this, _$identity); + +@override +Map toJson() { + return _$ScriptWidgetBelongToToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _ScriptWidgetBelongTo&&(identical(other.paramName, paramName) || other.paramName == paramName)&&const DeepCollectionEquality().equals(other._value, _value)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,paramName,const DeepCollectionEquality().hash(_value)); + +@override +String toString() { + return 'ScriptWidgetBelongTo(paramName: $paramName, value: $value)'; +} + + +} + +/// @nodoc +abstract mixin class _$ScriptWidgetBelongToCopyWith<$Res> implements $ScriptWidgetBelongToCopyWith<$Res> { + factory _$ScriptWidgetBelongToCopyWith(_ScriptWidgetBelongTo value, $Res Function(_ScriptWidgetBelongTo) _then) = __$ScriptWidgetBelongToCopyWithImpl; +@override @useResult +$Res call({ + String paramName,@JsonKey(fromJson: _stringListFromAny) List value +}); + + + + +} +/// @nodoc +class __$ScriptWidgetBelongToCopyWithImpl<$Res> + implements _$ScriptWidgetBelongToCopyWith<$Res> { + __$ScriptWidgetBelongToCopyWithImpl(this._self, this._then); + + final _ScriptWidgetBelongTo _self; + final $Res Function(_ScriptWidgetBelongTo) _then; + +/// Create a copy of ScriptWidgetBelongTo +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? paramName = null,Object? value = null,}) { + return _then(_ScriptWidgetBelongTo( +paramName: null == paramName ? _self.paramName : paramName // ignore: cast_nullable_to_non_nullable +as String,value: null == value ? _self._value : value // ignore: cast_nullable_to_non_nullable +as List, + )); +} + + +} + + +/// @nodoc +mixin _$ScriptWidgetParam { + + String get name; String get title; String get type; String get description; Object? get value; ScriptWidgetBelongTo? get belongTo; List get placeholders; List get enumOptions; +/// Create a copy of ScriptWidgetParam +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$ScriptWidgetParamCopyWith get copyWith => _$ScriptWidgetParamCopyWithImpl(this as ScriptWidgetParam, _$identity); + + /// Serializes this ScriptWidgetParam to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is ScriptWidgetParam&&(identical(other.name, name) || other.name == name)&&(identical(other.title, title) || other.title == title)&&(identical(other.type, type) || other.type == type)&&(identical(other.description, description) || other.description == description)&&const DeepCollectionEquality().equals(other.value, value)&&(identical(other.belongTo, belongTo) || other.belongTo == belongTo)&&const DeepCollectionEquality().equals(other.placeholders, placeholders)&&const DeepCollectionEquality().equals(other.enumOptions, enumOptions)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,name,title,type,description,const DeepCollectionEquality().hash(value),belongTo,const DeepCollectionEquality().hash(placeholders),const DeepCollectionEquality().hash(enumOptions)); + +@override +String toString() { + return 'ScriptWidgetParam(name: $name, title: $title, type: $type, description: $description, value: $value, belongTo: $belongTo, placeholders: $placeholders, enumOptions: $enumOptions)'; +} + + +} + +/// @nodoc +abstract mixin class $ScriptWidgetParamCopyWith<$Res> { + factory $ScriptWidgetParamCopyWith(ScriptWidgetParam value, $Res Function(ScriptWidgetParam) _then) = _$ScriptWidgetParamCopyWithImpl; +@useResult +$Res call({ + String name, String title, String type, String description, Object? value, ScriptWidgetBelongTo? belongTo, List placeholders, List enumOptions +}); + + +$ScriptWidgetBelongToCopyWith<$Res>? get belongTo; + +} +/// @nodoc +class _$ScriptWidgetParamCopyWithImpl<$Res> + implements $ScriptWidgetParamCopyWith<$Res> { + _$ScriptWidgetParamCopyWithImpl(this._self, this._then); + + final ScriptWidgetParam _self; + final $Res Function(ScriptWidgetParam) _then; + +/// Create a copy of ScriptWidgetParam +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? name = null,Object? title = null,Object? type = null,Object? description = null,Object? value = freezed,Object? belongTo = freezed,Object? placeholders = null,Object? enumOptions = null,}) { + return _then(_self.copyWith( +name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable +as String,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable +as String,description: null == description ? _self.description : description // ignore: cast_nullable_to_non_nullable +as String,value: freezed == value ? _self.value : value ,belongTo: freezed == belongTo ? _self.belongTo : belongTo // ignore: cast_nullable_to_non_nullable +as ScriptWidgetBelongTo?,placeholders: null == placeholders ? _self.placeholders : placeholders // ignore: cast_nullable_to_non_nullable +as List,enumOptions: null == enumOptions ? _self.enumOptions : enumOptions // ignore: cast_nullable_to_non_nullable +as List, + )); +} +/// Create a copy of ScriptWidgetParam +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$ScriptWidgetBelongToCopyWith<$Res>? get belongTo { + if (_self.belongTo == null) { + return null; + } + + return $ScriptWidgetBelongToCopyWith<$Res>(_self.belongTo!, (value) { + return _then(_self.copyWith(belongTo: value)); + }); +} +} + + +/// Adds pattern-matching-related methods to [ScriptWidgetParam]. +extension ScriptWidgetParamPatterns on ScriptWidgetParam { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _ScriptWidgetParam value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _ScriptWidgetParam() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _ScriptWidgetParam value) $default,){ +final _that = this; +switch (_that) { +case _ScriptWidgetParam(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _ScriptWidgetParam value)? $default,){ +final _that = this; +switch (_that) { +case _ScriptWidgetParam() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String name, String title, String type, String description, Object? value, ScriptWidgetBelongTo? belongTo, List placeholders, List enumOptions)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _ScriptWidgetParam() when $default != null: +return $default(_that.name,_that.title,_that.type,_that.description,_that.value,_that.belongTo,_that.placeholders,_that.enumOptions);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String name, String title, String type, String description, Object? value, ScriptWidgetBelongTo? belongTo, List placeholders, List enumOptions) $default,) {final _that = this; +switch (_that) { +case _ScriptWidgetParam(): +return $default(_that.name,_that.title,_that.type,_that.description,_that.value,_that.belongTo,_that.placeholders,_that.enumOptions);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String name, String title, String type, String description, Object? value, ScriptWidgetBelongTo? belongTo, List placeholders, List enumOptions)? $default,) {final _that = this; +switch (_that) { +case _ScriptWidgetParam() when $default != null: +return $default(_that.name,_that.title,_that.type,_that.description,_that.value,_that.belongTo,_that.placeholders,_that.enumOptions);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _ScriptWidgetParam implements ScriptWidgetParam { + const _ScriptWidgetParam({required this.name, this.title = '', this.type = 'input', this.description = '', this.value, this.belongTo, final List placeholders = const [], final List enumOptions = const []}): _placeholders = placeholders,_enumOptions = enumOptions; + factory _ScriptWidgetParam.fromJson(Map json) => _$ScriptWidgetParamFromJson(json); + +@override final String name; +@override@JsonKey() final String title; +@override@JsonKey() final String type; +@override@JsonKey() final String description; +@override final Object? value; +@override final ScriptWidgetBelongTo? belongTo; + final List _placeholders; +@override@JsonKey() List get placeholders { + if (_placeholders is EqualUnmodifiableListView) return _placeholders; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_placeholders); +} + + final List _enumOptions; +@override@JsonKey() List get enumOptions { + if (_enumOptions is EqualUnmodifiableListView) return _enumOptions; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_enumOptions); +} + + +/// Create a copy of ScriptWidgetParam +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$ScriptWidgetParamCopyWith<_ScriptWidgetParam> get copyWith => __$ScriptWidgetParamCopyWithImpl<_ScriptWidgetParam>(this, _$identity); + +@override +Map toJson() { + return _$ScriptWidgetParamToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _ScriptWidgetParam&&(identical(other.name, name) || other.name == name)&&(identical(other.title, title) || other.title == title)&&(identical(other.type, type) || other.type == type)&&(identical(other.description, description) || other.description == description)&&const DeepCollectionEquality().equals(other.value, value)&&(identical(other.belongTo, belongTo) || other.belongTo == belongTo)&&const DeepCollectionEquality().equals(other._placeholders, _placeholders)&&const DeepCollectionEquality().equals(other._enumOptions, _enumOptions)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,name,title,type,description,const DeepCollectionEquality().hash(value),belongTo,const DeepCollectionEquality().hash(_placeholders),const DeepCollectionEquality().hash(_enumOptions)); + +@override +String toString() { + return 'ScriptWidgetParam(name: $name, title: $title, type: $type, description: $description, value: $value, belongTo: $belongTo, placeholders: $placeholders, enumOptions: $enumOptions)'; +} + + +} + +/// @nodoc +abstract mixin class _$ScriptWidgetParamCopyWith<$Res> implements $ScriptWidgetParamCopyWith<$Res> { + factory _$ScriptWidgetParamCopyWith(_ScriptWidgetParam value, $Res Function(_ScriptWidgetParam) _then) = __$ScriptWidgetParamCopyWithImpl; +@override @useResult +$Res call({ + String name, String title, String type, String description, Object? value, ScriptWidgetBelongTo? belongTo, List placeholders, List enumOptions +}); + + +@override $ScriptWidgetBelongToCopyWith<$Res>? get belongTo; + +} +/// @nodoc +class __$ScriptWidgetParamCopyWithImpl<$Res> + implements _$ScriptWidgetParamCopyWith<$Res> { + __$ScriptWidgetParamCopyWithImpl(this._self, this._then); + + final _ScriptWidgetParam _self; + final $Res Function(_ScriptWidgetParam) _then; + +/// Create a copy of ScriptWidgetParam +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? name = null,Object? title = null,Object? type = null,Object? description = null,Object? value = freezed,Object? belongTo = freezed,Object? placeholders = null,Object? enumOptions = null,}) { + return _then(_ScriptWidgetParam( +name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable +as String,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable +as String,description: null == description ? _self.description : description // ignore: cast_nullable_to_non_nullable +as String,value: freezed == value ? _self.value : value ,belongTo: freezed == belongTo ? _self.belongTo : belongTo // ignore: cast_nullable_to_non_nullable +as ScriptWidgetBelongTo?,placeholders: null == placeholders ? _self._placeholders : placeholders // ignore: cast_nullable_to_non_nullable +as List,enumOptions: null == enumOptions ? _self._enumOptions : enumOptions // ignore: cast_nullable_to_non_nullable +as List, + )); +} + +/// Create a copy of ScriptWidgetParam +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$ScriptWidgetBelongToCopyWith<$Res>? get belongTo { + if (_self.belongTo == null) { + return null; + } + + return $ScriptWidgetBelongToCopyWith<$Res>(_self.belongTo!, (value) { + return _then(_self.copyWith(belongTo: value)); + }); +} +} + + +/// @nodoc +mixin _$ScriptWidgetModule { + + String get id; String get title; String get description; bool get requiresWebView; String get functionName; bool get sectionMode; int get cacheDuration; String get type; List get params; +/// Create a copy of ScriptWidgetModule +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$ScriptWidgetModuleCopyWith get copyWith => _$ScriptWidgetModuleCopyWithImpl(this as ScriptWidgetModule, _$identity); + + /// Serializes this ScriptWidgetModule to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is ScriptWidgetModule&&(identical(other.id, id) || other.id == id)&&(identical(other.title, title) || other.title == title)&&(identical(other.description, description) || other.description == description)&&(identical(other.requiresWebView, requiresWebView) || other.requiresWebView == requiresWebView)&&(identical(other.functionName, functionName) || other.functionName == functionName)&&(identical(other.sectionMode, sectionMode) || other.sectionMode == sectionMode)&&(identical(other.cacheDuration, cacheDuration) || other.cacheDuration == cacheDuration)&&(identical(other.type, type) || other.type == type)&&const DeepCollectionEquality().equals(other.params, params)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,title,description,requiresWebView,functionName,sectionMode,cacheDuration,type,const DeepCollectionEquality().hash(params)); + +@override +String toString() { + return 'ScriptWidgetModule(id: $id, title: $title, description: $description, requiresWebView: $requiresWebView, functionName: $functionName, sectionMode: $sectionMode, cacheDuration: $cacheDuration, type: $type, params: $params)'; +} + + +} + +/// @nodoc +abstract mixin class $ScriptWidgetModuleCopyWith<$Res> { + factory $ScriptWidgetModuleCopyWith(ScriptWidgetModule value, $Res Function(ScriptWidgetModule) _then) = _$ScriptWidgetModuleCopyWithImpl; +@useResult +$Res call({ + String id, String title, String description, bool requiresWebView, String functionName, bool sectionMode, int cacheDuration, String type, List params +}); + + + + +} +/// @nodoc +class _$ScriptWidgetModuleCopyWithImpl<$Res> + implements $ScriptWidgetModuleCopyWith<$Res> { + _$ScriptWidgetModuleCopyWithImpl(this._self, this._then); + + final ScriptWidgetModule _self; + final $Res Function(ScriptWidgetModule) _then; + +/// Create a copy of ScriptWidgetModule +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? title = null,Object? description = null,Object? requiresWebView = null,Object? functionName = null,Object? sectionMode = null,Object? cacheDuration = null,Object? type = null,Object? params = null,}) { + return _then(_self.copyWith( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable +as String,description: null == description ? _self.description : description // ignore: cast_nullable_to_non_nullable +as String,requiresWebView: null == requiresWebView ? _self.requiresWebView : requiresWebView // ignore: cast_nullable_to_non_nullable +as bool,functionName: null == functionName ? _self.functionName : functionName // ignore: cast_nullable_to_non_nullable +as String,sectionMode: null == sectionMode ? _self.sectionMode : sectionMode // ignore: cast_nullable_to_non_nullable +as bool,cacheDuration: null == cacheDuration ? _self.cacheDuration : cacheDuration // ignore: cast_nullable_to_non_nullable +as int,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable +as String,params: null == params ? _self.params : params // ignore: cast_nullable_to_non_nullable +as List, + )); +} + +} + + +/// Adds pattern-matching-related methods to [ScriptWidgetModule]. +extension ScriptWidgetModulePatterns on ScriptWidgetModule { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _ScriptWidgetModule value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _ScriptWidgetModule() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _ScriptWidgetModule value) $default,){ +final _that = this; +switch (_that) { +case _ScriptWidgetModule(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _ScriptWidgetModule value)? $default,){ +final _that = this; +switch (_that) { +case _ScriptWidgetModule() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String id, String title, String description, bool requiresWebView, String functionName, bool sectionMode, int cacheDuration, String type, List params)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _ScriptWidgetModule() when $default != null: +return $default(_that.id,_that.title,_that.description,_that.requiresWebView,_that.functionName,_that.sectionMode,_that.cacheDuration,_that.type,_that.params);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String id, String title, String description, bool requiresWebView, String functionName, bool sectionMode, int cacheDuration, String type, List params) $default,) {final _that = this; +switch (_that) { +case _ScriptWidgetModule(): +return $default(_that.id,_that.title,_that.description,_that.requiresWebView,_that.functionName,_that.sectionMode,_that.cacheDuration,_that.type,_that.params);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String id, String title, String description, bool requiresWebView, String functionName, bool sectionMode, int cacheDuration, String type, List params)? $default,) {final _that = this; +switch (_that) { +case _ScriptWidgetModule() when $default != null: +return $default(_that.id,_that.title,_that.description,_that.requiresWebView,_that.functionName,_that.sectionMode,_that.cacheDuration,_that.type,_that.params);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _ScriptWidgetModule implements ScriptWidgetModule { + const _ScriptWidgetModule({this.id = '', required this.title, this.description = '', this.requiresWebView = false, required this.functionName, this.sectionMode = false, this.cacheDuration = 3600, this.type = 'list', final List params = const []}): _params = params; + factory _ScriptWidgetModule.fromJson(Map json) => _$ScriptWidgetModuleFromJson(json); + +@override@JsonKey() final String id; +@override final String title; +@override@JsonKey() final String description; +@override@JsonKey() final bool requiresWebView; +@override final String functionName; +@override@JsonKey() final bool sectionMode; +@override@JsonKey() final int cacheDuration; +@override@JsonKey() final String type; + final List _params; +@override@JsonKey() List get params { + if (_params is EqualUnmodifiableListView) return _params; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_params); +} + + +/// Create a copy of ScriptWidgetModule +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$ScriptWidgetModuleCopyWith<_ScriptWidgetModule> get copyWith => __$ScriptWidgetModuleCopyWithImpl<_ScriptWidgetModule>(this, _$identity); + +@override +Map toJson() { + return _$ScriptWidgetModuleToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _ScriptWidgetModule&&(identical(other.id, id) || other.id == id)&&(identical(other.title, title) || other.title == title)&&(identical(other.description, description) || other.description == description)&&(identical(other.requiresWebView, requiresWebView) || other.requiresWebView == requiresWebView)&&(identical(other.functionName, functionName) || other.functionName == functionName)&&(identical(other.sectionMode, sectionMode) || other.sectionMode == sectionMode)&&(identical(other.cacheDuration, cacheDuration) || other.cacheDuration == cacheDuration)&&(identical(other.type, type) || other.type == type)&&const DeepCollectionEquality().equals(other._params, _params)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,title,description,requiresWebView,functionName,sectionMode,cacheDuration,type,const DeepCollectionEquality().hash(_params)); + +@override +String toString() { + return 'ScriptWidgetModule(id: $id, title: $title, description: $description, requiresWebView: $requiresWebView, functionName: $functionName, sectionMode: $sectionMode, cacheDuration: $cacheDuration, type: $type, params: $params)'; +} + + +} + +/// @nodoc +abstract mixin class _$ScriptWidgetModuleCopyWith<$Res> implements $ScriptWidgetModuleCopyWith<$Res> { + factory _$ScriptWidgetModuleCopyWith(_ScriptWidgetModule value, $Res Function(_ScriptWidgetModule) _then) = __$ScriptWidgetModuleCopyWithImpl; +@override @useResult +$Res call({ + String id, String title, String description, bool requiresWebView, String functionName, bool sectionMode, int cacheDuration, String type, List params +}); + + + + +} +/// @nodoc +class __$ScriptWidgetModuleCopyWithImpl<$Res> + implements _$ScriptWidgetModuleCopyWith<$Res> { + __$ScriptWidgetModuleCopyWithImpl(this._self, this._then); + + final _ScriptWidgetModule _self; + final $Res Function(_ScriptWidgetModule) _then; + +/// Create a copy of ScriptWidgetModule +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? title = null,Object? description = null,Object? requiresWebView = null,Object? functionName = null,Object? sectionMode = null,Object? cacheDuration = null,Object? type = null,Object? params = null,}) { + return _then(_ScriptWidgetModule( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable +as String,description: null == description ? _self.description : description // ignore: cast_nullable_to_non_nullable +as String,requiresWebView: null == requiresWebView ? _self.requiresWebView : requiresWebView // ignore: cast_nullable_to_non_nullable +as bool,functionName: null == functionName ? _self.functionName : functionName // ignore: cast_nullable_to_non_nullable +as String,sectionMode: null == sectionMode ? _self.sectionMode : sectionMode // ignore: cast_nullable_to_non_nullable +as bool,cacheDuration: null == cacheDuration ? _self.cacheDuration : cacheDuration // ignore: cast_nullable_to_non_nullable +as int,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable +as String,params: null == params ? _self._params : params // ignore: cast_nullable_to_non_nullable +as List, + )); +} + + +} + + +/// @nodoc +mixin _$ScriptWidgetManifest { + + String get id; String get title; String get description; String get author; String get site; String get version; String get requiredVersion; int get detailCacheDuration; List get globalParams; List get modules; ScriptWidgetModule? get search; +/// Create a copy of ScriptWidgetManifest +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$ScriptWidgetManifestCopyWith get copyWith => _$ScriptWidgetManifestCopyWithImpl(this as ScriptWidgetManifest, _$identity); + + /// Serializes this ScriptWidgetManifest to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is ScriptWidgetManifest&&(identical(other.id, id) || other.id == id)&&(identical(other.title, title) || other.title == title)&&(identical(other.description, description) || other.description == description)&&(identical(other.author, author) || other.author == author)&&(identical(other.site, site) || other.site == site)&&(identical(other.version, version) || other.version == version)&&(identical(other.requiredVersion, requiredVersion) || other.requiredVersion == requiredVersion)&&(identical(other.detailCacheDuration, detailCacheDuration) || other.detailCacheDuration == detailCacheDuration)&&const DeepCollectionEquality().equals(other.globalParams, globalParams)&&const DeepCollectionEquality().equals(other.modules, modules)&&(identical(other.search, search) || other.search == search)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,title,description,author,site,version,requiredVersion,detailCacheDuration,const DeepCollectionEquality().hash(globalParams),const DeepCollectionEquality().hash(modules),search); + +@override +String toString() { + return 'ScriptWidgetManifest(id: $id, title: $title, description: $description, author: $author, site: $site, version: $version, requiredVersion: $requiredVersion, detailCacheDuration: $detailCacheDuration, globalParams: $globalParams, modules: $modules, search: $search)'; +} + + +} + +/// @nodoc +abstract mixin class $ScriptWidgetManifestCopyWith<$Res> { + factory $ScriptWidgetManifestCopyWith(ScriptWidgetManifest value, $Res Function(ScriptWidgetManifest) _then) = _$ScriptWidgetManifestCopyWithImpl; +@useResult +$Res call({ + String id, String title, String description, String author, String site, String version, String requiredVersion, int detailCacheDuration, List globalParams, List modules, ScriptWidgetModule? search +}); + + +$ScriptWidgetModuleCopyWith<$Res>? get search; + +} +/// @nodoc +class _$ScriptWidgetManifestCopyWithImpl<$Res> + implements $ScriptWidgetManifestCopyWith<$Res> { + _$ScriptWidgetManifestCopyWithImpl(this._self, this._then); + + final ScriptWidgetManifest _self; + final $Res Function(ScriptWidgetManifest) _then; + +/// Create a copy of ScriptWidgetManifest +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? title = null,Object? description = null,Object? author = null,Object? site = null,Object? version = null,Object? requiredVersion = null,Object? detailCacheDuration = null,Object? globalParams = null,Object? modules = null,Object? search = freezed,}) { + return _then(_self.copyWith( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable +as String,description: null == description ? _self.description : description // ignore: cast_nullable_to_non_nullable +as String,author: null == author ? _self.author : author // ignore: cast_nullable_to_non_nullable +as String,site: null == site ? _self.site : site // ignore: cast_nullable_to_non_nullable +as String,version: null == version ? _self.version : version // ignore: cast_nullable_to_non_nullable +as String,requiredVersion: null == requiredVersion ? _self.requiredVersion : requiredVersion // ignore: cast_nullable_to_non_nullable +as String,detailCacheDuration: null == detailCacheDuration ? _self.detailCacheDuration : detailCacheDuration // ignore: cast_nullable_to_non_nullable +as int,globalParams: null == globalParams ? _self.globalParams : globalParams // ignore: cast_nullable_to_non_nullable +as List,modules: null == modules ? _self.modules : modules // ignore: cast_nullable_to_non_nullable +as List,search: freezed == search ? _self.search : search // ignore: cast_nullable_to_non_nullable +as ScriptWidgetModule?, + )); +} +/// Create a copy of ScriptWidgetManifest +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$ScriptWidgetModuleCopyWith<$Res>? get search { + if (_self.search == null) { + return null; + } + + return $ScriptWidgetModuleCopyWith<$Res>(_self.search!, (value) { + return _then(_self.copyWith(search: value)); + }); +} +} + + +/// Adds pattern-matching-related methods to [ScriptWidgetManifest]. +extension ScriptWidgetManifestPatterns on ScriptWidgetManifest { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _ScriptWidgetManifest value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _ScriptWidgetManifest() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _ScriptWidgetManifest value) $default,){ +final _that = this; +switch (_that) { +case _ScriptWidgetManifest(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _ScriptWidgetManifest value)? $default,){ +final _that = this; +switch (_that) { +case _ScriptWidgetManifest() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String id, String title, String description, String author, String site, String version, String requiredVersion, int detailCacheDuration, List globalParams, List modules, ScriptWidgetModule? search)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _ScriptWidgetManifest() when $default != null: +return $default(_that.id,_that.title,_that.description,_that.author,_that.site,_that.version,_that.requiredVersion,_that.detailCacheDuration,_that.globalParams,_that.modules,_that.search);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String id, String title, String description, String author, String site, String version, String requiredVersion, int detailCacheDuration, List globalParams, List modules, ScriptWidgetModule? search) $default,) {final _that = this; +switch (_that) { +case _ScriptWidgetManifest(): +return $default(_that.id,_that.title,_that.description,_that.author,_that.site,_that.version,_that.requiredVersion,_that.detailCacheDuration,_that.globalParams,_that.modules,_that.search);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String id, String title, String description, String author, String site, String version, String requiredVersion, int detailCacheDuration, List globalParams, List modules, ScriptWidgetModule? search)? $default,) {final _that = this; +switch (_that) { +case _ScriptWidgetManifest() when $default != null: +return $default(_that.id,_that.title,_that.description,_that.author,_that.site,_that.version,_that.requiredVersion,_that.detailCacheDuration,_that.globalParams,_that.modules,_that.search);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _ScriptWidgetManifest implements ScriptWidgetManifest { + const _ScriptWidgetManifest({required this.id, required this.title, this.description = '', this.author = '', this.site = '', this.version = '0.0.0', this.requiredVersion = '0.0.1', this.detailCacheDuration = 60, final List globalParams = const [], final List modules = const [], this.search}): _globalParams = globalParams,_modules = modules; + factory _ScriptWidgetManifest.fromJson(Map json) => _$ScriptWidgetManifestFromJson(json); + +@override final String id; +@override final String title; +@override@JsonKey() final String description; +@override@JsonKey() final String author; +@override@JsonKey() final String site; +@override@JsonKey() final String version; +@override@JsonKey() final String requiredVersion; +@override@JsonKey() final int detailCacheDuration; + final List _globalParams; +@override@JsonKey() List get globalParams { + if (_globalParams is EqualUnmodifiableListView) return _globalParams; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_globalParams); +} + + final List _modules; +@override@JsonKey() List get modules { + if (_modules is EqualUnmodifiableListView) return _modules; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_modules); +} + +@override final ScriptWidgetModule? search; + +/// Create a copy of ScriptWidgetManifest +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$ScriptWidgetManifestCopyWith<_ScriptWidgetManifest> get copyWith => __$ScriptWidgetManifestCopyWithImpl<_ScriptWidgetManifest>(this, _$identity); + +@override +Map toJson() { + return _$ScriptWidgetManifestToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _ScriptWidgetManifest&&(identical(other.id, id) || other.id == id)&&(identical(other.title, title) || other.title == title)&&(identical(other.description, description) || other.description == description)&&(identical(other.author, author) || other.author == author)&&(identical(other.site, site) || other.site == site)&&(identical(other.version, version) || other.version == version)&&(identical(other.requiredVersion, requiredVersion) || other.requiredVersion == requiredVersion)&&(identical(other.detailCacheDuration, detailCacheDuration) || other.detailCacheDuration == detailCacheDuration)&&const DeepCollectionEquality().equals(other._globalParams, _globalParams)&&const DeepCollectionEquality().equals(other._modules, _modules)&&(identical(other.search, search) || other.search == search)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,title,description,author,site,version,requiredVersion,detailCacheDuration,const DeepCollectionEquality().hash(_globalParams),const DeepCollectionEquality().hash(_modules),search); + +@override +String toString() { + return 'ScriptWidgetManifest(id: $id, title: $title, description: $description, author: $author, site: $site, version: $version, requiredVersion: $requiredVersion, detailCacheDuration: $detailCacheDuration, globalParams: $globalParams, modules: $modules, search: $search)'; +} + + +} + +/// @nodoc +abstract mixin class _$ScriptWidgetManifestCopyWith<$Res> implements $ScriptWidgetManifestCopyWith<$Res> { + factory _$ScriptWidgetManifestCopyWith(_ScriptWidgetManifest value, $Res Function(_ScriptWidgetManifest) _then) = __$ScriptWidgetManifestCopyWithImpl; +@override @useResult +$Res call({ + String id, String title, String description, String author, String site, String version, String requiredVersion, int detailCacheDuration, List globalParams, List modules, ScriptWidgetModule? search +}); + + +@override $ScriptWidgetModuleCopyWith<$Res>? get search; + +} +/// @nodoc +class __$ScriptWidgetManifestCopyWithImpl<$Res> + implements _$ScriptWidgetManifestCopyWith<$Res> { + __$ScriptWidgetManifestCopyWithImpl(this._self, this._then); + + final _ScriptWidgetManifest _self; + final $Res Function(_ScriptWidgetManifest) _then; + +/// Create a copy of ScriptWidgetManifest +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? title = null,Object? description = null,Object? author = null,Object? site = null,Object? version = null,Object? requiredVersion = null,Object? detailCacheDuration = null,Object? globalParams = null,Object? modules = null,Object? search = freezed,}) { + return _then(_ScriptWidgetManifest( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable +as String,description: null == description ? _self.description : description // ignore: cast_nullable_to_non_nullable +as String,author: null == author ? _self.author : author // ignore: cast_nullable_to_non_nullable +as String,site: null == site ? _self.site : site // ignore: cast_nullable_to_non_nullable +as String,version: null == version ? _self.version : version // ignore: cast_nullable_to_non_nullable +as String,requiredVersion: null == requiredVersion ? _self.requiredVersion : requiredVersion // ignore: cast_nullable_to_non_nullable +as String,detailCacheDuration: null == detailCacheDuration ? _self.detailCacheDuration : detailCacheDuration // ignore: cast_nullable_to_non_nullable +as int,globalParams: null == globalParams ? _self._globalParams : globalParams // ignore: cast_nullable_to_non_nullable +as List,modules: null == modules ? _self._modules : modules // ignore: cast_nullable_to_non_nullable +as List,search: freezed == search ? _self.search : search // ignore: cast_nullable_to_non_nullable +as ScriptWidgetModule?, + )); +} + +/// Create a copy of ScriptWidgetManifest +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$ScriptWidgetModuleCopyWith<$Res>? get search { + if (_self.search == null) { + return null; + } + + return $ScriptWidgetModuleCopyWith<$Res>(_self.search!, (value) { + return _then(_self.copyWith(search: value)); + }); +} +} + + +/// @nodoc +mixin _$ScriptVideoPerson { + +@JsonKey(fromJson: _stringFromAny) String get id; String get title; String get avatar; String get role; +/// Create a copy of ScriptVideoPerson +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$ScriptVideoPersonCopyWith get copyWith => _$ScriptVideoPersonCopyWithImpl(this as ScriptVideoPerson, _$identity); + + /// Serializes this ScriptVideoPerson to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is ScriptVideoPerson&&(identical(other.id, id) || other.id == id)&&(identical(other.title, title) || other.title == title)&&(identical(other.avatar, avatar) || other.avatar == avatar)&&(identical(other.role, role) || other.role == role)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,title,avatar,role); + +@override +String toString() { + return 'ScriptVideoPerson(id: $id, title: $title, avatar: $avatar, role: $role)'; +} + + +} + +/// @nodoc +abstract mixin class $ScriptVideoPersonCopyWith<$Res> { + factory $ScriptVideoPersonCopyWith(ScriptVideoPerson value, $Res Function(ScriptVideoPerson) _then) = _$ScriptVideoPersonCopyWithImpl; +@useResult +$Res call({ +@JsonKey(fromJson: _stringFromAny) String id, String title, String avatar, String role +}); + + + + +} +/// @nodoc +class _$ScriptVideoPersonCopyWithImpl<$Res> + implements $ScriptVideoPersonCopyWith<$Res> { + _$ScriptVideoPersonCopyWithImpl(this._self, this._then); + + final ScriptVideoPerson _self; + final $Res Function(ScriptVideoPerson) _then; + +/// Create a copy of ScriptVideoPerson +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? title = null,Object? avatar = null,Object? role = null,}) { + return _then(_self.copyWith( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable +as String,avatar: null == avatar ? _self.avatar : avatar // ignore: cast_nullable_to_non_nullable +as String,role: null == role ? _self.role : role // ignore: cast_nullable_to_non_nullable +as String, + )); +} + +} + + +/// Adds pattern-matching-related methods to [ScriptVideoPerson]. +extension ScriptVideoPersonPatterns on ScriptVideoPerson { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _ScriptVideoPerson value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _ScriptVideoPerson() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _ScriptVideoPerson value) $default,){ +final _that = this; +switch (_that) { +case _ScriptVideoPerson(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _ScriptVideoPerson value)? $default,){ +final _that = this; +switch (_that) { +case _ScriptVideoPerson() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function(@JsonKey(fromJson: _stringFromAny) String id, String title, String avatar, String role)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _ScriptVideoPerson() when $default != null: +return $default(_that.id,_that.title,_that.avatar,_that.role);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function(@JsonKey(fromJson: _stringFromAny) String id, String title, String avatar, String role) $default,) {final _that = this; +switch (_that) { +case _ScriptVideoPerson(): +return $default(_that.id,_that.title,_that.avatar,_that.role);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function(@JsonKey(fromJson: _stringFromAny) String id, String title, String avatar, String role)? $default,) {final _that = this; +switch (_that) { +case _ScriptVideoPerson() when $default != null: +return $default(_that.id,_that.title,_that.avatar,_that.role);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _ScriptVideoPerson implements ScriptVideoPerson { + const _ScriptVideoPerson({@JsonKey(fromJson: _stringFromAny) this.id = '', this.title = '', this.avatar = '', this.role = ''}); + factory _ScriptVideoPerson.fromJson(Map json) => _$ScriptVideoPersonFromJson(json); + +@override@JsonKey(fromJson: _stringFromAny) final String id; +@override@JsonKey() final String title; +@override@JsonKey() final String avatar; +@override@JsonKey() final String role; + +/// Create a copy of ScriptVideoPerson +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$ScriptVideoPersonCopyWith<_ScriptVideoPerson> get copyWith => __$ScriptVideoPersonCopyWithImpl<_ScriptVideoPerson>(this, _$identity); + +@override +Map toJson() { + return _$ScriptVideoPersonToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _ScriptVideoPerson&&(identical(other.id, id) || other.id == id)&&(identical(other.title, title) || other.title == title)&&(identical(other.avatar, avatar) || other.avatar == avatar)&&(identical(other.role, role) || other.role == role)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,title,avatar,role); + +@override +String toString() { + return 'ScriptVideoPerson(id: $id, title: $title, avatar: $avatar, role: $role)'; +} + + +} + +/// @nodoc +abstract mixin class _$ScriptVideoPersonCopyWith<$Res> implements $ScriptVideoPersonCopyWith<$Res> { + factory _$ScriptVideoPersonCopyWith(_ScriptVideoPerson value, $Res Function(_ScriptVideoPerson) _then) = __$ScriptVideoPersonCopyWithImpl; +@override @useResult +$Res call({ +@JsonKey(fromJson: _stringFromAny) String id, String title, String avatar, String role +}); + + + + +} +/// @nodoc +class __$ScriptVideoPersonCopyWithImpl<$Res> + implements _$ScriptVideoPersonCopyWith<$Res> { + __$ScriptVideoPersonCopyWithImpl(this._self, this._then); + + final _ScriptVideoPerson _self; + final $Res Function(_ScriptVideoPerson) _then; + +/// Create a copy of ScriptVideoPerson +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? title = null,Object? avatar = null,Object? role = null,}) { + return _then(_ScriptVideoPerson( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable +as String,avatar: null == avatar ? _self.avatar : avatar // ignore: cast_nullable_to_non_nullable +as String,role: null == role ? _self.role : role // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + + +/// @nodoc +mixin _$ScriptVideoGenre { + +@JsonKey(fromJson: _stringFromAny) String get id; String get title; +/// Create a copy of ScriptVideoGenre +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$ScriptVideoGenreCopyWith get copyWith => _$ScriptVideoGenreCopyWithImpl(this as ScriptVideoGenre, _$identity); + + /// Serializes this ScriptVideoGenre to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is ScriptVideoGenre&&(identical(other.id, id) || other.id == id)&&(identical(other.title, title) || other.title == title)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,title); + +@override +String toString() { + return 'ScriptVideoGenre(id: $id, title: $title)'; +} + + +} + +/// @nodoc +abstract mixin class $ScriptVideoGenreCopyWith<$Res> { + factory $ScriptVideoGenreCopyWith(ScriptVideoGenre value, $Res Function(ScriptVideoGenre) _then) = _$ScriptVideoGenreCopyWithImpl; +@useResult +$Res call({ +@JsonKey(fromJson: _stringFromAny) String id, String title +}); + + + + +} +/// @nodoc +class _$ScriptVideoGenreCopyWithImpl<$Res> + implements $ScriptVideoGenreCopyWith<$Res> { + _$ScriptVideoGenreCopyWithImpl(this._self, this._then); + + final ScriptVideoGenre _self; + final $Res Function(ScriptVideoGenre) _then; + +/// Create a copy of ScriptVideoGenre +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? title = null,}) { + return _then(_self.copyWith( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable +as String, + )); +} + +} + + +/// Adds pattern-matching-related methods to [ScriptVideoGenre]. +extension ScriptVideoGenrePatterns on ScriptVideoGenre { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _ScriptVideoGenre value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _ScriptVideoGenre() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _ScriptVideoGenre value) $default,){ +final _that = this; +switch (_that) { +case _ScriptVideoGenre(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _ScriptVideoGenre value)? $default,){ +final _that = this; +switch (_that) { +case _ScriptVideoGenre() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function(@JsonKey(fromJson: _stringFromAny) String id, String title)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _ScriptVideoGenre() when $default != null: +return $default(_that.id,_that.title);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function(@JsonKey(fromJson: _stringFromAny) String id, String title) $default,) {final _that = this; +switch (_that) { +case _ScriptVideoGenre(): +return $default(_that.id,_that.title);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function(@JsonKey(fromJson: _stringFromAny) String id, String title)? $default,) {final _that = this; +switch (_that) { +case _ScriptVideoGenre() when $default != null: +return $default(_that.id,_that.title);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _ScriptVideoGenre implements ScriptVideoGenre { + const _ScriptVideoGenre({@JsonKey(fromJson: _stringFromAny) this.id = '', this.title = ''}); + factory _ScriptVideoGenre.fromJson(Map json) => _$ScriptVideoGenreFromJson(json); + +@override@JsonKey(fromJson: _stringFromAny) final String id; +@override@JsonKey() final String title; + +/// Create a copy of ScriptVideoGenre +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$ScriptVideoGenreCopyWith<_ScriptVideoGenre> get copyWith => __$ScriptVideoGenreCopyWithImpl<_ScriptVideoGenre>(this, _$identity); + +@override +Map toJson() { + return _$ScriptVideoGenreToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _ScriptVideoGenre&&(identical(other.id, id) || other.id == id)&&(identical(other.title, title) || other.title == title)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,title); + +@override +String toString() { + return 'ScriptVideoGenre(id: $id, title: $title)'; +} + + +} + +/// @nodoc +abstract mixin class _$ScriptVideoGenreCopyWith<$Res> implements $ScriptVideoGenreCopyWith<$Res> { + factory _$ScriptVideoGenreCopyWith(_ScriptVideoGenre value, $Res Function(_ScriptVideoGenre) _then) = __$ScriptVideoGenreCopyWithImpl; +@override @useResult +$Res call({ +@JsonKey(fromJson: _stringFromAny) String id, String title +}); + + + + +} +/// @nodoc +class __$ScriptVideoGenreCopyWithImpl<$Res> + implements _$ScriptVideoGenreCopyWith<$Res> { + __$ScriptVideoGenreCopyWithImpl(this._self, this._then); + + final _ScriptVideoGenre _self; + final $Res Function(_ScriptVideoGenre) _then; + +/// Create a copy of ScriptVideoGenre +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? title = null,}) { + return _then(_ScriptVideoGenre( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + + +/// @nodoc +mixin _$ScriptVideoTrailer { + + String get coverUrl; String get url; +/// Create a copy of ScriptVideoTrailer +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$ScriptVideoTrailerCopyWith get copyWith => _$ScriptVideoTrailerCopyWithImpl(this as ScriptVideoTrailer, _$identity); + + /// Serializes this ScriptVideoTrailer to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is ScriptVideoTrailer&&(identical(other.coverUrl, coverUrl) || other.coverUrl == coverUrl)&&(identical(other.url, url) || other.url == url)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,coverUrl,url); + +@override +String toString() { + return 'ScriptVideoTrailer(coverUrl: $coverUrl, url: $url)'; +} + + +} + +/// @nodoc +abstract mixin class $ScriptVideoTrailerCopyWith<$Res> { + factory $ScriptVideoTrailerCopyWith(ScriptVideoTrailer value, $Res Function(ScriptVideoTrailer) _then) = _$ScriptVideoTrailerCopyWithImpl; +@useResult +$Res call({ + String coverUrl, String url +}); + + + + +} +/// @nodoc +class _$ScriptVideoTrailerCopyWithImpl<$Res> + implements $ScriptVideoTrailerCopyWith<$Res> { + _$ScriptVideoTrailerCopyWithImpl(this._self, this._then); + + final ScriptVideoTrailer _self; + final $Res Function(ScriptVideoTrailer) _then; + +/// Create a copy of ScriptVideoTrailer +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? coverUrl = null,Object? url = null,}) { + return _then(_self.copyWith( +coverUrl: null == coverUrl ? _self.coverUrl : coverUrl // ignore: cast_nullable_to_non_nullable +as String,url: null == url ? _self.url : url // ignore: cast_nullable_to_non_nullable +as String, + )); +} + +} + + +/// Adds pattern-matching-related methods to [ScriptVideoTrailer]. +extension ScriptVideoTrailerPatterns on ScriptVideoTrailer { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _ScriptVideoTrailer value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _ScriptVideoTrailer() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _ScriptVideoTrailer value) $default,){ +final _that = this; +switch (_that) { +case _ScriptVideoTrailer(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _ScriptVideoTrailer value)? $default,){ +final _that = this; +switch (_that) { +case _ScriptVideoTrailer() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String coverUrl, String url)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _ScriptVideoTrailer() when $default != null: +return $default(_that.coverUrl,_that.url);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String coverUrl, String url) $default,) {final _that = this; +switch (_that) { +case _ScriptVideoTrailer(): +return $default(_that.coverUrl,_that.url);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String coverUrl, String url)? $default,) {final _that = this; +switch (_that) { +case _ScriptVideoTrailer() when $default != null: +return $default(_that.coverUrl,_that.url);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _ScriptVideoTrailer implements ScriptVideoTrailer { + const _ScriptVideoTrailer({this.coverUrl = '', this.url = ''}); + factory _ScriptVideoTrailer.fromJson(Map json) => _$ScriptVideoTrailerFromJson(json); + +@override@JsonKey() final String coverUrl; +@override@JsonKey() final String url; + +/// Create a copy of ScriptVideoTrailer +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$ScriptVideoTrailerCopyWith<_ScriptVideoTrailer> get copyWith => __$ScriptVideoTrailerCopyWithImpl<_ScriptVideoTrailer>(this, _$identity); + +@override +Map toJson() { + return _$ScriptVideoTrailerToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _ScriptVideoTrailer&&(identical(other.coverUrl, coverUrl) || other.coverUrl == coverUrl)&&(identical(other.url, url) || other.url == url)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,coverUrl,url); + +@override +String toString() { + return 'ScriptVideoTrailer(coverUrl: $coverUrl, url: $url)'; +} + + +} + +/// @nodoc +abstract mixin class _$ScriptVideoTrailerCopyWith<$Res> implements $ScriptVideoTrailerCopyWith<$Res> { + factory _$ScriptVideoTrailerCopyWith(_ScriptVideoTrailer value, $Res Function(_ScriptVideoTrailer) _then) = __$ScriptVideoTrailerCopyWithImpl; +@override @useResult +$Res call({ + String coverUrl, String url +}); + + + + +} +/// @nodoc +class __$ScriptVideoTrailerCopyWithImpl<$Res> + implements _$ScriptVideoTrailerCopyWith<$Res> { + __$ScriptVideoTrailerCopyWithImpl(this._self, this._then); + + final _ScriptVideoTrailer _self; + final $Res Function(_ScriptVideoTrailer) _then; + +/// Create a copy of ScriptVideoTrailer +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? coverUrl = null,Object? url = null,}) { + return _then(_ScriptVideoTrailer( +coverUrl: null == coverUrl ? _self.coverUrl : coverUrl // ignore: cast_nullable_to_non_nullable +as String,url: null == url ? _self.url : url // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + + +/// @nodoc +mixin _$ScriptVideoItem { + +@JsonKey(fromJson: _stringFromAny) String get id; String get type; String get title; String get coverUrl;@JsonKey(readValue: _readPosterPath) String get posterPath; String get detailPoster; String get backdropPath; List get backdropPaths; String get releaseDate; String get mediaType;@JsonKey(fromJson: _doubleFromAny) double? get rating; String get genreTitle; List get genreItems; List get peoples;@JsonKey(fromJson: _intFromAny) int? get duration; String get durationText; String get previewUrl; List get trailers; String get videoUrl; String get link;@JsonKey(fromJson: _intFromAny) int? get episode; String get description; String get playerType; List get childItems; List get episodeItems; List get relatedItems; +/// Create a copy of ScriptVideoItem +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$ScriptVideoItemCopyWith get copyWith => _$ScriptVideoItemCopyWithImpl(this as ScriptVideoItem, _$identity); + + /// Serializes this ScriptVideoItem to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is ScriptVideoItem&&(identical(other.id, id) || other.id == id)&&(identical(other.type, type) || other.type == type)&&(identical(other.title, title) || other.title == title)&&(identical(other.coverUrl, coverUrl) || other.coverUrl == coverUrl)&&(identical(other.posterPath, posterPath) || other.posterPath == posterPath)&&(identical(other.detailPoster, detailPoster) || other.detailPoster == detailPoster)&&(identical(other.backdropPath, backdropPath) || other.backdropPath == backdropPath)&&const DeepCollectionEquality().equals(other.backdropPaths, backdropPaths)&&(identical(other.releaseDate, releaseDate) || other.releaseDate == releaseDate)&&(identical(other.mediaType, mediaType) || other.mediaType == mediaType)&&(identical(other.rating, rating) || other.rating == rating)&&(identical(other.genreTitle, genreTitle) || other.genreTitle == genreTitle)&&const DeepCollectionEquality().equals(other.genreItems, genreItems)&&const DeepCollectionEquality().equals(other.peoples, peoples)&&(identical(other.duration, duration) || other.duration == duration)&&(identical(other.durationText, durationText) || other.durationText == durationText)&&(identical(other.previewUrl, previewUrl) || other.previewUrl == previewUrl)&&const DeepCollectionEquality().equals(other.trailers, trailers)&&(identical(other.videoUrl, videoUrl) || other.videoUrl == videoUrl)&&(identical(other.link, link) || other.link == link)&&(identical(other.episode, episode) || other.episode == episode)&&(identical(other.description, description) || other.description == description)&&(identical(other.playerType, playerType) || other.playerType == playerType)&&const DeepCollectionEquality().equals(other.childItems, childItems)&&const DeepCollectionEquality().equals(other.episodeItems, episodeItems)&&const DeepCollectionEquality().equals(other.relatedItems, relatedItems)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hashAll([runtimeType,id,type,title,coverUrl,posterPath,detailPoster,backdropPath,const DeepCollectionEquality().hash(backdropPaths),releaseDate,mediaType,rating,genreTitle,const DeepCollectionEquality().hash(genreItems),const DeepCollectionEquality().hash(peoples),duration,durationText,previewUrl,const DeepCollectionEquality().hash(trailers),videoUrl,link,episode,description,playerType,const DeepCollectionEquality().hash(childItems),const DeepCollectionEquality().hash(episodeItems),const DeepCollectionEquality().hash(relatedItems)]); + +@override +String toString() { + return 'ScriptVideoItem(id: $id, type: $type, title: $title, coverUrl: $coverUrl, posterPath: $posterPath, detailPoster: $detailPoster, backdropPath: $backdropPath, backdropPaths: $backdropPaths, releaseDate: $releaseDate, mediaType: $mediaType, rating: $rating, genreTitle: $genreTitle, genreItems: $genreItems, peoples: $peoples, duration: $duration, durationText: $durationText, previewUrl: $previewUrl, trailers: $trailers, videoUrl: $videoUrl, link: $link, episode: $episode, description: $description, playerType: $playerType, childItems: $childItems, episodeItems: $episodeItems, relatedItems: $relatedItems)'; +} + + +} + +/// @nodoc +abstract mixin class $ScriptVideoItemCopyWith<$Res> { + factory $ScriptVideoItemCopyWith(ScriptVideoItem value, $Res Function(ScriptVideoItem) _then) = _$ScriptVideoItemCopyWithImpl; +@useResult +$Res call({ +@JsonKey(fromJson: _stringFromAny) String id, String type, String title, String coverUrl,@JsonKey(readValue: _readPosterPath) String posterPath, String detailPoster, String backdropPath, List backdropPaths, String releaseDate, String mediaType,@JsonKey(fromJson: _doubleFromAny) double? rating, String genreTitle, List genreItems, List peoples,@JsonKey(fromJson: _intFromAny) int? duration, String durationText, String previewUrl, List trailers, String videoUrl, String link,@JsonKey(fromJson: _intFromAny) int? episode, String description, String playerType, List childItems, List episodeItems, List relatedItems +}); + + + + +} +/// @nodoc +class _$ScriptVideoItemCopyWithImpl<$Res> + implements $ScriptVideoItemCopyWith<$Res> { + _$ScriptVideoItemCopyWithImpl(this._self, this._then); + + final ScriptVideoItem _self; + final $Res Function(ScriptVideoItem) _then; + +/// Create a copy of ScriptVideoItem +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? type = null,Object? title = null,Object? coverUrl = null,Object? posterPath = null,Object? detailPoster = null,Object? backdropPath = null,Object? backdropPaths = null,Object? releaseDate = null,Object? mediaType = null,Object? rating = freezed,Object? genreTitle = null,Object? genreItems = null,Object? peoples = null,Object? duration = freezed,Object? durationText = null,Object? previewUrl = null,Object? trailers = null,Object? videoUrl = null,Object? link = null,Object? episode = freezed,Object? description = null,Object? playerType = null,Object? childItems = null,Object? episodeItems = null,Object? relatedItems = null,}) { + return _then(_self.copyWith( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable +as String,title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable +as String,coverUrl: null == coverUrl ? _self.coverUrl : coverUrl // ignore: cast_nullable_to_non_nullable +as String,posterPath: null == posterPath ? _self.posterPath : posterPath // ignore: cast_nullable_to_non_nullable +as String,detailPoster: null == detailPoster ? _self.detailPoster : detailPoster // ignore: cast_nullable_to_non_nullable +as String,backdropPath: null == backdropPath ? _self.backdropPath : backdropPath // ignore: cast_nullable_to_non_nullable +as String,backdropPaths: null == backdropPaths ? _self.backdropPaths : backdropPaths // ignore: cast_nullable_to_non_nullable +as List,releaseDate: null == releaseDate ? _self.releaseDate : releaseDate // ignore: cast_nullable_to_non_nullable +as String,mediaType: null == mediaType ? _self.mediaType : mediaType // ignore: cast_nullable_to_non_nullable +as String,rating: freezed == rating ? _self.rating : rating // ignore: cast_nullable_to_non_nullable +as double?,genreTitle: null == genreTitle ? _self.genreTitle : genreTitle // ignore: cast_nullable_to_non_nullable +as String,genreItems: null == genreItems ? _self.genreItems : genreItems // ignore: cast_nullable_to_non_nullable +as List,peoples: null == peoples ? _self.peoples : peoples // ignore: cast_nullable_to_non_nullable +as List,duration: freezed == duration ? _self.duration : duration // ignore: cast_nullable_to_non_nullable +as int?,durationText: null == durationText ? _self.durationText : durationText // ignore: cast_nullable_to_non_nullable +as String,previewUrl: null == previewUrl ? _self.previewUrl : previewUrl // ignore: cast_nullable_to_non_nullable +as String,trailers: null == trailers ? _self.trailers : trailers // ignore: cast_nullable_to_non_nullable +as List,videoUrl: null == videoUrl ? _self.videoUrl : videoUrl // ignore: cast_nullable_to_non_nullable +as String,link: null == link ? _self.link : link // ignore: cast_nullable_to_non_nullable +as String,episode: freezed == episode ? _self.episode : episode // ignore: cast_nullable_to_non_nullable +as int?,description: null == description ? _self.description : description // ignore: cast_nullable_to_non_nullable +as String,playerType: null == playerType ? _self.playerType : playerType // ignore: cast_nullable_to_non_nullable +as String,childItems: null == childItems ? _self.childItems : childItems // ignore: cast_nullable_to_non_nullable +as List,episodeItems: null == episodeItems ? _self.episodeItems : episodeItems // ignore: cast_nullable_to_non_nullable +as List,relatedItems: null == relatedItems ? _self.relatedItems : relatedItems // ignore: cast_nullable_to_non_nullable +as List, + )); +} + +} + + +/// Adds pattern-matching-related methods to [ScriptVideoItem]. +extension ScriptVideoItemPatterns on ScriptVideoItem { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _ScriptVideoItem value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _ScriptVideoItem() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _ScriptVideoItem value) $default,){ +final _that = this; +switch (_that) { +case _ScriptVideoItem(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _ScriptVideoItem value)? $default,){ +final _that = this; +switch (_that) { +case _ScriptVideoItem() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function(@JsonKey(fromJson: _stringFromAny) String id, String type, String title, String coverUrl, @JsonKey(readValue: _readPosterPath) String posterPath, String detailPoster, String backdropPath, List backdropPaths, String releaseDate, String mediaType, @JsonKey(fromJson: _doubleFromAny) double? rating, String genreTitle, List genreItems, List peoples, @JsonKey(fromJson: _intFromAny) int? duration, String durationText, String previewUrl, List trailers, String videoUrl, String link, @JsonKey(fromJson: _intFromAny) int? episode, String description, String playerType, List childItems, List episodeItems, List relatedItems)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _ScriptVideoItem() when $default != null: +return $default(_that.id,_that.type,_that.title,_that.coverUrl,_that.posterPath,_that.detailPoster,_that.backdropPath,_that.backdropPaths,_that.releaseDate,_that.mediaType,_that.rating,_that.genreTitle,_that.genreItems,_that.peoples,_that.duration,_that.durationText,_that.previewUrl,_that.trailers,_that.videoUrl,_that.link,_that.episode,_that.description,_that.playerType,_that.childItems,_that.episodeItems,_that.relatedItems);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function(@JsonKey(fromJson: _stringFromAny) String id, String type, String title, String coverUrl, @JsonKey(readValue: _readPosterPath) String posterPath, String detailPoster, String backdropPath, List backdropPaths, String releaseDate, String mediaType, @JsonKey(fromJson: _doubleFromAny) double? rating, String genreTitle, List genreItems, List peoples, @JsonKey(fromJson: _intFromAny) int? duration, String durationText, String previewUrl, List trailers, String videoUrl, String link, @JsonKey(fromJson: _intFromAny) int? episode, String description, String playerType, List childItems, List episodeItems, List relatedItems) $default,) {final _that = this; +switch (_that) { +case _ScriptVideoItem(): +return $default(_that.id,_that.type,_that.title,_that.coverUrl,_that.posterPath,_that.detailPoster,_that.backdropPath,_that.backdropPaths,_that.releaseDate,_that.mediaType,_that.rating,_that.genreTitle,_that.genreItems,_that.peoples,_that.duration,_that.durationText,_that.previewUrl,_that.trailers,_that.videoUrl,_that.link,_that.episode,_that.description,_that.playerType,_that.childItems,_that.episodeItems,_that.relatedItems);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function(@JsonKey(fromJson: _stringFromAny) String id, String type, String title, String coverUrl, @JsonKey(readValue: _readPosterPath) String posterPath, String detailPoster, String backdropPath, List backdropPaths, String releaseDate, String mediaType, @JsonKey(fromJson: _doubleFromAny) double? rating, String genreTitle, List genreItems, List peoples, @JsonKey(fromJson: _intFromAny) int? duration, String durationText, String previewUrl, List trailers, String videoUrl, String link, @JsonKey(fromJson: _intFromAny) int? episode, String description, String playerType, List childItems, List episodeItems, List relatedItems)? $default,) {final _that = this; +switch (_that) { +case _ScriptVideoItem() when $default != null: +return $default(_that.id,_that.type,_that.title,_that.coverUrl,_that.posterPath,_that.detailPoster,_that.backdropPath,_that.backdropPaths,_that.releaseDate,_that.mediaType,_that.rating,_that.genreTitle,_that.genreItems,_that.peoples,_that.duration,_that.durationText,_that.previewUrl,_that.trailers,_that.videoUrl,_that.link,_that.episode,_that.description,_that.playerType,_that.childItems,_that.episodeItems,_that.relatedItems);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _ScriptVideoItem implements ScriptVideoItem { + const _ScriptVideoItem({@JsonKey(fromJson: _stringFromAny) required this.id, this.type = 'link', this.title = '', this.coverUrl = '', @JsonKey(readValue: _readPosterPath) this.posterPath = '', this.detailPoster = '', this.backdropPath = '', final List backdropPaths = const [], this.releaseDate = '', this.mediaType = '', @JsonKey(fromJson: _doubleFromAny) this.rating, this.genreTitle = '', final List genreItems = const [], final List peoples = const [], @JsonKey(fromJson: _intFromAny) this.duration, this.durationText = '', this.previewUrl = '', final List trailers = const [], this.videoUrl = '', this.link = '', @JsonKey(fromJson: _intFromAny) this.episode, this.description = '', this.playerType = 'app', final List childItems = const [], final List episodeItems = const [], final List relatedItems = const []}): _backdropPaths = backdropPaths,_genreItems = genreItems,_peoples = peoples,_trailers = trailers,_childItems = childItems,_episodeItems = episodeItems,_relatedItems = relatedItems; + factory _ScriptVideoItem.fromJson(Map json) => _$ScriptVideoItemFromJson(json); + +@override@JsonKey(fromJson: _stringFromAny) final String id; +@override@JsonKey() final String type; +@override@JsonKey() final String title; +@override@JsonKey() final String coverUrl; +@override@JsonKey(readValue: _readPosterPath) final String posterPath; +@override@JsonKey() final String detailPoster; +@override@JsonKey() final String backdropPath; + final List _backdropPaths; +@override@JsonKey() List get backdropPaths { + if (_backdropPaths is EqualUnmodifiableListView) return _backdropPaths; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_backdropPaths); +} + +@override@JsonKey() final String releaseDate; +@override@JsonKey() final String mediaType; +@override@JsonKey(fromJson: _doubleFromAny) final double? rating; +@override@JsonKey() final String genreTitle; + final List _genreItems; +@override@JsonKey() List get genreItems { + if (_genreItems is EqualUnmodifiableListView) return _genreItems; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_genreItems); +} + + final List _peoples; +@override@JsonKey() List get peoples { + if (_peoples is EqualUnmodifiableListView) return _peoples; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_peoples); +} + +@override@JsonKey(fromJson: _intFromAny) final int? duration; +@override@JsonKey() final String durationText; +@override@JsonKey() final String previewUrl; + final List _trailers; +@override@JsonKey() List get trailers { + if (_trailers is EqualUnmodifiableListView) return _trailers; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_trailers); +} + +@override@JsonKey() final String videoUrl; +@override@JsonKey() final String link; +@override@JsonKey(fromJson: _intFromAny) final int? episode; +@override@JsonKey() final String description; +@override@JsonKey() final String playerType; + final List _childItems; +@override@JsonKey() List get childItems { + if (_childItems is EqualUnmodifiableListView) return _childItems; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_childItems); +} + + final List _episodeItems; +@override@JsonKey() List get episodeItems { + if (_episodeItems is EqualUnmodifiableListView) return _episodeItems; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_episodeItems); +} + + final List _relatedItems; +@override@JsonKey() List get relatedItems { + if (_relatedItems is EqualUnmodifiableListView) return _relatedItems; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_relatedItems); +} + + +/// Create a copy of ScriptVideoItem +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$ScriptVideoItemCopyWith<_ScriptVideoItem> get copyWith => __$ScriptVideoItemCopyWithImpl<_ScriptVideoItem>(this, _$identity); + +@override +Map toJson() { + return _$ScriptVideoItemToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _ScriptVideoItem&&(identical(other.id, id) || other.id == id)&&(identical(other.type, type) || other.type == type)&&(identical(other.title, title) || other.title == title)&&(identical(other.coverUrl, coverUrl) || other.coverUrl == coverUrl)&&(identical(other.posterPath, posterPath) || other.posterPath == posterPath)&&(identical(other.detailPoster, detailPoster) || other.detailPoster == detailPoster)&&(identical(other.backdropPath, backdropPath) || other.backdropPath == backdropPath)&&const DeepCollectionEquality().equals(other._backdropPaths, _backdropPaths)&&(identical(other.releaseDate, releaseDate) || other.releaseDate == releaseDate)&&(identical(other.mediaType, mediaType) || other.mediaType == mediaType)&&(identical(other.rating, rating) || other.rating == rating)&&(identical(other.genreTitle, genreTitle) || other.genreTitle == genreTitle)&&const DeepCollectionEquality().equals(other._genreItems, _genreItems)&&const DeepCollectionEquality().equals(other._peoples, _peoples)&&(identical(other.duration, duration) || other.duration == duration)&&(identical(other.durationText, durationText) || other.durationText == durationText)&&(identical(other.previewUrl, previewUrl) || other.previewUrl == previewUrl)&&const DeepCollectionEquality().equals(other._trailers, _trailers)&&(identical(other.videoUrl, videoUrl) || other.videoUrl == videoUrl)&&(identical(other.link, link) || other.link == link)&&(identical(other.episode, episode) || other.episode == episode)&&(identical(other.description, description) || other.description == description)&&(identical(other.playerType, playerType) || other.playerType == playerType)&&const DeepCollectionEquality().equals(other._childItems, _childItems)&&const DeepCollectionEquality().equals(other._episodeItems, _episodeItems)&&const DeepCollectionEquality().equals(other._relatedItems, _relatedItems)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hashAll([runtimeType,id,type,title,coverUrl,posterPath,detailPoster,backdropPath,const DeepCollectionEquality().hash(_backdropPaths),releaseDate,mediaType,rating,genreTitle,const DeepCollectionEquality().hash(_genreItems),const DeepCollectionEquality().hash(_peoples),duration,durationText,previewUrl,const DeepCollectionEquality().hash(_trailers),videoUrl,link,episode,description,playerType,const DeepCollectionEquality().hash(_childItems),const DeepCollectionEquality().hash(_episodeItems),const DeepCollectionEquality().hash(_relatedItems)]); + +@override +String toString() { + return 'ScriptVideoItem(id: $id, type: $type, title: $title, coverUrl: $coverUrl, posterPath: $posterPath, detailPoster: $detailPoster, backdropPath: $backdropPath, backdropPaths: $backdropPaths, releaseDate: $releaseDate, mediaType: $mediaType, rating: $rating, genreTitle: $genreTitle, genreItems: $genreItems, peoples: $peoples, duration: $duration, durationText: $durationText, previewUrl: $previewUrl, trailers: $trailers, videoUrl: $videoUrl, link: $link, episode: $episode, description: $description, playerType: $playerType, childItems: $childItems, episodeItems: $episodeItems, relatedItems: $relatedItems)'; +} + + +} + +/// @nodoc +abstract mixin class _$ScriptVideoItemCopyWith<$Res> implements $ScriptVideoItemCopyWith<$Res> { + factory _$ScriptVideoItemCopyWith(_ScriptVideoItem value, $Res Function(_ScriptVideoItem) _then) = __$ScriptVideoItemCopyWithImpl; +@override @useResult +$Res call({ +@JsonKey(fromJson: _stringFromAny) String id, String type, String title, String coverUrl,@JsonKey(readValue: _readPosterPath) String posterPath, String detailPoster, String backdropPath, List backdropPaths, String releaseDate, String mediaType,@JsonKey(fromJson: _doubleFromAny) double? rating, String genreTitle, List genreItems, List peoples,@JsonKey(fromJson: _intFromAny) int? duration, String durationText, String previewUrl, List trailers, String videoUrl, String link,@JsonKey(fromJson: _intFromAny) int? episode, String description, String playerType, List childItems, List episodeItems, List relatedItems +}); + + + + +} +/// @nodoc +class __$ScriptVideoItemCopyWithImpl<$Res> + implements _$ScriptVideoItemCopyWith<$Res> { + __$ScriptVideoItemCopyWithImpl(this._self, this._then); + + final _ScriptVideoItem _self; + final $Res Function(_ScriptVideoItem) _then; + +/// Create a copy of ScriptVideoItem +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? type = null,Object? title = null,Object? coverUrl = null,Object? posterPath = null,Object? detailPoster = null,Object? backdropPath = null,Object? backdropPaths = null,Object? releaseDate = null,Object? mediaType = null,Object? rating = freezed,Object? genreTitle = null,Object? genreItems = null,Object? peoples = null,Object? duration = freezed,Object? durationText = null,Object? previewUrl = null,Object? trailers = null,Object? videoUrl = null,Object? link = null,Object? episode = freezed,Object? description = null,Object? playerType = null,Object? childItems = null,Object? episodeItems = null,Object? relatedItems = null,}) { + return _then(_ScriptVideoItem( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable +as String,title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable +as String,coverUrl: null == coverUrl ? _self.coverUrl : coverUrl // ignore: cast_nullable_to_non_nullable +as String,posterPath: null == posterPath ? _self.posterPath : posterPath // ignore: cast_nullable_to_non_nullable +as String,detailPoster: null == detailPoster ? _self.detailPoster : detailPoster // ignore: cast_nullable_to_non_nullable +as String,backdropPath: null == backdropPath ? _self.backdropPath : backdropPath // ignore: cast_nullable_to_non_nullable +as String,backdropPaths: null == backdropPaths ? _self._backdropPaths : backdropPaths // ignore: cast_nullable_to_non_nullable +as List,releaseDate: null == releaseDate ? _self.releaseDate : releaseDate // ignore: cast_nullable_to_non_nullable +as String,mediaType: null == mediaType ? _self.mediaType : mediaType // ignore: cast_nullable_to_non_nullable +as String,rating: freezed == rating ? _self.rating : rating // ignore: cast_nullable_to_non_nullable +as double?,genreTitle: null == genreTitle ? _self.genreTitle : genreTitle // ignore: cast_nullable_to_non_nullable +as String,genreItems: null == genreItems ? _self._genreItems : genreItems // ignore: cast_nullable_to_non_nullable +as List,peoples: null == peoples ? _self._peoples : peoples // ignore: cast_nullable_to_non_nullable +as List,duration: freezed == duration ? _self.duration : duration // ignore: cast_nullable_to_non_nullable +as int?,durationText: null == durationText ? _self.durationText : durationText // ignore: cast_nullable_to_non_nullable +as String,previewUrl: null == previewUrl ? _self.previewUrl : previewUrl // ignore: cast_nullable_to_non_nullable +as String,trailers: null == trailers ? _self._trailers : trailers // ignore: cast_nullable_to_non_nullable +as List,videoUrl: null == videoUrl ? _self.videoUrl : videoUrl // ignore: cast_nullable_to_non_nullable +as String,link: null == link ? _self.link : link // ignore: cast_nullable_to_non_nullable +as String,episode: freezed == episode ? _self.episode : episode // ignore: cast_nullable_to_non_nullable +as int?,description: null == description ? _self.description : description // ignore: cast_nullable_to_non_nullable +as String,playerType: null == playerType ? _self.playerType : playerType // ignore: cast_nullable_to_non_nullable +as String,childItems: null == childItems ? _self._childItems : childItems // ignore: cast_nullable_to_non_nullable +as List,episodeItems: null == episodeItems ? _self._episodeItems : episodeItems // ignore: cast_nullable_to_non_nullable +as List,relatedItems: null == relatedItems ? _self._relatedItems : relatedItems // ignore: cast_nullable_to_non_nullable +as List, + )); +} + + +} + + +/// @nodoc +mixin _$ScriptVideoResource { + + String get name; String get description; String get url;@JsonKey(readValue: _readResourceHeaders) Map get customHeaders; String get playerType; +/// Create a copy of ScriptVideoResource +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$ScriptVideoResourceCopyWith get copyWith => _$ScriptVideoResourceCopyWithImpl(this as ScriptVideoResource, _$identity); + + /// Serializes this ScriptVideoResource to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is ScriptVideoResource&&(identical(other.name, name) || other.name == name)&&(identical(other.description, description) || other.description == description)&&(identical(other.url, url) || other.url == url)&&const DeepCollectionEquality().equals(other.customHeaders, customHeaders)&&(identical(other.playerType, playerType) || other.playerType == playerType)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,name,description,url,const DeepCollectionEquality().hash(customHeaders),playerType); + +@override +String toString() { + return 'ScriptVideoResource(name: $name, description: $description, url: $url, customHeaders: $customHeaders, playerType: $playerType)'; +} + + +} + +/// @nodoc +abstract mixin class $ScriptVideoResourceCopyWith<$Res> { + factory $ScriptVideoResourceCopyWith(ScriptVideoResource value, $Res Function(ScriptVideoResource) _then) = _$ScriptVideoResourceCopyWithImpl; +@useResult +$Res call({ + String name, String description, String url,@JsonKey(readValue: _readResourceHeaders) Map customHeaders, String playerType +}); + + + + +} +/// @nodoc +class _$ScriptVideoResourceCopyWithImpl<$Res> + implements $ScriptVideoResourceCopyWith<$Res> { + _$ScriptVideoResourceCopyWithImpl(this._self, this._then); + + final ScriptVideoResource _self; + final $Res Function(ScriptVideoResource) _then; + +/// Create a copy of ScriptVideoResource +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? name = null,Object? description = null,Object? url = null,Object? customHeaders = null,Object? playerType = null,}) { + return _then(_self.copyWith( +name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,description: null == description ? _self.description : description // ignore: cast_nullable_to_non_nullable +as String,url: null == url ? _self.url : url // ignore: cast_nullable_to_non_nullable +as String,customHeaders: null == customHeaders ? _self.customHeaders : customHeaders // ignore: cast_nullable_to_non_nullable +as Map,playerType: null == playerType ? _self.playerType : playerType // ignore: cast_nullable_to_non_nullable +as String, + )); +} + +} + + +/// Adds pattern-matching-related methods to [ScriptVideoResource]. +extension ScriptVideoResourcePatterns on ScriptVideoResource { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _ScriptVideoResource value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _ScriptVideoResource() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _ScriptVideoResource value) $default,){ +final _that = this; +switch (_that) { +case _ScriptVideoResource(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _ScriptVideoResource value)? $default,){ +final _that = this; +switch (_that) { +case _ScriptVideoResource() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String name, String description, String url, @JsonKey(readValue: _readResourceHeaders) Map customHeaders, String playerType)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _ScriptVideoResource() when $default != null: +return $default(_that.name,_that.description,_that.url,_that.customHeaders,_that.playerType);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String name, String description, String url, @JsonKey(readValue: _readResourceHeaders) Map customHeaders, String playerType) $default,) {final _that = this; +switch (_that) { +case _ScriptVideoResource(): +return $default(_that.name,_that.description,_that.url,_that.customHeaders,_that.playerType);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String name, String description, String url, @JsonKey(readValue: _readResourceHeaders) Map customHeaders, String playerType)? $default,) {final _that = this; +switch (_that) { +case _ScriptVideoResource() when $default != null: +return $default(_that.name,_that.description,_that.url,_that.customHeaders,_that.playerType);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _ScriptVideoResource implements ScriptVideoResource { + const _ScriptVideoResource({this.name = '', this.description = '', required this.url, @JsonKey(readValue: _readResourceHeaders) final Map customHeaders = const {}, this.playerType = 'app'}): _customHeaders = customHeaders; + factory _ScriptVideoResource.fromJson(Map json) => _$ScriptVideoResourceFromJson(json); + +@override@JsonKey() final String name; +@override@JsonKey() final String description; +@override final String url; + final Map _customHeaders; +@override@JsonKey(readValue: _readResourceHeaders) Map get customHeaders { + if (_customHeaders is EqualUnmodifiableMapView) return _customHeaders; + // ignore: implicit_dynamic_type + return EqualUnmodifiableMapView(_customHeaders); +} + +@override@JsonKey() final String playerType; + +/// Create a copy of ScriptVideoResource +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$ScriptVideoResourceCopyWith<_ScriptVideoResource> get copyWith => __$ScriptVideoResourceCopyWithImpl<_ScriptVideoResource>(this, _$identity); + +@override +Map toJson() { + return _$ScriptVideoResourceToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _ScriptVideoResource&&(identical(other.name, name) || other.name == name)&&(identical(other.description, description) || other.description == description)&&(identical(other.url, url) || other.url == url)&&const DeepCollectionEquality().equals(other._customHeaders, _customHeaders)&&(identical(other.playerType, playerType) || other.playerType == playerType)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,name,description,url,const DeepCollectionEquality().hash(_customHeaders),playerType); + +@override +String toString() { + return 'ScriptVideoResource(name: $name, description: $description, url: $url, customHeaders: $customHeaders, playerType: $playerType)'; +} + + +} + +/// @nodoc +abstract mixin class _$ScriptVideoResourceCopyWith<$Res> implements $ScriptVideoResourceCopyWith<$Res> { + factory _$ScriptVideoResourceCopyWith(_ScriptVideoResource value, $Res Function(_ScriptVideoResource) _then) = __$ScriptVideoResourceCopyWithImpl; +@override @useResult +$Res call({ + String name, String description, String url,@JsonKey(readValue: _readResourceHeaders) Map customHeaders, String playerType +}); + + + + +} +/// @nodoc +class __$ScriptVideoResourceCopyWithImpl<$Res> + implements _$ScriptVideoResourceCopyWith<$Res> { + __$ScriptVideoResourceCopyWithImpl(this._self, this._then); + + final _ScriptVideoResource _self; + final $Res Function(_ScriptVideoResource) _then; + +/// Create a copy of ScriptVideoResource +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? name = null,Object? description = null,Object? url = null,Object? customHeaders = null,Object? playerType = null,}) { + return _then(_ScriptVideoResource( +name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,description: null == description ? _self.description : description // ignore: cast_nullable_to_non_nullable +as String,url: null == url ? _self.url : url // ignore: cast_nullable_to_non_nullable +as String,customHeaders: null == customHeaders ? _self._customHeaders : customHeaders // ignore: cast_nullable_to_non_nullable +as Map,playerType: null == playerType ? _self.playerType : playerType // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + + +/// @nodoc +mixin _$InstalledScriptWidget { + + ScriptWidgetManifest get manifest; String get scriptSource; String get sourceUrl; bool get enabled; Map get globalParams; DateTime get installedAt; DateTime get updatedAt; +/// Create a copy of InstalledScriptWidget +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$InstalledScriptWidgetCopyWith get copyWith => _$InstalledScriptWidgetCopyWithImpl(this as InstalledScriptWidget, _$identity); + + /// Serializes this InstalledScriptWidget to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is InstalledScriptWidget&&(identical(other.manifest, manifest) || other.manifest == manifest)&&(identical(other.scriptSource, scriptSource) || other.scriptSource == scriptSource)&&(identical(other.sourceUrl, sourceUrl) || other.sourceUrl == sourceUrl)&&(identical(other.enabled, enabled) || other.enabled == enabled)&&const DeepCollectionEquality().equals(other.globalParams, globalParams)&&(identical(other.installedAt, installedAt) || other.installedAt == installedAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,manifest,scriptSource,sourceUrl,enabled,const DeepCollectionEquality().hash(globalParams),installedAt,updatedAt); + +@override +String toString() { + return 'InstalledScriptWidget(manifest: $manifest, scriptSource: $scriptSource, sourceUrl: $sourceUrl, enabled: $enabled, globalParams: $globalParams, installedAt: $installedAt, updatedAt: $updatedAt)'; +} + + +} + +/// @nodoc +abstract mixin class $InstalledScriptWidgetCopyWith<$Res> { + factory $InstalledScriptWidgetCopyWith(InstalledScriptWidget value, $Res Function(InstalledScriptWidget) _then) = _$InstalledScriptWidgetCopyWithImpl; +@useResult +$Res call({ + ScriptWidgetManifest manifest, String scriptSource, String sourceUrl, bool enabled, Map globalParams, DateTime installedAt, DateTime updatedAt +}); + + +$ScriptWidgetManifestCopyWith<$Res> get manifest; + +} +/// @nodoc +class _$InstalledScriptWidgetCopyWithImpl<$Res> + implements $InstalledScriptWidgetCopyWith<$Res> { + _$InstalledScriptWidgetCopyWithImpl(this._self, this._then); + + final InstalledScriptWidget _self; + final $Res Function(InstalledScriptWidget) _then; + +/// Create a copy of InstalledScriptWidget +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? manifest = null,Object? scriptSource = null,Object? sourceUrl = null,Object? enabled = null,Object? globalParams = null,Object? installedAt = null,Object? updatedAt = null,}) { + return _then(_self.copyWith( +manifest: null == manifest ? _self.manifest : manifest // ignore: cast_nullable_to_non_nullable +as ScriptWidgetManifest,scriptSource: null == scriptSource ? _self.scriptSource : scriptSource // ignore: cast_nullable_to_non_nullable +as String,sourceUrl: null == sourceUrl ? _self.sourceUrl : sourceUrl // ignore: cast_nullable_to_non_nullable +as String,enabled: null == enabled ? _self.enabled : enabled // ignore: cast_nullable_to_non_nullable +as bool,globalParams: null == globalParams ? _self.globalParams : globalParams // ignore: cast_nullable_to_non_nullable +as Map,installedAt: null == installedAt ? _self.installedAt : installedAt // ignore: cast_nullable_to_non_nullable +as DateTime,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable +as DateTime, + )); +} +/// Create a copy of InstalledScriptWidget +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$ScriptWidgetManifestCopyWith<$Res> get manifest { + + return $ScriptWidgetManifestCopyWith<$Res>(_self.manifest, (value) { + return _then(_self.copyWith(manifest: value)); + }); +} +} + + +/// Adds pattern-matching-related methods to [InstalledScriptWidget]. +extension InstalledScriptWidgetPatterns on InstalledScriptWidget { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _InstalledScriptWidget value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _InstalledScriptWidget() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _InstalledScriptWidget value) $default,){ +final _that = this; +switch (_that) { +case _InstalledScriptWidget(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _InstalledScriptWidget value)? $default,){ +final _that = this; +switch (_that) { +case _InstalledScriptWidget() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( ScriptWidgetManifest manifest, String scriptSource, String sourceUrl, bool enabled, Map globalParams, DateTime installedAt, DateTime updatedAt)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _InstalledScriptWidget() when $default != null: +return $default(_that.manifest,_that.scriptSource,_that.sourceUrl,_that.enabled,_that.globalParams,_that.installedAt,_that.updatedAt);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( ScriptWidgetManifest manifest, String scriptSource, String sourceUrl, bool enabled, Map globalParams, DateTime installedAt, DateTime updatedAt) $default,) {final _that = this; +switch (_that) { +case _InstalledScriptWidget(): +return $default(_that.manifest,_that.scriptSource,_that.sourceUrl,_that.enabled,_that.globalParams,_that.installedAt,_that.updatedAt);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( ScriptWidgetManifest manifest, String scriptSource, String sourceUrl, bool enabled, Map globalParams, DateTime installedAt, DateTime updatedAt)? $default,) {final _that = this; +switch (_that) { +case _InstalledScriptWidget() when $default != null: +return $default(_that.manifest,_that.scriptSource,_that.sourceUrl,_that.enabled,_that.globalParams,_that.installedAt,_that.updatedAt);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _InstalledScriptWidget implements InstalledScriptWidget { + const _InstalledScriptWidget({required this.manifest, required this.scriptSource, this.sourceUrl = '', this.enabled = true, final Map globalParams = const {}, required this.installedAt, required this.updatedAt}): _globalParams = globalParams; + factory _InstalledScriptWidget.fromJson(Map json) => _$InstalledScriptWidgetFromJson(json); + +@override final ScriptWidgetManifest manifest; +@override final String scriptSource; +@override@JsonKey() final String sourceUrl; +@override@JsonKey() final bool enabled; + final Map _globalParams; +@override@JsonKey() Map get globalParams { + if (_globalParams is EqualUnmodifiableMapView) return _globalParams; + // ignore: implicit_dynamic_type + return EqualUnmodifiableMapView(_globalParams); +} + +@override final DateTime installedAt; +@override final DateTime updatedAt; + +/// Create a copy of InstalledScriptWidget +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$InstalledScriptWidgetCopyWith<_InstalledScriptWidget> get copyWith => __$InstalledScriptWidgetCopyWithImpl<_InstalledScriptWidget>(this, _$identity); + +@override +Map toJson() { + return _$InstalledScriptWidgetToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _InstalledScriptWidget&&(identical(other.manifest, manifest) || other.manifest == manifest)&&(identical(other.scriptSource, scriptSource) || other.scriptSource == scriptSource)&&(identical(other.sourceUrl, sourceUrl) || other.sourceUrl == sourceUrl)&&(identical(other.enabled, enabled) || other.enabled == enabled)&&const DeepCollectionEquality().equals(other._globalParams, _globalParams)&&(identical(other.installedAt, installedAt) || other.installedAt == installedAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,manifest,scriptSource,sourceUrl,enabled,const DeepCollectionEquality().hash(_globalParams),installedAt,updatedAt); + +@override +String toString() { + return 'InstalledScriptWidget(manifest: $manifest, scriptSource: $scriptSource, sourceUrl: $sourceUrl, enabled: $enabled, globalParams: $globalParams, installedAt: $installedAt, updatedAt: $updatedAt)'; +} + + +} + +/// @nodoc +abstract mixin class _$InstalledScriptWidgetCopyWith<$Res> implements $InstalledScriptWidgetCopyWith<$Res> { + factory _$InstalledScriptWidgetCopyWith(_InstalledScriptWidget value, $Res Function(_InstalledScriptWidget) _then) = __$InstalledScriptWidgetCopyWithImpl; +@override @useResult +$Res call({ + ScriptWidgetManifest manifest, String scriptSource, String sourceUrl, bool enabled, Map globalParams, DateTime installedAt, DateTime updatedAt +}); + + +@override $ScriptWidgetManifestCopyWith<$Res> get manifest; + +} +/// @nodoc +class __$InstalledScriptWidgetCopyWithImpl<$Res> + implements _$InstalledScriptWidgetCopyWith<$Res> { + __$InstalledScriptWidgetCopyWithImpl(this._self, this._then); + + final _InstalledScriptWidget _self; + final $Res Function(_InstalledScriptWidget) _then; + +/// Create a copy of InstalledScriptWidget +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? manifest = null,Object? scriptSource = null,Object? sourceUrl = null,Object? enabled = null,Object? globalParams = null,Object? installedAt = null,Object? updatedAt = null,}) { + return _then(_InstalledScriptWidget( +manifest: null == manifest ? _self.manifest : manifest // ignore: cast_nullable_to_non_nullable +as ScriptWidgetManifest,scriptSource: null == scriptSource ? _self.scriptSource : scriptSource // ignore: cast_nullable_to_non_nullable +as String,sourceUrl: null == sourceUrl ? _self.sourceUrl : sourceUrl // ignore: cast_nullable_to_non_nullable +as String,enabled: null == enabled ? _self.enabled : enabled // ignore: cast_nullable_to_non_nullable +as bool,globalParams: null == globalParams ? _self._globalParams : globalParams // ignore: cast_nullable_to_non_nullable +as Map,installedAt: null == installedAt ? _self.installedAt : installedAt // ignore: cast_nullable_to_non_nullable +as DateTime,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable +as DateTime, + )); +} + +/// Create a copy of InstalledScriptWidget +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$ScriptWidgetManifestCopyWith<$Res> get manifest { + + return $ScriptWidgetManifestCopyWith<$Res>(_self.manifest, (value) { + return _then(_self.copyWith(manifest: value)); + }); +} +} + + +/// @nodoc +mixin _$ScriptWidgetCatalogEntry { + + String get id; String get title; String get description; String get requiredVersion; String get version; String get author; String get url; +/// Create a copy of ScriptWidgetCatalogEntry +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$ScriptWidgetCatalogEntryCopyWith get copyWith => _$ScriptWidgetCatalogEntryCopyWithImpl(this as ScriptWidgetCatalogEntry, _$identity); + + /// Serializes this ScriptWidgetCatalogEntry to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is ScriptWidgetCatalogEntry&&(identical(other.id, id) || other.id == id)&&(identical(other.title, title) || other.title == title)&&(identical(other.description, description) || other.description == description)&&(identical(other.requiredVersion, requiredVersion) || other.requiredVersion == requiredVersion)&&(identical(other.version, version) || other.version == version)&&(identical(other.author, author) || other.author == author)&&(identical(other.url, url) || other.url == url)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,title,description,requiredVersion,version,author,url); + +@override +String toString() { + return 'ScriptWidgetCatalogEntry(id: $id, title: $title, description: $description, requiredVersion: $requiredVersion, version: $version, author: $author, url: $url)'; +} + + +} + +/// @nodoc +abstract mixin class $ScriptWidgetCatalogEntryCopyWith<$Res> { + factory $ScriptWidgetCatalogEntryCopyWith(ScriptWidgetCatalogEntry value, $Res Function(ScriptWidgetCatalogEntry) _then) = _$ScriptWidgetCatalogEntryCopyWithImpl; +@useResult +$Res call({ + String id, String title, String description, String requiredVersion, String version, String author, String url +}); + + + + +} +/// @nodoc +class _$ScriptWidgetCatalogEntryCopyWithImpl<$Res> + implements $ScriptWidgetCatalogEntryCopyWith<$Res> { + _$ScriptWidgetCatalogEntryCopyWithImpl(this._self, this._then); + + final ScriptWidgetCatalogEntry _self; + final $Res Function(ScriptWidgetCatalogEntry) _then; + +/// Create a copy of ScriptWidgetCatalogEntry +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? title = null,Object? description = null,Object? requiredVersion = null,Object? version = null,Object? author = null,Object? url = null,}) { + return _then(_self.copyWith( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable +as String,description: null == description ? _self.description : description // ignore: cast_nullable_to_non_nullable +as String,requiredVersion: null == requiredVersion ? _self.requiredVersion : requiredVersion // ignore: cast_nullable_to_non_nullable +as String,version: null == version ? _self.version : version // ignore: cast_nullable_to_non_nullable +as String,author: null == author ? _self.author : author // ignore: cast_nullable_to_non_nullable +as String,url: null == url ? _self.url : url // ignore: cast_nullable_to_non_nullable +as String, + )); +} + +} + + +/// Adds pattern-matching-related methods to [ScriptWidgetCatalogEntry]. +extension ScriptWidgetCatalogEntryPatterns on ScriptWidgetCatalogEntry { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _ScriptWidgetCatalogEntry value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _ScriptWidgetCatalogEntry() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _ScriptWidgetCatalogEntry value) $default,){ +final _that = this; +switch (_that) { +case _ScriptWidgetCatalogEntry(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _ScriptWidgetCatalogEntry value)? $default,){ +final _that = this; +switch (_that) { +case _ScriptWidgetCatalogEntry() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String id, String title, String description, String requiredVersion, String version, String author, String url)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _ScriptWidgetCatalogEntry() when $default != null: +return $default(_that.id,_that.title,_that.description,_that.requiredVersion,_that.version,_that.author,_that.url);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String id, String title, String description, String requiredVersion, String version, String author, String url) $default,) {final _that = this; +switch (_that) { +case _ScriptWidgetCatalogEntry(): +return $default(_that.id,_that.title,_that.description,_that.requiredVersion,_that.version,_that.author,_that.url);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String id, String title, String description, String requiredVersion, String version, String author, String url)? $default,) {final _that = this; +switch (_that) { +case _ScriptWidgetCatalogEntry() when $default != null: +return $default(_that.id,_that.title,_that.description,_that.requiredVersion,_that.version,_that.author,_that.url);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _ScriptWidgetCatalogEntry implements ScriptWidgetCatalogEntry { + const _ScriptWidgetCatalogEntry({required this.id, required this.title, this.description = '', this.requiredVersion = '', this.version = '', this.author = '', required this.url}); + factory _ScriptWidgetCatalogEntry.fromJson(Map json) => _$ScriptWidgetCatalogEntryFromJson(json); + +@override final String id; +@override final String title; +@override@JsonKey() final String description; +@override@JsonKey() final String requiredVersion; +@override@JsonKey() final String version; +@override@JsonKey() final String author; +@override final String url; + +/// Create a copy of ScriptWidgetCatalogEntry +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$ScriptWidgetCatalogEntryCopyWith<_ScriptWidgetCatalogEntry> get copyWith => __$ScriptWidgetCatalogEntryCopyWithImpl<_ScriptWidgetCatalogEntry>(this, _$identity); + +@override +Map toJson() { + return _$ScriptWidgetCatalogEntryToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _ScriptWidgetCatalogEntry&&(identical(other.id, id) || other.id == id)&&(identical(other.title, title) || other.title == title)&&(identical(other.description, description) || other.description == description)&&(identical(other.requiredVersion, requiredVersion) || other.requiredVersion == requiredVersion)&&(identical(other.version, version) || other.version == version)&&(identical(other.author, author) || other.author == author)&&(identical(other.url, url) || other.url == url)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,title,description,requiredVersion,version,author,url); + +@override +String toString() { + return 'ScriptWidgetCatalogEntry(id: $id, title: $title, description: $description, requiredVersion: $requiredVersion, version: $version, author: $author, url: $url)'; +} + + +} + +/// @nodoc +abstract mixin class _$ScriptWidgetCatalogEntryCopyWith<$Res> implements $ScriptWidgetCatalogEntryCopyWith<$Res> { + factory _$ScriptWidgetCatalogEntryCopyWith(_ScriptWidgetCatalogEntry value, $Res Function(_ScriptWidgetCatalogEntry) _then) = __$ScriptWidgetCatalogEntryCopyWithImpl; +@override @useResult +$Res call({ + String id, String title, String description, String requiredVersion, String version, String author, String url +}); + + + + +} +/// @nodoc +class __$ScriptWidgetCatalogEntryCopyWithImpl<$Res> + implements _$ScriptWidgetCatalogEntryCopyWith<$Res> { + __$ScriptWidgetCatalogEntryCopyWithImpl(this._self, this._then); + + final _ScriptWidgetCatalogEntry _self; + final $Res Function(_ScriptWidgetCatalogEntry) _then; + +/// Create a copy of ScriptWidgetCatalogEntry +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? title = null,Object? description = null,Object? requiredVersion = null,Object? version = null,Object? author = null,Object? url = null,}) { + return _then(_ScriptWidgetCatalogEntry( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable +as String,description: null == description ? _self.description : description // ignore: cast_nullable_to_non_nullable +as String,requiredVersion: null == requiredVersion ? _self.requiredVersion : requiredVersion // ignore: cast_nullable_to_non_nullable +as String,version: null == version ? _self.version : version // ignore: cast_nullable_to_non_nullable +as String,author: null == author ? _self.author : author // ignore: cast_nullable_to_non_nullable +as String,url: null == url ? _self.url : url // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + + +/// @nodoc +mixin _$ScriptWidgetCatalog { + + String get title; String get description; String get icon; List get widgets; +/// Create a copy of ScriptWidgetCatalog +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$ScriptWidgetCatalogCopyWith get copyWith => _$ScriptWidgetCatalogCopyWithImpl(this as ScriptWidgetCatalog, _$identity); + + /// Serializes this ScriptWidgetCatalog to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is ScriptWidgetCatalog&&(identical(other.title, title) || other.title == title)&&(identical(other.description, description) || other.description == description)&&(identical(other.icon, icon) || other.icon == icon)&&const DeepCollectionEquality().equals(other.widgets, widgets)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,title,description,icon,const DeepCollectionEquality().hash(widgets)); + +@override +String toString() { + return 'ScriptWidgetCatalog(title: $title, description: $description, icon: $icon, widgets: $widgets)'; +} + + +} + +/// @nodoc +abstract mixin class $ScriptWidgetCatalogCopyWith<$Res> { + factory $ScriptWidgetCatalogCopyWith(ScriptWidgetCatalog value, $Res Function(ScriptWidgetCatalog) _then) = _$ScriptWidgetCatalogCopyWithImpl; +@useResult +$Res call({ + String title, String description, String icon, List widgets +}); + + + + +} +/// @nodoc +class _$ScriptWidgetCatalogCopyWithImpl<$Res> + implements $ScriptWidgetCatalogCopyWith<$Res> { + _$ScriptWidgetCatalogCopyWithImpl(this._self, this._then); + + final ScriptWidgetCatalog _self; + final $Res Function(ScriptWidgetCatalog) _then; + +/// Create a copy of ScriptWidgetCatalog +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? title = null,Object? description = null,Object? icon = null,Object? widgets = null,}) { + return _then(_self.copyWith( +title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable +as String,description: null == description ? _self.description : description // ignore: cast_nullable_to_non_nullable +as String,icon: null == icon ? _self.icon : icon // ignore: cast_nullable_to_non_nullable +as String,widgets: null == widgets ? _self.widgets : widgets // ignore: cast_nullable_to_non_nullable +as List, + )); +} + +} + + +/// Adds pattern-matching-related methods to [ScriptWidgetCatalog]. +extension ScriptWidgetCatalogPatterns on ScriptWidgetCatalog { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _ScriptWidgetCatalog value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _ScriptWidgetCatalog() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _ScriptWidgetCatalog value) $default,){ +final _that = this; +switch (_that) { +case _ScriptWidgetCatalog(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _ScriptWidgetCatalog value)? $default,){ +final _that = this; +switch (_that) { +case _ScriptWidgetCatalog() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String title, String description, String icon, List widgets)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _ScriptWidgetCatalog() when $default != null: +return $default(_that.title,_that.description,_that.icon,_that.widgets);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String title, String description, String icon, List widgets) $default,) {final _that = this; +switch (_that) { +case _ScriptWidgetCatalog(): +return $default(_that.title,_that.description,_that.icon,_that.widgets);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String title, String description, String icon, List widgets)? $default,) {final _that = this; +switch (_that) { +case _ScriptWidgetCatalog() when $default != null: +return $default(_that.title,_that.description,_that.icon,_that.widgets);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _ScriptWidgetCatalog implements ScriptWidgetCatalog { + const _ScriptWidgetCatalog({required this.title, this.description = '', this.icon = '', final List widgets = const []}): _widgets = widgets; + factory _ScriptWidgetCatalog.fromJson(Map json) => _$ScriptWidgetCatalogFromJson(json); + +@override final String title; +@override@JsonKey() final String description; +@override@JsonKey() final String icon; + final List _widgets; +@override@JsonKey() List get widgets { + if (_widgets is EqualUnmodifiableListView) return _widgets; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_widgets); +} + + +/// Create a copy of ScriptWidgetCatalog +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$ScriptWidgetCatalogCopyWith<_ScriptWidgetCatalog> get copyWith => __$ScriptWidgetCatalogCopyWithImpl<_ScriptWidgetCatalog>(this, _$identity); + +@override +Map toJson() { + return _$ScriptWidgetCatalogToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _ScriptWidgetCatalog&&(identical(other.title, title) || other.title == title)&&(identical(other.description, description) || other.description == description)&&(identical(other.icon, icon) || other.icon == icon)&&const DeepCollectionEquality().equals(other._widgets, _widgets)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,title,description,icon,const DeepCollectionEquality().hash(_widgets)); + +@override +String toString() { + return 'ScriptWidgetCatalog(title: $title, description: $description, icon: $icon, widgets: $widgets)'; +} + + +} + +/// @nodoc +abstract mixin class _$ScriptWidgetCatalogCopyWith<$Res> implements $ScriptWidgetCatalogCopyWith<$Res> { + factory _$ScriptWidgetCatalogCopyWith(_ScriptWidgetCatalog value, $Res Function(_ScriptWidgetCatalog) _then) = __$ScriptWidgetCatalogCopyWithImpl; +@override @useResult +$Res call({ + String title, String description, String icon, List widgets +}); + + + + +} +/// @nodoc +class __$ScriptWidgetCatalogCopyWithImpl<$Res> + implements _$ScriptWidgetCatalogCopyWith<$Res> { + __$ScriptWidgetCatalogCopyWithImpl(this._self, this._then); + + final _ScriptWidgetCatalog _self; + final $Res Function(_ScriptWidgetCatalog) _then; + +/// Create a copy of ScriptWidgetCatalog +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? title = null,Object? description = null,Object? icon = null,Object? widgets = null,}) { + return _then(_ScriptWidgetCatalog( +title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable +as String,description: null == description ? _self.description : description // ignore: cast_nullable_to_non_nullable +as String,icon: null == icon ? _self.icon : icon // ignore: cast_nullable_to_non_nullable +as String,widgets: null == widgets ? _self._widgets : widgets // ignore: cast_nullable_to_non_nullable +as List, + )); +} + + +} + + +/// @nodoc +mixin _$ScriptWidgetSubscription { + + String get url; ScriptWidgetCatalog get catalog; DateTime get refreshedAt; +/// Create a copy of ScriptWidgetSubscription +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$ScriptWidgetSubscriptionCopyWith get copyWith => _$ScriptWidgetSubscriptionCopyWithImpl(this as ScriptWidgetSubscription, _$identity); + + /// Serializes this ScriptWidgetSubscription to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is ScriptWidgetSubscription&&(identical(other.url, url) || other.url == url)&&(identical(other.catalog, catalog) || other.catalog == catalog)&&(identical(other.refreshedAt, refreshedAt) || other.refreshedAt == refreshedAt)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,url,catalog,refreshedAt); + +@override +String toString() { + return 'ScriptWidgetSubscription(url: $url, catalog: $catalog, refreshedAt: $refreshedAt)'; +} + + +} + +/// @nodoc +abstract mixin class $ScriptWidgetSubscriptionCopyWith<$Res> { + factory $ScriptWidgetSubscriptionCopyWith(ScriptWidgetSubscription value, $Res Function(ScriptWidgetSubscription) _then) = _$ScriptWidgetSubscriptionCopyWithImpl; +@useResult +$Res call({ + String url, ScriptWidgetCatalog catalog, DateTime refreshedAt +}); + + +$ScriptWidgetCatalogCopyWith<$Res> get catalog; + +} +/// @nodoc +class _$ScriptWidgetSubscriptionCopyWithImpl<$Res> + implements $ScriptWidgetSubscriptionCopyWith<$Res> { + _$ScriptWidgetSubscriptionCopyWithImpl(this._self, this._then); + + final ScriptWidgetSubscription _self; + final $Res Function(ScriptWidgetSubscription) _then; + +/// Create a copy of ScriptWidgetSubscription +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? url = null,Object? catalog = null,Object? refreshedAt = null,}) { + return _then(_self.copyWith( +url: null == url ? _self.url : url // ignore: cast_nullable_to_non_nullable +as String,catalog: null == catalog ? _self.catalog : catalog // ignore: cast_nullable_to_non_nullable +as ScriptWidgetCatalog,refreshedAt: null == refreshedAt ? _self.refreshedAt : refreshedAt // ignore: cast_nullable_to_non_nullable +as DateTime, + )); +} +/// Create a copy of ScriptWidgetSubscription +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$ScriptWidgetCatalogCopyWith<$Res> get catalog { + + return $ScriptWidgetCatalogCopyWith<$Res>(_self.catalog, (value) { + return _then(_self.copyWith(catalog: value)); + }); +} +} + + +/// Adds pattern-matching-related methods to [ScriptWidgetSubscription]. +extension ScriptWidgetSubscriptionPatterns on ScriptWidgetSubscription { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _ScriptWidgetSubscription value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _ScriptWidgetSubscription() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _ScriptWidgetSubscription value) $default,){ +final _that = this; +switch (_that) { +case _ScriptWidgetSubscription(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _ScriptWidgetSubscription value)? $default,){ +final _that = this; +switch (_that) { +case _ScriptWidgetSubscription() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String url, ScriptWidgetCatalog catalog, DateTime refreshedAt)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _ScriptWidgetSubscription() when $default != null: +return $default(_that.url,_that.catalog,_that.refreshedAt);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String url, ScriptWidgetCatalog catalog, DateTime refreshedAt) $default,) {final _that = this; +switch (_that) { +case _ScriptWidgetSubscription(): +return $default(_that.url,_that.catalog,_that.refreshedAt);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String url, ScriptWidgetCatalog catalog, DateTime refreshedAt)? $default,) {final _that = this; +switch (_that) { +case _ScriptWidgetSubscription() when $default != null: +return $default(_that.url,_that.catalog,_that.refreshedAt);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _ScriptWidgetSubscription implements ScriptWidgetSubscription { + const _ScriptWidgetSubscription({required this.url, required this.catalog, required this.refreshedAt}); + factory _ScriptWidgetSubscription.fromJson(Map json) => _$ScriptWidgetSubscriptionFromJson(json); + +@override final String url; +@override final ScriptWidgetCatalog catalog; +@override final DateTime refreshedAt; + +/// Create a copy of ScriptWidgetSubscription +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$ScriptWidgetSubscriptionCopyWith<_ScriptWidgetSubscription> get copyWith => __$ScriptWidgetSubscriptionCopyWithImpl<_ScriptWidgetSubscription>(this, _$identity); + +@override +Map toJson() { + return _$ScriptWidgetSubscriptionToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _ScriptWidgetSubscription&&(identical(other.url, url) || other.url == url)&&(identical(other.catalog, catalog) || other.catalog == catalog)&&(identical(other.refreshedAt, refreshedAt) || other.refreshedAt == refreshedAt)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,url,catalog,refreshedAt); + +@override +String toString() { + return 'ScriptWidgetSubscription(url: $url, catalog: $catalog, refreshedAt: $refreshedAt)'; +} + + +} + +/// @nodoc +abstract mixin class _$ScriptWidgetSubscriptionCopyWith<$Res> implements $ScriptWidgetSubscriptionCopyWith<$Res> { + factory _$ScriptWidgetSubscriptionCopyWith(_ScriptWidgetSubscription value, $Res Function(_ScriptWidgetSubscription) _then) = __$ScriptWidgetSubscriptionCopyWithImpl; +@override @useResult +$Res call({ + String url, ScriptWidgetCatalog catalog, DateTime refreshedAt +}); + + +@override $ScriptWidgetCatalogCopyWith<$Res> get catalog; + +} +/// @nodoc +class __$ScriptWidgetSubscriptionCopyWithImpl<$Res> + implements _$ScriptWidgetSubscriptionCopyWith<$Res> { + __$ScriptWidgetSubscriptionCopyWithImpl(this._self, this._then); + + final _ScriptWidgetSubscription _self; + final $Res Function(_ScriptWidgetSubscription) _then; + +/// Create a copy of ScriptWidgetSubscription +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? url = null,Object? catalog = null,Object? refreshedAt = null,}) { + return _then(_ScriptWidgetSubscription( +url: null == url ? _self.url : url // ignore: cast_nullable_to_non_nullable +as String,catalog: null == catalog ? _self.catalog : catalog // ignore: cast_nullable_to_non_nullable +as ScriptWidgetCatalog,refreshedAt: null == refreshedAt ? _self.refreshedAt : refreshedAt // ignore: cast_nullable_to_non_nullable +as DateTime, + )); +} + +/// Create a copy of ScriptWidgetSubscription +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$ScriptWidgetCatalogCopyWith<$Res> get catalog { + + return $ScriptWidgetCatalogCopyWith<$Res>(_self.catalog, (value) { + return _then(_self.copyWith(catalog: value)); + }); +} +} + +// dart format on diff --git a/lib/core/contracts/script_widget.g.dart b/lib/core/contracts/script_widget.g.dart new file mode 100644 index 0000000..2a12f88 --- /dev/null +++ b/lib/core/contracts/script_widget.g.dart @@ -0,0 +1,381 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'script_widget.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_ScriptWidgetOption _$ScriptWidgetOptionFromJson(Map json) => + _ScriptWidgetOption( + title: json['title'] as String? ?? '', + value: json['value'] == null ? '' : _stringFromAny(json['value']), + ); + +Map _$ScriptWidgetOptionToJson(_ScriptWidgetOption instance) => + {'title': instance.title, 'value': instance.value}; + +_ScriptWidgetBelongTo _$ScriptWidgetBelongToFromJson( + Map json, +) => _ScriptWidgetBelongTo( + paramName: json['paramName'] as String? ?? '', + value: json['value'] == null + ? const [] + : _stringListFromAny(json['value']), +); + +Map _$ScriptWidgetBelongToToJson( + _ScriptWidgetBelongTo instance, +) => { + 'paramName': instance.paramName, + 'value': instance.value, +}; + +_ScriptWidgetParam _$ScriptWidgetParamFromJson( + Map json, +) => _ScriptWidgetParam( + name: json['name'] as String, + title: json['title'] as String? ?? '', + type: json['type'] as String? ?? 'input', + description: json['description'] as String? ?? '', + value: json['value'], + belongTo: json['belongTo'] == null + ? null + : ScriptWidgetBelongTo.fromJson(json['belongTo'] as Map), + placeholders: + (json['placeholders'] as List?) + ?.map((e) => ScriptWidgetOption.fromJson(e as Map)) + .toList() ?? + const [], + enumOptions: + (json['enumOptions'] as List?) + ?.map((e) => ScriptWidgetOption.fromJson(e as Map)) + .toList() ?? + const [], +); + +Map _$ScriptWidgetParamToJson(_ScriptWidgetParam instance) => + { + 'name': instance.name, + 'title': instance.title, + 'type': instance.type, + 'description': instance.description, + 'value': instance.value, + 'belongTo': instance.belongTo, + 'placeholders': instance.placeholders, + 'enumOptions': instance.enumOptions, + }; + +_ScriptWidgetModule _$ScriptWidgetModuleFromJson(Map json) => + _ScriptWidgetModule( + id: json['id'] as String? ?? '', + title: json['title'] as String, + description: json['description'] as String? ?? '', + requiresWebView: json['requiresWebView'] as bool? ?? false, + functionName: json['functionName'] as String, + sectionMode: json['sectionMode'] as bool? ?? false, + cacheDuration: (json['cacheDuration'] as num?)?.toInt() ?? 3600, + type: json['type'] as String? ?? 'list', + params: + (json['params'] as List?) + ?.map( + (e) => ScriptWidgetParam.fromJson(e as Map), + ) + .toList() ?? + const [], + ); + +Map _$ScriptWidgetModuleToJson(_ScriptWidgetModule instance) => + { + 'id': instance.id, + 'title': instance.title, + 'description': instance.description, + 'requiresWebView': instance.requiresWebView, + 'functionName': instance.functionName, + 'sectionMode': instance.sectionMode, + 'cacheDuration': instance.cacheDuration, + 'type': instance.type, + 'params': instance.params, + }; + +_ScriptWidgetManifest _$ScriptWidgetManifestFromJson( + Map json, +) => _ScriptWidgetManifest( + id: json['id'] as String, + title: json['title'] as String, + description: json['description'] as String? ?? '', + author: json['author'] as String? ?? '', + site: json['site'] as String? ?? '', + version: json['version'] as String? ?? '0.0.0', + requiredVersion: json['requiredVersion'] as String? ?? '0.0.1', + detailCacheDuration: (json['detailCacheDuration'] as num?)?.toInt() ?? 60, + globalParams: + (json['globalParams'] as List?) + ?.map((e) => ScriptWidgetParam.fromJson(e as Map)) + .toList() ?? + const [], + modules: + (json['modules'] as List?) + ?.map((e) => ScriptWidgetModule.fromJson(e as Map)) + .toList() ?? + const [], + search: json['search'] == null + ? null + : ScriptWidgetModule.fromJson(json['search'] as Map), +); + +Map _$ScriptWidgetManifestToJson( + _ScriptWidgetManifest instance, +) => { + 'id': instance.id, + 'title': instance.title, + 'description': instance.description, + 'author': instance.author, + 'site': instance.site, + 'version': instance.version, + 'requiredVersion': instance.requiredVersion, + 'detailCacheDuration': instance.detailCacheDuration, + 'globalParams': instance.globalParams, + 'modules': instance.modules, + 'search': instance.search, +}; + +_ScriptVideoPerson _$ScriptVideoPersonFromJson(Map json) => + _ScriptVideoPerson( + id: json['id'] == null ? '' : _stringFromAny(json['id']), + title: json['title'] as String? ?? '', + avatar: json['avatar'] as String? ?? '', + role: json['role'] as String? ?? '', + ); + +Map _$ScriptVideoPersonToJson(_ScriptVideoPerson instance) => + { + 'id': instance.id, + 'title': instance.title, + 'avatar': instance.avatar, + 'role': instance.role, + }; + +_ScriptVideoGenre _$ScriptVideoGenreFromJson(Map json) => + _ScriptVideoGenre( + id: json['id'] == null ? '' : _stringFromAny(json['id']), + title: json['title'] as String? ?? '', + ); + +Map _$ScriptVideoGenreToJson(_ScriptVideoGenre instance) => + {'id': instance.id, 'title': instance.title}; + +_ScriptVideoTrailer _$ScriptVideoTrailerFromJson(Map json) => + _ScriptVideoTrailer( + coverUrl: json['coverUrl'] as String? ?? '', + url: json['url'] as String? ?? '', + ); + +Map _$ScriptVideoTrailerToJson(_ScriptVideoTrailer instance) => + {'coverUrl': instance.coverUrl, 'url': instance.url}; + +_ScriptVideoItem _$ScriptVideoItemFromJson( + Map json, +) => _ScriptVideoItem( + id: _stringFromAny(json['id']), + type: json['type'] as String? ?? 'link', + title: json['title'] as String? ?? '', + coverUrl: json['coverUrl'] as String? ?? '', + posterPath: _readPosterPath(json, 'posterPath') as String? ?? '', + detailPoster: json['detailPoster'] as String? ?? '', + backdropPath: json['backdropPath'] as String? ?? '', + backdropPaths: + (json['backdropPaths'] as List?) + ?.map((e) => e as String) + .toList() ?? + const [], + releaseDate: json['releaseDate'] as String? ?? '', + mediaType: json['mediaType'] as String? ?? '', + rating: _doubleFromAny(json['rating']), + genreTitle: json['genreTitle'] as String? ?? '', + genreItems: + (json['genreItems'] as List?) + ?.map((e) => ScriptVideoGenre.fromJson(e as Map)) + .toList() ?? + const [], + peoples: + (json['peoples'] as List?) + ?.map((e) => ScriptVideoPerson.fromJson(e as Map)) + .toList() ?? + const [], + duration: _intFromAny(json['duration']), + durationText: json['durationText'] as String? ?? '', + previewUrl: json['previewUrl'] as String? ?? '', + trailers: + (json['trailers'] as List?) + ?.map((e) => ScriptVideoTrailer.fromJson(e as Map)) + .toList() ?? + const [], + videoUrl: json['videoUrl'] as String? ?? '', + link: json['link'] as String? ?? '', + episode: _intFromAny(json['episode']), + description: json['description'] as String? ?? '', + playerType: json['playerType'] as String? ?? 'app', + childItems: + (json['childItems'] as List?) + ?.map((e) => ScriptVideoItem.fromJson(e as Map)) + .toList() ?? + const [], + episodeItems: + (json['episodeItems'] as List?) + ?.map((e) => ScriptVideoItem.fromJson(e as Map)) + .toList() ?? + const [], + relatedItems: + (json['relatedItems'] as List?) + ?.map((e) => ScriptVideoItem.fromJson(e as Map)) + .toList() ?? + const [], +); + +Map _$ScriptVideoItemToJson(_ScriptVideoItem instance) => + { + 'id': instance.id, + 'type': instance.type, + 'title': instance.title, + 'coverUrl': instance.coverUrl, + 'posterPath': instance.posterPath, + 'detailPoster': instance.detailPoster, + 'backdropPath': instance.backdropPath, + 'backdropPaths': instance.backdropPaths, + 'releaseDate': instance.releaseDate, + 'mediaType': instance.mediaType, + 'rating': instance.rating, + 'genreTitle': instance.genreTitle, + 'genreItems': instance.genreItems, + 'peoples': instance.peoples, + 'duration': instance.duration, + 'durationText': instance.durationText, + 'previewUrl': instance.previewUrl, + 'trailers': instance.trailers, + 'videoUrl': instance.videoUrl, + 'link': instance.link, + 'episode': instance.episode, + 'description': instance.description, + 'playerType': instance.playerType, + 'childItems': instance.childItems, + 'episodeItems': instance.episodeItems, + 'relatedItems': instance.relatedItems, + }; + +_ScriptVideoResource _$ScriptVideoResourceFromJson(Map json) => + _ScriptVideoResource( + name: json['name'] as String? ?? '', + description: json['description'] as String? ?? '', + url: json['url'] as String, + customHeaders: + (_readResourceHeaders(json, 'customHeaders') as Map?) + ?.map((k, e) => MapEntry(k, e as String)) ?? + const {}, + playerType: json['playerType'] as String? ?? 'app', + ); + +Map _$ScriptVideoResourceToJson( + _ScriptVideoResource instance, +) => { + 'name': instance.name, + 'description': instance.description, + 'url': instance.url, + 'customHeaders': instance.customHeaders, + 'playerType': instance.playerType, +}; + +_InstalledScriptWidget _$InstalledScriptWidgetFromJson( + Map json, +) => _InstalledScriptWidget( + manifest: ScriptWidgetManifest.fromJson( + json['manifest'] as Map, + ), + scriptSource: json['scriptSource'] as String, + sourceUrl: json['sourceUrl'] as String? ?? '', + enabled: json['enabled'] as bool? ?? true, + globalParams: + json['globalParams'] as Map? ?? + const {}, + installedAt: DateTime.parse(json['installedAt'] as String), + updatedAt: DateTime.parse(json['updatedAt'] as String), +); + +Map _$InstalledScriptWidgetToJson( + _InstalledScriptWidget instance, +) => { + 'manifest': instance.manifest, + 'scriptSource': instance.scriptSource, + 'sourceUrl': instance.sourceUrl, + 'enabled': instance.enabled, + 'globalParams': instance.globalParams, + 'installedAt': instance.installedAt.toIso8601String(), + 'updatedAt': instance.updatedAt.toIso8601String(), +}; + +_ScriptWidgetCatalogEntry _$ScriptWidgetCatalogEntryFromJson( + Map json, +) => _ScriptWidgetCatalogEntry( + id: json['id'] as String, + title: json['title'] as String, + description: json['description'] as String? ?? '', + requiredVersion: json['requiredVersion'] as String? ?? '', + version: json['version'] as String? ?? '', + author: json['author'] as String? ?? '', + url: json['url'] as String, +); + +Map _$ScriptWidgetCatalogEntryToJson( + _ScriptWidgetCatalogEntry instance, +) => { + 'id': instance.id, + 'title': instance.title, + 'description': instance.description, + 'requiredVersion': instance.requiredVersion, + 'version': instance.version, + 'author': instance.author, + 'url': instance.url, +}; + +_ScriptWidgetCatalog _$ScriptWidgetCatalogFromJson(Map json) => + _ScriptWidgetCatalog( + title: json['title'] as String, + description: json['description'] as String? ?? '', + icon: json['icon'] as String? ?? '', + widgets: + (json['widgets'] as List?) + ?.map( + (e) => ScriptWidgetCatalogEntry.fromJson( + e as Map, + ), + ) + .toList() ?? + const [], + ); + +Map _$ScriptWidgetCatalogToJson( + _ScriptWidgetCatalog instance, +) => { + 'title': instance.title, + 'description': instance.description, + 'icon': instance.icon, + 'widgets': instance.widgets, +}; + +_ScriptWidgetSubscription _$ScriptWidgetSubscriptionFromJson( + Map json, +) => _ScriptWidgetSubscription( + url: json['url'] as String, + catalog: ScriptWidgetCatalog.fromJson( + json['catalog'] as Map, + ), + refreshedAt: DateTime.parse(json['refreshedAt'] as String), +); + +Map _$ScriptWidgetSubscriptionToJson( + _ScriptWidgetSubscription instance, +) => { + 'url': instance.url, + 'catalog': instance.catalog, + 'refreshedAt': instance.refreshedAt.toIso8601String(), +}; diff --git a/lib/core/contracts/server.dart b/lib/core/contracts/server.dart new file mode 100644 index 0000000..e4a47f8 --- /dev/null +++ b/lib/core/contracts/server.dart @@ -0,0 +1,77 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'server.freezed.dart'; +part 'server.g.dart'; + +@freezed +abstract class EmbyServer with _$EmbyServer { + const factory EmbyServer({ + required String id, + required String name, + required String baseUrl, + required String createdAt, + required String updatedAt, + @JsonKey(includeIfNull: false) String? lastConnectedAt, + + + @JsonKey( + fromJson: _stringOrEmpty, + toJson: _emptyStringToNull, + includeIfNull: false, + ) + @Default('') + String iconUrl, + + + @Default(false) bool paused, + }) = _EmbyServer; + + factory EmbyServer.fromJson(Map json) => + _$EmbyServerFromJson(json); +} + +@freezed +abstract class EmbyServerSaveReq with _$EmbyServerSaveReq { + const factory EmbyServerSaveReq({ + @JsonKey(includeIfNull: false) String? id, + required String name, + required String baseUrl, + + + @JsonKey( + fromJson: _stringOrEmpty, + toJson: _emptyStringToNull, + includeIfNull: false, + ) + @Default('') + String iconUrl, + }) = _EmbyServerSaveReq; + + factory EmbyServerSaveReq.fromJson(Map json) => + _$EmbyServerSaveReqFromJson(json); +} + +@freezed +abstract class EmbyServerProbeReq with _$EmbyServerProbeReq { + const factory EmbyServerProbeReq({required String baseUrl}) = + _EmbyServerProbeReq; + + factory EmbyServerProbeReq.fromJson(Map json) => + _$EmbyServerProbeReqFromJson(json); +} + +@freezed +abstract class ProbePublicInfoRes with _$ProbePublicInfoRes { + const factory ProbePublicInfoRes({ + required String serverName, + required String version, + required String productName, + }) = _ProbePublicInfoRes; + + factory ProbePublicInfoRes.fromJson(Map json) => + _$ProbePublicInfoResFromJson(json); +} + +String _stringOrEmpty(Object? value) => value?.toString() ?? ''; + +String? _emptyStringToNull(String value) => value.isEmpty ? null : value; diff --git a/lib/core/contracts/server.freezed.dart b/lib/core/contracts/server.freezed.dart new file mode 100644 index 0000000..e25f0ee --- /dev/null +++ b/lib/core/contracts/server.freezed.dart @@ -0,0 +1,1112 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'server.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$EmbyServer { + + String get id; String get name; String get baseUrl; String get createdAt; String get updatedAt;@JsonKey(includeIfNull: false) String? get lastConnectedAt;/// 服务器图标 URL(来自图标库),空字符串表示未配置图标。 +/// 序列化时若为空则省略字段,与原手写 toJson 行为一致。 +@JsonKey(fromJson: _stringOrEmpty, toJson: _emptyStringToNull, includeIfNull: false) String get iconUrl;/// 服务器是否已暂停使用。暂停后不出现在侧栏、首页下拉,也不发起连通性探测。 +/// 保留登录态,恢复后可直接使用。默认 false。 + bool get paused; +/// Create a copy of EmbyServer +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$EmbyServerCopyWith get copyWith => _$EmbyServerCopyWithImpl(this as EmbyServer, _$identity); + + /// Serializes this EmbyServer to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is EmbyServer&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.baseUrl, baseUrl) || other.baseUrl == baseUrl)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.lastConnectedAt, lastConnectedAt) || other.lastConnectedAt == lastConnectedAt)&&(identical(other.iconUrl, iconUrl) || other.iconUrl == iconUrl)&&(identical(other.paused, paused) || other.paused == paused)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,name,baseUrl,createdAt,updatedAt,lastConnectedAt,iconUrl,paused); + +@override +String toString() { + return 'EmbyServer(id: $id, name: $name, baseUrl: $baseUrl, createdAt: $createdAt, updatedAt: $updatedAt, lastConnectedAt: $lastConnectedAt, iconUrl: $iconUrl, paused: $paused)'; +} + + +} + +/// @nodoc +abstract mixin class $EmbyServerCopyWith<$Res> { + factory $EmbyServerCopyWith(EmbyServer value, $Res Function(EmbyServer) _then) = _$EmbyServerCopyWithImpl; +@useResult +$Res call({ + String id, String name, String baseUrl, String createdAt, String updatedAt,@JsonKey(includeIfNull: false) String? lastConnectedAt,@JsonKey(fromJson: _stringOrEmpty, toJson: _emptyStringToNull, includeIfNull: false) String iconUrl, bool paused +}); + + + + +} +/// @nodoc +class _$EmbyServerCopyWithImpl<$Res> + implements $EmbyServerCopyWith<$Res> { + _$EmbyServerCopyWithImpl(this._self, this._then); + + final EmbyServer _self; + final $Res Function(EmbyServer) _then; + +/// Create a copy of EmbyServer +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? name = null,Object? baseUrl = null,Object? createdAt = null,Object? updatedAt = null,Object? lastConnectedAt = freezed,Object? iconUrl = null,Object? paused = null,}) { + return _then(_self.copyWith( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,baseUrl: null == baseUrl ? _self.baseUrl : baseUrl // ignore: cast_nullable_to_non_nullable +as String,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable +as String,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable +as String,lastConnectedAt: freezed == lastConnectedAt ? _self.lastConnectedAt : lastConnectedAt // ignore: cast_nullable_to_non_nullable +as String?,iconUrl: null == iconUrl ? _self.iconUrl : iconUrl // ignore: cast_nullable_to_non_nullable +as String,paused: null == paused ? _self.paused : paused // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + +} + + +/// Adds pattern-matching-related methods to [EmbyServer]. +extension EmbyServerPatterns on EmbyServer { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _EmbyServer value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _EmbyServer() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _EmbyServer value) $default,){ +final _that = this; +switch (_that) { +case _EmbyServer(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _EmbyServer value)? $default,){ +final _that = this; +switch (_that) { +case _EmbyServer() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String id, String name, String baseUrl, String createdAt, String updatedAt, @JsonKey(includeIfNull: false) String? lastConnectedAt, @JsonKey(fromJson: _stringOrEmpty, toJson: _emptyStringToNull, includeIfNull: false) String iconUrl, bool paused)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _EmbyServer() when $default != null: +return $default(_that.id,_that.name,_that.baseUrl,_that.createdAt,_that.updatedAt,_that.lastConnectedAt,_that.iconUrl,_that.paused);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String id, String name, String baseUrl, String createdAt, String updatedAt, @JsonKey(includeIfNull: false) String? lastConnectedAt, @JsonKey(fromJson: _stringOrEmpty, toJson: _emptyStringToNull, includeIfNull: false) String iconUrl, bool paused) $default,) {final _that = this; +switch (_that) { +case _EmbyServer(): +return $default(_that.id,_that.name,_that.baseUrl,_that.createdAt,_that.updatedAt,_that.lastConnectedAt,_that.iconUrl,_that.paused);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String id, String name, String baseUrl, String createdAt, String updatedAt, @JsonKey(includeIfNull: false) String? lastConnectedAt, @JsonKey(fromJson: _stringOrEmpty, toJson: _emptyStringToNull, includeIfNull: false) String iconUrl, bool paused)? $default,) {final _that = this; +switch (_that) { +case _EmbyServer() when $default != null: +return $default(_that.id,_that.name,_that.baseUrl,_that.createdAt,_that.updatedAt,_that.lastConnectedAt,_that.iconUrl,_that.paused);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _EmbyServer implements EmbyServer { + const _EmbyServer({required this.id, required this.name, required this.baseUrl, required this.createdAt, required this.updatedAt, @JsonKey(includeIfNull: false) this.lastConnectedAt, @JsonKey(fromJson: _stringOrEmpty, toJson: _emptyStringToNull, includeIfNull: false) this.iconUrl = '', this.paused = false}); + factory _EmbyServer.fromJson(Map json) => _$EmbyServerFromJson(json); + +@override final String id; +@override final String name; +@override final String baseUrl; +@override final String createdAt; +@override final String updatedAt; +@override@JsonKey(includeIfNull: false) final String? lastConnectedAt; +/// 服务器图标 URL(来自图标库),空字符串表示未配置图标。 +/// 序列化时若为空则省略字段,与原手写 toJson 行为一致。 +@override@JsonKey(fromJson: _stringOrEmpty, toJson: _emptyStringToNull, includeIfNull: false) final String iconUrl; +/// 服务器是否已暂停使用。暂停后不出现在侧栏、首页下拉,也不发起连通性探测。 +/// 保留登录态,恢复后可直接使用。默认 false。 +@override@JsonKey() final bool paused; + +/// Create a copy of EmbyServer +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$EmbyServerCopyWith<_EmbyServer> get copyWith => __$EmbyServerCopyWithImpl<_EmbyServer>(this, _$identity); + +@override +Map toJson() { + return _$EmbyServerToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _EmbyServer&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.baseUrl, baseUrl) || other.baseUrl == baseUrl)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.lastConnectedAt, lastConnectedAt) || other.lastConnectedAt == lastConnectedAt)&&(identical(other.iconUrl, iconUrl) || other.iconUrl == iconUrl)&&(identical(other.paused, paused) || other.paused == paused)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,name,baseUrl,createdAt,updatedAt,lastConnectedAt,iconUrl,paused); + +@override +String toString() { + return 'EmbyServer(id: $id, name: $name, baseUrl: $baseUrl, createdAt: $createdAt, updatedAt: $updatedAt, lastConnectedAt: $lastConnectedAt, iconUrl: $iconUrl, paused: $paused)'; +} + + +} + +/// @nodoc +abstract mixin class _$EmbyServerCopyWith<$Res> implements $EmbyServerCopyWith<$Res> { + factory _$EmbyServerCopyWith(_EmbyServer value, $Res Function(_EmbyServer) _then) = __$EmbyServerCopyWithImpl; +@override @useResult +$Res call({ + String id, String name, String baseUrl, String createdAt, String updatedAt,@JsonKey(includeIfNull: false) String? lastConnectedAt,@JsonKey(fromJson: _stringOrEmpty, toJson: _emptyStringToNull, includeIfNull: false) String iconUrl, bool paused +}); + + + + +} +/// @nodoc +class __$EmbyServerCopyWithImpl<$Res> + implements _$EmbyServerCopyWith<$Res> { + __$EmbyServerCopyWithImpl(this._self, this._then); + + final _EmbyServer _self; + final $Res Function(_EmbyServer) _then; + +/// Create a copy of EmbyServer +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? name = null,Object? baseUrl = null,Object? createdAt = null,Object? updatedAt = null,Object? lastConnectedAt = freezed,Object? iconUrl = null,Object? paused = null,}) { + return _then(_EmbyServer( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,baseUrl: null == baseUrl ? _self.baseUrl : baseUrl // ignore: cast_nullable_to_non_nullable +as String,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable +as String,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable +as String,lastConnectedAt: freezed == lastConnectedAt ? _self.lastConnectedAt : lastConnectedAt // ignore: cast_nullable_to_non_nullable +as String?,iconUrl: null == iconUrl ? _self.iconUrl : iconUrl // ignore: cast_nullable_to_non_nullable +as String,paused: null == paused ? _self.paused : paused // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + + +} + + +/// @nodoc +mixin _$EmbyServerSaveReq { + +@JsonKey(includeIfNull: false) String? get id; String get name; String get baseUrl;/// 期望的服务器图标 URL(空字符串表示清除图标)。 +@JsonKey(fromJson: _stringOrEmpty, toJson: _emptyStringToNull, includeIfNull: false) String get iconUrl; +/// Create a copy of EmbyServerSaveReq +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$EmbyServerSaveReqCopyWith get copyWith => _$EmbyServerSaveReqCopyWithImpl(this as EmbyServerSaveReq, _$identity); + + /// Serializes this EmbyServerSaveReq to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is EmbyServerSaveReq&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.baseUrl, baseUrl) || other.baseUrl == baseUrl)&&(identical(other.iconUrl, iconUrl) || other.iconUrl == iconUrl)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,name,baseUrl,iconUrl); + +@override +String toString() { + return 'EmbyServerSaveReq(id: $id, name: $name, baseUrl: $baseUrl, iconUrl: $iconUrl)'; +} + + +} + +/// @nodoc +abstract mixin class $EmbyServerSaveReqCopyWith<$Res> { + factory $EmbyServerSaveReqCopyWith(EmbyServerSaveReq value, $Res Function(EmbyServerSaveReq) _then) = _$EmbyServerSaveReqCopyWithImpl; +@useResult +$Res call({ +@JsonKey(includeIfNull: false) String? id, String name, String baseUrl,@JsonKey(fromJson: _stringOrEmpty, toJson: _emptyStringToNull, includeIfNull: false) String iconUrl +}); + + + + +} +/// @nodoc +class _$EmbyServerSaveReqCopyWithImpl<$Res> + implements $EmbyServerSaveReqCopyWith<$Res> { + _$EmbyServerSaveReqCopyWithImpl(this._self, this._then); + + final EmbyServerSaveReq _self; + final $Res Function(EmbyServerSaveReq) _then; + +/// Create a copy of EmbyServerSaveReq +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? id = freezed,Object? name = null,Object? baseUrl = null,Object? iconUrl = null,}) { + return _then(_self.copyWith( +id: freezed == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String?,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,baseUrl: null == baseUrl ? _self.baseUrl : baseUrl // ignore: cast_nullable_to_non_nullable +as String,iconUrl: null == iconUrl ? _self.iconUrl : iconUrl // ignore: cast_nullable_to_non_nullable +as String, + )); +} + +} + + +/// Adds pattern-matching-related methods to [EmbyServerSaveReq]. +extension EmbyServerSaveReqPatterns on EmbyServerSaveReq { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _EmbyServerSaveReq value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _EmbyServerSaveReq() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _EmbyServerSaveReq value) $default,){ +final _that = this; +switch (_that) { +case _EmbyServerSaveReq(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _EmbyServerSaveReq value)? $default,){ +final _that = this; +switch (_that) { +case _EmbyServerSaveReq() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function(@JsonKey(includeIfNull: false) String? id, String name, String baseUrl, @JsonKey(fromJson: _stringOrEmpty, toJson: _emptyStringToNull, includeIfNull: false) String iconUrl)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _EmbyServerSaveReq() when $default != null: +return $default(_that.id,_that.name,_that.baseUrl,_that.iconUrl);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function(@JsonKey(includeIfNull: false) String? id, String name, String baseUrl, @JsonKey(fromJson: _stringOrEmpty, toJson: _emptyStringToNull, includeIfNull: false) String iconUrl) $default,) {final _that = this; +switch (_that) { +case _EmbyServerSaveReq(): +return $default(_that.id,_that.name,_that.baseUrl,_that.iconUrl);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function(@JsonKey(includeIfNull: false) String? id, String name, String baseUrl, @JsonKey(fromJson: _stringOrEmpty, toJson: _emptyStringToNull, includeIfNull: false) String iconUrl)? $default,) {final _that = this; +switch (_that) { +case _EmbyServerSaveReq() when $default != null: +return $default(_that.id,_that.name,_that.baseUrl,_that.iconUrl);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _EmbyServerSaveReq implements EmbyServerSaveReq { + const _EmbyServerSaveReq({@JsonKey(includeIfNull: false) this.id, required this.name, required this.baseUrl, @JsonKey(fromJson: _stringOrEmpty, toJson: _emptyStringToNull, includeIfNull: false) this.iconUrl = ''}); + factory _EmbyServerSaveReq.fromJson(Map json) => _$EmbyServerSaveReqFromJson(json); + +@override@JsonKey(includeIfNull: false) final String? id; +@override final String name; +@override final String baseUrl; +/// 期望的服务器图标 URL(空字符串表示清除图标)。 +@override@JsonKey(fromJson: _stringOrEmpty, toJson: _emptyStringToNull, includeIfNull: false) final String iconUrl; + +/// Create a copy of EmbyServerSaveReq +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$EmbyServerSaveReqCopyWith<_EmbyServerSaveReq> get copyWith => __$EmbyServerSaveReqCopyWithImpl<_EmbyServerSaveReq>(this, _$identity); + +@override +Map toJson() { + return _$EmbyServerSaveReqToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _EmbyServerSaveReq&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.baseUrl, baseUrl) || other.baseUrl == baseUrl)&&(identical(other.iconUrl, iconUrl) || other.iconUrl == iconUrl)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,name,baseUrl,iconUrl); + +@override +String toString() { + return 'EmbyServerSaveReq(id: $id, name: $name, baseUrl: $baseUrl, iconUrl: $iconUrl)'; +} + + +} + +/// @nodoc +abstract mixin class _$EmbyServerSaveReqCopyWith<$Res> implements $EmbyServerSaveReqCopyWith<$Res> { + factory _$EmbyServerSaveReqCopyWith(_EmbyServerSaveReq value, $Res Function(_EmbyServerSaveReq) _then) = __$EmbyServerSaveReqCopyWithImpl; +@override @useResult +$Res call({ +@JsonKey(includeIfNull: false) String? id, String name, String baseUrl,@JsonKey(fromJson: _stringOrEmpty, toJson: _emptyStringToNull, includeIfNull: false) String iconUrl +}); + + + + +} +/// @nodoc +class __$EmbyServerSaveReqCopyWithImpl<$Res> + implements _$EmbyServerSaveReqCopyWith<$Res> { + __$EmbyServerSaveReqCopyWithImpl(this._self, this._then); + + final _EmbyServerSaveReq _self; + final $Res Function(_EmbyServerSaveReq) _then; + +/// Create a copy of EmbyServerSaveReq +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = freezed,Object? name = null,Object? baseUrl = null,Object? iconUrl = null,}) { + return _then(_EmbyServerSaveReq( +id: freezed == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String?,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,baseUrl: null == baseUrl ? _self.baseUrl : baseUrl // ignore: cast_nullable_to_non_nullable +as String,iconUrl: null == iconUrl ? _self.iconUrl : iconUrl // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + + +/// @nodoc +mixin _$EmbyServerProbeReq { + + String get baseUrl; +/// Create a copy of EmbyServerProbeReq +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$EmbyServerProbeReqCopyWith get copyWith => _$EmbyServerProbeReqCopyWithImpl(this as EmbyServerProbeReq, _$identity); + + /// Serializes this EmbyServerProbeReq to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is EmbyServerProbeReq&&(identical(other.baseUrl, baseUrl) || other.baseUrl == baseUrl)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,baseUrl); + +@override +String toString() { + return 'EmbyServerProbeReq(baseUrl: $baseUrl)'; +} + + +} + +/// @nodoc +abstract mixin class $EmbyServerProbeReqCopyWith<$Res> { + factory $EmbyServerProbeReqCopyWith(EmbyServerProbeReq value, $Res Function(EmbyServerProbeReq) _then) = _$EmbyServerProbeReqCopyWithImpl; +@useResult +$Res call({ + String baseUrl +}); + + + + +} +/// @nodoc +class _$EmbyServerProbeReqCopyWithImpl<$Res> + implements $EmbyServerProbeReqCopyWith<$Res> { + _$EmbyServerProbeReqCopyWithImpl(this._self, this._then); + + final EmbyServerProbeReq _self; + final $Res Function(EmbyServerProbeReq) _then; + +/// Create a copy of EmbyServerProbeReq +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? baseUrl = null,}) { + return _then(_self.copyWith( +baseUrl: null == baseUrl ? _self.baseUrl : baseUrl // ignore: cast_nullable_to_non_nullable +as String, + )); +} + +} + + +/// Adds pattern-matching-related methods to [EmbyServerProbeReq]. +extension EmbyServerProbeReqPatterns on EmbyServerProbeReq { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _EmbyServerProbeReq value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _EmbyServerProbeReq() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _EmbyServerProbeReq value) $default,){ +final _that = this; +switch (_that) { +case _EmbyServerProbeReq(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _EmbyServerProbeReq value)? $default,){ +final _that = this; +switch (_that) { +case _EmbyServerProbeReq() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String baseUrl)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _EmbyServerProbeReq() when $default != null: +return $default(_that.baseUrl);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String baseUrl) $default,) {final _that = this; +switch (_that) { +case _EmbyServerProbeReq(): +return $default(_that.baseUrl);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String baseUrl)? $default,) {final _that = this; +switch (_that) { +case _EmbyServerProbeReq() when $default != null: +return $default(_that.baseUrl);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _EmbyServerProbeReq implements EmbyServerProbeReq { + const _EmbyServerProbeReq({required this.baseUrl}); + factory _EmbyServerProbeReq.fromJson(Map json) => _$EmbyServerProbeReqFromJson(json); + +@override final String baseUrl; + +/// Create a copy of EmbyServerProbeReq +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$EmbyServerProbeReqCopyWith<_EmbyServerProbeReq> get copyWith => __$EmbyServerProbeReqCopyWithImpl<_EmbyServerProbeReq>(this, _$identity); + +@override +Map toJson() { + return _$EmbyServerProbeReqToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _EmbyServerProbeReq&&(identical(other.baseUrl, baseUrl) || other.baseUrl == baseUrl)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,baseUrl); + +@override +String toString() { + return 'EmbyServerProbeReq(baseUrl: $baseUrl)'; +} + + +} + +/// @nodoc +abstract mixin class _$EmbyServerProbeReqCopyWith<$Res> implements $EmbyServerProbeReqCopyWith<$Res> { + factory _$EmbyServerProbeReqCopyWith(_EmbyServerProbeReq value, $Res Function(_EmbyServerProbeReq) _then) = __$EmbyServerProbeReqCopyWithImpl; +@override @useResult +$Res call({ + String baseUrl +}); + + + + +} +/// @nodoc +class __$EmbyServerProbeReqCopyWithImpl<$Res> + implements _$EmbyServerProbeReqCopyWith<$Res> { + __$EmbyServerProbeReqCopyWithImpl(this._self, this._then); + + final _EmbyServerProbeReq _self; + final $Res Function(_EmbyServerProbeReq) _then; + +/// Create a copy of EmbyServerProbeReq +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? baseUrl = null,}) { + return _then(_EmbyServerProbeReq( +baseUrl: null == baseUrl ? _self.baseUrl : baseUrl // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + + +/// @nodoc +mixin _$ProbePublicInfoRes { + + String get serverName; String get version; String get productName; +/// Create a copy of ProbePublicInfoRes +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$ProbePublicInfoResCopyWith get copyWith => _$ProbePublicInfoResCopyWithImpl(this as ProbePublicInfoRes, _$identity); + + /// Serializes this ProbePublicInfoRes to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is ProbePublicInfoRes&&(identical(other.serverName, serverName) || other.serverName == serverName)&&(identical(other.version, version) || other.version == version)&&(identical(other.productName, productName) || other.productName == productName)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,serverName,version,productName); + +@override +String toString() { + return 'ProbePublicInfoRes(serverName: $serverName, version: $version, productName: $productName)'; +} + + +} + +/// @nodoc +abstract mixin class $ProbePublicInfoResCopyWith<$Res> { + factory $ProbePublicInfoResCopyWith(ProbePublicInfoRes value, $Res Function(ProbePublicInfoRes) _then) = _$ProbePublicInfoResCopyWithImpl; +@useResult +$Res call({ + String serverName, String version, String productName +}); + + + + +} +/// @nodoc +class _$ProbePublicInfoResCopyWithImpl<$Res> + implements $ProbePublicInfoResCopyWith<$Res> { + _$ProbePublicInfoResCopyWithImpl(this._self, this._then); + + final ProbePublicInfoRes _self; + final $Res Function(ProbePublicInfoRes) _then; + +/// Create a copy of ProbePublicInfoRes +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? serverName = null,Object? version = null,Object? productName = null,}) { + return _then(_self.copyWith( +serverName: null == serverName ? _self.serverName : serverName // ignore: cast_nullable_to_non_nullable +as String,version: null == version ? _self.version : version // ignore: cast_nullable_to_non_nullable +as String,productName: null == productName ? _self.productName : productName // ignore: cast_nullable_to_non_nullable +as String, + )); +} + +} + + +/// Adds pattern-matching-related methods to [ProbePublicInfoRes]. +extension ProbePublicInfoResPatterns on ProbePublicInfoRes { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _ProbePublicInfoRes value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _ProbePublicInfoRes() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _ProbePublicInfoRes value) $default,){ +final _that = this; +switch (_that) { +case _ProbePublicInfoRes(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _ProbePublicInfoRes value)? $default,){ +final _that = this; +switch (_that) { +case _ProbePublicInfoRes() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String serverName, String version, String productName)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _ProbePublicInfoRes() when $default != null: +return $default(_that.serverName,_that.version,_that.productName);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String serverName, String version, String productName) $default,) {final _that = this; +switch (_that) { +case _ProbePublicInfoRes(): +return $default(_that.serverName,_that.version,_that.productName);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String serverName, String version, String productName)? $default,) {final _that = this; +switch (_that) { +case _ProbePublicInfoRes() when $default != null: +return $default(_that.serverName,_that.version,_that.productName);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _ProbePublicInfoRes implements ProbePublicInfoRes { + const _ProbePublicInfoRes({required this.serverName, required this.version, required this.productName}); + factory _ProbePublicInfoRes.fromJson(Map json) => _$ProbePublicInfoResFromJson(json); + +@override final String serverName; +@override final String version; +@override final String productName; + +/// Create a copy of ProbePublicInfoRes +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$ProbePublicInfoResCopyWith<_ProbePublicInfoRes> get copyWith => __$ProbePublicInfoResCopyWithImpl<_ProbePublicInfoRes>(this, _$identity); + +@override +Map toJson() { + return _$ProbePublicInfoResToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _ProbePublicInfoRes&&(identical(other.serverName, serverName) || other.serverName == serverName)&&(identical(other.version, version) || other.version == version)&&(identical(other.productName, productName) || other.productName == productName)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,serverName,version,productName); + +@override +String toString() { + return 'ProbePublicInfoRes(serverName: $serverName, version: $version, productName: $productName)'; +} + + +} + +/// @nodoc +abstract mixin class _$ProbePublicInfoResCopyWith<$Res> implements $ProbePublicInfoResCopyWith<$Res> { + factory _$ProbePublicInfoResCopyWith(_ProbePublicInfoRes value, $Res Function(_ProbePublicInfoRes) _then) = __$ProbePublicInfoResCopyWithImpl; +@override @useResult +$Res call({ + String serverName, String version, String productName +}); + + + + +} +/// @nodoc +class __$ProbePublicInfoResCopyWithImpl<$Res> + implements _$ProbePublicInfoResCopyWith<$Res> { + __$ProbePublicInfoResCopyWithImpl(this._self, this._then); + + final _ProbePublicInfoRes _self; + final $Res Function(_ProbePublicInfoRes) _then; + +/// Create a copy of ProbePublicInfoRes +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? serverName = null,Object? version = null,Object? productName = null,}) { + return _then(_ProbePublicInfoRes( +serverName: null == serverName ? _self.serverName : serverName // ignore: cast_nullable_to_non_nullable +as String,version: null == version ? _self.version : version // ignore: cast_nullable_to_non_nullable +as String,productName: null == productName ? _self.productName : productName // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + +// dart format on diff --git a/lib/core/contracts/server.g.dart b/lib/core/contracts/server.g.dart new file mode 100644 index 0000000..02fe2ea --- /dev/null +++ b/lib/core/contracts/server.g.dart @@ -0,0 +1,66 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'server.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_EmbyServer _$EmbyServerFromJson(Map json) => _EmbyServer( + id: json['id'] as String, + name: json['name'] as String, + baseUrl: json['baseUrl'] as String, + createdAt: json['createdAt'] as String, + updatedAt: json['updatedAt'] as String, + lastConnectedAt: json['lastConnectedAt'] as String?, + iconUrl: json['iconUrl'] == null ? '' : _stringOrEmpty(json['iconUrl']), + paused: json['paused'] as bool? ?? false, +); + +Map _$EmbyServerToJson(_EmbyServer instance) => + { + 'id': instance.id, + 'name': instance.name, + 'baseUrl': instance.baseUrl, + 'createdAt': instance.createdAt, + 'updatedAt': instance.updatedAt, + 'lastConnectedAt': ?instance.lastConnectedAt, + 'iconUrl': ?_emptyStringToNull(instance.iconUrl), + 'paused': instance.paused, + }; + +_EmbyServerSaveReq _$EmbyServerSaveReqFromJson(Map json) => + _EmbyServerSaveReq( + id: json['id'] as String?, + name: json['name'] as String, + baseUrl: json['baseUrl'] as String, + iconUrl: json['iconUrl'] == null ? '' : _stringOrEmpty(json['iconUrl']), + ); + +Map _$EmbyServerSaveReqToJson(_EmbyServerSaveReq instance) => + { + 'id': ?instance.id, + 'name': instance.name, + 'baseUrl': instance.baseUrl, + 'iconUrl': ?_emptyStringToNull(instance.iconUrl), + }; + +_EmbyServerProbeReq _$EmbyServerProbeReqFromJson(Map json) => + _EmbyServerProbeReq(baseUrl: json['baseUrl'] as String); + +Map _$EmbyServerProbeReqToJson(_EmbyServerProbeReq instance) => + {'baseUrl': instance.baseUrl}; + +_ProbePublicInfoRes _$ProbePublicInfoResFromJson(Map json) => + _ProbePublicInfoRes( + serverName: json['serverName'] as String, + version: json['version'] as String, + productName: json['productName'] as String, + ); + +Map _$ProbePublicInfoResToJson(_ProbePublicInfoRes instance) => + { + 'serverName': instance.serverName, + 'version': instance.version, + 'productName': instance.productName, + }; diff --git a/lib/core/contracts/tmdb.dart b/lib/core/contracts/tmdb.dart new file mode 100644 index 0000000..ffc7d1e --- /dev/null +++ b/lib/core/contracts/tmdb.dart @@ -0,0 +1,324 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'tmdb.freezed.dart'; +part 'tmdb.g.dart'; + +@freezed +abstract class TmdbCastMember with _$TmdbCastMember { + const factory TmdbCastMember({ + @JsonKey(fromJson: _intRequired) required int id, + @JsonKey(fromJson: _strOrEmpty) @Default('') String name, + String? character, + @JsonKey(name: 'profile_path') String? profilePath, + @JsonKey(fromJson: _intOrZero) @Default(0) int order, + }) = _TmdbCastMember; + + factory TmdbCastMember.fromJson(Map json) => + _$TmdbCastMemberFromJson(json); +} + +@freezed +abstract class TmdbCrewMember with _$TmdbCrewMember { + const factory TmdbCrewMember({ + @JsonKey(fromJson: _intRequired) required int id, + @JsonKey(fromJson: _strOrEmpty) @Default('') String name, + String? job, + String? department, + @JsonKey(name: 'profile_path') String? profilePath, + }) = _TmdbCrewMember; + + factory TmdbCrewMember.fromJson(Map json) => + _$TmdbCrewMemberFromJson(json); +} + +@freezed +abstract class TmdbCreditsRes with _$TmdbCreditsRes { + const factory TmdbCreditsRes({ + @Default([]) List cast, + @Default([]) List crew, + }) = _TmdbCreditsRes; + + factory TmdbCreditsRes.fromJson(Map json) => + _$TmdbCreditsResFromJson(json); +} + +@freezed +abstract class TmdbRecommendation with _$TmdbRecommendation { + const factory TmdbRecommendation({ + @JsonKey(fromJson: _intRequired) required int id, + required String title, + @JsonKey(name: 'poster_path') String? posterPath, + @JsonKey(name: 'backdrop_path') String? backdropPath, + String? overview, + @JsonKey(name: 'vote_average', fromJson: _doubleOrNull) double? voteAverage, + @JsonKey(name: 'media_type') String? mediaType, + }) = _TmdbRecommendation; + + factory TmdbRecommendation.fromJson(Map json) => + _$TmdbRecommendationFromJson({ + ...json, + 'title': json['title'] ?? json['name'] ?? '', + }); +} + +@freezed +abstract class TmdbRecommendationsRes with _$TmdbRecommendationsRes { + const factory TmdbRecommendationsRes({ + @Default([]) List results, + @JsonKey(name: 'total_results', fromJson: _intOrZero) + @Default(0) + int totalResults, + }) = _TmdbRecommendationsRes; + + factory TmdbRecommendationsRes.fromJson(Map json) => + _$TmdbRecommendationsResFromJson(json); +} + +@freezed +abstract class TmdbEpisode with _$TmdbEpisode { + const factory TmdbEpisode({ + @JsonKey(name: 'episode_number', fromJson: _intRequired) + required int episodeNumber, + @JsonKey(fromJson: _strOrEmpty) @Default('') String name, + String? overview, + @JsonKey(name: 'still_path') String? stillPath, + @JsonKey(name: 'air_date') String? airDate, + @JsonKey(name: 'vote_average', fromJson: _doubleOrNull) double? voteAverage, + @JsonKey(fromJson: _intOrNull) int? runtime, + }) = _TmdbEpisode; + + factory TmdbEpisode.fromJson(Map json) => + _$TmdbEpisodeFromJson(json); +} + +@freezed +abstract class TmdbSeasonDetail with _$TmdbSeasonDetail { + const factory TmdbSeasonDetail({ + @JsonKey(name: 'season_number', fromJson: _intRequired) + required int seasonNumber, + @JsonKey(fromJson: _strOrEmpty) @Default('') String name, + String? overview, + @JsonKey(name: 'poster_path') String? posterPath, + @Default([]) List episodes, + }) = _TmdbSeasonDetail; + + factory TmdbSeasonDetail.fromJson(Map json) => + _$TmdbSeasonDetailFromJson(json); +} + +@freezed +abstract class TmdbVideo with _$TmdbVideo { + const TmdbVideo._(); + + const factory TmdbVideo({ + @JsonKey(fromJson: _strOrEmpty) @Default('') String id, + @JsonKey(fromJson: _strOrEmpty) @Default('') String key, + @JsonKey(fromJson: _strOrEmpty) @Default('') String name, + @JsonKey(fromJson: _strOrEmpty) @Default('') String site, + @JsonKey(fromJson: _strOrEmpty) @Default('') String type, + @JsonKey(fromJson: _intOrZero) @Default(0) int size, + @JsonKey(fromJson: _boolOrFalse) @Default(false) bool official, + }) = _TmdbVideo; + + factory TmdbVideo.fromJson(Map json) => + _$TmdbVideoFromJson(json); + + String? get youtubeUrl => site == 'YouTube' && key.isNotEmpty + ? 'https://www.youtube.com/watch?v=$key' + : null; + + String? get youtubeThumbnailUrl => site == 'YouTube' && key.isNotEmpty + ? 'https://img.youtube.com/vi/$key/mqdefault.jpg' + : null; +} + +@freezed +abstract class TmdbVideosRes with _$TmdbVideosRes { + const factory TmdbVideosRes({ + @Default([]) List results, + }) = _TmdbVideosRes; + + factory TmdbVideosRes.fromJson(Map json) => + _$TmdbVideosResFromJson(json); +} + +@freezed +abstract class TmdbPersonDetail with _$TmdbPersonDetail { + const factory TmdbPersonDetail({ + @JsonKey(fromJson: _intRequired) required int id, + @JsonKey(fromJson: _strOrEmpty) @Default('') String name, + String? biography, + String? birthday, + String? deathday, + @JsonKey(name: 'place_of_birth') String? placeOfBirth, + @JsonKey(name: 'profile_path') String? profilePath, + @JsonKey(name: 'known_for_department') String? knownForDepartment, + @JsonKey(fromJson: _doubleOrNull) double? popularity, + }) = _TmdbPersonDetail; + + factory TmdbPersonDetail.fromJson(Map json) => + _$TmdbPersonDetailFromJson(json); +} + +@freezed +abstract class TmdbPersonCredit with _$TmdbPersonCredit { + const factory TmdbPersonCredit({ + @JsonKey(fromJson: _intRequired) required int id, + required String title, + String? character, + @JsonKey(name: 'poster_path') String? posterPath, + @JsonKey(name: 'release_date') String? releaseDate, + @JsonKey(name: 'vote_average', fromJson: _doubleOrNull) double? voteAverage, + @JsonKey(name: 'media_type') String? mediaType, + }) = _TmdbPersonCredit; + + factory TmdbPersonCredit.fromJson(Map json) => + _$TmdbPersonCreditFromJson({ + ...json, + 'title': json['title'] ?? json['name'] ?? '', + 'release_date': json['release_date'] ?? json['first_air_date'], + }); +} + +@freezed +abstract class TmdbPersonCreditsRes with _$TmdbPersonCreditsRes { + const factory TmdbPersonCreditsRes({ + @Default([]) List cast, + }) = _TmdbPersonCreditsRes; + + factory TmdbPersonCreditsRes.fromJson(Map json) => + _$TmdbPersonCreditsResFromJson(json); +} + +@freezed +abstract class TmdbGenre with _$TmdbGenre { + const factory TmdbGenre({ + @JsonKey(fromJson: _intRequired) required int id, + @JsonKey(fromJson: _strOrEmpty) @Default('') String name, + }) = _TmdbGenre; + + factory TmdbGenre.fromJson(Map json) => + _$TmdbGenreFromJson(json); +} + +@freezed +abstract class TmdbMediaDetail with _$TmdbMediaDetail { + const factory TmdbMediaDetail({ + @JsonKey(fromJson: _intRequired) required int id, + @JsonKey(fromJson: _strOrEmpty) @Default('') String title, + @JsonKey(name: 'poster_path') String? posterPath, + @JsonKey(name: 'backdrop_path') String? backdropPath, + @JsonKey(name: 'release_date') String? releaseDate, + @Default([]) List genres, + @JsonKey(fromJson: _intOrNull) int? runtime, + String? tagline, + String? overview, + String? status, + String? homepage, + @JsonKey(name: 'vote_average', fromJson: _doubleOrNull) double? voteAverage, + @JsonKey(name: 'vote_count', fromJson: _intOrNull) int? voteCount, + @JsonKey(fromJson: _intOrNull) int? revenue, + @JsonKey(fromJson: _intOrNull) int? budget, + }) = _TmdbMediaDetail; + + + factory TmdbMediaDetail.fromJson(Map json) => + _$TmdbMediaDetailFromJson({ + ...json, + 'title': json['title'] ?? json['name'] ?? '', + 'release_date': json['release_date'] ?? json['first_air_date'], + 'runtime': + json['runtime'] ?? + (json['episode_run_time'] is List && + (json['episode_run_time'] as List).isNotEmpty + ? (json['episode_run_time'] as List).first + : null), + }); +} + +@freezed +abstract class TmdbImage with _$TmdbImage { + const TmdbImage._(); + + const factory TmdbImage({ + @JsonKey(name: 'file_path', fromJson: _strOrEmpty) + @Default('') + String filePath, + @JsonKey(fromJson: _intOrZero) @Default(0) int width, + @JsonKey(fromJson: _intOrZero) @Default(0) int height, + @JsonKey(name: 'vote_average', fromJson: _doubleOrNull) double? voteAverage, + @JsonKey(name: 'iso_639_1') String? iso639, + }) = _TmdbImage; + + factory TmdbImage.fromJson(Map json) => + _$TmdbImageFromJson(json); + + bool get isLanguageNeutral => iso639 == null; +} + +@freezed +abstract class TmdbImageSet with _$TmdbImageSet { + const factory TmdbImageSet({ + @Default([]) List backdrops, + @Default([]) List posters, + @Default([]) List logos, + }) = _TmdbImageSet; + + factory TmdbImageSet.fromJson(Map json) => + _$TmdbImageSetFromJson(json); +} + +@freezed +abstract class TmdbNetwork with _$TmdbNetwork { + const factory TmdbNetwork({ + @JsonKey(fromJson: _intRequired) required int id, + @JsonKey(fromJson: _strOrEmpty) @Default('') String name, + @JsonKey(name: 'logo_path') String? logoPath, + @JsonKey(name: 'origin_country') String? originCountry, + }) = _TmdbNetwork; + + factory TmdbNetwork.fromJson(Map json) => + _$TmdbNetworkFromJson(json); +} + +@freezed +abstract class TmdbEnrichedDetail with _$TmdbEnrichedDetail { + const factory TmdbEnrichedDetail({ + required TmdbMediaDetail detail, + TmdbVideosRes? videos, + TmdbCreditsRes? credits, + TmdbRecommendationsRes? recommendations, + TmdbImageSet? images, + + + String? imdbId, + @Default([]) List networks, + }) = _TmdbEnrichedDetail; + + factory TmdbEnrichedDetail.fromJson(Map json) => + _$TmdbEnrichedDetailFromJson({ + 'detail': json, + 'videos': json['videos'], + 'credits': json['credits'], + 'recommendations': json['recommendations'], + 'images': json['images'], + 'imdbId': _imdbIdOf(json['external_ids']), + 'networks': json['networks'], + }); +} + + +String? _imdbIdOf(Object? externalIds) { + if (externalIds is! Map) return null; + final v = externalIds['imdb_id']; + if (v is! String) return null; + final trimmed = v.trim(); + return trimmed.isEmpty ? null : trimmed; +} + +int _intRequired(Object? v) => (v as num).toInt(); +int _intOrZero(Object? v) => (v as num?)?.toInt() ?? 0; +int? _intOrNull(Object? v) => (v as num?)?.toInt(); +double? _doubleOrNull(Object? v) => (v as num?)?.toDouble(); +bool _boolOrFalse(Object? v) => (v as bool?) ?? false; +String _strOrEmpty(Object? v) => v?.toString() ?? ''; diff --git a/lib/core/contracts/tmdb.freezed.dart b/lib/core/contracts/tmdb.freezed.dart new file mode 100644 index 0000000..2755257 --- /dev/null +++ b/lib/core/contracts/tmdb.freezed.dart @@ -0,0 +1,5162 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'tmdb.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$TmdbCastMember { + +@JsonKey(fromJson: _intRequired) int get id;@JsonKey(fromJson: _strOrEmpty) String get name; String? get character;@JsonKey(name: 'profile_path') String? get profilePath;@JsonKey(fromJson: _intOrZero) int get order; +/// Create a copy of TmdbCastMember +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$TmdbCastMemberCopyWith get copyWith => _$TmdbCastMemberCopyWithImpl(this as TmdbCastMember, _$identity); + + /// Serializes this TmdbCastMember to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is TmdbCastMember&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.character, character) || other.character == character)&&(identical(other.profilePath, profilePath) || other.profilePath == profilePath)&&(identical(other.order, order) || other.order == order)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,name,character,profilePath,order); + +@override +String toString() { + return 'TmdbCastMember(id: $id, name: $name, character: $character, profilePath: $profilePath, order: $order)'; +} + + +} + +/// @nodoc +abstract mixin class $TmdbCastMemberCopyWith<$Res> { + factory $TmdbCastMemberCopyWith(TmdbCastMember value, $Res Function(TmdbCastMember) _then) = _$TmdbCastMemberCopyWithImpl; +@useResult +$Res call({ +@JsonKey(fromJson: _intRequired) int id,@JsonKey(fromJson: _strOrEmpty) String name, String? character,@JsonKey(name: 'profile_path') String? profilePath,@JsonKey(fromJson: _intOrZero) int order +}); + + + + +} +/// @nodoc +class _$TmdbCastMemberCopyWithImpl<$Res> + implements $TmdbCastMemberCopyWith<$Res> { + _$TmdbCastMemberCopyWithImpl(this._self, this._then); + + final TmdbCastMember _self; + final $Res Function(TmdbCastMember) _then; + +/// Create a copy of TmdbCastMember +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? name = null,Object? character = freezed,Object? profilePath = freezed,Object? order = null,}) { + return _then(_self.copyWith( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as int,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,character: freezed == character ? _self.character : character // ignore: cast_nullable_to_non_nullable +as String?,profilePath: freezed == profilePath ? _self.profilePath : profilePath // ignore: cast_nullable_to_non_nullable +as String?,order: null == order ? _self.order : order // ignore: cast_nullable_to_non_nullable +as int, + )); +} + +} + + +/// Adds pattern-matching-related methods to [TmdbCastMember]. +extension TmdbCastMemberPatterns on TmdbCastMember { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _TmdbCastMember value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _TmdbCastMember() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _TmdbCastMember value) $default,){ +final _that = this; +switch (_that) { +case _TmdbCastMember(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _TmdbCastMember value)? $default,){ +final _that = this; +switch (_that) { +case _TmdbCastMember() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function(@JsonKey(fromJson: _intRequired) int id, @JsonKey(fromJson: _strOrEmpty) String name, String? character, @JsonKey(name: 'profile_path') String? profilePath, @JsonKey(fromJson: _intOrZero) int order)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _TmdbCastMember() when $default != null: +return $default(_that.id,_that.name,_that.character,_that.profilePath,_that.order);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function(@JsonKey(fromJson: _intRequired) int id, @JsonKey(fromJson: _strOrEmpty) String name, String? character, @JsonKey(name: 'profile_path') String? profilePath, @JsonKey(fromJson: _intOrZero) int order) $default,) {final _that = this; +switch (_that) { +case _TmdbCastMember(): +return $default(_that.id,_that.name,_that.character,_that.profilePath,_that.order);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function(@JsonKey(fromJson: _intRequired) int id, @JsonKey(fromJson: _strOrEmpty) String name, String? character, @JsonKey(name: 'profile_path') String? profilePath, @JsonKey(fromJson: _intOrZero) int order)? $default,) {final _that = this; +switch (_that) { +case _TmdbCastMember() when $default != null: +return $default(_that.id,_that.name,_that.character,_that.profilePath,_that.order);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _TmdbCastMember implements TmdbCastMember { + const _TmdbCastMember({@JsonKey(fromJson: _intRequired) required this.id, @JsonKey(fromJson: _strOrEmpty) this.name = '', this.character, @JsonKey(name: 'profile_path') this.profilePath, @JsonKey(fromJson: _intOrZero) this.order = 0}); + factory _TmdbCastMember.fromJson(Map json) => _$TmdbCastMemberFromJson(json); + +@override@JsonKey(fromJson: _intRequired) final int id; +@override@JsonKey(fromJson: _strOrEmpty) final String name; +@override final String? character; +@override@JsonKey(name: 'profile_path') final String? profilePath; +@override@JsonKey(fromJson: _intOrZero) final int order; + +/// Create a copy of TmdbCastMember +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$TmdbCastMemberCopyWith<_TmdbCastMember> get copyWith => __$TmdbCastMemberCopyWithImpl<_TmdbCastMember>(this, _$identity); + +@override +Map toJson() { + return _$TmdbCastMemberToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _TmdbCastMember&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.character, character) || other.character == character)&&(identical(other.profilePath, profilePath) || other.profilePath == profilePath)&&(identical(other.order, order) || other.order == order)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,name,character,profilePath,order); + +@override +String toString() { + return 'TmdbCastMember(id: $id, name: $name, character: $character, profilePath: $profilePath, order: $order)'; +} + + +} + +/// @nodoc +abstract mixin class _$TmdbCastMemberCopyWith<$Res> implements $TmdbCastMemberCopyWith<$Res> { + factory _$TmdbCastMemberCopyWith(_TmdbCastMember value, $Res Function(_TmdbCastMember) _then) = __$TmdbCastMemberCopyWithImpl; +@override @useResult +$Res call({ +@JsonKey(fromJson: _intRequired) int id,@JsonKey(fromJson: _strOrEmpty) String name, String? character,@JsonKey(name: 'profile_path') String? profilePath,@JsonKey(fromJson: _intOrZero) int order +}); + + + + +} +/// @nodoc +class __$TmdbCastMemberCopyWithImpl<$Res> + implements _$TmdbCastMemberCopyWith<$Res> { + __$TmdbCastMemberCopyWithImpl(this._self, this._then); + + final _TmdbCastMember _self; + final $Res Function(_TmdbCastMember) _then; + +/// Create a copy of TmdbCastMember +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? name = null,Object? character = freezed,Object? profilePath = freezed,Object? order = null,}) { + return _then(_TmdbCastMember( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as int,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,character: freezed == character ? _self.character : character // ignore: cast_nullable_to_non_nullable +as String?,profilePath: freezed == profilePath ? _self.profilePath : profilePath // ignore: cast_nullable_to_non_nullable +as String?,order: null == order ? _self.order : order // ignore: cast_nullable_to_non_nullable +as int, + )); +} + + +} + + +/// @nodoc +mixin _$TmdbCrewMember { + +@JsonKey(fromJson: _intRequired) int get id;@JsonKey(fromJson: _strOrEmpty) String get name; String? get job; String? get department;@JsonKey(name: 'profile_path') String? get profilePath; +/// Create a copy of TmdbCrewMember +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$TmdbCrewMemberCopyWith get copyWith => _$TmdbCrewMemberCopyWithImpl(this as TmdbCrewMember, _$identity); + + /// Serializes this TmdbCrewMember to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is TmdbCrewMember&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.job, job) || other.job == job)&&(identical(other.department, department) || other.department == department)&&(identical(other.profilePath, profilePath) || other.profilePath == profilePath)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,name,job,department,profilePath); + +@override +String toString() { + return 'TmdbCrewMember(id: $id, name: $name, job: $job, department: $department, profilePath: $profilePath)'; +} + + +} + +/// @nodoc +abstract mixin class $TmdbCrewMemberCopyWith<$Res> { + factory $TmdbCrewMemberCopyWith(TmdbCrewMember value, $Res Function(TmdbCrewMember) _then) = _$TmdbCrewMemberCopyWithImpl; +@useResult +$Res call({ +@JsonKey(fromJson: _intRequired) int id,@JsonKey(fromJson: _strOrEmpty) String name, String? job, String? department,@JsonKey(name: 'profile_path') String? profilePath +}); + + + + +} +/// @nodoc +class _$TmdbCrewMemberCopyWithImpl<$Res> + implements $TmdbCrewMemberCopyWith<$Res> { + _$TmdbCrewMemberCopyWithImpl(this._self, this._then); + + final TmdbCrewMember _self; + final $Res Function(TmdbCrewMember) _then; + +/// Create a copy of TmdbCrewMember +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? name = null,Object? job = freezed,Object? department = freezed,Object? profilePath = freezed,}) { + return _then(_self.copyWith( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as int,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,job: freezed == job ? _self.job : job // ignore: cast_nullable_to_non_nullable +as String?,department: freezed == department ? _self.department : department // ignore: cast_nullable_to_non_nullable +as String?,profilePath: freezed == profilePath ? _self.profilePath : profilePath // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [TmdbCrewMember]. +extension TmdbCrewMemberPatterns on TmdbCrewMember { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _TmdbCrewMember value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _TmdbCrewMember() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _TmdbCrewMember value) $default,){ +final _that = this; +switch (_that) { +case _TmdbCrewMember(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _TmdbCrewMember value)? $default,){ +final _that = this; +switch (_that) { +case _TmdbCrewMember() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function(@JsonKey(fromJson: _intRequired) int id, @JsonKey(fromJson: _strOrEmpty) String name, String? job, String? department, @JsonKey(name: 'profile_path') String? profilePath)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _TmdbCrewMember() when $default != null: +return $default(_that.id,_that.name,_that.job,_that.department,_that.profilePath);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function(@JsonKey(fromJson: _intRequired) int id, @JsonKey(fromJson: _strOrEmpty) String name, String? job, String? department, @JsonKey(name: 'profile_path') String? profilePath) $default,) {final _that = this; +switch (_that) { +case _TmdbCrewMember(): +return $default(_that.id,_that.name,_that.job,_that.department,_that.profilePath);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function(@JsonKey(fromJson: _intRequired) int id, @JsonKey(fromJson: _strOrEmpty) String name, String? job, String? department, @JsonKey(name: 'profile_path') String? profilePath)? $default,) {final _that = this; +switch (_that) { +case _TmdbCrewMember() when $default != null: +return $default(_that.id,_that.name,_that.job,_that.department,_that.profilePath);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _TmdbCrewMember implements TmdbCrewMember { + const _TmdbCrewMember({@JsonKey(fromJson: _intRequired) required this.id, @JsonKey(fromJson: _strOrEmpty) this.name = '', this.job, this.department, @JsonKey(name: 'profile_path') this.profilePath}); + factory _TmdbCrewMember.fromJson(Map json) => _$TmdbCrewMemberFromJson(json); + +@override@JsonKey(fromJson: _intRequired) final int id; +@override@JsonKey(fromJson: _strOrEmpty) final String name; +@override final String? job; +@override final String? department; +@override@JsonKey(name: 'profile_path') final String? profilePath; + +/// Create a copy of TmdbCrewMember +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$TmdbCrewMemberCopyWith<_TmdbCrewMember> get copyWith => __$TmdbCrewMemberCopyWithImpl<_TmdbCrewMember>(this, _$identity); + +@override +Map toJson() { + return _$TmdbCrewMemberToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _TmdbCrewMember&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.job, job) || other.job == job)&&(identical(other.department, department) || other.department == department)&&(identical(other.profilePath, profilePath) || other.profilePath == profilePath)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,name,job,department,profilePath); + +@override +String toString() { + return 'TmdbCrewMember(id: $id, name: $name, job: $job, department: $department, profilePath: $profilePath)'; +} + + +} + +/// @nodoc +abstract mixin class _$TmdbCrewMemberCopyWith<$Res> implements $TmdbCrewMemberCopyWith<$Res> { + factory _$TmdbCrewMemberCopyWith(_TmdbCrewMember value, $Res Function(_TmdbCrewMember) _then) = __$TmdbCrewMemberCopyWithImpl; +@override @useResult +$Res call({ +@JsonKey(fromJson: _intRequired) int id,@JsonKey(fromJson: _strOrEmpty) String name, String? job, String? department,@JsonKey(name: 'profile_path') String? profilePath +}); + + + + +} +/// @nodoc +class __$TmdbCrewMemberCopyWithImpl<$Res> + implements _$TmdbCrewMemberCopyWith<$Res> { + __$TmdbCrewMemberCopyWithImpl(this._self, this._then); + + final _TmdbCrewMember _self; + final $Res Function(_TmdbCrewMember) _then; + +/// Create a copy of TmdbCrewMember +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? name = null,Object? job = freezed,Object? department = freezed,Object? profilePath = freezed,}) { + return _then(_TmdbCrewMember( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as int,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,job: freezed == job ? _self.job : job // ignore: cast_nullable_to_non_nullable +as String?,department: freezed == department ? _self.department : department // ignore: cast_nullable_to_non_nullable +as String?,profilePath: freezed == profilePath ? _self.profilePath : profilePath // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + + +} + + +/// @nodoc +mixin _$TmdbCreditsRes { + + List get cast; List get crew; +/// Create a copy of TmdbCreditsRes +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$TmdbCreditsResCopyWith get copyWith => _$TmdbCreditsResCopyWithImpl(this as TmdbCreditsRes, _$identity); + + /// Serializes this TmdbCreditsRes to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is TmdbCreditsRes&&const DeepCollectionEquality().equals(other.cast, cast)&&const DeepCollectionEquality().equals(other.crew, crew)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(cast),const DeepCollectionEquality().hash(crew)); + +@override +String toString() { + return 'TmdbCreditsRes(cast: $cast, crew: $crew)'; +} + + +} + +/// @nodoc +abstract mixin class $TmdbCreditsResCopyWith<$Res> { + factory $TmdbCreditsResCopyWith(TmdbCreditsRes value, $Res Function(TmdbCreditsRes) _then) = _$TmdbCreditsResCopyWithImpl; +@useResult +$Res call({ + List cast, List crew +}); + + + + +} +/// @nodoc +class _$TmdbCreditsResCopyWithImpl<$Res> + implements $TmdbCreditsResCopyWith<$Res> { + _$TmdbCreditsResCopyWithImpl(this._self, this._then); + + final TmdbCreditsRes _self; + final $Res Function(TmdbCreditsRes) _then; + +/// Create a copy of TmdbCreditsRes +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? cast = null,Object? crew = null,}) { + return _then(_self.copyWith( +cast: null == cast ? _self.cast : cast // ignore: cast_nullable_to_non_nullable +as List,crew: null == crew ? _self.crew : crew // ignore: cast_nullable_to_non_nullable +as List, + )); +} + +} + + +/// Adds pattern-matching-related methods to [TmdbCreditsRes]. +extension TmdbCreditsResPatterns on TmdbCreditsRes { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _TmdbCreditsRes value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _TmdbCreditsRes() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _TmdbCreditsRes value) $default,){ +final _that = this; +switch (_that) { +case _TmdbCreditsRes(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _TmdbCreditsRes value)? $default,){ +final _that = this; +switch (_that) { +case _TmdbCreditsRes() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( List cast, List crew)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _TmdbCreditsRes() when $default != null: +return $default(_that.cast,_that.crew);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( List cast, List crew) $default,) {final _that = this; +switch (_that) { +case _TmdbCreditsRes(): +return $default(_that.cast,_that.crew);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( List cast, List crew)? $default,) {final _that = this; +switch (_that) { +case _TmdbCreditsRes() when $default != null: +return $default(_that.cast,_that.crew);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _TmdbCreditsRes implements TmdbCreditsRes { + const _TmdbCreditsRes({final List cast = const [], final List crew = const []}): _cast = cast,_crew = crew; + factory _TmdbCreditsRes.fromJson(Map json) => _$TmdbCreditsResFromJson(json); + + final List _cast; +@override@JsonKey() List get cast { + if (_cast is EqualUnmodifiableListView) return _cast; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_cast); +} + + final List _crew; +@override@JsonKey() List get crew { + if (_crew is EqualUnmodifiableListView) return _crew; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_crew); +} + + +/// Create a copy of TmdbCreditsRes +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$TmdbCreditsResCopyWith<_TmdbCreditsRes> get copyWith => __$TmdbCreditsResCopyWithImpl<_TmdbCreditsRes>(this, _$identity); + +@override +Map toJson() { + return _$TmdbCreditsResToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _TmdbCreditsRes&&const DeepCollectionEquality().equals(other._cast, _cast)&&const DeepCollectionEquality().equals(other._crew, _crew)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_cast),const DeepCollectionEquality().hash(_crew)); + +@override +String toString() { + return 'TmdbCreditsRes(cast: $cast, crew: $crew)'; +} + + +} + +/// @nodoc +abstract mixin class _$TmdbCreditsResCopyWith<$Res> implements $TmdbCreditsResCopyWith<$Res> { + factory _$TmdbCreditsResCopyWith(_TmdbCreditsRes value, $Res Function(_TmdbCreditsRes) _then) = __$TmdbCreditsResCopyWithImpl; +@override @useResult +$Res call({ + List cast, List crew +}); + + + + +} +/// @nodoc +class __$TmdbCreditsResCopyWithImpl<$Res> + implements _$TmdbCreditsResCopyWith<$Res> { + __$TmdbCreditsResCopyWithImpl(this._self, this._then); + + final _TmdbCreditsRes _self; + final $Res Function(_TmdbCreditsRes) _then; + +/// Create a copy of TmdbCreditsRes +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? cast = null,Object? crew = null,}) { + return _then(_TmdbCreditsRes( +cast: null == cast ? _self._cast : cast // ignore: cast_nullable_to_non_nullable +as List,crew: null == crew ? _self._crew : crew // ignore: cast_nullable_to_non_nullable +as List, + )); +} + + +} + + +/// @nodoc +mixin _$TmdbRecommendation { + +@JsonKey(fromJson: _intRequired) int get id; String get title;@JsonKey(name: 'poster_path') String? get posterPath;@JsonKey(name: 'backdrop_path') String? get backdropPath; String? get overview;@JsonKey(name: 'vote_average', fromJson: _doubleOrNull) double? get voteAverage;@JsonKey(name: 'media_type') String? get mediaType; +/// Create a copy of TmdbRecommendation +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$TmdbRecommendationCopyWith get copyWith => _$TmdbRecommendationCopyWithImpl(this as TmdbRecommendation, _$identity); + + /// Serializes this TmdbRecommendation to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is TmdbRecommendation&&(identical(other.id, id) || other.id == id)&&(identical(other.title, title) || other.title == title)&&(identical(other.posterPath, posterPath) || other.posterPath == posterPath)&&(identical(other.backdropPath, backdropPath) || other.backdropPath == backdropPath)&&(identical(other.overview, overview) || other.overview == overview)&&(identical(other.voteAverage, voteAverage) || other.voteAverage == voteAverage)&&(identical(other.mediaType, mediaType) || other.mediaType == mediaType)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,title,posterPath,backdropPath,overview,voteAverage,mediaType); + +@override +String toString() { + return 'TmdbRecommendation(id: $id, title: $title, posterPath: $posterPath, backdropPath: $backdropPath, overview: $overview, voteAverage: $voteAverage, mediaType: $mediaType)'; +} + + +} + +/// @nodoc +abstract mixin class $TmdbRecommendationCopyWith<$Res> { + factory $TmdbRecommendationCopyWith(TmdbRecommendation value, $Res Function(TmdbRecommendation) _then) = _$TmdbRecommendationCopyWithImpl; +@useResult +$Res call({ +@JsonKey(fromJson: _intRequired) int id, String title,@JsonKey(name: 'poster_path') String? posterPath,@JsonKey(name: 'backdrop_path') String? backdropPath, String? overview,@JsonKey(name: 'vote_average', fromJson: _doubleOrNull) double? voteAverage,@JsonKey(name: 'media_type') String? mediaType +}); + + + + +} +/// @nodoc +class _$TmdbRecommendationCopyWithImpl<$Res> + implements $TmdbRecommendationCopyWith<$Res> { + _$TmdbRecommendationCopyWithImpl(this._self, this._then); + + final TmdbRecommendation _self; + final $Res Function(TmdbRecommendation) _then; + +/// Create a copy of TmdbRecommendation +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? title = null,Object? posterPath = freezed,Object? backdropPath = freezed,Object? overview = freezed,Object? voteAverage = freezed,Object? mediaType = freezed,}) { + return _then(_self.copyWith( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as int,title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable +as String,posterPath: freezed == posterPath ? _self.posterPath : posterPath // ignore: cast_nullable_to_non_nullable +as String?,backdropPath: freezed == backdropPath ? _self.backdropPath : backdropPath // ignore: cast_nullable_to_non_nullable +as String?,overview: freezed == overview ? _self.overview : overview // ignore: cast_nullable_to_non_nullable +as String?,voteAverage: freezed == voteAverage ? _self.voteAverage : voteAverage // ignore: cast_nullable_to_non_nullable +as double?,mediaType: freezed == mediaType ? _self.mediaType : mediaType // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [TmdbRecommendation]. +extension TmdbRecommendationPatterns on TmdbRecommendation { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _TmdbRecommendation value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _TmdbRecommendation() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _TmdbRecommendation value) $default,){ +final _that = this; +switch (_that) { +case _TmdbRecommendation(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _TmdbRecommendation value)? $default,){ +final _that = this; +switch (_that) { +case _TmdbRecommendation() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function(@JsonKey(fromJson: _intRequired) int id, String title, @JsonKey(name: 'poster_path') String? posterPath, @JsonKey(name: 'backdrop_path') String? backdropPath, String? overview, @JsonKey(name: 'vote_average', fromJson: _doubleOrNull) double? voteAverage, @JsonKey(name: 'media_type') String? mediaType)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _TmdbRecommendation() when $default != null: +return $default(_that.id,_that.title,_that.posterPath,_that.backdropPath,_that.overview,_that.voteAverage,_that.mediaType);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function(@JsonKey(fromJson: _intRequired) int id, String title, @JsonKey(name: 'poster_path') String? posterPath, @JsonKey(name: 'backdrop_path') String? backdropPath, String? overview, @JsonKey(name: 'vote_average', fromJson: _doubleOrNull) double? voteAverage, @JsonKey(name: 'media_type') String? mediaType) $default,) {final _that = this; +switch (_that) { +case _TmdbRecommendation(): +return $default(_that.id,_that.title,_that.posterPath,_that.backdropPath,_that.overview,_that.voteAverage,_that.mediaType);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function(@JsonKey(fromJson: _intRequired) int id, String title, @JsonKey(name: 'poster_path') String? posterPath, @JsonKey(name: 'backdrop_path') String? backdropPath, String? overview, @JsonKey(name: 'vote_average', fromJson: _doubleOrNull) double? voteAverage, @JsonKey(name: 'media_type') String? mediaType)? $default,) {final _that = this; +switch (_that) { +case _TmdbRecommendation() when $default != null: +return $default(_that.id,_that.title,_that.posterPath,_that.backdropPath,_that.overview,_that.voteAverage,_that.mediaType);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _TmdbRecommendation implements TmdbRecommendation { + const _TmdbRecommendation({@JsonKey(fromJson: _intRequired) required this.id, required this.title, @JsonKey(name: 'poster_path') this.posterPath, @JsonKey(name: 'backdrop_path') this.backdropPath, this.overview, @JsonKey(name: 'vote_average', fromJson: _doubleOrNull) this.voteAverage, @JsonKey(name: 'media_type') this.mediaType}); + factory _TmdbRecommendation.fromJson(Map json) => _$TmdbRecommendationFromJson(json); + +@override@JsonKey(fromJson: _intRequired) final int id; +@override final String title; +@override@JsonKey(name: 'poster_path') final String? posterPath; +@override@JsonKey(name: 'backdrop_path') final String? backdropPath; +@override final String? overview; +@override@JsonKey(name: 'vote_average', fromJson: _doubleOrNull) final double? voteAverage; +@override@JsonKey(name: 'media_type') final String? mediaType; + +/// Create a copy of TmdbRecommendation +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$TmdbRecommendationCopyWith<_TmdbRecommendation> get copyWith => __$TmdbRecommendationCopyWithImpl<_TmdbRecommendation>(this, _$identity); + +@override +Map toJson() { + return _$TmdbRecommendationToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _TmdbRecommendation&&(identical(other.id, id) || other.id == id)&&(identical(other.title, title) || other.title == title)&&(identical(other.posterPath, posterPath) || other.posterPath == posterPath)&&(identical(other.backdropPath, backdropPath) || other.backdropPath == backdropPath)&&(identical(other.overview, overview) || other.overview == overview)&&(identical(other.voteAverage, voteAverage) || other.voteAverage == voteAverage)&&(identical(other.mediaType, mediaType) || other.mediaType == mediaType)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,title,posterPath,backdropPath,overview,voteAverage,mediaType); + +@override +String toString() { + return 'TmdbRecommendation(id: $id, title: $title, posterPath: $posterPath, backdropPath: $backdropPath, overview: $overview, voteAverage: $voteAverage, mediaType: $mediaType)'; +} + + +} + +/// @nodoc +abstract mixin class _$TmdbRecommendationCopyWith<$Res> implements $TmdbRecommendationCopyWith<$Res> { + factory _$TmdbRecommendationCopyWith(_TmdbRecommendation value, $Res Function(_TmdbRecommendation) _then) = __$TmdbRecommendationCopyWithImpl; +@override @useResult +$Res call({ +@JsonKey(fromJson: _intRequired) int id, String title,@JsonKey(name: 'poster_path') String? posterPath,@JsonKey(name: 'backdrop_path') String? backdropPath, String? overview,@JsonKey(name: 'vote_average', fromJson: _doubleOrNull) double? voteAverage,@JsonKey(name: 'media_type') String? mediaType +}); + + + + +} +/// @nodoc +class __$TmdbRecommendationCopyWithImpl<$Res> + implements _$TmdbRecommendationCopyWith<$Res> { + __$TmdbRecommendationCopyWithImpl(this._self, this._then); + + final _TmdbRecommendation _self; + final $Res Function(_TmdbRecommendation) _then; + +/// Create a copy of TmdbRecommendation +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? title = null,Object? posterPath = freezed,Object? backdropPath = freezed,Object? overview = freezed,Object? voteAverage = freezed,Object? mediaType = freezed,}) { + return _then(_TmdbRecommendation( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as int,title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable +as String,posterPath: freezed == posterPath ? _self.posterPath : posterPath // ignore: cast_nullable_to_non_nullable +as String?,backdropPath: freezed == backdropPath ? _self.backdropPath : backdropPath // ignore: cast_nullable_to_non_nullable +as String?,overview: freezed == overview ? _self.overview : overview // ignore: cast_nullable_to_non_nullable +as String?,voteAverage: freezed == voteAverage ? _self.voteAverage : voteAverage // ignore: cast_nullable_to_non_nullable +as double?,mediaType: freezed == mediaType ? _self.mediaType : mediaType // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + + +} + + +/// @nodoc +mixin _$TmdbRecommendationsRes { + + List get results;@JsonKey(name: 'total_results', fromJson: _intOrZero) int get totalResults; +/// Create a copy of TmdbRecommendationsRes +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$TmdbRecommendationsResCopyWith get copyWith => _$TmdbRecommendationsResCopyWithImpl(this as TmdbRecommendationsRes, _$identity); + + /// Serializes this TmdbRecommendationsRes to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is TmdbRecommendationsRes&&const DeepCollectionEquality().equals(other.results, results)&&(identical(other.totalResults, totalResults) || other.totalResults == totalResults)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(results),totalResults); + +@override +String toString() { + return 'TmdbRecommendationsRes(results: $results, totalResults: $totalResults)'; +} + + +} + +/// @nodoc +abstract mixin class $TmdbRecommendationsResCopyWith<$Res> { + factory $TmdbRecommendationsResCopyWith(TmdbRecommendationsRes value, $Res Function(TmdbRecommendationsRes) _then) = _$TmdbRecommendationsResCopyWithImpl; +@useResult +$Res call({ + List results,@JsonKey(name: 'total_results', fromJson: _intOrZero) int totalResults +}); + + + + +} +/// @nodoc +class _$TmdbRecommendationsResCopyWithImpl<$Res> + implements $TmdbRecommendationsResCopyWith<$Res> { + _$TmdbRecommendationsResCopyWithImpl(this._self, this._then); + + final TmdbRecommendationsRes _self; + final $Res Function(TmdbRecommendationsRes) _then; + +/// Create a copy of TmdbRecommendationsRes +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? results = null,Object? totalResults = null,}) { + return _then(_self.copyWith( +results: null == results ? _self.results : results // ignore: cast_nullable_to_non_nullable +as List,totalResults: null == totalResults ? _self.totalResults : totalResults // ignore: cast_nullable_to_non_nullable +as int, + )); +} + +} + + +/// Adds pattern-matching-related methods to [TmdbRecommendationsRes]. +extension TmdbRecommendationsResPatterns on TmdbRecommendationsRes { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _TmdbRecommendationsRes value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _TmdbRecommendationsRes() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _TmdbRecommendationsRes value) $default,){ +final _that = this; +switch (_that) { +case _TmdbRecommendationsRes(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _TmdbRecommendationsRes value)? $default,){ +final _that = this; +switch (_that) { +case _TmdbRecommendationsRes() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( List results, @JsonKey(name: 'total_results', fromJson: _intOrZero) int totalResults)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _TmdbRecommendationsRes() when $default != null: +return $default(_that.results,_that.totalResults);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( List results, @JsonKey(name: 'total_results', fromJson: _intOrZero) int totalResults) $default,) {final _that = this; +switch (_that) { +case _TmdbRecommendationsRes(): +return $default(_that.results,_that.totalResults);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( List results, @JsonKey(name: 'total_results', fromJson: _intOrZero) int totalResults)? $default,) {final _that = this; +switch (_that) { +case _TmdbRecommendationsRes() when $default != null: +return $default(_that.results,_that.totalResults);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _TmdbRecommendationsRes implements TmdbRecommendationsRes { + const _TmdbRecommendationsRes({final List results = const [], @JsonKey(name: 'total_results', fromJson: _intOrZero) this.totalResults = 0}): _results = results; + factory _TmdbRecommendationsRes.fromJson(Map json) => _$TmdbRecommendationsResFromJson(json); + + final List _results; +@override@JsonKey() List get results { + if (_results is EqualUnmodifiableListView) return _results; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_results); +} + +@override@JsonKey(name: 'total_results', fromJson: _intOrZero) final int totalResults; + +/// Create a copy of TmdbRecommendationsRes +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$TmdbRecommendationsResCopyWith<_TmdbRecommendationsRes> get copyWith => __$TmdbRecommendationsResCopyWithImpl<_TmdbRecommendationsRes>(this, _$identity); + +@override +Map toJson() { + return _$TmdbRecommendationsResToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _TmdbRecommendationsRes&&const DeepCollectionEquality().equals(other._results, _results)&&(identical(other.totalResults, totalResults) || other.totalResults == totalResults)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_results),totalResults); + +@override +String toString() { + return 'TmdbRecommendationsRes(results: $results, totalResults: $totalResults)'; +} + + +} + +/// @nodoc +abstract mixin class _$TmdbRecommendationsResCopyWith<$Res> implements $TmdbRecommendationsResCopyWith<$Res> { + factory _$TmdbRecommendationsResCopyWith(_TmdbRecommendationsRes value, $Res Function(_TmdbRecommendationsRes) _then) = __$TmdbRecommendationsResCopyWithImpl; +@override @useResult +$Res call({ + List results,@JsonKey(name: 'total_results', fromJson: _intOrZero) int totalResults +}); + + + + +} +/// @nodoc +class __$TmdbRecommendationsResCopyWithImpl<$Res> + implements _$TmdbRecommendationsResCopyWith<$Res> { + __$TmdbRecommendationsResCopyWithImpl(this._self, this._then); + + final _TmdbRecommendationsRes _self; + final $Res Function(_TmdbRecommendationsRes) _then; + +/// Create a copy of TmdbRecommendationsRes +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? results = null,Object? totalResults = null,}) { + return _then(_TmdbRecommendationsRes( +results: null == results ? _self._results : results // ignore: cast_nullable_to_non_nullable +as List,totalResults: null == totalResults ? _self.totalResults : totalResults // ignore: cast_nullable_to_non_nullable +as int, + )); +} + + +} + + +/// @nodoc +mixin _$TmdbEpisode { + +@JsonKey(name: 'episode_number', fromJson: _intRequired) int get episodeNumber;@JsonKey(fromJson: _strOrEmpty) String get name; String? get overview;@JsonKey(name: 'still_path') String? get stillPath;@JsonKey(name: 'air_date') String? get airDate;@JsonKey(name: 'vote_average', fromJson: _doubleOrNull) double? get voteAverage;@JsonKey(fromJson: _intOrNull) int? get runtime; +/// Create a copy of TmdbEpisode +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$TmdbEpisodeCopyWith get copyWith => _$TmdbEpisodeCopyWithImpl(this as TmdbEpisode, _$identity); + + /// Serializes this TmdbEpisode to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is TmdbEpisode&&(identical(other.episodeNumber, episodeNumber) || other.episodeNumber == episodeNumber)&&(identical(other.name, name) || other.name == name)&&(identical(other.overview, overview) || other.overview == overview)&&(identical(other.stillPath, stillPath) || other.stillPath == stillPath)&&(identical(other.airDate, airDate) || other.airDate == airDate)&&(identical(other.voteAverage, voteAverage) || other.voteAverage == voteAverage)&&(identical(other.runtime, runtime) || other.runtime == runtime)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,episodeNumber,name,overview,stillPath,airDate,voteAverage,runtime); + +@override +String toString() { + return 'TmdbEpisode(episodeNumber: $episodeNumber, name: $name, overview: $overview, stillPath: $stillPath, airDate: $airDate, voteAverage: $voteAverage, runtime: $runtime)'; +} + + +} + +/// @nodoc +abstract mixin class $TmdbEpisodeCopyWith<$Res> { + factory $TmdbEpisodeCopyWith(TmdbEpisode value, $Res Function(TmdbEpisode) _then) = _$TmdbEpisodeCopyWithImpl; +@useResult +$Res call({ +@JsonKey(name: 'episode_number', fromJson: _intRequired) int episodeNumber,@JsonKey(fromJson: _strOrEmpty) String name, String? overview,@JsonKey(name: 'still_path') String? stillPath,@JsonKey(name: 'air_date') String? airDate,@JsonKey(name: 'vote_average', fromJson: _doubleOrNull) double? voteAverage,@JsonKey(fromJson: _intOrNull) int? runtime +}); + + + + +} +/// @nodoc +class _$TmdbEpisodeCopyWithImpl<$Res> + implements $TmdbEpisodeCopyWith<$Res> { + _$TmdbEpisodeCopyWithImpl(this._self, this._then); + + final TmdbEpisode _self; + final $Res Function(TmdbEpisode) _then; + +/// Create a copy of TmdbEpisode +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? episodeNumber = null,Object? name = null,Object? overview = freezed,Object? stillPath = freezed,Object? airDate = freezed,Object? voteAverage = freezed,Object? runtime = freezed,}) { + return _then(_self.copyWith( +episodeNumber: null == episodeNumber ? _self.episodeNumber : episodeNumber // ignore: cast_nullable_to_non_nullable +as int,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,overview: freezed == overview ? _self.overview : overview // ignore: cast_nullable_to_non_nullable +as String?,stillPath: freezed == stillPath ? _self.stillPath : stillPath // ignore: cast_nullable_to_non_nullable +as String?,airDate: freezed == airDate ? _self.airDate : airDate // ignore: cast_nullable_to_non_nullable +as String?,voteAverage: freezed == voteAverage ? _self.voteAverage : voteAverage // ignore: cast_nullable_to_non_nullable +as double?,runtime: freezed == runtime ? _self.runtime : runtime // ignore: cast_nullable_to_non_nullable +as int?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [TmdbEpisode]. +extension TmdbEpisodePatterns on TmdbEpisode { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _TmdbEpisode value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _TmdbEpisode() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _TmdbEpisode value) $default,){ +final _that = this; +switch (_that) { +case _TmdbEpisode(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _TmdbEpisode value)? $default,){ +final _that = this; +switch (_that) { +case _TmdbEpisode() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function(@JsonKey(name: 'episode_number', fromJson: _intRequired) int episodeNumber, @JsonKey(fromJson: _strOrEmpty) String name, String? overview, @JsonKey(name: 'still_path') String? stillPath, @JsonKey(name: 'air_date') String? airDate, @JsonKey(name: 'vote_average', fromJson: _doubleOrNull) double? voteAverage, @JsonKey(fromJson: _intOrNull) int? runtime)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _TmdbEpisode() when $default != null: +return $default(_that.episodeNumber,_that.name,_that.overview,_that.stillPath,_that.airDate,_that.voteAverage,_that.runtime);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function(@JsonKey(name: 'episode_number', fromJson: _intRequired) int episodeNumber, @JsonKey(fromJson: _strOrEmpty) String name, String? overview, @JsonKey(name: 'still_path') String? stillPath, @JsonKey(name: 'air_date') String? airDate, @JsonKey(name: 'vote_average', fromJson: _doubleOrNull) double? voteAverage, @JsonKey(fromJson: _intOrNull) int? runtime) $default,) {final _that = this; +switch (_that) { +case _TmdbEpisode(): +return $default(_that.episodeNumber,_that.name,_that.overview,_that.stillPath,_that.airDate,_that.voteAverage,_that.runtime);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function(@JsonKey(name: 'episode_number', fromJson: _intRequired) int episodeNumber, @JsonKey(fromJson: _strOrEmpty) String name, String? overview, @JsonKey(name: 'still_path') String? stillPath, @JsonKey(name: 'air_date') String? airDate, @JsonKey(name: 'vote_average', fromJson: _doubleOrNull) double? voteAverage, @JsonKey(fromJson: _intOrNull) int? runtime)? $default,) {final _that = this; +switch (_that) { +case _TmdbEpisode() when $default != null: +return $default(_that.episodeNumber,_that.name,_that.overview,_that.stillPath,_that.airDate,_that.voteAverage,_that.runtime);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _TmdbEpisode implements TmdbEpisode { + const _TmdbEpisode({@JsonKey(name: 'episode_number', fromJson: _intRequired) required this.episodeNumber, @JsonKey(fromJson: _strOrEmpty) this.name = '', this.overview, @JsonKey(name: 'still_path') this.stillPath, @JsonKey(name: 'air_date') this.airDate, @JsonKey(name: 'vote_average', fromJson: _doubleOrNull) this.voteAverage, @JsonKey(fromJson: _intOrNull) this.runtime}); + factory _TmdbEpisode.fromJson(Map json) => _$TmdbEpisodeFromJson(json); + +@override@JsonKey(name: 'episode_number', fromJson: _intRequired) final int episodeNumber; +@override@JsonKey(fromJson: _strOrEmpty) final String name; +@override final String? overview; +@override@JsonKey(name: 'still_path') final String? stillPath; +@override@JsonKey(name: 'air_date') final String? airDate; +@override@JsonKey(name: 'vote_average', fromJson: _doubleOrNull) final double? voteAverage; +@override@JsonKey(fromJson: _intOrNull) final int? runtime; + +/// Create a copy of TmdbEpisode +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$TmdbEpisodeCopyWith<_TmdbEpisode> get copyWith => __$TmdbEpisodeCopyWithImpl<_TmdbEpisode>(this, _$identity); + +@override +Map toJson() { + return _$TmdbEpisodeToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _TmdbEpisode&&(identical(other.episodeNumber, episodeNumber) || other.episodeNumber == episodeNumber)&&(identical(other.name, name) || other.name == name)&&(identical(other.overview, overview) || other.overview == overview)&&(identical(other.stillPath, stillPath) || other.stillPath == stillPath)&&(identical(other.airDate, airDate) || other.airDate == airDate)&&(identical(other.voteAverage, voteAverage) || other.voteAverage == voteAverage)&&(identical(other.runtime, runtime) || other.runtime == runtime)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,episodeNumber,name,overview,stillPath,airDate,voteAverage,runtime); + +@override +String toString() { + return 'TmdbEpisode(episodeNumber: $episodeNumber, name: $name, overview: $overview, stillPath: $stillPath, airDate: $airDate, voteAverage: $voteAverage, runtime: $runtime)'; +} + + +} + +/// @nodoc +abstract mixin class _$TmdbEpisodeCopyWith<$Res> implements $TmdbEpisodeCopyWith<$Res> { + factory _$TmdbEpisodeCopyWith(_TmdbEpisode value, $Res Function(_TmdbEpisode) _then) = __$TmdbEpisodeCopyWithImpl; +@override @useResult +$Res call({ +@JsonKey(name: 'episode_number', fromJson: _intRequired) int episodeNumber,@JsonKey(fromJson: _strOrEmpty) String name, String? overview,@JsonKey(name: 'still_path') String? stillPath,@JsonKey(name: 'air_date') String? airDate,@JsonKey(name: 'vote_average', fromJson: _doubleOrNull) double? voteAverage,@JsonKey(fromJson: _intOrNull) int? runtime +}); + + + + +} +/// @nodoc +class __$TmdbEpisodeCopyWithImpl<$Res> + implements _$TmdbEpisodeCopyWith<$Res> { + __$TmdbEpisodeCopyWithImpl(this._self, this._then); + + final _TmdbEpisode _self; + final $Res Function(_TmdbEpisode) _then; + +/// Create a copy of TmdbEpisode +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? episodeNumber = null,Object? name = null,Object? overview = freezed,Object? stillPath = freezed,Object? airDate = freezed,Object? voteAverage = freezed,Object? runtime = freezed,}) { + return _then(_TmdbEpisode( +episodeNumber: null == episodeNumber ? _self.episodeNumber : episodeNumber // ignore: cast_nullable_to_non_nullable +as int,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,overview: freezed == overview ? _self.overview : overview // ignore: cast_nullable_to_non_nullable +as String?,stillPath: freezed == stillPath ? _self.stillPath : stillPath // ignore: cast_nullable_to_non_nullable +as String?,airDate: freezed == airDate ? _self.airDate : airDate // ignore: cast_nullable_to_non_nullable +as String?,voteAverage: freezed == voteAverage ? _self.voteAverage : voteAverage // ignore: cast_nullable_to_non_nullable +as double?,runtime: freezed == runtime ? _self.runtime : runtime // ignore: cast_nullable_to_non_nullable +as int?, + )); +} + + +} + + +/// @nodoc +mixin _$TmdbSeasonDetail { + +@JsonKey(name: 'season_number', fromJson: _intRequired) int get seasonNumber;@JsonKey(fromJson: _strOrEmpty) String get name; String? get overview;@JsonKey(name: 'poster_path') String? get posterPath; List get episodes; +/// Create a copy of TmdbSeasonDetail +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$TmdbSeasonDetailCopyWith get copyWith => _$TmdbSeasonDetailCopyWithImpl(this as TmdbSeasonDetail, _$identity); + + /// Serializes this TmdbSeasonDetail to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is TmdbSeasonDetail&&(identical(other.seasonNumber, seasonNumber) || other.seasonNumber == seasonNumber)&&(identical(other.name, name) || other.name == name)&&(identical(other.overview, overview) || other.overview == overview)&&(identical(other.posterPath, posterPath) || other.posterPath == posterPath)&&const DeepCollectionEquality().equals(other.episodes, episodes)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,seasonNumber,name,overview,posterPath,const DeepCollectionEquality().hash(episodes)); + +@override +String toString() { + return 'TmdbSeasonDetail(seasonNumber: $seasonNumber, name: $name, overview: $overview, posterPath: $posterPath, episodes: $episodes)'; +} + + +} + +/// @nodoc +abstract mixin class $TmdbSeasonDetailCopyWith<$Res> { + factory $TmdbSeasonDetailCopyWith(TmdbSeasonDetail value, $Res Function(TmdbSeasonDetail) _then) = _$TmdbSeasonDetailCopyWithImpl; +@useResult +$Res call({ +@JsonKey(name: 'season_number', fromJson: _intRequired) int seasonNumber,@JsonKey(fromJson: _strOrEmpty) String name, String? overview,@JsonKey(name: 'poster_path') String? posterPath, List episodes +}); + + + + +} +/// @nodoc +class _$TmdbSeasonDetailCopyWithImpl<$Res> + implements $TmdbSeasonDetailCopyWith<$Res> { + _$TmdbSeasonDetailCopyWithImpl(this._self, this._then); + + final TmdbSeasonDetail _self; + final $Res Function(TmdbSeasonDetail) _then; + +/// Create a copy of TmdbSeasonDetail +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? seasonNumber = null,Object? name = null,Object? overview = freezed,Object? posterPath = freezed,Object? episodes = null,}) { + return _then(_self.copyWith( +seasonNumber: null == seasonNumber ? _self.seasonNumber : seasonNumber // ignore: cast_nullable_to_non_nullable +as int,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,overview: freezed == overview ? _self.overview : overview // ignore: cast_nullable_to_non_nullable +as String?,posterPath: freezed == posterPath ? _self.posterPath : posterPath // ignore: cast_nullable_to_non_nullable +as String?,episodes: null == episodes ? _self.episodes : episodes // ignore: cast_nullable_to_non_nullable +as List, + )); +} + +} + + +/// Adds pattern-matching-related methods to [TmdbSeasonDetail]. +extension TmdbSeasonDetailPatterns on TmdbSeasonDetail { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _TmdbSeasonDetail value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _TmdbSeasonDetail() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _TmdbSeasonDetail value) $default,){ +final _that = this; +switch (_that) { +case _TmdbSeasonDetail(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _TmdbSeasonDetail value)? $default,){ +final _that = this; +switch (_that) { +case _TmdbSeasonDetail() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function(@JsonKey(name: 'season_number', fromJson: _intRequired) int seasonNumber, @JsonKey(fromJson: _strOrEmpty) String name, String? overview, @JsonKey(name: 'poster_path') String? posterPath, List episodes)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _TmdbSeasonDetail() when $default != null: +return $default(_that.seasonNumber,_that.name,_that.overview,_that.posterPath,_that.episodes);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function(@JsonKey(name: 'season_number', fromJson: _intRequired) int seasonNumber, @JsonKey(fromJson: _strOrEmpty) String name, String? overview, @JsonKey(name: 'poster_path') String? posterPath, List episodes) $default,) {final _that = this; +switch (_that) { +case _TmdbSeasonDetail(): +return $default(_that.seasonNumber,_that.name,_that.overview,_that.posterPath,_that.episodes);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function(@JsonKey(name: 'season_number', fromJson: _intRequired) int seasonNumber, @JsonKey(fromJson: _strOrEmpty) String name, String? overview, @JsonKey(name: 'poster_path') String? posterPath, List episodes)? $default,) {final _that = this; +switch (_that) { +case _TmdbSeasonDetail() when $default != null: +return $default(_that.seasonNumber,_that.name,_that.overview,_that.posterPath,_that.episodes);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _TmdbSeasonDetail implements TmdbSeasonDetail { + const _TmdbSeasonDetail({@JsonKey(name: 'season_number', fromJson: _intRequired) required this.seasonNumber, @JsonKey(fromJson: _strOrEmpty) this.name = '', this.overview, @JsonKey(name: 'poster_path') this.posterPath, final List episodes = const []}): _episodes = episodes; + factory _TmdbSeasonDetail.fromJson(Map json) => _$TmdbSeasonDetailFromJson(json); + +@override@JsonKey(name: 'season_number', fromJson: _intRequired) final int seasonNumber; +@override@JsonKey(fromJson: _strOrEmpty) final String name; +@override final String? overview; +@override@JsonKey(name: 'poster_path') final String? posterPath; + final List _episodes; +@override@JsonKey() List get episodes { + if (_episodes is EqualUnmodifiableListView) return _episodes; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_episodes); +} + + +/// Create a copy of TmdbSeasonDetail +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$TmdbSeasonDetailCopyWith<_TmdbSeasonDetail> get copyWith => __$TmdbSeasonDetailCopyWithImpl<_TmdbSeasonDetail>(this, _$identity); + +@override +Map toJson() { + return _$TmdbSeasonDetailToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _TmdbSeasonDetail&&(identical(other.seasonNumber, seasonNumber) || other.seasonNumber == seasonNumber)&&(identical(other.name, name) || other.name == name)&&(identical(other.overview, overview) || other.overview == overview)&&(identical(other.posterPath, posterPath) || other.posterPath == posterPath)&&const DeepCollectionEquality().equals(other._episodes, _episodes)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,seasonNumber,name,overview,posterPath,const DeepCollectionEquality().hash(_episodes)); + +@override +String toString() { + return 'TmdbSeasonDetail(seasonNumber: $seasonNumber, name: $name, overview: $overview, posterPath: $posterPath, episodes: $episodes)'; +} + + +} + +/// @nodoc +abstract mixin class _$TmdbSeasonDetailCopyWith<$Res> implements $TmdbSeasonDetailCopyWith<$Res> { + factory _$TmdbSeasonDetailCopyWith(_TmdbSeasonDetail value, $Res Function(_TmdbSeasonDetail) _then) = __$TmdbSeasonDetailCopyWithImpl; +@override @useResult +$Res call({ +@JsonKey(name: 'season_number', fromJson: _intRequired) int seasonNumber,@JsonKey(fromJson: _strOrEmpty) String name, String? overview,@JsonKey(name: 'poster_path') String? posterPath, List episodes +}); + + + + +} +/// @nodoc +class __$TmdbSeasonDetailCopyWithImpl<$Res> + implements _$TmdbSeasonDetailCopyWith<$Res> { + __$TmdbSeasonDetailCopyWithImpl(this._self, this._then); + + final _TmdbSeasonDetail _self; + final $Res Function(_TmdbSeasonDetail) _then; + +/// Create a copy of TmdbSeasonDetail +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? seasonNumber = null,Object? name = null,Object? overview = freezed,Object? posterPath = freezed,Object? episodes = null,}) { + return _then(_TmdbSeasonDetail( +seasonNumber: null == seasonNumber ? _self.seasonNumber : seasonNumber // ignore: cast_nullable_to_non_nullable +as int,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,overview: freezed == overview ? _self.overview : overview // ignore: cast_nullable_to_non_nullable +as String?,posterPath: freezed == posterPath ? _self.posterPath : posterPath // ignore: cast_nullable_to_non_nullable +as String?,episodes: null == episodes ? _self._episodes : episodes // ignore: cast_nullable_to_non_nullable +as List, + )); +} + + +} + + +/// @nodoc +mixin _$TmdbVideo { + +@JsonKey(fromJson: _strOrEmpty) String get id;@JsonKey(fromJson: _strOrEmpty) String get key;@JsonKey(fromJson: _strOrEmpty) String get name;@JsonKey(fromJson: _strOrEmpty) String get site;@JsonKey(fromJson: _strOrEmpty) String get type;@JsonKey(fromJson: _intOrZero) int get size;@JsonKey(fromJson: _boolOrFalse) bool get official; +/// Create a copy of TmdbVideo +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$TmdbVideoCopyWith get copyWith => _$TmdbVideoCopyWithImpl(this as TmdbVideo, _$identity); + + /// Serializes this TmdbVideo to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is TmdbVideo&&(identical(other.id, id) || other.id == id)&&(identical(other.key, key) || other.key == key)&&(identical(other.name, name) || other.name == name)&&(identical(other.site, site) || other.site == site)&&(identical(other.type, type) || other.type == type)&&(identical(other.size, size) || other.size == size)&&(identical(other.official, official) || other.official == official)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,key,name,site,type,size,official); + +@override +String toString() { + return 'TmdbVideo(id: $id, key: $key, name: $name, site: $site, type: $type, size: $size, official: $official)'; +} + + +} + +/// @nodoc +abstract mixin class $TmdbVideoCopyWith<$Res> { + factory $TmdbVideoCopyWith(TmdbVideo value, $Res Function(TmdbVideo) _then) = _$TmdbVideoCopyWithImpl; +@useResult +$Res call({ +@JsonKey(fromJson: _strOrEmpty) String id,@JsonKey(fromJson: _strOrEmpty) String key,@JsonKey(fromJson: _strOrEmpty) String name,@JsonKey(fromJson: _strOrEmpty) String site,@JsonKey(fromJson: _strOrEmpty) String type,@JsonKey(fromJson: _intOrZero) int size,@JsonKey(fromJson: _boolOrFalse) bool official +}); + + + + +} +/// @nodoc +class _$TmdbVideoCopyWithImpl<$Res> + implements $TmdbVideoCopyWith<$Res> { + _$TmdbVideoCopyWithImpl(this._self, this._then); + + final TmdbVideo _self; + final $Res Function(TmdbVideo) _then; + +/// Create a copy of TmdbVideo +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? key = null,Object? name = null,Object? site = null,Object? type = null,Object? size = null,Object? official = null,}) { + return _then(_self.copyWith( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,key: null == key ? _self.key : key // ignore: cast_nullable_to_non_nullable +as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,site: null == site ? _self.site : site // ignore: cast_nullable_to_non_nullable +as String,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable +as String,size: null == size ? _self.size : size // ignore: cast_nullable_to_non_nullable +as int,official: null == official ? _self.official : official // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + +} + + +/// Adds pattern-matching-related methods to [TmdbVideo]. +extension TmdbVideoPatterns on TmdbVideo { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _TmdbVideo value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _TmdbVideo() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _TmdbVideo value) $default,){ +final _that = this; +switch (_that) { +case _TmdbVideo(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _TmdbVideo value)? $default,){ +final _that = this; +switch (_that) { +case _TmdbVideo() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function(@JsonKey(fromJson: _strOrEmpty) String id, @JsonKey(fromJson: _strOrEmpty) String key, @JsonKey(fromJson: _strOrEmpty) String name, @JsonKey(fromJson: _strOrEmpty) String site, @JsonKey(fromJson: _strOrEmpty) String type, @JsonKey(fromJson: _intOrZero) int size, @JsonKey(fromJson: _boolOrFalse) bool official)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _TmdbVideo() when $default != null: +return $default(_that.id,_that.key,_that.name,_that.site,_that.type,_that.size,_that.official);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function(@JsonKey(fromJson: _strOrEmpty) String id, @JsonKey(fromJson: _strOrEmpty) String key, @JsonKey(fromJson: _strOrEmpty) String name, @JsonKey(fromJson: _strOrEmpty) String site, @JsonKey(fromJson: _strOrEmpty) String type, @JsonKey(fromJson: _intOrZero) int size, @JsonKey(fromJson: _boolOrFalse) bool official) $default,) {final _that = this; +switch (_that) { +case _TmdbVideo(): +return $default(_that.id,_that.key,_that.name,_that.site,_that.type,_that.size,_that.official);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function(@JsonKey(fromJson: _strOrEmpty) String id, @JsonKey(fromJson: _strOrEmpty) String key, @JsonKey(fromJson: _strOrEmpty) String name, @JsonKey(fromJson: _strOrEmpty) String site, @JsonKey(fromJson: _strOrEmpty) String type, @JsonKey(fromJson: _intOrZero) int size, @JsonKey(fromJson: _boolOrFalse) bool official)? $default,) {final _that = this; +switch (_that) { +case _TmdbVideo() when $default != null: +return $default(_that.id,_that.key,_that.name,_that.site,_that.type,_that.size,_that.official);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _TmdbVideo extends TmdbVideo { + const _TmdbVideo({@JsonKey(fromJson: _strOrEmpty) this.id = '', @JsonKey(fromJson: _strOrEmpty) this.key = '', @JsonKey(fromJson: _strOrEmpty) this.name = '', @JsonKey(fromJson: _strOrEmpty) this.site = '', @JsonKey(fromJson: _strOrEmpty) this.type = '', @JsonKey(fromJson: _intOrZero) this.size = 0, @JsonKey(fromJson: _boolOrFalse) this.official = false}): super._(); + factory _TmdbVideo.fromJson(Map json) => _$TmdbVideoFromJson(json); + +@override@JsonKey(fromJson: _strOrEmpty) final String id; +@override@JsonKey(fromJson: _strOrEmpty) final String key; +@override@JsonKey(fromJson: _strOrEmpty) final String name; +@override@JsonKey(fromJson: _strOrEmpty) final String site; +@override@JsonKey(fromJson: _strOrEmpty) final String type; +@override@JsonKey(fromJson: _intOrZero) final int size; +@override@JsonKey(fromJson: _boolOrFalse) final bool official; + +/// Create a copy of TmdbVideo +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$TmdbVideoCopyWith<_TmdbVideo> get copyWith => __$TmdbVideoCopyWithImpl<_TmdbVideo>(this, _$identity); + +@override +Map toJson() { + return _$TmdbVideoToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _TmdbVideo&&(identical(other.id, id) || other.id == id)&&(identical(other.key, key) || other.key == key)&&(identical(other.name, name) || other.name == name)&&(identical(other.site, site) || other.site == site)&&(identical(other.type, type) || other.type == type)&&(identical(other.size, size) || other.size == size)&&(identical(other.official, official) || other.official == official)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,key,name,site,type,size,official); + +@override +String toString() { + return 'TmdbVideo(id: $id, key: $key, name: $name, site: $site, type: $type, size: $size, official: $official)'; +} + + +} + +/// @nodoc +abstract mixin class _$TmdbVideoCopyWith<$Res> implements $TmdbVideoCopyWith<$Res> { + factory _$TmdbVideoCopyWith(_TmdbVideo value, $Res Function(_TmdbVideo) _then) = __$TmdbVideoCopyWithImpl; +@override @useResult +$Res call({ +@JsonKey(fromJson: _strOrEmpty) String id,@JsonKey(fromJson: _strOrEmpty) String key,@JsonKey(fromJson: _strOrEmpty) String name,@JsonKey(fromJson: _strOrEmpty) String site,@JsonKey(fromJson: _strOrEmpty) String type,@JsonKey(fromJson: _intOrZero) int size,@JsonKey(fromJson: _boolOrFalse) bool official +}); + + + + +} +/// @nodoc +class __$TmdbVideoCopyWithImpl<$Res> + implements _$TmdbVideoCopyWith<$Res> { + __$TmdbVideoCopyWithImpl(this._self, this._then); + + final _TmdbVideo _self; + final $Res Function(_TmdbVideo) _then; + +/// Create a copy of TmdbVideo +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? key = null,Object? name = null,Object? site = null,Object? type = null,Object? size = null,Object? official = null,}) { + return _then(_TmdbVideo( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,key: null == key ? _self.key : key // ignore: cast_nullable_to_non_nullable +as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,site: null == site ? _self.site : site // ignore: cast_nullable_to_non_nullable +as String,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable +as String,size: null == size ? _self.size : size // ignore: cast_nullable_to_non_nullable +as int,official: null == official ? _self.official : official // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + + +} + + +/// @nodoc +mixin _$TmdbVideosRes { + + List get results; +/// Create a copy of TmdbVideosRes +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$TmdbVideosResCopyWith get copyWith => _$TmdbVideosResCopyWithImpl(this as TmdbVideosRes, _$identity); + + /// Serializes this TmdbVideosRes to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is TmdbVideosRes&&const DeepCollectionEquality().equals(other.results, results)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(results)); + +@override +String toString() { + return 'TmdbVideosRes(results: $results)'; +} + + +} + +/// @nodoc +abstract mixin class $TmdbVideosResCopyWith<$Res> { + factory $TmdbVideosResCopyWith(TmdbVideosRes value, $Res Function(TmdbVideosRes) _then) = _$TmdbVideosResCopyWithImpl; +@useResult +$Res call({ + List results +}); + + + + +} +/// @nodoc +class _$TmdbVideosResCopyWithImpl<$Res> + implements $TmdbVideosResCopyWith<$Res> { + _$TmdbVideosResCopyWithImpl(this._self, this._then); + + final TmdbVideosRes _self; + final $Res Function(TmdbVideosRes) _then; + +/// Create a copy of TmdbVideosRes +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? results = null,}) { + return _then(_self.copyWith( +results: null == results ? _self.results : results // ignore: cast_nullable_to_non_nullable +as List, + )); +} + +} + + +/// Adds pattern-matching-related methods to [TmdbVideosRes]. +extension TmdbVideosResPatterns on TmdbVideosRes { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _TmdbVideosRes value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _TmdbVideosRes() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _TmdbVideosRes value) $default,){ +final _that = this; +switch (_that) { +case _TmdbVideosRes(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _TmdbVideosRes value)? $default,){ +final _that = this; +switch (_that) { +case _TmdbVideosRes() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( List results)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _TmdbVideosRes() when $default != null: +return $default(_that.results);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( List results) $default,) {final _that = this; +switch (_that) { +case _TmdbVideosRes(): +return $default(_that.results);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( List results)? $default,) {final _that = this; +switch (_that) { +case _TmdbVideosRes() when $default != null: +return $default(_that.results);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _TmdbVideosRes implements TmdbVideosRes { + const _TmdbVideosRes({final List results = const []}): _results = results; + factory _TmdbVideosRes.fromJson(Map json) => _$TmdbVideosResFromJson(json); + + final List _results; +@override@JsonKey() List get results { + if (_results is EqualUnmodifiableListView) return _results; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_results); +} + + +/// Create a copy of TmdbVideosRes +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$TmdbVideosResCopyWith<_TmdbVideosRes> get copyWith => __$TmdbVideosResCopyWithImpl<_TmdbVideosRes>(this, _$identity); + +@override +Map toJson() { + return _$TmdbVideosResToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _TmdbVideosRes&&const DeepCollectionEquality().equals(other._results, _results)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_results)); + +@override +String toString() { + return 'TmdbVideosRes(results: $results)'; +} + + +} + +/// @nodoc +abstract mixin class _$TmdbVideosResCopyWith<$Res> implements $TmdbVideosResCopyWith<$Res> { + factory _$TmdbVideosResCopyWith(_TmdbVideosRes value, $Res Function(_TmdbVideosRes) _then) = __$TmdbVideosResCopyWithImpl; +@override @useResult +$Res call({ + List results +}); + + + + +} +/// @nodoc +class __$TmdbVideosResCopyWithImpl<$Res> + implements _$TmdbVideosResCopyWith<$Res> { + __$TmdbVideosResCopyWithImpl(this._self, this._then); + + final _TmdbVideosRes _self; + final $Res Function(_TmdbVideosRes) _then; + +/// Create a copy of TmdbVideosRes +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? results = null,}) { + return _then(_TmdbVideosRes( +results: null == results ? _self._results : results // ignore: cast_nullable_to_non_nullable +as List, + )); +} + + +} + + +/// @nodoc +mixin _$TmdbPersonDetail { + +@JsonKey(fromJson: _intRequired) int get id;@JsonKey(fromJson: _strOrEmpty) String get name; String? get biography; String? get birthday; String? get deathday;@JsonKey(name: 'place_of_birth') String? get placeOfBirth;@JsonKey(name: 'profile_path') String? get profilePath;@JsonKey(name: 'known_for_department') String? get knownForDepartment;@JsonKey(fromJson: _doubleOrNull) double? get popularity; +/// Create a copy of TmdbPersonDetail +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$TmdbPersonDetailCopyWith get copyWith => _$TmdbPersonDetailCopyWithImpl(this as TmdbPersonDetail, _$identity); + + /// Serializes this TmdbPersonDetail to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is TmdbPersonDetail&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.biography, biography) || other.biography == biography)&&(identical(other.birthday, birthday) || other.birthday == birthday)&&(identical(other.deathday, deathday) || other.deathday == deathday)&&(identical(other.placeOfBirth, placeOfBirth) || other.placeOfBirth == placeOfBirth)&&(identical(other.profilePath, profilePath) || other.profilePath == profilePath)&&(identical(other.knownForDepartment, knownForDepartment) || other.knownForDepartment == knownForDepartment)&&(identical(other.popularity, popularity) || other.popularity == popularity)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,name,biography,birthday,deathday,placeOfBirth,profilePath,knownForDepartment,popularity); + +@override +String toString() { + return 'TmdbPersonDetail(id: $id, name: $name, biography: $biography, birthday: $birthday, deathday: $deathday, placeOfBirth: $placeOfBirth, profilePath: $profilePath, knownForDepartment: $knownForDepartment, popularity: $popularity)'; +} + + +} + +/// @nodoc +abstract mixin class $TmdbPersonDetailCopyWith<$Res> { + factory $TmdbPersonDetailCopyWith(TmdbPersonDetail value, $Res Function(TmdbPersonDetail) _then) = _$TmdbPersonDetailCopyWithImpl; +@useResult +$Res call({ +@JsonKey(fromJson: _intRequired) int id,@JsonKey(fromJson: _strOrEmpty) String name, String? biography, String? birthday, String? deathday,@JsonKey(name: 'place_of_birth') String? placeOfBirth,@JsonKey(name: 'profile_path') String? profilePath,@JsonKey(name: 'known_for_department') String? knownForDepartment,@JsonKey(fromJson: _doubleOrNull) double? popularity +}); + + + + +} +/// @nodoc +class _$TmdbPersonDetailCopyWithImpl<$Res> + implements $TmdbPersonDetailCopyWith<$Res> { + _$TmdbPersonDetailCopyWithImpl(this._self, this._then); + + final TmdbPersonDetail _self; + final $Res Function(TmdbPersonDetail) _then; + +/// Create a copy of TmdbPersonDetail +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? name = null,Object? biography = freezed,Object? birthday = freezed,Object? deathday = freezed,Object? placeOfBirth = freezed,Object? profilePath = freezed,Object? knownForDepartment = freezed,Object? popularity = freezed,}) { + return _then(_self.copyWith( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as int,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,biography: freezed == biography ? _self.biography : biography // ignore: cast_nullable_to_non_nullable +as String?,birthday: freezed == birthday ? _self.birthday : birthday // ignore: cast_nullable_to_non_nullable +as String?,deathday: freezed == deathday ? _self.deathday : deathday // ignore: cast_nullable_to_non_nullable +as String?,placeOfBirth: freezed == placeOfBirth ? _self.placeOfBirth : placeOfBirth // ignore: cast_nullable_to_non_nullable +as String?,profilePath: freezed == profilePath ? _self.profilePath : profilePath // ignore: cast_nullable_to_non_nullable +as String?,knownForDepartment: freezed == knownForDepartment ? _self.knownForDepartment : knownForDepartment // ignore: cast_nullable_to_non_nullable +as String?,popularity: freezed == popularity ? _self.popularity : popularity // ignore: cast_nullable_to_non_nullable +as double?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [TmdbPersonDetail]. +extension TmdbPersonDetailPatterns on TmdbPersonDetail { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _TmdbPersonDetail value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _TmdbPersonDetail() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _TmdbPersonDetail value) $default,){ +final _that = this; +switch (_that) { +case _TmdbPersonDetail(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _TmdbPersonDetail value)? $default,){ +final _that = this; +switch (_that) { +case _TmdbPersonDetail() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function(@JsonKey(fromJson: _intRequired) int id, @JsonKey(fromJson: _strOrEmpty) String name, String? biography, String? birthday, String? deathday, @JsonKey(name: 'place_of_birth') String? placeOfBirth, @JsonKey(name: 'profile_path') String? profilePath, @JsonKey(name: 'known_for_department') String? knownForDepartment, @JsonKey(fromJson: _doubleOrNull) double? popularity)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _TmdbPersonDetail() when $default != null: +return $default(_that.id,_that.name,_that.biography,_that.birthday,_that.deathday,_that.placeOfBirth,_that.profilePath,_that.knownForDepartment,_that.popularity);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function(@JsonKey(fromJson: _intRequired) int id, @JsonKey(fromJson: _strOrEmpty) String name, String? biography, String? birthday, String? deathday, @JsonKey(name: 'place_of_birth') String? placeOfBirth, @JsonKey(name: 'profile_path') String? profilePath, @JsonKey(name: 'known_for_department') String? knownForDepartment, @JsonKey(fromJson: _doubleOrNull) double? popularity) $default,) {final _that = this; +switch (_that) { +case _TmdbPersonDetail(): +return $default(_that.id,_that.name,_that.biography,_that.birthday,_that.deathday,_that.placeOfBirth,_that.profilePath,_that.knownForDepartment,_that.popularity);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function(@JsonKey(fromJson: _intRequired) int id, @JsonKey(fromJson: _strOrEmpty) String name, String? biography, String? birthday, String? deathday, @JsonKey(name: 'place_of_birth') String? placeOfBirth, @JsonKey(name: 'profile_path') String? profilePath, @JsonKey(name: 'known_for_department') String? knownForDepartment, @JsonKey(fromJson: _doubleOrNull) double? popularity)? $default,) {final _that = this; +switch (_that) { +case _TmdbPersonDetail() when $default != null: +return $default(_that.id,_that.name,_that.biography,_that.birthday,_that.deathday,_that.placeOfBirth,_that.profilePath,_that.knownForDepartment,_that.popularity);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _TmdbPersonDetail implements TmdbPersonDetail { + const _TmdbPersonDetail({@JsonKey(fromJson: _intRequired) required this.id, @JsonKey(fromJson: _strOrEmpty) this.name = '', this.biography, this.birthday, this.deathday, @JsonKey(name: 'place_of_birth') this.placeOfBirth, @JsonKey(name: 'profile_path') this.profilePath, @JsonKey(name: 'known_for_department') this.knownForDepartment, @JsonKey(fromJson: _doubleOrNull) this.popularity}); + factory _TmdbPersonDetail.fromJson(Map json) => _$TmdbPersonDetailFromJson(json); + +@override@JsonKey(fromJson: _intRequired) final int id; +@override@JsonKey(fromJson: _strOrEmpty) final String name; +@override final String? biography; +@override final String? birthday; +@override final String? deathday; +@override@JsonKey(name: 'place_of_birth') final String? placeOfBirth; +@override@JsonKey(name: 'profile_path') final String? profilePath; +@override@JsonKey(name: 'known_for_department') final String? knownForDepartment; +@override@JsonKey(fromJson: _doubleOrNull) final double? popularity; + +/// Create a copy of TmdbPersonDetail +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$TmdbPersonDetailCopyWith<_TmdbPersonDetail> get copyWith => __$TmdbPersonDetailCopyWithImpl<_TmdbPersonDetail>(this, _$identity); + +@override +Map toJson() { + return _$TmdbPersonDetailToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _TmdbPersonDetail&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.biography, biography) || other.biography == biography)&&(identical(other.birthday, birthday) || other.birthday == birthday)&&(identical(other.deathday, deathday) || other.deathday == deathday)&&(identical(other.placeOfBirth, placeOfBirth) || other.placeOfBirth == placeOfBirth)&&(identical(other.profilePath, profilePath) || other.profilePath == profilePath)&&(identical(other.knownForDepartment, knownForDepartment) || other.knownForDepartment == knownForDepartment)&&(identical(other.popularity, popularity) || other.popularity == popularity)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,name,biography,birthday,deathday,placeOfBirth,profilePath,knownForDepartment,popularity); + +@override +String toString() { + return 'TmdbPersonDetail(id: $id, name: $name, biography: $biography, birthday: $birthday, deathday: $deathday, placeOfBirth: $placeOfBirth, profilePath: $profilePath, knownForDepartment: $knownForDepartment, popularity: $popularity)'; +} + + +} + +/// @nodoc +abstract mixin class _$TmdbPersonDetailCopyWith<$Res> implements $TmdbPersonDetailCopyWith<$Res> { + factory _$TmdbPersonDetailCopyWith(_TmdbPersonDetail value, $Res Function(_TmdbPersonDetail) _then) = __$TmdbPersonDetailCopyWithImpl; +@override @useResult +$Res call({ +@JsonKey(fromJson: _intRequired) int id,@JsonKey(fromJson: _strOrEmpty) String name, String? biography, String? birthday, String? deathday,@JsonKey(name: 'place_of_birth') String? placeOfBirth,@JsonKey(name: 'profile_path') String? profilePath,@JsonKey(name: 'known_for_department') String? knownForDepartment,@JsonKey(fromJson: _doubleOrNull) double? popularity +}); + + + + +} +/// @nodoc +class __$TmdbPersonDetailCopyWithImpl<$Res> + implements _$TmdbPersonDetailCopyWith<$Res> { + __$TmdbPersonDetailCopyWithImpl(this._self, this._then); + + final _TmdbPersonDetail _self; + final $Res Function(_TmdbPersonDetail) _then; + +/// Create a copy of TmdbPersonDetail +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? name = null,Object? biography = freezed,Object? birthday = freezed,Object? deathday = freezed,Object? placeOfBirth = freezed,Object? profilePath = freezed,Object? knownForDepartment = freezed,Object? popularity = freezed,}) { + return _then(_TmdbPersonDetail( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as int,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,biography: freezed == biography ? _self.biography : biography // ignore: cast_nullable_to_non_nullable +as String?,birthday: freezed == birthday ? _self.birthday : birthday // ignore: cast_nullable_to_non_nullable +as String?,deathday: freezed == deathday ? _self.deathday : deathday // ignore: cast_nullable_to_non_nullable +as String?,placeOfBirth: freezed == placeOfBirth ? _self.placeOfBirth : placeOfBirth // ignore: cast_nullable_to_non_nullable +as String?,profilePath: freezed == profilePath ? _self.profilePath : profilePath // ignore: cast_nullable_to_non_nullable +as String?,knownForDepartment: freezed == knownForDepartment ? _self.knownForDepartment : knownForDepartment // ignore: cast_nullable_to_non_nullable +as String?,popularity: freezed == popularity ? _self.popularity : popularity // ignore: cast_nullable_to_non_nullable +as double?, + )); +} + + +} + + +/// @nodoc +mixin _$TmdbPersonCredit { + +@JsonKey(fromJson: _intRequired) int get id; String get title; String? get character;@JsonKey(name: 'poster_path') String? get posterPath;@JsonKey(name: 'release_date') String? get releaseDate;@JsonKey(name: 'vote_average', fromJson: _doubleOrNull) double? get voteAverage;@JsonKey(name: 'media_type') String? get mediaType; +/// Create a copy of TmdbPersonCredit +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$TmdbPersonCreditCopyWith get copyWith => _$TmdbPersonCreditCopyWithImpl(this as TmdbPersonCredit, _$identity); + + /// Serializes this TmdbPersonCredit to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is TmdbPersonCredit&&(identical(other.id, id) || other.id == id)&&(identical(other.title, title) || other.title == title)&&(identical(other.character, character) || other.character == character)&&(identical(other.posterPath, posterPath) || other.posterPath == posterPath)&&(identical(other.releaseDate, releaseDate) || other.releaseDate == releaseDate)&&(identical(other.voteAverage, voteAverage) || other.voteAverage == voteAverage)&&(identical(other.mediaType, mediaType) || other.mediaType == mediaType)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,title,character,posterPath,releaseDate,voteAverage,mediaType); + +@override +String toString() { + return 'TmdbPersonCredit(id: $id, title: $title, character: $character, posterPath: $posterPath, releaseDate: $releaseDate, voteAverage: $voteAverage, mediaType: $mediaType)'; +} + + +} + +/// @nodoc +abstract mixin class $TmdbPersonCreditCopyWith<$Res> { + factory $TmdbPersonCreditCopyWith(TmdbPersonCredit value, $Res Function(TmdbPersonCredit) _then) = _$TmdbPersonCreditCopyWithImpl; +@useResult +$Res call({ +@JsonKey(fromJson: _intRequired) int id, String title, String? character,@JsonKey(name: 'poster_path') String? posterPath,@JsonKey(name: 'release_date') String? releaseDate,@JsonKey(name: 'vote_average', fromJson: _doubleOrNull) double? voteAverage,@JsonKey(name: 'media_type') String? mediaType +}); + + + + +} +/// @nodoc +class _$TmdbPersonCreditCopyWithImpl<$Res> + implements $TmdbPersonCreditCopyWith<$Res> { + _$TmdbPersonCreditCopyWithImpl(this._self, this._then); + + final TmdbPersonCredit _self; + final $Res Function(TmdbPersonCredit) _then; + +/// Create a copy of TmdbPersonCredit +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? title = null,Object? character = freezed,Object? posterPath = freezed,Object? releaseDate = freezed,Object? voteAverage = freezed,Object? mediaType = freezed,}) { + return _then(_self.copyWith( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as int,title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable +as String,character: freezed == character ? _self.character : character // ignore: cast_nullable_to_non_nullable +as String?,posterPath: freezed == posterPath ? _self.posterPath : posterPath // ignore: cast_nullable_to_non_nullable +as String?,releaseDate: freezed == releaseDate ? _self.releaseDate : releaseDate // ignore: cast_nullable_to_non_nullable +as String?,voteAverage: freezed == voteAverage ? _self.voteAverage : voteAverage // ignore: cast_nullable_to_non_nullable +as double?,mediaType: freezed == mediaType ? _self.mediaType : mediaType // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [TmdbPersonCredit]. +extension TmdbPersonCreditPatterns on TmdbPersonCredit { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _TmdbPersonCredit value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _TmdbPersonCredit() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _TmdbPersonCredit value) $default,){ +final _that = this; +switch (_that) { +case _TmdbPersonCredit(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _TmdbPersonCredit value)? $default,){ +final _that = this; +switch (_that) { +case _TmdbPersonCredit() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function(@JsonKey(fromJson: _intRequired) int id, String title, String? character, @JsonKey(name: 'poster_path') String? posterPath, @JsonKey(name: 'release_date') String? releaseDate, @JsonKey(name: 'vote_average', fromJson: _doubleOrNull) double? voteAverage, @JsonKey(name: 'media_type') String? mediaType)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _TmdbPersonCredit() when $default != null: +return $default(_that.id,_that.title,_that.character,_that.posterPath,_that.releaseDate,_that.voteAverage,_that.mediaType);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function(@JsonKey(fromJson: _intRequired) int id, String title, String? character, @JsonKey(name: 'poster_path') String? posterPath, @JsonKey(name: 'release_date') String? releaseDate, @JsonKey(name: 'vote_average', fromJson: _doubleOrNull) double? voteAverage, @JsonKey(name: 'media_type') String? mediaType) $default,) {final _that = this; +switch (_that) { +case _TmdbPersonCredit(): +return $default(_that.id,_that.title,_that.character,_that.posterPath,_that.releaseDate,_that.voteAverage,_that.mediaType);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function(@JsonKey(fromJson: _intRequired) int id, String title, String? character, @JsonKey(name: 'poster_path') String? posterPath, @JsonKey(name: 'release_date') String? releaseDate, @JsonKey(name: 'vote_average', fromJson: _doubleOrNull) double? voteAverage, @JsonKey(name: 'media_type') String? mediaType)? $default,) {final _that = this; +switch (_that) { +case _TmdbPersonCredit() when $default != null: +return $default(_that.id,_that.title,_that.character,_that.posterPath,_that.releaseDate,_that.voteAverage,_that.mediaType);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _TmdbPersonCredit implements TmdbPersonCredit { + const _TmdbPersonCredit({@JsonKey(fromJson: _intRequired) required this.id, required this.title, this.character, @JsonKey(name: 'poster_path') this.posterPath, @JsonKey(name: 'release_date') this.releaseDate, @JsonKey(name: 'vote_average', fromJson: _doubleOrNull) this.voteAverage, @JsonKey(name: 'media_type') this.mediaType}); + factory _TmdbPersonCredit.fromJson(Map json) => _$TmdbPersonCreditFromJson(json); + +@override@JsonKey(fromJson: _intRequired) final int id; +@override final String title; +@override final String? character; +@override@JsonKey(name: 'poster_path') final String? posterPath; +@override@JsonKey(name: 'release_date') final String? releaseDate; +@override@JsonKey(name: 'vote_average', fromJson: _doubleOrNull) final double? voteAverage; +@override@JsonKey(name: 'media_type') final String? mediaType; + +/// Create a copy of TmdbPersonCredit +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$TmdbPersonCreditCopyWith<_TmdbPersonCredit> get copyWith => __$TmdbPersonCreditCopyWithImpl<_TmdbPersonCredit>(this, _$identity); + +@override +Map toJson() { + return _$TmdbPersonCreditToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _TmdbPersonCredit&&(identical(other.id, id) || other.id == id)&&(identical(other.title, title) || other.title == title)&&(identical(other.character, character) || other.character == character)&&(identical(other.posterPath, posterPath) || other.posterPath == posterPath)&&(identical(other.releaseDate, releaseDate) || other.releaseDate == releaseDate)&&(identical(other.voteAverage, voteAverage) || other.voteAverage == voteAverage)&&(identical(other.mediaType, mediaType) || other.mediaType == mediaType)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,title,character,posterPath,releaseDate,voteAverage,mediaType); + +@override +String toString() { + return 'TmdbPersonCredit(id: $id, title: $title, character: $character, posterPath: $posterPath, releaseDate: $releaseDate, voteAverage: $voteAverage, mediaType: $mediaType)'; +} + + +} + +/// @nodoc +abstract mixin class _$TmdbPersonCreditCopyWith<$Res> implements $TmdbPersonCreditCopyWith<$Res> { + factory _$TmdbPersonCreditCopyWith(_TmdbPersonCredit value, $Res Function(_TmdbPersonCredit) _then) = __$TmdbPersonCreditCopyWithImpl; +@override @useResult +$Res call({ +@JsonKey(fromJson: _intRequired) int id, String title, String? character,@JsonKey(name: 'poster_path') String? posterPath,@JsonKey(name: 'release_date') String? releaseDate,@JsonKey(name: 'vote_average', fromJson: _doubleOrNull) double? voteAverage,@JsonKey(name: 'media_type') String? mediaType +}); + + + + +} +/// @nodoc +class __$TmdbPersonCreditCopyWithImpl<$Res> + implements _$TmdbPersonCreditCopyWith<$Res> { + __$TmdbPersonCreditCopyWithImpl(this._self, this._then); + + final _TmdbPersonCredit _self; + final $Res Function(_TmdbPersonCredit) _then; + +/// Create a copy of TmdbPersonCredit +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? title = null,Object? character = freezed,Object? posterPath = freezed,Object? releaseDate = freezed,Object? voteAverage = freezed,Object? mediaType = freezed,}) { + return _then(_TmdbPersonCredit( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as int,title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable +as String,character: freezed == character ? _self.character : character // ignore: cast_nullable_to_non_nullable +as String?,posterPath: freezed == posterPath ? _self.posterPath : posterPath // ignore: cast_nullable_to_non_nullable +as String?,releaseDate: freezed == releaseDate ? _self.releaseDate : releaseDate // ignore: cast_nullable_to_non_nullable +as String?,voteAverage: freezed == voteAverage ? _self.voteAverage : voteAverage // ignore: cast_nullable_to_non_nullable +as double?,mediaType: freezed == mediaType ? _self.mediaType : mediaType // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + + +} + + +/// @nodoc +mixin _$TmdbPersonCreditsRes { + + List get cast; +/// Create a copy of TmdbPersonCreditsRes +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$TmdbPersonCreditsResCopyWith get copyWith => _$TmdbPersonCreditsResCopyWithImpl(this as TmdbPersonCreditsRes, _$identity); + + /// Serializes this TmdbPersonCreditsRes to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is TmdbPersonCreditsRes&&const DeepCollectionEquality().equals(other.cast, cast)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(cast)); + +@override +String toString() { + return 'TmdbPersonCreditsRes(cast: $cast)'; +} + + +} + +/// @nodoc +abstract mixin class $TmdbPersonCreditsResCopyWith<$Res> { + factory $TmdbPersonCreditsResCopyWith(TmdbPersonCreditsRes value, $Res Function(TmdbPersonCreditsRes) _then) = _$TmdbPersonCreditsResCopyWithImpl; +@useResult +$Res call({ + List cast +}); + + + + +} +/// @nodoc +class _$TmdbPersonCreditsResCopyWithImpl<$Res> + implements $TmdbPersonCreditsResCopyWith<$Res> { + _$TmdbPersonCreditsResCopyWithImpl(this._self, this._then); + + final TmdbPersonCreditsRes _self; + final $Res Function(TmdbPersonCreditsRes) _then; + +/// Create a copy of TmdbPersonCreditsRes +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? cast = null,}) { + return _then(_self.copyWith( +cast: null == cast ? _self.cast : cast // ignore: cast_nullable_to_non_nullable +as List, + )); +} + +} + + +/// Adds pattern-matching-related methods to [TmdbPersonCreditsRes]. +extension TmdbPersonCreditsResPatterns on TmdbPersonCreditsRes { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _TmdbPersonCreditsRes value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _TmdbPersonCreditsRes() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _TmdbPersonCreditsRes value) $default,){ +final _that = this; +switch (_that) { +case _TmdbPersonCreditsRes(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _TmdbPersonCreditsRes value)? $default,){ +final _that = this; +switch (_that) { +case _TmdbPersonCreditsRes() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( List cast)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _TmdbPersonCreditsRes() when $default != null: +return $default(_that.cast);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( List cast) $default,) {final _that = this; +switch (_that) { +case _TmdbPersonCreditsRes(): +return $default(_that.cast);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( List cast)? $default,) {final _that = this; +switch (_that) { +case _TmdbPersonCreditsRes() when $default != null: +return $default(_that.cast);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _TmdbPersonCreditsRes implements TmdbPersonCreditsRes { + const _TmdbPersonCreditsRes({final List cast = const []}): _cast = cast; + factory _TmdbPersonCreditsRes.fromJson(Map json) => _$TmdbPersonCreditsResFromJson(json); + + final List _cast; +@override@JsonKey() List get cast { + if (_cast is EqualUnmodifiableListView) return _cast; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_cast); +} + + +/// Create a copy of TmdbPersonCreditsRes +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$TmdbPersonCreditsResCopyWith<_TmdbPersonCreditsRes> get copyWith => __$TmdbPersonCreditsResCopyWithImpl<_TmdbPersonCreditsRes>(this, _$identity); + +@override +Map toJson() { + return _$TmdbPersonCreditsResToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _TmdbPersonCreditsRes&&const DeepCollectionEquality().equals(other._cast, _cast)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_cast)); + +@override +String toString() { + return 'TmdbPersonCreditsRes(cast: $cast)'; +} + + +} + +/// @nodoc +abstract mixin class _$TmdbPersonCreditsResCopyWith<$Res> implements $TmdbPersonCreditsResCopyWith<$Res> { + factory _$TmdbPersonCreditsResCopyWith(_TmdbPersonCreditsRes value, $Res Function(_TmdbPersonCreditsRes) _then) = __$TmdbPersonCreditsResCopyWithImpl; +@override @useResult +$Res call({ + List cast +}); + + + + +} +/// @nodoc +class __$TmdbPersonCreditsResCopyWithImpl<$Res> + implements _$TmdbPersonCreditsResCopyWith<$Res> { + __$TmdbPersonCreditsResCopyWithImpl(this._self, this._then); + + final _TmdbPersonCreditsRes _self; + final $Res Function(_TmdbPersonCreditsRes) _then; + +/// Create a copy of TmdbPersonCreditsRes +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? cast = null,}) { + return _then(_TmdbPersonCreditsRes( +cast: null == cast ? _self._cast : cast // ignore: cast_nullable_to_non_nullable +as List, + )); +} + + +} + + +/// @nodoc +mixin _$TmdbGenre { + +@JsonKey(fromJson: _intRequired) int get id;@JsonKey(fromJson: _strOrEmpty) String get name; +/// Create a copy of TmdbGenre +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$TmdbGenreCopyWith get copyWith => _$TmdbGenreCopyWithImpl(this as TmdbGenre, _$identity); + + /// Serializes this TmdbGenre to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is TmdbGenre&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,name); + +@override +String toString() { + return 'TmdbGenre(id: $id, name: $name)'; +} + + +} + +/// @nodoc +abstract mixin class $TmdbGenreCopyWith<$Res> { + factory $TmdbGenreCopyWith(TmdbGenre value, $Res Function(TmdbGenre) _then) = _$TmdbGenreCopyWithImpl; +@useResult +$Res call({ +@JsonKey(fromJson: _intRequired) int id,@JsonKey(fromJson: _strOrEmpty) String name +}); + + + + +} +/// @nodoc +class _$TmdbGenreCopyWithImpl<$Res> + implements $TmdbGenreCopyWith<$Res> { + _$TmdbGenreCopyWithImpl(this._self, this._then); + + final TmdbGenre _self; + final $Res Function(TmdbGenre) _then; + +/// Create a copy of TmdbGenre +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? name = null,}) { + return _then(_self.copyWith( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as int,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String, + )); +} + +} + + +/// Adds pattern-matching-related methods to [TmdbGenre]. +extension TmdbGenrePatterns on TmdbGenre { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _TmdbGenre value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _TmdbGenre() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _TmdbGenre value) $default,){ +final _that = this; +switch (_that) { +case _TmdbGenre(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _TmdbGenre value)? $default,){ +final _that = this; +switch (_that) { +case _TmdbGenre() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function(@JsonKey(fromJson: _intRequired) int id, @JsonKey(fromJson: _strOrEmpty) String name)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _TmdbGenre() when $default != null: +return $default(_that.id,_that.name);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function(@JsonKey(fromJson: _intRequired) int id, @JsonKey(fromJson: _strOrEmpty) String name) $default,) {final _that = this; +switch (_that) { +case _TmdbGenre(): +return $default(_that.id,_that.name);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function(@JsonKey(fromJson: _intRequired) int id, @JsonKey(fromJson: _strOrEmpty) String name)? $default,) {final _that = this; +switch (_that) { +case _TmdbGenre() when $default != null: +return $default(_that.id,_that.name);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _TmdbGenre implements TmdbGenre { + const _TmdbGenre({@JsonKey(fromJson: _intRequired) required this.id, @JsonKey(fromJson: _strOrEmpty) this.name = ''}); + factory _TmdbGenre.fromJson(Map json) => _$TmdbGenreFromJson(json); + +@override@JsonKey(fromJson: _intRequired) final int id; +@override@JsonKey(fromJson: _strOrEmpty) final String name; + +/// Create a copy of TmdbGenre +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$TmdbGenreCopyWith<_TmdbGenre> get copyWith => __$TmdbGenreCopyWithImpl<_TmdbGenre>(this, _$identity); + +@override +Map toJson() { + return _$TmdbGenreToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _TmdbGenre&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,name); + +@override +String toString() { + return 'TmdbGenre(id: $id, name: $name)'; +} + + +} + +/// @nodoc +abstract mixin class _$TmdbGenreCopyWith<$Res> implements $TmdbGenreCopyWith<$Res> { + factory _$TmdbGenreCopyWith(_TmdbGenre value, $Res Function(_TmdbGenre) _then) = __$TmdbGenreCopyWithImpl; +@override @useResult +$Res call({ +@JsonKey(fromJson: _intRequired) int id,@JsonKey(fromJson: _strOrEmpty) String name +}); + + + + +} +/// @nodoc +class __$TmdbGenreCopyWithImpl<$Res> + implements _$TmdbGenreCopyWith<$Res> { + __$TmdbGenreCopyWithImpl(this._self, this._then); + + final _TmdbGenre _self; + final $Res Function(_TmdbGenre) _then; + +/// Create a copy of TmdbGenre +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? name = null,}) { + return _then(_TmdbGenre( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as int,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + + +/// @nodoc +mixin _$TmdbMediaDetail { + +@JsonKey(fromJson: _intRequired) int get id;@JsonKey(fromJson: _strOrEmpty) String get title;@JsonKey(name: 'poster_path') String? get posterPath;@JsonKey(name: 'backdrop_path') String? get backdropPath;@JsonKey(name: 'release_date') String? get releaseDate; List get genres;@JsonKey(fromJson: _intOrNull) int? get runtime; String? get tagline; String? get overview; String? get status; String? get homepage;@JsonKey(name: 'vote_average', fromJson: _doubleOrNull) double? get voteAverage;@JsonKey(name: 'vote_count', fromJson: _intOrNull) int? get voteCount;@JsonKey(fromJson: _intOrNull) int? get revenue;@JsonKey(fromJson: _intOrNull) int? get budget; +/// Create a copy of TmdbMediaDetail +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$TmdbMediaDetailCopyWith get copyWith => _$TmdbMediaDetailCopyWithImpl(this as TmdbMediaDetail, _$identity); + + /// Serializes this TmdbMediaDetail to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is TmdbMediaDetail&&(identical(other.id, id) || other.id == id)&&(identical(other.title, title) || other.title == title)&&(identical(other.posterPath, posterPath) || other.posterPath == posterPath)&&(identical(other.backdropPath, backdropPath) || other.backdropPath == backdropPath)&&(identical(other.releaseDate, releaseDate) || other.releaseDate == releaseDate)&&const DeepCollectionEquality().equals(other.genres, genres)&&(identical(other.runtime, runtime) || other.runtime == runtime)&&(identical(other.tagline, tagline) || other.tagline == tagline)&&(identical(other.overview, overview) || other.overview == overview)&&(identical(other.status, status) || other.status == status)&&(identical(other.homepage, homepage) || other.homepage == homepage)&&(identical(other.voteAverage, voteAverage) || other.voteAverage == voteAverage)&&(identical(other.voteCount, voteCount) || other.voteCount == voteCount)&&(identical(other.revenue, revenue) || other.revenue == revenue)&&(identical(other.budget, budget) || other.budget == budget)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,title,posterPath,backdropPath,releaseDate,const DeepCollectionEquality().hash(genres),runtime,tagline,overview,status,homepage,voteAverage,voteCount,revenue,budget); + +@override +String toString() { + return 'TmdbMediaDetail(id: $id, title: $title, posterPath: $posterPath, backdropPath: $backdropPath, releaseDate: $releaseDate, genres: $genres, runtime: $runtime, tagline: $tagline, overview: $overview, status: $status, homepage: $homepage, voteAverage: $voteAverage, voteCount: $voteCount, revenue: $revenue, budget: $budget)'; +} + + +} + +/// @nodoc +abstract mixin class $TmdbMediaDetailCopyWith<$Res> { + factory $TmdbMediaDetailCopyWith(TmdbMediaDetail value, $Res Function(TmdbMediaDetail) _then) = _$TmdbMediaDetailCopyWithImpl; +@useResult +$Res call({ +@JsonKey(fromJson: _intRequired) int id,@JsonKey(fromJson: _strOrEmpty) String title,@JsonKey(name: 'poster_path') String? posterPath,@JsonKey(name: 'backdrop_path') String? backdropPath,@JsonKey(name: 'release_date') String? releaseDate, List genres,@JsonKey(fromJson: _intOrNull) int? runtime, String? tagline, String? overview, String? status, String? homepage,@JsonKey(name: 'vote_average', fromJson: _doubleOrNull) double? voteAverage,@JsonKey(name: 'vote_count', fromJson: _intOrNull) int? voteCount,@JsonKey(fromJson: _intOrNull) int? revenue,@JsonKey(fromJson: _intOrNull) int? budget +}); + + + + +} +/// @nodoc +class _$TmdbMediaDetailCopyWithImpl<$Res> + implements $TmdbMediaDetailCopyWith<$Res> { + _$TmdbMediaDetailCopyWithImpl(this._self, this._then); + + final TmdbMediaDetail _self; + final $Res Function(TmdbMediaDetail) _then; + +/// Create a copy of TmdbMediaDetail +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? title = null,Object? posterPath = freezed,Object? backdropPath = freezed,Object? releaseDate = freezed,Object? genres = null,Object? runtime = freezed,Object? tagline = freezed,Object? overview = freezed,Object? status = freezed,Object? homepage = freezed,Object? voteAverage = freezed,Object? voteCount = freezed,Object? revenue = freezed,Object? budget = freezed,}) { + return _then(_self.copyWith( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as int,title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable +as String,posterPath: freezed == posterPath ? _self.posterPath : posterPath // ignore: cast_nullable_to_non_nullable +as String?,backdropPath: freezed == backdropPath ? _self.backdropPath : backdropPath // ignore: cast_nullable_to_non_nullable +as String?,releaseDate: freezed == releaseDate ? _self.releaseDate : releaseDate // ignore: cast_nullable_to_non_nullable +as String?,genres: null == genres ? _self.genres : genres // ignore: cast_nullable_to_non_nullable +as List,runtime: freezed == runtime ? _self.runtime : runtime // ignore: cast_nullable_to_non_nullable +as int?,tagline: freezed == tagline ? _self.tagline : tagline // ignore: cast_nullable_to_non_nullable +as String?,overview: freezed == overview ? _self.overview : overview // ignore: cast_nullable_to_non_nullable +as String?,status: freezed == status ? _self.status : status // ignore: cast_nullable_to_non_nullable +as String?,homepage: freezed == homepage ? _self.homepage : homepage // ignore: cast_nullable_to_non_nullable +as String?,voteAverage: freezed == voteAverage ? _self.voteAverage : voteAverage // ignore: cast_nullable_to_non_nullable +as double?,voteCount: freezed == voteCount ? _self.voteCount : voteCount // ignore: cast_nullable_to_non_nullable +as int?,revenue: freezed == revenue ? _self.revenue : revenue // ignore: cast_nullable_to_non_nullable +as int?,budget: freezed == budget ? _self.budget : budget // ignore: cast_nullable_to_non_nullable +as int?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [TmdbMediaDetail]. +extension TmdbMediaDetailPatterns on TmdbMediaDetail { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _TmdbMediaDetail value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _TmdbMediaDetail() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _TmdbMediaDetail value) $default,){ +final _that = this; +switch (_that) { +case _TmdbMediaDetail(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _TmdbMediaDetail value)? $default,){ +final _that = this; +switch (_that) { +case _TmdbMediaDetail() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function(@JsonKey(fromJson: _intRequired) int id, @JsonKey(fromJson: _strOrEmpty) String title, @JsonKey(name: 'poster_path') String? posterPath, @JsonKey(name: 'backdrop_path') String? backdropPath, @JsonKey(name: 'release_date') String? releaseDate, List genres, @JsonKey(fromJson: _intOrNull) int? runtime, String? tagline, String? overview, String? status, String? homepage, @JsonKey(name: 'vote_average', fromJson: _doubleOrNull) double? voteAverage, @JsonKey(name: 'vote_count', fromJson: _intOrNull) int? voteCount, @JsonKey(fromJson: _intOrNull) int? revenue, @JsonKey(fromJson: _intOrNull) int? budget)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _TmdbMediaDetail() when $default != null: +return $default(_that.id,_that.title,_that.posterPath,_that.backdropPath,_that.releaseDate,_that.genres,_that.runtime,_that.tagline,_that.overview,_that.status,_that.homepage,_that.voteAverage,_that.voteCount,_that.revenue,_that.budget);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function(@JsonKey(fromJson: _intRequired) int id, @JsonKey(fromJson: _strOrEmpty) String title, @JsonKey(name: 'poster_path') String? posterPath, @JsonKey(name: 'backdrop_path') String? backdropPath, @JsonKey(name: 'release_date') String? releaseDate, List genres, @JsonKey(fromJson: _intOrNull) int? runtime, String? tagline, String? overview, String? status, String? homepage, @JsonKey(name: 'vote_average', fromJson: _doubleOrNull) double? voteAverage, @JsonKey(name: 'vote_count', fromJson: _intOrNull) int? voteCount, @JsonKey(fromJson: _intOrNull) int? revenue, @JsonKey(fromJson: _intOrNull) int? budget) $default,) {final _that = this; +switch (_that) { +case _TmdbMediaDetail(): +return $default(_that.id,_that.title,_that.posterPath,_that.backdropPath,_that.releaseDate,_that.genres,_that.runtime,_that.tagline,_that.overview,_that.status,_that.homepage,_that.voteAverage,_that.voteCount,_that.revenue,_that.budget);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function(@JsonKey(fromJson: _intRequired) int id, @JsonKey(fromJson: _strOrEmpty) String title, @JsonKey(name: 'poster_path') String? posterPath, @JsonKey(name: 'backdrop_path') String? backdropPath, @JsonKey(name: 'release_date') String? releaseDate, List genres, @JsonKey(fromJson: _intOrNull) int? runtime, String? tagline, String? overview, String? status, String? homepage, @JsonKey(name: 'vote_average', fromJson: _doubleOrNull) double? voteAverage, @JsonKey(name: 'vote_count', fromJson: _intOrNull) int? voteCount, @JsonKey(fromJson: _intOrNull) int? revenue, @JsonKey(fromJson: _intOrNull) int? budget)? $default,) {final _that = this; +switch (_that) { +case _TmdbMediaDetail() when $default != null: +return $default(_that.id,_that.title,_that.posterPath,_that.backdropPath,_that.releaseDate,_that.genres,_that.runtime,_that.tagline,_that.overview,_that.status,_that.homepage,_that.voteAverage,_that.voteCount,_that.revenue,_that.budget);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _TmdbMediaDetail implements TmdbMediaDetail { + const _TmdbMediaDetail({@JsonKey(fromJson: _intRequired) required this.id, @JsonKey(fromJson: _strOrEmpty) this.title = '', @JsonKey(name: 'poster_path') this.posterPath, @JsonKey(name: 'backdrop_path') this.backdropPath, @JsonKey(name: 'release_date') this.releaseDate, final List genres = const [], @JsonKey(fromJson: _intOrNull) this.runtime, this.tagline, this.overview, this.status, this.homepage, @JsonKey(name: 'vote_average', fromJson: _doubleOrNull) this.voteAverage, @JsonKey(name: 'vote_count', fromJson: _intOrNull) this.voteCount, @JsonKey(fromJson: _intOrNull) this.revenue, @JsonKey(fromJson: _intOrNull) this.budget}): _genres = genres; + factory _TmdbMediaDetail.fromJson(Map json) => _$TmdbMediaDetailFromJson(json); + +@override@JsonKey(fromJson: _intRequired) final int id; +@override@JsonKey(fromJson: _strOrEmpty) final String title; +@override@JsonKey(name: 'poster_path') final String? posterPath; +@override@JsonKey(name: 'backdrop_path') final String? backdropPath; +@override@JsonKey(name: 'release_date') final String? releaseDate; + final List _genres; +@override@JsonKey() List get genres { + if (_genres is EqualUnmodifiableListView) return _genres; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_genres); +} + +@override@JsonKey(fromJson: _intOrNull) final int? runtime; +@override final String? tagline; +@override final String? overview; +@override final String? status; +@override final String? homepage; +@override@JsonKey(name: 'vote_average', fromJson: _doubleOrNull) final double? voteAverage; +@override@JsonKey(name: 'vote_count', fromJson: _intOrNull) final int? voteCount; +@override@JsonKey(fromJson: _intOrNull) final int? revenue; +@override@JsonKey(fromJson: _intOrNull) final int? budget; + +/// Create a copy of TmdbMediaDetail +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$TmdbMediaDetailCopyWith<_TmdbMediaDetail> get copyWith => __$TmdbMediaDetailCopyWithImpl<_TmdbMediaDetail>(this, _$identity); + +@override +Map toJson() { + return _$TmdbMediaDetailToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _TmdbMediaDetail&&(identical(other.id, id) || other.id == id)&&(identical(other.title, title) || other.title == title)&&(identical(other.posterPath, posterPath) || other.posterPath == posterPath)&&(identical(other.backdropPath, backdropPath) || other.backdropPath == backdropPath)&&(identical(other.releaseDate, releaseDate) || other.releaseDate == releaseDate)&&const DeepCollectionEquality().equals(other._genres, _genres)&&(identical(other.runtime, runtime) || other.runtime == runtime)&&(identical(other.tagline, tagline) || other.tagline == tagline)&&(identical(other.overview, overview) || other.overview == overview)&&(identical(other.status, status) || other.status == status)&&(identical(other.homepage, homepage) || other.homepage == homepage)&&(identical(other.voteAverage, voteAverage) || other.voteAverage == voteAverage)&&(identical(other.voteCount, voteCount) || other.voteCount == voteCount)&&(identical(other.revenue, revenue) || other.revenue == revenue)&&(identical(other.budget, budget) || other.budget == budget)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,title,posterPath,backdropPath,releaseDate,const DeepCollectionEquality().hash(_genres),runtime,tagline,overview,status,homepage,voteAverage,voteCount,revenue,budget); + +@override +String toString() { + return 'TmdbMediaDetail(id: $id, title: $title, posterPath: $posterPath, backdropPath: $backdropPath, releaseDate: $releaseDate, genres: $genres, runtime: $runtime, tagline: $tagline, overview: $overview, status: $status, homepage: $homepage, voteAverage: $voteAverage, voteCount: $voteCount, revenue: $revenue, budget: $budget)'; +} + + +} + +/// @nodoc +abstract mixin class _$TmdbMediaDetailCopyWith<$Res> implements $TmdbMediaDetailCopyWith<$Res> { + factory _$TmdbMediaDetailCopyWith(_TmdbMediaDetail value, $Res Function(_TmdbMediaDetail) _then) = __$TmdbMediaDetailCopyWithImpl; +@override @useResult +$Res call({ +@JsonKey(fromJson: _intRequired) int id,@JsonKey(fromJson: _strOrEmpty) String title,@JsonKey(name: 'poster_path') String? posterPath,@JsonKey(name: 'backdrop_path') String? backdropPath,@JsonKey(name: 'release_date') String? releaseDate, List genres,@JsonKey(fromJson: _intOrNull) int? runtime, String? tagline, String? overview, String? status, String? homepage,@JsonKey(name: 'vote_average', fromJson: _doubleOrNull) double? voteAverage,@JsonKey(name: 'vote_count', fromJson: _intOrNull) int? voteCount,@JsonKey(fromJson: _intOrNull) int? revenue,@JsonKey(fromJson: _intOrNull) int? budget +}); + + + + +} +/// @nodoc +class __$TmdbMediaDetailCopyWithImpl<$Res> + implements _$TmdbMediaDetailCopyWith<$Res> { + __$TmdbMediaDetailCopyWithImpl(this._self, this._then); + + final _TmdbMediaDetail _self; + final $Res Function(_TmdbMediaDetail) _then; + +/// Create a copy of TmdbMediaDetail +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? title = null,Object? posterPath = freezed,Object? backdropPath = freezed,Object? releaseDate = freezed,Object? genres = null,Object? runtime = freezed,Object? tagline = freezed,Object? overview = freezed,Object? status = freezed,Object? homepage = freezed,Object? voteAverage = freezed,Object? voteCount = freezed,Object? revenue = freezed,Object? budget = freezed,}) { + return _then(_TmdbMediaDetail( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as int,title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable +as String,posterPath: freezed == posterPath ? _self.posterPath : posterPath // ignore: cast_nullable_to_non_nullable +as String?,backdropPath: freezed == backdropPath ? _self.backdropPath : backdropPath // ignore: cast_nullable_to_non_nullable +as String?,releaseDate: freezed == releaseDate ? _self.releaseDate : releaseDate // ignore: cast_nullable_to_non_nullable +as String?,genres: null == genres ? _self._genres : genres // ignore: cast_nullable_to_non_nullable +as List,runtime: freezed == runtime ? _self.runtime : runtime // ignore: cast_nullable_to_non_nullable +as int?,tagline: freezed == tagline ? _self.tagline : tagline // ignore: cast_nullable_to_non_nullable +as String?,overview: freezed == overview ? _self.overview : overview // ignore: cast_nullable_to_non_nullable +as String?,status: freezed == status ? _self.status : status // ignore: cast_nullable_to_non_nullable +as String?,homepage: freezed == homepage ? _self.homepage : homepage // ignore: cast_nullable_to_non_nullable +as String?,voteAverage: freezed == voteAverage ? _self.voteAverage : voteAverage // ignore: cast_nullable_to_non_nullable +as double?,voteCount: freezed == voteCount ? _self.voteCount : voteCount // ignore: cast_nullable_to_non_nullable +as int?,revenue: freezed == revenue ? _self.revenue : revenue // ignore: cast_nullable_to_non_nullable +as int?,budget: freezed == budget ? _self.budget : budget // ignore: cast_nullable_to_non_nullable +as int?, + )); +} + + +} + + +/// @nodoc +mixin _$TmdbImage { + +@JsonKey(name: 'file_path', fromJson: _strOrEmpty) String get filePath;@JsonKey(fromJson: _intOrZero) int get width;@JsonKey(fromJson: _intOrZero) int get height;@JsonKey(name: 'vote_average', fromJson: _doubleOrNull) double? get voteAverage;@JsonKey(name: 'iso_639_1') String? get iso639; +/// Create a copy of TmdbImage +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$TmdbImageCopyWith get copyWith => _$TmdbImageCopyWithImpl(this as TmdbImage, _$identity); + + /// Serializes this TmdbImage to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is TmdbImage&&(identical(other.filePath, filePath) || other.filePath == filePath)&&(identical(other.width, width) || other.width == width)&&(identical(other.height, height) || other.height == height)&&(identical(other.voteAverage, voteAverage) || other.voteAverage == voteAverage)&&(identical(other.iso639, iso639) || other.iso639 == iso639)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,filePath,width,height,voteAverage,iso639); + +@override +String toString() { + return 'TmdbImage(filePath: $filePath, width: $width, height: $height, voteAverage: $voteAverage, iso639: $iso639)'; +} + + +} + +/// @nodoc +abstract mixin class $TmdbImageCopyWith<$Res> { + factory $TmdbImageCopyWith(TmdbImage value, $Res Function(TmdbImage) _then) = _$TmdbImageCopyWithImpl; +@useResult +$Res call({ +@JsonKey(name: 'file_path', fromJson: _strOrEmpty) String filePath,@JsonKey(fromJson: _intOrZero) int width,@JsonKey(fromJson: _intOrZero) int height,@JsonKey(name: 'vote_average', fromJson: _doubleOrNull) double? voteAverage,@JsonKey(name: 'iso_639_1') String? iso639 +}); + + + + +} +/// @nodoc +class _$TmdbImageCopyWithImpl<$Res> + implements $TmdbImageCopyWith<$Res> { + _$TmdbImageCopyWithImpl(this._self, this._then); + + final TmdbImage _self; + final $Res Function(TmdbImage) _then; + +/// Create a copy of TmdbImage +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? filePath = null,Object? width = null,Object? height = null,Object? voteAverage = freezed,Object? iso639 = freezed,}) { + return _then(_self.copyWith( +filePath: null == filePath ? _self.filePath : filePath // ignore: cast_nullable_to_non_nullable +as String,width: null == width ? _self.width : width // ignore: cast_nullable_to_non_nullable +as int,height: null == height ? _self.height : height // ignore: cast_nullable_to_non_nullable +as int,voteAverage: freezed == voteAverage ? _self.voteAverage : voteAverage // ignore: cast_nullable_to_non_nullable +as double?,iso639: freezed == iso639 ? _self.iso639 : iso639 // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [TmdbImage]. +extension TmdbImagePatterns on TmdbImage { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _TmdbImage value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _TmdbImage() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _TmdbImage value) $default,){ +final _that = this; +switch (_that) { +case _TmdbImage(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _TmdbImage value)? $default,){ +final _that = this; +switch (_that) { +case _TmdbImage() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function(@JsonKey(name: 'file_path', fromJson: _strOrEmpty) String filePath, @JsonKey(fromJson: _intOrZero) int width, @JsonKey(fromJson: _intOrZero) int height, @JsonKey(name: 'vote_average', fromJson: _doubleOrNull) double? voteAverage, @JsonKey(name: 'iso_639_1') String? iso639)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _TmdbImage() when $default != null: +return $default(_that.filePath,_that.width,_that.height,_that.voteAverage,_that.iso639);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function(@JsonKey(name: 'file_path', fromJson: _strOrEmpty) String filePath, @JsonKey(fromJson: _intOrZero) int width, @JsonKey(fromJson: _intOrZero) int height, @JsonKey(name: 'vote_average', fromJson: _doubleOrNull) double? voteAverage, @JsonKey(name: 'iso_639_1') String? iso639) $default,) {final _that = this; +switch (_that) { +case _TmdbImage(): +return $default(_that.filePath,_that.width,_that.height,_that.voteAverage,_that.iso639);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function(@JsonKey(name: 'file_path', fromJson: _strOrEmpty) String filePath, @JsonKey(fromJson: _intOrZero) int width, @JsonKey(fromJson: _intOrZero) int height, @JsonKey(name: 'vote_average', fromJson: _doubleOrNull) double? voteAverage, @JsonKey(name: 'iso_639_1') String? iso639)? $default,) {final _that = this; +switch (_that) { +case _TmdbImage() when $default != null: +return $default(_that.filePath,_that.width,_that.height,_that.voteAverage,_that.iso639);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _TmdbImage extends TmdbImage { + const _TmdbImage({@JsonKey(name: 'file_path', fromJson: _strOrEmpty) this.filePath = '', @JsonKey(fromJson: _intOrZero) this.width = 0, @JsonKey(fromJson: _intOrZero) this.height = 0, @JsonKey(name: 'vote_average', fromJson: _doubleOrNull) this.voteAverage, @JsonKey(name: 'iso_639_1') this.iso639}): super._(); + factory _TmdbImage.fromJson(Map json) => _$TmdbImageFromJson(json); + +@override@JsonKey(name: 'file_path', fromJson: _strOrEmpty) final String filePath; +@override@JsonKey(fromJson: _intOrZero) final int width; +@override@JsonKey(fromJson: _intOrZero) final int height; +@override@JsonKey(name: 'vote_average', fromJson: _doubleOrNull) final double? voteAverage; +@override@JsonKey(name: 'iso_639_1') final String? iso639; + +/// Create a copy of TmdbImage +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$TmdbImageCopyWith<_TmdbImage> get copyWith => __$TmdbImageCopyWithImpl<_TmdbImage>(this, _$identity); + +@override +Map toJson() { + return _$TmdbImageToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _TmdbImage&&(identical(other.filePath, filePath) || other.filePath == filePath)&&(identical(other.width, width) || other.width == width)&&(identical(other.height, height) || other.height == height)&&(identical(other.voteAverage, voteAverage) || other.voteAverage == voteAverage)&&(identical(other.iso639, iso639) || other.iso639 == iso639)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,filePath,width,height,voteAverage,iso639); + +@override +String toString() { + return 'TmdbImage(filePath: $filePath, width: $width, height: $height, voteAverage: $voteAverage, iso639: $iso639)'; +} + + +} + +/// @nodoc +abstract mixin class _$TmdbImageCopyWith<$Res> implements $TmdbImageCopyWith<$Res> { + factory _$TmdbImageCopyWith(_TmdbImage value, $Res Function(_TmdbImage) _then) = __$TmdbImageCopyWithImpl; +@override @useResult +$Res call({ +@JsonKey(name: 'file_path', fromJson: _strOrEmpty) String filePath,@JsonKey(fromJson: _intOrZero) int width,@JsonKey(fromJson: _intOrZero) int height,@JsonKey(name: 'vote_average', fromJson: _doubleOrNull) double? voteAverage,@JsonKey(name: 'iso_639_1') String? iso639 +}); + + + + +} +/// @nodoc +class __$TmdbImageCopyWithImpl<$Res> + implements _$TmdbImageCopyWith<$Res> { + __$TmdbImageCopyWithImpl(this._self, this._then); + + final _TmdbImage _self; + final $Res Function(_TmdbImage) _then; + +/// Create a copy of TmdbImage +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? filePath = null,Object? width = null,Object? height = null,Object? voteAverage = freezed,Object? iso639 = freezed,}) { + return _then(_TmdbImage( +filePath: null == filePath ? _self.filePath : filePath // ignore: cast_nullable_to_non_nullable +as String,width: null == width ? _self.width : width // ignore: cast_nullable_to_non_nullable +as int,height: null == height ? _self.height : height // ignore: cast_nullable_to_non_nullable +as int,voteAverage: freezed == voteAverage ? _self.voteAverage : voteAverage // ignore: cast_nullable_to_non_nullable +as double?,iso639: freezed == iso639 ? _self.iso639 : iso639 // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + + +} + + +/// @nodoc +mixin _$TmdbImageSet { + + List get backdrops; List get posters; List get logos; +/// Create a copy of TmdbImageSet +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$TmdbImageSetCopyWith get copyWith => _$TmdbImageSetCopyWithImpl(this as TmdbImageSet, _$identity); + + /// Serializes this TmdbImageSet to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is TmdbImageSet&&const DeepCollectionEquality().equals(other.backdrops, backdrops)&&const DeepCollectionEquality().equals(other.posters, posters)&&const DeepCollectionEquality().equals(other.logos, logos)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(backdrops),const DeepCollectionEquality().hash(posters),const DeepCollectionEquality().hash(logos)); + +@override +String toString() { + return 'TmdbImageSet(backdrops: $backdrops, posters: $posters, logos: $logos)'; +} + + +} + +/// @nodoc +abstract mixin class $TmdbImageSetCopyWith<$Res> { + factory $TmdbImageSetCopyWith(TmdbImageSet value, $Res Function(TmdbImageSet) _then) = _$TmdbImageSetCopyWithImpl; +@useResult +$Res call({ + List backdrops, List posters, List logos +}); + + + + +} +/// @nodoc +class _$TmdbImageSetCopyWithImpl<$Res> + implements $TmdbImageSetCopyWith<$Res> { + _$TmdbImageSetCopyWithImpl(this._self, this._then); + + final TmdbImageSet _self; + final $Res Function(TmdbImageSet) _then; + +/// Create a copy of TmdbImageSet +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? backdrops = null,Object? posters = null,Object? logos = null,}) { + return _then(_self.copyWith( +backdrops: null == backdrops ? _self.backdrops : backdrops // ignore: cast_nullable_to_non_nullable +as List,posters: null == posters ? _self.posters : posters // ignore: cast_nullable_to_non_nullable +as List,logos: null == logos ? _self.logos : logos // ignore: cast_nullable_to_non_nullable +as List, + )); +} + +} + + +/// Adds pattern-matching-related methods to [TmdbImageSet]. +extension TmdbImageSetPatterns on TmdbImageSet { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _TmdbImageSet value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _TmdbImageSet() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _TmdbImageSet value) $default,){ +final _that = this; +switch (_that) { +case _TmdbImageSet(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _TmdbImageSet value)? $default,){ +final _that = this; +switch (_that) { +case _TmdbImageSet() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( List backdrops, List posters, List logos)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _TmdbImageSet() when $default != null: +return $default(_that.backdrops,_that.posters,_that.logos);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( List backdrops, List posters, List logos) $default,) {final _that = this; +switch (_that) { +case _TmdbImageSet(): +return $default(_that.backdrops,_that.posters,_that.logos);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( List backdrops, List posters, List logos)? $default,) {final _that = this; +switch (_that) { +case _TmdbImageSet() when $default != null: +return $default(_that.backdrops,_that.posters,_that.logos);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _TmdbImageSet implements TmdbImageSet { + const _TmdbImageSet({final List backdrops = const [], final List posters = const [], final List logos = const []}): _backdrops = backdrops,_posters = posters,_logos = logos; + factory _TmdbImageSet.fromJson(Map json) => _$TmdbImageSetFromJson(json); + + final List _backdrops; +@override@JsonKey() List get backdrops { + if (_backdrops is EqualUnmodifiableListView) return _backdrops; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_backdrops); +} + + final List _posters; +@override@JsonKey() List get posters { + if (_posters is EqualUnmodifiableListView) return _posters; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_posters); +} + + final List _logos; +@override@JsonKey() List get logos { + if (_logos is EqualUnmodifiableListView) return _logos; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_logos); +} + + +/// Create a copy of TmdbImageSet +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$TmdbImageSetCopyWith<_TmdbImageSet> get copyWith => __$TmdbImageSetCopyWithImpl<_TmdbImageSet>(this, _$identity); + +@override +Map toJson() { + return _$TmdbImageSetToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _TmdbImageSet&&const DeepCollectionEquality().equals(other._backdrops, _backdrops)&&const DeepCollectionEquality().equals(other._posters, _posters)&&const DeepCollectionEquality().equals(other._logos, _logos)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_backdrops),const DeepCollectionEquality().hash(_posters),const DeepCollectionEquality().hash(_logos)); + +@override +String toString() { + return 'TmdbImageSet(backdrops: $backdrops, posters: $posters, logos: $logos)'; +} + + +} + +/// @nodoc +abstract mixin class _$TmdbImageSetCopyWith<$Res> implements $TmdbImageSetCopyWith<$Res> { + factory _$TmdbImageSetCopyWith(_TmdbImageSet value, $Res Function(_TmdbImageSet) _then) = __$TmdbImageSetCopyWithImpl; +@override @useResult +$Res call({ + List backdrops, List posters, List logos +}); + + + + +} +/// @nodoc +class __$TmdbImageSetCopyWithImpl<$Res> + implements _$TmdbImageSetCopyWith<$Res> { + __$TmdbImageSetCopyWithImpl(this._self, this._then); + + final _TmdbImageSet _self; + final $Res Function(_TmdbImageSet) _then; + +/// Create a copy of TmdbImageSet +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? backdrops = null,Object? posters = null,Object? logos = null,}) { + return _then(_TmdbImageSet( +backdrops: null == backdrops ? _self._backdrops : backdrops // ignore: cast_nullable_to_non_nullable +as List,posters: null == posters ? _self._posters : posters // ignore: cast_nullable_to_non_nullable +as List,logos: null == logos ? _self._logos : logos // ignore: cast_nullable_to_non_nullable +as List, + )); +} + + +} + + +/// @nodoc +mixin _$TmdbNetwork { + +@JsonKey(fromJson: _intRequired) int get id;@JsonKey(fromJson: _strOrEmpty) String get name;@JsonKey(name: 'logo_path') String? get logoPath;@JsonKey(name: 'origin_country') String? get originCountry; +/// Create a copy of TmdbNetwork +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$TmdbNetworkCopyWith get copyWith => _$TmdbNetworkCopyWithImpl(this as TmdbNetwork, _$identity); + + /// Serializes this TmdbNetwork to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is TmdbNetwork&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.logoPath, logoPath) || other.logoPath == logoPath)&&(identical(other.originCountry, originCountry) || other.originCountry == originCountry)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,name,logoPath,originCountry); + +@override +String toString() { + return 'TmdbNetwork(id: $id, name: $name, logoPath: $logoPath, originCountry: $originCountry)'; +} + + +} + +/// @nodoc +abstract mixin class $TmdbNetworkCopyWith<$Res> { + factory $TmdbNetworkCopyWith(TmdbNetwork value, $Res Function(TmdbNetwork) _then) = _$TmdbNetworkCopyWithImpl; +@useResult +$Res call({ +@JsonKey(fromJson: _intRequired) int id,@JsonKey(fromJson: _strOrEmpty) String name,@JsonKey(name: 'logo_path') String? logoPath,@JsonKey(name: 'origin_country') String? originCountry +}); + + + + +} +/// @nodoc +class _$TmdbNetworkCopyWithImpl<$Res> + implements $TmdbNetworkCopyWith<$Res> { + _$TmdbNetworkCopyWithImpl(this._self, this._then); + + final TmdbNetwork _self; + final $Res Function(TmdbNetwork) _then; + +/// Create a copy of TmdbNetwork +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? name = null,Object? logoPath = freezed,Object? originCountry = freezed,}) { + return _then(_self.copyWith( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as int,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,logoPath: freezed == logoPath ? _self.logoPath : logoPath // ignore: cast_nullable_to_non_nullable +as String?,originCountry: freezed == originCountry ? _self.originCountry : originCountry // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [TmdbNetwork]. +extension TmdbNetworkPatterns on TmdbNetwork { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _TmdbNetwork value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _TmdbNetwork() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _TmdbNetwork value) $default,){ +final _that = this; +switch (_that) { +case _TmdbNetwork(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _TmdbNetwork value)? $default,){ +final _that = this; +switch (_that) { +case _TmdbNetwork() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function(@JsonKey(fromJson: _intRequired) int id, @JsonKey(fromJson: _strOrEmpty) String name, @JsonKey(name: 'logo_path') String? logoPath, @JsonKey(name: 'origin_country') String? originCountry)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _TmdbNetwork() when $default != null: +return $default(_that.id,_that.name,_that.logoPath,_that.originCountry);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function(@JsonKey(fromJson: _intRequired) int id, @JsonKey(fromJson: _strOrEmpty) String name, @JsonKey(name: 'logo_path') String? logoPath, @JsonKey(name: 'origin_country') String? originCountry) $default,) {final _that = this; +switch (_that) { +case _TmdbNetwork(): +return $default(_that.id,_that.name,_that.logoPath,_that.originCountry);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function(@JsonKey(fromJson: _intRequired) int id, @JsonKey(fromJson: _strOrEmpty) String name, @JsonKey(name: 'logo_path') String? logoPath, @JsonKey(name: 'origin_country') String? originCountry)? $default,) {final _that = this; +switch (_that) { +case _TmdbNetwork() when $default != null: +return $default(_that.id,_that.name,_that.logoPath,_that.originCountry);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _TmdbNetwork implements TmdbNetwork { + const _TmdbNetwork({@JsonKey(fromJson: _intRequired) required this.id, @JsonKey(fromJson: _strOrEmpty) this.name = '', @JsonKey(name: 'logo_path') this.logoPath, @JsonKey(name: 'origin_country') this.originCountry}); + factory _TmdbNetwork.fromJson(Map json) => _$TmdbNetworkFromJson(json); + +@override@JsonKey(fromJson: _intRequired) final int id; +@override@JsonKey(fromJson: _strOrEmpty) final String name; +@override@JsonKey(name: 'logo_path') final String? logoPath; +@override@JsonKey(name: 'origin_country') final String? originCountry; + +/// Create a copy of TmdbNetwork +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$TmdbNetworkCopyWith<_TmdbNetwork> get copyWith => __$TmdbNetworkCopyWithImpl<_TmdbNetwork>(this, _$identity); + +@override +Map toJson() { + return _$TmdbNetworkToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _TmdbNetwork&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.logoPath, logoPath) || other.logoPath == logoPath)&&(identical(other.originCountry, originCountry) || other.originCountry == originCountry)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,name,logoPath,originCountry); + +@override +String toString() { + return 'TmdbNetwork(id: $id, name: $name, logoPath: $logoPath, originCountry: $originCountry)'; +} + + +} + +/// @nodoc +abstract mixin class _$TmdbNetworkCopyWith<$Res> implements $TmdbNetworkCopyWith<$Res> { + factory _$TmdbNetworkCopyWith(_TmdbNetwork value, $Res Function(_TmdbNetwork) _then) = __$TmdbNetworkCopyWithImpl; +@override @useResult +$Res call({ +@JsonKey(fromJson: _intRequired) int id,@JsonKey(fromJson: _strOrEmpty) String name,@JsonKey(name: 'logo_path') String? logoPath,@JsonKey(name: 'origin_country') String? originCountry +}); + + + + +} +/// @nodoc +class __$TmdbNetworkCopyWithImpl<$Res> + implements _$TmdbNetworkCopyWith<$Res> { + __$TmdbNetworkCopyWithImpl(this._self, this._then); + + final _TmdbNetwork _self; + final $Res Function(_TmdbNetwork) _then; + +/// Create a copy of TmdbNetwork +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? name = null,Object? logoPath = freezed,Object? originCountry = freezed,}) { + return _then(_TmdbNetwork( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as int,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,logoPath: freezed == logoPath ? _self.logoPath : logoPath // ignore: cast_nullable_to_non_nullable +as String?,originCountry: freezed == originCountry ? _self.originCountry : originCountry // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + + +} + + +/// @nodoc +mixin _$TmdbEnrichedDetail { + + TmdbMediaDetail get detail; TmdbVideosRes? get videos; TmdbCreditsRes? get credits; TmdbRecommendationsRes? get recommendations; TmdbImageSet? get images;/// imdb id(如 `tt0137523`),来自 `append_to_response=external_ids` 的 +/// `external_ids.imdb_id`。仅作库内反查的 movie 兜底用(见 R1),TMDB 未刮削 +/// 或为空字符串时为 null。 + String? get imdbId; List get networks; +/// Create a copy of TmdbEnrichedDetail +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$TmdbEnrichedDetailCopyWith get copyWith => _$TmdbEnrichedDetailCopyWithImpl(this as TmdbEnrichedDetail, _$identity); + + /// Serializes this TmdbEnrichedDetail to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is TmdbEnrichedDetail&&(identical(other.detail, detail) || other.detail == detail)&&(identical(other.videos, videos) || other.videos == videos)&&(identical(other.credits, credits) || other.credits == credits)&&(identical(other.recommendations, recommendations) || other.recommendations == recommendations)&&(identical(other.images, images) || other.images == images)&&(identical(other.imdbId, imdbId) || other.imdbId == imdbId)&&const DeepCollectionEquality().equals(other.networks, networks)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,detail,videos,credits,recommendations,images,imdbId,const DeepCollectionEquality().hash(networks)); + +@override +String toString() { + return 'TmdbEnrichedDetail(detail: $detail, videos: $videos, credits: $credits, recommendations: $recommendations, images: $images, imdbId: $imdbId, networks: $networks)'; +} + + +} + +/// @nodoc +abstract mixin class $TmdbEnrichedDetailCopyWith<$Res> { + factory $TmdbEnrichedDetailCopyWith(TmdbEnrichedDetail value, $Res Function(TmdbEnrichedDetail) _then) = _$TmdbEnrichedDetailCopyWithImpl; +@useResult +$Res call({ + TmdbMediaDetail detail, TmdbVideosRes? videos, TmdbCreditsRes? credits, TmdbRecommendationsRes? recommendations, TmdbImageSet? images, String? imdbId, List networks +}); + + +$TmdbMediaDetailCopyWith<$Res> get detail;$TmdbVideosResCopyWith<$Res>? get videos;$TmdbCreditsResCopyWith<$Res>? get credits;$TmdbRecommendationsResCopyWith<$Res>? get recommendations;$TmdbImageSetCopyWith<$Res>? get images; + +} +/// @nodoc +class _$TmdbEnrichedDetailCopyWithImpl<$Res> + implements $TmdbEnrichedDetailCopyWith<$Res> { + _$TmdbEnrichedDetailCopyWithImpl(this._self, this._then); + + final TmdbEnrichedDetail _self; + final $Res Function(TmdbEnrichedDetail) _then; + +/// Create a copy of TmdbEnrichedDetail +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? detail = null,Object? videos = freezed,Object? credits = freezed,Object? recommendations = freezed,Object? images = freezed,Object? imdbId = freezed,Object? networks = null,}) { + return _then(_self.copyWith( +detail: null == detail ? _self.detail : detail // ignore: cast_nullable_to_non_nullable +as TmdbMediaDetail,videos: freezed == videos ? _self.videos : videos // ignore: cast_nullable_to_non_nullable +as TmdbVideosRes?,credits: freezed == credits ? _self.credits : credits // ignore: cast_nullable_to_non_nullable +as TmdbCreditsRes?,recommendations: freezed == recommendations ? _self.recommendations : recommendations // ignore: cast_nullable_to_non_nullable +as TmdbRecommendationsRes?,images: freezed == images ? _self.images : images // ignore: cast_nullable_to_non_nullable +as TmdbImageSet?,imdbId: freezed == imdbId ? _self.imdbId : imdbId // ignore: cast_nullable_to_non_nullable +as String?,networks: null == networks ? _self.networks : networks // ignore: cast_nullable_to_non_nullable +as List, + )); +} +/// Create a copy of TmdbEnrichedDetail +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$TmdbMediaDetailCopyWith<$Res> get detail { + + return $TmdbMediaDetailCopyWith<$Res>(_self.detail, (value) { + return _then(_self.copyWith(detail: value)); + }); +}/// Create a copy of TmdbEnrichedDetail +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$TmdbVideosResCopyWith<$Res>? get videos { + if (_self.videos == null) { + return null; + } + + return $TmdbVideosResCopyWith<$Res>(_self.videos!, (value) { + return _then(_self.copyWith(videos: value)); + }); +}/// Create a copy of TmdbEnrichedDetail +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$TmdbCreditsResCopyWith<$Res>? get credits { + if (_self.credits == null) { + return null; + } + + return $TmdbCreditsResCopyWith<$Res>(_self.credits!, (value) { + return _then(_self.copyWith(credits: value)); + }); +}/// Create a copy of TmdbEnrichedDetail +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$TmdbRecommendationsResCopyWith<$Res>? get recommendations { + if (_self.recommendations == null) { + return null; + } + + return $TmdbRecommendationsResCopyWith<$Res>(_self.recommendations!, (value) { + return _then(_self.copyWith(recommendations: value)); + }); +}/// Create a copy of TmdbEnrichedDetail +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$TmdbImageSetCopyWith<$Res>? get images { + if (_self.images == null) { + return null; + } + + return $TmdbImageSetCopyWith<$Res>(_self.images!, (value) { + return _then(_self.copyWith(images: value)); + }); +} +} + + +/// Adds pattern-matching-related methods to [TmdbEnrichedDetail]. +extension TmdbEnrichedDetailPatterns on TmdbEnrichedDetail { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _TmdbEnrichedDetail value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _TmdbEnrichedDetail() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _TmdbEnrichedDetail value) $default,){ +final _that = this; +switch (_that) { +case _TmdbEnrichedDetail(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _TmdbEnrichedDetail value)? $default,){ +final _that = this; +switch (_that) { +case _TmdbEnrichedDetail() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( TmdbMediaDetail detail, TmdbVideosRes? videos, TmdbCreditsRes? credits, TmdbRecommendationsRes? recommendations, TmdbImageSet? images, String? imdbId, List networks)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _TmdbEnrichedDetail() when $default != null: +return $default(_that.detail,_that.videos,_that.credits,_that.recommendations,_that.images,_that.imdbId,_that.networks);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( TmdbMediaDetail detail, TmdbVideosRes? videos, TmdbCreditsRes? credits, TmdbRecommendationsRes? recommendations, TmdbImageSet? images, String? imdbId, List networks) $default,) {final _that = this; +switch (_that) { +case _TmdbEnrichedDetail(): +return $default(_that.detail,_that.videos,_that.credits,_that.recommendations,_that.images,_that.imdbId,_that.networks);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( TmdbMediaDetail detail, TmdbVideosRes? videos, TmdbCreditsRes? credits, TmdbRecommendationsRes? recommendations, TmdbImageSet? images, String? imdbId, List networks)? $default,) {final _that = this; +switch (_that) { +case _TmdbEnrichedDetail() when $default != null: +return $default(_that.detail,_that.videos,_that.credits,_that.recommendations,_that.images,_that.imdbId,_that.networks);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _TmdbEnrichedDetail implements TmdbEnrichedDetail { + const _TmdbEnrichedDetail({required this.detail, this.videos, this.credits, this.recommendations, this.images, this.imdbId, final List networks = const []}): _networks = networks; + factory _TmdbEnrichedDetail.fromJson(Map json) => _$TmdbEnrichedDetailFromJson(json); + +@override final TmdbMediaDetail detail; +@override final TmdbVideosRes? videos; +@override final TmdbCreditsRes? credits; +@override final TmdbRecommendationsRes? recommendations; +@override final TmdbImageSet? images; +/// imdb id(如 `tt0137523`),来自 `append_to_response=external_ids` 的 +/// `external_ids.imdb_id`。仅作库内反查的 movie 兜底用(见 R1),TMDB 未刮削 +/// 或为空字符串时为 null。 +@override final String? imdbId; + final List _networks; +@override@JsonKey() List get networks { + if (_networks is EqualUnmodifiableListView) return _networks; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_networks); +} + + +/// Create a copy of TmdbEnrichedDetail +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$TmdbEnrichedDetailCopyWith<_TmdbEnrichedDetail> get copyWith => __$TmdbEnrichedDetailCopyWithImpl<_TmdbEnrichedDetail>(this, _$identity); + +@override +Map toJson() { + return _$TmdbEnrichedDetailToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _TmdbEnrichedDetail&&(identical(other.detail, detail) || other.detail == detail)&&(identical(other.videos, videos) || other.videos == videos)&&(identical(other.credits, credits) || other.credits == credits)&&(identical(other.recommendations, recommendations) || other.recommendations == recommendations)&&(identical(other.images, images) || other.images == images)&&(identical(other.imdbId, imdbId) || other.imdbId == imdbId)&&const DeepCollectionEquality().equals(other._networks, _networks)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,detail,videos,credits,recommendations,images,imdbId,const DeepCollectionEquality().hash(_networks)); + +@override +String toString() { + return 'TmdbEnrichedDetail(detail: $detail, videos: $videos, credits: $credits, recommendations: $recommendations, images: $images, imdbId: $imdbId, networks: $networks)'; +} + + +} + +/// @nodoc +abstract mixin class _$TmdbEnrichedDetailCopyWith<$Res> implements $TmdbEnrichedDetailCopyWith<$Res> { + factory _$TmdbEnrichedDetailCopyWith(_TmdbEnrichedDetail value, $Res Function(_TmdbEnrichedDetail) _then) = __$TmdbEnrichedDetailCopyWithImpl; +@override @useResult +$Res call({ + TmdbMediaDetail detail, TmdbVideosRes? videos, TmdbCreditsRes? credits, TmdbRecommendationsRes? recommendations, TmdbImageSet? images, String? imdbId, List networks +}); + + +@override $TmdbMediaDetailCopyWith<$Res> get detail;@override $TmdbVideosResCopyWith<$Res>? get videos;@override $TmdbCreditsResCopyWith<$Res>? get credits;@override $TmdbRecommendationsResCopyWith<$Res>? get recommendations;@override $TmdbImageSetCopyWith<$Res>? get images; + +} +/// @nodoc +class __$TmdbEnrichedDetailCopyWithImpl<$Res> + implements _$TmdbEnrichedDetailCopyWith<$Res> { + __$TmdbEnrichedDetailCopyWithImpl(this._self, this._then); + + final _TmdbEnrichedDetail _self; + final $Res Function(_TmdbEnrichedDetail) _then; + +/// Create a copy of TmdbEnrichedDetail +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? detail = null,Object? videos = freezed,Object? credits = freezed,Object? recommendations = freezed,Object? images = freezed,Object? imdbId = freezed,Object? networks = null,}) { + return _then(_TmdbEnrichedDetail( +detail: null == detail ? _self.detail : detail // ignore: cast_nullable_to_non_nullable +as TmdbMediaDetail,videos: freezed == videos ? _self.videos : videos // ignore: cast_nullable_to_non_nullable +as TmdbVideosRes?,credits: freezed == credits ? _self.credits : credits // ignore: cast_nullable_to_non_nullable +as TmdbCreditsRes?,recommendations: freezed == recommendations ? _self.recommendations : recommendations // ignore: cast_nullable_to_non_nullable +as TmdbRecommendationsRes?,images: freezed == images ? _self.images : images // ignore: cast_nullable_to_non_nullable +as TmdbImageSet?,imdbId: freezed == imdbId ? _self.imdbId : imdbId // ignore: cast_nullable_to_non_nullable +as String?,networks: null == networks ? _self._networks : networks // ignore: cast_nullable_to_non_nullable +as List, + )); +} + +/// Create a copy of TmdbEnrichedDetail +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$TmdbMediaDetailCopyWith<$Res> get detail { + + return $TmdbMediaDetailCopyWith<$Res>(_self.detail, (value) { + return _then(_self.copyWith(detail: value)); + }); +}/// Create a copy of TmdbEnrichedDetail +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$TmdbVideosResCopyWith<$Res>? get videos { + if (_self.videos == null) { + return null; + } + + return $TmdbVideosResCopyWith<$Res>(_self.videos!, (value) { + return _then(_self.copyWith(videos: value)); + }); +}/// Create a copy of TmdbEnrichedDetail +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$TmdbCreditsResCopyWith<$Res>? get credits { + if (_self.credits == null) { + return null; + } + + return $TmdbCreditsResCopyWith<$Res>(_self.credits!, (value) { + return _then(_self.copyWith(credits: value)); + }); +}/// Create a copy of TmdbEnrichedDetail +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$TmdbRecommendationsResCopyWith<$Res>? get recommendations { + if (_self.recommendations == null) { + return null; + } + + return $TmdbRecommendationsResCopyWith<$Res>(_self.recommendations!, (value) { + return _then(_self.copyWith(recommendations: value)); + }); +}/// Create a copy of TmdbEnrichedDetail +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$TmdbImageSetCopyWith<$Res>? get images { + if (_self.images == null) { + return null; + } + + return $TmdbImageSetCopyWith<$Res>(_self.images!, (value) { + return _then(_self.copyWith(images: value)); + }); +} +} + +// dart format on diff --git a/lib/core/contracts/tmdb.g.dart b/lib/core/contracts/tmdb.g.dart new file mode 100644 index 0000000..044be28 --- /dev/null +++ b/lib/core/contracts/tmdb.g.dart @@ -0,0 +1,384 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'tmdb.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_TmdbCastMember _$TmdbCastMemberFromJson(Map json) => + _TmdbCastMember( + id: _intRequired(json['id']), + name: json['name'] == null ? '' : _strOrEmpty(json['name']), + character: json['character'] as String?, + profilePath: json['profile_path'] as String?, + order: json['order'] == null ? 0 : _intOrZero(json['order']), + ); + +Map _$TmdbCastMemberToJson(_TmdbCastMember instance) => + { + 'id': instance.id, + 'name': instance.name, + 'character': instance.character, + 'profile_path': instance.profilePath, + 'order': instance.order, + }; + +_TmdbCrewMember _$TmdbCrewMemberFromJson(Map json) => + _TmdbCrewMember( + id: _intRequired(json['id']), + name: json['name'] == null ? '' : _strOrEmpty(json['name']), + job: json['job'] as String?, + department: json['department'] as String?, + profilePath: json['profile_path'] as String?, + ); + +Map _$TmdbCrewMemberToJson(_TmdbCrewMember instance) => + { + 'id': instance.id, + 'name': instance.name, + 'job': instance.job, + 'department': instance.department, + 'profile_path': instance.profilePath, + }; + +_TmdbCreditsRes _$TmdbCreditsResFromJson(Map json) => + _TmdbCreditsRes( + cast: + (json['cast'] as List?) + ?.map((e) => TmdbCastMember.fromJson(e as Map)) + .toList() ?? + const [], + crew: + (json['crew'] as List?) + ?.map((e) => TmdbCrewMember.fromJson(e as Map)) + .toList() ?? + const [], + ); + +Map _$TmdbCreditsResToJson(_TmdbCreditsRes instance) => + {'cast': instance.cast, 'crew': instance.crew}; + +_TmdbRecommendation _$TmdbRecommendationFromJson(Map json) => + _TmdbRecommendation( + id: _intRequired(json['id']), + title: json['title'] as String, + posterPath: json['poster_path'] as String?, + backdropPath: json['backdrop_path'] as String?, + overview: json['overview'] as String?, + voteAverage: _doubleOrNull(json['vote_average']), + mediaType: json['media_type'] as String?, + ); + +Map _$TmdbRecommendationToJson(_TmdbRecommendation instance) => + { + 'id': instance.id, + 'title': instance.title, + 'poster_path': instance.posterPath, + 'backdrop_path': instance.backdropPath, + 'overview': instance.overview, + 'vote_average': instance.voteAverage, + 'media_type': instance.mediaType, + }; + +_TmdbRecommendationsRes _$TmdbRecommendationsResFromJson( + Map json, +) => _TmdbRecommendationsRes( + results: + (json['results'] as List?) + ?.map((e) => TmdbRecommendation.fromJson(e as Map)) + .toList() ?? + const [], + totalResults: json['total_results'] == null + ? 0 + : _intOrZero(json['total_results']), +); + +Map _$TmdbRecommendationsResToJson( + _TmdbRecommendationsRes instance, +) => { + 'results': instance.results, + 'total_results': instance.totalResults, +}; + +_TmdbEpisode _$TmdbEpisodeFromJson(Map json) => _TmdbEpisode( + episodeNumber: _intRequired(json['episode_number']), + name: json['name'] == null ? '' : _strOrEmpty(json['name']), + overview: json['overview'] as String?, + stillPath: json['still_path'] as String?, + airDate: json['air_date'] as String?, + voteAverage: _doubleOrNull(json['vote_average']), + runtime: _intOrNull(json['runtime']), +); + +Map _$TmdbEpisodeToJson(_TmdbEpisode instance) => + { + 'episode_number': instance.episodeNumber, + 'name': instance.name, + 'overview': instance.overview, + 'still_path': instance.stillPath, + 'air_date': instance.airDate, + 'vote_average': instance.voteAverage, + 'runtime': instance.runtime, + }; + +_TmdbSeasonDetail _$TmdbSeasonDetailFromJson(Map json) => + _TmdbSeasonDetail( + seasonNumber: _intRequired(json['season_number']), + name: json['name'] == null ? '' : _strOrEmpty(json['name']), + overview: json['overview'] as String?, + posterPath: json['poster_path'] as String?, + episodes: + (json['episodes'] as List?) + ?.map((e) => TmdbEpisode.fromJson(e as Map)) + .toList() ?? + const [], + ); + +Map _$TmdbSeasonDetailToJson(_TmdbSeasonDetail instance) => + { + 'season_number': instance.seasonNumber, + 'name': instance.name, + 'overview': instance.overview, + 'poster_path': instance.posterPath, + 'episodes': instance.episodes, + }; + +_TmdbVideo _$TmdbVideoFromJson(Map json) => _TmdbVideo( + id: json['id'] == null ? '' : _strOrEmpty(json['id']), + key: json['key'] == null ? '' : _strOrEmpty(json['key']), + name: json['name'] == null ? '' : _strOrEmpty(json['name']), + site: json['site'] == null ? '' : _strOrEmpty(json['site']), + type: json['type'] == null ? '' : _strOrEmpty(json['type']), + size: json['size'] == null ? 0 : _intOrZero(json['size']), + official: json['official'] == null ? false : _boolOrFalse(json['official']), +); + +Map _$TmdbVideoToJson(_TmdbVideo instance) => + { + 'id': instance.id, + 'key': instance.key, + 'name': instance.name, + 'site': instance.site, + 'type': instance.type, + 'size': instance.size, + 'official': instance.official, + }; + +_TmdbVideosRes _$TmdbVideosResFromJson(Map json) => + _TmdbVideosRes( + results: + (json['results'] as List?) + ?.map((e) => TmdbVideo.fromJson(e as Map)) + .toList() ?? + const [], + ); + +Map _$TmdbVideosResToJson(_TmdbVideosRes instance) => + {'results': instance.results}; + +_TmdbPersonDetail _$TmdbPersonDetailFromJson(Map json) => + _TmdbPersonDetail( + id: _intRequired(json['id']), + name: json['name'] == null ? '' : _strOrEmpty(json['name']), + biography: json['biography'] as String?, + birthday: json['birthday'] as String?, + deathday: json['deathday'] as String?, + placeOfBirth: json['place_of_birth'] as String?, + profilePath: json['profile_path'] as String?, + knownForDepartment: json['known_for_department'] as String?, + popularity: _doubleOrNull(json['popularity']), + ); + +Map _$TmdbPersonDetailToJson(_TmdbPersonDetail instance) => + { + 'id': instance.id, + 'name': instance.name, + 'biography': instance.biography, + 'birthday': instance.birthday, + 'deathday': instance.deathday, + 'place_of_birth': instance.placeOfBirth, + 'profile_path': instance.profilePath, + 'known_for_department': instance.knownForDepartment, + 'popularity': instance.popularity, + }; + +_TmdbPersonCredit _$TmdbPersonCreditFromJson(Map json) => + _TmdbPersonCredit( + id: _intRequired(json['id']), + title: json['title'] as String, + character: json['character'] as String?, + posterPath: json['poster_path'] as String?, + releaseDate: json['release_date'] as String?, + voteAverage: _doubleOrNull(json['vote_average']), + mediaType: json['media_type'] as String?, + ); + +Map _$TmdbPersonCreditToJson(_TmdbPersonCredit instance) => + { + 'id': instance.id, + 'title': instance.title, + 'character': instance.character, + 'poster_path': instance.posterPath, + 'release_date': instance.releaseDate, + 'vote_average': instance.voteAverage, + 'media_type': instance.mediaType, + }; + +_TmdbPersonCreditsRes _$TmdbPersonCreditsResFromJson( + Map json, +) => _TmdbPersonCreditsRes( + cast: + (json['cast'] as List?) + ?.map((e) => TmdbPersonCredit.fromJson(e as Map)) + .toList() ?? + const [], +); + +Map _$TmdbPersonCreditsResToJson( + _TmdbPersonCreditsRes instance, +) => {'cast': instance.cast}; + +_TmdbGenre _$TmdbGenreFromJson(Map json) => _TmdbGenre( + id: _intRequired(json['id']), + name: json['name'] == null ? '' : _strOrEmpty(json['name']), +); + +Map _$TmdbGenreToJson(_TmdbGenre instance) => + {'id': instance.id, 'name': instance.name}; + +_TmdbMediaDetail _$TmdbMediaDetailFromJson(Map json) => + _TmdbMediaDetail( + id: _intRequired(json['id']), + title: json['title'] == null ? '' : _strOrEmpty(json['title']), + posterPath: json['poster_path'] as String?, + backdropPath: json['backdrop_path'] as String?, + releaseDate: json['release_date'] as String?, + genres: + (json['genres'] as List?) + ?.map((e) => TmdbGenre.fromJson(e as Map)) + .toList() ?? + const [], + runtime: _intOrNull(json['runtime']), + tagline: json['tagline'] as String?, + overview: json['overview'] as String?, + status: json['status'] as String?, + homepage: json['homepage'] as String?, + voteAverage: _doubleOrNull(json['vote_average']), + voteCount: _intOrNull(json['vote_count']), + revenue: _intOrNull(json['revenue']), + budget: _intOrNull(json['budget']), + ); + +Map _$TmdbMediaDetailToJson(_TmdbMediaDetail instance) => + { + 'id': instance.id, + 'title': instance.title, + 'poster_path': instance.posterPath, + 'backdrop_path': instance.backdropPath, + 'release_date': instance.releaseDate, + 'genres': instance.genres, + 'runtime': instance.runtime, + 'tagline': instance.tagline, + 'overview': instance.overview, + 'status': instance.status, + 'homepage': instance.homepage, + 'vote_average': instance.voteAverage, + 'vote_count': instance.voteCount, + 'revenue': instance.revenue, + 'budget': instance.budget, + }; + +_TmdbImage _$TmdbImageFromJson(Map json) => _TmdbImage( + filePath: json['file_path'] == null ? '' : _strOrEmpty(json['file_path']), + width: json['width'] == null ? 0 : _intOrZero(json['width']), + height: json['height'] == null ? 0 : _intOrZero(json['height']), + voteAverage: _doubleOrNull(json['vote_average']), + iso639: json['iso_639_1'] as String?, +); + +Map _$TmdbImageToJson(_TmdbImage instance) => + { + 'file_path': instance.filePath, + 'width': instance.width, + 'height': instance.height, + 'vote_average': instance.voteAverage, + 'iso_639_1': instance.iso639, + }; + +_TmdbImageSet _$TmdbImageSetFromJson(Map json) => + _TmdbImageSet( + backdrops: + (json['backdrops'] as List?) + ?.map((e) => TmdbImage.fromJson(e as Map)) + .toList() ?? + const [], + posters: + (json['posters'] as List?) + ?.map((e) => TmdbImage.fromJson(e as Map)) + .toList() ?? + const [], + logos: + (json['logos'] as List?) + ?.map((e) => TmdbImage.fromJson(e as Map)) + .toList() ?? + const [], + ); + +Map _$TmdbImageSetToJson(_TmdbImageSet instance) => + { + 'backdrops': instance.backdrops, + 'posters': instance.posters, + 'logos': instance.logos, + }; + +_TmdbNetwork _$TmdbNetworkFromJson(Map json) => _TmdbNetwork( + id: _intRequired(json['id']), + name: json['name'] == null ? '' : _strOrEmpty(json['name']), + logoPath: json['logo_path'] as String?, + originCountry: json['origin_country'] as String?, +); + +Map _$TmdbNetworkToJson(_TmdbNetwork instance) => + { + 'id': instance.id, + 'name': instance.name, + 'logo_path': instance.logoPath, + 'origin_country': instance.originCountry, + }; + +_TmdbEnrichedDetail _$TmdbEnrichedDetailFromJson(Map json) => + _TmdbEnrichedDetail( + detail: TmdbMediaDetail.fromJson(json['detail'] as Map), + videos: json['videos'] == null + ? null + : TmdbVideosRes.fromJson(json['videos'] as Map), + credits: json['credits'] == null + ? null + : TmdbCreditsRes.fromJson(json['credits'] as Map), + recommendations: json['recommendations'] == null + ? null + : TmdbRecommendationsRes.fromJson( + json['recommendations'] as Map, + ), + images: json['images'] == null + ? null + : TmdbImageSet.fromJson(json['images'] as Map), + imdbId: json['imdbId'] as String?, + networks: + (json['networks'] as List?) + ?.map((e) => TmdbNetwork.fromJson(e as Map)) + .toList() ?? + const [], + ); + +Map _$TmdbEnrichedDetailToJson(_TmdbEnrichedDetail instance) => + { + 'detail': instance.detail, + 'videos': instance.videos, + 'credits': instance.credits, + 'recommendations': instance.recommendations, + 'images': instance.images, + 'imdbId': instance.imdbId, + 'networks': instance.networks, + }; diff --git a/lib/core/contracts/trakt/trakt_calendar_entry.dart b/lib/core/contracts/trakt/trakt_calendar_entry.dart new file mode 100644 index 0000000..07468b4 --- /dev/null +++ b/lib/core/contracts/trakt/trakt_calendar_entry.dart @@ -0,0 +1,154 @@ +import 'trakt_ids.dart'; + + +class TraktCalendarEntry { + + final DateTime firstAired; + + + final String type; + + final String? showTitle; + final int? showYear; + final TraktIds? showIds; + final int? episodeSeason; + final int? episodeNumber; + final String? episodeTitle; + final TraktIds? episodeIds; + + final String? movieTitle; + final int? movieYear; + final TraktIds? movieIds; + + + final String? posterUrl; + + const TraktCalendarEntry({ + required this.firstAired, + required this.type, + this.showTitle, + this.showYear, + this.showIds, + this.episodeSeason, + this.episodeNumber, + this.episodeTitle, + this.episodeIds, + this.movieTitle, + this.movieYear, + this.movieIds, + this.posterUrl, + }); + + TraktCalendarEntry withPoster(String? url) => TraktCalendarEntry( + firstAired: firstAired, + type: type, + showTitle: showTitle, + showYear: showYear, + showIds: showIds, + episodeSeason: episodeSeason, + episodeNumber: episodeNumber, + episodeTitle: episodeTitle, + episodeIds: episodeIds, + movieTitle: movieTitle, + movieYear: movieYear, + movieIds: movieIds, + posterUrl: url, + ); + + TraktCalendarEntry withTitle(String title) => TraktCalendarEntry( + firstAired: firstAired, + type: type, + showTitle: type == 'movie' ? showTitle : title, + showYear: showYear, + showIds: showIds, + episodeSeason: episodeSeason, + episodeNumber: episodeNumber, + episodeTitle: episodeTitle, + episodeIds: episodeIds, + movieTitle: type == 'movie' ? title : movieTitle, + movieYear: movieYear, + movieIds: movieIds, + posterUrl: posterUrl, + ); + + + int? get tmdbId => type == 'movie' ? movieIds?.tmdb : showIds?.tmdb; + + + String get mediaType => type == 'movie' ? 'movie' : 'tv'; + + + String get displayTitle => + type == 'movie' ? (movieTitle ?? '') : (showTitle ?? ''); + + + String get displaySubtitle { + if (type == 'movie') return movieYear?.toString() ?? ''; + final parts = []; + if (episodeSeason != null && episodeNumber != null) { + parts.add( + 'S${episodeSeason.toString().padLeft(2, '0')}' + 'E${episodeNumber.toString().padLeft(2, '0')}', + ); + } + if (episodeTitle != null && episodeTitle!.isNotEmpty) { + parts.add(episodeTitle!); + } + return parts.join(' · '); + } + + + static TraktCalendarEntry? fromShowJson(Map json) { + final aired = _parseDate(json['first_aired']); + if (aired == null) return null; + + final show = json['show']; + final episode = json['episode']; + if (show is! Map) return null; + + return TraktCalendarEntry( + firstAired: aired, + type: 'show', + showTitle: show['title'] as String?, + showYear: (show['year'] as num?)?.toInt(), + showIds: TraktIds.fromJson(show['ids']), + episodeSeason: episode is Map + ? (episode['season'] as num?)?.toInt() + : null, + episodeNumber: episode is Map + ? (episode['number'] as num?)?.toInt() + : null, + episodeTitle: episode is Map + ? episode['title'] as String? + : null, + episodeIds: episode is Map + ? TraktIds.fromJson(episode['ids']) + : null, + ); + } + + + static TraktCalendarEntry? fromMovieJson(Map json) { + final releasedStr = json['released'] as String?; + final aired = releasedStr != null && releasedStr.isNotEmpty + ? DateTime.tryParse(releasedStr) + : null; + if (aired == null) return null; + + final movie = json['movie']; + if (movie is! Map) return null; + + return TraktCalendarEntry( + firstAired: aired, + type: 'movie', + movieTitle: movie['title'] as String?, + movieYear: (movie['year'] as num?)?.toInt(), + movieIds: TraktIds.fromJson(movie['ids']), + ); + } +} + +DateTime? _parseDate(Object? raw) { + if (raw is! String || raw.isEmpty) return null; + return DateTime.tryParse(raw); +} diff --git a/lib/core/contracts/trakt/trakt_ids.dart b/lib/core/contracts/trakt/trakt_ids.dart new file mode 100644 index 0000000..24e8fb1 --- /dev/null +++ b/lib/core/contracts/trakt/trakt_ids.dart @@ -0,0 +1,40 @@ + + +class TraktIds { + final int? trakt; + final String? imdb; + final int? tmdb; + final int? tvdb; + final String? slug; + + const TraktIds({this.trakt, this.imdb, this.tmdb, this.tvdb, this.slug}); + + factory TraktIds.fromJson(Object? raw) { + if (raw is! Map) return const TraktIds(); + final json = raw.cast(); + return TraktIds( + trakt: (json['trakt'] as num?)?.toInt(), + imdb: json['imdb'] as String?, + tmdb: (json['tmdb'] as num?)?.toInt(), + tvdb: (json['tvdb'] as num?)?.toInt(), + slug: json['slug'] as String?, + ); + } + + + bool get hasReliableId => + (trakt != null && trakt! > 0) || + (imdb != null && imdb!.isNotEmpty) || + (tmdb != null && tmdb! > 0) || + (tvdb != null && tvdb! > 0); + + Map toJson() { + final out = {}; + if (trakt != null && trakt! > 0) out['trakt'] = trakt; + if (imdb != null && imdb!.isNotEmpty) out['imdb'] = imdb; + if (tmdb != null && tmdb! > 0) out['tmdb'] = tmdb; + if (tvdb != null && tvdb! > 0) out['tvdb'] = tvdb; + if (slug != null && slug!.isNotEmpty) out['slug'] = slug; + return out; + } +} diff --git a/lib/core/contracts/trakt/trakt_playback_entry.dart b/lib/core/contracts/trakt/trakt_playback_entry.dart new file mode 100644 index 0000000..a2086b3 --- /dev/null +++ b/lib/core/contracts/trakt/trakt_playback_entry.dart @@ -0,0 +1,79 @@ +import 'trakt_ids.dart'; + + +class TraktPlaybackEntry { + + final double progress; + final DateTime? pausedAt; + + + final String type; + + final String? movieTitle; + final int? movieYear; + final TraktIds? movieIds; + + final String? showTitle; + final int? showYear; + final TraktIds? showIds; + + final int? episodeSeason; + final int? episodeNumber; + final String? episodeTitle; + final TraktIds? episodeIds; + + const TraktPlaybackEntry({ + required this.progress, + required this.type, + this.pausedAt, + this.movieTitle, + this.movieYear, + this.movieIds, + this.showTitle, + this.showYear, + this.showIds, + this.episodeSeason, + this.episodeNumber, + this.episodeTitle, + this.episodeIds, + }); + + static TraktPlaybackEntry? fromJson(Map json) { + final movie = json['movie']; + final show = json['show']; + final episode = json['episode']; + final hasMovie = movie is Map; + final hasShow = show is Map; + if (!hasMovie && !hasShow) return null; + + final rawType = json['type'] as String? ?? ''; + return TraktPlaybackEntry( + progress: (json['progress'] as num?)?.toDouble() ?? 0, + pausedAt: _parseDate(json['paused_at']), + type: rawType.isNotEmpty ? rawType : (hasMovie ? 'movie' : 'episode'), + movieTitle: hasMovie ? movie['title'] as String? : null, + movieYear: hasMovie ? (movie['year'] as num?)?.toInt() : null, + movieIds: hasMovie ? TraktIds.fromJson(movie['ids']) : null, + showTitle: hasShow ? show['title'] as String? : null, + showYear: hasShow ? (show['year'] as num?)?.toInt() : null, + showIds: hasShow ? TraktIds.fromJson(show['ids']) : null, + episodeSeason: episode is Map + ? (episode['season'] as num?)?.toInt() + : null, + episodeNumber: episode is Map + ? (episode['number'] as num?)?.toInt() + : null, + episodeTitle: episode is Map + ? episode['title'] as String? + : null, + episodeIds: episode is Map + ? TraktIds.fromJson(episode['ids']) + : null, + ); + } +} + +DateTime? _parseDate(Object? raw) { + if (raw is! String || raw.isEmpty) return null; + return DateTime.tryParse(raw); +} diff --git a/lib/core/contracts/trakt/trakt_response.dart b/lib/core/contracts/trakt/trakt_response.dart new file mode 100644 index 0000000..d045b91 --- /dev/null +++ b/lib/core/contracts/trakt/trakt_response.dart @@ -0,0 +1,47 @@ + +enum TraktResponseClass { + + success, + + + duplicate, + + + invalid, + + + rateLimited, + + + unauthorized, + + + transient, +} + + +class TraktScrobbleOutcome { + final TraktResponseClass cls; + + + final Duration? retryAfter; + + const TraktScrobbleOutcome(this.cls, {this.retryAfter}); +} + + +TraktResponseClass classifyTraktStatus(int statusCode) { + if (statusCode >= 200 && statusCode < 300) return TraktResponseClass.success; + switch (statusCode) { + case 401: + return TraktResponseClass.unauthorized; + case 409: + return TraktResponseClass.duplicate; + case 422: + return TraktResponseClass.invalid; + case 429: + return TraktResponseClass.rateLimited; + default: + return TraktResponseClass.transient; + } +} diff --git a/lib/core/contracts/trakt/trakt_scrobble_target.dart b/lib/core/contracts/trakt/trakt_scrobble_target.dart new file mode 100644 index 0000000..e3302b8 --- /dev/null +++ b/lib/core/contracts/trakt/trakt_scrobble_target.dart @@ -0,0 +1,97 @@ +import 'trakt_ids.dart'; + + +enum TraktMediaKind { movie, episode } + + +class TraktScrobbleTarget { + final TraktMediaKind kind; + + final String title; + final int? year; + final TraktIds ids; + + final int? season; + final int? number; + final TraktIds episodeIds; + + const TraktScrobbleTarget._({ + required this.kind, + required this.title, + required this.year, + required this.ids, + this.season, + this.number, + this.episodeIds = const TraktIds(), + }); + + factory TraktScrobbleTarget.movie({ + required String title, + required int? year, + required TraktIds ids, + }) { + return TraktScrobbleTarget._( + kind: TraktMediaKind.movie, + title: title, + year: year, + ids: ids, + ); + } + + factory TraktScrobbleTarget.episode({ + required String showTitle, + required int? showYear, + required TraktIds showIds, + required int? season, + required int? number, + required TraktIds episodeIds, + }) { + return TraktScrobbleTarget._( + kind: TraktMediaKind.episode, + title: showTitle, + year: showYear, + ids: showIds, + season: season, + number: number, + episodeIds: episodeIds, + ); + } + + + Map toBody({ + required double progress, + String? appVersion, + String? appDate, + }) { + final body = {}; + + switch (kind) { + case TraktMediaKind.movie: + body['movie'] = _mediaNode(title, year, ids); + case TraktMediaKind.episode: + body['show'] = _mediaNode(title, year, ids); + final episode = {'season': season, 'number': number}; + final epIds = episodeIds.toJson(); + if (epIds.isNotEmpty) episode['ids'] = epIds; + body['episode'] = episode; + } + + body['progress'] = progress.toDouble(); + if (appVersion != null && appVersion.isNotEmpty) { + body['app_version'] = appVersion; + } + if (appDate != null && appDate.isNotEmpty) body['app_date'] = appDate; + return body; + } + + static Map _mediaNode( + String title, + int? year, + TraktIds ids, + ) { + final node = {'title': title}; + if (year != null && year > 0) node['year'] = year; + node['ids'] = ids.toJson(); + return node; + } +} diff --git a/lib/core/contracts/trakt/trakt_sync_job.dart b/lib/core/contracts/trakt/trakt_sync_job.dart new file mode 100644 index 0000000..48794ff --- /dev/null +++ b/lib/core/contracts/trakt/trakt_sync_job.dart @@ -0,0 +1,82 @@ + +enum TraktSyncJobType { + scrobbleStart('scrobble_start'), + scrobblePause('scrobble_pause'), + scrobbleStop('scrobble_stop'), + historyAdd('history_add'), + historyRemove('history_remove'); + + const TraktSyncJobType(this.wire); + + + final String wire; + + bool get isHistory => this == historyAdd || this == historyRemove; + + static TraktSyncJobType fromWire(String wire) { + return TraktSyncJobType.values.firstWhere( + (e) => e.wire == wire, + orElse: () => TraktSyncJobType.scrobbleStart, + ); + } +} + + +class TraktSyncJob { + final String id; + final TraktSyncJobType type; + final Map body; + final int retryCount; + final DateTime? nextRetryAt; + final bool exhausted; + + const TraktSyncJob({ + required this.id, + required this.type, + required this.body, + this.retryCount = 0, + this.nextRetryAt, + this.exhausted = false, + }); + + TraktSyncJob copyWith({ + int? retryCount, + DateTime? nextRetryAt, + bool clearNextRetryAt = false, + bool? exhausted, + }) { + return TraktSyncJob( + id: id, + type: type, + body: body, + retryCount: retryCount ?? this.retryCount, + nextRetryAt: clearNextRetryAt ? null : (nextRetryAt ?? this.nextRetryAt), + exhausted: exhausted ?? this.exhausted, + ); + } + + Map toJson() => { + 'id': id, + 'type': type.wire, + 'body': body, + 'retryCount': retryCount, + 'nextRetryAt': nextRetryAt?.toIso8601String(), + 'exhausted': exhausted, + }; + + factory TraktSyncJob.fromJson(Map json) { + return TraktSyncJob( + id: json['id'] as String, + type: TraktSyncJobType.fromWire(json['type'] as String? ?? ''), + body: (json['body'] as Map?)?.cast() ?? const {}, + retryCount: (json['retryCount'] as num?)?.toInt() ?? 0, + nextRetryAt: _parseDate(json['nextRetryAt']), + exhausted: json['exhausted'] as bool? ?? false, + ); + } + + static DateTime? _parseDate(Object? raw) { + if (raw is! String || raw.isEmpty) return null; + return DateTime.tryParse(raw); + } +} diff --git a/lib/core/contracts/trakt/trakt_token.dart b/lib/core/contracts/trakt/trakt_token.dart new file mode 100644 index 0000000..afca91f --- /dev/null +++ b/lib/core/contracts/trakt/trakt_token.dart @@ -0,0 +1,20 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'trakt_token.freezed.dart'; +part 'trakt_token.g.dart'; + + +@freezed +abstract class TraktToken with _$TraktToken { + const factory TraktToken({ + @JsonKey(name: 'access_token') @Default('') String accessToken, + @JsonKey(name: 'refresh_token') @Default('') String refreshToken, + @JsonKey(name: 'expires_in') @Default(0) int expiresIn, + @JsonKey(name: 'created_at') @Default(0) int createdAt, + @JsonKey(name: 'token_type') @Default('') String tokenType, + @Default('') String scope, + }) = _TraktToken; + + factory TraktToken.fromJson(Map json) => + _$TraktTokenFromJson(json); +} diff --git a/lib/core/contracts/trakt/trakt_token.freezed.dart b/lib/core/contracts/trakt/trakt_token.freezed.dart new file mode 100644 index 0000000..ce2bbc8 --- /dev/null +++ b/lib/core/contracts/trakt/trakt_token.freezed.dart @@ -0,0 +1,292 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'trakt_token.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$TraktToken { + +@JsonKey(name: 'access_token') String get accessToken;@JsonKey(name: 'refresh_token') String get refreshToken;@JsonKey(name: 'expires_in') int get expiresIn;@JsonKey(name: 'created_at') int get createdAt;@JsonKey(name: 'token_type') String get tokenType; String get scope; +/// Create a copy of TraktToken +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$TraktTokenCopyWith get copyWith => _$TraktTokenCopyWithImpl(this as TraktToken, _$identity); + + /// Serializes this TraktToken to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is TraktToken&&(identical(other.accessToken, accessToken) || other.accessToken == accessToken)&&(identical(other.refreshToken, refreshToken) || other.refreshToken == refreshToken)&&(identical(other.expiresIn, expiresIn) || other.expiresIn == expiresIn)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.tokenType, tokenType) || other.tokenType == tokenType)&&(identical(other.scope, scope) || other.scope == scope)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,accessToken,refreshToken,expiresIn,createdAt,tokenType,scope); + +@override +String toString() { + return 'TraktToken(accessToken: $accessToken, refreshToken: $refreshToken, expiresIn: $expiresIn, createdAt: $createdAt, tokenType: $tokenType, scope: $scope)'; +} + + +} + +/// @nodoc +abstract mixin class $TraktTokenCopyWith<$Res> { + factory $TraktTokenCopyWith(TraktToken value, $Res Function(TraktToken) _then) = _$TraktTokenCopyWithImpl; +@useResult +$Res call({ +@JsonKey(name: 'access_token') String accessToken,@JsonKey(name: 'refresh_token') String refreshToken,@JsonKey(name: 'expires_in') int expiresIn,@JsonKey(name: 'created_at') int createdAt,@JsonKey(name: 'token_type') String tokenType, String scope +}); + + + + +} +/// @nodoc +class _$TraktTokenCopyWithImpl<$Res> + implements $TraktTokenCopyWith<$Res> { + _$TraktTokenCopyWithImpl(this._self, this._then); + + final TraktToken _self; + final $Res Function(TraktToken) _then; + +/// Create a copy of TraktToken +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? accessToken = null,Object? refreshToken = null,Object? expiresIn = null,Object? createdAt = null,Object? tokenType = null,Object? scope = null,}) { + return _then(_self.copyWith( +accessToken: null == accessToken ? _self.accessToken : accessToken // ignore: cast_nullable_to_non_nullable +as String,refreshToken: null == refreshToken ? _self.refreshToken : refreshToken // ignore: cast_nullable_to_non_nullable +as String,expiresIn: null == expiresIn ? _self.expiresIn : expiresIn // ignore: cast_nullable_to_non_nullable +as int,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable +as int,tokenType: null == tokenType ? _self.tokenType : tokenType // ignore: cast_nullable_to_non_nullable +as String,scope: null == scope ? _self.scope : scope // ignore: cast_nullable_to_non_nullable +as String, + )); +} + +} + + +/// Adds pattern-matching-related methods to [TraktToken]. +extension TraktTokenPatterns on TraktToken { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _TraktToken value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _TraktToken() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _TraktToken value) $default,){ +final _that = this; +switch (_that) { +case _TraktToken(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _TraktToken value)? $default,){ +final _that = this; +switch (_that) { +case _TraktToken() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function(@JsonKey(name: 'access_token') String accessToken, @JsonKey(name: 'refresh_token') String refreshToken, @JsonKey(name: 'expires_in') int expiresIn, @JsonKey(name: 'created_at') int createdAt, @JsonKey(name: 'token_type') String tokenType, String scope)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _TraktToken() when $default != null: +return $default(_that.accessToken,_that.refreshToken,_that.expiresIn,_that.createdAt,_that.tokenType,_that.scope);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function(@JsonKey(name: 'access_token') String accessToken, @JsonKey(name: 'refresh_token') String refreshToken, @JsonKey(name: 'expires_in') int expiresIn, @JsonKey(name: 'created_at') int createdAt, @JsonKey(name: 'token_type') String tokenType, String scope) $default,) {final _that = this; +switch (_that) { +case _TraktToken(): +return $default(_that.accessToken,_that.refreshToken,_that.expiresIn,_that.createdAt,_that.tokenType,_that.scope);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function(@JsonKey(name: 'access_token') String accessToken, @JsonKey(name: 'refresh_token') String refreshToken, @JsonKey(name: 'expires_in') int expiresIn, @JsonKey(name: 'created_at') int createdAt, @JsonKey(name: 'token_type') String tokenType, String scope)? $default,) {final _that = this; +switch (_that) { +case _TraktToken() when $default != null: +return $default(_that.accessToken,_that.refreshToken,_that.expiresIn,_that.createdAt,_that.tokenType,_that.scope);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _TraktToken implements TraktToken { + const _TraktToken({@JsonKey(name: 'access_token') this.accessToken = '', @JsonKey(name: 'refresh_token') this.refreshToken = '', @JsonKey(name: 'expires_in') this.expiresIn = 0, @JsonKey(name: 'created_at') this.createdAt = 0, @JsonKey(name: 'token_type') this.tokenType = '', this.scope = ''}); + factory _TraktToken.fromJson(Map json) => _$TraktTokenFromJson(json); + +@override@JsonKey(name: 'access_token') final String accessToken; +@override@JsonKey(name: 'refresh_token') final String refreshToken; +@override@JsonKey(name: 'expires_in') final int expiresIn; +@override@JsonKey(name: 'created_at') final int createdAt; +@override@JsonKey(name: 'token_type') final String tokenType; +@override@JsonKey() final String scope; + +/// Create a copy of TraktToken +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$TraktTokenCopyWith<_TraktToken> get copyWith => __$TraktTokenCopyWithImpl<_TraktToken>(this, _$identity); + +@override +Map toJson() { + return _$TraktTokenToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _TraktToken&&(identical(other.accessToken, accessToken) || other.accessToken == accessToken)&&(identical(other.refreshToken, refreshToken) || other.refreshToken == refreshToken)&&(identical(other.expiresIn, expiresIn) || other.expiresIn == expiresIn)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.tokenType, tokenType) || other.tokenType == tokenType)&&(identical(other.scope, scope) || other.scope == scope)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,accessToken,refreshToken,expiresIn,createdAt,tokenType,scope); + +@override +String toString() { + return 'TraktToken(accessToken: $accessToken, refreshToken: $refreshToken, expiresIn: $expiresIn, createdAt: $createdAt, tokenType: $tokenType, scope: $scope)'; +} + + +} + +/// @nodoc +abstract mixin class _$TraktTokenCopyWith<$Res> implements $TraktTokenCopyWith<$Res> { + factory _$TraktTokenCopyWith(_TraktToken value, $Res Function(_TraktToken) _then) = __$TraktTokenCopyWithImpl; +@override @useResult +$Res call({ +@JsonKey(name: 'access_token') String accessToken,@JsonKey(name: 'refresh_token') String refreshToken,@JsonKey(name: 'expires_in') int expiresIn,@JsonKey(name: 'created_at') int createdAt,@JsonKey(name: 'token_type') String tokenType, String scope +}); + + + + +} +/// @nodoc +class __$TraktTokenCopyWithImpl<$Res> + implements _$TraktTokenCopyWith<$Res> { + __$TraktTokenCopyWithImpl(this._self, this._then); + + final _TraktToken _self; + final $Res Function(_TraktToken) _then; + +/// Create a copy of TraktToken +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? accessToken = null,Object? refreshToken = null,Object? expiresIn = null,Object? createdAt = null,Object? tokenType = null,Object? scope = null,}) { + return _then(_TraktToken( +accessToken: null == accessToken ? _self.accessToken : accessToken // ignore: cast_nullable_to_non_nullable +as String,refreshToken: null == refreshToken ? _self.refreshToken : refreshToken // ignore: cast_nullable_to_non_nullable +as String,expiresIn: null == expiresIn ? _self.expiresIn : expiresIn // ignore: cast_nullable_to_non_nullable +as int,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable +as int,tokenType: null == tokenType ? _self.tokenType : tokenType // ignore: cast_nullable_to_non_nullable +as String,scope: null == scope ? _self.scope : scope // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + +// dart format on diff --git a/lib/core/contracts/trakt/trakt_token.g.dart b/lib/core/contracts/trakt/trakt_token.g.dart new file mode 100644 index 0000000..052f4e2 --- /dev/null +++ b/lib/core/contracts/trakt/trakt_token.g.dart @@ -0,0 +1,26 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'trakt_token.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_TraktToken _$TraktTokenFromJson(Map json) => _TraktToken( + accessToken: json['access_token'] as String? ?? '', + refreshToken: json['refresh_token'] as String? ?? '', + expiresIn: (json['expires_in'] as num?)?.toInt() ?? 0, + createdAt: (json['created_at'] as num?)?.toInt() ?? 0, + tokenType: json['token_type'] as String? ?? '', + scope: json['scope'] as String? ?? '', +); + +Map _$TraktTokenToJson(_TraktToken instance) => + { + 'access_token': instance.accessToken, + 'refresh_token': instance.refreshToken, + 'expires_in': instance.expiresIn, + 'created_at': instance.createdAt, + 'token_type': instance.tokenType, + 'scope': instance.scope, + }; diff --git a/lib/core/contracts/trakt/trakt_user.dart b/lib/core/contracts/trakt/trakt_user.dart new file mode 100644 index 0000000..692bcf2 --- /dev/null +++ b/lib/core/contracts/trakt/trakt_user.dart @@ -0,0 +1,22 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'trakt_user.freezed.dart'; +part 'trakt_user.g.dart'; + + +@freezed +abstract class TraktUser with _$TraktUser { + const factory TraktUser({@Default('') String username}) = _TraktUser; + + factory TraktUser.fromJson(Map json) => + _$TraktUserFromJson(json); +} + + +@freezed +abstract class TraktUserSettings with _$TraktUserSettings { + const factory TraktUserSettings({TraktUser? user}) = _TraktUserSettings; + + factory TraktUserSettings.fromJson(Map json) => + _$TraktUserSettingsFromJson(json); +} diff --git a/lib/core/contracts/trakt/trakt_user.freezed.dart b/lib/core/contracts/trakt/trakt_user.freezed.dart new file mode 100644 index 0000000..23f5499 --- /dev/null +++ b/lib/core/contracts/trakt/trakt_user.freezed.dart @@ -0,0 +1,564 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'trakt_user.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$TraktUser { + + String get username; +/// Create a copy of TraktUser +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$TraktUserCopyWith get copyWith => _$TraktUserCopyWithImpl(this as TraktUser, _$identity); + + /// Serializes this TraktUser to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is TraktUser&&(identical(other.username, username) || other.username == username)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,username); + +@override +String toString() { + return 'TraktUser(username: $username)'; +} + + +} + +/// @nodoc +abstract mixin class $TraktUserCopyWith<$Res> { + factory $TraktUserCopyWith(TraktUser value, $Res Function(TraktUser) _then) = _$TraktUserCopyWithImpl; +@useResult +$Res call({ + String username +}); + + + + +} +/// @nodoc +class _$TraktUserCopyWithImpl<$Res> + implements $TraktUserCopyWith<$Res> { + _$TraktUserCopyWithImpl(this._self, this._then); + + final TraktUser _self; + final $Res Function(TraktUser) _then; + +/// Create a copy of TraktUser +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? username = null,}) { + return _then(_self.copyWith( +username: null == username ? _self.username : username // ignore: cast_nullable_to_non_nullable +as String, + )); +} + +} + + +/// Adds pattern-matching-related methods to [TraktUser]. +extension TraktUserPatterns on TraktUser { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _TraktUser value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _TraktUser() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _TraktUser value) $default,){ +final _that = this; +switch (_that) { +case _TraktUser(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _TraktUser value)? $default,){ +final _that = this; +switch (_that) { +case _TraktUser() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String username)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _TraktUser() when $default != null: +return $default(_that.username);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String username) $default,) {final _that = this; +switch (_that) { +case _TraktUser(): +return $default(_that.username);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String username)? $default,) {final _that = this; +switch (_that) { +case _TraktUser() when $default != null: +return $default(_that.username);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _TraktUser implements TraktUser { + const _TraktUser({this.username = ''}); + factory _TraktUser.fromJson(Map json) => _$TraktUserFromJson(json); + +@override@JsonKey() final String username; + +/// Create a copy of TraktUser +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$TraktUserCopyWith<_TraktUser> get copyWith => __$TraktUserCopyWithImpl<_TraktUser>(this, _$identity); + +@override +Map toJson() { + return _$TraktUserToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _TraktUser&&(identical(other.username, username) || other.username == username)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,username); + +@override +String toString() { + return 'TraktUser(username: $username)'; +} + + +} + +/// @nodoc +abstract mixin class _$TraktUserCopyWith<$Res> implements $TraktUserCopyWith<$Res> { + factory _$TraktUserCopyWith(_TraktUser value, $Res Function(_TraktUser) _then) = __$TraktUserCopyWithImpl; +@override @useResult +$Res call({ + String username +}); + + + + +} +/// @nodoc +class __$TraktUserCopyWithImpl<$Res> + implements _$TraktUserCopyWith<$Res> { + __$TraktUserCopyWithImpl(this._self, this._then); + + final _TraktUser _self; + final $Res Function(_TraktUser) _then; + +/// Create a copy of TraktUser +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? username = null,}) { + return _then(_TraktUser( +username: null == username ? _self.username : username // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + + +/// @nodoc +mixin _$TraktUserSettings { + + TraktUser? get user; +/// Create a copy of TraktUserSettings +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$TraktUserSettingsCopyWith get copyWith => _$TraktUserSettingsCopyWithImpl(this as TraktUserSettings, _$identity); + + /// Serializes this TraktUserSettings to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is TraktUserSettings&&(identical(other.user, user) || other.user == user)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,user); + +@override +String toString() { + return 'TraktUserSettings(user: $user)'; +} + + +} + +/// @nodoc +abstract mixin class $TraktUserSettingsCopyWith<$Res> { + factory $TraktUserSettingsCopyWith(TraktUserSettings value, $Res Function(TraktUserSettings) _then) = _$TraktUserSettingsCopyWithImpl; +@useResult +$Res call({ + TraktUser? user +}); + + +$TraktUserCopyWith<$Res>? get user; + +} +/// @nodoc +class _$TraktUserSettingsCopyWithImpl<$Res> + implements $TraktUserSettingsCopyWith<$Res> { + _$TraktUserSettingsCopyWithImpl(this._self, this._then); + + final TraktUserSettings _self; + final $Res Function(TraktUserSettings) _then; + +/// Create a copy of TraktUserSettings +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? user = freezed,}) { + return _then(_self.copyWith( +user: freezed == user ? _self.user : user // ignore: cast_nullable_to_non_nullable +as TraktUser?, + )); +} +/// Create a copy of TraktUserSettings +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$TraktUserCopyWith<$Res>? get user { + if (_self.user == null) { + return null; + } + + return $TraktUserCopyWith<$Res>(_self.user!, (value) { + return _then(_self.copyWith(user: value)); + }); +} +} + + +/// Adds pattern-matching-related methods to [TraktUserSettings]. +extension TraktUserSettingsPatterns on TraktUserSettings { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _TraktUserSettings value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _TraktUserSettings() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _TraktUserSettings value) $default,){ +final _that = this; +switch (_that) { +case _TraktUserSettings(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _TraktUserSettings value)? $default,){ +final _that = this; +switch (_that) { +case _TraktUserSettings() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( TraktUser? user)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _TraktUserSettings() when $default != null: +return $default(_that.user);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( TraktUser? user) $default,) {final _that = this; +switch (_that) { +case _TraktUserSettings(): +return $default(_that.user);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( TraktUser? user)? $default,) {final _that = this; +switch (_that) { +case _TraktUserSettings() when $default != null: +return $default(_that.user);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _TraktUserSettings implements TraktUserSettings { + const _TraktUserSettings({this.user}); + factory _TraktUserSettings.fromJson(Map json) => _$TraktUserSettingsFromJson(json); + +@override final TraktUser? user; + +/// Create a copy of TraktUserSettings +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$TraktUserSettingsCopyWith<_TraktUserSettings> get copyWith => __$TraktUserSettingsCopyWithImpl<_TraktUserSettings>(this, _$identity); + +@override +Map toJson() { + return _$TraktUserSettingsToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _TraktUserSettings&&(identical(other.user, user) || other.user == user)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,user); + +@override +String toString() { + return 'TraktUserSettings(user: $user)'; +} + + +} + +/// @nodoc +abstract mixin class _$TraktUserSettingsCopyWith<$Res> implements $TraktUserSettingsCopyWith<$Res> { + factory _$TraktUserSettingsCopyWith(_TraktUserSettings value, $Res Function(_TraktUserSettings) _then) = __$TraktUserSettingsCopyWithImpl; +@override @useResult +$Res call({ + TraktUser? user +}); + + +@override $TraktUserCopyWith<$Res>? get user; + +} +/// @nodoc +class __$TraktUserSettingsCopyWithImpl<$Res> + implements _$TraktUserSettingsCopyWith<$Res> { + __$TraktUserSettingsCopyWithImpl(this._self, this._then); + + final _TraktUserSettings _self; + final $Res Function(_TraktUserSettings) _then; + +/// Create a copy of TraktUserSettings +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? user = freezed,}) { + return _then(_TraktUserSettings( +user: freezed == user ? _self.user : user // ignore: cast_nullable_to_non_nullable +as TraktUser?, + )); +} + +/// Create a copy of TraktUserSettings +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$TraktUserCopyWith<$Res>? get user { + if (_self.user == null) { + return null; + } + + return $TraktUserCopyWith<$Res>(_self.user!, (value) { + return _then(_self.copyWith(user: value)); + }); +} +} + +// dart format on diff --git a/lib/core/contracts/trakt/trakt_user.g.dart b/lib/core/contracts/trakt/trakt_user.g.dart new file mode 100644 index 0000000..dd6cfed --- /dev/null +++ b/lib/core/contracts/trakt/trakt_user.g.dart @@ -0,0 +1,23 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'trakt_user.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_TraktUser _$TraktUserFromJson(Map json) => + _TraktUser(username: json['username'] as String? ?? ''); + +Map _$TraktUserToJson(_TraktUser instance) => + {'username': instance.username}; + +_TraktUserSettings _$TraktUserSettingsFromJson(Map json) => + _TraktUserSettings( + user: json['user'] == null + ? null + : TraktUser.fromJson(json['user'] as Map), + ); + +Map _$TraktUserSettingsToJson(_TraktUserSettings instance) => + {'user': instance.user}; diff --git a/lib/core/domain/errors.dart b/lib/core/domain/errors.dart new file mode 100644 index 0000000..27e7edf --- /dev/null +++ b/lib/core/domain/errors.dart @@ -0,0 +1,13 @@ +import '../contracts/error.dart'; + +class DomainError implements Exception { + final ErrorCode code; + final String message; + final bool retryable; + final Object? details; + + DomainError(this.code, this.message, {this.retryable = false, this.details}); + + @override + String toString() => 'DomainError(${code.value}): $message'; +} diff --git a/lib/core/domain/ports/authed_trakt_gateway.dart b/lib/core/domain/ports/authed_trakt_gateway.dart new file mode 100644 index 0000000..1d21519 --- /dev/null +++ b/lib/core/domain/ports/authed_trakt_gateway.dart @@ -0,0 +1,29 @@ +import '../../contracts/trakt/trakt_response.dart'; +import '../../contracts/trakt/trakt_sync_job.dart'; + + +abstract class AuthedTraktGateway { + + Future isConnected(); + + + Future scrobble( + TraktSyncJobType type, + Map body, + ); + + + Future syncHistory( + TraktSyncJobType type, + Map body, + ); + + + Future>> fetchPlayback(); + + + Future>> fetchCalendarShows(); + + + Future>> fetchCalendarMovies(); +} diff --git a/lib/core/domain/ports/danmaku_gateway.dart b/lib/core/domain/ports/danmaku_gateway.dart new file mode 100644 index 0000000..f2bcd80 --- /dev/null +++ b/lib/core/domain/ports/danmaku_gateway.dart @@ -0,0 +1,31 @@ +import '../../contracts/danmaku.dart'; + +abstract class DanmakuGateway { + + + Future> match( + String baseUrl, + String fileName, + int durationSec, + ); + + + Future> searchEpisodes( + String baseUrl, + String anime, + int? episode, + ); + + + Future> fetchComments( + String baseUrl, + int episodeId, { + int chConvert = 0, + }); + + + Future> getBangumiEpisodes( + String baseUrl, + int animeId, + ); +} diff --git a/lib/core/domain/ports/danmaku_sources_store.dart b/lib/core/domain/ports/danmaku_sources_store.dart new file mode 100644 index 0000000..e7ef55f --- /dev/null +++ b/lib/core/domain/ports/danmaku_sources_store.dart @@ -0,0 +1,10 @@ +import '../../contracts/danmaku.dart'; + + +abstract class DanmakuSourcesStore { + + Future> list(); + + + Future replaceAll(List sources); +} diff --git a/lib/core/domain/ports/emby_gateway.dart b/lib/core/domain/ports/emby_gateway.dart new file mode 100644 index 0000000..d8072c2 --- /dev/null +++ b/lib/core/domain/ports/emby_gateway.dart @@ -0,0 +1,171 @@ +import '../../contracts/cancellation.dart'; +import '../../contracts/library.dart'; +import '../../contracts/playback.dart'; +import '../../contracts/server.dart'; + + +class AuthedRequestContext { + final String baseUrl; + final String token; + final String userId; + + const AuthedRequestContext({ + required this.baseUrl, + required this.token, + required this.userId, + }); +} + + +class EmbyAuthResult { + final String accessToken; + final String userId; + final String userName; + + const EmbyAuthResult({ + required this.accessToken, + required this.userId, + required this.userName, + }); +} + + +abstract class EmbyGateway { + Future probePublicInfo(String baseUrl); + + Future authenticateByName({ + required String baseUrl, + required String username, + required String password, + }); + + Future changePassword({ + required AuthedRequestContext ctx, + required String currentPassword, + required String newPassword, + }); + + Future getUserViews(AuthedRequestContext ctx); + + Future getLatestItems( + AuthedRequestContext ctx, + String parentId, { + int? limit, + CancellationToken? cancelToken, + }); + + Future getResumeItems( + AuthedRequestContext ctx, { + int? limit, + }); + + Future> getLibraryItems({ + required AuthedRequestContext ctx, + String? parentId, + String? includeItemTypes, + List? genreIds, + String? searchTerm, + String? anyProviderIdEquals, + List? personIds, + String? fields, + String? enableImageTypes, + bool? groupProgramsBySeries, + int? imageTypeLimit, + int? startIndex, + int? limit, + bool? recursive, + String? sortBy, + SortOrder? sortOrder, + String? filters, + CancellationToken? cancelToken, + }); + + Future> getFavoriteItems({ + required AuthedRequestContext ctx, + required String includeItemTypes, + required String sortBy, + SortOrder? sortOrder, + }); + + Future getItemCounts(AuthedRequestContext ctx); + + Future getItemDetail(AuthedRequestContext ctx, String itemId); + + Future> getSeriesNextUp( + AuthedRequestContext ctx, + String seriesId, { + int? limit, + }); + + Future> getSeriesSeasons( + AuthedRequestContext ctx, + String seriesId, { + CancellationToken? cancelToken, + }); + + Future> getSeriesEpisodes( + AuthedRequestContext ctx, + String seriesId, + String seasonId, { + CancellationToken? cancelToken, + }); + + Future> getSimilarItems( + AuthedRequestContext ctx, + String itemId, { + int? limit, + }); + + Future> getAdditionalParts( + AuthedRequestContext ctx, + String itemId, + ); + + Future> getSpecialFeatures( + AuthedRequestContext ctx, + String itemId, + ); + + Future setFavoriteStatus({ + required AuthedRequestContext ctx, + required String itemId, + required bool isFavorite, + }); + + Future setPlayedStatus({ + required AuthedRequestContext ctx, + required String itemId, + required bool played, + }); + + Future hideFromResume({ + required AuthedRequestContext ctx, + required String itemId, + required bool hide, + }); + + Future reportPlaybackStarted({ + required AuthedRequestContext ctx, + required PlaybackReportPayload payload, + }); + + Future reportPlaybackProgress({ + required AuthedRequestContext ctx, + required PlaybackReportPayload payload, + }); + + Future reportPlaybackStopped({ + required AuthedRequestContext ctx, + required PlaybackReportPayload payload, + }); + + + Future> fetchPlaybackInfo({ + required AuthedRequestContext ctx, + required String itemId, + String? mediaSourceId, + int startTimeTicks = 0, + bool isPlayback = false, + required Map deviceProfile, + }); +} diff --git a/lib/core/domain/ports/script_widget_remote_gateway.dart b/lib/core/domain/ports/script_widget_remote_gateway.dart new file mode 100644 index 0000000..068c606 --- /dev/null +++ b/lib/core/domain/ports/script_widget_remote_gateway.dart @@ -0,0 +1,3 @@ +abstract class ScriptWidgetRemoteGateway { + Future fetchText(String url); +} diff --git a/lib/core/domain/ports/script_widget_repository.dart b/lib/core/domain/ports/script_widget_repository.dart new file mode 100644 index 0000000..0e2cc25 --- /dev/null +++ b/lib/core/domain/ports/script_widget_repository.dart @@ -0,0 +1,23 @@ +import '../../contracts/script_widget.dart'; + +abstract class ScriptWidgetRepository { + Future> listWidgets(); + + Future findWidget(String widgetId); + + Future upsertWidget(InstalledScriptWidget widget); + + Future deleteWidget(String widgetId); + + Future> listSubscriptions(); + + Future upsertSubscription(ScriptWidgetSubscription subscription); + + + Future replaceAllWidgets(List widgets); + + + Future replaceAllSubscriptions( + List subscriptions, + ); +} diff --git a/lib/core/domain/ports/script_widget_runtime.dart b/lib/core/domain/ports/script_widget_runtime.dart new file mode 100644 index 0000000..d4244cb --- /dev/null +++ b/lib/core/domain/ports/script_widget_runtime.dart @@ -0,0 +1,19 @@ +import '../../contracts/script_widget.dart'; + +abstract class ScriptWidgetRuntime { + Future inspectWidget(String scriptSource); + + Future loadWidget(String scriptSource); + + bool isWidgetLoaded(String widgetId); + + Future invokeModuleFunction( + String widgetId, + String functionName, + Map params, + ); + + Future unloadWidget(String widgetId); + + Future dispose(); +} diff --git a/lib/core/domain/ports/server_repository.dart b/lib/core/domain/ports/server_repository.dart new file mode 100644 index 0000000..a86fcea --- /dev/null +++ b/lib/core/domain/ports/server_repository.dart @@ -0,0 +1,14 @@ +import '../../contracts/server.dart'; + +abstract class ServerRepository { + Future> list(); + Future findById(String id); + Future findByBaseUrl(String baseUrl); + Future create(EmbyServer server); + Future update(EmbyServer server); + Future deleteById(String id); + Future touchConnectedAt(String id, String updatedAt); + + + Future replaceAll(List servers); +} diff --git a/lib/core/domain/ports/server_sync_gateway.dart b/lib/core/domain/ports/server_sync_gateway.dart new file mode 100644 index 0000000..a674775 --- /dev/null +++ b/lib/core/domain/ports/server_sync_gateway.dart @@ -0,0 +1,28 @@ + + +library; + + +class ServerSyncSnapshot { + final String? blob; + + const ServerSyncSnapshot({this.blob}); + + bool get isEmpty => blob == null || blob!.trim().isEmpty; +} + +abstract class ServerSyncGateway { + + + Future pull({ + required String baseUrl, + required String accessToken, + }); + + + Future push({ + required String baseUrl, + required String accessToken, + required String blob, + }); +} diff --git a/lib/core/domain/ports/session_repository.dart b/lib/core/domain/ports/session_repository.dart new file mode 100644 index 0000000..4d2f55d --- /dev/null +++ b/lib/core/domain/ports/session_repository.dart @@ -0,0 +1,18 @@ +import '../../contracts/auth.dart'; + +abstract class SessionRepository { + Future set(SessionData session); + + + Future setMany(List sessions); + + + Future replaceAll(List sessions, {String? activeServerId}); + + Future getByServerId(String serverId); + Future getActive(); + Future getActiveServerId(); + Future> listAll(); + Future setActive(String serverId); + Future clear([String? serverId]); +} diff --git a/lib/core/domain/ports/sm_account_gateway.dart b/lib/core/domain/ports/sm_account_gateway.dart new file mode 100644 index 0000000..bd861da --- /dev/null +++ b/lib/core/domain/ports/sm_account_gateway.dart @@ -0,0 +1,148 @@ + + +library; + + +class SmLoginResult { + final bool ok; + final String email; + + + final String accessToken; + + + final String refreshToken; + + final int expiresIn; + + + final bool isVip; + + + final int? vipExpiresAt; + + + final bool vipPermanent; + + + final bool needsVerification; + + final String? message; + + const SmLoginResult({ + required this.ok, + this.email = '', + this.accessToken = '', + this.refreshToken = '', + this.expiresIn = 0, + this.isVip = false, + this.vipExpiresAt, + this.vipPermanent = false, + this.needsVerification = false, + this.message, + }); + + factory SmLoginResult.failure( + String message, { + bool needsVerification = false, + }) => SmLoginResult( + ok: false, + needsVerification: needsVerification, + message: message, + ); +} + + +class SmTokenRefreshResult { + final bool ok; + final String accessToken; + final String refreshToken; + final int expiresIn; + + + final bool isVip; + final int? vipExpiresAt; + final bool vipPermanent; + + + final bool hasVipInfo; + + + final bool invalidRefreshToken; + final String? message; + + const SmTokenRefreshResult({ + required this.ok, + this.accessToken = '', + this.refreshToken = '', + this.expiresIn = 0, + this.isVip = false, + this.vipExpiresAt, + this.vipPermanent = false, + this.hasVipInfo = false, + this.invalidRefreshToken = false, + this.message, + }); + + factory SmTokenRefreshResult.failure( + String message, { + bool invalidRefreshToken = false, + }) => SmTokenRefreshResult( + ok: false, + invalidRefreshToken: invalidRefreshToken, + message: message, + ); +} + + +class SmActionResult { + final bool ok; + final String? message; + + const SmActionResult({required this.ok, this.message}); + + factory SmActionResult.success() => const SmActionResult(ok: true); + + factory SmActionResult.failure(String message) => + SmActionResult(ok: false, message: message); +} + + +abstract class SmAccountGateway { + + + Future login({ + required String baseUrl, + required String email, + required String password, + }); + + + Future registerStart({ + required String baseUrl, + required String email, + }); + + + Future registerVerify({ + required String baseUrl, + required String email, + required String password, + required String code, + }); + + + Future resendCode({ + required String baseUrl, + required String email, + }); + + + Future logout({required String baseUrl, required String refreshToken}); + + + Future refreshAccessToken({ + required String baseUrl, + required String refreshToken, + }); +} diff --git a/lib/core/domain/ports/tmdb_gateway.dart b/lib/core/domain/ports/tmdb_gateway.dart new file mode 100644 index 0000000..c02736d --- /dev/null +++ b/lib/core/domain/ports/tmdb_gateway.dart @@ -0,0 +1,126 @@ +import '../../contracts/tmdb.dart'; + + +class TmdbRequestConfig { + final String apiBaseUrl; + final String imageBaseUrl; + final String accessToken; + final String apiKey; + + + final Future Function()? onUnauthorized; + + const TmdbRequestConfig({ + required this.apiBaseUrl, + required this.imageBaseUrl, + this.accessToken = '', + this.apiKey = '', + this.onUnauthorized, + }); +} + +abstract class TmdbGateway { + Future getCredits( + TmdbRequestConfig config, + String tmdbId, + String mediaType, { + required String language, + }); + + Future getRecommendations( + TmdbRequestConfig config, + String tmdbId, + String mediaType, { + required String language, + }); + + Future getSeasonDetail( + TmdbRequestConfig config, + String tmdbId, + int seasonNumber, { + required String language, + }); + + Future getImages( + TmdbRequestConfig config, + String tmdbId, + String mediaType, { + required String language, + }); + + Future getEnrichedDetail( + TmdbRequestConfig config, + String tmdbId, + String mediaType, { + required String language, + List appendToResponse, + }); + + Future getVideos( + TmdbRequestConfig config, + String tmdbId, + String mediaType, { + required String language, + }); + + Future getPersonDetail( + TmdbRequestConfig config, + int personId, { + required String language, + }); + + Future getPersonCredits( + TmdbRequestConfig config, + int personId, { + required String language, + }); + + Future getMediaDetail( + TmdbRequestConfig config, + String tmdbId, + String mediaType, { + required String language, + }); + + Future getTrending( + TmdbRequestConfig config, { + required String language, + String mediaType, + String timeWindow, + }); + + Future getPopularMovies( + TmdbRequestConfig config, { + required String language, + }); + + Future getPopularTv( + TmdbRequestConfig config, { + required String language, + }); + + Future getAiringTodayTv( + TmdbRequestConfig config, { + required String language, + }); + + Future getTopRatedMovies( + TmdbRequestConfig config, { + required String language, + }); + + + Future getDiscover( + TmdbRequestConfig config, + String mediaType, { + required String language, + String sortBy = 'popularity.desc', + String? withGenres, + String? voteCountGte, + }); + + Future testConnection( + TmdbRequestConfig config, { + required String language, + }); +} diff --git a/lib/core/domain/ports/trakt_gateway.dart b/lib/core/domain/ports/trakt_gateway.dart new file mode 100644 index 0000000..8dda5e9 --- /dev/null +++ b/lib/core/domain/ports/trakt_gateway.dart @@ -0,0 +1,14 @@ +import '../../contracts/trakt/trakt_token.dart'; +import '../../contracts/trakt/trakt_user.dart'; + + +abstract class TraktGateway { + + Future exchangeCode(String code); + + + Future refreshToken(String refreshToken); + + + Future getMe(String accessToken); +} diff --git a/lib/core/domain/usecases/auth_usecases.dart b/lib/core/domain/usecases/auth_usecases.dart new file mode 100644 index 0000000..49361bd --- /dev/null +++ b/lib/core/domain/usecases/auth_usecases.dart @@ -0,0 +1,161 @@ +import '../../contracts/contracts.dart'; +import '../errors.dart'; +import '../ports/emby_gateway.dart'; +import '../ports/server_repository.dart'; +import '../ports/session_repository.dart'; + +class AuthByNameUseCase { + final ServerRepository serverRepository; + final SessionRepository sessionRepository; + final EmbyGateway embyGateway; + final DateTime Function() now; + + AuthByNameUseCase({ + required this.serverRepository, + required this.sessionRepository, + required this.embyGateway, + DateTime Function()? now, + }) : now = now ?? DateTime.now; + + Future execute(AuthByNameReq input) async { + final username = input.username.trim(); + final password = input.password; + if (username.isEmpty || password.isEmpty) { + throw DomainError(ErrorCode.invalidParams, '用户名或密码不能为空'); + } + + final server = await serverRepository.findById(input.serverId); + if (server == null) { + throw DomainError( + ErrorCode.storeNotFound, + '服务器不存在', + details: {'serverId': input.serverId}, + ); + } + + final auth = await embyGateway.authenticateByName( + baseUrl: server.baseUrl, + username: username, + password: password, + ); + + final nowIso = now().toUtc().toIso8601String(); + await sessionRepository.set( + SessionData( + serverId: server.id, + token: auth.accessToken, + userId: auth.userId, + userName: auth.userName, + password: password, + createdAt: nowIso, + ), + ); + await serverRepository.touchConnectedAt(server.id, nowIso); + } +} + +class LogoutUseCase { + final SessionRepository sessionRepository; + LogoutUseCase({required this.sessionRepository}); + + Future execute([String? serverId]) => sessionRepository.clear(serverId); +} + +class ListSessionsUseCase { + final SessionRepository sessionRepository; + final ServerRepository serverRepository; + + ListSessionsUseCase({ + required this.sessionRepository, + required this.serverRepository, + }); + + Future execute() async { + final sessions = await sessionRepository.listAll(); + final activeServerId = await sessionRepository.getActiveServerId(); + final result = []; + + for (final session in sessions) { + final server = await serverRepository.findById(session.serverId); + if (server != null) { + result.add( + AuthedSession( + serverId: session.serverId, + serverName: server.name, + serverUrl: server.baseUrl, + token: session.token, + userId: session.userId, + userName: session.userName, + password: session.password, + isActive: session.serverId == activeServerId, + ), + ); + } + } + + return SessionListRes(sessions: result, activeServerId: activeServerId); + } +} + +class SwitchSessionUseCase { + final SessionRepository sessionRepository; + + SwitchSessionUseCase({required this.sessionRepository}); + + Future execute(String serverId) async { + final session = await sessionRepository.getByServerId(serverId); + if (session == null) { + throw DomainError( + ErrorCode.storeNotFound, + '该服务器没有可用会话', + details: {'serverId': serverId}, + ); + } + await sessionRepository.setActive(serverId); + } +} + +class ChangePasswordUseCase { + final SessionRepository sessionRepository; + final ServerRepository serverRepository; + final EmbyGateway embyGateway; + + ChangePasswordUseCase({ + required this.sessionRepository, + required this.serverRepository, + required this.embyGateway, + }); + + Future execute({ + required String serverId, + required String currentPassword, + required String newPassword, + }) async { + if (currentPassword.isEmpty || newPassword.isEmpty) { + throw DomainError(ErrorCode.invalidParams, '密码不能为空'); + } + + final session = await sessionRepository.getByServerId(serverId); + if (session == null) { + throw DomainError(ErrorCode.storeNotFound, '该服务器没有可用会话'); + } + final server = await serverRepository.findById(serverId); + if (server == null) { + throw DomainError(ErrorCode.storeNotFound, '服务器不存在'); + } + + final ctx = AuthedRequestContext( + baseUrl: server.baseUrl, + token: session.token, + userId: session.userId, + ); + + await embyGateway.changePassword( + ctx: ctx, + currentPassword: currentPassword, + newPassword: newPassword, + ); + + await sessionRepository.setMany([session.copyWith(password: newPassword)]); + } +} diff --git a/lib/core/domain/usecases/backup_usecases.dart b/lib/core/domain/usecases/backup_usecases.dart new file mode 100644 index 0000000..c7be161 --- /dev/null +++ b/lib/core/domain/usecases/backup_usecases.dart @@ -0,0 +1,186 @@ +import 'dart:convert'; + +import '../../contracts/contracts.dart'; +import '../errors.dart'; +import '../ports/danmaku_sources_store.dart'; +import '../ports/script_widget_repository.dart'; +import '../ports/server_repository.dart'; +import '../ports/session_repository.dart'; + +const int kBackupSchemaVersion = 1; + +class BackupExportResult { + final String json; + final int serverCount; + final int sessionCount; + final int danmakuSourceCount; + final int scriptWidgetCount; + final int scriptWidgetSubscriptionCount; + + const BackupExportResult({ + required this.json, + required this.serverCount, + required this.sessionCount, + required this.danmakuSourceCount, + required this.scriptWidgetCount, + required this.scriptWidgetSubscriptionCount, + }); +} + +class BackupImportResult { + final int serverCount; + final int sessionCount; + final int danmakuSourceCount; + final int scriptWidgetCount; + final int scriptWidgetSubscriptionCount; + + const BackupImportResult({ + required this.serverCount, + required this.sessionCount, + required this.danmakuSourceCount, + required this.scriptWidgetCount, + required this.scriptWidgetSubscriptionCount, + }); +} + +class ExportBackupUseCase { + final ServerRepository serverRepository; + final SessionRepository sessionRepository; + final DanmakuSourcesStore danmakuSourcesStore; + final ScriptWidgetRepository scriptWidgetRepository; + final DateTime Function() now; + + ExportBackupUseCase({ + required this.serverRepository, + required this.sessionRepository, + required this.danmakuSourcesStore, + required this.scriptWidgetRepository, + DateTime Function()? now, + }) : now = (now ?? DateTime.now); + + Future execute() async { + final servers = await serverRepository.list(); + final sessions = await sessionRepository.listAll(); + final danmakuSources = await danmakuSourcesStore.list(); + final scriptWidgets = await scriptWidgetRepository.listWidgets(); + final scriptWidgetSubscriptions = + await scriptWidgetRepository.listSubscriptions(); + final bundle = BackupBundle( + version: kBackupSchemaVersion, + exportedAt: now().toUtc().toIso8601String(), + servers: servers, + sessions: sessions, + danmakuSources: danmakuSources, + scriptWidgets: scriptWidgets, + scriptWidgetSubscriptions: scriptWidgetSubscriptions, + ); + final json = const JsonEncoder.withIndent(' ').convert(bundle.toJson()); + return BackupExportResult( + json: json, + serverCount: servers.length, + sessionCount: sessions.length, + danmakuSourceCount: danmakuSources.length, + scriptWidgetCount: scriptWidgets.length, + scriptWidgetSubscriptionCount: scriptWidgetSubscriptions.length, + ); + } +} + +class ImportBackupUseCase { + final ServerRepository serverRepository; + final SessionRepository sessionRepository; + final DanmakuSourcesStore danmakuSourcesStore; + final ScriptWidgetRepository scriptWidgetRepository; + + ImportBackupUseCase({ + required this.serverRepository, + required this.sessionRepository, + required this.danmakuSourcesStore, + required this.scriptWidgetRepository, + }); + + Future execute(String rawJson) async { + final BackupBundle bundle; + try { + final decoded = jsonDecode(rawJson); + if (decoded is! Map) { + throw DomainError(ErrorCode.invalidParams, '备份文件格式不正确'); + } + bundle = BackupBundle.fromJson(decoded); + } on DomainError { + rethrow; + } catch (e) { + throw DomainError( + ErrorCode.invalidParams, + '备份文件解析失败', + details: {'reason': e.toString()}, + ); + } + + if (bundle.version != kBackupSchemaVersion) { + throw DomainError( + ErrorCode.invalidParams, + '不支持的备份文件版本:${bundle.version}', + details: {'expected': kBackupSchemaVersion, 'got': bundle.version}, + ); + } + + for (final server in bundle.servers) { + await serverRepository.update(server); + } + await sessionRepository.setMany(bundle.sessions); + + final mergedSources = mergeDanmakuSourcesByUrl( + local: await danmakuSourcesStore.list(), + remote: bundle.danmakuSources, + ); + await danmakuSourcesStore.replaceAll(mergedSources); + + for (final scriptWidget in bundle.scriptWidgets) { + await scriptWidgetRepository.upsertWidget(scriptWidget); + } + for (final subscription in bundle.scriptWidgetSubscriptions) { + await scriptWidgetRepository.upsertSubscription(subscription); + } + + return BackupImportResult( + serverCount: bundle.servers.length, + sessionCount: bundle.sessions.length, + danmakuSourceCount: mergedSources.length, + scriptWidgetCount: bundle.scriptWidgets.length, + scriptWidgetSubscriptionCount: bundle.scriptWidgetSubscriptions.length, + ); + } +} + + +List mergeDanmakuSourcesByUrl({ + required List local, + required List remote, +}) { + final localByUrl = { + for (final s in local) s.url.trim(): s, + }; + final out = []; + final seen = {}; + for (final r in remote) { + final url = r.url.trim(); + if (url.isEmpty || seen.contains(url)) continue; + seen.add(url); + final hit = localByUrl[url]; + if (hit != null) { + out.add(hit.copyWith(name: hit.name.trim().isEmpty ? r.name : hit.name)); + } else { + out.add( + DanmakuSource( + id: r.id.trim().isEmpty + ? 'src_${url.hashCode.toUnsigned(32).toRadixString(16)}' + : r.id, + name: r.name, + url: url, + ), + ); + } + } + return out; +} diff --git a/lib/core/domain/usecases/library_usecases.dart b/lib/core/domain/usecases/library_usecases.dart new file mode 100644 index 0000000..5489add --- /dev/null +++ b/lib/core/domain/usecases/library_usecases.dart @@ -0,0 +1,346 @@ +import 'dart:async'; + +import '../../../shared/utils/app_logger.dart'; +import '../../contracts/contracts.dart'; +import '../ports/emby_gateway.dart'; +import '../ports/server_repository.dart'; +import '../ports/session_repository.dart'; +import 'usecase_helpers.dart'; + +const _ucTag = 'UseCase'; + + +Stream _fanOutPerServer({ + required SessionRepository sessionRepository, + required ServerRepository serverRepository, + required Future?> Function(AuthedRequestContext context) + fetchItems, + required String logTag, +}) { + final controller = StreamController(); + () async { + try { + final allSessions = await sessionRepository.listAll(); + final allServers = await serverRepository.list(); + final serverMap = {for (final s in allServers) s.id: s}; + + final futures = allSessions.map((session) async { + final server = serverMap[session.serverId]; + if (server == null) return; + final context = AuthedRequestContext( + baseUrl: server.baseUrl, + token: session.token, + userId: session.userId, + ); + try { + final items = await fetchItems(context); + if (items == null || items.isEmpty) return; + controller.add( + GlobalSearchServerResult( + serverId: server.id, + serverName: server.name, + serverUrl: server.baseUrl, + token: session.token, + userId: session.userId, + items: items, + ), + ); + } catch (e) { + AppLogger.warn(_ucTag, '$logTag on ${server.name} failed', e); + } + }); + + await Future.wait(futures); + } catch (e) { + controller.addError(e); + } finally { + controller.close(); + } + }(); + return controller.stream; +} + +class GetLibraryViewsUseCase extends AuthedUseCase { + GetLibraryViewsUseCase({ + required super.sessionRepository, + required super.serverRepository, + required super.embyGateway, + }); + + Future execute() async { + return embyGateway.getUserViews(await ctx()); + } +} + +class GetLibraryResumeUseCase extends AuthedUseCase { + GetLibraryResumeUseCase({ + required super.sessionRepository, + required super.serverRepository, + required super.embyGateway, + }); + + Future execute() async { + return embyGateway.getResumeItems(await ctx(), limit: 20); + } +} + +class GetLibraryLatestUseCase extends AuthedUseCase { + GetLibraryLatestUseCase({ + required super.sessionRepository, + required super.serverRepository, + required super.embyGateway, + }); + + Future execute( + LibraryLatestReq input, { + CancellationToken? cancelToken, + }) async { + return embyGateway.getLatestItems( + await ctx(), + input.parentId, + limit: input.limit ?? 16, + cancelToken: cancelToken, + ); + } +} + +class GetLibraryItemsUseCase extends AuthedUseCase { + GetLibraryItemsUseCase({ + required super.sessionRepository, + required super.serverRepository, + required super.embyGateway, + }); + + Future execute(LibraryItemsReq input) async { + final items = await embyGateway.getLibraryItems( + ctx: await ctx(), + parentId: input.parentId, + includeItemTypes: input.includeItemTypes, + genreIds: input.genreIds, + searchTerm: input.searchTerm, + anyProviderIdEquals: input.anyProviderIdEquals, + fields: input.fields, + enableImageTypes: input.enableImageTypes, + groupProgramsBySeries: input.groupProgramsBySeries, + imageTypeLimit: input.imageTypeLimit, + startIndex: input.startIndex, + limit: input.limit, + recursive: input.recursive, + sortBy: input.sortBy, + sortOrder: input.sortOrder, + ); + return LibraryItemsRes( + parentId: input.parentId ?? '__global__', + items: items, + ); + } +} + + +class GetHomeBannerUseCase extends AuthedUseCase { + GetHomeBannerUseCase({ + required super.sessionRepository, + required super.serverRepository, + required super.embyGateway, + }); + + Future> execute({int fetchLimit = 24, int take = 8}) async { + final items = await embyGateway.getLibraryItems( + ctx: await ctx(), + includeItemTypes: 'Movie,Series', + recursive: true, + sortBy: 'DateCreated', + sortOrder: SortOrder.descending, + fields: + 'BasicSyncInfo,CommunityRating,ProductionYear,Overview,Genres,ProviderIds,UserData', + enableImageTypes: 'Backdrop,Logo,Primary,Thumb', + imageTypeLimit: 2, + limit: fetchLimit, + ); + return items + .where((it) => (it.BackdropImageTags ?? const []).isNotEmpty) + .take(take) + .toList(growable: false); + } +} + +class GlobalKeywordSearchUseCase extends AuthedUseCase { + GlobalKeywordSearchUseCase({ + required super.sessionRepository, + required super.serverRepository, + required super.embyGateway, + }); + + static const _defaultTypes = 'Movie,Series'; + + Stream executeStream(GlobalSearchReq input) { + final controller = StreamController(); + () async { + final keyword = input.keyword.trim(); + if (keyword.isEmpty) { + controller.close(); + return; + } + try { + var sessions = await sessionRepository.listAll(); + if (input.serverId != null) { + sessions = sessions + .where((s) => s.serverId == input.serverId) + .toList(); + } + final allServers = await serverRepository.list(); + final serverMap = {for (final s in allServers) s.id: s}; + final types = input.includeItemTypes ?? _defaultTypes; + + final futures = sessions.map((session) async { + final result = await _searchOne( + session: session, + server: serverMap[session.serverId], + keyword: keyword, + types: types, + limit: input.limitPerServer ?? 30, + ); + if (result != null) controller.add(result); + }); + + await Future.wait(futures); + } catch (error, stackTrace) { + controller.addError(error, stackTrace); + } finally { + controller.close(); + } + }(); + return controller.stream; + } + + Future _searchOne({ + required SessionData session, + required EmbyServer? server, + required String keyword, + required String types, + required int limit, + }) async { + if (server == null) return null; + final c = AuthedRequestContext( + baseUrl: server.baseUrl, + token: session.token, + userId: session.userId, + ); + try { + final items = await embyGateway.getLibraryItems( + ctx: c, + searchTerm: keyword, + recursive: true, + includeItemTypes: types, + limit: limit, + sortBy: 'SortName', + sortOrder: SortOrder.ascending, + ); + if (items.isEmpty) return null; + return GlobalSearchServerResult( + serverId: server.id, + serverName: server.name, + serverUrl: server.baseUrl, + token: session.token, + userId: session.userId, + items: items, + ); + } catch (e) { + AppLogger.warn(_ucTag, 'GlobalKeywordSearch on ${server.name} failed', e); + return null; + } + } +} + +class GetAggregatedResumeUseCase extends AuthedUseCase { + GetAggregatedResumeUseCase({ + required super.sessionRepository, + required super.serverRepository, + required super.embyGateway, + }); + + Stream executeStream({int? limitPerServer}) { + return _fanOutPerServer( + sessionRepository: sessionRepository, + serverRepository: serverRepository, + logTag: 'AggregatedResume', + fetchItems: (context) async { + final res = await embyGateway.getResumeItems( + context, + limit: limitPerServer ?? 20, + ); + return res.Items; + }, + ); + } +} + +class GetAggregatedFavoritesUseCase extends AuthedUseCase { + static const _defaultTypes = 'Movie,Series,Episode'; + + GetAggregatedFavoritesUseCase({ + required super.sessionRepository, + required super.serverRepository, + required super.embyGateway, + }); + + Stream executeStream({ + String includeItemTypes = _defaultTypes, + int? limitPerServer, + }) { + final types = includeItemTypes.trim().isEmpty + ? _defaultTypes + : includeItemTypes; + return _fanOutPerServer( + sessionRepository: sessionRepository, + serverRepository: serverRepository, + logTag: 'AggregatedFavorites', + fetchItems: (context) async { + final items = await embyGateway.getFavoriteItems( + ctx: context, + includeItemTypes: types, + sortBy: 'SortName', + sortOrder: SortOrder.ascending, + ); + return limitPerServer == null + ? items + : items.take(limitPerServer).toList(growable: false); + }, + ); + } +} + +class GetAggregatedRecentUseCase extends AuthedUseCase { + static const _defaultTypes = 'Movie,Series'; + + GetAggregatedRecentUseCase({ + required super.sessionRepository, + required super.serverRepository, + required super.embyGateway, + }); + + Stream executeStream({ + String includeItemTypes = _defaultTypes, + int? limitPerServer, + }) { + return _fanOutPerServer( + sessionRepository: sessionRepository, + serverRepository: serverRepository, + logTag: 'AggregatedRecent', + fetchItems: (context) async { + return embyGateway.getLibraryItems( + ctx: context, + includeItemTypes: includeItemTypes, + fields: + 'BasicSyncInfo,CollectionType,PrimaryImageAspectRatio,UserData,' + 'CommunityRating,ProviderIds,ProductionYear,ChildCount,Container,' + 'CanDelete,DateCreated', + sortBy: 'DateCreated', + sortOrder: SortOrder.descending, + recursive: true, + limit: limitPerServer ?? 20, + ); + }, + ); + } +} diff --git a/lib/core/domain/usecases/media_usecases.dart b/lib/core/domain/usecases/media_usecases.dart new file mode 100644 index 0000000..feb8111 --- /dev/null +++ b/lib/core/domain/usecases/media_usecases.dart @@ -0,0 +1,913 @@ +import 'dart:async'; + +import '../../../shared/utils/app_logger.dart'; +import '../../contracts/contracts.dart'; +import '../ports/emby_gateway.dart'; +import 'usecase_helpers.dart'; + +const _ucTag = 'UseCase'; + +class GetMediaDetailUseCase extends AuthedUseCase { + GetMediaDetailUseCase({ + required super.sessionRepository, + required super.serverRepository, + required super.embyGateway, + }); + + Future execute(MediaDetailReq input) async { + final base = await embyGateway.getItemDetail(await ctx(), input.itemId); + return MediaDetailRes(base: base); + } + + + Future executeForServer({ + required String serverId, + required String itemId, + }) async { + final ctx = await requireCtxForServer(serverId); + final base = await embyGateway.getItemDetail(ctx, itemId); + return MediaDetailRes(base: base); + } +} + +class SetFavoriteUseCase extends AuthedUseCase { + SetFavoriteUseCase({ + required super.sessionRepository, + required super.serverRepository, + required super.embyGateway, + }); + + Future execute(FavoriteSetReq input) async { + final result = await embyGateway.setFavoriteStatus( + ctx: await ctx(), + itemId: input.itemId, + isFavorite: input.isFavorite, + ); + return FavoriteSetRes(itemId: input.itemId, isFavorite: result); + } + + + Future executeForServer({ + required String serverId, + required FavoriteSetReq input, + }) async { + final ctx = await requireCtxForServer(serverId); + final result = await embyGateway.setFavoriteStatus( + ctx: ctx, + itemId: input.itemId, + isFavorite: input.isFavorite, + ); + return FavoriteSetRes(itemId: input.itemId, isFavorite: result); + } +} + +class SetPlayedUseCase extends AuthedUseCase { + SetPlayedUseCase({ + required super.sessionRepository, + required super.serverRepository, + required super.embyGateway, + }); + + Future execute(PlayedSetReq input) async { + final result = await embyGateway.setPlayedStatus( + ctx: await ctx(), + itemId: input.itemId, + played: input.played, + ); + return PlayedSetRes(itemId: input.itemId, played: result); + } + + + Future executeForServer({ + required String serverId, + required PlayedSetReq input, + }) async { + final ctx = await requireCtxForServer(serverId); + final result = await embyGateway.setPlayedStatus( + ctx: ctx, + itemId: input.itemId, + played: input.played, + ); + return PlayedSetRes(itemId: input.itemId, played: result); + } +} + +class HideFromResumeUseCase extends AuthedUseCase { + HideFromResumeUseCase({ + required super.sessionRepository, + required super.serverRepository, + required super.embyGateway, + }); + + Future execute(HideFromResumeReq input) async { + final result = await embyGateway.hideFromResume( + ctx: await ctx(), + itemId: input.itemId, + hide: input.hide, + ); + return HideFromResumeRes(itemId: input.itemId, hide: result); + } +} + +class GetSeriesEpisodesUseCase extends AuthedUseCase { + GetSeriesEpisodesUseCase({ + required super.sessionRepository, + required super.serverRepository, + required super.embyGateway, + }); + + Future execute(MediaEpisodesReq input) async { + final items = await embyGateway.getSeriesEpisodes( + await ctx(), + input.seriesId, + input.seasonId, + ); + return MediaEpisodesRes( + seriesId: input.seriesId, + seasonId: input.seasonId, + items: items, + ); + } + + + Future executeForServer({ + required String serverId, + required MediaEpisodesReq input, + }) async { + final ctx = await requireCtxForServer(serverId); + final items = await embyGateway.getSeriesEpisodes( + ctx, + input.seriesId, + input.seasonId, + ); + return MediaEpisodesRes( + seriesId: input.seriesId, + seasonId: input.seasonId, + items: items, + ); + } +} + +class GetSeriesSeasonsUseCase extends AuthedUseCase { + GetSeriesSeasonsUseCase({ + required super.sessionRepository, + required super.serverRepository, + required super.embyGateway, + }); + + Future execute(SeriesSeasonsReq input) async { + final seasons = await embyGateway.getSeriesSeasons( + await ctx(), + input.seriesId, + ); + return SeriesSeasonsRes(seriesId: input.seriesId, items: seasons); + } + + + Future executeForServer({ + required String serverId, + required SeriesSeasonsReq input, + }) async { + final ctx = await requireCtxForServer(serverId); + final seasons = await embyGateway.getSeriesSeasons(ctx, input.seriesId); + return SeriesSeasonsRes(seriesId: input.seriesId, items: seasons); + } +} + +class GetSimilarItemsUseCase extends AuthedUseCase { + GetSimilarItemsUseCase({ + required super.sessionRepository, + required super.serverRepository, + required super.embyGateway, + }); + + Future execute(SimilarItemsReq input) async { + final items = await embyGateway.getSimilarItems( + await ctx(), + input.itemId, + limit: input.limit, + ); + return SimilarItemsRes(itemId: input.itemId, items: items); + } +} + +class GetAdditionalPartsUseCase extends AuthedUseCase { + GetAdditionalPartsUseCase({ + required super.sessionRepository, + required super.serverRepository, + required super.embyGateway, + }); + + Future execute(AdditionalPartsReq input) async { + final items = await embyGateway.getAdditionalParts( + await ctx(), + input.itemId, + ); + return AdditionalPartsRes(itemId: input.itemId, items: items); + } + + + Future executeForServer({ + required String serverId, + required String itemId, + }) async { + final ctx = await resolveAuthedContextForServer( + sessionRepository: sessionRepository, + serverRepository: serverRepository, + serverId: serverId, + ); + if (ctx == null) { + return AdditionalPartsRes(itemId: itemId, items: const []); + } + final items = await embyGateway.getAdditionalParts(ctx, itemId); + return AdditionalPartsRes(itemId: itemId, items: items); + } +} + +class GetSpecialFeaturesUseCase extends AuthedUseCase { + GetSpecialFeaturesUseCase({ + required super.sessionRepository, + required super.serverRepository, + required super.embyGateway, + }); + + Future> execute(String itemId) async { + return embyGateway.getSpecialFeatures(await ctx(), itemId); + } +} + +class CrossServerSearchUseCase extends AuthedUseCase { + CrossServerSearchUseCase({ + required super.sessionRepository, + required super.serverRepository, + required super.embyGateway, + }); + + + final Map _seriesIdentityCache = {}; + final Map _emptyServerSearchStreaks = {}; + + static const _emptyServerSkipThreshold = 3; + static const _emptyServerSkipTtl = Duration(hours: 1); + + String _identityCacheKey(String serverId, String seriesPidQuery) => + '$serverId|$seriesPidQuery'; + + + Stream> executeStream( + CrossServerSearchReq input, { + CancellationToken? cancelToken, + }) { + final controller = StreamController>(); + final sw = Stopwatch()..start(); + () async { + try { + final activeServerId = await sessionRepository.getActiveServerId(); + final excludedServerId = input.excludedServerId ?? activeServerId; + final allSessions = await sessionRepository.listAll(); + final allServers = await serverRepository.list(); + final serverMap = {for (final s in allServers) s.id: s}; + + final acc = []; + var probed = 0; + var skipped = 0; + final futures = >[]; + for (final session in allSessions) { + if (session.serverId == excludedServerId) continue; + final server = serverMap[session.serverId]; + if (server == null) continue; + if (_shouldSkipEmptyServer(server)) { + skipped++; + final streak = _emptyServerSearchStreaks[server.id]; + AppLogger.debug( + 'DetailTiming', + 'xserver.server name=${server.name} skipped emptyStreak=${streak?.count ?? 0}', + ); + continue; + } + probed++; + futures.add(() async { + final serverSw = Stopwatch()..start(); + final cards = await _buildCardsForServer( + server, + session, + input, + cancelToken: cancelToken, + ); + AppLogger.debug( + 'DetailTiming', + 'xserver.server name=${server.name} cards=${cards.length} ms=${serverSw.elapsedMilliseconds}', + ); + if (cancelToken?.isCancelled == true) return; + _recordServerSearchResult(server, cards); + if (cards.isEmpty) return; + acc.addAll(cards); + if (!controller.isClosed) { + controller.add(List.unmodifiable(acc)); + } + }()); + } + await Future.wait(futures); + AppLogger.debug( + 'DetailTiming', + 'xserver.total probed=$probed skipped=$skipped cards=${acc.length} ms=${sw.elapsedMilliseconds}', + ); + } catch (e) { + if (!controller.isClosed) controller.addError(e); + } finally { + if (!controller.isClosed) await controller.close(); + } + }(); + return controller.stream; + } + + bool _shouldSkipEmptyServer(EmbyServer server) { + final streak = _emptyServerSearchStreaks[server.id]; + final skipUntil = streak?.skipUntil; + if (skipUntil == null) return false; + if (DateTime.now().isBefore(skipUntil)) return true; + _emptyServerSearchStreaks.remove(server.id); + return false; + } + + void _recordServerSearchResult( + EmbyServer server, + List cards, + ) { + if (cards.isNotEmpty) { + _emptyServerSearchStreaks.remove(server.id); + return; + } + final streak = _emptyServerSearchStreaks.putIfAbsent( + server.id, + _EmptyServerSearchStreak.new, + ); + streak.count++; + if (streak.count < _emptyServerSkipThreshold) return; + streak.skipUntil = DateTime.now().add(_emptyServerSkipTtl); + AppLogger.debug( + 'DetailTiming', + 'xserver.server name=${server.name} emptyStreak=${streak.count} ' + 'skipUntil=${streak.skipUntil!.toIso8601String()}', + ); + } + + Future> _buildCardsForServer( + EmbyServer server, + SessionData session, + CrossServerSearchReq req, { + CancellationToken? cancelToken, + }) async { + final ctx = AuthedRequestContext( + baseUrl: server.baseUrl, + token: session.token, + userId: session.userId, + ); + try { + if (req.anchorType == 'Movie') { + return await _buildMovieCards( + ctx, + server, + session, + req, + cancelToken: cancelToken, + ); + } + if (req.anchorType == 'Episode') { + return await _buildEpisodeCards( + ctx, + server, + session, + req, + cancelToken: cancelToken, + ); + } + return const []; + } on CancelledException { + return const []; + } catch (e) { + AppLogger.warn(_ucTag, 'CrossServerSearch on ${server.name} failed', e); + return const []; + } + } + + Future> _buildMovieCards( + AuthedRequestContext ctx, + EmbyServer server, + SessionData session, + CrossServerSearchReq req, { + CancellationToken? cancelToken, + }) async { + const movieFields = 'ProviderIds,UserData,MediaSources,MediaStreams'; + final providerQuery = _buildProviderIdQuery(req.providerIds); + var items = []; + if (providerQuery != null && providerQuery.isNotEmpty) { + final providerItems = await embyGateway.getLibraryItems( + ctx: ctx, + includeItemTypes: 'Movie', + anyProviderIdEquals: providerQuery, + fields: movieFields, + limit: 10, + recursive: true, + cancelToken: cancelToken, + ); + items = providerItems + .where((item) => _itemMatchesAnyProviderId(item, req.providerIds)) + .toList(growable: false); + final droppedItemCount = providerItems.length - items.length; + if (droppedItemCount > 0) { + AppLogger.debug( + 'DetailTiming', + 'xserver.provider-filter server=${server.name} ' + 'dropped=$droppedItemCount returned=${providerItems.length}', + ); + } + } + if (items.isEmpty && req.itemName.isNotEmpty) { + final nameSearchItems = await embyGateway.getLibraryItems( + ctx: ctx, + includeItemTypes: 'Movie', + searchTerm: req.itemName, + fields: movieFields, + limit: 20, + recursive: true, + cancelToken: cancelToken, + ); + items = nameSearchItems + .where( + (item) => !_itemHasConflictingProviderId(item, req.providerIds), + ) + .toList(growable: false); + } + final cards = []; + if (cancelToken?.isCancelled == true) return List.unmodifiable(cards); + final detailedList = await Future.wait( + items.map( + (it) => _fetchDetailedItem(ctx, server, it, cancelToken: cancelToken), + ), + ); + if (cancelToken?.isCancelled == true) return List.unmodifiable(cards); + for (final detailed in detailedList) { + cards.addAll(_expandToCards(server, session, detailed)); + } + return List.unmodifiable(cards); + } + + + Future _fetchDetailedItem( + AuthedRequestContext ctx, + EmbyServer server, + EmbyRawItem item, { + CancellationToken? cancelToken, + }) async { + if (cancelToken?.isCancelled == true) return item; + try { + final full = await embyGateway.getItemDetail(ctx, item.Id); + final fullSources = full.extra['MediaSources']; + if (fullSources is List && fullSources.isNotEmpty) return full; + return item; + } catch (e) { + AppLogger.warn( + _ucTag, + 'CrossServer detail fetch failed on ${server.name} item=${item.Id}', + e, + ); + return item; + } + } + + Future> _buildEpisodeCards( + AuthedRequestContext ctx, + EmbyServer server, + SessionData session, + CrossServerSearchReq req, { + CancellationToken? cancelToken, + }) async { + const episodeFields = 'ProviderIds,UserData,MediaSources,MediaStreams'; + + final providerQuery = _buildProviderIdQuery(req.providerIds); + if (providerQuery != null && providerQuery.isNotEmpty) { + final episodes = await embyGateway.getLibraryItems( + ctx: ctx, + includeItemTypes: 'Episode', + anyProviderIdEquals: providerQuery, + fields: episodeFields, + limit: 5, + recursive: true, + cancelToken: cancelToken, + ); + final exact = _matchEpisodeBySE( + episodes, + req.parentIndexNumber, + req.indexNumber, + ); + if (exact != null) { + final detailed = await _fetchDetailedItem( + ctx, + server, + exact, + cancelToken: cancelToken, + ); + return _expandToCards(server, session, detailed); + } + } + + final seriesProviderIds = req.seriesProviderIds; + if (seriesProviderIds == null || seriesProviderIds.isEmpty) { + return const []; + } + + final identity = await _resolveSeriesIdentity( + ctx, + server.id, + seriesProviderIds, + cancelToken: cancelToken, + ); + if (identity == null) return const []; + + EmbyRawSeason? matchedSeason; + for (final s in identity.seasons) { + if (s.IndexNumber == req.parentIndexNumber) { + matchedSeason = s; + break; + } + } + if (matchedSeason == null) return const []; + + final episodes = await embyGateway.getSeriesEpisodes( + ctx, + identity.remoteSeriesId, + matchedSeason.Id, + cancelToken: cancelToken, + ); + final target = _matchEpisodeBySE( + episodes, + req.parentIndexNumber, + req.indexNumber, + ); + if (target == null) return const []; + final detailed = await _fetchDetailedItem( + ctx, + server, + target, + cancelToken: cancelToken, + ); + return _expandToCards(server, session, detailed); + } + + + Future<_SeriesIdentity?> _resolveSeriesIdentity( + AuthedRequestContext ctx, + String serverId, + Map seriesProviderIds, { + CancellationToken? cancelToken, + }) async { + final seriesPidQuery = _buildProviderIdQuery(seriesProviderIds); + if (seriesPidQuery == null || seriesPidQuery.isEmpty) return null; + final key = _identityCacheKey(serverId, seriesPidQuery); + if (_seriesIdentityCache.containsKey(key)) { + return _seriesIdentityCache[key]; + } + final seriesList = await embyGateway.getLibraryItems( + ctx: ctx, + includeItemTypes: 'Series', + anyProviderIdEquals: seriesPidQuery, + fields: 'ProviderIds', + limit: 1, + recursive: true, + cancelToken: cancelToken, + ); + if (seriesList.isEmpty || + !_itemMatchesAnyProviderId(seriesList.first, seriesProviderIds)) { + _seriesIdentityCache[key] = null; + return null; + } + final remoteSeriesId = seriesList.first.Id; + final seasons = await embyGateway.getSeriesSeasons( + ctx, + remoteSeriesId, + cancelToken: cancelToken, + ); + final identity = _SeriesIdentity( + remoteSeriesId: remoteSeriesId, + seasons: seasons, + ); + _seriesIdentityCache[key] = identity; + return identity; + } + + + Future prewarmSeriesIdentity( + Map seriesProviderIds, { + CancellationToken? cancelToken, + }) async { + if (seriesProviderIds.isEmpty) return; + + try { + final activeServerId = await sessionRepository.getActiveServerId(); + final allSessions = await sessionRepository.listAll(); + final allServers = await serverRepository.list(); + final serverMap = {for (final s in allServers) s.id: s}; + + final futures = >[]; + for (final session in allSessions) { + if (session.serverId == activeServerId) continue; + final server = serverMap[session.serverId]; + if (server == null) continue; + final ctx = AuthedRequestContext( + baseUrl: server.baseUrl, + token: session.token, + userId: session.userId, + ); + futures.add(() async { + try { + await _resolveSeriesIdentity( + ctx, + server.id, + seriesProviderIds, + cancelToken: cancelToken, + ); + } on CancelledException { + return; + } catch (e) { + AppLogger.warn( + _ucTag, + 'prewarmSeriesIdentity on ${server.name} failed', + e, + ); + } + }()); + } + await Future.wait(futures); + } on CancelledException { + + } catch (e) { + AppLogger.warn(_ucTag, 'prewarmSeriesIdentity setup failed', e); + } + } + + static List _expandToCards( + EmbyServer server, + SessionData session, + EmbyRawItem item, + ) { + final raw = item.extra['MediaSources']; + if (raw is! List) return const []; + final sources = raw + .whereType>() + .map(EmbyRawMediaSource.fromJson) + .toList(); + final cards = []; + for (final s in sources) { + final id = s.Id; + if (id == null) continue; + cards.add( + CrossServerSourceCard( + serverId: server.id, + serverName: server.name, + serverUrl: server.baseUrl, + token: session.token, + userId: session.userId, + landingItemId: item.Id, + mediaSourceId: id, + item: item, + mediaSource: s, + quality: _extractQualityFromSource(s), + ), + ); + } + return cards; + } + + static EmbyRawItem? _matchEpisodeBySE( + List episodes, + int? parentIndex, + int? index, + ) { + if (parentIndex == null || index == null) return null; + for (final it in episodes) { + final ps = (it.extra['ParentIndexNumber'] as num?)?.toInt(); + if (ps == parentIndex && it.IndexNumber == index) return it; + } + return null; + } + + static bool _itemMatchesAnyProviderId( + EmbyRawItem item, + Map requestedProviderIds, + ) { + final normalizedRequestedProviderIds = normalizeProviderIds( + requestedProviderIds, + ); + if (normalizedRequestedProviderIds.isEmpty) return false; + + final normalizedItemProviderIds = normalizedProviderIdsOfItem(item); + for (final requestedEntry in normalizedRequestedProviderIds.entries) { + if (normalizedItemProviderIds[requestedEntry.key] == + requestedEntry.value) { + return true; + } + } + return false; + } + + static bool _itemHasConflictingProviderId( + EmbyRawItem item, + Map requestedProviderIds, + ) { + final normalizedRequestedProviderIds = normalizeProviderIds( + requestedProviderIds, + ); + if (normalizedRequestedProviderIds.isEmpty) return false; + + final normalizedItemProviderIds = normalizedProviderIdsOfItem(item); + for (final requestedEntry in normalizedRequestedProviderIds.entries) { + final itemProviderId = normalizedItemProviderIds[requestedEntry.key]; + if (itemProviderId != null && itemProviderId != requestedEntry.value) { + return true; + } + } + return false; + } + + static MediaQualityInfo? _extractQualityFromSource(EmbyRawMediaSource src) { + final streams = src.MediaStreams ?? const []; + if (streams.isEmpty || !streams.any((s) => s.Type == 'Video')) { + final fallback = MediaQualityInfo.fromSourceName( + src.Name, + fallbackHeight: src.Height, + container: src.Container, + ); + AppLogger.debug( + 'DetailQuality', + 'name-fallback sourceId=${src.Id ?? 'null'} ' + 'name="${src.Name ?? 'null'}" -> ' + '${fallback == null ? 'null' : '"${fallback.resolutionLabel} ${fallback.dynamicRangeLabel}"'}', + ); + return fallback; + } + return MediaQualityInfo.fromMediaSource(src); + } + + static String? _buildProviderIdQuery(Map providerIds) { + if (providerIds.isEmpty) return null; + final parts = []; + for (final entry in providerIds.entries) { + if (entry.value.isNotEmpty) { + parts.add('${entry.key}.${entry.value}'); + } + } + if (parts.isEmpty) return null; + return parts.join(','); + } +} + +class _SeriesIdentity { + final String remoteSeriesId; + final List seasons; + const _SeriesIdentity({required this.remoteSeriesId, required this.seasons}); +} + +class _EmptyServerSearchStreak { + int count = 0; + DateTime? skipUntil; +} + + +String _buildAvailabilityProviderQuery({ + required String tmdbId, + String? imdbId, +}) { + final parts = ['Tmdb.${tmdbId.trim()}']; + final imdb = imdbId?.trim() ?? ''; + if (imdb.isNotEmpty) parts.add('Imdb.$imdb'); + return parts.join(','); +} + + +class TmdbAvailabilityUseCase extends AuthedUseCase { + TmdbAvailabilityUseCase({ + required super.sessionRepository, + required super.serverRepository, + required super.embyGateway, + this.settleTimeout = const Duration(seconds: 3), + }); + + final Duration settleTimeout; + + Stream> executeStream( + TmdbAvailabilityReq input, { + CancellationToken? cancelToken, + }) { + final controller = StreamController>(); + Timer? settleTimer; + () async { + try { + final tmdbId = input.tmdbId.trim(); + if (tmdbId.isEmpty) { + if (!controller.isClosed) controller.add(const []); + return; + } + final isMovie = input.mediaType == 'movie'; + final includeItemTypes = isMovie ? 'Movie' : 'Series'; + final providerQuery = _buildAvailabilityProviderQuery( + tmdbId: tmdbId, + imdbId: isMovie ? input.imdbId : null, + ); + + final allSessions = await sessionRepository.listAll(); + final allServers = await serverRepository.list(); + final serverMap = {for (final s in allServers) s.id: s}; + + final acc = []; + var settled = false; + void emit() { + if (!controller.isClosed) controller.add(List.unmodifiable(acc)); + } + + var pending = 0; + final futures = >[]; + for (final session in allSessions) { + final server = serverMap[session.serverId]; + if (server == null) continue; + pending++; + futures.add( + _probeServer( + server, + session, + includeItemTypes, + providerQuery, + cancelToken: cancelToken, + ).then((hit) { + pending--; + if (hit != null) acc.add(hit); + if (pending == 0) { + settleTimer?.cancel(); + if (!settled || hit != null) emit(); + } else if (settled && hit != null) { + emit(); + } + }), + ); + } + if (pending == 0) { + emit(); + return; + } + settleTimer = Timer(settleTimeout, () { + settled = true; + emit(); + }); + await Future.wait(futures); + } catch (e) { + if (!controller.isClosed) controller.addError(e); + } finally { + settleTimer?.cancel(); + if (!controller.isClosed) await controller.close(); + } + }(); + return controller.stream; + } + + Future _probeServer( + EmbyServer server, + SessionData session, + String includeItemTypes, + String providerQuery, { + CancellationToken? cancelToken, + }) async { + final ctx = AuthedRequestContext( + baseUrl: server.baseUrl, + token: session.token, + userId: session.userId, + ); + try { + final items = await embyGateway.getLibraryItems( + ctx: ctx, + includeItemTypes: includeItemTypes, + anyProviderIdEquals: providerQuery, + fields: 'ProviderIds,ProductionYear,UserData', + limit: 1, + recursive: true, + cancelToken: cancelToken, + ); + if (items.isEmpty) return null; + final item = items.first; + return TmdbLibraryHit( + serverId: server.id, + serverName: server.name, + itemId: item.Id, + itemType: item.Type ?? includeItemTypes, + name: item.Name, + productionYear: item.ProductionYear, + playbackPositionTicks: item.UserData?.PlaybackPositionTicks, + runTimeTicks: item.RunTimeTicks, + ); + } on CancelledException { + return null; + } catch (e) { + AppLogger.warn(_ucTag, 'TmdbAvailability on ${server.name} failed', e); + return null; + } + } +} diff --git a/lib/core/domain/usecases/script_widget/script_widget_service.dart b/lib/core/domain/usecases/script_widget/script_widget_service.dart new file mode 100644 index 0000000..126e37e --- /dev/null +++ b/lib/core/domain/usecases/script_widget/script_widget_service.dart @@ -0,0 +1,628 @@ +import 'dart:convert'; + +import '../../../contracts/danmaku.dart'; +import '../../../contracts/error.dart'; +import '../../../contracts/script_widget.dart'; +import '../../../../shared/utils/app_logger.dart'; +import '../../../../shared/utils/async_lock.dart'; +import '../../errors.dart'; +import '../../ports/danmaku_sources_store.dart'; +import '../../ports/script_widget_remote_gateway.dart'; +import '../../ports/script_widget_repository.dart'; +import '../../ports/script_widget_runtime.dart'; + +const String scriptWidgetHostVersion = '0.0.2'; + +class ScriptWidgetContentSection { + final String title; + final List items; + + const ScriptWidgetContentSection({required this.title, required this.items}); +} + +class ScriptWidgetService { + static const String _tag = 'ScriptWidgetService'; + + final ScriptWidgetRepository repository; + final ScriptWidgetRemoteGateway remoteGateway; + final ScriptWidgetRuntime runtime; + final DanmakuSourcesStore? danmakuSourcesStore; + final Future Function()? onDanmakuSourcesChanged; + final Map _widgetLocks = {}; + + ScriptWidgetService({ + required this.repository, + required this.remoteGateway, + required this.runtime, + this.danmakuSourcesStore, + this.onDanmakuSourcesChanged, + }); + + Future> listWidgets() => repository.listWidgets(); + + Future> listSubscriptions() => + repository.listSubscriptions(); + + Future requireWidget(String widgetId) async { + final widget = await repository.findWidget(widgetId); + if (widget == null) { + throw DomainError(ErrorCode.storeNotFound, '未找到模块:$widgetId'); + } + return widget; + } + + Future installFromUrl(String url) async { + final scriptSource = await remoteGateway.fetchText(url); + return installFromSource(scriptSource, sourceUrl: url); + } + + Future installFromSource( + String scriptSource, { + String sourceUrl = '', + }) async { + final manifest = await runtime.inspectWidget(scriptSource); + _assertCompatible(manifest); + return _runWidgetOperation( + manifest.id, + () => + _installInspectedWidget(manifest, scriptSource, sourceUrl: sourceUrl), + ); + } + + Future importSubscription(String url) async { + final rawCatalog = await remoteGateway.fetchText(url); + return importSubscriptionSource(rawCatalog, sourceId: url); + } + + Future importSubscriptionSource( + String rawCatalog, { + required String sourceId, + }) async { + final decoded = jsonDecode(rawCatalog); + if (decoded is! Map) { + throw DomainError(ErrorCode.invalidParams, '订阅文件不是有效的 JSON 对象'); + } + final decodedCatalog = ScriptWidgetCatalog.fromJson( + Map.from(decoded), + ); + if (decodedCatalog.title.trim().isEmpty) { + throw DomainError(ErrorCode.invalidParams, '订阅缺少 title'); + } + final catalog = decodedCatalog.copyWith( + widgets: decodedCatalog.widgets + .map((entry) => _resolveCatalogEntryUrl(entry, sourceId)) + .toList(growable: false), + ); + final subscription = ScriptWidgetSubscription( + url: sourceId, + catalog: catalog, + refreshedAt: DateTime.now().toUtc(), + ); + await repository.upsertSubscription(subscription); + return subscription; + } + + Future refreshWidget(String widgetId) async { + final widget = await requireWidget(widgetId); + if (widget.sourceUrl.trim().isEmpty) { + throw DomainError(ErrorCode.invalidParams, '本地导入模块没有可用的更新地址'); + } + final scriptSource = await remoteGateway.fetchText(widget.sourceUrl); + final manifest = await runtime.inspectWidget(scriptSource); + if (manifest.id != widgetId) { + throw DomainError( + ErrorCode.invalidParams, + '模块更新的 ID 不匹配:期望 $widgetId,实际为 ${manifest.id}', + ); + } + _assertCompatible(manifest); + return _runWidgetOperation( + widgetId, + () => _installInspectedWidget( + manifest, + scriptSource, + sourceUrl: widget.sourceUrl, + ), + ); + } + + Future setWidgetEnabled(String widgetId, bool enabled) async { + await _runWidgetOperation(widgetId, () async { + final widget = await requireWidget(widgetId); + final updated = widget.copyWith( + enabled: enabled, + updatedAt: DateTime.now().toUtc(), + ); + await repository.upsertWidget(updated); + await _synchronizeDanmakuSource(updated); + }); + } + + Future updateGlobalParams( + String widgetId, + Map globalParams, + ) async { + await _runWidgetOperation(widgetId, () async { + final widget = await requireWidget(widgetId); + await repository.upsertWidget( + widget.copyWith( + globalParams: Map.unmodifiable(globalParams), + updatedAt: DateTime.now().toUtc(), + ), + ); + }); + } + + Future deleteWidget(String widgetId) async { + await _runWidgetOperation(widgetId, () async { + await repository.deleteWidget(widgetId); + await runtime.unloadWidget(widgetId); + final sourceStore = danmakuSourcesStore; + if (sourceStore != null) { + final sources = await sourceStore.list(); + await sourceStore.replaceAll( + sources.where((source) => source.url != 'jsw://$widgetId').toList(), + ); + await onDanmakuSourcesChanged?.call(); + } + }); + } + + Future invoke( + String widgetId, + String functionName, + Map params, + ) async { + return _runWidgetOperation(widgetId, () async { + final widget = await requireWidget(widgetId); + if (!widget.enabled) { + throw DomainError( + ErrorCode.invalidParams, + '模块已停用:${widget.manifest.title}', + ); + } + await _ensureLoaded(widget); + final rawResult = await runtime.invokeModuleFunction( + widgetId, + functionName, + {...widget.globalParams, ...params}, + ); + AppLogger.debug( + _tag, + '[$widgetId] $functionName -> ${_summarizeInvocationResult(rawResult)}', + ); + return rawResult; + }); + } + + Future> loadModuleSections( + String widgetId, + ScriptWidgetModule module, + Map params, + ) async { + final resolvedParams = { + ...buildScriptWidgetParameterDefaults(module.params), + ...params, + }; + final rawResult = await invoke( + widgetId, + module.functionName, + resolvedParams, + ); + return decodeVideoSections( + rawResult, + fallbackTitle: module.title, + preserveSections: module.sectionMode, + ); + } + + Future> search( + String widgetId, + Map params, + ) async { + final widget = await requireWidget(widgetId); + final searchModule = widget.manifest.search; + if (searchModule == null) return const []; + final rawResult = await invoke(widgetId, searchModule.functionName, params); + return decodeVideoItems(rawResult); + } + + Future loadDetail(String widgetId, String link) async { + final rawResult = await invoke(widgetId, 'loadDetail', { + '__forwardRawArgument': link, + 'link': link, + }); + final items = decodeVideoItems(rawResult); + return items.isEmpty ? null : items.first; + } + + Future> loadResources( + String widgetId, + Map params, + ) async { + final widget = await requireWidget(widgetId); + final resourceModule = widget.manifest.modules + .where( + (module) => module.id == 'loadResource' || module.type == 'stream', + ) + .firstOrNull; + if (resourceModule == null) return const []; + final rawResult = await invoke( + widgetId, + resourceModule.functionName, + params, + ); + return _decodeMapList(rawResult) + .map(ScriptVideoResource.fromJson) + .where((resource) => resource.url.trim().isNotEmpty) + .toList(growable: false); + } + + Future _ensureLoaded(InstalledScriptWidget widget) async { + final widgetId = widget.manifest.id; + if (runtime.isWidgetLoaded(widgetId)) return; + final loadedManifest = await runtime.loadWidget(widget.scriptSource); + if (loadedManifest.id != widgetId) { + await runtime.unloadWidget(loadedManifest.id); + throw DomainError( + ErrorCode.invalidParams, + '已安装模块的脚本 ID 不匹配:期望 $widgetId,实际为 ${loadedManifest.id}', + ); + } + } + + Future _installInspectedWidget( + ScriptWidgetManifest manifest, + String scriptSource, { + required String sourceUrl, + }) async { + final existingWidget = await repository.findWidget(manifest.id); + final existingDanmakuSources = danmakuSourcesStore == null + ? null + : await danmakuSourcesStore!.list(); + final now = DateTime.now().toUtc(); + final globalParams = { + for (final parameter in manifest.globalParams) + parameter.name: + existingWidget?.globalParams[parameter.name] ?? parameter.value, + }; + final installedWidget = InstalledScriptWidget( + manifest: manifest, + scriptSource: scriptSource, + sourceUrl: sourceUrl, + enabled: existingWidget?.enabled ?? true, + globalParams: globalParams, + installedAt: existingWidget?.installedAt ?? now, + updatedAt: now, + ); + + try { + final loadedManifest = await runtime.loadWidget(scriptSource); + if (loadedManifest.id != manifest.id) { + await runtime.unloadWidget(loadedManifest.id); + throw DomainError( + ErrorCode.invalidParams, + '模块脚本在检查与加载期间返回了不同的 ID', + details: { + 'inspectedId': manifest.id, + 'loadedId': loadedManifest.id, + }, + ); + } + await repository.upsertWidget(installedWidget); + await _synchronizeDanmakuSource(installedWidget); + return installedWidget; + } catch (error, stackTrace) { + await _restoreInstallation( + manifest.id, + existingWidget, + existingDanmakuSources, + ); + Error.throwWithStackTrace(error, stackTrace); + } + } + + Future _restoreInstallation( + String widgetId, + InstalledScriptWidget? existingWidget, + List? existingDanmakuSources, + ) async { + await _ignoreRollbackFailure(() async { + if (existingWidget == null) { + await repository.deleteWidget(widgetId); + } else { + await repository.upsertWidget(existingWidget); + } + }); + await _ignoreRollbackFailure(() async { + if (existingWidget == null) { + await runtime.unloadWidget(widgetId); + } else { + var restoredExistingRuntime = false; + try { + final restoredManifest = await runtime.loadWidget( + existingWidget.scriptSource, + ); + restoredExistingRuntime = restoredManifest.id == widgetId; + if (!restoredExistingRuntime) { + await runtime.unloadWidget(restoredManifest.id); + } + } finally { + if (!restoredExistingRuntime) { + await runtime.unloadWidget(widgetId); + } + } + } + }); + final sourceStore = danmakuSourcesStore; + if (sourceStore != null && existingDanmakuSources != null) { + await _ignoreRollbackFailure(() async { + await sourceStore.replaceAll(existingDanmakuSources); + await onDanmakuSourcesChanged?.call(); + }); + } + } + + Future _runWidgetOperation( + String widgetId, + Future Function() operation, + ) { + + + return _widgetLocks.putIfAbsent(widgetId, AsyncLock.new).run(operation); + } + + Future _ignoreRollbackFailure(Future Function() rollback) async { + try { + await rollback(); + } catch (_) {} + } + + ScriptWidgetCatalogEntry _resolveCatalogEntryUrl( + ScriptWidgetCatalogEntry entry, + String sourceId, + ) { + final entryUri = Uri.tryParse(entry.url); + if (entryUri == null || entryUri.hasScheme) return entry; + final sourceUri = Uri.tryParse(sourceId); + if (sourceUri == null || !sourceUri.hasScheme) return entry; + return entry.copyWith(url: sourceUri.resolveUri(entryUri).toString()); + } + + void _assertCompatible(ScriptWidgetManifest manifest) { + if (_compareVersions(manifest.requiredVersion, scriptWidgetHostVersion) > + 0) { + throw DomainError( + ErrorCode.invalidParams, + '模块需要 ForwardWidgets ${manifest.requiredVersion},当前兼容版本为 $scriptWidgetHostVersion', + ); + } + if (manifest.modules.any((module) => module.requiresWebView)) { + throw DomainError(ErrorCode.invalidParams, '当前版本暂不支持 requiresWebView 模块'); + } + } + + Future _synchronizeDanmakuSource(InstalledScriptWidget widget) async { + final sourceStore = danmakuSourcesStore; + if (sourceStore == null) return; + final hasDanmaku = widget.manifest.modules.any( + (module) => module.type == 'danmu', + ); + final sourceUrl = 'jsw://${widget.manifest.id}'; + final sources = await sourceStore.list(); + sources.removeWhere((source) => source.url == sourceUrl); + if (hasDanmaku) { + sources.add( + DanmakuSource( + id: 'script-widget-${widget.manifest.id}', + name: widget.manifest.title, + url: sourceUrl, + enabled: widget.enabled, + ), + ); + } + await sourceStore.replaceAll(sources); + await onDanmakuSourcesChanged?.call(); + } +} + +List decodeVideoItems(Object? rawResult) { + final maps = _decodeMapList(rawResult); + if (maps.isEmpty) { + if (_isExplicitlyEmptyModuleResult(rawResult)) { + return const []; + } + throw DomainError( + ErrorCode.invalidParams, + '模块返回了无法识别的内容格式', + details: { + 'resultType': rawResult.runtimeType.toString(), + 'result': _summarizeModuleResult(rawResult), + }, + ); + } + + final items = []; + final decodingErrors = []; + for (final map in maps) { + if (map['items'] is List && !map.containsKey('id')) { + try { + items.addAll(decodeVideoItems(map['items'])); + } catch (error) { + decodingErrors.add(error.toString()); + } + continue; + } + try { + items.add(ScriptVideoItem.fromJson(_normalizeVideoItemMap(map))); + } catch (error) { + decodingErrors.add(error.toString()); + } + } + + if (items.isEmpty && decodingErrors.isNotEmpty) { + throw DomainError( + ErrorCode.invalidParams, + '模块返回了内容,但字段格式与 ForwardWidgets 契约不兼容', + details: { + 'itemCount': maps.length, + 'firstItem': _summarizeModuleResult(maps.first), + 'firstError': decodingErrors.first, + }, + ); + } + return items; +} + +Map _normalizeVideoItemMap(Map source) { + final normalized = Map.from(source); + final fallbackId = + normalized['link'] ?? + normalized['videoUrl'] ?? + normalized['url'] ?? + normalized['title']; + normalized['id'] = normalized['id'] ?? fallbackId ?? ''; + const stringFieldNames = { + 'id', + 'type', + 'title', + 'coverUrl', + 'posterPath', + 'posterUrl', + 'poster_url', + 'detailPoster', + 'backdropPath', + 'releaseDate', + 'mediaType', + 'genreTitle', + 'durationText', + 'previewUrl', + 'videoUrl', + 'link', + 'description', + 'playerType', + }; + for (final fieldName in stringFieldNames) { + final value = normalized[fieldName]; + if (value != null) normalized[fieldName] = value.toString(); + } + normalized['title'] ??= ''; + normalized['type'] ??= 'link'; + + final subtitle = normalized['subTitle'] ?? normalized['subtitle']; + if (subtitle != null && + (normalized['description'] == null || + normalized['description'].toString().isEmpty)) { + normalized['description'] = subtitle.toString(); + } + return normalized; +} + +bool _isExplicitlyEmptyModuleResult(Object? rawResult) { + if (rawResult is List) return rawResult.isEmpty; + if (rawResult is Map) return rawResult.isEmpty; + return false; +} + +String _summarizeModuleResult(Object? rawResult) { + final summary = rawResult?.toString() ?? 'null'; + const maximumSummaryLength = 500; + if (summary.length <= maximumSummaryLength) return summary; + return '${summary.substring(0, maximumSummaryLength)}…'; +} + +List decodeVideoSections( + Object? rawResult, { + required String fallbackTitle, + required bool preserveSections, +}) { + if (preserveSections && rawResult is List) { + final sections = []; + for (final rawSection in rawResult.whereType()) { + final section = Map.from(rawSection); + final rawItems = + section['items'] ?? section['data'] ?? section['results']; + if (rawItems == null) continue; + final items = decodeVideoItems(rawItems); + if (items.isEmpty) continue; + sections.add( + ScriptWidgetContentSection( + title: + section['title']?.toString() ?? + section['name']?.toString() ?? + fallbackTitle, + items: items, + ), + ); + } + if (sections.isNotEmpty) return sections; + } + + final items = decodeVideoItems(rawResult); + if (items.isEmpty) return const []; + return [ + ScriptWidgetContentSection(title: fallbackTitle, items: items), + ]; +} + +List> _decodeMapList(Object? rawResult) { + if (rawResult is String) { + final trimmedResult = rawResult.trim(); + if (trimmedResult.isEmpty) return const >[]; + try { + return _decodeMapList(jsonDecode(trimmedResult)); + } catch (_) { + return const >[]; + } + } + if (rawResult is Map) { + final map = Map.from(rawResult); + final nested = map['items'] ?? map['results'] ?? map['data']; + if (!map.containsKey('id') && nested != null) { + return _decodeMapList(nested); + } + return >[map]; + } + if (rawResult is! List) return const >[]; + return rawResult + .whereType() + .map(Map.from) + .toList(growable: false); +} + +int _compareVersions(String left, String right) { + final leftParts = _versionParts(left); + final rightParts = _versionParts(right); + final length = leftParts.length > rightParts.length + ? leftParts.length + : rightParts.length; + for (var index = 0; index < length; index++) { + final leftValue = index < leftParts.length ? leftParts[index] : 0; + final rightValue = index < rightParts.length ? rightParts[index] : 0; + if (leftValue != rightValue) return leftValue.compareTo(rightValue); + } + return 0; +} + +List _versionParts(String version) { + return version + .split('.') + .map((part) => int.tryParse(RegExp(r'\d+').stringMatch(part) ?? '') ?? 0) + .toList(growable: false); +} + +String _summarizeInvocationResult(Object? rawResult) { + if (rawResult == null) return 'null'; + if (rawResult is List) { + return 'List(length=${rawResult.length})'; + } + if (rawResult is Map) { + return 'Map(keys=${rawResult.keys.take(6).toList()}, size=${rawResult.length})'; + } + final summary = rawResult.toString(); + const maximumSummaryLength = 200; + if (summary.length <= maximumSummaryLength) { + return '${rawResult.runtimeType} $summary'; + } + return '${rawResult.runtimeType} ${summary.substring(0, maximumSummaryLength)}…'; +} diff --git a/lib/core/domain/usecases/series_resume_target.dart b/lib/core/domain/usecases/series_resume_target.dart new file mode 100644 index 0000000..5a4e268 --- /dev/null +++ b/lib/core/domain/usecases/series_resume_target.dart @@ -0,0 +1,33 @@ +import '../../contracts/library.dart'; +import '../ports/emby_gateway.dart'; + + +typedef SeriesResumeTarget = ({String episodeId, String? seasonId}); + + +Future resolveSeriesResumeTarget( + EmbyGateway gateway, + AuthedRequestContext ctx, + String seriesId, +) async { + final nextUp = await gateway.getSeriesNextUp(ctx, seriesId, limit: 1); + if (nextUp.isNotEmpty) { + final ep = nextUp.first; + return (episodeId: ep.Id, seasonId: ep.SeasonId); + } + final lastPlayed = await gateway.getLibraryItems( + ctx: ctx, + parentId: seriesId, + includeItemTypes: 'Episode', + recursive: true, + filters: 'IsPlayed', + sortBy: 'DatePlayed', + sortOrder: SortOrder.descending, + limit: 1, + ); + if (lastPlayed.isNotEmpty) { + final ep = lastPlayed.first; + return (episodeId: ep.Id, seasonId: ep.SeasonId); + } + return null; +} diff --git a/lib/core/domain/usecases/server_sync_usecases.dart b/lib/core/domain/usecases/server_sync_usecases.dart new file mode 100644 index 0000000..2122d7e --- /dev/null +++ b/lib/core/domain/usecases/server_sync_usecases.dart @@ -0,0 +1,281 @@ +import 'dart:convert'; + +import '../../contracts/auth.dart'; +import '../../contracts/backup.dart'; +import '../../contracts/error.dart'; +import '../../contracts/script_widget.dart'; +import '../../contracts/server.dart'; +import '../errors.dart'; +import '../ports/danmaku_sources_store.dart'; +import '../ports/emby_gateway.dart'; +import '../ports/script_widget_repository.dart'; +import '../ports/server_repository.dart'; +import '../ports/server_sync_gateway.dart'; +import '../ports/session_repository.dart'; +import 'backup_usecases.dart'; + + +class ServerSyncUploadResult { + final int serverCount; + final int sessionCount; + final int danmakuSourceCount; + final int scriptWidgetCount; + final int scriptWidgetSubscriptionCount; + + const ServerSyncUploadResult({ + required this.serverCount, + required this.sessionCount, + required this.danmakuSourceCount, + required this.scriptWidgetCount, + required this.scriptWidgetSubscriptionCount, + }); +} + + +class ServerSyncPullResult { + final bool empty; + final int serverCount; + final int sessionCount; + final int danmakuSourceCount; + final int scriptWidgetCount; + final int scriptWidgetSubscriptionCount; + + const ServerSyncPullResult({ + required this.empty, + required this.serverCount, + required this.sessionCount, + required this.danmakuSourceCount, + required this.scriptWidgetCount, + required this.scriptWidgetSubscriptionCount, + }); +} + + +class UploadServerSyncUseCase { + final ExportBackupUseCase export; + final ServerSyncGateway gateway; + + UploadServerSyncUseCase({required this.export, required this.gateway}); + + Future execute({ + required String baseUrl, + required String accessToken, + }) async { + final exported = await export.execute(); + await gateway.push( + baseUrl: baseUrl, + accessToken: accessToken, + blob: exported.json, + ); + return ServerSyncUploadResult( + serverCount: exported.serverCount, + sessionCount: exported.sessionCount, + danmakuSourceCount: exported.danmakuSourceCount, + scriptWidgetCount: exported.scriptWidgetCount, + scriptWidgetSubscriptionCount: exported.scriptWidgetSubscriptionCount, + ); + } +} + + +class PullServerSyncUseCase { + final ServerRepository serverRepository; + final SessionRepository sessionRepository; + final DanmakuSourcesStore danmakuSourcesStore; + final ScriptWidgetRepository scriptWidgetRepository; + final ServerSyncGateway gateway; + + PullServerSyncUseCase({ + required this.serverRepository, + required this.sessionRepository, + required this.danmakuSourcesStore, + required this.scriptWidgetRepository, + required this.gateway, + }); + + Future execute({ + required String baseUrl, + required String accessToken, + }) async { + final snapshot = await gateway.pull( + baseUrl: baseUrl, + accessToken: accessToken, + ); + if (snapshot.isEmpty) { + return const ServerSyncPullResult( + empty: true, + serverCount: 0, + sessionCount: 0, + danmakuSourceCount: 0, + scriptWidgetCount: 0, + scriptWidgetSubscriptionCount: 0, + ); + } + + final bundle = _parseBundle(snapshot.blob!); + final prevActive = await sessionRepository.getActiveServerId(); + final oldServers = await serverRepository.list(); + final oldSessions = await sessionRepository.listAll(); + final oldScriptWidgets = await scriptWidgetRepository.listWidgets(); + final oldScriptWidgetSubscriptions = + await scriptWidgetRepository.listSubscriptions(); + + final keepActive = + prevActive != null && + bundle.sessions.any((s) => s.serverId == prevActive); + final nextActive = keepActive + ? prevActive + : (bundle.sessions.isNotEmpty ? bundle.sessions.first.serverId : null); + + try { + await serverRepository.replaceAll(bundle.servers); + await sessionRepository.replaceAll( + bundle.sessions, + activeServerId: nextActive, + ); + } catch (_) { + await _restoreLocalSnapshot( + servers: oldServers, + sessions: oldSessions, + activeServerId: prevActive, + ); + rethrow; + } + + final mergedSources = mergeDanmakuSourcesByUrl( + local: await danmakuSourcesStore.list(), + remote: bundle.danmakuSources, + ); + await danmakuSourcesStore.replaceAll(mergedSources); + + try { + await scriptWidgetRepository.replaceAllWidgets(bundle.scriptWidgets); + await scriptWidgetRepository.replaceAllSubscriptions( + bundle.scriptWidgetSubscriptions, + ); + } catch (_) { + await _restoreScriptWidgets( + widgets: oldScriptWidgets, + subscriptions: oldScriptWidgetSubscriptions, + ); + rethrow; + } + + return ServerSyncPullResult( + empty: false, + serverCount: bundle.servers.length, + sessionCount: bundle.sessions.length, + danmakuSourceCount: mergedSources.length, + scriptWidgetCount: bundle.scriptWidgets.length, + scriptWidgetSubscriptionCount: bundle.scriptWidgetSubscriptions.length, + ); + } + + BackupBundle _parseBundle(String rawJson) { + final BackupBundle bundle; + try { + final decoded = jsonDecode(rawJson); + if (decoded is! Map) { + throw DomainError(ErrorCode.invalidParams, '云端同步数据格式不正确'); + } + bundle = BackupBundle.fromJson(decoded); + } on DomainError { + rethrow; + } catch (e) { + throw DomainError( + ErrorCode.invalidParams, + '云端同步数据解析失败', + details: {'reason': e.toString()}, + ); + } + if (bundle.version != kBackupSchemaVersion) { + throw DomainError( + ErrorCode.invalidParams, + '不支持的同步数据版本:${bundle.version}', + details: {'expected': kBackupSchemaVersion, 'got': bundle.version}, + ); + } + return bundle; + } + + Future _restoreLocalSnapshot({ + required List servers, + required List sessions, + required String? activeServerId, + }) async { + try { + await serverRepository.replaceAll(servers); + await sessionRepository.replaceAll( + sessions, + activeServerId: activeServerId, + ); + } catch (_) {} + } + + Future _restoreScriptWidgets({ + required List widgets, + required List subscriptions, + }) async { + try { + await scriptWidgetRepository.replaceAllWidgets(widgets); + await scriptWidgetRepository.replaceAllSubscriptions(subscriptions); + } catch (_) {} + } +} + + +class RepairSessionsUseCase { + final ServerRepository serverRepository; + final SessionRepository sessionRepository; + final EmbyGateway embyGateway; + + RepairSessionsUseCase({ + required this.serverRepository, + required this.sessionRepository, + required this.embyGateway, + }); + + Future execute() async { + final sessions = await sessionRepository.listAll(); + var repaired = 0; + for (final session in sessions) { + final server = await serverRepository.findById(session.serverId); + if (server == null) continue; + final ctx = AuthedRequestContext( + baseUrl: server.baseUrl, + token: session.token, + userId: session.userId, + ); + try { + await embyGateway.getItemCounts(ctx); + } on DomainError catch (e) { + if (e.code != ErrorCode.authInvalidCredentials && + e.code != ErrorCode.authForbidden) { + continue; + } + if (await _reauth(server.baseUrl, session)) repaired++; + } catch (_) { + continue; + } + } + return repaired; + } + + Future _reauth(String baseUrl, SessionData session) async { + final pwd = session.password; + if (pwd == null || pwd.isEmpty) return false; + try { + final auth = await embyGateway.authenticateByName( + baseUrl: baseUrl, + username: session.userName, + password: pwd, + ); + await sessionRepository.setMany([ + session.copyWith(token: auth.accessToken, userId: auth.userId), + ]); + return true; + } catch (_) { + return false; + } + } +} diff --git a/lib/core/domain/usecases/server_usecases.dart b/lib/core/domain/usecases/server_usecases.dart new file mode 100644 index 0000000..15dbc33 --- /dev/null +++ b/lib/core/domain/usecases/server_usecases.dart @@ -0,0 +1,179 @@ +import 'package:uuid/uuid.dart'; + +import '../../contracts/contracts.dart'; +import '../errors.dart'; +import '../ports/emby_gateway.dart'; +import '../ports/server_repository.dart'; +import '../ports/session_repository.dart'; + +const _uuid = Uuid(); + +class ListServersUseCase { + final ServerRepository serverRepository; + ListServersUseCase({required this.serverRepository}); + + Future> execute() => serverRepository.list(); +} + +class ReorderServersUseCase { + final ServerRepository serverRepository; + + ReorderServersUseCase({required this.serverRepository}); + + Future execute(List orderedServers) { + return serverRepository.replaceAll(orderedServers); + } +} + +class ProbeServerUseCase { + final EmbyGateway embyGateway; + ProbeServerUseCase({required this.embyGateway}); + + Future execute(EmbyServerProbeReq input) async { + final baseUrl = input.baseUrl.trim(); + if (baseUrl.isEmpty) { + throw DomainError(ErrorCode.invalidParams, '服务器地址不能为空'); + } + return embyGateway.probePublicInfo(baseUrl); + } +} + +class SaveServerUseCase { + final ServerRepository serverRepository; + final DateTime Function() now; + final String Function() idGenerator; + + SaveServerUseCase({ + required this.serverRepository, + DateTime Function()? now, + String Function()? idGenerator, + }) : now = (now ?? DateTime.now), + idGenerator = (idGenerator ?? _defaultIdGenerator); + + static String _defaultIdGenerator() => _uuid.v4(); + + Future execute(EmbyServerSaveReq input) async { + final name = input.name.trim(); + if (name.isEmpty) { + throw DomainError(ErrorCode.invalidParams, '服务器名称不能为空'); + } + + final baseUrl = _normalizeBaseUrl(input.baseUrl); + final nowIso = now().toUtc().toIso8601String(); + + final duplicated = await serverRepository.findByBaseUrl(baseUrl); + if (duplicated != null && duplicated.id != input.id) { + throw DomainError( + ErrorCode.storeConflict, + '服务器地址已存在', + details: {'baseUrl': baseUrl}, + ); + } + + final iconUrl = input.iconUrl.trim(); + + if (input.id != null) { + final current = await serverRepository.findById(input.id!); + if (current == null) { + throw DomainError( + ErrorCode.storeNotFound, + '服务器不存在', + details: {'id': input.id}, + ); + } + return serverRepository.update( + current.copyWith( + name: name, + baseUrl: baseUrl, + updatedAt: nowIso, + iconUrl: iconUrl, + ), + ); + } + + return serverRepository.create( + EmbyServer( + id: idGenerator(), + name: name, + baseUrl: baseUrl, + createdAt: nowIso, + updatedAt: nowIso, + iconUrl: iconUrl, + ), + ); + } + + + String _normalizeBaseUrl(String raw) { + final trimmed = raw.trim(); + if (trimmed.isEmpty) { + throw DomainError(ErrorCode.invalidParams, '服务器地址不能为空'); + } + + Uri parsed; + try { + parsed = Uri.parse(trimmed); + } catch (_) { + throw DomainError( + ErrorCode.invalidParams, + '服务器地址格式不正确', + details: {'baseUrl': raw}, + ); + } + + if (parsed.scheme != 'http' && parsed.scheme != 'https') { + throw DomainError( + ErrorCode.invalidParams, + '仅支持 HTTP/HTTPS 协议', + details: {'baseUrl': raw}, + ); + } + + final origin = + '${parsed.scheme}://${parsed.host}' + '${parsed.hasPort ? ':${parsed.port}' : ''}'; + final cleanPath = parsed.path.replaceAll(RegExp(r'/+$'), ''); + final normalizedPath = cleanPath.isEmpty ? '/' : cleanPath; + + if (RegExp(r'/emby$', caseSensitive: false).hasMatch(normalizedPath)) { + return '$origin$normalizedPath'; + } + if (normalizedPath == '/') { + return '$origin/emby'; + } + return '$origin$normalizedPath/emby'; + } +} + +class TogglePauseServerUseCase { + final ServerRepository serverRepository; + + TogglePauseServerUseCase({required this.serverRepository}); + + Future execute(String id) async { + final current = await serverRepository.findById(id); + if (current == null) { + throw DomainError(ErrorCode.storeNotFound, '服务器不存在', details: {'id': id}); + } + return serverRepository.update(current.copyWith(paused: !current.paused)); + } +} + +class DeleteServerUseCase { + final ServerRepository serverRepository; + final SessionRepository sessionRepository; + + DeleteServerUseCase({ + required this.serverRepository, + required this.sessionRepository, + }); + + Future execute(String id) async { + final exists = await serverRepository.findById(id); + if (exists == null) { + throw DomainError(ErrorCode.storeNotFound, '服务器不存在', details: {'id': id}); + } + await serverRepository.deleteById(id); + await sessionRepository.clear(id); + } +} diff --git a/lib/core/domain/usecases/usecase_helpers.dart b/lib/core/domain/usecases/usecase_helpers.dart new file mode 100644 index 0000000..2889e11 --- /dev/null +++ b/lib/core/domain/usecases/usecase_helpers.dart @@ -0,0 +1,82 @@ +import '../../contracts/contracts.dart'; +import '../errors.dart'; +import '../ports/emby_gateway.dart'; +import '../ports/server_repository.dart'; +import '../ports/session_repository.dart'; + + +Future resolveAuthedContext({ + required SessionRepository sessionRepository, + required ServerRepository serverRepository, +}) async { + final session = await sessionRepository.getActive(); + if (session == null) { + throw DomainError(ErrorCode.storeNotFound, '没有活跃会话'); + } + final server = await serverRepository.findById(session.serverId); + if (server == null) { + throw DomainError( + ErrorCode.storeNotFound, + '服务器不存在', + details: {'serverId': session.serverId}, + ); + } + return AuthedRequestContext( + baseUrl: server.baseUrl, + token: session.token, + userId: session.userId, + ); +} + + +Future resolveAuthedContextForServer({ + required SessionRepository sessionRepository, + required ServerRepository serverRepository, + required String serverId, +}) async { + final session = await sessionRepository.getByServerId(serverId); + if (session == null) return null; + final server = await serverRepository.findById(serverId); + if (server == null) return null; + return AuthedRequestContext( + baseUrl: server.baseUrl, + token: session.token, + userId: session.userId, + ); +} + + +abstract class AuthedUseCase { + AuthedUseCase({ + required this.sessionRepository, + required this.serverRepository, + required this.embyGateway, + }); + + final SessionRepository sessionRepository; + final ServerRepository serverRepository; + final EmbyGateway embyGateway; + + + Future ctx() => resolveAuthedContext( + sessionRepository: sessionRepository, + serverRepository: serverRepository, + ); + + + Future requireCtxForServer(String serverId) async { + final ctx = await resolveAuthedContextForServer( + sessionRepository: sessionRepository, + serverRepository: serverRepository, + serverId: serverId, + ); + if (ctx == null) { + throw DomainError( + ErrorCode.storeNotFound, + '服务器无活跃会话', + details: {'serverId': serverId}, + ); + } + return ctx; + } +} diff --git a/lib/core/infra/danmaku_api/danmaku_client.dart b/lib/core/infra/danmaku_api/danmaku_client.dart new file mode 100644 index 0000000..2ae8bcc --- /dev/null +++ b/lib/core/infra/danmaku_api/danmaku_client.dart @@ -0,0 +1,226 @@ +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:dio/io.dart'; + +import '../../contracts/danmaku.dart'; +import '../../domain/ports/danmaku_gateway.dart'; +import '../emby_api/emby_headers.dart'; +import '../../../shared/utils/app_logger.dart'; + +const _tag = 'Danmaku'; + + +String buildDanmakuFileName({ + required String name, + required String? type, + required String? seriesName, + required int? season, + required int? episode, +}) { + if (type == 'Episode' && seriesName != null && seriesName.isNotEmpty) { + if (season != null && episode != null) { + return '$seriesName S${season}E$episode'; + } + AppLogger.warn( + _tag, + 'buildDanmakuFileName: missing index fields ' + '(season=$season, episode=$episode) for "$seriesName / $name"; ' + 'fallback to raw name', + ); + } + return name; +} + +class DanmakuApiClient implements DanmakuGateway { + DanmakuApiClient({Dio? dio}) : _dio = dio ?? _buildDefaultDio(); + + final Dio _dio; + + static Dio _buildDefaultDio() { + final dio = Dio( + BaseOptions( + connectTimeout: const Duration(seconds: 4), + receiveTimeout: const Duration(seconds: 8), + headers: { + 'User-Agent': EmbyRequestHeaders.userAgent, + 'Accept': 'application/json, text/plain, */*', + 'accept-language': 'zh-CN,en,*', + }, + ), + ); + dio.httpClientAdapter = IOHttpClientAdapter( + createHttpClient: () => + HttpClient()..idleTimeout = const Duration(seconds: 1), + ); + return dio; + } + + static const _matchConnectTimeout = Duration(seconds: 8); + static const _matchReceiveTimeout = Duration(seconds: 8); + + static const _commentReceiveTimeout = Duration(seconds: 30); + + @override + Future> match( + String baseUrl, + String fileName, + int durationSec, + ) async { + try { + final resp = await _dio.post>( + '$baseUrl/api/v2/match', + data: { + 'fileHash': '', + 'fileName': fileName, + 'fileSize': 0, + 'matchMode': 'fileName', + 'videoDuration': durationSec, + }, + options: Options( + contentType: 'application/json', + sendTimeout: _matchConnectTimeout, + receiveTimeout: _matchReceiveTimeout, + ), + ); + final data = resp.data; + if (data == null) { + AppLogger.warn(_tag, 'match($fileName) null response body'); + return []; + } + if (data['success'] != true || data['isMatched'] != true) { + AppLogger.debug( + _tag, + 'match($fileName) no result: success=${data['success']} ' + 'isMatched=${data['isMatched']}', + ); + return []; + } + final list = (data['matches'] as List) + .map((e) => DanmakuMatch.fromJson(e as Map)) + .toList(); + AppLogger.debug(_tag, 'match($fileName) → ${list.length} entries'); + return list; + } catch (e) { + AppLogger.warn(_tag, 'match($fileName) failed', e); + rethrow; + } + } + + @override + Future> searchEpisodes( + String baseUrl, + String anime, + int? episode, + ) async { + try { + final resp = await _dio.get>( + '$baseUrl/api/v2/search/episodes', + queryParameters: { + 'anime': anime, + if (episode != null) 'episode': episode, + }, + options: Options(receiveTimeout: _matchReceiveTimeout), + ); + final data = resp.data; + if (data == null || data['success'] != true) { + AppLogger.debug( + _tag, + 'searchEpisodes($anime) no result: success=${data?['success']}', + ); + return []; + } + final animes = data['animes']; + if (animes is! List) return []; + final out = []; + for (final a in animes.whereType>()) { + final animeId = (a['animeId'] as num?)?.toInt() ?? 0; + final animeTitle = a['animeTitle']?.toString() ?? ''; + final eps = a['episodes']; + if (eps is! List) continue; + for (final e in eps.whereType>()) { + final epId = (e['episodeId'] as num?)?.toInt(); + if (epId == null) continue; + out.add( + DanmakuMatch( + episodeId: epId, + animeId: animeId, + animeTitle: animeTitle, + episodeTitle: e['episodeTitle']?.toString() ?? '', + ), + ); + } + } + AppLogger.debug( + _tag, + 'searchEpisodes($anime ep=$episode) → ${out.length} entries', + ); + return out; + } catch (e) { + AppLogger.warn(_tag, 'searchEpisodes($anime) failed', e); + rethrow; + } + } + + @override + Future> getBangumiEpisodes( + String baseUrl, + int animeId, + ) async { + if (animeId <= 0) return []; + try { + final resp = await _dio.get>( + '$baseUrl/api/v2/bangumi/$animeId', + ); + final data = resp.data; + if (data == null || data['success'] != true) return []; + final bangumi = data['bangumi']; + if (bangumi is! Map) return []; + final episodes = bangumi['episodes']; + if (episodes is! List) return []; + return episodes + .whereType>() + .map(DanmakuBangumiEpisode.fromJson) + .toList(); + } catch (e) { + AppLogger.warn(_tag, 'getBangumiEpisodes($animeId) failed', e); + return []; + } + } + + @override + Future> fetchComments( + String baseUrl, + int episodeId, { + int chConvert = 0, + }) async { + try { + final resp = await _dio.get>( + '$baseUrl/api/v2/comment/$episodeId' + '?format=json&chConvert=$chConvert&withRelated=true', + options: Options(receiveTimeout: _commentReceiveTimeout), + ); + final data = resp.data; + if (data == null) { + AppLogger.warn(_tag, 'fetchComments($episodeId) null response body'); + return []; + } + final list = (data['comments'] as List? ?? []).map((e) { + final m = e as Map; + return DanmakuComment.fromRaw( + (m['cid'] as num).toInt(), + m['p'] as String, + m['m'] as String, + ); + }).toList(); + AppLogger.debug( + _tag, + 'fetchComments($episodeId) → ${list.length} comments', + ); + return list; + } catch (e) { + AppLogger.warn(_tag, 'fetchComments($episodeId) failed', e); + rethrow; + } + } +} diff --git a/lib/core/infra/danmaku_api/dispatching_danmaku_gateway.dart b/lib/core/infra/danmaku_api/dispatching_danmaku_gateway.dart new file mode 100644 index 0000000..4d2d0d4 --- /dev/null +++ b/lib/core/infra/danmaku_api/dispatching_danmaku_gateway.dart @@ -0,0 +1,53 @@ +import '../../contracts/danmaku.dart'; +import '../../domain/ports/danmaku_gateway.dart'; + +class DispatchingDanmakuGateway implements DanmakuGateway { + final DanmakuGateway httpGateway; + final DanmakuGateway scriptWidgetGateway; + + const DispatchingDanmakuGateway({ + required this.httpGateway, + required this.scriptWidgetGateway, + }); + + DanmakuGateway _gatewayFor(String sourceUrl) { + return sourceUrl.startsWith('jsw://') ? scriptWidgetGateway : httpGateway; + } + + @override + Future> match( + String baseUrl, + String fileName, + int durationSec, + ) { + return _gatewayFor(baseUrl).match(baseUrl, fileName, durationSec); + } + + @override + Future> searchEpisodes( + String baseUrl, + String anime, + int? episode, + ) { + return _gatewayFor(baseUrl).searchEpisodes(baseUrl, anime, episode); + } + + @override + Future> fetchComments( + String baseUrl, + int episodeId, { + int chConvert = 0, + }) { + return _gatewayFor( + baseUrl, + ).fetchComments(baseUrl, episodeId, chConvert: chConvert); + } + + @override + Future> getBangumiEpisodes( + String baseUrl, + int animeId, + ) { + return _gatewayFor(baseUrl).getBangumiEpisodes(baseUrl, animeId); + } +} diff --git a/lib/core/infra/danmaku_api/script_widget_danmaku_gateway.dart b/lib/core/infra/danmaku_api/script_widget_danmaku_gateway.dart new file mode 100644 index 0000000..2eac1a5 --- /dev/null +++ b/lib/core/infra/danmaku_api/script_widget_danmaku_gateway.dart @@ -0,0 +1,175 @@ +import '../../contracts/danmaku.dart'; +import '../../domain/ports/danmaku_gateway.dart'; +import '../../domain/usecases/script_widget/script_widget_service.dart'; + +class ScriptWidgetDanmakuGateway implements DanmakuGateway { + final Future Function() loadService; + + const ScriptWidgetDanmakuGateway({required this.loadService}); + + @override + Future> match( + String baseUrl, + String fileName, + int durationSec, + ) async { + final parsedContext = _parseFileName(fileName); + return _searchAndExpand( + baseUrl, + title: parsedContext.title, + type: parsedContext.episode == null ? 'movie' : 'tv', + season: parsedContext.season, + episode: parsedContext.episode, + durationSec: durationSec, + ); + } + + @override + Future> searchEpisodes( + String baseUrl, + String anime, + int? episode, + ) { + return _searchAndExpand( + baseUrl, + title: anime, + type: 'tv', + episode: episode, + durationSec: 0, + ); + } + + Future> _searchAndExpand( + String sourceUrl, { + required String title, + required String type, + required int durationSec, + int? season, + int? episode, + }) async { + final service = await loadService(); + final widgetId = _widgetIdFromSource(sourceUrl); + final rawSearch = await service.invoke(widgetId, 'searchDanmu', { + 'title': title, + 'seriesName': type == 'tv' ? title : null, + 'type': type, + 'season': season?.toString(), + 'episode': episode?.toString(), + 'runtime': durationSec, + }); + final searchMap = _asMap(rawSearch); + final rawAnimes = searchMap['animes']; + if (rawAnimes is! List) return const []; + + final matches = []; + for (final rawAnime in rawAnimes.whereType()) { + final anime = Map.from(rawAnime); + final animeId = _intValue(anime['animeId'] ?? anime['id']); + if (animeId == null) continue; + final animeTitle = + anime['animeTitle']?.toString() ?? + anime['title']?.toString() ?? + title; + final episodes = await getBangumiEpisodes(sourceUrl, animeId); + for (final candidate in episodes) { + final candidateNumber = + int.tryParse(candidate.episodeNumber) ?? + _episodeNumberFromTitle(candidate.episodeTitle); + if (episode != null && candidateNumber != episode) continue; + matches.add( + DanmakuMatch( + episodeId: candidate.episodeId, + animeId: animeId, + animeTitle: animeTitle, + episodeTitle: candidate.episodeTitle, + ), + ); + } + } + return matches; + } + + @override + Future> fetchComments( + String baseUrl, + int episodeId, { + int chConvert = 0, + }) async { + final service = await loadService(); + final rawResult = await service.invoke( + _widgetIdFromSource(baseUrl), + 'getComments', + {'commentId': episodeId, 'chConvert': chConvert}, + ); + final resultMap = _asMap(rawResult); + final rawComments = resultMap['comments'] ?? rawResult; + if (rawComments is! List) return const []; + final comments = []; + for (final rawComment in rawComments.whereType()) { + final comment = Map.from(rawComment); + final cid = _intValue(comment['cid']) ?? comments.length; + final parameter = comment['p']?.toString(); + final message = comment['m']?.toString(); + if (parameter == null || message == null) continue; + comments.add(DanmakuComment.fromRaw(cid, parameter, message)); + } + return comments; + } + + @override + Future> getBangumiEpisodes( + String baseUrl, + int animeId, + ) async { + final service = await loadService(); + final rawResult = await service.invoke( + _widgetIdFromSource(baseUrl), + 'getDetail', + {'animeId': animeId}, + ); + final rawEpisodes = _asMap(rawResult)['episodes'] ?? rawResult; + if (rawEpisodes is! List) return const []; + final episodes = []; + for (final rawEpisode in rawEpisodes.whereType()) { + try { + episodes.add( + DanmakuBangumiEpisode.fromJson(Map.from(rawEpisode)), + ); + } catch (_) {} + } + return episodes; + } +} + +String _widgetIdFromSource(String sourceUrl) => Uri.parse(sourceUrl).host; + +({String title, int? season, int? episode}) _parseFileName(String fileName) { + final match = RegExp( + r'^(.*?)\s+S(\d+)E(\d+)\s*$', + caseSensitive: false, + ).firstMatch(fileName.trim()); + if (match == null) { + return (title: fileName.trim(), season: null, episode: null); + } + return ( + title: match.group(1)?.trim() ?? fileName.trim(), + season: int.tryParse(match.group(2) ?? ''), + episode: int.tryParse(match.group(3) ?? ''), + ); +} + +int? _episodeNumberFromTitle(String title) { + final match = RegExp(r'(?:第\s*)?(\d+)(?:\s*[集话]|$)').firstMatch(title); + return int.tryParse(match?.group(1) ?? ''); +} + +Map _asMap(Object? value) { + if (value is Map) return value; + if (value is Map) return Map.from(value); + return {}; +} + +int? _intValue(Object? value) { + if (value is num) return value.toInt(); + return int.tryParse(value?.toString() ?? ''); +} diff --git a/lib/core/infra/deep_link/deep_link_service.dart b/lib/core/infra/deep_link/deep_link_service.dart new file mode 100644 index 0000000..bb84e1c --- /dev/null +++ b/lib/core/infra/deep_link/deep_link_service.dart @@ -0,0 +1,57 @@ +import 'dart:async'; + +import 'package:app_links/app_links.dart'; +import 'package:window_manager/window_manager.dart'; + +import '../../../shared/utils/app_logger.dart'; +import '../trakt_api/trakt_credentials.dart'; + + +class DeepLinkService { + DeepLinkService._(); + + static final DeepLinkService instance = DeepLinkService._(); + + static const _tag = 'DeepLink'; + + final AppLinks _appLinks = AppLinks(); + final StreamController _controller = StreamController.broadcast(); + bool _initialized = false; + + + Stream get uriStream => _controller.stream; + + Future init() async { + if (_initialized) return; + _initialized = true; + + _appLinks.uriLinkStream.listen( + _handle, + onError: (Object e) => AppLogger.warn(_tag, 'uri stream error', e), + ); + + try { + final initial = await _appLinks.getInitialLink(); + if (initial != null) _handle(initial); + } catch (e) { + AppLogger.warn(_tag, 'getInitialLink failed', e); + } + } + + void _handle(Uri uri) { + if (uri.scheme != TraktCredentials.scheme || + uri.host != TraktCredentials.host) { + return; + } + AppLogger.info(_tag, 'received ${uri.scheme}://${uri.host}'); + _bringToFront(); + _controller.add(uri); + } + + Future _bringToFront() async { + try { + await windowManager.show(); + await windowManager.focus(); + } catch (_) {} + } +} diff --git a/lib/core/infra/emby_api/emby_api_client.dart b/lib/core/infra/emby_api/emby_api_client.dart new file mode 100644 index 0000000..b9646a8 --- /dev/null +++ b/lib/core/infra/emby_api/emby_api_client.dart @@ -0,0 +1,706 @@ +import 'dart:async'; + +import 'package:dio/dio.dart'; + +import '../../../shared/utils/app_logger.dart'; +import '../../../shared/utils/url_utils.dart'; +import '../../contracts/contracts.dart'; +import '../../domain/errors.dart'; +import '../../domain/ports/emby_gateway.dart'; +import 'emby_headers.dart'; + +class EmbyApiClient implements EmbyGateway { + static const _detailFields = + 'BasicSyncInfo,RunTimeTicks,CommunityRating,ProductionYear,ChildCount,' + 'Container,Overview,OfficialRating,Genres,Status,People,Studios,' + 'ProviderIds,AlternateMediaSources,UserData,UserDataLastPlayedDate'; + static const _seasonFields = + 'BasicSyncInfo,CanDelete,Container,PrimaryImageAspectRatio,ChildCount,' + 'ProductionYear,IndexNumber,Status,EndDate,Overview,' + 'OfficialRating,Genres,CommunityRating,ProviderIds'; + static const _episodeFields = + 'BasicSyncInfo,RunTimeTicks,CommunityRating,Container,' + 'People,PrimaryImageAspectRatio,DateCreated,Genres,' + 'MediaStreams,MediaSources,UserData,UserDataLastPlayedDate,Path,ParentId,' + 'Overview,Studios,ProviderIds,AlternateMediaSources,Chapters'; + static const _similarFields = + 'BasicSyncInfo,CommunityRating,ProductionYear,ChildCount,Container,' + 'Overview,OfficialRating,Genres,People,Studios,ProviderIds'; + + final Dio _dio; + final String _deviceId; + final String _deviceName; + + EmbyApiClient({ + Dio? dio, + required this._deviceId, + this._deviceName = 'iPad', + }) : _dio = dio ?? _createDio(); + + static Dio _createDio() => Dio( + BaseOptions( + connectTimeout: const Duration(seconds: 15), + receiveTimeout: const Duration(seconds: 30), + ), + ); + + + Future _get( + String url, { + String? token, + String? userId, + Map? query, + CancellationToken? cancelToken, + }) async { + return _request( + method: 'GET', + url: url, + token: token, + userId: userId, + query: query, + cancelToken: cancelToken, + ); + } + + Future _post( + String url, { + String? token, + String? userId, + Map? query, + Object? body, + CancellationToken? cancelToken, + bool quiet = false, + }) async { + return _request( + method: 'POST', + url: url, + token: token, + userId: userId, + query: query, + body: body, + cancelToken: cancelToken, + quiet: quiet, + ); + } + + Future _delete( + String url, { + String? token, + String? userId, + Map? query, + CancellationToken? cancelToken, + }) async { + return _request( + method: 'DELETE', + url: url, + token: token, + userId: userId, + query: query, + cancelToken: cancelToken, + ); + } + + static const _tag = 'EmbyApi'; + + static String _extractPath(String url) { + final uri = Uri.tryParse(url); + if (uri == null) return url; + return uri.path; + } + + Future _request({ + required String method, + required String url, + String? token, + String? userId, + Map? query, + Object? body, + CancellationToken? cancelToken, + bool quiet = false, + }) async { + final path = _extractPath(url); + final queryLog = query != null && query.isNotEmpty + ? ' query=${query.keys.join(',')}' + : ''; + if (!quiet) { + AppLogger.debug(_tag, '--> $method $path$queryLog'); + } + final stopwatch = Stopwatch()..start(); + + final headers = EmbyRequestHeaders.json( + deviceId: _deviceId, + deviceName: _deviceName, + token: token, + userId: userId, + hasBody: body != null, + ); + + CancelToken? dioCancelToken; + if (cancelToken != null) { + dioCancelToken = CancelToken(); + cancelToken.whenCancelled.then((_) { + if (!dioCancelToken!.isCancelled) { + dioCancelToken.cancel(cancelToken.reason ?? 'cancelled'); + } + }); + } + + try { + final response = await _dio.request( + url, + queryParameters: query, + data: body, + cancelToken: dioCancelToken, + options: Options( + method: method, + headers: headers, + responseType: ResponseType.json, + validateStatus: (s) => true, + ), + ); + + stopwatch.stop(); + final status = response.statusCode ?? 0; + final ms = stopwatch.elapsedMilliseconds; + if (!quiet) { + AppLogger.debug(_tag, '<-- $status $method $path (${ms}ms)'); + } + + if (status == 401) { + throw DomainError( + ErrorCode.authInvalidCredentials, + '用户名或密码错误', + details: {'status': status}, + ); + } + if (status == 403) { + throw DomainError( + ErrorCode.authForbidden, + '权限不足', + details: {'status': status}, + ); + } + if (status >= 400) { + AppLogger.warn(_tag, '<-- $status $method $path (${ms}ms)'); + throw DomainError( + ErrorCode.serverHttpError, + 'HTTP 请求失败($status)', + retryable: status >= 500, + details: {'status': status}, + ); + } + return response.data; + } on DomainError { + rethrow; + } on DioException catch (e) { + stopwatch.stop(); + final ms = stopwatch.elapsedMilliseconds; + if (CancelToken.isCancel(e)) { + AppLogger.debug(_tag, '<-- CANCELLED $method $path (${ms}ms)'); + throw CancelledException(e.message); + } + if (e.type == DioExceptionType.connectionTimeout || + e.type == DioExceptionType.receiveTimeout || + e.type == DioExceptionType.sendTimeout) { + AppLogger.warn(_tag, '<-- TIMEOUT $method $path (${ms}ms)'); + throw DomainError( + ErrorCode.serverTimeout, + '请求超时', + retryable: true, + details: {'message': e.message}, + ); + } + AppLogger.warn(_tag, '<-- NETWORK_ERROR $method $path (${ms}ms)', e); + throw DomainError( + ErrorCode.serverUnreachable, + '无法连接服务器', + retryable: true, + details: {'message': e.message}, + ); + } catch (e) { + stopwatch.stop(); + AppLogger.warn( + _tag, + '<-- ERROR $method $path (${stopwatch.elapsedMilliseconds}ms)', + e, + ); + throw DomainError( + ErrorCode.internalError, + '内部错误:$e', + details: {'message': e.toString()}, + ); + } + } + + @override + Future probePublicInfo(String baseUrl) async { + final url = '${stripTrailingSlash(baseUrl)}/System/Info/Public'; + final json = await _get(url); + return ProbePublicInfoRes( + serverName: (json['ServerName'] ?? '') as String, + version: (json['Version'] ?? '') as String, + productName: (json['ProductName'] ?? 'Emby Server') as String, + ); + } + + @override + Future authenticateByName({ + required String baseUrl, + required String username, + required String password, + }) async { + final url = '${stripTrailingSlash(baseUrl)}/Users/AuthenticateByName'; + final body = {'Username': username, 'Pw': password, 'Password': password}; + final json = await _post(url, body: body); + final data = json as Map; + final user = data['User'] as Map?; + if (user == null) { + throw DomainError(ErrorCode.authInvalidCredentials, '认证响应格式错误'); + } + return EmbyAuthResult( + accessToken: data['AccessToken'] as String, + userId: user['Id'] as String, + userName: user['Name'] as String, + ); + } + + @override + Future changePassword({ + required AuthedRequestContext ctx, + required String currentPassword, + required String newPassword, + }) async { + final url = + '${stripTrailingSlash(ctx.baseUrl)}/Users/${ctx.userId}/Password'; + await _post( + url, + token: ctx.token, + userId: ctx.userId, + body: {'CurrentPw': currentPassword, 'NewPw': newPassword}, + ); + } + + @override + Future getUserViews(AuthedRequestContext ctx) async { + final url = '${stripTrailingSlash(ctx.baseUrl)}/Users/${ctx.userId}/Views'; + final json = await _get(url, token: ctx.token, userId: ctx.userId); + return EmbyRawLibraries.fromJson(json); + } + + @override + Future getLatestItems( + AuthedRequestContext ctx, + String parentId, { + int? limit, + CancellationToken? cancelToken, + }) async { + final url = '${stripTrailingSlash(ctx.baseUrl)}/Users/${ctx.userId}/Items'; + final json = await _get( + url, + token: ctx.token, + userId: ctx.userId, + query: { + 'ParentId': parentId, + 'Fields': + 'BasicSyncInfo,CollectionType,PrimaryImageAspectRatio,UserData,CommunityRating,ProviderIds,ProductionYear,ChildCount,Container,CanDelete', + 'Recursive': 'true', + 'IncludeItemTypes': 'Series,Movie,Video,MusicVideo,MusicAlbum', + 'Limit': '${limit ?? 30}', + 'EnableImageTypes': 'Primary,Backdrop,Thumb', + 'SortBy': 'DateLastContentAdded,DateCreated,SortName', + 'SortOrder': 'Descending', + 'StartIndex': '0', + }, + cancelToken: cancelToken, + ); + final totalCount = (json['TotalRecordCount'] as num?)?.toInt() ?? 0; + return LibraryLatestRes( + parentId: parentId, + items: _parseItems(json), + totalCount: totalCount, + ); + } + + @override + Future getResumeItems( + AuthedRequestContext ctx, { + int? limit, + }) async { + final url = + '${stripTrailingSlash(ctx.baseUrl)}/Users/${ctx.userId}/Items/Resume'; + final json = await _get( + url, + token: ctx.token, + userId: ctx.userId, + query: { + 'Fields': + 'BasicSyncInfo,CanDelete,Container,PrimaryImageAspectRatio,ProductionYear,ProviderIds,UserDataLastPlayedDate', + 'Recursive': 'true', + 'MediaTypes': 'Video', + 'Limit': '${limit ?? 20}', + 'ImageTypeLimit': '1', + 'EnableImageTypes': 'Primary,Backdrop,Thumb', + }, + ); + return LibraryResumeRes(Items: _parseItems(json)); + } + + + static List _parseItems(dynamic json) { + final items = json is List + ? json + : (json['Items'] as List?) ?? []; + return items + .map((e) => EmbyRawItem.fromJson(e as Map)) + .toList(); + } + + @override + Future getItemDetail( + AuthedRequestContext ctx, + String itemId, + ) async { + final url = + '${stripTrailingSlash(ctx.baseUrl)}/Users/${ctx.userId}/Items/$itemId'; + final json = await _get( + url, + token: ctx.token, + userId: ctx.userId, + query: { + 'Fields': _detailFields, + 'EnableImageTypes': 'Primary,Backdrop,Thumb,Logo', + }, + ); + return EmbyRawItem.fromJson(json); + } + + @override + Future reportPlaybackStarted({ + required AuthedRequestContext ctx, + required PlaybackReportPayload payload, + }) async { + final url = '${stripTrailingSlash(ctx.baseUrl)}/Sessions/Playing'; + await _post( + url, + token: ctx.token, + userId: ctx.userId, + body: payload.toJson(includeNowPlayingQueue: true), + ); + } + + @override + Future reportPlaybackProgress({ + required AuthedRequestContext ctx, + required PlaybackReportPayload payload, + }) async { + final url = '${stripTrailingSlash(ctx.baseUrl)}/Sessions/Playing/Progress'; + await _post( + url, + token: ctx.token, + userId: ctx.userId, + body: payload.toJson(), + quiet: true, + ); + } + + @override + Future reportPlaybackStopped({ + required AuthedRequestContext ctx, + required PlaybackReportPayload payload, + }) async { + final url = '${stripTrailingSlash(ctx.baseUrl)}/Sessions/Playing/Stopped'; + await _post( + url, + token: ctx.token, + userId: ctx.userId, + body: payload.toJson(), + ); + } + + @override + Future> getSeriesNextUp( + AuthedRequestContext ctx, + String seriesId, { + int? limit, + }) async { + final url = '${stripTrailingSlash(ctx.baseUrl)}/Shows/NextUp'; + final json = await _get( + url, + token: ctx.token, + userId: ctx.userId, + query: { + 'UserId': ctx.userId, + 'SeriesId': seriesId, + 'Limit': '${limit ?? 1}', + 'Fields': + 'BasicSyncInfo,CanDelete,Container,PrimaryImageAspectRatio,ProductionYear,DateCreated,Genres,Path,ParentId,Overview', + }, + ); + return _parseItems(json); + } + + @override + Future> getSeriesSeasons( + AuthedRequestContext ctx, + String seriesId, { + CancellationToken? cancelToken, + }) async { + final url = '${stripTrailingSlash(ctx.baseUrl)}/Shows/$seriesId/Seasons'; + final json = await _get( + url, + token: ctx.token, + userId: ctx.userId, + query: { + 'UserId': ctx.userId, + 'EnableTotalRecordCount': 'false', + 'Fields': _seasonFields, + }, + cancelToken: cancelToken, + ); + final items = (json['Items'] as List?) ?? []; + return items + .map((e) => EmbyRawSeason.fromJson(e as Map)) + .toList(); + } + + @override + Future> getSeriesEpisodes( + AuthedRequestContext ctx, + String seriesId, + String seasonId, { + CancellationToken? cancelToken, + }) async { + final url = '${stripTrailingSlash(ctx.baseUrl)}/Shows/$seriesId/Episodes'; + final json = await _get( + url, + token: ctx.token, + userId: ctx.userId, + query: { + 'UserId': ctx.userId, + 'EnableTotalRecordCount': 'false', + 'Fields': _episodeFields, + 'EnableImageTypes': 'Primary,Backdrop,Thumb', + 'SeasonId': seasonId, + }, + cancelToken: cancelToken, + ); + return _parseItems(json); + } + + @override + Future setFavoriteStatus({ + required AuthedRequestContext ctx, + required String itemId, + required bool isFavorite, + }) async { + final url = + '${stripTrailingSlash(ctx.baseUrl)}/Users/${ctx.userId}/FavoriteItems/$itemId'; + if (isFavorite) { + await _post(url, token: ctx.token, userId: ctx.userId); + } else { + await _delete(url, token: ctx.token, userId: ctx.userId); + } + return isFavorite; + } + + @override + Future setPlayedStatus({ + required AuthedRequestContext ctx, + required String itemId, + required bool played, + }) async { + final url = + '${stripTrailingSlash(ctx.baseUrl)}/Users/${ctx.userId}/PlayedItems/$itemId'; + if (played) { + await _post(url, token: ctx.token, userId: ctx.userId); + } else { + await _delete(url, token: ctx.token, userId: ctx.userId); + } + return played; + } + + @override + Future hideFromResume({ + required AuthedRequestContext ctx, + required String itemId, + required bool hide, + }) async { + final url = + '${stripTrailingSlash(ctx.baseUrl)}/Users/${ctx.userId}/Items/$itemId/HideFromResume'; + await _post( + url, + token: ctx.token, + userId: ctx.userId, + query: {'Hide': hide ? 'true' : 'false'}, + ); + return hide; + } + + @override + Future> getSimilarItems( + AuthedRequestContext ctx, + String itemId, { + int? limit, + }) async { + final url = '${stripTrailingSlash(ctx.baseUrl)}/Items/$itemId/Similar'; + final json = await _get( + url, + token: ctx.token, + userId: ctx.userId, + query: { + 'Fields': _similarFields, + 'Limit': '${limit ?? 10}', + 'ImageTypes': 'Logo', + 'SortBy': 'ProductionYear,CommunityRating,PlayCount', + 'Recursive': 'true', + 'EnableImageTypes': 'Primary,Backdrop,Thumb,Logo', + 'SortOrder': 'Descending', + 'IncludeItemTypes': 'Series,Movie,Video', + 'ImageTypeLimit': '1', + }, + ); + return _parseItems(json); + } + + @override + Future> getAdditionalParts( + AuthedRequestContext ctx, + String itemId, + ) async { + final url = + '${stripTrailingSlash(ctx.baseUrl)}/Videos/$itemId/AdditionalParts'; + final json = await _get(url, token: ctx.token, userId: ctx.userId); + return _parseItems(json); + } + + @override + Future> getSpecialFeatures( + AuthedRequestContext ctx, + String itemId, + ) async { + final url = + '${stripTrailingSlash(ctx.baseUrl)}/Users/${ctx.userId}/Items/$itemId/SpecialFeatures'; + final json = await _get(url, token: ctx.token, userId: ctx.userId); + return _parseItems(json); + } + + @override + Future> getLibraryItems({ + required AuthedRequestContext ctx, + String? parentId, + String? includeItemTypes, + List? genreIds, + String? searchTerm, + String? anyProviderIdEquals, + List? personIds, + String? fields, + String? enableImageTypes, + bool? groupProgramsBySeries, + int? imageTypeLimit, + int? startIndex, + int? limit, + bool? recursive, + String? sortBy, + SortOrder? sortOrder, + String? filters, + CancellationToken? cancelToken, + }) async { + final url = '${stripTrailingSlash(ctx.baseUrl)}/Users/${ctx.userId}/Items'; + final query = { + if (parentId != null) 'ParentId': parentId, + if (includeItemTypes != null) 'IncludeItemTypes': includeItemTypes, + if (genreIds != null && genreIds.isNotEmpty) + 'GenreIds': genreIds.join(','), + if (searchTerm != null) 'SearchTerm': searchTerm, + if (anyProviderIdEquals != null) + 'AnyProviderIdEquals': anyProviderIdEquals, + if (personIds != null && personIds.isNotEmpty) + 'PersonIds': personIds.join(','), + if (filters != null) 'Filters': filters, + 'Fields': + fields ?? + 'BasicSyncInfo,CollectionType,PrimaryImageAspectRatio,UserData,CommunityRating,ProviderIds,ProductionYear,ChildCount,Container,CanDelete', + 'EnableImageTypes': enableImageTypes ?? 'Primary,Backdrop,Thumb', + if (groupProgramsBySeries != null) + 'GroupProgramsBySeries': groupProgramsBySeries ? 'true' : 'false', + if (imageTypeLimit != null) 'ImageTypeLimit': '$imageTypeLimit', + 'StartIndex': '${startIndex ?? 0}', + 'Limit': '${limit ?? 50}', + 'Recursive': (recursive ?? true) ? 'true' : 'false', + 'SortBy': sortBy ?? 'SortName', + 'SortOrder': sortOrder?.value ?? 'Ascending', + }; + final json = await _get( + url, + token: ctx.token, + userId: ctx.userId, + query: query, + cancelToken: cancelToken, + ); + return _parseItems(json); + } + + @override + Future> getFavoriteItems({ + required AuthedRequestContext ctx, + required String includeItemTypes, + required String sortBy, + SortOrder? sortOrder, + }) async { + final url = '${stripTrailingSlash(ctx.baseUrl)}/Users/${ctx.userId}/Items'; + final json = await _get( + url, + token: ctx.token, + userId: ctx.userId, + query: { + 'SortOrder': sortOrder?.value ?? 'Ascending', + 'Filters': 'IsFavorite', + 'Recursive': 'true', + 'Fields': + 'BasicSyncInfo,CommunityRating,ProductionYear,ChildCount,Container,' + 'Overview,UserDataLastPlayedDate,OfficialRating,Genres,Status,' + 'People,Studios,ProviderIds', + 'EnableImageTypes': 'Primary,Backdrop,Thumb,Logo', + 'CollapseBoxSetItems': 'false', + 'IncludeItemTypes': includeItemTypes, + 'SortBy': sortBy, + }, + ); + return _parseItems(json); + } + + @override + Future getItemCounts(AuthedRequestContext ctx) async { + final url = '${stripTrailingSlash(ctx.baseUrl)}/Items/Counts'; + final json = await _get(url, token: ctx.token, userId: ctx.userId); + return ItemCountsRes.fromJson(json as Map); + } + + @override + Future> fetchPlaybackInfo({ + required AuthedRequestContext ctx, + required String itemId, + String? mediaSourceId, + int startTimeTicks = 0, + bool isPlayback = false, + required Map deviceProfile, + }) async { + final url = '${stripTrailingSlash(ctx.baseUrl)}/Items/$itemId/PlaybackInfo'; + final json = await _post( + url, + token: ctx.token, + userId: ctx.userId, + query: { + 'AutoOpenLiveStream': 'false', + 'reqformat': 'json', + 'StartTimeTicks': '$startTimeTicks', + 'UserId': ctx.userId, + 'MaxStreamingBitrate': '200000000', + 'IsPlayback': isPlayback ? 'true' : 'false', + if (mediaSourceId != null) 'MediaSourceId': mediaSourceId, + }, + body: {'DeviceProfile': deviceProfile}, + ); + return (json as Map); + } +} diff --git a/lib/core/infra/emby_api/emby_headers.dart b/lib/core/infra/emby_api/emby_headers.dart new file mode 100644 index 0000000..a867c1b --- /dev/null +++ b/lib/core/infra/emby_api/emby_headers.dart @@ -0,0 +1,142 @@ +class EmbyAuthHeader { + + + static String build({ + required String deviceId, + required String deviceName, + String? token, + String? userId, + }) { + final parts = []; + if (token != null) { + parts.add('Token="$token"'); + } + if (userId != null && userId.isNotEmpty) { + parts.add('Emby UserId="$userId"'); + } + parts.addAll([ + 'Client="${EmbyRequestHeaders.clientName}"', + 'Device="$deviceName"', + 'DeviceId="$deviceId"', + 'Version="${EmbyRequestHeaders.version}"', + ]); + return 'MediaBrowser ${parts.join(', ')}'; + } +} + +class EmbyRequestHeaders { + static const version = '6.1.0'; + static const clientName = 'SenPlayer'; + + + static const userAgent = 'SenPlayer/6.2.0'; + static const acceptLanguage = 'zh-CN,zh-Hans;q=0.9'; + static const embyLanguage = 'zh-cn'; + + static Map json({ + required String deviceId, + required String deviceName, + String? token, + String? userId, + bool hasBody = false, + }) { + return { + 'Accept': 'application/json', + 'Accept-Language': acceptLanguage, + 'User-Agent': userAgent, + ..._clientIdentity(deviceId: deviceId, deviceName: deviceName), + ..._auth( + deviceId: deviceId, + deviceName: deviceName, + token: token, + userId: userId, + ), + if (hasBody) 'Content-Type': 'application/json', + }; + } + + static Map image({ + required String deviceId, + required String deviceName, + required String token, + required String userId, + }) { + return { + 'User-Agent': userAgent, + 'Accept': 'image/*,*/*;q=0.8', + 'Accept-Language': acceptLanguage, + 'Priority': 'u=3, i', + ..._clientIdentity(deviceId: deviceId, deviceName: deviceName), + ..._auth( + deviceId: deviceId, + deviceName: deviceName, + token: token, + userId: userId, + ), + }; + } + + static Map media({ + required String deviceId, + required String deviceName, + required String token, + required String userId, + }) { + return { + 'User-Agent': userAgent, + 'Accept': '*/*', + 'Accept-Language': acceptLanguage, + ..._clientIdentity(deviceId: deviceId, deviceName: deviceName), + ..._auth( + deviceId: deviceId, + deviceName: deviceName, + token: token, + userId: userId, + ), + }; + } + + + static Map directMedia() { + return { + 'User-Agent': userAgent, + 'Accept': '*/*', + 'Accept-Language': acceptLanguage, + }; + } + + static Map _clientIdentity({ + required String deviceId, + required String deviceName, + }) { + return { + 'X-Emby-Client': clientName, + 'X-Emby-Client-Version': version, + 'X-Emby-Device-Id': deviceId, + 'X-Emby-Device-Name': deviceName, + 'X-Emby-Language': embyLanguage, + }; + } + + static Map _auth({ + required String deviceId, + required String deviceName, + String? token, + String? userId, + }) { + final authorizationHeader = EmbyAuthHeader.build( + deviceId: deviceId, + deviceName: deviceName, + token: token, + userId: userId, + ); + return { + 'X-Emby-Authorization': authorizationHeader, + 'Authorization': authorizationHeader, + if (token != null && token.isNotEmpty) ...{ + 'X-Emby-Token': token, + 'X-MediaBrowser-Token': token, + }, + }; + } +} diff --git a/lib/core/infra/emby_api/playback_resolver.dart b/lib/core/infra/emby_api/playback_resolver.dart new file mode 100644 index 0000000..a8d2c6e --- /dev/null +++ b/lib/core/infra/emby_api/playback_resolver.dart @@ -0,0 +1,488 @@ +import 'package:flutter/foundation.dart'; + +import '../../../shared/utils/app_logger.dart'; +import '../../../shared/utils/emby_ticks.dart'; +import '../../../shared/utils/url_utils.dart'; +import '../../contracts/contracts.dart'; +import '../../domain/errors.dart'; +import '../../domain/ports/emby_gateway.dart'; +import 'emby_headers.dart'; + +const _tag = 'PlaybackResolver'; + +class PlaybackResolver { + final EmbyGateway gateway; + final String _deviceId; + final String _deviceName; + + PlaybackResolver({ + required this.gateway, + required this._deviceId, + this._deviceName = 'iPad', + }); + + + Future prepare({ + required AuthedRequestContext ctx, + required PlaybackRequestPayload payload, + EmbyRawItem? preloadedItem, + Map? preloadedPlaybackInfo, + }) async { + var item = preloadedItem?.Id == payload.itemId + ? preloadedItem! + : await gateway.getItemDetail(ctx, payload.itemId); + + if (item.Type == 'Series') { + preloadedPlaybackInfo = null; + final seasons = await gateway.getSeriesSeasons(ctx, item.Id); + if (seasons.isEmpty) { + throw DomainError( + ErrorCode.storeNotFound, + '剧集没有可用季', + details: {'seriesId': item.Id}, + ); + } + final episodes = await gateway.getSeriesEpisodes( + ctx, + item.Id, + seasons.first.Id, + ); + if (episodes.isEmpty) { + throw DomainError( + ErrorCode.storeNotFound, + '剧集没有可用分集', + details: {'seriesId': item.Id}, + ); + } + item = episodes.first; + } + + final resumePositionTicks = _startPositionTicksFor(payload, item); + + final playbackInfo = + (preloadedPlaybackInfo != null && preloadedPlaybackInfo.isNotEmpty) + ? preloadedPlaybackInfo + : await gateway.fetchPlaybackInfo( + ctx: ctx, + itemId: item.Id, + mediaSourceId: payload.mediaSourceId, + startTimeTicks: resumePositionTicks, + isPlayback: true, + deviceProfile: deviceProfile(), + ); + + final mediaSources = + (playbackInfo['MediaSources'] as List?) ?? []; + if (mediaSources.isEmpty) { + final serverErrorCode = playbackInfo['ErrorCode'] as String?; + final responseKeys = playbackInfo.keys.take(10).toList(); + AppLogger.warn( + _tag, + 'PlaybackInfo returned empty MediaSources for itemId=${item.Id} ' + 'name=${item.Name} serverErrorCode=$serverErrorCode ' + 'responseKeys=$responseKeys', + ); + throw DomainError( + ErrorCode.storeNotFound, + serverErrorCode != null + ? '没有可播放的媒体源(服务端:$serverErrorCode)' + : '没有可播放的媒体源', + details: { + 'itemId': item.Id, + 'itemName': item.Name, + if (serverErrorCode != null) 'serverErrorCode': serverErrorCode, + if (payload.mediaSourceId != null) + 'requestedMediaSourceId': payload.mediaSourceId, + }, + ); + } + + Map sourceJson; + if (payload.mediaSourceId != null) { + sourceJson = mediaSources.cast>().firstWhere( + (s) => s['Id'] == payload.mediaSourceId, + orElse: () => mediaSources.first as Map, + ); + } else { + sourceJson = mediaSources.first as Map; + } + + final source = EmbyRawMediaSource.fromJson(sourceJson); + final playSessionId = + (playbackInfo['PlaySessionId'] as String?) ?? + (sourceJson['PlaySessionId'] as String?); + final streamUrl = _resolveStreamUrl( + ctx: ctx, + itemId: item.Id, + sourceJson: sourceJson, + playSessionId: playSessionId, + ); + + final subtitles = resolveSubtitles( + source.MediaStreams ?? [], + baseUrl: ctx.baseUrl, + token: ctx.token, + itemId: item.Id, + mediaSourceId: source.Id, + ); + final audioTracks = _resolveAudioTracks(streams: source.MediaStreams ?? []); + + final positionTicks = resumePositionTicks; + final startSeconds = positionTicks / kTicksPerSecond; + final durationTicks = item.RunTimeTicks; + final durationSeconds = durationTicks != null + ? durationTicks / kTicksPerSecond + : null; + + final playMethod = _resolvePlayMethod(sourceJson); + + AppLogger.debug( + _tag, + 'prepared item=${item.Name} (${item.Id}) source=${source.Id} ' + 'container=${source.Container} method=$playMethod ' + 'startSeconds=${startSeconds.toStringAsFixed(1)} ' + 'subs=${subtitles.length} audio=${audioTracks.length}', + ); + + return PlaybackRequestResult( + streamUrl: streamUrl, + container: _normalizeContainer(source.Container), + mimeType: _guessMimeType(source.Container), + directStream: sourceJson['SupportsDirectStream'] as bool? ?? false, + item: item, + mediaSource: source, + startPositionSeconds: startSeconds, + durationSeconds: durationSeconds, + subtitles: subtitles, + defaultSubtitleIndex: source.DefaultSubtitleStreamIndex, + audioTracks: audioTracks, + defaultAudioStreamIndex: source.DefaultAudioStreamIndex, + playSessionId: playSessionId, + playMethod: playMethod, + ); + } + + int _startPositionTicksFor(PlaybackRequestPayload payload, EmbyRawItem item) { + if (payload.playFromStart) return 0; + final explicitTicks = payload.startPositionTicks; + if (explicitTicks != null && explicitTicks > 0) return explicitTicks; + return item.UserData?.PlaybackPositionTicks ?? 0; + } + + + String _resolveStreamUrl({ + required AuthedRequestContext ctx, + required String itemId, + required Map sourceJson, + String? playSessionId, + }) { + final base = stripTrailingSlash(ctx.baseUrl); + final mediaSourceId = sourceJson['Id'] as String?; + final container = _normalizeContainer(sourceJson['Container'] as String?); + + final directStreamUrl = sourceJson['DirectStreamUrl'] as String?; + if (directStreamUrl != null && directStreamUrl.isNotEmpty) { + return _buildPlayableUrl( + base, + directStreamUrl, + ctx.token, + appendTokenToCrossOrigin: false, + ); + } + + final transcodingUrl = sourceJson['TranscodingUrl'] as String?; + if (transcodingUrl != null && transcodingUrl.isNotEmpty) { + return _buildPlayableUrl( + base, + transcodingUrl, + ctx.token, + appendTokenToCrossOrigin: false, + ); + } + + final ext = container ?? 'mkv'; + final params = _staticStreamQueryParameters( + ctx: ctx, + mediaSourceId: mediaSourceId, + playSessionId: playSessionId, + ); + final query = params.entries + .map((e) => '${e.key}=${Uri.encodeComponent(e.value)}') + .join('&'); + return '$base/Videos/$itemId/stream.$ext?$query'; + } + + Map _staticStreamQueryParameters({ + required AuthedRequestContext ctx, + String? mediaSourceId, + String? playSessionId, + }) { + return { + 'UserId': ctx.userId, + 'IsPlayback': 'true', + 'MaxStreamingBitrate': '500000000', + 'Static': 'true', + 'api_key': ctx.token, + 'X-Emby-Authorization': EmbyAuthHeader.build( + deviceId: _deviceId, + deviceName: _deviceName, + ), + 'X-Emby-Client': EmbyRequestHeaders.clientName, + 'X-Emby-Client-Version': EmbyRequestHeaders.version, + 'X-Emby-Device-Id': _deviceId, + 'X-Emby-Device-Name': _deviceName, + 'X-Emby-Language': 'zh-cn', + 'X-Emby-Token': ctx.token, + if (mediaSourceId != null) 'MediaSourceId': mediaSourceId, + if (playSessionId != null) 'PlaySessionId': playSessionId, + }; + } + + + String _resolvePlayMethod(Map sourceJson) { + final directStreamUrl = sourceJson['DirectStreamUrl'] as String?; + if (directStreamUrl != null && directStreamUrl.isNotEmpty) { + return 'DirectStream'; + } + final transcodingUrl = sourceJson['TranscodingUrl'] as String?; + if (transcodingUrl != null && transcodingUrl.isNotEmpty) { + return 'Transcode'; + } + return 'DirectStream'; + } + + + static String _buildPlayableUrl( + String base, + String rawUrl, + String token, { + bool appendTokenToCrossOrigin = true, + }) { + final baseUri = Uri.parse(base); + final rawUri = Uri.parse(rawUrl); + final playableUri = rawUri.hasScheme + ? rawUri + : Uri.parse('$base${rawUrl.startsWith('/') ? '' : '/'}$rawUrl'); + if (!appendTokenToCrossOrigin && playableUri.origin != baseUri.origin) { + return playableUri.toString(); + } + if (playableUri.queryParameters.containsKey('api_key')) { + return playableUri.toString(); + } + + return playableUri + .replace( + queryParameters: { + ...playableUri.queryParametersAll, + 'api_key': token, + }, + ) + .toString(); + } + + + @visibleForTesting + static List resolveSubtitles( + List streams, { + String? baseUrl, + String? token, + String? itemId, + String? mediaSourceId, + }) { + final subtitles = []; + + for (final stream in streams) { + if (stream.Type != 'Subtitle') continue; + final isExternal = + (stream.IsExternal ?? false) || + stream.DeliveryMethod == 'External' || + (stream.DeliveryUrl != null && stream.DeliveryUrl!.isNotEmpty); + if (isExternal) { + if (!isTextSubtitleCodec(stream.Codec)) continue; + final rawDeliveryUrl = stream.DeliveryUrl; + if (rawDeliveryUrl == null || + rawDeliveryUrl.isEmpty || + baseUrl == null || + token == null) { + continue; + } + subtitles.add( + EmbySubtitleTrack( + index: stream.Index ?? 0, + title: stream.Title ?? stream.DisplayTitle, + language: stream.Language, + isDefault: stream.IsDefault ?? false, + isForced: stream.IsForced ?? false, + isExternal: true, + deliveryUrl: _buildPlayableUrl( + stripTrailingSlash(baseUrl), + rawDeliveryUrl, + token, + ), + codec: stream.Codec, + ), + ); + continue; + } + subtitles.add( + EmbySubtitleTrack( + index: stream.Index ?? 0, + title: stream.Title ?? stream.DisplayTitle, + language: stream.Language, + isDefault: stream.IsDefault ?? false, + isForced: stream.IsForced ?? false, + codec: stream.Codec, + deliveryUrl: _embeddedTextSubtitleUrl( + baseUrl: baseUrl, + token: token, + itemId: itemId, + mediaSourceId: mediaSourceId, + index: stream.Index, + codec: stream.Codec, + ), + ), + ); + } + return subtitles; + } + + + static String? _embeddedTextSubtitleUrl({ + required String? baseUrl, + required String? token, + required String? itemId, + required String? mediaSourceId, + required int? index, + required String? codec, + }) { + if (!isTextSubtitleCodec(codec)) return null; + if (baseUrl == null || + token == null || + itemId == null || + mediaSourceId == null || + index == null) { + return null; + } + final extension = _subtitleDeliveryExtension(codec); + final path = + '/Videos/$itemId/$mediaSourceId/Subtitles/$index/0/Stream.$extension'; + return _buildPlayableUrl(stripTrailingSlash(baseUrl), path, token); + } + + + static String _subtitleDeliveryExtension(String? codec) { + final normalized = (codec ?? 'srt').toLowerCase(); + return normalized == 'webvtt' ? 'vtt' : normalized; + } + + + static bool isTextSubtitleCodec(String? codec) { + if (codec == null) return false; + const textCodecs = {'srt', 'subrip', 'vtt', 'webvtt', 'ass', 'ssa'}; + return textCodecs.contains(codec.toLowerCase()); + } + + List _resolveAudioTracks({ + required List streams, + }) { + final tracks = []; + for (final stream in streams) { + if (stream.Type != 'Audio') continue; + tracks.add( + EmbyAudioTrack( + index: stream.Index ?? 0, + title: stream.Title, + language: stream.Language, + codec: stream.Codec, + channels: stream.Channels, + displayTitle: stream.DisplayTitle, + isDefault: stream.IsDefault ?? false, + ), + ); + } + return tracks; + } + + + static Map deviceProfile() { + return { + 'MaxStaticBitrate': 200000000, + 'MaxStreamingBitrate': 200000000, + 'MusicStreamingTranscodingBitrate': 200000000, + 'DirectPlayProfiles': [ + {'Type': 'Video'}, + {'Type': 'Audio'}, + ], + 'TranscodingProfiles': [ + { + 'Container': 'ts', + 'Type': 'Video', + 'AudioCodec': 'aac,mp3,wav,ac3,eac3,flac,opus', + 'VideoCodec': 'hevc,h264,h265,mpeg4', + 'Context': 'Streaming', + 'Protocol': 'hls', + 'MaxAudioChannels': '6', + 'MinSegments': '1', + 'BreakOnNonKeyFrames': true, + 'ManifestSubtitles': 'vtt', + }, + ], + 'ContainerProfiles': >[], + 'SubtitleProfiles': [ + {'Format': 'vtt', 'Method': 'External'}, + {'Format': 'ass', 'Method': 'External'}, + {'Format': 'ssa', 'Method': 'External'}, + {'Format': 'srt', 'Method': 'External'}, + {'Format': 'sub', 'Method': 'External'}, + {'Format': 'subrip', 'Method': 'External'}, + {'Format': 'smi', 'Method': 'External'}, + {'Format': 'ttml', 'Method': 'External'}, + {'Format': 'webvtt', 'Method': 'External'}, + {'Format': 'dvdsub', 'Method': 'External'}, + {'Format': 'sup', 'Method': 'External'}, + {'Format': 'dvdsub', 'Method': 'Embed'}, + {'Format': 'vobsub', 'Method': 'Embed'}, + {'Format': 'vtt', 'Method': 'Embed'}, + {'Format': 'ass', 'Method': 'Embed'}, + {'Format': 'ssa', 'Method': 'Embed'}, + {'Format': 'srt', 'Method': 'Embed'}, + {'Format': 'sub', 'Method': 'Embed'}, + {'Format': 'pgssub', 'Method': 'Embed'}, + {'Format': 'pgs', 'Method': 'Embed'}, + {'Format': 'subrip', 'Method': 'Embed'}, + {'Format': 'smi', 'Method': 'Embed'}, + {'Format': 'ttml', 'Method': 'Embed'}, + {'Format': 'webvtt', 'Method': 'Embed'}, + {'Format': 'mov_text', 'Method': 'Embed'}, + {'Format': 'dvb_teletext', 'Method': 'Embed'}, + {'Format': 'dvb_subtitle', 'Method': 'Embed'}, + {'Format': 'dvbsub', 'Method': 'Embed'}, + {'Format': 'idx', 'Method': 'Embed'}, + {'Format': 'sup', 'Method': 'Embed'}, + {'Format': 'vtt', 'Method': 'Hls'}, + ], + }; + } + + + String? _normalizeContainer(String? raw) { + if (raw == null) return null; + final container = raw.split(',').first.trim().toLowerCase(); + if (container.isEmpty) return null; + if (container == 'matroska') return 'mkv'; + return container; + } + + String? _guessMimeType(String? container) { + if (container == null) return null; + return switch (_normalizeContainer(container)) { + 'mp4' || 'm4v' => 'video/mp4', + 'mkv' => 'video/x-matroska', + 'webm' => 'video/webm', + 'avi' => 'video/x-msvideo', + 'mov' => 'video/quicktime', + 'ts' || 'm2ts' || 'mpegts' => 'video/mp2t', + _ => null, + }; + } +} diff --git a/lib/core/infra/github_releases/github_releases_client.dart b/lib/core/infra/github_releases/github_releases_client.dart new file mode 100644 index 0000000..9e68db8 --- /dev/null +++ b/lib/core/infra/github_releases/github_releases_client.dart @@ -0,0 +1,108 @@ +import 'dart:convert'; + +import 'package:dio/dio.dart'; + +import '../../contracts/app_update.dart'; +import '../emby_api/emby_headers.dart'; + +const String kSmPlayerReleaseOwner = 'YOUR_GITHUB_OWNER'; +const String kSmPlayerReleaseRepo = 'YOUR_GITHUB_REPO'; + + +const String kSmPlayerManifestTag = '_manifests'; + +class GithubReleasesClient { + static Uri _platformManifestUri(String platformKey) => Uri.parse( + 'https://github.com/$kSmPlayerReleaseOwner/$kSmPlayerReleaseRepo' + '/releases/download/$kSmPlayerManifestTag/latest-$platformKey.json', + ); + + final Dio _dio; + + GithubReleasesClient({Dio? dio}) + : _dio = + dio ?? + Dio( + BaseOptions( + connectTimeout: const Duration(seconds: 8), + receiveTimeout: const Duration(seconds: 15), + responseType: ResponseType.json, + headers: { + 'Accept': 'application/json', + 'User-Agent': EmbyRequestHeaders.userAgent, + }, + ), + ); + + Future fetchLatestRelease({ + required String platformKey, + }) async { + final response = await _dio.get( + _platformManifestUri(platformKey).toString(), + ); + final data = response.data; + + final Map json; + if (data is Map) { + json = data; + } else if (data is String) { + final decoded = jsonDecode(data); + if (decoded is! Map) { + throw const FormatException('GitHub release JSON root is not a map'); + } + json = decoded; + } else { + throw FormatException( + 'GitHub release JSON has unexpected type: ${data.runtimeType}', + ); + } + + final platformAssets = {}; + final rawPlatforms = json['platforms']; + if (rawPlatforms is Map) { + rawPlatforms.forEach((key, value) { + if (value is Map) { + final asset = AppUpdateAsset.fromJson(value); + if (asset.name.isNotEmpty) { + platformAssets[key.toString()] = asset; + } + } + }); + } + + final assets = platformAssets.values.toList(growable: true); + + final rawAssets = json['assets']; + if (rawAssets is List) { + assets.addAll( + rawAssets + .whereType>() + .map(AppUpdateAsset.fromJson) + .where((asset) => asset.name.isNotEmpty), + ); + } + + final htmlUrl = _firstNonEmpty([ + json['html_url'], + json['htmlUrl'], + json['downloadUrl'], + _latestReleasePageUrl, + ]); + return AppUpdateInfo( + version: _firstNonEmpty([json['version'], json['tag_name']]), + releaseNotes: _firstNonEmpty([json['notes'], json['body']]), + htmlUrl: Uri.tryParse(htmlUrl) ?? Uri(), + assets: assets, + platformAssets: platformAssets, + ); + } + + static String get _latestReleasePageUrl => + 'https://github.com/$kSmPlayerReleaseOwner/$kSmPlayerReleaseRepo/releases/latest'; + + static String _firstNonEmpty(Iterable values) { + return values + .map((value) => value?.toString().trim() ?? '') + .firstWhere((value) => value.isNotEmpty, orElse: () => ''); + } +} diff --git a/lib/core/infra/icon_library_api/icon_library_client.dart b/lib/core/infra/icon_library_api/icon_library_client.dart new file mode 100644 index 0000000..869221d --- /dev/null +++ b/lib/core/infra/icon_library_api/icon_library_client.dart @@ -0,0 +1,47 @@ +import 'dart:convert'; + +import 'package:dio/dio.dart'; + +import '../../contracts/icon_library.dart'; + +class IconLibraryClient { + final Dio _dio; + + IconLibraryClient({Dio? dio}) + : _dio = + dio ?? + Dio( + BaseOptions( + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 15), + responseType: ResponseType.json, + followRedirects: true, + ), + ); + + + Future fetch(String url) async { + final trimmed = url.trim(); + if (trimmed.isEmpty) { + throw ArgumentError('icon library URL is empty'); + } + final response = await _dio.get(trimmed); + final data = response.data; + + Map json; + if (data is Map) { + json = data; + } else if (data is String) { + final decoded = jsonDecode(data); + if (decoded is! Map) { + throw const FormatException('icon library JSON root is not a map'); + } + json = decoded; + } else { + throw FormatException( + 'icon library JSON has unexpected type: ${data.runtimeType}', + ); + } + return IconLibrary.parse(trimmed, json); + } +} diff --git a/lib/core/infra/pip/android_pip_controller.dart b/lib/core/infra/pip/android_pip_controller.dart new file mode 100644 index 0000000..48e1cb8 --- /dev/null +++ b/lib/core/infra/pip/android_pip_controller.dart @@ -0,0 +1,163 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/services.dart'; + +import '../../../shared/utils/app_logger.dart'; + +const _tag = 'AndroidPip'; + + +enum PipAction { play, pause, next, prev, seekForward, seekBackward } + + +class AndroidPipController { + static const _methodChannel = MethodChannel('smplayer/pip'); + static const _eventChannel = EventChannel('smplayer/pip_events'); + + AndroidPipController._(); + static final AndroidPipController instance = AndroidPipController._(); + + StreamController? _pipModeController; + StreamController? _actionController; + StreamSubscription? _eventSub; + + + Future isSupported() async { + if (!Platform.isAndroid) return false; + try { + return (await _methodChannel.invokeMethod('isSupported')) ?? false; + } catch (e) { + AppLogger.warn(_tag, 'isSupported check failed', e); + return false; + } + } + + + Future isInPipMode() async { + if (!Platform.isAndroid) return false; + try { + return (await _methodChannel.invokeMethod('isInPipMode')) ?? false; + } catch (e) { + return false; + } + } + + + Future configure({ + int aspectX = 16, + int aspectY = 9, + bool autoEnterOnLeave = false, + bool isPlaying = false, + bool hasNext = false, + bool hasPrev = false, + bool useEpisodeActions = true, + }) async { + if (!Platform.isAndroid) return; + try { + await _methodChannel.invokeMethod('configure', { + 'aspectX': aspectX, + 'aspectY': aspectY, + 'autoEnterOnLeave': autoEnterOnLeave, + 'isPlaying': isPlaying, + 'hasNext': hasNext, + 'hasPrev': hasPrev, + 'useEpisodeActions': useEpisodeActions, + }); + } catch (e) { + AppLogger.warn(_tag, 'configure failed', e); + } + } + + + Future enterPip({int? aspectX, int? aspectY}) async { + if (!Platform.isAndroid) return false; + try { + return (await _methodChannel.invokeMethod('enterPip', { + 'aspectX': aspectX ?? 16, + 'aspectY': aspectY ?? 9, + })) ?? + false; + } catch (e) { + AppLogger.warn(_tag, 'enterPip failed', e); + return false; + } + } + + + Future updatePlaybackState({ + required bool isPlaying, + required bool hasNext, + required bool hasPrev, + required bool useEpisodeActions, + }) async { + if (!Platform.isAndroid) return; + try { + await _methodChannel.invokeMethod('updatePlaybackState', { + 'isPlaying': isPlaying, + 'hasNext': hasNext, + 'hasPrev': hasPrev, + 'useEpisodeActions': useEpisodeActions, + }); + } catch (e) { + AppLogger.warn(_tag, 'updatePlaybackState failed', e); + } + } + + + Stream get pipModeChanges { + _ensureEventListening(); + return (_pipModeController ??= StreamController.broadcast()).stream; + } + + + Stream get actionStream { + _ensureEventListening(); + return (_actionController ??= StreamController.broadcast()) + .stream; + } + + void _ensureEventListening() { + if (_eventSub != null) return; + _eventSub = _eventChannel.receiveBroadcastStream().listen( + (event) { + if (event is! Map) return; + final map = event.cast(); + final type = map['type'] as String?; + + if (type == 'modeChanged') { + final isInPip = map['isInPip'] as bool? ?? false; + _pipModeController?.add(isInPip); + } else if (type == 'action') { + final action = _parseAction(map['action'] as String?); + if (action != null) { + _actionController?.add(action); + } + } + }, + onError: (Object e) { + AppLogger.warn(_tag, 'event stream error', e); + }, + ); + } + + Future dispose() async { + final eventSub = _eventSub; + _eventSub = null; + await eventSub?.cancel(); + await _pipModeController?.close(); + _pipModeController = null; + await _actionController?.close(); + _actionController = null; + } + + PipAction? _parseAction(String? name) => switch (name) { + 'play' => PipAction.play, + 'pause' => PipAction.pause, + 'next' => PipAction.next, + 'prev' => PipAction.prev, + 'seekForward' => PipAction.seekForward, + 'seekBackward' => PipAction.seekBackward, + _ => null, + }; +} diff --git a/lib/core/infra/player_engine/fvp_player_engine.dart b/lib/core/infra/player_engine/fvp_player_engine.dart new file mode 100644 index 0000000..9163fbc --- /dev/null +++ b/lib/core/infra/player_engine/fvp_player_engine.dart @@ -0,0 +1,1161 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:fvp/mdk.dart' as mdk; + +import '../../contracts/cancellation.dart'; +import '../../media/embedded_subtitle_cues.dart'; +import '../../../shared/utils/app_logger.dart'; +import 'player_engine.dart'; +import 'seek_guard.dart'; +import 'texture_video_view.dart'; + +const _tag = 'FvpPlayer'; +const _androidTlsChannel = MethodChannel('smplayer/android_tls'); + +Future? _androidTrustedCaBundlePath; + +Future _loadAndroidTrustedCaBundlePath() async { + final existing = _androidTrustedCaBundlePath; + if (existing != null) return existing; + + final loading = _androidTlsChannel + .invokeMethod('exportTrustedCaBundle') + .then((path) { + if (path == null || path.isEmpty) { + throw StateError('Android 系统 CA 导出返回了空路径'); + } + return path; + }); + _androidTrustedCaBundlePath = loading; + try { + return await loading; + } catch (_) { + if (identical(_androidTrustedCaBundlePath, loading)) { + _androidTrustedCaBundlePath = null; + } + rethrow; + } +} + + +bool isFvpNetworkError(String error) { + final lower = error.toLowerCase(); + if (lower.startsWith('tcp:') || lower.startsWith('tls:')) return true; + const patterns = [ + 'tls', + 'ssl', + 'handshake', + 'connection refused', + 'connection reset', + 'connection timed out', + 'network is unreachable', + 'host not found', + 'name resolution', + 'no route to host', + 'http error 4', + 'http error 5', + 'server returned 4', + 'server returned 5', + 'protocol not found', + 'no address', + ]; + for (final pattern in patterns) { + if (lower.contains(pattern)) return true; + } + return false; +} + + +@visibleForTesting +String? fvpNetworkErrorPayload({ + required String category, + required int errorCode, + required String detail, +}) { + if (errorCode >= 0 || detail.isEmpty) return null; + + final isIoLike = + category.startsWith('reader') || + category.startsWith('io') || + category.startsWith('demuxer') || + category == 'source'; + if (!isIoLike) return null; + + final normalizedCategory = category.startsWith('reader') + ? 'reader' + : category; + final payload = '$normalizedCategory: $detail (err=$errorCode)'; + return isFvpNetworkError(payload) ? payload : null; +} + + +const bool kDvForceSoftwareDecode = true; + + +const List kDvSoftwareDecoders = ['FFmpeg:threads=0']; + + +({int min, int max}) bufferRangeForRate(double rate) { + final factor = rate > 1 ? rate : 1.0; + return (min: (1000 * factor).round(), max: (15000 * factor).round()); +} + + +const int kDvPlaybackMinBufferMs = 3000; +({int min, int max}) playbackBufferRangeForRate( + double rate, { + bool isDolbyVision = false, +}) { + final base = bufferRangeForRate(rate); + if (!isDolbyVision || base.min >= kDvPlaybackMinBufferMs) return base; + return (min: kDvPlaybackMinBufferMs, max: base.max); +} + + +List? decoderListForMode(String mode) { + if (mode == 'no') { + return const ['FFmpeg:threads=0', 'dav1d']; + } + if (Platform.isWindows) { + return const [ + 'MFT:d3d=11', + 'D3D11', + 'DXVA', + 'CUDA', + 'hap', + 'FFmpeg', + 'dav1d', + ]; + } + if (Platform.isMacOS || Platform.isIOS) { + return const ['hap', 'FFmpeg', 'dav1d']; + } + if (Platform.isAndroid) { + return const ['AMediaCodec', 'FFmpeg', 'dav1d']; + } + return null; +} + + +class FvpPlayerEngine implements PlayerEngine { + FvpPlayerEngine() { + _configureBaseProperties(); + _stateSub = _player.onStateChanged.listen(_handleStateChanged); + _statusSub = _player.onMediaStatus.listen(_handleMediaStatusChanged); + _eventSub = _player.onEvent.listen(_handleEvent); + _player.onSubtitleText(_handleSubtitleText); + _player.textureId.addListener(_bumpTextureVersion); + } + + final mdk.Player _player = mdk.Player(); + + @override + final PlayerStreams stream = PlayerStreams(); + @override + final PlayerState state = PlayerState(); + @override + final ValueNotifier videoSize = ValueNotifier(null); + @override + final ValueNotifier textureVersion = ValueNotifier(0); + + @override + bool get usesPlatformSurface => false; + + + double _estimatedRxBytes = 0; + int? _lastDownloadFrontierMs; + + @override + Future totalRxBytes() async { + if (_disposed) return null; + final bitRate = _player.mediaInfo.bitRate; + if (bitRate <= 0) return null; + final frontierMs = _player.position + _player.buffered(); + final lastFrontierMs = _lastDownloadFrontierMs; + _lastDownloadFrontierMs = frontierMs; + if (lastFrontierMs != null) { + final deltaMediaMs = frontierMs - lastFrontierMs; + if (deltaMediaMs > 0) { + _estimatedRxBytes += deltaMediaMs / 1000.0 * bitRate / 8.0; + } + } + return _estimatedRxBytes.round(); + } + + + void _resetRxEstimator() { + _estimatedRxBytes = 0; + _lastDownloadFrontierMs = null; + } + + @override + bool isNetworkStreamError(String error) => isFvpNetworkError(error); + + StreamSubscription< + ({mdk.PlaybackState oldValue, mdk.PlaybackState newValue}) + >? + _stateSub; + StreamSubscription<({mdk.MediaStatus oldValue, mdk.MediaStatus newValue})>? + _statusSub; + StreamSubscription? _eventSub; + Timer? _positionTimer; + Timer? _statsTimer; + + bool _disposed = false; + bool _manualStop = false; + bool _completedEmitted = false; + double _lastNonZeroVolume = 100.0; + String _hwdecMode = 'auto-copy'; + + + String _activeVideoDecoder = '?'; + + + ui.Size? _textureNaturalSize; + + + String? _lastColorSignature; + + + final List _recentEvents = []; + + + bool _isDolbyVision = false; + + + bool _dvSoftwareDecodeFailed = false; + + + final EmbeddedSubtitleCues _primaryCues = EmbeddedSubtitleCues(); + List _lastEmittedSubtitle = const []; + + + bool _subtitleCueReceived = false; + + + int? _desiredSubtitleRel; + int? _desiredAudioRel; + + + bool _reassertTracksPending = false; + + final SeekGuard _seekGuard = SeekGuard(); + + + final ValueNotifier _debugStats = + ValueNotifier(null); + + @override + ValueListenable get debugStats => _debugStats; + + @override + ValueListenable get textureId => _player.textureId; + + + void setDolbyVision(bool isDv) { + _isDolbyVision = isDv; + } + + @override + String get displayName => 'fvp'; + + @override + void configureHardwareDecoding(String mode) { + _hwdecMode = mode; + final List? decoders; + if (kDvForceSoftwareDecode && + _isDolbyVision && + mode != 'no' && + !_dvSoftwareDecodeFailed) { + decoders = kDvSoftwareDecoders; + } else { + decoders = decoderListForMode(mode); + } + if (decoders != null) { + _player.videoDecoders = decoders; + } + } + + @override + Future open( + String url, { + Duration start = Duration.zero, + bool play = true, + Map? headers, + CancellationToken? cancel, + }) async { + _ensureNotDisposed(); + cancel?.throwIfCancelled(); + await _stopForMediaChange(); + if (_disposed) return; + cancel?.throwIfCancelled(); + + _recentEvents.clear(); + _completedEmitted = false; + _dvSoftwareDecodeFailed = false; + _desiredSubtitleRel = null; + _desiredAudioRel = null; + _reassertTracksPending = false; + _resetRxEstimator(); + videoSize.value = null; + _seekGuard.arm(start); + state + ..position = start + ..buffer = start + ..duration = Duration.zero + ..playing = false + ..completed = false + ..tracks = const PlayerTracks() + ..track = const PlayerTrack() + ..subtitle = const [] + ..error = null; + _emitStateSnapshot(); + + configureHardwareDecoding(_hwdecMode); + _setHttpHeaders(headers); + await _configureAndroidTls(url); + if (_disposed) return; + cancel?.throwIfCancelled(); + _player.media = url; + _logSchemeIfDirectConnect(url); + _player.onSubtitleText(_handleSubtitleText); + _player.activeSubtitleTracks = const []; + _player.activeAudioTracks = const []; + + var ret = await _runCancellable(_player.prepare(position: 0), cancel); + if (_disposed) return; + cancel?.throwIfCancelled(); + var loaded = ret >= 0 || _player.mediaStatus.test(mdk.MediaStatus.loaded); + + if (!loaded && + kDvForceSoftwareDecode && + _isDolbyVision && + _hwdecMode != 'no') { + await Future.delayed(const Duration(milliseconds: 200)); + if (_disposed) return; + cancel?.throwIfCancelled(); + AppLogger.warn( + _tag, + 'DV software decode failed (ret=$ret); retrying with hardware decoders', + ); + _dvSoftwareDecodeFailed = true; + final hwDecoders = decoderListForMode(_hwdecMode); + if (hwDecoders != null) { + _player.videoDecoders = hwDecoders; + } + await _stopForMediaChange(); + if (_disposed) return; + cancel?.throwIfCancelled(); + _recentEvents.clear(); + await _configureAndroidTls(url); + if (_disposed) return; + cancel?.throwIfCancelled(); + _player.media = url; + _player.onSubtitleText(_handleSubtitleText); + _player.activeSubtitleTracks = const []; + _player.activeAudioTracks = const []; + ret = await _runCancellable(_player.prepare(position: 0), cancel); + if (_disposed) return; + cancel?.throwIfCancelled(); + loaded = ret >= 0 || _player.mediaStatus.test(mdk.MediaStatus.loaded); + } + + if (!loaded) { + await Future.delayed(const Duration(milliseconds: 500)); + if (_disposed) return; + _logOpenFailureDiagnostics(url, start, ret); + throw StateError('MDK 打开媒体失败:$ret'); + } + if (start > Duration.zero) { + _applyPlaybackBufferRange(); + await _primeResumeSeek(start, cancel); + if (_disposed) return; + cancel?.throwIfCancelled(); + } + + _refreshMediaInfo(); + _logDvDiagnostics(); + await _refreshTexture(); + if (_disposed) return; + cancel?.throwIfCancelled(); + _player + ..volume = state.volume / 100.0 + ..mute = state.volume <= 0; + if (play) { + await this.play(); + } + } + + + Future _runCancellable(Future future, CancellationToken? cancel) { + if (cancel == null) return future; + return Future.any([ + future, + cancel.whenCancelled.then( + (_) => Future.error(CancelledException(cancel.reason)), + ), + ]); + } + + @override + Future play() async { + _ensureNotDisposed(); + _manualStop = false; + final tex = _player.textureId.value; + if (tex == null || tex < 0) { + try { + await _refreshTexture(); + } catch (e) { + AppLogger.warn(_tag, 'play: texture refresh failed', e); + } + if (_disposed) return; + } + _player.state = mdk.PlaybackState.playing; + _setPlaying(true); + _startPositionTimer(); + } + + @override + Future pause() async { + if (_disposed) return; + _player.state = mdk.PlaybackState.paused; + _setPlaying(false); + } + + @override + Future stop() async { + if (_disposed) return; + await _stopForMediaChange(); + } + + @override + Future seek(Duration position) async { + if (_disposed) return; + final durationMs = state.duration.inMilliseconds; + final maxMs = durationMs <= 0 ? 1 << 62 : durationMs; + final target = Duration( + milliseconds: position.inMilliseconds.clamp(0, maxMs).toInt(), + ); + _seekGuard.arm(target); + _resetRxEstimator(); + state.position = target; + stream.addPosition(target); + await _player.seek(position: target.inMilliseconds); + _pollPosition(); + } + + @override + Future setRate(double rate) async { + if (_disposed) return; + state.rate = rate; + _player.playbackRate = rate; + _applyPlaybackBufferRange(); + } + + @override + Future setVolume(double volume) async { + if (_disposed) return; + final next = volume.clamp(0.0, 200.0); + state.volume = next; + if (next > 0) _lastNonZeroVolume = next; + _player + ..volume = next / 100.0 + ..mute = next <= 0; + stream.addVolume(next); + } + + @override + Future toggleMute() async { + if (state.volume <= 0) { + await setVolume(_lastNonZeroVolume <= 0 ? 100 : _lastNonZeroVolume); + } else { + await setVolume(0); + } + } + + @override + Future setAudioTrack(AudioTrack track) async { + if (_disposed) return; + if (track.no) { + _desiredAudioRel = -1; + _player.activeAudioTracks = const []; + } else if (track.index != null) { + final rel = _relativeAudioIndex(track.index!); + if (rel >= 0) { + _desiredAudioRel = rel; + _player.activeAudioTracks = [rel]; + } + } + _reassertTracksPending = true; + state.track = state.track.copyWith(audio: track); + stream.addTrack(state.track); + } + + @override + Future setSubtitleTrack(SubtitleTrack track) async { + if (_disposed) return; + _primaryCues.clear(); + _lastEmittedSubtitle = const []; + _subtitleCueReceived = false; + if (track.no) { + _desiredSubtitleRel = -1; + _player.activeSubtitleTracks = const []; + _clearSubtitle(); + AppLogger.debug(_tag, 'setSubtitleTrack: disabled'); + } else if (track.index != null) { + final rel = _relativeSubtitleIndex(track.index!); + if (rel >= 0) { + _desiredSubtitleRel = rel; + _player.activeSubtitleTracks = [rel]; + AppLogger.info( + _tag, + 'setSubtitleTrack: activated globalIdx=${track.index} ' + 'relIdx=$rel title=${track.title} lang=${track.language}', + ); + } else { + AppLogger.warn( + _tag, + 'setSubtitleTrack: no relative index for global idx=${track.index} ' + '(engine has ${state.tracks.subtitle.length} subtitle tracks)', + ); + } + } else { + AppLogger.warn( + _tag, + 'setSubtitleTrack: track has no index and is not "no" — ' + 'id=${track.id} title=${track.title} (no-op)', + ); + } + _reassertTracksPending = true; + state.track = state.track.copyWith(subtitle: track); + stream.addTrack(state.track); + } + + @override + void setNativeSubtitleRendering(bool enabled) { + if (_disposed) return; + _player.setProperty('subtitle', enabled ? '1' : '0'); + AppLogger.debug( + _tag, + 'setNativeSubtitleRendering: ${enabled ? "ON (bitmap/PGS)" : "OFF (text overlay)"}', + ); + } + + + void _reapplyDesiredTracks() { + final sub = _desiredSubtitleRel; + final aud = _desiredAudioRel; + if (sub != null) { + _player.activeSubtitleTracks = sub >= 0 ? [sub] : const []; + } + if (aud != null) { + _player.activeAudioTracks = aud >= 0 ? [aud] : const []; + } + } + + @override + void setSubtitleStyle({ + required double fontSize, + required double bgOpacity, + required int position, + }) { + if (_disposed) return; + _player + ..setProperty('subtitle.font.size', fontSize.toStringAsFixed(0)) + ..setProperty('subtitle.size', 'video') + ..setProperty('subtitle.alignment.x', '0') + ..setProperty('subtitle.alignment.y', _subtitleAlignY(position)) + ..setProperty('subtitle.margin.y', _subtitleMarginY(position)); + final alpha = (bgOpacity.clamp(0.0, 1.0) * 255).round(); + _player.setProperty( + 'subtitle.color.background', + '0x000000${alpha.toRadixString(16).padLeft(2, '0')}', + ); + } + + @override + Widget buildVideoView(BuildContext context, ValueListenable fit) { + return TextureVideoView( + textureId: textureId, + textureVersion: textureVersion, + videoSize: videoSize, + fit: fit, + ); + } + + @override + Future dispose() async { + if (_disposed) return; + _disposed = true; + _stopPositionTimer(); + _player.textureId.removeListener(_bumpTextureVersion); + await _stateSub?.cancel(); + await _statusSub?.cancel(); + await _eventSub?.cancel(); + _player.onSubtitleText(null); + _player.dispose(); + videoSize.dispose(); + textureVersion.dispose(); + _debugStats.dispose(); + stream.dispose(); + } + + void _configureBaseProperties() { + _player.setProperty('subtitle', '0'); + _player + ..setProperty('video.decoder', 'shader_resource=0') + ..setProperty('avformat.strict', 'experimental') + ..setProperty('avformat.safe', '0') + ..setProperty('avio.reconnect', '1') + ..setProperty('avio.reconnect_delay_max', '7') + ..setProperty('avformat.rtsp_transport', 'tcp') + ..setProperty('avformat.extension_picky', '0') + ..setProperty('avformat.allowed_segment_extensions', 'ALL'); + final base = bufferRangeForRate(1.0); + _player.setBufferRange(min: base.min, max: base.max); + } + + void _applyPlaybackBufferRange() { + final range = playbackBufferRangeForRate( + state.rate, + isDolbyVision: _isDolbyVision, + ); + _player.setBufferRange(min: range.min, max: range.max); + } + + void _setHttpHeaders(Map? headers) { + final values = headers?.entries + .where((entry) => entry.key.isNotEmpty && entry.value.isNotEmpty) + .map((entry) { + final value = entry.value.replaceAll(RegExp(r'[\r\n]+'), ' '); + return '${entry.key}: $value'; + }) + .join('\r\n'); + _player.setProperty( + 'avio.headers', + values == null || values.isEmpty ? '' : '$values\r\n', + ); + } + + + void _logSchemeIfDirectConnect(String url) { + final uri = Uri.tryParse(url); + if (uri == null || uri.host == '127.0.0.1' || uri.host == 'localhost') { + return; + } + AppLogger.info( + _tag, + 'direct-connect open: scheme=${uri.scheme} host=${uri.host} ' + 'port=${uri.hasPort ? uri.port : "(default)"}', + ); + } + + + Future _configureAndroidTls(String url) async { + if (!Platform.isAndroid) return; + final uri = Uri.tryParse(url); + if (uri == null || uri.scheme != 'https') return; + final trustedCaBundlePath = await _loadAndroidTrustedCaBundlePath(); + _player.setProperty('avio.ca_file', trustedCaBundlePath); + _player.setProperty('avio.tls_verify', '1'); + } + + void _handleStateChanged( + ({mdk.PlaybackState oldValue, mdk.PlaybackState newValue}) event, + ) { + if (_disposed) return; + final next = event.newValue; + _setPlaying(next == mdk.PlaybackState.playing); + if (next == mdk.PlaybackState.playing) { + _startPositionTimer(); + } + if (next == mdk.PlaybackState.stopped && + !_manualStop && + _isAtNaturalEnd()) { + _emitCompleted(); + } + } + + void _handleMediaStatusChanged( + ({mdk.MediaStatus oldValue, mdk.MediaStatus newValue}) event, + ) { + if (_disposed) return; + _refreshMediaInfo(); + if (_reassertTracksPending && event.newValue.test(mdk.MediaStatus.loaded)) { + _reassertTracksPending = false; + _reapplyDesiredTracks(); + } + if (event.newValue.test(mdk.MediaStatus.end) && + !_manualStop && + _isAtNaturalEnd()) { + _emitCompleted(); + } + } + + void _handleEvent(mdk.MediaEvent event) { + if (_disposed) return; + if (event.category != 'reader.buffering') { + _recentEvents.add('${event.category}|err=${event.error}|${event.detail}'); + if (_recentEvents.length > 16) _recentEvents.removeAt(0); + } + if (event.category == 'reader.buffering') { + _pollPosition(); + } else if (event.category == 'decoder.video') { + _activeVideoDecoder = event.detail; + AppLogger.info(_tag, 'active video decoder: ${event.detail}'); + } + _maybeEmitNetworkError(event); + } + + + void _maybeEmitNetworkError(mdk.MediaEvent event) { + final payload = fvpNetworkErrorPayload( + category: event.category, + errorCode: event.error, + detail: event.detail, + ); + if (payload == null) return; + AppLogger.warn(_tag, 'network error via mdk event: $payload'); + stream.addError(payload); + } + + + void _logDvDiagnostics() { + try { + final video = _player.mediaInfo.video; + if (video == null || video.isEmpty) return; + final codec = video.first.codec; + AppLogger.info( + _tag, + 'DV diag: doviProfile=${codec.doviProfile} ' + 'colorSpace=${codec.colorSpace} codec=${codec.codec} ' + 'isDolbyVision=$_isDolbyVision forceSW=$kDvForceSoftwareDecode ' + 'hwdec=$_hwdecMode', + ); + } catch (e) { + AppLogger.debug(_tag, 'logDvDiagnostics failed', e); + } + } + + + String? _currentColorSignature() { + try { + final video = _player.mediaInfo.video; + if (video == null || video.isEmpty) return null; + final codec = video.first.codec; + return '${codec.doviProfile}/${codec.colorSpace}'; + } catch (e) { + AppLogger.debug(_tag, 'currentColorSignature failed', e); + return null; + } + } + + + Future _primeResumeSeek( + Duration start, + CancellationToken? cancel, + ) async { + for (var attempt = 1; attempt <= 2; attempt++) { + final pos = await _runCancellable( + _player.seek(position: start.inMilliseconds), + cancel, + ); + if (_disposed) return; + cancel?.throwIfCancelled(); + if (pos >= 0) { + AppLogger.info( + _tag, + 'resume seek to ${start.inMilliseconds}ms (attempt $attempt)', + ); + return; + } + AppLogger.warn( + _tag, + 'resume seek to ${start.inMilliseconds}ms skipped ret=$pos ' + '(attempt $attempt)', + ); + await Future.delayed(const Duration(milliseconds: 200)); + if (_disposed) return; + cancel?.throwIfCancelled(); + } + AppLogger.warn( + _tag, + 'resume seek failed after retries; continuing from file head', + ); + _seekGuard.disarm(); + } + + + void _logOpenFailureDiagnostics(String url, Duration start, int ret) { + try { + final status = _player.mediaStatus; + final decoders = _player.videoDecoders; + final host = Uri.tryParse(url)?.host ?? '?'; + final events = _recentEvents.isEmpty + ? '(none)' + : _recentEvents.join(' ▸ '); + AppLogger.warn( + _tag, + 'open FAILED ret=$ret startMs=${start.inMilliseconds} host=$host ' + 'mediaStatus=$status decoders=$decoders isDV=$_isDolbyVision ' + 'hwdec=$_hwdecMode\n recentEvents: $events', + ); + } catch (e) { + AppLogger.warn(_tag, 'logOpenFailureDiagnostics failed', e); + } + } + + + bool _isAtNaturalEnd() { + final pos = state.position.inMilliseconds; + final dur = state.duration.inMilliseconds; + if (dur <= 0) return true; + return pos >= dur - 2000; + } + + void _handleSubtitleText(double start, double end, List text) { + if (_disposed) return; + final cleaned = text + .map(_stripAssMetadata) + .where((s) => s.isNotEmpty) + .toList(); + + if (cleaned.isEmpty) return; + if (!_subtitleCueReceived) { + _subtitleCueReceived = true; + final preview = cleaned.first.length > 40 + ? cleaned.first.substring(0, 40) + : cleaned.first; + AppLogger.debug( + _tag, + 'subtitle: first cue received from engine ' + '(start=${start.toStringAsFixed(1)}s text="$preview")', + ); + } + _primaryCues.add(start, end, cleaned); + } + + + void _refreshPrimarySubtitle(Duration position) { + if (_disposed || _primaryCues.isEmpty) return; + final lines = _primaryCues.textAt(position); + if (listEquals(lines, _lastEmittedSubtitle)) return; + _lastEmittedSubtitle = List.unmodifiable(lines); + state.subtitle = _lastEmittedSubtitle; + stream.addSubtitle(state.subtitle); + } + + void _startPositionTimer() { + _positionTimer ??= Timer.periodic( + const Duration(milliseconds: 250), + (_) => _pollPosition(), + ); + _startStatsTimer(); + } + + void _stopPositionTimer() { + _positionTimer?.cancel(); + _positionTimer = null; + _stopStatsTimer(); + } + + void _startStatsTimer() { + _statsTimer ??= Timer.periodic( + const Duration(seconds: 1), + (_) => _sampleStats(), + ); + } + + void _stopStatsTimer() { + _statsTimer?.cancel(); + _statsTimer = null; + } + + + void _sampleStats() { + if (_disposed || !state.playing) return; + try { + final bufferLeadMs = _player.buffered(); + final bitrateMbps = _player.mediaInfo.bitRate / 1e6; + _debugStats.value = PlaybackStats( + position: state.position, + bufferLeadMs: bufferLeadMs, + bitrateMbps: bitrateMbps, + rate: state.rate, + decoder: _activeVideoDecoder, + isDolbyVision: _isDolbyVision, + hwdec: _hwdecMode, + ); + } catch (e) { + AppLogger.debug(_tag, 'sampleStats failed', e); + } + } + + void _pollPosition() { + if (_disposed) return; + final position = Duration(milliseconds: _player.position); + if (_seekGuard.shouldSuppress(position)) return; + final bufferedEnd = Duration( + milliseconds: _player.position + _player.buffered(), + ); + state.position = position; + if (state.duration > Duration.zero && bufferedEnd > state.duration) { + state.buffer = state.duration; + } else { + state.buffer = bufferedEnd; + } + stream.addPosition(state.position); + stream.addBuffer(state.buffer); + _refreshPrimarySubtitle(position); + } + + void _refreshMediaInfo() { + if (_disposed) return; + try { + final info = _player.mediaInfo; + if (info.duration > 0) { + final duration = Duration(milliseconds: info.duration); + if (duration != state.duration) { + state.duration = duration; + stream.addDuration(duration); + } + } + final tracks = PlayerTracks( + audio: (info.audio ?? const []) + .map(_audioTrackFromStream) + .toList(growable: false), + subtitle: (info.subtitle ?? const []) + .map(_subtitleTrackFromStream) + .toList(growable: false), + ); + if (tracks != state.tracks) { + state.tracks = tracks; + stream.addTracks(tracks); + } + _syncSelectedTracks(); + } catch (e) { + AppLogger.debug(_tag, 'refreshMediaInfo failed', e); + } + } + + + int _relativeAudioIndex(int globalIndex) => + state.tracks.audio.indexWhere((t) => t.index == globalIndex); + + int _relativeSubtitleIndex(int globalIndex) => + state.tracks.subtitle.indexWhere((t) => t.index == globalIndex); + + void _syncSelectedTracks() { + final activeAudioRel = _player.activeAudioTracks; + final audio = + (activeAudioRel.isNotEmpty && + activeAudioRel.first >= 0 && + activeAudioRel.first < state.tracks.audio.length) + ? state.tracks.audio[activeAudioRel.first] + : const AudioTrack.auto(); + final activeSubRel = _player.activeSubtitleTracks; + final subtitle = + (activeSubRel.isNotEmpty && + activeSubRel.first >= 0 && + activeSubRel.first < state.tracks.subtitle.length) + ? state.tracks.subtitle[activeSubRel.first] + : const SubtitleTrack.no(); + final next = PlayerTrack(audio: audio, subtitle: subtitle); + if (next != state.track) { + state.track = next; + stream.addTrack(next); + } + } + + + Future _refreshTexture() async { + if (_disposed) return; + final existing = _player.textureId.value; + final hasTexture = existing != null && existing >= 0; + + ui.Size? newSize; + try { + newSize = await _player.textureSize; + } catch (e) { + AppLogger.debug(_tag, 'textureSize unavailable', e); + newSize = null; + } + if (_disposed) return; + + final sizeChanged = + hasTexture && + newSize != null && + _textureNaturalSize != null && + (newSize.width != _textureNaturalSize!.width || + newSize.height != _textureNaturalSize!.height); + + final newSignature = _currentColorSignature(); + final colorChanged = + hasTexture && + _lastColorSignature != null && + newSignature != null && + newSignature != _lastColorSignature; + + if (!hasTexture || sizeChanged || colorChanged) { + if (sizeChanged) { + AppLogger.info( + _tag, + 'texture size changed $_textureNaturalSize -> $newSize; recreating', + ); + } else if (colorChanged) { + AppLogger.info( + _tag, + 'color signature changed $_lastColorSignature -> $newSignature; ' + 'recreating texture to reset DV color pipeline', + ); + } + final int tex; + try { + tex = await _player.updateTexture(); + } catch (e) { + if (_disposed) return; + rethrow; + } + if (_disposed) return; + if (tex < 0) { + throw StateError('MDK 创建视频纹理失败'); + } + _textureNaturalSize = newSize; + } + + if (newSignature != null) { + _lastColorSignature = newSignature; + } + + if (newSize != null) { + videoSize.value = newSize; + } else if (!hasTexture) { + videoSize.value = null; + } + if (_disposed) return; + textureVersion.value++; + } + + Future _stopForMediaChange() async { + _manualStop = true; + _setPlaying(false); + _stopPositionTimer(); + _clearSubtitle(); + if (_player.state != mdk.PlaybackState.stopped) { + _player.state = mdk.PlaybackState.stopped; + try { + final stopped = _player.waitFor( + mdk.PlaybackState.stopped, + timeout: 1500, + ); + if (!stopped) { + AppLogger.warn(_tag, 'Timed out waiting for MDK to stop'); + } + } catch (error) { + AppLogger.warn(_tag, 'Failed to wait for MDK stop', error); + } + } + await Future.delayed(Duration.zero); + } + + void _setPlaying(bool playing) { + if (state.playing == playing) return; + state.playing = playing; + stream.addPlaying(playing); + } + + void _emitCompleted() { + if (_completedEmitted) return; + _completedEmitted = true; + state.completed = true; + stream.addCompleted(true); + _stopPositionTimer(); + } + + void _emitStateSnapshot() { + stream.addPosition(state.position); + stream.addDuration(state.duration); + stream.addBuffer(state.buffer); + stream.addPlaying(state.playing); + stream.addCompleted(state.completed); + stream.addTracks(state.tracks); + stream.addTrack(state.track); + stream.addSubtitle(state.subtitle); + stream.addVolume(state.volume); + } + + void _clearSubtitle() { + _primaryCues.clear(); + _lastEmittedSubtitle = const []; + state.subtitle = const []; + stream.addSubtitle(const []); + } + + void _bumpTextureVersion() { + if (!_disposed) textureVersion.value++; + } + + void _ensureNotDisposed() { + if (_disposed) { + throw StateError('播放器已释放'); + } + } + + static AudioTrack _audioTrackFromStream(mdk.AudioStreamInfo stream) { + final title = stream.metadata['title']; + final language = stream.metadata['language']; + return AudioTrack( + id: stream.index.toString(), + index: stream.index, + title: title?.isNotEmpty == true ? title : null, + language: language?.isNotEmpty == true ? language : null, + ); + } + + static SubtitleTrack _subtitleTrackFromStream(mdk.SubtitleStreamInfo stream) { + final title = stream.metadata['title']; + final language = stream.metadata['language']; + return SubtitleTrack( + id: stream.index.toString(), + index: stream.index, + title: title?.isNotEmpty == true ? title : null, + language: language?.isNotEmpty == true ? language : null, + ); + } + + static String _subtitleAlignY(int position) { + if (position <= 35) return '1'; + if (position >= 85) return '-1'; + return '0'; + } + + static String _subtitleMarginY(int position) { + final p = position.clamp(0, 150); + if (p >= 85) { + return ((150 - p) * 4).clamp(10, 260).round().toString(); + } + if (p <= 35) { + return (p * 4).clamp(10, 180).round().toString(); + } + return '10'; + } + + + static String _stripAssMetadata(String text) { + if (text.isEmpty) return text; + final match = RegExp( + r'^\d+,\d+,[^,]*,[^,]*,\d*,\d*,\d*,[^,]*,(.*)', + dotAll: true, + ).firstMatch(text); + if (match != null) { + text = match.group(1) ?? text; + } + text = text.replaceAll(RegExp(r'\{\\[^}]*\}'), ''); + return text.trim(); + } +} diff --git a/lib/core/infra/player_engine/media3_player_engine.dart b/lib/core/infra/player_engine/media3_player_engine.dart new file mode 100644 index 0000000..5a0f371 --- /dev/null +++ b/lib/core/infra/player_engine/media3_player_engine.dart @@ -0,0 +1,609 @@ +import 'dart:async'; +import 'dart:io' show Platform; +import 'dart:ui' as ui; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +import '../../contracts/cancellation.dart'; +import '../../../shared/utils/app_logger.dart'; +import 'player_engine.dart'; + +const _tag = 'Media3Player'; + + +@visibleForTesting +bool isMedia3NetworkError(String code) => code.startsWith('ERROR_CODE_IO_'); + + +class Media3PlayerEngine implements PlayerEngine { + Media3PlayerEngine({bool? isAndroid}) { + final onAndroid = isAndroid ?? Platform.isAndroid; + if (!onAndroid) { + throw UnsupportedError('Media3PlayerEngine is Android-only'); + } + AppLogger.debug(_tag, 'construct'); + } + + static const MethodChannel _mc = MethodChannel('smplayer/media3_engine'); + static const EventChannel _events = EventChannel( + 'smplayer/media3_engine/events', + ); + + @override + final PlayerStreams stream = PlayerStreams(); + @override + final PlayerState state = PlayerState(); + + + @override + final ValueNotifier textureId = ValueNotifier(null); + @override + final ValueNotifier textureVersion = ValueNotifier(0); + @override + final ValueNotifier videoSize = ValueNotifier(null); + + StreamSubscription? _eventSub; + + + final ValueNotifier _playerIdNotifier = ValueNotifier(null); + int? get _playerId => _playerIdNotifier.value; + set _playerId(int? v) => _playerIdNotifier.value = v; + + + int _generation = 0; + + bool _disposed = false; + bool _completedEmitted = false; + double _lastNonZeroVolume = 100.0; + + @override + String get displayName => 'media3'; + + @override + bool get usesPlatformSurface => true; + + @override + Future totalRxBytes() async { + if (_disposed || _playerId == null) return null; + final value = await _mc.invokeMethod( + 'getProperty', + {'playerId': _playerId, 'name': 'totalRxBytes'}, + ); + return (value as num?)?.toInt(); + } + + @override + bool isNetworkStreamError(String error) => isMedia3NetworkError(error); + + @override + void configureHardwareDecoding(String mode) {} + + @override + Future open( + String url, { + Duration start = Duration.zero, + bool play = true, + Map? headers, + CancellationToken? cancel, + }) async { + _ensureNotDisposed(); + cancel?.throwIfCancelled(); + + await _ensureCreated(); + if (_disposed) return; + cancel?.throwIfCancelled(); + + final generation = ++_generation; + + final openFuture = _mc.invokeMethod('open', { + 'playerId': _playerId, + 'url': url, + 'headers': headers ?? const {}, + 'startMs': start.inMilliseconds, + 'play': play, + 'generation': generation, + }); + + await _runCancellable(openFuture, cancel, generation); + if (_disposed) return; + cancel?.throwIfCancelled(); + + _completedEmitted = false; + videoSize.value = null; + state + ..position = start + ..buffer = start + ..duration = Duration.zero + ..playing = false + ..completed = false + ..tracks = const PlayerTracks() + ..track = const PlayerTrack() + ..subtitle = const [] + ..error = null; + _emitStateSnapshot(); + await _invoke('replayKeyState'); + } + + + Future _runCancellable( + Future future, + CancellationToken? cancel, + int generation, + ) { + if (cancel == null) return future; + return Future.any([ + future, + cancel.whenCancelled.then((_) async { + if (!_disposed && _playerId != null) { + ++_generation; + try { + await _mc.invokeMethod('stop', { + 'playerId': _playerId, + 'generation': _generation, + }); + } catch (e) { + AppLogger.debug(_tag, 'stop on cancel failed', e); + } + } + throw CancelledException(cancel.reason); + }), + ]); + } + + @override + Future play() async { + if (_disposed || _playerId == null) return; + await _invoke('play'); + } + + @override + Future pause() async { + if (_disposed || _playerId == null) return; + await _invoke('pause'); + } + + @override + Future stop() async { + if (_disposed || _playerId == null) return; + ++_generation; + await _invoke('stop', {'generation': _generation}); + } + + @override + Future seek(Duration position) async { + if (_disposed || _playerId == null) return; + final durationMs = state.duration.inMilliseconds; + final maxMs = durationMs <= 0 ? (1 << 62) : durationMs; + final target = Duration( + milliseconds: position.inMilliseconds.clamp(0, maxMs).toInt(), + ); + state.position = target; + stream.addPosition(target); + await _invoke('seek', { + 'positionMs': target.inMilliseconds, + }); + } + + @override + Future setRate(double rate) async { + if (_disposed || _playerId == null) return; + state.rate = rate; + await _invoke('setRate', {'rate': rate}); + } + + @override + Future setVolume(double volume) async { + if (_disposed) return; + final next = volume.clamp(0.0, 200.0).toDouble(); + state.volume = next; + if (next > 0) _lastNonZeroVolume = next; + stream.addVolume(next); + if (_playerId == null) return; + await _invoke('setVolume', {'volume': next}); + } + + @override + Future toggleMute() async { + if (state.volume <= 0) { + await setVolume(_lastNonZeroVolume <= 0 ? 100 : _lastNonZeroVolume); + } else { + await setVolume(0); + } + } + + @override + Future setAudioTrack(AudioTrack track) async { + if (_disposed || _playerId == null) return; + await _invoke('setAudioTrack', {'id': track.id}); + state.track = state.track.copyWith(audio: track); + stream.addTrack(state.track); + } + + @override + Future setSubtitleTrack(SubtitleTrack track) async { + if (_disposed || _playerId == null) return; + await _invoke('setSubtitleTrack', {'id': track.id}); + state.track = state.track.copyWith(subtitle: track); + stream.addTrack(state.track); + } + + @override + void setNativeSubtitleRendering(bool enabled) { + + + } + + @override + void setSubtitleStyle({ + required double fontSize, + required double bgOpacity, + required int position, + }) { + if (_disposed || _playerId == null) return; + _invoke('setSubtitleStyle', { + 'fontSize': fontSize, + 'bgOpacity': bgOpacity, + 'position': position, + }); + } + + + @override + ValueListenable get debugStats => kEmptyPlaybackStats; + + @override + Widget buildVideoView(BuildContext context, ValueListenable fit) { + return _Media3PlatformVideoView( + playerId: _playerIdNotifier, + fit: fit, + onResizeMode: _setResizeMode, + ); + } + + Future _setResizeMode(String mode) async { + if (_disposed || _playerId == null) return; + try { + await _mc.invokeMethod('setResizeMode', { + 'playerId': _playerId, + 'mode': mode, + }); + } catch (e) { + AppLogger.debug(_tag, 'setResizeMode($mode) failed', e); + } + } + + @override + Future dispose() async { + if (_disposed) return; + _disposed = true; + ++_generation; + await _eventSub?.cancel(); + _eventSub = null; + final id = _playerId; + _playerId = null; + if (id != null) { + try { + await _mc.invokeMethod('dispose', { + 'playerId': id, + }); + } catch (e) { + AppLogger.warn(_tag, 'native dispose failed', e); + } + } + textureId.dispose(); + textureVersion.dispose(); + videoSize.dispose(); + _playerIdNotifier.dispose(); + stream.dispose(); + } + + + Future prewarmNative() async { + if (_disposed) return; + await _ensureCreated(); + } + + + Future? _createFuture; + + + Future _ensureCreated() { + if (_playerId != null) return Future.value(); + return _createFuture ??= _doCreateNative(); + } + + Future _doCreateNative() async { + try { + final result = await _mc.invokeMapMethod('create'); + if (result == null) { + throw StateError('Media3 create returned null'); + } + final createdPlayerId = (result['playerId'] as num).toInt(); + + if (_disposed) { + AppLogger.debug( + _tag, + 'disposed during create, releasing orphan playerId=$createdPlayerId', + ); + try { + await _mc.invokeMethod('dispose', { + 'playerId': createdPlayerId, + }); + } catch (e) { + AppLogger.warn(_tag, 'orphan dispose failed', e); + } + return; + } + + _playerId = createdPlayerId; + AppLogger.debug( + _tag, + 'created playerId=$_playerId ' + 'ffmpeg=${result['ffmpegAvailable']} v=${result['ffmpegVersion']}', + ); + _eventSub = _events.receiveBroadcastStream().listen( + _handleEvent, + onError: (Object e) { + if (_disposed) return; + AppLogger.warn(_tag, 'event stream error', e); + }, + ); + try { + await _mc.invokeMethod('setVolume', { + 'playerId': _playerId, + 'volume': state.volume, + }); + } catch (e) { + AppLogger.debug(_tag, 'initial setVolume replay failed', e); + } + } catch (e) { + _createFuture = null; + rethrow; + } + } + + + void _handleEvent(dynamic event) { + if (_disposed) return; + if (event is! Map) return; + if (event['playerId'] != _playerId) return; + final gen = (event['generation'] as num?)?.toInt(); + if (gen == null || gen != _generation) return; + + final type = event['type'] as String?; + switch (type) { + case 'position': + final ms = (event['positionMs'] as num?)?.toInt() ?? 0; + final pos = Duration(milliseconds: ms); + state.position = pos; + stream.addPosition(pos); + final bufMs = (event['bufferMs'] as num?)?.toInt(); + if (bufMs != null) { + final buf = Duration(milliseconds: bufMs); + state.buffer = buf; + stream.addBuffer(buf); + } + case 'duration': + final ms = (event['durationMs'] as num?)?.toInt() ?? 0; + final dur = Duration(milliseconds: ms); + if (dur == state.duration) return; + state.duration = dur; + stream.addDuration(dur); + case 'playing': + final playing = event['playing'] == true; + if (state.playing == playing) return; + state.playing = playing; + stream.addPlaying(playing); + case 'completed': + if (_completedEmitted) return; + _completedEmitted = true; + state.completed = true; + stream.addCompleted(true); + case 'videoSize': + final w = (event['width'] as num?)?.toDouble() ?? 0; + final h = (event['height'] as num?)?.toDouble() ?? 0; + if (w > 0 && h > 0) { + videoSize.value = ui.Size(w, h); + } + case 'tracks': + final tracks = _decodeTracks(event); + if (tracks == state.tracks) return; + state.tracks = tracks; + stream.addTracks(tracks); + case 'subtitle': + final lines = + (event['lines'] as List?)?.cast() ?? const []; + state.subtitle = lines; + stream.addSubtitle(lines); + case 'volume': + final vol = (event['volume'] as num?)?.toDouble(); + if (vol != null) { + state.volume = vol; + if (vol > 0) _lastNonZeroVolume = vol; + stream.addVolume(vol); + } + case 'error': + final code = event['code'] as String? ?? 'unknown'; + state.error = code; + AppLogger.warn(_tag, 'media3 error: $code ${event['message'] ?? ''}'); + stream.addError(code); + default: + AppLogger.debug(_tag, 'unknown event type: $type'); + } + } + + + PlayerTracks _decodeTracks(Map event) { + final audio = []; + for (final raw in (event['audio'] as List? ?? const [])) { + final m = raw as Map; + audio.add( + AudioTrack( + id: m['id'] as String, + index: (m['index'] as num?)?.toInt(), + title: (m['title'] as String?)?.isNotEmpty == true + ? m['title'] as String + : null, + language: (m['language'] as String?)?.isNotEmpty == true + ? m['language'] as String + : null, + ), + ); + } + final subtitle = []; + for (final raw in (event['subtitle'] as List? ?? const [])) { + final m = raw as Map; + subtitle.add( + SubtitleTrack( + id: m['id'] as String, + index: (m['index'] as num?)?.toInt(), + title: (m['title'] as String?)?.isNotEmpty == true + ? m['title'] as String + : null, + language: (m['language'] as String?)?.isNotEmpty == true + ? m['language'] as String + : null, + ), + ); + } + return PlayerTracks(audio: audio, subtitle: subtitle); + } + + Future _invoke(String method, [Map? args]) { + return _mc.invokeMethod(method, { + 'playerId': _playerId, + ...?args, + }); + } + + void _ensureNotDisposed() { + if (_disposed) throw StateError('Media3PlayerEngine has been disposed'); + } + + void _emitStateSnapshot() { + stream.addPosition(state.position); + stream.addDuration(state.duration); + stream.addBuffer(state.buffer); + stream.addPlaying(state.playing); + stream.addCompleted(state.completed); + stream.addTracks(state.tracks); + stream.addTrack(state.track); + stream.addSubtitle(state.subtitle); + stream.addVolume(state.volume); + } +} + + +class _Media3PlatformVideoView extends StatefulWidget { + const _Media3PlatformVideoView({ + required this.playerId, + required this.fit, + required this.onResizeMode, + }); + + static const String _viewType = 'smplayer/media3_surface'; + + final ValueListenable playerId; + final ValueListenable fit; + final Future Function(String mode) onResizeMode; + + @override + State<_Media3PlatformVideoView> createState() => + _Media3PlatformVideoViewState(); +} + +class _Media3PlatformVideoViewState extends State<_Media3PlatformVideoView> { + int? _lastSentPlayerId; + String? _lastSentMode; + + @override + void initState() { + super.initState(); + widget.playerId.addListener(_syncResizeMode); + widget.fit.addListener(_syncResizeMode); + _syncResizeMode(); + } + + @override + void didUpdateWidget(covariant _Media3PlatformVideoView oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.playerId != widget.playerId) { + oldWidget.playerId.removeListener(_syncResizeMode); + widget.playerId.addListener(_syncResizeMode); + } + if (oldWidget.fit != widget.fit) { + oldWidget.fit.removeListener(_syncResizeMode); + widget.fit.addListener(_syncResizeMode); + } + _syncResizeMode(); + } + + @override + void dispose() { + widget.playerId.removeListener(_syncResizeMode); + widget.fit.removeListener(_syncResizeMode); + super.dispose(); + } + + void _syncResizeMode() { + final id = widget.playerId.value; + if (id == null) return; + final mode = _resizeModeForFit(widget.fit.value); + if (_lastSentPlayerId == id && _lastSentMode == mode) return; + _lastSentPlayerId = id; + _lastSentMode = mode; + unawaited(widget.onResizeMode(mode)); + } + + String _resizeModeForFit(BoxFit fit) { + return switch (fit) { + BoxFit.fill => 'fill', + BoxFit.cover => 'zoom', + _ => 'fit', + }; + } + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: widget.playerId, + builder: (context, id, _) { + if (id == null) { + return const SizedBox.expand(); + } + return PlatformViewLink( + key: ValueKey(id), + viewType: _Media3PlatformVideoView._viewType, + surfaceFactory: (context, controller) { + return AndroidViewSurface( + controller: controller as AndroidViewController, + gestureRecognizers: + const >{}, + hitTestBehavior: PlatformViewHitTestBehavior.transparent, + ); + }, + onCreatePlatformView: (params) { + final controller = + PlatformViewsService.initExpensiveAndroidView( + id: params.id, + viewType: _Media3PlatformVideoView._viewType, + layoutDirection: TextDirection.ltr, + creationParams: {'playerId': id}, + creationParamsCodec: const StandardMessageCodec(), + onFocus: () => params.onFocusChanged(true), + ) + ..addOnPlatformViewCreatedListener( + params.onPlatformViewCreated, + ) + ..create(); + return controller; + }, + ); + }, + ); + } +} diff --git a/lib/core/infra/player_engine/media_kit_player_engine.dart b/lib/core/infra/player_engine/media_kit_player_engine.dart new file mode 100644 index 0000000..93cfbb5 --- /dev/null +++ b/lib/core/infra/player_engine/media_kit_player_engine.dart @@ -0,0 +1,615 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:media_kit/media_kit.dart' as mk; +import 'package:media_kit_video/media_kit_video.dart' as mkv; + +import '../../contracts/cancellation.dart'; +import '../../../shared/utils/app_logger.dart'; +import 'player_engine.dart'; + +const _tag = 'MKPlayer'; + + +@visibleForTesting +bool isMpvNetworkError(String error) { + final lower = error.toLowerCase(); + if (lower.startsWith('tcp:') || lower.startsWith('tls:')) return true; + const patterns = [ + 'failed to open http', + 'loading failed', + 'connection refused', + 'connection reset', + 'connection timed out', + 'network is unreachable', + 'host not found', + 'name resolution', + 'http error 4', + 'http error 5', + 'tls handshake', + ]; + for (final p in patterns) { + if (lower.contains(p)) return true; + } + return false; +} + +const int _mpvNetworkTimeoutSeconds = 60; + +double clampMediaKitVolume(double volume) => + volume.clamp(0.0, 200.0).toDouble(); + + +PlayerTracks mapMkTracksToPlayerTracks(mk.Tracks mkTracks) { + final audio = []; + for (var i = 0; i < mkTracks.audio.length; i++) { + final t = mkTracks.audio[i]; + if (t.id == 'auto' || t.id == 'no') continue; + audio.add(mapMkAudioTrack(t, index: i)); + } + + final subtitle = []; + for (var i = 0; i < mkTracks.subtitle.length; i++) { + final t = mkTracks.subtitle[i]; + if (t.id == 'auto' || t.id == 'no') continue; + subtitle.add(mapMkSubtitleTrack(t, index: i)); + } + + return PlayerTracks(audio: audio, subtitle: subtitle); +} + + +AudioTrack mapMkAudioTrack(mk.AudioTrack t, {int? index}) { + if (t.id == 'auto') return const AudioTrack.auto(); + if (t.id == 'no') return const AudioTrack.no(); + return AudioTrack( + id: t.id, + index: index, + title: t.title?.isNotEmpty == true ? t.title : null, + language: t.language?.isNotEmpty == true ? t.language : null, + native: t, + ); +} + + +SubtitleTrack mapMkSubtitleTrack(mk.SubtitleTrack t, {int? index}) { + if (t.id == 'auto') return const SubtitleTrack.no(); + if (t.id == 'no') return const SubtitleTrack.no(); + return SubtitleTrack( + id: t.id, + index: index, + title: t.title?.isNotEmpty == true ? t.title : null, + language: t.language?.isNotEmpty == true ? t.language : null, + native: t, + ); +} + + +class MediaKitPlayerEngine implements PlayerEngine { + MediaKitPlayerEngine() { + AppLogger.debug(_tag, 'construct start'); + _subscribeToStreams(); + _configureNetworkTimeout(); + _configureNetworkReconnect(); + AppLogger.debug(_tag, 'construct done'); + } + + final mk.Player _player = mk.Player( + configuration: const mk.PlayerConfiguration( + title: 'smPlayer mpv', + libass: true, + + + logLevel: mk.MPVLogLevel.warn, + ), + ); + mkv.VideoController? _controller; + + @override + final PlayerStreams stream = PlayerStreams(); + @override + final PlayerState state = PlayerState(); + + @override + final ValueNotifier textureId = ValueNotifier(null); + + @override + final ValueNotifier textureVersion = ValueNotifier(0); + + @override + bool get usesPlatformSurface => false; + + @override + bool isNetworkStreamError(String error) => isMpvNetworkError(error); + + @override + Future totalRxBytes() async { + final native = _player.platform; + if (_disposed || native is! mk.NativePlayer) return null; + try { + final raw = await native.getProperty('cache-speed'); + final speed = double.tryParse(raw); + if (speed == null || speed < 0) return _rxBytesAccum; + final dtMs = _rxStopwatch.elapsedMilliseconds; + _rxStopwatch.reset(); + _rxStopwatch.start(); + + final clampedDtMs = dtMs.clamp(0, 5000); + _rxBytesAccum += (speed * clampedDtMs) ~/ 1000; + return _rxBytesAccum; + } catch (_) { + return null; + } + } + + @override + final ValueNotifier videoSize = ValueNotifier(null); + + bool _disposed = false; + bool _completedEmitted = false; + bool _playerReady = false; + double _lastNonZeroVolume = 100.0; + + final List> _subs = []; + double? _pendingInitialVolume; + + + int _rxBytesAccum = 0; + final Stopwatch _rxStopwatch = Stopwatch(); + + void _subscribeToStreams() { + _subs.addAll([ + _player.stream.playing.listen(_handlePlaying), + _player.stream.completed.listen(_handleCompleted), + _player.stream.position.listen(_handlePosition), + _player.stream.duration.listen(_handleDuration), + _player.stream.buffer.listen(_handleBuffer), + _player.stream.volume.listen(_handleVolume), + _player.stream.tracks.listen(_handleTracks), + _player.stream.track.listen(_handleTrack), + _player.stream.width.listen(_handleWidth), + _player.stream.height.listen(_handleHeight), + _player.stream.error.listen(_handleError), + _player.stream.log.listen(_handleMpvLog), + ]); + } + + + int? _lastHttpErrorStatus; + + static final RegExp _httpErrorStatusPattern = RegExp(r'HTTP error (\d{3})'); + + + void _handleMpvLog(mk.PlayerLog log) { + final text = log.text; + if (text.contains('HTTP error')) { + final match = _httpErrorStatusPattern.firstMatch(text); + if (match != null) { + _lastHttpErrorStatus = int.tryParse(match.group(1)!); + } + } + if (!kDebugMode) return; + if (text.contains('request:') || + text.contains('User-Agent') || + text.contains('HTTP error') || + text.contains('Location:')) { + AppLogger.debug(_tag, 'mpv[${log.prefix}] ${text.trim()}'); + } + } + + void _configureVolumeBoostLimit() { + _setMpvPropertyWhenReady('volume-max', '200'); + } + + void _configureNetworkTimeout() { + _setMpvPropertyWhenReady('network-timeout', '$_mpvNetworkTimeoutSeconds'); + } + + void _configureNetworkReconnect() { + const reconnectOptions = + 'reconnect=1,' + 'reconnect_streamed=1,' + 'reconnect_on_network_error=1,' + 'reconnect_on_http_error=4xx+5xx,' + 'reconnect_delay_max=5'; + _setMpvPropertyWhenReady('stream-lavf-o', reconnectOptions); + } + + + void _setMpvPropertyWhenReady(String propertyName, String propertyValue) { + final native = _player.platform; + if (native is! mk.NativePlayer) return; + AppLogger.debug(_tag, 'set $propertyName=$propertyValue queued'); + unawaited( + native + .setProperty(propertyName, propertyValue) + .then( + (_) => AppLogger.debug(_tag, 'set $propertyName done'), + onError: (Object e) => + AppLogger.debug(_tag, 'set $propertyName failed', e), + ), + ); + } + + void _handlePlaying(bool playing) { + if (_disposed) return; + if (state.playing == playing) return; + state.playing = playing; + stream.addPlaying(playing); + } + + void _handleCompleted(bool completed) { + if (_disposed) return; + if (!completed) return; + if (_completedEmitted) return; + _completedEmitted = true; + state.completed = true; + stream.addCompleted(true); + } + + void _handlePosition(Duration pos) { + if (_disposed) return; + state.position = pos; + stream.addPosition(pos); + } + + void _handleDuration(Duration dur) { + if (_disposed) return; + if (dur == state.duration) return; + state.duration = dur; + stream.addDuration(dur); + } + + void _handleBuffer(Duration buf) { + if (_disposed) return; + state.buffer = buf; + stream.addBuffer(buf); + } + + void _handleVolume(double vol) { + if (_disposed) return; + state.volume = vol; + if (vol > 0) _lastNonZeroVolume = vol; + stream.addVolume(vol); + } + + void _handleTracks(mk.Tracks mkTracks) { + if (_disposed) return; + final tracks = mapMkTracksToPlayerTracks(mkTracks); + if (tracks == state.tracks) return; + state.tracks = tracks; + stream.addTracks(tracks); + } + + void _handleTrack(mk.Track mkTrack) { + if (_disposed) return; + final next = PlayerTrack( + audio: mapMkAudioTrack(mkTrack.audio), + subtitle: mapMkSubtitleTrack(mkTrack.subtitle), + ); + if (next == state.track) return; + state.track = next; + stream.addTrack(next); + } + + void _handleWidth(int? w) { + if (_disposed) return; + _updateVideoSize(w, null); + } + + void _handleHeight(int? h) { + if (_disposed) return; + _updateVideoSize(null, h); + } + + void _handleError(String error) { + if (_disposed) return; + + + final status = _lastHttpErrorStatus; + final enriched = (status != null && !error.contains('(http ')) + ? '$error (http $status)' + : error; + state.error = enriched; + AppLogger.warn(_tag, 'mpv error: $enriched'); + stream.addError(enriched); + } + + void _updateVideoSize(int? newW, int? newH) { + final current = videoSize.value; + final w = newW?.toDouble() ?? current?.width ?? 0; + final h = newH?.toDouble() ?? current?.height ?? 0; + if (w > 0 && h > 0) { + videoSize.value = ui.Size(w, h); + } + } + + @override + String get displayName => 'mpv'; + + @override + void configureHardwareDecoding(String mode) { + final native = _player.platform; + if (native is! mk.NativePlayer) return; + final mpvHwdec = mode == 'no' ? 'no' : mode; + native.setProperty('hwdec', mpvHwdec).catchError((e) { + AppLogger.debug(_tag, 'setProperty(hwdec, $mpvHwdec) failed', e); + }); + AppLogger.debug(_tag, 'hwdec=$mpvHwdec'); + } + + @override + Future open( + String url, { + Duration start = Duration.zero, + bool play = true, + Map? headers, + CancellationToken? cancel, + }) async { + _ensureNotDisposed(); + cancel?.throwIfCancelled(); + AppLogger.debug( + _tag, + 'open start start=${start.inMilliseconds}ms play=$play ' + 'headers=${headers?.keys.join(',') ?? ''}', + ); + + _rxBytesAccum = 0; + _rxStopwatch.reset(); + _rxStopwatch.start(); + _completedEmitted = false; + _lastHttpErrorStatus = null; + videoSize.value = null; + state + ..position = start + ..buffer = start + ..duration = Duration.zero + ..playing = false + ..completed = false + ..tracks = const PlayerTracks() + ..track = const PlayerTrack() + ..subtitle = const [] + ..error = null; + _emitStateSnapshot(); + + final media = mk.Media( + url, + httpHeaders: headers, + start: start == Duration.zero ? null : start, + ); + + AppLogger.debug(_tag, 'player.open start'); + await _runCancellable(_player.open(media, play: false), cancel); + AppLogger.debug(_tag, 'player.open done'); + + if (_disposed) return; + cancel?.throwIfCancelled(); + + _playerReady = true; + _configureVolumeBoostLimit(); + final pendingVolume = _pendingInitialVolume; + if (pendingVolume != null) { + _pendingInitialVolume = null; + await setVolume(pendingVolume); + if (_disposed) return; + cancel?.throwIfCancelled(); + } + + if (start > Duration.zero) { + try { + await _runCancellable(_player.seek(start), cancel); + } on CancelledException { + rethrow; + } catch (e) { + AppLogger.debug(_tag, 'seek-on-open failed, continuing', e); + } + if (_disposed) return; + cancel?.throwIfCancelled(); + } + + if (play) { + AppLogger.debug(_tag, 'player.play start'); + await _player.play(); + AppLogger.debug(_tag, 'player.play done'); + } + } + + + Future _runCancellable(Future future, CancellationToken? cancel) { + if (cancel == null) return future; + return Future.any([ + future, + cancel.whenCancelled.then((_) async { + try { + if (!_disposed) await _player.stop(); + } catch (e) { + AppLogger.debug(_tag, 'stop on cancel failed', e); + } + throw CancelledException(cancel.reason); + }), + ]); + } + + @override + Future play() async { + if (_disposed) return; + await _player.play(); + } + + @override + Future pause() async { + if (_disposed) return; + await _player.pause(); + } + + @override + Future stop() async { + if (_disposed) return; + await _player.stop(); + } + + @override + Future seek(Duration position) async { + if (_disposed) return; + final durationMs = state.duration.inMilliseconds; + final maxMs = durationMs <= 0 ? (1 << 62) : durationMs; + final target = Duration( + milliseconds: position.inMilliseconds.clamp(0, maxMs).toInt(), + ); + state.position = target; + stream.addPosition(target); + await _player.seek(target); + } + + @override + Future setRate(double rate) async { + if (_disposed) return; + state.rate = rate; + await _player.setRate(rate); + } + + @override + Future setVolume(double volume) async { + if (_disposed) return; + final next = clampMediaKitVolume(volume); + state.volume = next; + if (next > 0) _lastNonZeroVolume = next; + if (!_playerReady) { + _pendingInitialVolume = next; + } else { + await _player.setVolume(next); + } + stream.addVolume(next); + } + + @override + Future toggleMute() async { + if (state.volume <= 0) { + await setVolume(_lastNonZeroVolume <= 0 ? 100 : _lastNonZeroVolume); + } else { + await setVolume(0); + } + } + + @override + Future setAudioTrack(AudioTrack track) async { + if (_disposed) return; + if (track.no) { + await _player.setAudioTrack(mk.AudioTrack.no()); + } else if (track.native is mk.AudioTrack) { + await _player.setAudioTrack(track.native as mk.AudioTrack); + } else { + AppLogger.warn( + _tag, + 'setAudioTrack: no native mk.AudioTrack on ${track.id}', + ); + } + state.track = state.track.copyWith(audio: track); + stream.addTrack(state.track); + } + + @override + Future setSubtitleTrack(SubtitleTrack track) async { + if (_disposed) return; + if (track.no) { + await _player.setSubtitleTrack(mk.SubtitleTrack.no()); + } else if (track.native is mk.SubtitleTrack) { + await _player.setSubtitleTrack(track.native as mk.SubtitleTrack); + } else { + AppLogger.warn( + _tag, + 'setSubtitleTrack: no native mk.SubtitleTrack on ${track.id} ' + '(track globalIdx=${track.index} title=${track.title})', + ); + } + state.track = state.track.copyWith(subtitle: track); + stream.addTrack(state.track); + } + + @override + void setNativeSubtitleRendering(bool enabled) { + + + } + + @override + void setSubtitleStyle({ + required double fontSize, + required double bgOpacity, + required int position, + }) {} + + + @override + ValueListenable get debugStats => kEmptyPlaybackStats; + + + @override + Widget buildVideoView(BuildContext context, ValueListenable fit) { + final controller = _ensureVideoController(); + return ValueListenableBuilder( + valueListenable: fit, + builder: (context, fitValue, _) => mkv.Video( + controller: controller, + fit: fitValue, + controls: null, + wakelock: false, + pauseUponEnteringBackgroundMode: false, + ), + ); + } + + @override + Future dispose() async { + if (_disposed) return; + _disposed = true; + for (final sub in _subs) { + await sub.cancel(); + } + _subs.clear(); + await _player.dispose(); + textureId.dispose(); + textureVersion.dispose(); + videoSize.dispose(); + stream.dispose(); + } + + mkv.VideoController _ensureVideoController() { + final existing = _controller; + if (existing != null) return existing; + + AppLogger.debug( + _tag, + 'video controller create start os=${Platform.operatingSystem}', + ); + + final controller = mkv.VideoController( + _player, + configuration: const mkv.VideoControllerConfiguration( + enableHardwareAcceleration: true, + ), + ); + AppLogger.debug(_tag, 'video controller create done'); + _controller = controller; + return controller; + } + + void _ensureNotDisposed() { + if (_disposed) throw StateError('MediaKitPlayerEngine has been disposed'); + } + + void _emitStateSnapshot() { + stream.addPosition(state.position); + stream.addDuration(state.duration); + stream.addBuffer(state.buffer); + stream.addPlaying(state.playing); + stream.addCompleted(state.completed); + stream.addTracks(state.tracks); + stream.addTrack(state.track); + stream.addSubtitle(state.subtitle); + stream.addVolume(state.volume); + } +} diff --git a/lib/core/infra/player_engine/player_engine.dart b/lib/core/infra/player_engine/player_engine.dart new file mode 100644 index 0000000..163eeec --- /dev/null +++ b/lib/core/infra/player_engine/player_engine.dart @@ -0,0 +1,327 @@ +import 'dart:async'; +import 'dart:ui' as ui; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import '../../contracts/cancellation.dart'; + + +int? streamOpenErrorHttpStatus(String error) { + final match = RegExp(r'\(http (\d{3})\)').firstMatch(error.toLowerCase()); + if (match == null) return null; + return int.tryParse(match.group(1)!); +} + + +bool isTerminalStreamOpenError(String error) { + final status = streamOpenErrorHttpStatus(error); + if (status == null) return false; + if (status == 408 || status == 429) return false; + return status >= 400 && status < 500; +} + +abstract class PlayerEngine { + PlayerStreams get stream; + PlayerState get state; + ValueListenable get textureId; + ValueListenable get textureVersion; + ValueNotifier get videoSize; + + + String get displayName; + + + bool get usesPlatformSurface => false; + + + Future totalRxBytes() async => null; + + + bool isNetworkStreamError(String error) => false; + + void configureHardwareDecoding(String mode); + + + Future open( + String url, { + Duration start = Duration.zero, + bool play = true, + Map? headers, + CancellationToken? cancel, + }); + + Future play(); + Future pause(); + Future stop(); + Future seek(Duration position); + Future setRate(double rate); + Future setVolume(double volume); + Future toggleMute(); + Future setAudioTrack(AudioTrack track); + Future setSubtitleTrack(SubtitleTrack track); + + + void setNativeSubtitleRendering(bool enabled) {} + + void setSubtitleStyle({ + required double fontSize, + required double bgOpacity, + required int position, + }); + + + Widget buildVideoView(BuildContext context, ValueListenable fit); + + + ValueListenable get debugStats => kEmptyPlaybackStats; + + Future dispose(); +} + + +final ValueNotifier kEmptyPlaybackStats = + ValueNotifier(null); + + +@immutable +class PlaybackStats { + final Duration position; + + + final int bufferLeadMs; + final double bitrateMbps; + final double rate; + + + final String decoder; + final bool isDolbyVision; + final String hwdec; + + const PlaybackStats({ + required this.position, + required this.bufferLeadMs, + required this.bitrateMbps, + required this.rate, + required this.decoder, + required this.isDolbyVision, + required this.hwdec, + }); +} + +class PlayerStreams { + final StreamController _position = + StreamController.broadcast(); + final StreamController _duration = + StreamController.broadcast(); + final StreamController _buffer = + StreamController.broadcast(); + final StreamController _playing = StreamController.broadcast(); + final StreamController _completed = StreamController.broadcast(); + final StreamController _tracks = + StreamController.broadcast(); + final StreamController _track = + StreamController.broadcast(); + final StreamController> _subtitle = + StreamController>.broadcast(); + final StreamController _volume = StreamController.broadcast(); + final StreamController _error = StreamController.broadcast(); + + Stream get position => _position.stream; + Stream get duration => _duration.stream; + Stream get buffer => _buffer.stream; + Stream get playing => _playing.stream; + Stream get completed => _completed.stream; + Stream get tracks => _tracks.stream; + Stream get track => _track.stream; + Stream> get subtitle => _subtitle.stream; + Stream get volume => _volume.stream; + Stream get error => _error.stream; + + void addPosition(Duration value) => _position.add(value); + void addDuration(Duration value) => _duration.add(value); + void addBuffer(Duration value) => _buffer.add(value); + void addPlaying(bool value) => _playing.add(value); + void addCompleted(bool value) => _completed.add(value); + void addTracks(PlayerTracks value) => _tracks.add(value); + void addTrack(PlayerTrack value) => _track.add(value); + void addSubtitle(List value) => _subtitle.add(value); + void addVolume(double value) => _volume.add(value); + void addError(String value) => _error.add(value); + + void dispose() { + _position.close(); + _duration.close(); + _buffer.close(); + _playing.close(); + _completed.close(); + _tracks.close(); + _track.close(); + _subtitle.close(); + _volume.close(); + _error.close(); + } +} + +class PlayerState { + Duration position = Duration.zero; + Duration duration = Duration.zero; + Duration buffer = Duration.zero; + bool playing = false; + bool completed = false; + double volume = 100.0; + double rate = 1.0; + PlayerTracks tracks = const PlayerTracks(); + PlayerTrack track = const PlayerTrack(); + List subtitle = const []; + String? error; +} + +@immutable +class PlayerTracks { + final List audio; + final List subtitle; + + const PlayerTracks({ + this.audio = const [], + this.subtitle = const [], + }); + + List get embeddedSubtitles => + subtitle.where((t) => t.id != 'auto' && t.id != 'no').toList(); + + List get embeddedAudio => + audio.where((t) => t.id != 'auto' && t.id != 'no').toList(); + + @override + bool operator ==(Object other) { + return other is PlayerTracks && + listEquals(other.audio, audio) && + listEquals(other.subtitle, subtitle); + } + + @override + int get hashCode => + Object.hash(Object.hashAll(audio), Object.hashAll(subtitle)); +} + +@immutable +class PlayerTrack { + final AudioTrack audio; + final SubtitleTrack subtitle; + + const PlayerTrack({ + this.audio = const AudioTrack.auto(), + this.subtitle = const SubtitleTrack.no(), + }); + + PlayerTrack copyWith({AudioTrack? audio, SubtitleTrack? subtitle}) { + return PlayerTrack( + audio: audio ?? this.audio, + subtitle: subtitle ?? this.subtitle, + ); + } + + @override + bool operator ==(Object other) { + return other is PlayerTrack && + other.audio == audio && + other.subtitle == subtitle; + } + + @override + int get hashCode => Object.hash(audio, subtitle); +} + +@immutable +class AudioTrack { + final String id; + final int? index; + final String? title; + final String? language; + final bool no; + final bool auto; + final Object? native; + + const AudioTrack({ + required this.id, + this.index, + this.title, + this.language, + this.no = false, + this.auto = false, + this.native, + }); + + const AudioTrack.no() + : id = 'no', + index = null, + title = null, + language = null, + no = true, + auto = false, + native = null; + + const AudioTrack.auto() + : id = 'auto', + index = null, + title = null, + language = null, + no = false, + auto = true, + native = null; + + @override + bool operator ==(Object other) { + return other is AudioTrack && + other.id == id && + other.index == index && + other.title == title && + other.language == language && + other.no == no && + other.auto == auto; + } + + @override + int get hashCode => Object.hash(id, index, title, language, no, auto); +} + +@immutable +class SubtitleTrack { + final String id; + final int? index; + final String? title; + final String? language; + final bool no; + final Object? native; + + const SubtitleTrack({ + required this.id, + this.index, + this.title, + this.language, + this.no = false, + this.native, + }); + + const SubtitleTrack.no() + : id = 'no', + index = null, + title = null, + language = null, + no = true, + native = null; + + @override + bool operator ==(Object other) { + return other is SubtitleTrack && + other.id == id && + other.index == index && + other.title == title && + other.language == language && + other.no == no; + } + + @override + int get hashCode => Object.hash(id, index, title, language, no); +} diff --git a/lib/core/infra/player_engine/seek_guard.dart b/lib/core/infra/player_engine/seek_guard.dart new file mode 100644 index 0000000..9b14489 --- /dev/null +++ b/lib/core/infra/player_engine/seek_guard.dart @@ -0,0 +1,36 @@ +import '../../../shared/utils/app_logger.dart'; + +const _tag = 'SeekGuard'; + + +class SeekGuard { + SeekGuard({this.tolerance = const Duration(seconds: 2)}); + + final Duration tolerance; + + Duration? _target; + + void arm(Duration target) { + _target = target > Duration.zero ? target : null; + } + + void disarm() { + _target = null; + } + + bool shouldSuppress(Duration reported) { + final guard = _target; + if (guard == null) return false; + + if (reported < guard - tolerance) { + AppLogger.debug( + _tag, + 'Suppressing reported position $reported below target $guard', + ); + return true; + } + + disarm(); + return false; + } +} diff --git a/lib/core/infra/player_engine/texture_video_view.dart b/lib/core/infra/player_engine/texture_video_view.dart new file mode 100644 index 0000000..a610483 --- /dev/null +++ b/lib/core/infra/player_engine/texture_video_view.dart @@ -0,0 +1,63 @@ +import 'dart:ui' as ui; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + + +class TextureVideoView extends StatelessWidget { + const TextureVideoView({ + super.key, + required this.textureId, + required this.textureVersion, + required this.videoSize, + required this.fit, + }); + + final ValueListenable textureId; + final ValueListenable textureVersion; + final ValueListenable videoSize; + final ValueListenable fit; + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: textureId, + builder: (context, id, _) { + if (id == null || id < 0) { + return const SizedBox.expand(); + } + return ValueListenableBuilder( + valueListenable: textureVersion, + builder: (context, version, _) { + final texture = Texture( + key: ValueKey(Object.hash(id, version)), + textureId: id, + ); + return ValueListenableBuilder( + valueListenable: videoSize, + builder: (context, size, _) { + if (size == null || size.width <= 0 || size.height <= 0) { + return texture; + } + return ValueListenableBuilder( + valueListenable: fit, + builder: (context, fitValue, _) => SizedBox.expand( + child: FittedBox( + fit: fitValue, + clipBehavior: Clip.hardEdge, + child: SizedBox( + width: size.width, + height: size.height, + child: texture, + ), + ), + ), + ); + }, + ); + }, + ); + }, + ); + } +} diff --git a/lib/core/infra/script_widget/quickjs_widget_runtime.dart b/lib/core/infra/script_widget/quickjs_widget_runtime.dart new file mode 100644 index 0000000..6e7c165 --- /dev/null +++ b/lib/core/infra/script_widget/quickjs_widget_runtime.dart @@ -0,0 +1,521 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:fjs/fjs.dart'; +import 'package:flutter/foundation.dart' show visibleForTesting; +import 'package:flutter/services.dart' show rootBundle; + +import '../../../shared/utils/app_logger.dart'; +import '../../contracts/error.dart'; +import '../../contracts/script_widget.dart'; +import '../../domain/errors.dart'; +import '../../domain/ports/script_widget_runtime.dart'; +import 'widget_host_bridge.dart'; + +const String _tag = 'QuickJsWidgetRuntime'; +const Duration _defaultInvocationTimeout = Duration(seconds: 30); +const int _maximumStackBytes = 1024 * 1024; +const int _maximumHeapBytes = 64 * 1024 * 1024; + + +const JsEvalOptions _scriptEvalOptions = JsEvalOptions.raw( + global: true, + strict: false, + backtraceBarrier: false, + promise: true, +); + + +class QuickJsWidgetRuntime implements ScriptWidgetRuntime { + final WidgetHostBridge _hostBridge; + final Duration invocationTimeout; + final Map _contexts = + {}; + + QuickJsWidgetRuntime({ + WidgetHostBridge? hostBridge, + this.invocationTimeout = _defaultInvocationTimeout, + }) : _hostBridge = hostBridge ?? WidgetHostBridge(), + assert(invocationTimeout > Duration.zero); + + @override + Future inspectWidget(String scriptSource) async { + if (scriptSource.trim().isEmpty) { + throw DomainError(ErrorCode.invalidParams, '模块脚本不能为空'); + } + + return _readManifest(scriptSource); + } + + @override + Future loadWidget(String scriptSource) async { + final manifest = await inspectWidget(scriptSource); + + final bootstrapData = await _hostBridge.loadBootstrapData(manifest.id); + final engine = await _createEngine(); + try { + await engine.init( + bridge: (JsValue message) => _dispatchHostMessage(manifest.id, message), + ); + await _installHostBootstrap(engine, bootstrapData); + await _evaluateOrThrow(engine, scriptSource); + final newContext = _LoadedWidgetContext(engine: engine); + final existingContext = _contexts[manifest.id]; + _contexts[manifest.id] = newContext; + existingContext?.dispose(); + return manifest; + } catch (_) { + unawaited(engine.close().catchError((_) {})); + rethrow; + } + } + + @override + bool isWidgetLoaded(String widgetId) => _contexts.containsKey(widgetId); + + Future _readManifest(String scriptSource) async { + final engine = await _createEngine(); + try { + await engine.initWithoutBridge(); + await _evaluateOrThrow( + engine, + + + 'var Widget = {}; ' + 'var module = { exports: {} }; ' + 'var exports = module.exports;', + ); + await _evaluateOrThrow(engine, scriptSource); + final metadataResult = await _evaluateOrThrow( + engine, + "typeof WidgetMetadata === 'object' && WidgetMetadata !== null " + '? JSON.stringify(WidgetMetadata) : null', + ); + final rawMetadata = metadataResult.value; + final decodedMetadata = rawMetadata is String + ? jsonDecode(rawMetadata) + : null; + if (decodedMetadata is! Map) { + throw DomainError(ErrorCode.invalidParams, '脚本缺少有效的 WidgetMetadata 对象'); + } + final manifest = ScriptWidgetManifest.fromJson( + Map.from(decodedMetadata), + ); + if (manifest.id.trim().isEmpty || manifest.title.trim().isEmpty) { + throw DomainError( + ErrorCode.invalidParams, + 'WidgetMetadata.id 和 title 不能为空', + ); + } + return manifest; + } on DomainError { + rethrow; + } catch (error) { + throw DomainError(ErrorCode.invalidParams, '无法解析模块元数据', details: error); + } finally { + unawaited(engine.close().catchError((_) {})); + } + } + + @override + Future invokeModuleFunction( + String widgetId, + String functionName, + Map params, + ) async { + final context = _contexts[widgetId]; + if (context == null) { + throw DomainError(ErrorCode.storeNotFound, '模块尚未加载:$widgetId'); + } + if (functionName.trim().isEmpty) { + throw DomainError(ErrorCode.invalidParams, '模块函数名不能为空'); + } + + + _drainBackgroundErrors(context.engine, widgetId); + + final encodedFunctionName = jsonEncode(functionName); + final rawArgument = params['__forwardRawArgument']; + final encodedArgument = rawArgument == null + ? jsonEncode(params) + : jsonEncode(rawArgument); + final JsValue invocationResult; + try { + invocationResult = await context.engine + .eval( + source: JsCode.code(''' +await (async function() { + var functionName = $encodedFunctionName; + var moduleFunction = globalThis[functionName]; + if (typeof moduleFunction !== 'function') { + throw new Error('Module function not found: ' + functionName); + } + return await moduleFunction($encodedArgument); +})(); +'''), + options: _scriptEvalOptions, + ) + .timeout(invocationTimeout); + } on TimeoutException catch (error) { + await unloadWidget(widgetId); + throw _timeoutError(widgetId, functionName, error); + } on DomainError { + rethrow; + } catch (error) { + await unloadWidget(widgetId); + throw _executionError(widgetId, functionName, error.toString()); + } + return normalizeQuickJsValue(invocationResult.value); + } + + DomainError _timeoutError( + String widgetId, + String functionName, + Object error, + ) { + return DomainError( + ErrorCode.serverTimeout, + '模块执行超过 ${invocationTimeout.inSeconds} 秒,已终止运行环境', + retryable: true, + details: { + 'widgetId': widgetId, + 'functionName': functionName, + 'error': error.toString(), + }, + ); + } + + @override + Future unloadWidget(String widgetId) async { + _contexts.remove(widgetId)?.dispose(); + _hostBridge.clearWidget(widgetId); + } + + @override + Future dispose() async { + final contexts = _contexts.values.toList(growable: false); + _contexts.clear(); + for (final context in contexts) { + context.dispose(); + } + _hostBridge.clear(); + } + + Future _dispatchHostMessage(String widgetId, JsValue message) async { + try { + final decoded = message.value; + final request = decoded is Map + ? Map.from(decoded) + : const {}; + final channel = request['channel']?.toString() ?? ''; + final rawPayload = request['payload']; + final result = await _hostBridge.handleMessage( + widgetId, + channel, + rawPayload is Map + ? Map.from(rawPayload) + : {}, + ); + return JsResult.ok(JsValue.from(result)); + } catch (error) { + return JsResult.err(JsError.bridge(error.toString())); + } + } + + void _drainBackgroundErrors(JsEngine engine, String widgetId) { + for (final backgroundError in engine.drainUnhandledJobErrors()) { + AppLogger.warn(_tag, '[$widgetId] 脚本后台异步错误:$backgroundError'); + } + } +} + +Future? _libFjsInit; + +Future _ensureLibFjsReady() { + return _libFjsInit ??= LibFjs.init().then( + (_) {}, + onError: (Object error) { + if (error is StateError) { + + + return; + } + _libFjsInit = null; + throw DomainError( + ErrorCode.internalError, + '脚本引擎初始化失败', + details: error.toString(), + ); + }, + ); +} + +Future _createEngine() async { + await _ensureLibFjsReady(); + return JsEngine.create( + + + builtins: const JsBuiltinOptions(timers: true, url: true), + runtimeOptions: JsEngineRuntimeOptions( + memoryLimit: BigInt.from(_maximumHeapBytes), + maxStackSize: BigInt.from(_maximumStackBytes), + ), + ); +} + +class _LoadedWidgetContext { + final JsEngine engine; + + const _LoadedWidgetContext({required this.engine}); + + void dispose() { + + + unawaited(engine.close().catchError((_) {})); + } +} + +Future _evaluateOrThrow(JsEngine engine, String source) async { + try { + return await engine.eval( + source: JsCode.code(source), + options: _scriptEvalOptions, + ); + } on DomainError { + rethrow; + } catch (error) { + throw DomainError( + ErrorCode.invalidParams, + '模块脚本执行失败', + details: error.toString(), + ); + } +} + + +@visibleForTesting +Object? normalizeQuickJsValue(Object? value) { + if (value is Map) { + return { + for (final entry in value.entries) + entry.key?.toString() ?? '': normalizeQuickJsValue(entry.value), + }; + } + if (value is List) { + return value.map(normalizeQuickJsValue).toList(growable: false); + } + return value; +} + +DomainError _executionError( + String widgetId, + String functionName, + String details, +) { + return DomainError( + ErrorCode.internalError, + '模块函数执行失败:$functionName', + details: { + 'widgetId': widgetId, + 'functionName': functionName, + 'error': details, + }, + ); +} + +Future? _cheerioBundle; + +Future _cheerioBundleSource() => + _cheerioBundle ??= rootBundle.loadString('assets/js/cheerio.min.js'); + + +Future _installHostBootstrap( + JsEngine engine, + WidgetHostBootstrapData bootstrapData, +) async { + final encodedBootData = jsonEncode({ + 'storage': bootstrapData.storage, + 'sharedCache': bootstrapData.sharedCache, + }); + await _evaluateOrThrow( + engine, + 'globalThis.__widgetHostBootData = $encodedBootData;', + ); + await _evaluateOrThrow(engine, await _cheerioBundleSource()); + await _evaluateOrThrow(engine, _kHostBootstrapJs); +} + +const String _kHostBootstrapJs = r''' +(function() { + var bootData = globalThis.__widgetHostBootData || {}; + var widgetStorageData = bootData.storage || {}; + var sharedCacheData = bootData.sharedCache || {}; + delete globalThis.__widgetHostBootData; + + if (typeof globalThis.module === 'undefined') { + globalThis.module = { exports: {} }; + } + if (typeof globalThis.exports === 'undefined') { + globalThis.exports = globalThis.module.exports; + } + if (typeof globalThis.global === 'undefined') { + globalThis.global = globalThis; + } + if (typeof globalThis.self === 'undefined') { + globalThis.self = globalThis; + } + + function hostCall(channel, payload) { + return fjs.bridge_call({ channel: channel, payload: payload }).catch( + function(rejection) { + var message = (rejection instanceof Error) + ? rejection.message + : String(rejection != null ? rejection : 'unknown host error'); + throw new Error(message); + } + ); + } + + function hostNotify(channel, payload) { + try { + hostCall(channel, payload).catch(function() {}); + } catch (error) {} + } + + function persistStorage() { + hostNotify('WidgetStoragePersist', { storage: widgetStorageData }); + } + + function persistSharedCache() { + hostNotify('WidgetSharedCachePersist', { sharedCache: sharedCacheData }); + } + + var cheerioModule = globalThis.__fjsCheerio; + + function loadHtml(html) { + return cheerioModule.load(String(html == null ? '' : html)); + } + + var domEntries = {}; + var domNextHandle = 1; + function domRegister(doc, node) { + var handle = domNextHandle++; + domEntries[handle] = { doc: doc, node: node }; + return handle; + } + + function normalizeConsoleValue(value) { + if (value instanceof Error) { + return { + __consoleErrorMarker: true, + name: value.name || 'Error', + message: value.message || String(value), + stack: value.stack || '' + }; + } + return value; + } + function forwardConsole(level) { + return function() { + var values = []; + for (var i = 0; i < arguments.length; i++) { + values.push(normalizeConsoleValue(arguments[i])); + } + hostNotify('WidgetConsole', { level: level, values: values }); + }; + } + globalThis.console = { + log: forwardConsole('log'), + info: forwardConsole('info'), + debug: forwardConsole('debug'), + warn: forwardConsole('warn'), + error: forwardConsole('error'), + trace: forwardConsole('debug') + }; + + globalThis.Widget = { + http: { + get: function(url, options) { + return hostCall('WidgetHttp', { method: 'GET', url: url, options: options || {} }); + }, + post: function(url, body, options) { + return hostCall('WidgetHttp', { method: 'POST', url: url, body: body, options: options || {} }); + }, + put: function(url, body, options) { + return hostCall('WidgetHttp', { method: 'PUT', url: url, body: body, options: options || {} }); + }, + delete: function(url, options) { + return hostCall('WidgetHttp', { method: 'DELETE', url: url, options: options || {} }); + } + }, + storage: { + get: function(key) { return widgetStorageData[key]; }, + set: function(key, value) { widgetStorageData[key] = value; persistStorage(); }, + remove: function(key) { delete widgetStorageData[key]; persistStorage(); }, + getItem: function(key) { return widgetStorageData[key]; }, + setItem: function(key, value) { widgetStorageData[key] = value; persistStorage(); }, + removeItem: function(key) { delete widgetStorageData[key]; persistStorage(); } + }, + sharedCache: { + get: function(namespace, key) { + var namespaceData = sharedCacheData[namespace] || {}; + return namespaceData[key]; + }, + set: function(namespace, key, value) { + if (!sharedCacheData[namespace]) sharedCacheData[namespace] = {}; + sharedCacheData[namespace][key] = value; + persistSharedCache(); + }, + remove: function(namespace, key) { + if (sharedCacheData[namespace]) delete sharedCacheData[namespace][key]; + persistSharedCache(); + } + }, + html: { load: loadHtml }, + dom: { + parse: function(html) { + try { + return domRegister(loadHtml(html), null); + } catch (error) { + return null; + } + }, + select: function(handle, selector) { + var entry = domEntries[handle]; + if (!entry || !selector) return []; + try { + var matched = entry.node == null + ? entry.doc(selector) + : entry.doc(entry.node).find(selector); + return matched.toArray().map(function(node) { + return domRegister(entry.doc, node); + }); + } catch (error) { + return []; + } + }, + text: function(handle) { + var entry = domEntries[handle]; + if (!entry) return ''; + return entry.node == null + ? entry.doc('body').text() + : entry.doc(entry.node).text(); + }, + html: function(handle) { + var entry = domEntries[handle]; + if (!entry) return ''; + var value = entry.node == null + ? entry.doc.html() + : entry.doc(entry.node).html(); + return value == null ? '' : value; + }, + attr: function(handle, name) { + var entry = domEntries[handle]; + if (!entry || entry.node == null) return null; + var value = entry.doc(entry.node).attr(String(name)); + return value == null ? null : value; + } + } + }; +})(); +'''; diff --git a/lib/core/infra/script_widget/safe_script_http_client.dart b/lib/core/infra/script_widget/safe_script_http_client.dart new file mode 100644 index 0000000..f5eb10f --- /dev/null +++ b/lib/core/infra/script_widget/safe_script_http_client.dart @@ -0,0 +1,390 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter/foundation.dart' show visibleForTesting; + +const int scriptHttpMaximumResponseBytes = 10 * 1024 * 1024; +const int _maximumRedirects = 5; +const Duration _connectTimeout = Duration(seconds: 15); +const Duration _receiveTimeout = Duration(seconds: 30); + +class SafeScriptHttpResponse { + final Object? data; + final String text; + final int statusCode; + final String reasonPhrase; + final Map headers; + + const SafeScriptHttpResponse({ + required this.data, + required this.text, + required this.statusCode, + required this.reasonPhrase, + required this.headers, + }); +} + +class SafeScriptHttpException implements Exception { + final String message; + + const SafeScriptHttpException(this.message); + + @override + String toString() => message; +} + +class SafeScriptHttpClient { + Future request({ + required Uri uri, + String method = 'GET', + Map headers = const {}, + Map queryParameters = const {}, + Object? body, + bool followRedirects = true, + }) async { + var currentUri = _appendQueryParameters(uri, queryParameters); + var currentMethod = method.toUpperCase(); + if (!_allowedMethods.contains(currentMethod)) { + throw const SafeScriptHttpException('HTTP method is not permitted'); + } + var currentHeaders = _sanitizeRequestHeaders(headers); + var currentBody = body; + + for (var redirectCount = 0; ; redirectCount++) { + final addresses = await _resolvePublicAddresses(currentUri); + final httpClient = _createPinnedHttpClient(currentUri, addresses.first); + try { + final request = await httpClient.openUrl(currentMethod, currentUri); + request.followRedirects = false; + currentHeaders.forEach(request.headers.set); + _writeRequestBody(request, currentBody, currentHeaders); + + final response = await request.close().timeout(_receiveTimeout); + final location = response.headers.value(HttpHeaders.locationHeader); + final shouldRedirect = + followRedirects && + location != null && + response.isRedirect; + if (shouldRedirect) { + if (redirectCount >= _maximumRedirects) { + throw const SafeScriptHttpException('HTTP redirect limit exceeded'); + } + final redirectedUri = currentUri.resolve(location); + final changesOrigin = !_hasSameOrigin(currentUri, redirectedUri); + if (changesOrigin) { + currentHeaders = _removeSensitiveHeaders(currentHeaders); + } + if (_redirectsAsGet(response.statusCode, currentMethod)) { + currentMethod = 'GET'; + currentBody = null; + currentHeaders = _removeEntityHeaders(currentHeaders); + } + currentUri = redirectedUri; + continue; + } + + final responseBytes = await _readLimitedResponse(response); + final responseText = _decodeResponseText(responseBytes, response); + return SafeScriptHttpResponse( + data: _decodeResponseData(responseText, response), + text: responseText, + statusCode: response.statusCode, + reasonPhrase: response.reasonPhrase, + headers: _responseHeaders(response), + ); + } finally { + httpClient.close(force: true); + } + } + } +} + +HttpClient _createPinnedHttpClient(Uri uri, InternetAddress address) { + final httpClient = HttpClient() + ..connectionTimeout = _connectTimeout + ..idleTimeout = _receiveTimeout + ..findProxy = (_) => 'DIRECT'; + httpClient.connectionFactory = + (Uri connectionUri, String? proxyHost, int? proxyPort) async { + if (proxyHost != null || proxyPort != null) { + throw const SafeScriptHttpException('HTTP proxies are not permitted'); + } + if (!_hasSameOrigin(uri, connectionUri)) { + throw const SafeScriptHttpException('Unexpected connection target'); + } + final socketTask = await Socket.startConnect( + address, + connectionUri.port, + ); + if (connectionUri.scheme != 'https') return socketTask; + + final Future secureSocket = socketTask.socket.then( + (socket) => SecureSocket.secure(socket, host: connectionUri.host), + ); + return ConnectionTask.fromSocket( + secureSocket, + socketTask.cancel, + ); + }; + return httpClient; +} + +Future> _resolvePublicAddresses(Uri uri) async { + if (!isValidScriptHttpUri(uri)) { + throw const SafeScriptHttpException( + 'Only valid HTTP(S) URLs are permitted', + ); + } + + final literalAddress = InternetAddress.tryParse(uri.host); + final resolvedAddresses = literalAddress == null + ? await InternetAddress.lookup(uri.host).timeout(_connectTimeout) + : [literalAddress]; + return filterPublicScriptAddresses(host: uri.host, addresses: resolvedAddresses); +} + + +@visibleForTesting +List filterPublicScriptAddresses({ + required String host, + required List addresses, +}) { + if (addresses.isEmpty) { + throw SafeScriptHttpException('Could not resolve host: $host'); + } + final publicAddresses = addresses + .where(isPublicScriptAddress) + .toList(growable: false); + if (publicAddresses.isEmpty) { + final rejectedAddresses = addresses + .map((address) => address.address) + .join(', '); + throw SafeScriptHttpException( + 'Private or special-purpose network addresses are not permitted ' + '(host=$host, addresses=$rejectedAddresses)', + ); + } + return publicAddresses; +} + +bool isPublicScriptAddress(InternetAddress address) { + final bytes = address.rawAddress; + if (address.type == InternetAddressType.IPv4) { + return _isPublicIpv4(bytes); + } + if (address.type != InternetAddressType.IPv6 || bytes.length != 16) { + return false; + } + + + final isIpv4Mapped = + bytes.take(10).every((byte) => byte == 0) && + bytes[10] == 0xff && + bytes[11] == 0xff; + if (isIpv4Mapped) return _isPublicIpv4(bytes.sublist(12)); + + + final isUnspecifiedOrLoopback = bytes + .take(15) + .every((byte) => byte == 0) && (bytes[15] == 0 || bytes[15] == 1); + if (isUnspecifiedOrLoopback) return false; + + + if ((bytes[0] & 0xfe) == 0xfc) return false; + + + if (bytes[0] == 0xfe && (bytes[1] & 0xc0) == 0x80) return false; + + + final isGlobalUnicast = (bytes[0] & 0xe0) == 0x20; + if (!isGlobalUnicast) return false; + + + return true; +} + +bool _isPublicIpv4(List bytes) { + if (bytes.length != 4) return false; + final first = bytes[0]; + final second = bytes[1]; + final third = bytes[2]; + + + if (first == 0 || first == 10 || first == 127) return false; + if (first == 169 && second == 254) return false; + if (first == 172 && second >= 16 && second <= 31) return false; + if (first == 192 && second == 0 && third == 0) return false; + if (first == 192 && second == 168) return false; + if (first >= 224) return false; + + + return true; +} + +Future _readLimitedResponse(HttpClientResponse response) async { + return collectScriptResponseBytes( + response.timeout(_receiveTimeout), + declaredLength: response.contentLength, + ); +} + +Future collectScriptResponseBytes( + Stream> responseStream, { + int declaredLength = -1, + int maximumBytes = scriptHttpMaximumResponseBytes, +}) async { + if (declaredLength > maximumBytes) { + throw const SafeScriptHttpException('HTTP response exceeds 10 MB'); + } + + final builder = BytesBuilder(copy: false); + var receivedBytes = 0; + await for (final chunk in responseStream) { + receivedBytes += chunk.length; + if (receivedBytes > maximumBytes) { + throw const SafeScriptHttpException('HTTP response exceeds 10 MB'); + } + builder.add(chunk); + } + return builder.takeBytes(); +} + +void _writeRequestBody( + HttpClientRequest request, + Object? body, + Map headers, +) { + if (body == null) return; + if (body is List) { + request.add(body); + return; + } + if (body is String) { + request.write(body); + return; + } + final hasContentType = headers.keys.any( + (name) => name.toLowerCase() == HttpHeaders.contentTypeHeader, + ); + if (!hasContentType) { + request.headers.contentType = ContentType.json; + } + request.write(jsonEncode(body)); +} + +String _decodeResponseText( + Uint8List responseBytes, + HttpClientResponse response, +) { + final charset = response.headers.contentType?.charset?.toLowerCase(); + if (charset == 'iso-8859-1' || charset == 'latin1') { + return latin1.decode(responseBytes); + } + return utf8.decode(responseBytes, allowMalformed: true); +} + +Object? _decodeResponseData(String responseText, HttpClientResponse response) { + if (responseText.isEmpty) return responseText; + final mimeType = response.headers.contentType?.mimeType.toLowerCase() ?? ''; + final declaredAsJson = + mimeType == 'application/json' || mimeType.endsWith('+json'); + final looksLikeJson = + responseText.startsWith('{') || responseText.startsWith('['); + if (!declaredAsJson && !looksLikeJson) return responseText; + try { + return jsonDecode(responseText); + } catch (_) { + return responseText; + } +} + +Map _responseHeaders(HttpClientResponse response) { + final headers = {}; + response.headers.forEach((name, values) { + headers[name] = values.join(', '); + }); + return headers; +} + +Map _sanitizeRequestHeaders(Map headers) { + return _removeHeaders(headers, const { + 'connection', + 'content-length', + 'host', + 'proxy-authorization', + 'proxy-connection', + 'transfer-encoding', + 'upgrade', + }); +} + +Map _removeSensitiveHeaders(Map headers) { + return _removeHeaders(headers, const {'authorization', 'cookie'}); +} + +Map _removeEntityHeaders(Map headers) { + return _removeHeaders(headers, const { + 'content-encoding', + 'content-language', + 'content-type', + }); +} + +Map _removeHeaders( + Map headers, + Set excludedNames, +) { + return { + for (final entry in headers.entries) + if (!excludedNames.contains(entry.key.toLowerCase())) + entry.key: entry.value, + }; +} + +Uri _appendQueryParameters(Uri uri, Map queryParameters) { + if (queryParameters.isEmpty) return uri; + return uri.replace( + queryParameters: { + ...uri.queryParametersAll, + for (final entry in queryParameters.entries) + if (entry.value != null) + entry.key: entry.value is Iterable + ? (entry.value! as Iterable) + .map((value) => value?.toString() ?? '') + .toList(growable: false) + : entry.value.toString(), + }, + ); +} + +bool isValidScriptHttpUri(Uri uri) { + return (uri.scheme == 'http' || uri.scheme == 'https') && + uri.hasAuthority && + uri.host.isNotEmpty && + uri.userInfo.isEmpty; +} + +const Set _allowedMethods = { + 'GET', + 'POST', + 'PUT', + 'PATCH', + 'DELETE', + 'HEAD', + 'OPTIONS', +}; + +bool _redirectsAsGet(int statusCode, String method) { + return statusCode == HttpStatus.seeOther || + ((statusCode == HttpStatus.movedPermanently || + statusCode == HttpStatus.found) && + method == 'POST'); +} + +bool _hasSameOrigin(Uri left, Uri right) { + return left.scheme == right.scheme && + left.host.toLowerCase() == right.host.toLowerCase() && + left.port == right.port; +} diff --git a/lib/core/infra/script_widget/script_widget_remote_client.dart b/lib/core/infra/script_widget/script_widget_remote_client.dart new file mode 100644 index 0000000..bd938b5 --- /dev/null +++ b/lib/core/infra/script_widget/script_widget_remote_client.dart @@ -0,0 +1,35 @@ +import 'dart:async'; + +import '../../contracts/error.dart'; +import '../../domain/errors.dart'; +import '../../domain/ports/script_widget_remote_gateway.dart'; +import 'safe_script_http_client.dart'; + +class ScriptWidgetRemoteClient implements ScriptWidgetRemoteGateway { + final SafeScriptHttpClient _httpClient; + + ScriptWidgetRemoteClient({SafeScriptHttpClient? httpClient}) + : _httpClient = httpClient ?? SafeScriptHttpClient(); + + @override + Future fetchText(String url) async { + final uri = Uri.tryParse(url.trim()); + if (uri == null) { + throw DomainError(ErrorCode.invalidParams, '模块地址必须是有效的 HTTP(S) URL'); + } + try { + final response = await _httpClient.request(uri: uri); + return response.text; + } on DomainError { + rethrow; + } catch (error) { + final isTimeout = error is TimeoutException; + throw DomainError( + isTimeout ? ErrorCode.serverTimeout : ErrorCode.serverUnreachable, + isTimeout ? '下载模块超时' : '无法下载模块', + retryable: true, + details: {'url': url, 'error': error.toString()}, + ); + } + } +} diff --git a/lib/core/infra/script_widget/widget_host_bridge.dart b/lib/core/infra/script_widget/widget_host_bridge.dart new file mode 100644 index 0000000..dc00636 --- /dev/null +++ b/lib/core/infra/script_widget/widget_host_bridge.dart @@ -0,0 +1,197 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart' show visibleForTesting; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../../../shared/utils/async_lock.dart'; +import '../../../shared/utils/app_logger.dart'; +import 'safe_script_http_client.dart'; + +typedef WidgetHostBootstrapData = ({ + Map storage, + Map sharedCache, +}); + +const String _browserUserAgent = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) ' + 'AppleWebKit/537.36 (KHTML, like Gecko) ' + 'Chrome/131.0.0.0 Safari/537.36'; + +class WidgetHostBridge { + static const String _tag = 'ScriptWidgetHost'; + static const String _storagePrefix = 'smplayer.script_widget.storage.'; + static const String _sharedCachePrefix = + 'smplayer.script_widget.shared_cache.'; + + final SafeScriptHttpClient _httpClient; + final Map _persistenceLocks = {}; + + WidgetHostBridge({SafeScriptHttpClient? httpClient}) + : _httpClient = httpClient ?? SafeScriptHttpClient(); + + Future loadBootstrapData(String widgetId) async { + final preferences = await SharedPreferences.getInstance(); + return ( + storage: _decodeObjectMap( + preferences.getString('$_storagePrefix$widgetId'), + ), + sharedCache: _decodeObjectMap( + preferences.getString('$_sharedCachePrefix$widgetId'), + ), + ); + } + + + Future handleMessage( + String widgetId, + String channel, + Map payload, + ) async { + switch (channel) { + case 'WidgetHttp': + return _handleHttpRequest(payload); + case 'WidgetStoragePersist': + return _persistJson( + '$_storagePrefix$widgetId', + _asMap(payload['storage']), + ); + case 'WidgetSharedCachePersist': + return _persistJson( + '$_sharedCachePrefix$widgetId', + _asMap(payload['sharedCache']), + ); + case 'WidgetConsole': + final level = payload['level']?.toString() ?? 'log'; + final rawValues = payload['values']; + final message = rawValues is List + ? rawValues.map(formatConsoleValue).join(' ') + : formatConsoleValue(rawValues); + switch (level) { + case 'error': + AppLogger.error(_tag, '[$widgetId] $message', message); + case 'warn': + AppLogger.warn(_tag, '[$widgetId] $message'); + case 'info': + AppLogger.info(_tag, '[$widgetId] $message'); + default: + AppLogger.debug(_tag, '[$widgetId] $message'); + } + return true; + default: + throw ArgumentError('未知的宿主通道:$channel'); + } + } + + Future> _handleHttpRequest( + Map request, + ) async { + final method = request['method']?.toString().toUpperCase() ?? 'GET'; + final url = request['url']?.toString() ?? ''; + final uri = Uri.tryParse(url); + if (uri == null) { + throw ArgumentError('Widget.http only permits HTTP(S) URLs'); + } + final optionsMap = _asMap(request['options']); + final scriptHeaders = { + for (final entry in _asMap(optionsMap['headers']).entries) + entry.key: entry.value.toString(), + }; + final hasUserAgent = scriptHeaders.keys.any( + (headerName) => headerName.toLowerCase() == 'user-agent', + ); + final headers = { + if (!hasUserAgent) 'User-Agent': _browserUserAgent, + ...scriptHeaders, + }; + final queryParameters = { + for (final entry in _asMap(optionsMap['params']).entries) + entry.key: entry.value, + }; + final response = await _httpClient.request( + uri: uri, + method: method, + headers: headers, + queryParameters: queryParameters, + body: request['body'], + followRedirects: optionsMap['allow_redirects'] != false, + ); + return { + 'data': response.data, + 'status': response.statusCode, + + + 'statusCode': response.statusCode, + 'statusText': response.reasonPhrase, + 'headers': response.headers, + }; + } + + Future _persistJson( + String preferenceKey, + Map value, + ) => _persistenceLocks.putIfAbsent(preferenceKey, AsyncLock.new).run(() async { + final preferences = await SharedPreferences.getInstance(); + return preferences.setString(preferenceKey, jsonEncode(value)); + }); + + void clearWidget(String widgetId) { + _persistenceLocks.remove('$_storagePrefix$widgetId'); + _persistenceLocks.remove('$_sharedCachePrefix$widgetId'); + } + + void clear() => _persistenceLocks.clear(); +} + +Map _asMap(Object? value) { + if (value is Map) return value; + if (value is Map) return Map.from(value); + if (value is String) { + try { + final decodedValue = jsonDecode(value); + if (decodedValue is Map) { + return Map.from(decodedValue); + } + } catch (_) {} + } + return {}; +} + +Map _decodeObjectMap(String? rawJson) { + if (rawJson == null || rawJson.trim().isEmpty) return {}; + try { + return _asMap(jsonDecode(rawJson)); + } catch (_) { + return {}; + } +} + +@visibleForTesting +String formatConsoleValue(Object? value) { + if (value == null) return 'null'; + if (value is String) return value; + if (value is num || value is bool) return value.toString(); + if (value is Map) { + if (value['__consoleErrorMarker'] == true) { + final errorName = value['name']?.toString() ?? 'Error'; + final errorMessage = value['message']?.toString() ?? ''; + final errorStack = value['stack']?.toString() ?? ''; + final formatted = errorMessage.isEmpty + ? errorName + : '$errorName: $errorMessage'; + return errorStack.isEmpty ? formatted : '$formatted\n$errorStack'; + } + try { + return jsonEncode(value); + } catch (_) { + return value.toString(); + } + } + if (value is Iterable) { + try { + return jsonEncode(value.toList(growable: false)); + } catch (_) { + return value.toString(); + } + } + return value.toString(); +} diff --git a/lib/core/infra/server_sync_api/server_sync_client.dart b/lib/core/infra/server_sync_api/server_sync_client.dart new file mode 100644 index 0000000..df95ef4 --- /dev/null +++ b/lib/core/infra/server_sync_api/server_sync_client.dart @@ -0,0 +1,119 @@ +import 'package:dio/dio.dart'; + +import '../../domain/errors.dart'; +import '../../contracts/error.dart'; +import '../../domain/ports/server_sync_gateway.dart'; +import '../../../shared/utils/app_logger.dart'; +import '../../../shared/utils/url_utils.dart'; + + +class ServerSyncClient implements ServerSyncGateway { + static const _tag = 'ServerSync'; + + final Dio _dio; + + ServerSyncClient({Dio? dio}) + : _dio = + dio ?? + Dio( + BaseOptions( + connectTimeout: const Duration(seconds: 8), + receiveTimeout: const Duration(seconds: 15), + sendTimeout: const Duration(seconds: 15), + headers: {'Accept': 'application/json'}, + validateStatus: (status) => status != null && status < 500, + ), + ); + + @override + Future pull({ + required String baseUrl, + required String accessToken, + }) async { + final url = '${stripTrailingSlash(baseUrl.trim())}/api/user/server-sync'; + try { + final res = await _dio.get>( + url, + options: Options(headers: {'Authorization': 'Bearer $accessToken'}), + ); + final status = res.statusCode ?? 0; + if (!_isSuccess(status)) { + throw _httpError(status, res.data); + } + final body = res.data ?? const {}; + return ServerSyncSnapshot(blob: body['blob'] as String?); + } on DioException catch (e) { + throw _networkError(e); + } + } + + @override + Future push({ + required String baseUrl, + required String accessToken, + required String blob, + }) async { + final url = '${stripTrailingSlash(baseUrl.trim())}/api/user/server-sync'; + try { + final res = await _dio.put>( + url, + data: {'blob': blob}, + options: Options(headers: {'Authorization': 'Bearer $accessToken'}), + ); + final status = res.statusCode ?? 0; + if (!_isSuccess(status)) { + throw _httpError(status, res.data); + } + } on DioException catch (e) { + throw _networkError(e); + } + } + + DomainError _httpError(int status, Map? body) { + final message = (body?['message'] as String?)?.trim(); + AppLogger.warn(_tag, 'HTTP $status ${message ?? ''}'); + return switch (status) { + 401 || 403 => DomainError( + ErrorCode.authForbidden, + message ?? '账号登录已过期,请重新登录', + details: {'status': status}, + ), + 413 => DomainError( + ErrorCode.invalidParams, + message ?? '同步数据过大(上限 256KB)', + details: {'status': status}, + ), + 400 => DomainError( + ErrorCode.invalidParams, + message ?? '请求无效', + details: {'status': status}, + ), + _ => DomainError( + ErrorCode.serverHttpError, + message ?? '同步失败(HTTP $status)', + retryable: true, + details: {'status': status}, + ), + }; + } + + DomainError _networkError(DioException e) { + AppLogger.warn(_tag, 'network error', e); + return switch (e.type) { + DioExceptionType.connectionTimeout || + DioExceptionType.receiveTimeout || + DioExceptionType.sendTimeout => DomainError( + ErrorCode.serverTimeout, + '连接超时,请检查网络', + retryable: true, + ), + _ => DomainError( + ErrorCode.serverUnreachable, + '无法连接服务器,请检查网络', + retryable: true, + ), + }; + } + + static bool _isSuccess(int status) => status >= 200 && status < 300; +} diff --git a/lib/core/infra/sm_account_api/sm_account_client.dart b/lib/core/infra/sm_account_api/sm_account_client.dart new file mode 100644 index 0000000..3c7a2f8 --- /dev/null +++ b/lib/core/infra/sm_account_api/sm_account_client.dart @@ -0,0 +1,214 @@ +import 'package:dio/dio.dart'; + +import '../../../shared/utils/app_logger.dart'; +import '../../../shared/utils/url_utils.dart'; +import '../../domain/ports/sm_account_gateway.dart'; + + +class SmAccountClient implements SmAccountGateway { + static const _tag = 'SmAccount'; + + final Dio _dio; + + SmAccountClient({Dio? dio}) + : _dio = + dio ?? + Dio( + BaseOptions( + connectTimeout: const Duration(seconds: 8), + receiveTimeout: const Duration(seconds: 12), + headers: {'Accept': 'application/json'}, + validateStatus: (status) => status != null && status < 500, + ), + ); + + + Future _guard( + String op, + Future Function() body, { + required T Function(String message) failure, + required String fallbackMessage, + }) async { + try { + return await body(); + } on DioException catch (e) { + AppLogger.warn(_tag, '$op network error', e); + return failure(_networkHint(e)); + } catch (e) { + AppLogger.warn(_tag, '$op unexpected error', e); + return failure(fallbackMessage); + } + } + + @override + Future login({ + required String baseUrl, + required String email, + required String password, + }) => _guard('login', () async { + final url = '${stripTrailingSlash(baseUrl.trim())}/api/user/login'; + final res = await _dio.post>( + url, + data: {'email': email, 'password': password}, + ); + return _sessionResultFrom(res, fallbackEmail: email); + }, failure: SmLoginResult.failure, fallbackMessage: '登录失败,请稍后重试'); + + @override + Future registerStart({ + required String baseUrl, + required String email, + }) => _guard('registerStart', () async { + final url = '${stripTrailingSlash(baseUrl.trim())}/api/user/register/start'; + final res = await _dio.post>( + url, + data: {'email': email}, + ); + return _actionResultFrom(res, 'registerStart'); + }, failure: SmActionResult.failure, fallbackMessage: '发送验证码失败,请稍后重试'); + + @override + Future registerVerify({ + required String baseUrl, + required String email, + required String password, + required String code, + }) => _guard('registerVerify', () async { + final url = + '${stripTrailingSlash(baseUrl.trim())}/api/user/register/verify'; + final res = await _dio.post>( + url, + data: {'email': email, 'password': password, 'code': code}, + ); + return _sessionResultFrom(res, fallbackEmail: email); + }, failure: SmLoginResult.failure, fallbackMessage: '验证失败,请稍后重试'); + + @override + Future resendCode({ + required String baseUrl, + required String email, + }) => _guard('resendCode', () async { + final url = '${stripTrailingSlash(baseUrl.trim())}/api/user/resend-code'; + final res = await _dio.post>( + url, + data: {'email': email, 'purpose': 'register'}, + ); + return _actionResultFrom(res, 'resendCode'); + }, failure: SmActionResult.failure, fallbackMessage: '重发验证码失败,请稍后重试'); + + @override + Future refreshAccessToken({ + required String baseUrl, + required String refreshToken, + }) => _guard('refreshAccessToken', () async { + final url = '${stripTrailingSlash(baseUrl.trim())}/api/user/token/refresh'; + final res = await _dio.post>( + url, + data: {'refreshToken': refreshToken}, + ); + final status = res.statusCode ?? 0; + final body = res.data ?? const {}; + if (_isSuccess(status) && body['ok'] == true) { + return SmTokenRefreshResult( + ok: true, + accessToken: (body['accessToken'] as String?) ?? '', + refreshToken: (body['refreshToken'] as String?) ?? refreshToken, + expiresIn: (body['expiresIn'] as num?)?.toInt() ?? 0, + isVip: body['isVip'] == true, + vipExpiresAt: (body['vipExpiresAt'] as num?)?.toInt(), + vipPermanent: body['vipPermanent'] == true, + hasVipInfo: body.containsKey('isVip'), + ); + } + final message = (body['message'] as String?)?.trim(); + AppLogger.warn(_tag, 'refreshAccessToken ← HTTP $status ${message ?? ''}'); + return SmTokenRefreshResult.failure( + message ?? _statusHint(status), + invalidRefreshToken: status == 401 || status == 403, + ); + }, failure: SmTokenRefreshResult.failure, fallbackMessage: 'Token 刷新失败,请稍后重试'); + + + SmActionResult _actionResultFrom( + Response> res, + String op, + ) { + final body = res.data ?? const {}; + if (_isSuccess(res.statusCode) && body['ok'] == true) { + return SmActionResult.success(); + } + final message = (body['message'] as String?)?.trim(); + AppLogger.warn(_tag, '$op ← HTTP ${res.statusCode} ${message ?? ''}'); + return SmActionResult.failure(message ?? _statusHint(res.statusCode ?? 0)); + } + + + SmLoginResult _sessionResultFrom( + Response> res, { + required String fallbackEmail, + }) { + final status = res.statusCode ?? 0; + final body = res.data ?? const {}; + final ok = body['ok'] == true; + + if (_isSuccess(status) && ok) { + final refreshToken = (body['refreshToken'] as String?)?.trim() ?? ''; + if (refreshToken.isEmpty) { + AppLogger.warn(_tag, 'auth ok but missing refresh token'); + return SmLoginResult.failure('认证成功但未收到登录凭据,请稍后重试'); + } + return SmLoginResult( + ok: true, + email: (body['email'] as String?)?.trim() ?? fallbackEmail.trim(), + accessToken: (body['accessToken'] as String?) ?? '', + refreshToken: refreshToken, + expiresIn: (body['expiresIn'] as num?)?.toInt() ?? 0, + isVip: body['isVip'] == true, + vipExpiresAt: (body['vipExpiresAt'] as num?)?.toInt(), + vipPermanent: body['vipPermanent'] == true, + ); + } + + final message = (body['message'] as String?)?.trim(); + final needsVerification = body['needsVerification'] == true; + AppLogger.warn(_tag, 'auth ← HTTP $status ${message ?? ''}'); + return SmLoginResult.failure( + message ?? _statusHint(status), + needsVerification: needsVerification, + ); + } + + @override + Future logout({ + required String baseUrl, + required String refreshToken, + }) async { + final token = refreshToken.trim(); + if (token.isEmpty) return; + final url = '${stripTrailingSlash(baseUrl.trim())}/api/user/token/logout'; + try { + await _dio.post>(url, data: {'refreshToken': token}); + } catch (e) { + AppLogger.warn(_tag, 'logout failed (non-fatal)', e); + } + } + + + static bool _isSuccess(int? status) => + status != null && status >= 200 && status < 300; + + static String _statusHint(int status) => switch (status) { + 401 => '邮箱或密码错误', + 403 => '邮箱尚未验证', + 429 => '请求过于频繁,请稍后再试', + _ => '请求失败(HTTP $status)', + }; + + static String _networkHint(DioException e) => switch (e.type) { + DioExceptionType.connectionTimeout || + DioExceptionType.receiveTimeout || + DioExceptionType.sendTimeout => '连接超时,请检查网络或服务器地址', + DioExceptionType.connectionError => '无法连接服务器,请检查服务器地址', + _ => '网络异常,请稍后重试', + }; +} diff --git a/lib/core/infra/storage/device_id_store.dart b/lib/core/infra/storage/device_id_store.dart new file mode 100644 index 0000000..6815bce --- /dev/null +++ b/lib/core/infra/storage/device_id_store.dart @@ -0,0 +1,15 @@ +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:uuid/uuid.dart'; + +class DeviceIdStore { + static const _key = 'smplayer.device_id'; + + Future getOrCreate() async { + final prefs = await SharedPreferences.getInstance(); + final existing = prefs.getString(_key); + if (existing != null && existing.isNotEmpty) return existing; + final id = const Uuid().v4(); + await prefs.setString(_key, id); + return id; + } +} diff --git a/lib/core/infra/storage/discover_page_cache_store.dart b/lib/core/infra/storage/discover_page_cache_store.dart new file mode 100644 index 0000000..389d9a4 --- /dev/null +++ b/lib/core/infra/storage/discover_page_cache_store.dart @@ -0,0 +1,92 @@ +import 'dart:async'; +import 'dart:io'; + +import '../../../shared/utils/async_lock.dart'; +import '../../../shared/utils/url_utils.dart'; +import '../../contracts/tmdb.dart'; +import 'json_file_cache_io.dart'; + +class DiscoverPageCacheScope { + final String apiBaseUrl; + final String imageBaseUrl; + final String language; + + const DiscoverPageCacheScope({ + required this.apiBaseUrl, + required this.imageBaseUrl, + required this.language, + }); + + String get cacheKey => [ + _normalize(apiBaseUrl), + _normalize(imageBaseUrl), + language, + ].map(sanitizeCacheKeyComponent).join('_'); + + static String _normalize(String value) => stripTrailingSlash(value.trim()); +} + +class DiscoverPageCacheEntry { + final TmdbRecommendationsRes data; + final DateTime savedAt; + + const DiscoverPageCacheEntry({required this.data, required this.savedAt}); + + factory DiscoverPageCacheEntry.fromJson(Map json) { + return DiscoverPageCacheEntry( + data: TmdbRecommendationsRes.fromJson( + json['data'] as Map, + ), + savedAt: DateTime.parse(json['savedAt'] as String), + ); + } + + Map toJson() => { + 'data': data.toJson(), + 'savedAt': savedAt.toUtc().toIso8601String(), + }; +} + +class DiscoverPageCacheStore { + final Directory _directory; + final Map _locks = {}; + + DiscoverPageCacheStore({required this._directory}); + + Future readRow( + DiscoverPageCacheScope scope, + String rowKey, + ) async { + return readJsonCacheFile( + _rowFile(scope, rowKey), + DiscoverPageCacheEntry.fromJson, + ); + } + + Future writeRow( + DiscoverPageCacheScope scope, + String rowKey, + TmdbRecommendationsRes data, + ) async { + final lock = _locks.putIfAbsent(_lockKey(scope, rowKey), AsyncLock.new); + await lock.run(() async { + await writeJsonCacheFile( + _rowFile(scope, rowKey), + DiscoverPageCacheEntry( + data: data, + savedAt: DateTime.now().toUtc(), + ).toJson(), + ); + }); + } + + File _rowFile(DiscoverPageCacheScope scope, String rowKey) { + final safeRowKey = sanitizeCacheKeyComponent(rowKey); + return File( + '${_directory.path}/discover_page_cache_${scope.cacheKey}_$safeRowKey.json', + ); + } + + String _lockKey(DiscoverPageCacheScope scope, String rowKey) => + '${scope.cacheKey}:$rowKey'; +} diff --git a/lib/core/infra/storage/home_page_cache_store.dart b/lib/core/infra/storage/home_page_cache_store.dart new file mode 100644 index 0000000..9fe6417 --- /dev/null +++ b/lib/core/infra/storage/home_page_cache_store.dart @@ -0,0 +1,175 @@ +import 'dart:async'; +import 'dart:io'; + +import '../../../shared/utils/async_lock.dart'; +import '../../contracts/library.dart'; +import 'json_file_cache_io.dart'; + +class HomeOverviewCacheData { + final List libraries; + final List resumeItems; + final Map> latestByLibrary; + final DateTime savedAt; + + const HomeOverviewCacheData({ + required this.libraries, + required this.resumeItems, + required this.latestByLibrary, + required this.savedAt, + }); + + factory HomeOverviewCacheData.empty() => HomeOverviewCacheData( + libraries: const [], + resumeItems: const [], + latestByLibrary: const {}, + savedAt: DateTime.fromMillisecondsSinceEpoch(0), + ); + + factory HomeOverviewCacheData.fromJson(Map json) { + final libs = (json['libraries'] as List? ?? []) + .map((e) => EmbyRawLibrary.fromJson(e as Map)) + .toList(); + final resume = (json['resumeItems'] as List? ?? []) + .map((e) => EmbyRawItem.fromJson(e as Map)) + .toList(); + final latestRaw = json['latestByLibrary'] as Map? ?? {}; + final latest = latestRaw.map( + (k, v) => MapEntry( + k, + (v as List) + .map((e) => EmbyRawItem.fromJson(e as Map)) + .toList(), + ), + ); + return HomeOverviewCacheData( + libraries: libs, + resumeItems: resume, + latestByLibrary: latest, + savedAt: DateTime.parse(json['savedAt'] as String), + ); + } + + Map toJson() => { + 'libraries': libraries.map((e) => e.toJson()).toList(), + 'resumeItems': resumeItems.map(_itemToCacheJson).toList(), + 'latestByLibrary': latestByLibrary.map( + (k, v) => MapEntry(k, v.map(_itemToCacheJson).toList()), + ), + 'savedAt': savedAt.toUtc().toIso8601String(), + }; + + HomeOverviewCacheData copyWith({ + List? libraries, + List? resumeItems, + Map>? latestByLibrary, + }) => HomeOverviewCacheData( + libraries: libraries ?? this.libraries, + resumeItems: resumeItems ?? this.resumeItems, + latestByLibrary: latestByLibrary ?? this.latestByLibrary, + savedAt: DateTime.now().toUtc(), + ); +} + +class HomeBannerCacheData { + final List bannerItems; + final DateTime savedAt; + + const HomeBannerCacheData({required this.bannerItems, required this.savedAt}); + + factory HomeBannerCacheData.fromJson(Map json) { + final items = (json['bannerItems'] as List? ?? []) + .map((e) => EmbyRawItem.fromJson(e as Map)) + .toList(); + return HomeBannerCacheData( + bannerItems: items, + savedAt: DateTime.parse(json['savedAt'] as String), + ); + } + + Map toJson() => { + 'bannerItems': bannerItems.map(_itemToCacheJson).toList(), + 'savedAt': savedAt.toUtc().toIso8601String(), + }; +} + +Map _itemToCacheJson(EmbyRawItem item) => { + ...item.extra, + ...item.toJson(), +}; + +class HomePageCacheStore { + final Directory _directory; + final Map _overviewLocks = {}; + final Map _bannerLocks = {}; + + HomePageCacheStore({required this._directory}); + + Future readOverview( + String serverId, + String userId, + ) async { + final file = _overviewFile(serverId, userId); + return readJsonCacheFile(file, HomeOverviewCacheData.fromJson); + } + + Future readBanner( + String serverId, + String userId, + ) async { + final file = _bannerFile(serverId, userId); + return readJsonCacheFile(file, HomeBannerCacheData.fromJson); + } + + + Future updateOverviewFields( + String serverId, + String userId, { + List? libraries, + List? resumeItems, + Map>? latestByLibrary, + }) async { + final lock = _overviewLocks.putIfAbsent( + _cacheKey(serverId, userId), + AsyncLock.new, + ); + await lock.run(() async { + final file = _overviewFile(serverId, userId); + final existing = + await readJsonCacheFile(file, HomeOverviewCacheData.fromJson) ?? + HomeOverviewCacheData.empty(); + final updated = existing.copyWith( + libraries: libraries, + resumeItems: resumeItems, + latestByLibrary: latestByLibrary, + ); + await writeJsonCacheFile(file, updated.toJson()); + }); + } + + Future writeBanner( + String serverId, + String userId, + HomeBannerCacheData data, + ) async { + final lock = _bannerLocks.putIfAbsent( + _cacheKey(serverId, userId), + AsyncLock.new, + ); + await lock.run(() async { + final file = _bannerFile(serverId, userId); + await writeJsonCacheFile(file, data.toJson()); + }); + } + + String _cacheKey(String serverId, String userId) => + '${sanitizeCacheKeyComponent(serverId)}_' + '${sanitizeCacheKeyComponent(userId)}'; + + File _overviewFile(String serverId, String userId) => File( + '${_directory.path}/home_overview_cache_${_cacheKey(serverId, userId)}.json', + ); + + File _bannerFile(String serverId, String userId) => File( + '${_directory.path}/home_banner_cache_${_cacheKey(serverId, userId)}.json', + ); +} diff --git a/lib/core/infra/storage/json_file_cache_io.dart b/lib/core/infra/storage/json_file_cache_io.dart new file mode 100644 index 0000000..704e362 --- /dev/null +++ b/lib/core/infra/storage/json_file_cache_io.dart @@ -0,0 +1,33 @@ +import 'dart:convert'; +import 'dart:io'; + +final _cacheKeyUnsafeChars = RegExp(r'[^a-zA-Z0-9]'); + + +String sanitizeCacheKeyComponent(String value) => + value.replaceAll(_cacheKeyUnsafeChars, '_'); + + +Future readJsonCacheFile( + File file, + T Function(Map) fromJson, +) async { + try { + if (!await file.exists()) return null; + final raw = await file.readAsString(); + if (raw.trim().isEmpty) return null; + return fromJson(jsonDecode(raw) as Map); + } catch (_) { + return null; + } +} + +Future writeJsonCacheFile(File file, Map json) async { + if (!await file.parent.exists()) { + await file.parent.create(recursive: true); + } + await file.writeAsString( + const JsonEncoder.withIndent(' ').convert(json), + flush: true, + ); +} diff --git a/lib/core/infra/storage/json_script_widget_repository.dart b/lib/core/infra/storage/json_script_widget_repository.dart new file mode 100644 index 0000000..26780dc --- /dev/null +++ b/lib/core/infra/storage/json_script_widget_repository.dart @@ -0,0 +1,179 @@ +import 'dart:convert'; +import 'dart:io'; + +import '../../../shared/utils/async_lock.dart'; +import '../../contracts/script_widget.dart'; +import '../../domain/ports/script_widget_repository.dart'; + +class JsonScriptWidgetRepository implements ScriptWidgetRepository { + final File file; + final AsyncLock _lock = AsyncLock(); + + JsonScriptWidgetRepository({required this.file}); + + Future<_ScriptWidgetRepositoryState> _read() async { + if (!await file.exists()) return const _ScriptWidgetRepositoryState(); + final rawJson = await file.readAsString(); + if (rawJson.trim().isEmpty) return const _ScriptWidgetRepositoryState(); + try { + final decoded = jsonDecode(rawJson); + if (decoded is! Map) { + return const _ScriptWidgetRepositoryState(); + } + return _ScriptWidgetRepositoryState.fromJson(decoded); + } catch (_) { + return const _ScriptWidgetRepositoryState(); + } + } + + Future _write(_ScriptWidgetRepositoryState state) async { + if (!await file.parent.exists()) { + await file.parent.create(recursive: true); + } + await file.writeAsString( + const JsonEncoder.withIndent(' ').convert(state.toJson()), + flush: true, + ); + } + + @override + Future> listWidgets() => _lock.run(() async { + final state = await _read(); + final widgets = List.from(state.widgets); + widgets.sort( + (left, right) => left.manifest.title.compareTo(right.manifest.title), + ); + return widgets; + }); + + @override + Future findWidget(String widgetId) async { + final widgets = await listWidgets(); + for (final widget in widgets) { + if (widget.manifest.id == widgetId) return widget; + } + return null; + } + + @override + Future upsertWidget(InstalledScriptWidget widget) => + _lock.run(() async { + final state = await _read(); + final widgets = List.from(state.widgets); + final existingIndex = widgets.indexWhere( + (candidate) => candidate.manifest.id == widget.manifest.id, + ); + if (existingIndex < 0) { + widgets.add(widget); + } else { + widgets[existingIndex] = widget; + } + await _write(state.copyWith(widgets: widgets)); + }); + + @override + Future deleteWidget(String widgetId) => _lock.run(() async { + final state = await _read(); + final widgets = List.from(state.widgets) + ..removeWhere((widget) => widget.manifest.id == widgetId); + await _write(state.copyWith(widgets: widgets)); + }); + + @override + Future> listSubscriptions() => + _lock.run(() async { + final state = await _read(); + return List.unmodifiable(state.subscriptions); + }); + + @override + Future upsertSubscription(ScriptWidgetSubscription subscription) => + _lock.run(() async { + final state = await _read(); + final subscriptions = List.from( + state.subscriptions, + ); + final existingIndex = subscriptions.indexWhere( + (candidate) => candidate.url == subscription.url, + ); + if (existingIndex < 0) { + subscriptions.add(subscription); + } else { + subscriptions[existingIndex] = subscription; + } + await _write(state.copyWith(subscriptions: subscriptions)); + }); + + @override + Future replaceAllWidgets(List widgets) => + _lock.run(() async { + final state = await _read(); + await _write( + state.copyWith(widgets: List.from(widgets)), + ); + }); + + @override + Future replaceAllSubscriptions( + List subscriptions, + ) => _lock.run(() async { + final state = await _read(); + await _write( + state.copyWith( + subscriptions: List.from(subscriptions), + ), + ); + }); +} + +class _ScriptWidgetRepositoryState { + final List widgets; + final List subscriptions; + + const _ScriptWidgetRepositoryState({ + this.widgets = const [], + this.subscriptions = const [], + }); + + factory _ScriptWidgetRepositoryState.fromJson(Map json) { + return _ScriptWidgetRepositoryState( + widgets: _decodeList(json['widgets'], InstalledScriptWidget.fromJson), + subscriptions: _decodeList( + json['subscriptions'], + ScriptWidgetSubscription.fromJson, + ), + ); + } + + _ScriptWidgetRepositoryState copyWith({ + List? widgets, + List? subscriptions, + }) { + return _ScriptWidgetRepositoryState( + widgets: widgets ?? this.widgets, + subscriptions: subscriptions ?? this.subscriptions, + ); + } + + Map toJson() => { + 'widgets': widgets.map((widget) => widget.toJson()).toList(), + 'subscriptions': subscriptions + .map((subscription) => subscription.toJson()) + .toList(), + }; +} + +List _decodeList( + Object? rawValue, + T Function(Map) fromJson, +) { + if (rawValue is! List) return []; + final decodedItems = []; + for (final rawItem in rawValue) { + if (rawItem is! Map) continue; + try { + decodedItems.add(fromJson(Map.from(rawItem))); + } catch (_) {} + } + return decodedItems; +} diff --git a/lib/core/infra/storage/json_server_repository.dart b/lib/core/infra/storage/json_server_repository.dart new file mode 100644 index 0000000..98fed7a --- /dev/null +++ b/lib/core/infra/storage/json_server_repository.dart @@ -0,0 +1,119 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import '../../../shared/utils/async_lock.dart'; +import '../../../shared/utils/url_utils.dart'; +import '../../contracts/server.dart'; +import '../../domain/ports/server_repository.dart'; + +class JsonServerRepository implements ServerRepository { + final File _file; + final _lock = AsyncLock(); + + JsonServerRepository({required this._file}); + + Future> _read() async { + if (!await _file.exists()) return []; + final raw = await _file.readAsString(); + if (raw.trim().isEmpty) return []; + try { + final list = jsonDecode(raw) as List; + return list + .map((e) => EmbyServer.fromJson(e as Map)) + .toList(); + } catch (_) { + return []; + } + } + + Future _write(List servers) async { + if (!await _file.parent.exists()) { + await _file.parent.create(recursive: true); + } + final json = servers.map((e) => e.toJson()).toList(); + await _file.writeAsString( + const JsonEncoder.withIndent(' ').convert(json), + flush: true, + ); + } + + @override + Future> list() => _lock.run(_read); + + @override + Future findById(String id) async { + final servers = await list(); + for (final s in servers) { + if (s.id == id) return s; + } + return null; + } + + @override + Future findByBaseUrl(String baseUrl) async { + final servers = await list(); + final normalized = stripTrailingSlash(baseUrl.toLowerCase()); + for (final s in servers) { + if (stripTrailingSlash(s.baseUrl.toLowerCase()) == normalized) { + return s; + } + } + return null; + } + + @override + Future create(EmbyServer server) async { + return _lock.run(() async { + final list = await _read(); + list.add(server); + await _write(list); + return server; + }); + } + + @override + Future update(EmbyServer server) async { + return _lock.run(() async { + final list = await _read(); + final idx = list.indexWhere((e) => e.id == server.id); + if (idx < 0) { + list.add(server); + } else { + list[idx] = server; + } + await _write(list); + return server; + }); + } + + @override + Future deleteById(String id) async { + await _lock.run(() async { + final list = await _read(); + list.removeWhere((e) => e.id == id); + await _write(list); + }); + } + + @override + Future replaceAll(List servers) async { + await _lock.run(() async { + await _write(List.from(servers)); + }); + } + + @override + Future touchConnectedAt(String id, String updatedAt) async { + await _lock.run(() async { + final list = await _read(); + final idx = list.indexWhere((e) => e.id == id); + if (idx < 0) return; + list[idx] = list[idx].copyWith( + lastConnectedAt: updatedAt, + updatedAt: updatedAt, + ); + await _write(list); + }); + } +} diff --git a/lib/core/infra/storage/json_session_repository.dart b/lib/core/infra/storage/json_session_repository.dart new file mode 100644 index 0000000..a66ee20 --- /dev/null +++ b/lib/core/infra/storage/json_session_repository.dart @@ -0,0 +1,161 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import '../../../shared/utils/async_lock.dart'; +import '../../contracts/auth.dart'; +import '../../domain/ports/session_repository.dart'; + +class _SessionStoreData { + List sessions; + String? activeServerId; + + _SessionStoreData({required this.sessions, this.activeServerId}); + + factory _SessionStoreData.empty() => + _SessionStoreData(sessions: []); + + factory _SessionStoreData.fromJson(Map json) => + _SessionStoreData( + sessions: ((json['sessions'] as List?) ?? []) + .map((e) => SessionData.fromJson(e as Map)) + .toList(), + activeServerId: json['activeServerId'] as String?, + ); + + Map toJson() => { + 'sessions': sessions.map((e) => e.toJson()).toList(), + 'activeServerId': activeServerId, + }; +} + +class JsonSessionRepository implements SessionRepository { + final File _file; + final _lock = AsyncLock(); + + JsonSessionRepository({required this._file}); + + Future<_SessionStoreData> _read() async { + if (!await _file.exists()) return _SessionStoreData.empty(); + final raw = await _file.readAsString(); + if (raw.trim().isEmpty) return _SessionStoreData.empty(); + try { + return _SessionStoreData.fromJson( + jsonDecode(raw) as Map, + ); + } catch (_) { + return _SessionStoreData.empty(); + } + } + + Future _write(_SessionStoreData data) async { + if (!await _file.parent.exists()) { + await _file.parent.create(recursive: true); + } + await _file.writeAsString( + const JsonEncoder.withIndent(' ').convert(data.toJson()), + flush: true, + ); + } + + @override + Future set(SessionData session) async { + return _lock.run(() async { + final data = await _read(); + data.sessions.removeWhere((e) => e.serverId == session.serverId); + data.sessions.add(session); + data.activeServerId = session.serverId; + await _write(data); + return session; + }); + } + + @override + Future setMany(List sessions) async { + if (sessions.isEmpty) return; + await _lock.run(() async { + final data = await _read(); + for (final session in sessions) { + data.sessions.removeWhere((e) => e.serverId == session.serverId); + data.sessions.add(session); + } + await _write(data); + }); + } + + @override + Future replaceAll( + List sessions, { + String? activeServerId, + }) async { + await _lock.run(() async { + await _write( + _SessionStoreData( + sessions: List.from(sessions), + activeServerId: activeServerId, + ), + ); + }); + } + + @override + Future getByServerId(String serverId) async { + final data = await _lock.run(_read); + for (final s in data.sessions) { + if (s.serverId == serverId) return s; + } + return null; + } + + @override + Future getActive() async { + final data = await _lock.run(_read); + if (data.activeServerId == null) { + return data.sessions.isEmpty ? null : data.sessions.first; + } + for (final s in data.sessions) { + if (s.serverId == data.activeServerId) return s; + } + return data.sessions.isEmpty ? null : data.sessions.first; + } + + @override + Future getActiveServerId() async { + final data = await _lock.run(_read); + return data.activeServerId; + } + + @override + Future> listAll() async { + final data = await _lock.run(_read); + return List.from(data.sessions); + } + + @override + Future setActive(String serverId) async { + await _lock.run(() async { + final data = await _read(); + data.activeServerId = serverId; + await _write(data); + }); + } + + @override + Future clear([String? serverId]) async { + await _lock.run(() async { + final data = await _read(); + if (serverId == null) { + data.sessions.clear(); + data.activeServerId = null; + } else { + data.sessions.removeWhere((e) => e.serverId == serverId); + if (data.activeServerId == serverId) { + data.activeServerId = data.sessions.isEmpty + ? null + : data.sessions.first.serverId; + } + } + await _write(data); + }); + } +} diff --git a/lib/core/infra/storage/prefs_danmaku_sources_store.dart b/lib/core/infra/storage/prefs_danmaku_sources_store.dart new file mode 100644 index 0000000..813daf3 --- /dev/null +++ b/lib/core/infra/storage/prefs_danmaku_sources_store.dart @@ -0,0 +1,52 @@ +import 'dart:convert'; + +import 'package:shared_preferences/shared_preferences.dart'; + +import '../../contracts/danmaku.dart'; +import '../../domain/ports/danmaku_sources_store.dart'; + + +class PrefsDanmakuSourcesStore implements DanmakuSourcesStore { + static const _keySources = 'smplayer.danmaku_sources'; + static const _keySourceUrl = 'smplayer.danmaku_source_url'; + + @override + Future> list() async { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getStringList(_keySources); + if (raw == null) return const []; + final out = []; + for (final item in raw) { + try { + final data = jsonDecode(item); + if (data is Map) { + final source = DanmakuSource.fromJson(data); + if (source.url.isNotEmpty) out.add(source); + } + } catch (_) {} + } + return out; + } + + @override + Future replaceAll(List sources) async { + final prefs = await SharedPreferences.getInstance(); + final serialized = sources + .where((s) => s.url.trim().isNotEmpty) + .map((s) => jsonEncode(s.toJson())) + .toList(); + await prefs.setStringList(_keySources, serialized); + String? enabledFirst; + for (final s in sources) { + if (s.enabled && s.url.trim().isNotEmpty) { + enabledFirst = s.url; + break; + } + } + if (enabledFirst == null) { + await prefs.remove(_keySourceUrl); + } else { + await prefs.setString(_keySourceUrl, enabledFirst); + } + } +} diff --git a/lib/core/infra/storage/search_history_store.dart b/lib/core/infra/storage/search_history_store.dart new file mode 100644 index 0000000..c93015b --- /dev/null +++ b/lib/core/infra/storage/search_history_store.dart @@ -0,0 +1,67 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:shared_preferences/shared_preferences.dart'; + +import '../../../shared/utils/async_lock.dart'; + +class SearchHistoryStore { + static const _key = 'smplayer.search_history'; + static const int _maxItems = 30; + + + final AsyncLock _lock = AsyncLock(); + + Future> list() async { + final prefs = await SharedPreferences.getInstance(); + return _read(prefs); + } + + Future add(String keyword) { + final trimmed = keyword.trim(); + if (trimmed.isEmpty) return Future.value(); + return _lock.run(() async { + final prefs = await SharedPreferences.getInstance(); + final history = await _read(prefs); + history + ..remove(trimmed) + ..insert(0, trimmed); + if (history.length > _maxItems) { + history.removeRange(_maxItems, history.length); + } + await prefs.setStringList(_key, history); + }); + } + + Future remove(String keyword) { + return _lock.run(() async { + final prefs = await SharedPreferences.getInstance(); + final history = await _read(prefs) + ..remove(keyword); + await prefs.setStringList(_key, history); + }); + } + + Future clear() { + return _lock.run(() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_key); + }); + } + + Future> _read(SharedPreferences prefs) async { + final raw = prefs.get(_key); + if (raw is List) return raw.whereType().toList(); + if (raw is! String || raw.isEmpty) return []; + + try { + final decoded = jsonDecode(raw); + if (decoded is! List) return []; + final history = decoded.whereType().toList(); + await prefs.setStringList(_key, history); + return history; + } catch (_) { + return []; + } + } +} diff --git a/lib/core/infra/system_audio/system_audio_controller.dart b/lib/core/infra/system_audio/system_audio_controller.dart new file mode 100644 index 0000000..d26d5a8 --- /dev/null +++ b/lib/core/infra/system_audio/system_audio_controller.dart @@ -0,0 +1,65 @@ +import 'dart:async'; + +import 'package:flutter/services.dart'; + +class SystemVolumeEvent { + final int current; + final int max; + const SystemVolumeEvent({required this.current, required this.max}); + + double get percent => max == 0 ? 0 : current / max; +} + +class SystemAudioController { + static const _methodChannel = MethodChannel('smplayer/system_audio'); + static const _eventChannel = EventChannel('smplayer/system_audio_events'); + + SystemAudioController._(); + static final SystemAudioController instance = SystemAudioController._(); + + Stream? _volumeChanges; + + Stream get volumeChanges { + _volumeChanges ??= _eventChannel.receiveBroadcastStream().map((event) { + final map = (event as Map).cast(); + return SystemVolumeEvent( + current: (map['current'] as num).toInt(), + max: (map['max'] as num).toInt(), + ); + }); + return _volumeChanges!; + } + + Future getVolume() async { + final result = await _methodChannel.invokeMapMethod( + 'getVolume', + ); + if (result == null) { + return const SystemVolumeEvent(current: 0, max: 15); + } + return SystemVolumeEvent( + current: (result['current'] as num).toInt(), + max: (result['max'] as num).toInt(), + ); + } + + Future adjustVolume(int delta) async { + final result = await _methodChannel.invokeMapMethod( + 'adjustVolume', + {'delta': delta}, + ); + if (result == null) { + return const SystemVolumeEvent(current: 0, max: 15); + } + return SystemVolumeEvent( + current: (result['current'] as num).toInt(), + max: (result['max'] as num).toInt(), + ); + } + + Future setKeyIntercept(bool enabled) async { + await _methodChannel.invokeMethod('setKeyIntercept', { + 'enabled': enabled, + }); + } +} diff --git a/lib/core/infra/tmdb_api/tmdb_client.dart b/lib/core/infra/tmdb_api/tmdb_client.dart new file mode 100644 index 0000000..beb2342 --- /dev/null +++ b/lib/core/infra/tmdb_api/tmdb_client.dart @@ -0,0 +1,420 @@ +import 'package:dio/dio.dart'; + +import '../../../shared/utils/app_logger.dart'; +import '../../contracts/tmdb.dart'; +import '../../domain/ports/tmdb_gateway.dart'; +import 'tmdb_rest_api.dart'; + +class TmdbApiClient implements TmdbGateway { + static const _tag = 'Tmdb'; + + TmdbApiClient({Dio? dio}) : _api = _buildApi(dio); + + static TmdbRestApi _buildApi(Dio? injected) { + final dio = + injected ?? + Dio( + BaseOptions( + connectTimeout: const Duration(seconds: 8), + receiveTimeout: const Duration(seconds: 15), + headers: {'Accept': 'application/json'}, + ), + ); + dio.interceptors.add(TmdbConfigInterceptor()); + dio.interceptors.add(TmdbAuthRetryInterceptor(dio)); + return TmdbRestApi(dio); + } + + final TmdbRestApi _api; + + Map _extras(TmdbRequestConfig config) => { + kTmdbConfigExtraKey: config, + }; + + + Map _extrasWithLang( + TmdbRequestConfig config, + String language, + ) => { + kTmdbConfigExtraKey: config, + kTmdbAcceptLanguageExtraKey: language, + }; + + @override + Future getCredits( + TmdbRequestConfig config, + String tmdbId, + String mediaType, { + required String language, + }) async { + final type = _normalizeMediaType(mediaType); + if (tmdbId.isEmpty || type == null) return null; + try { + return await _api.getCredits(type, tmdbId, language, _extras(config)); + } catch (e) { + AppLogger.warn(_tag, 'GET /$type/$tmdbId/credits failed', e); + return null; + } + } + + @override + Future getRecommendations( + TmdbRequestConfig config, + String tmdbId, + String mediaType, { + required String language, + }) async { + final type = _normalizeMediaType(mediaType); + if (tmdbId.isEmpty || type == null) return null; + try { + return await _api.getRecommendations( + type, + tmdbId, + language, + '1', + _extras(config), + ); + } catch (e) { + AppLogger.warn(_tag, 'GET /$type/$tmdbId/recommendations failed', e); + return null; + } + } + + @override + Future getSeasonDetail( + TmdbRequestConfig config, + String tmdbId, + int seasonNumber, { + required String language, + }) async { + if (tmdbId.isEmpty || seasonNumber < 0) return null; + try { + return await _api.getSeasonDetail( + tmdbId, + seasonNumber, + language, + _extras(config), + ); + } catch (e) { + AppLogger.warn(_tag, 'GET /tv/$tmdbId/season/$seasonNumber failed', e); + return null; + } + } + + @override + Future getImages( + TmdbRequestConfig config, + String tmdbId, + String mediaType, { + required String language, + }) async { + final type = _normalizeMediaType(mediaType); + if (tmdbId.isEmpty || type == null) return null; + try { + return await _api.getImages( + type, + tmdbId, + _includeImageLanguage(language), + _extrasWithLang(config, language), + ); + } catch (e) { + AppLogger.warn(_tag, 'GET /$type/$tmdbId/images failed', e); + return null; + } + } + + @override + Future getEnrichedDetail( + TmdbRequestConfig config, + String tmdbId, + String mediaType, { + required String language, + List appendToResponse = const [ + 'videos', + 'credits', + 'recommendations', + 'images', + 'external_ids', + ], + }) async { + final type = _normalizeMediaType(mediaType); + if (tmdbId.isEmpty || type == null) return null; + + final TmdbEnrichedDetail detail; + try { + detail = await _api.getEnrichedDetail( + type, + tmdbId, + language, + appendToResponse.join(','), + appendToResponse.contains('images') + ? _includeImageLanguage(language) + : null, + _extras(config), + ); + } catch (e) { + AppLogger.warn(_tag, 'GET /$type/$tmdbId failed', e); + return null; + } + + final shouldFallbackVideos = + appendToResponse.contains('videos') && + !_isEnglishLanguage(language) && + (detail.videos?.results.isEmpty ?? true); + if (!shouldFallbackVideos) return detail; + + final fallbackVideos = await getVideos( + config, + tmdbId, + type, + language: 'en-US', + ); + if (fallbackVideos == null || fallbackVideos.results.isEmpty) { + return detail; + } + return detail.copyWith(videos: fallbackVideos); + } + + @override + Future getVideos( + TmdbRequestConfig config, + String tmdbId, + String mediaType, { + required String language, + }) async { + final type = _normalizeMediaType(mediaType); + if (tmdbId.isEmpty || type == null) return null; + try { + return await _api.getVideos(type, tmdbId, language, _extras(config)); + } catch (e) { + AppLogger.warn(_tag, 'GET /$type/$tmdbId/videos failed', e); + return null; + } + } + + @override + Future getPersonDetail( + TmdbRequestConfig config, + int personId, { + required String language, + }) async { + if (personId <= 0) return null; + try { + return await _api.getPersonDetail(personId, language, _extras(config)); + } catch (e) { + AppLogger.warn(_tag, 'GET /person/$personId failed', e); + return null; + } + } + + @override + Future getPersonCredits( + TmdbRequestConfig config, + int personId, { + required String language, + }) async { + if (personId <= 0) return null; + try { + return await _api.getPersonCredits(personId, language, _extras(config)); + } catch (e) { + AppLogger.warn(_tag, 'GET /person/$personId/combined_credits failed', e); + return null; + } + } + + @override + Future getMediaDetail( + TmdbRequestConfig config, + String tmdbId, + String mediaType, { + required String language, + }) async { + final type = _normalizeMediaType(mediaType); + if (tmdbId.isEmpty || type == null) return null; + try { + return await _api.getMediaDetail(type, tmdbId, language, _extras(config)); + } catch (e) { + AppLogger.warn(_tag, 'GET /$type/$tmdbId failed', e); + return null; + } + } + + @override + Future getTrending( + TmdbRequestConfig config, { + required String language, + String mediaType = 'all', + String timeWindow = 'day', + }) async { + try { + return await _api.getTrending( + mediaType, + timeWindow, + language, + '1', + _extras(config), + ); + } catch (e) { + AppLogger.warn(_tag, 'GET /trending/$mediaType/$timeWindow failed', e); + return null; + } + } + + @override + Future getPopularMovies( + TmdbRequestConfig config, { + required String language, + }) async { + try { + return await _api.getPopularMovies(language, '1', _extras(config)); + } catch (e) { + AppLogger.warn(_tag, 'GET /movie/popular failed', e); + return null; + } + } + + @override + Future getPopularTv( + TmdbRequestConfig config, { + required String language, + }) async { + try { + return await _api.getPopularTv(language, '1', _extras(config)); + } catch (e) { + AppLogger.warn(_tag, 'GET /tv/popular failed', e); + return null; + } + } + + @override + Future getAiringTodayTv( + TmdbRequestConfig config, { + required String language, + }) async { + try { + return await _api.getAiringTodayTv(language, '1', _extras(config)); + } catch (e) { + AppLogger.warn(_tag, 'GET /tv/airing_today failed', e); + return null; + } + } + + @override + Future getTopRatedMovies( + TmdbRequestConfig config, { + required String language, + }) async { + try { + return await _api.getTopRatedMovies(language, '1', _extras(config)); + } catch (e) { + AppLogger.warn(_tag, 'GET /movie/top_rated failed', e); + return null; + } + } + + @override + Future getDiscover( + TmdbRequestConfig config, + String mediaType, { + required String language, + String sortBy = 'popularity.desc', + String? withGenres, + String? voteCountGte, + }) async { + final type = _normalizeMediaType(mediaType); + if (type == null) return null; + try { + return await _api.getDiscover( + type, + language, + '1', + sortBy, + withGenres, + voteCountGte, + _extras(config), + ); + } catch (e) { + AppLogger.warn(_tag, 'GET /discover/$type failed', e); + return null; + } + } + + @override + Future testConnection( + TmdbRequestConfig config, { + required String language, + }) async { + final accessToken = config.accessToken.trim(); + final apiKey = config.apiKey.trim(); + final authMode = accessToken.isNotEmpty + ? 'bearer(${_maskSecret(accessToken)})' + : apiKey.isNotEmpty + ? 'apiKey(${_maskSecret(apiKey)})' + : 'none'; + AppLogger.info( + _tag, + 'testConnection → GET ${config.apiBaseUrl}/configuration ' + 'auth=$authMode lang=$language', + ); + try { + final res = await _api.getConfiguration( + _extrasWithLang(config, language), + ); + final data = res.data; + final ok = + data is Map && (data['images'] is Map || data['change_keys'] is List); + AppLogger.info( + _tag, + 'testConnection ← ${res.response.statusCode} ' + 'ok=$ok (body=${data.runtimeType})', + ); + return ok; + } on DioException catch (e) { + final status = e.response?.statusCode; + final hint = switch (status) { + 401 => '认证失败:token/api_key 被拒绝,检查认证方式(Bearer/ApiKey)是否与凭据类型匹配', + 403 => '权限不足:凭据有效但无访问权限', + 404 => 'baseUrl 路径错误:检查 ${config.apiBaseUrl}', + null => '网络层失败(${e.type.name}):检查 baseUrl 可达性/代理', + _ => '', + }; + AppLogger.warn( + _tag, + 'testConnection ← HTTP $status auth=$authMode ' + 'url=${config.apiBaseUrl}/configuration' + '${hint.isEmpty ? '' : ' — $hint'}', + e, + ); + return false; + } catch (e) { + AppLogger.warn(_tag, 'testConnection ← 非 Dio 异常 auth=$authMode', e); + return false; + } + } + + + static String _maskSecret(String secret) { + if (secret.length <= 4) return '****'; + return 'len=${secret.length},…${secret.substring(secret.length - 4)}'; + } + + static String? _normalizeMediaType(String mediaType) { + return switch (mediaType) { + 'movie' || 'tv' => mediaType, + _ => null, + }; + } + + static String _includeImageLanguage(String language) { + final imageLanguages = [_languageCode(language), 'en', 'null']; + return imageLanguages.where((v) => v.isNotEmpty).toSet().join(','); + } + + static bool _isEnglishLanguage(String language) { + return _languageCode(language) == 'en'; + } + + static String _languageCode(String language) { + return language.trim().split(RegExp('[-_]')).first.toLowerCase(); + } +} diff --git a/lib/core/infra/tmdb_api/tmdb_rest_api.dart b/lib/core/infra/tmdb_api/tmdb_rest_api.dart new file mode 100644 index 0000000..8f5d268 --- /dev/null +++ b/lib/core/infra/tmdb_api/tmdb_rest_api.dart @@ -0,0 +1,363 @@ +import 'package:dio/dio.dart'; + +import '../../contracts/tmdb.dart'; +import '../../domain/ports/tmdb_gateway.dart'; + + +const String kTmdbConfigExtraKey = 'tmdbConfig'; + + +const String kTmdbAcceptLanguageExtraKey = 'tmdbAcceptLanguage'; + + +const String kTmdbOverrideAccessTokenKey = 'tmdbOverrideAccessToken'; + + +const String kTmdbAuthRetriedKey = 'tmdbAuthRetried'; + + +class TmdbRestApi { + TmdbRestApi(this._dio); + + final Dio _dio; + + Future getCredits( + String mediaType, + String tmdbId, + String language, + Map extras, + ) { + return _getJson( + '/$mediaType/$tmdbId/credits', + queryParameters: {'language': language}, + extras: extras, + fromJson: TmdbCreditsRes.fromJson, + ); + } + + Future getRecommendations( + String mediaType, + String tmdbId, + String language, + String page, + Map extras, + ) { + return _getJson( + '/$mediaType/$tmdbId/recommendations', + queryParameters: {'language': language, 'page': page}, + extras: extras, + fromJson: TmdbRecommendationsRes.fromJson, + ); + } + + Future getSeasonDetail( + String tmdbId, + int seasonNumber, + String language, + Map extras, + ) { + return _getJson( + '/tv/$tmdbId/season/$seasonNumber', + queryParameters: {'language': language}, + extras: extras, + fromJson: TmdbSeasonDetail.fromJson, + ); + } + + Future getImages( + String mediaType, + String tmdbId, + String includeImageLanguage, + Map extras, + ) { + return _getJson( + '/$mediaType/$tmdbId/images', + queryParameters: {'include_image_language': includeImageLanguage}, + extras: extras, + fromJson: TmdbImageSet.fromJson, + ); + } + + Future getEnrichedDetail( + String mediaType, + String tmdbId, + String language, + String appendToResponse, + String? includeImageLanguage, + Map extras, + ) { + return _getJson( + '/$mediaType/$tmdbId', + queryParameters: { + 'language': language, + 'append_to_response': appendToResponse, + 'include_image_language': includeImageLanguage, + }, + extras: extras, + fromJson: TmdbEnrichedDetail.fromJson, + ); + } + + Future getMediaDetail( + String mediaType, + String tmdbId, + String language, + Map extras, + ) { + return _getJson( + '/$mediaType/$tmdbId', + queryParameters: {'language': language}, + extras: extras, + fromJson: TmdbMediaDetail.fromJson, + ); + } + + Future getVideos( + String mediaType, + String tmdbId, + String language, + Map extras, + ) { + return _getJson( + '/$mediaType/$tmdbId/videos', + queryParameters: {'language': language}, + extras: extras, + fromJson: TmdbVideosRes.fromJson, + ); + } + + Future getPersonDetail( + int personId, + String language, + Map extras, + ) { + return _getJson( + '/person/$personId', + queryParameters: {'language': language}, + extras: extras, + fromJson: TmdbPersonDetail.fromJson, + ); + } + + Future getPersonCredits( + int personId, + String language, + Map extras, + ) { + return _getJson( + '/person/$personId/combined_credits', + queryParameters: {'language': language}, + extras: extras, + fromJson: TmdbPersonCreditsRes.fromJson, + ); + } + + Future getTrending( + String mediaType, + String timeWindow, + String language, + String page, + Map extras, + ) { + return _getJson( + '/trending/$mediaType/$timeWindow', + queryParameters: {'language': language, 'page': page}, + extras: extras, + fromJson: TmdbRecommendationsRes.fromJson, + ); + } + + Future getPopularMovies( + String language, + String page, + Map extras, + ) { + return _getJson( + '/movie/popular', + queryParameters: {'language': language, 'page': page}, + extras: extras, + fromJson: TmdbRecommendationsRes.fromJson, + ); + } + + Future getPopularTv( + String language, + String page, + Map extras, + ) { + return _getJson( + '/tv/popular', + queryParameters: {'language': language, 'page': page}, + extras: extras, + fromJson: TmdbRecommendationsRes.fromJson, + ); + } + + Future getAiringTodayTv( + String language, + String page, + Map extras, + ) { + return _getJson( + '/tv/airing_today', + queryParameters: {'language': language, 'page': page}, + extras: extras, + fromJson: TmdbRecommendationsRes.fromJson, + ); + } + + Future getTopRatedMovies( + String language, + String page, + Map extras, + ) { + return _getJson( + '/movie/top_rated', + queryParameters: {'language': language, 'page': page}, + extras: extras, + fromJson: TmdbRecommendationsRes.fromJson, + ); + } + + Future getDiscover( + String mediaType, + String language, + String page, + String sortBy, + String? withGenres, + String? voteCountGte, + Map extras, + ) { + return _getJson( + '/discover/$mediaType', + queryParameters: { + 'language': language, + 'page': page, + 'sort_by': sortBy, + 'with_genres': withGenres, + 'vote_count.gte': voteCountGte, + }, + extras: extras, + fromJson: TmdbRecommendationsRes.fromJson, + ); + } + + Future> getConfiguration( + Map extras, + ) async { + final response = await _dio.get( + '/configuration', + options: Options(extra: extras), + ); + return TmdbRawResponse(data: response.data, response: response); + } + + Future _getJson( + String path, { + required Map queryParameters, + required Map extras, + required T Function(Map json) fromJson, + }) async { + final response = await _dio.get( + path, + queryParameters: _withoutNullValues(queryParameters), + options: Options(extra: extras), + ); + final data = response.data; + if (data is Map) return fromJson(data); + if (data is Map) return fromJson(Map.from(data)); + throw FormatException( + 'TMDB response is not a JSON object: ${data.runtimeType}', + ); + } + + Map _withoutNullValues(Map values) { + return { + for (final entry in values.entries) + if (entry.value != null) entry.key: entry.value, + }; + } +} + +class TmdbRawResponse { + const TmdbRawResponse({required this.data, required this.response}); + + final T data; + final Response response; +} + + +class TmdbConfigInterceptor extends Interceptor { + @override + void onRequest(RequestOptions options, RequestInterceptorHandler handler) { + final config = options.extra[kTmdbConfigExtraKey]; + if (config is! TmdbRequestConfig) { + handler.next(options); + return; + } + + options.baseUrl = config.apiBaseUrl; + + final override = options.extra[kTmdbOverrideAccessTokenKey]; + final accessToken = (override is String && override.trim().isNotEmpty) + ? override.trim() + : config.accessToken.trim(); + final apiKey = config.apiKey.trim(); + if (accessToken.isNotEmpty) { + options.headers['Authorization'] = 'Bearer $accessToken'; + } else if (apiKey.isNotEmpty) { + options.queryParameters['api_key'] = apiKey; + } + + final extraLang = options.extra[kTmdbAcceptLanguageExtraKey]; + final queryLang = options.queryParameters['language']; + final lang = extraLang is String && extraLang.trim().isNotEmpty + ? extraLang.trim() + : (queryLang is String && queryLang.trim().isNotEmpty + ? queryLang.trim() + : null); + if (lang != null) { + options.headers['Accept-Language'] = '$lang,en,*'; + } + + handler.next(options); + } +} + + +class TmdbAuthRetryInterceptor extends Interceptor { + TmdbAuthRetryInterceptor(this._dio); + + final Dio _dio; + + @override + Future onError( + DioException err, + ErrorInterceptorHandler handler, + ) async { + final options = err.requestOptions; + final config = options.extra[kTmdbConfigExtraKey]; + final refresh = config is TmdbRequestConfig ? config.onUnauthorized : null; + final alreadyRetried = options.extra[kTmdbAuthRetriedKey] == true; + + if (err.response?.statusCode != 401 || refresh == null || alreadyRetried) { + handler.next(err); + return; + } + + final newToken = await refresh(); + if (newToken == null || newToken.trim().isEmpty) { + handler.next(err); + return; + } + + options.extra[kTmdbOverrideAccessTokenKey] = newToken.trim(); + options.extra[kTmdbAuthRetriedKey] = true; + try { + final response = await _dio.fetch(options); + handler.resolve(response); + } on DioException catch (e) { + handler.next(e); + } + } +} diff --git a/lib/core/infra/trakt_api/authed_trakt_client.dart b/lib/core/infra/trakt_api/authed_trakt_client.dart new file mode 100644 index 0000000..1b40188 --- /dev/null +++ b/lib/core/infra/trakt_api/authed_trakt_client.dart @@ -0,0 +1,213 @@ +import 'package:dio/dio.dart'; + +import '../../../shared/utils/app_logger.dart'; +import '../../contracts/trakt/trakt_response.dart'; +import '../../contracts/trakt/trakt_sync_job.dart'; +import '../../domain/ports/authed_trakt_gateway.dart'; +import 'trakt_auth.dart'; +import 'trakt_credentials.dart'; +import 'trakt_rest_api.dart' show TraktHeaderInterceptor; + +const _tag = 'AuthedTrakt'; + + +class AuthedTraktClient implements AuthedTraktGateway { + AuthedTraktClient({required this._auth, Dio? dio}) + : _dio = + (dio ?? + Dio( + BaseOptions( + baseUrl: TraktCredentials.apiBaseUrl, + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 15), + headers: {'Accept': 'application/json'}, + ), + )) + ..interceptors.add(TraktHeaderInterceptor()); + + final TraktAuth _auth; + final Dio _dio; + + @override + Future isConnected() => _auth.isConnected(); + + @override + Future scrobble( + TraktSyncJobType type, + Map body, + ) { + return _post(_scrobblePath(type), body, allowRefreshRetry: true); + } + + @override + Future syncHistory( + TraktSyncJobType type, + Map body, + ) { + final path = type == TraktSyncJobType.historyRemove + ? '/sync/history/remove' + : '/sync/history'; + return _post(path, body, allowRefreshRetry: true); + } + + @override + Future>> fetchPlayback() => + _getList('/sync/playback?extended=full'); + + @override + Future>> fetchCalendarShows() => + _getList('/calendars/my/shows/${_todayUtc()}/7'); + + @override + Future>> fetchCalendarMovies() => + _getList('/calendars/my/movies/${_todayUtc()}/7'); + + static String _todayUtc() { + final now = DateTime.now().toUtc(); + return '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}'; + } + + + Future>> _getList( + String path, { + bool allowRefreshRetry = true, + }) async { + final token = await _auth.validAccessToken(); + if (token == null) throw StateError('Trakt not connected'); + + try { + final res = await _dio.get( + path, + options: Options( + headers: {'Authorization': 'Bearer $token'}, + validateStatus: (_) => true, + ), + ); + final status = res.statusCode ?? 0; + if (status == 401 && allowRefreshRetry) { + final refreshed = await _auth.forceRefresh(); + if (refreshed == null) throw StateError('Trakt token refresh failed'); + return _getList(path, allowRefreshRetry: false); + } + if (status < 200 || status >= 300) { + throw StateError('Trakt GET $path → $status'); + } + final data = res.data; + if (data is! List) return const []; + return data.whereType>().toList(); + } on DioException catch (e) { + AppLogger.debug(_tag, 'GET $path network error', e); + throw StateError('Trakt network error on GET $path: $e'); + } + } + + static String _scrobblePath(TraktSyncJobType type) { + switch (type) { + case TraktSyncJobType.scrobbleStart: + return '/scrobble/start'; + case TraktSyncJobType.scrobblePause: + return '/scrobble/pause'; + case TraktSyncJobType.scrobbleStop: + default: + return '/scrobble/stop'; + } + } + + Future _post( + String path, + Map body, { + required bool allowRefreshRetry, + }) async { + final token = await _auth.validAccessToken(); + if (token == null) { + return const TraktScrobbleOutcome(TraktResponseClass.unauthorized); + } + + try { + final res = await _dio.post( + path, + data: body, + options: Options( + headers: {'Authorization': 'Bearer $token'}, + validateStatus: (_) => true, + ), + ); + final status = res.statusCode ?? 0; + final cls = classifyTraktStatus(status); + + if (cls == TraktResponseClass.success) { + final data = res.data; + AppLogger.debug( + _tag, + 'scrobble $path → $status ' + 'sent=${_identitySummary(body)} ' + 'matched=${data is Map ? _identitySummary(data) : '?'} ' + 'action=${data is Map ? data['action'] : '?'} ' + 'trakt_progress=${data is Map ? data['progress'] : '?'}', + ); + } else { + AppLogger.debug(_tag, 'scrobble $path → $status (${cls.name})'); + } + + if (cls == TraktResponseClass.unauthorized && allowRefreshRetry) { + final refreshed = await _auth.forceRefresh(); + if (refreshed != null) { + return _post(path, body, allowRefreshRetry: false); + } + return const TraktScrobbleOutcome(TraktResponseClass.unauthorized); + } + + if (cls == TraktResponseClass.rateLimited) { + return TraktScrobbleOutcome( + cls, + retryAfter: _parseRetryAfter(res.headers.value('retry-after')), + ); + } + return TraktScrobbleOutcome(cls); + } on DioException catch (e) { + final status = e.response?.statusCode; + if (status != null) { + final cls = classifyTraktStatus(status); + if (cls == TraktResponseClass.rateLimited) { + return TraktScrobbleOutcome( + cls, + retryAfter: _parseRetryAfter( + e.response?.headers.value('retry-after'), + ), + ); + } + return TraktScrobbleOutcome(cls); + } + AppLogger.debug(_tag, 'network error on $path → transient', e); + return const TraktScrobbleOutcome(TraktResponseClass.transient); + } catch (e) { + AppLogger.debug(_tag, 'unexpected error on $path → transient', e); + return const TraktScrobbleOutcome(TraktResponseClass.transient); + } + } + + + static String _identitySummary(Map data) { + final movie = data['movie']; + if (movie is Map) return 'movie "${movie['title']}" ids=${movie['ids']}'; + final show = data['show']; + final episode = data['episode']; + if (show is Map || episode is Map) { + final showTitle = show is Map ? show['title'] : null; + final showIds = show is Map ? show['ids'] : null; + final s = episode is Map ? episode['season'] : null; + final n = episode is Map ? episode['number'] : null; + final epIds = episode is Map ? episode['ids'] : null; + return 'episode "$showTitle" S${s}E$n showIds=$showIds epIds=$epIds'; + } + return 'none'; + } + + + static Duration? _parseRetryAfter(String? raw) { + if (raw == null || raw.isEmpty) return null; + final secs = int.tryParse(raw.trim()); + if (secs == null || secs < 0) return null; + return Duration(seconds: secs); + } +} diff --git a/lib/core/infra/trakt_api/trakt_auth.dart b/lib/core/infra/trakt_api/trakt_auth.dart new file mode 100644 index 0000000..c62b0c5 --- /dev/null +++ b/lib/core/infra/trakt_api/trakt_auth.dart @@ -0,0 +1,113 @@ +import 'dart:async'; + +import '../../../shared/utils/app_logger.dart'; +import '../../contracts/trakt/trakt_token.dart'; +import '../../domain/ports/trakt_gateway.dart'; +import 'trakt_token_store.dart'; + +const _tag = 'TraktAuth'; + + +class TraktAuth { + TraktAuth({ + required this.store, + required this.gateway, + this.refreshBufferSeconds = 24 * 3600, + }); + + final TraktTokenStore store; + final TraktGateway gateway; + + + final int refreshBufferSeconds; + + + void Function()? onCleared; + + Future? _inflightRefresh; + int _generation = 0; + + Future isConnected() async => (await store.read()) != null; + + + Future validAccessToken() async { + final token = await store.read(); + if (token == null || token.accessToken.isEmpty) return null; + + final nowSec = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final expiresAt = token.createdAt + token.expiresIn; + final notExpiring = + token.expiresIn <= 0 || nowSec < expiresAt - refreshBufferSeconds; + if (notExpiring) return token.accessToken; + + if (token.refreshToken.isEmpty) { + await _clear(); + return null; + } + + try { + final fresh = await _refreshSingleFlight(token.refreshToken); + return fresh.accessToken; + } on _TraktRefreshCancelled { + return null; + } catch (e) { + AppLogger.warn(_tag, 'refresh failed, clearing token', e); + await _clear(); + return null; + } + } + + + Future forceRefresh() async { + final token = await store.read(); + if (token == null || token.refreshToken.isEmpty) { + await _clear(); + return null; + } + try { + final fresh = await _refreshSingleFlight(token.refreshToken); + return fresh.accessToken; + } on _TraktRefreshCancelled { + return null; + } catch (e) { + AppLogger.warn(_tag, 'force refresh failed, clearing token', e); + await _clear(); + return null; + } + } + + + Future persist(TraktToken token) => store.write(token); + + + Future clear() => _clear(); + + Future _refreshSingleFlight(String refreshToken) { + final existing = _inflightRefresh; + if (existing != null) return existing; + final generation = _generation; + final future = () async { + try { + final fresh = await gateway.refreshToken(refreshToken); + if (generation != _generation) throw const _TraktRefreshCancelled(); + await store.write(fresh); + AppLogger.debug(_tag, 'token refreshed'); + return fresh; + } finally { + _inflightRefresh = null; + } + }(); + _inflightRefresh = future; + return future; + } + + Future _clear() async { + _generation++; + await store.clear(); + onCleared?.call(); + } +} + +class _TraktRefreshCancelled implements Exception { + const _TraktRefreshCancelled(); +} diff --git a/lib/core/infra/trakt_api/trakt_client.dart b/lib/core/infra/trakt_api/trakt_client.dart new file mode 100644 index 0000000..b1087fb --- /dev/null +++ b/lib/core/infra/trakt_api/trakt_client.dart @@ -0,0 +1,71 @@ +import 'package:dio/dio.dart'; + +import '../../../shared/utils/app_logger.dart'; +import '../../contracts/trakt/trakt_token.dart'; +import '../../contracts/trakt/trakt_user.dart'; +import '../../domain/ports/trakt_gateway.dart'; +import 'trakt_credentials.dart'; +import 'trakt_rest_api.dart'; + + +class TraktApiClient implements TraktGateway { + static const _tag = 'Trakt'; + + TraktApiClient({Dio? dio}) + : _api = TraktRestApi( + (dio ?? + Dio( + BaseOptions( + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 15), + headers: {'Accept': 'application/json'}, + ), + )) + ..interceptors.add(TraktHeaderInterceptor()), + ); + + final TraktRestApi _api; + + @override + Future exchangeCode(String code) async { + try { + return await _api.exchangeToken({ + 'client_id': TraktCredentials.clientId, + 'client_secret': TraktCredentials.clientSecret, + 'redirect_uri': TraktCredentials.redirectUri, + 'code': code, + 'grant_type': 'authorization_code', + }); + } catch (e) { + AppLogger.warn(_tag, 'exchangeCode failed', e); + rethrow; + } + } + + @override + Future refreshToken(String refreshToken) async { + try { + return await _api.exchangeToken({ + 'client_id': TraktCredentials.clientId, + 'client_secret': TraktCredentials.clientSecret, + 'redirect_uri': TraktCredentials.redirectUri, + 'refresh_token': refreshToken, + 'grant_type': 'refresh_token', + }); + } catch (e) { + AppLogger.warn(_tag, 'refreshToken failed', e); + rethrow; + } + } + + @override + Future getMe(String accessToken) async { + try { + final res = await _api.getUserSettings('Bearer $accessToken'); + return res.user ?? const TraktUser(); + } catch (e) { + AppLogger.warn(_tag, 'getMe failed', e); + rethrow; + } + } +} diff --git a/lib/core/infra/trakt_api/trakt_credentials.dart b/lib/core/infra/trakt_api/trakt_credentials.dart new file mode 100644 index 0000000..f2c855a --- /dev/null +++ b/lib/core/infra/trakt_api/trakt_credentials.dart @@ -0,0 +1,30 @@ + + +abstract final class TraktCredentials { + const TraktCredentials._(); + + static const String clientId = + 'YOUR_TRAKT_CLIENT_ID'; + static const String clientSecret = + 'YOUR_TRAKT_CLIENT_SECRET'; + + + static const String redirectUri = 'smplayer://oauth-trakt'; + static const String scheme = 'smplayer'; + static const String host = 'oauth-trakt'; + + static const String apiBaseUrl = 'https://api.trakt.tv'; + static const String authBaseUrl = 'https://trakt.tv'; + static const String apiVersion = '2'; + + + static Uri authorizeUrl(String state) => + Uri.parse('$authBaseUrl/oauth/authorize').replace( + queryParameters: { + 'response_type': 'code', + 'client_id': clientId, + 'redirect_uri': redirectUri, + 'state': state, + }, + ); +} diff --git a/lib/core/infra/trakt_api/trakt_media_matcher.dart b/lib/core/infra/trakt_api/trakt_media_matcher.dart new file mode 100644 index 0000000..ac3425c --- /dev/null +++ b/lib/core/infra/trakt_api/trakt_media_matcher.dart @@ -0,0 +1,108 @@ +import '../../contracts/trakt/trakt_ids.dart'; +import '../../contracts/trakt/trakt_scrobble_target.dart'; + + +abstract final class TraktMediaMatcher { + const TraktMediaMatcher._(); + + + static TraktScrobbleTarget? match({ + required String? itemType, + required String itemName, + required int? productionYear, + required Map providerIds, + Map seriesProviderIds = const {}, + String? seriesName, + int? seriesProductionYear, + int? parentIndexNumber, + int? itemIndexNumber, + }) { + switch (itemType) { + case 'Movie': + return _matchMovie(itemName, productionYear, providerIds); + case 'Episode': + return _matchEpisode( + ownIds: providerIds, + seriesIds: seriesProviderIds, + seriesName: seriesName, + seriesYear: seriesProductionYear, + season: parentIndexNumber, + number: itemIndexNumber, + ); + default: + return null; + } + } + + static TraktScrobbleTarget? _matchMovie( + String name, + int? year, + Map providerIds, + ) { + final ids = _idsFrom(providerIds); + final title = name.trim(); + if (!ids.hasReliableId && title.isEmpty) return null; + return TraktScrobbleTarget.movie(title: title, year: year, ids: ids); + } + + static TraktScrobbleTarget? _matchEpisode({ + required Map ownIds, + required Map seriesIds, + required String? seriesName, + required int? seriesYear, + required int? season, + required int? number, + }) { + final episodeIds = _idsFrom(ownIds); + final showIds = _idsFrom(seriesIds); + final title = (seriesName ?? '').trim(); + + if (episodeIds.hasReliableId) { + return TraktScrobbleTarget.episode( + showTitle: title, + showYear: seriesYear, + showIds: showIds, + season: season, + number: number, + episodeIds: episodeIds, + ); + } + + final hasShowIdentity = showIds.hasReliableId || title.isNotEmpty; + if (hasShowIdentity && season != null && number != null) { + return TraktScrobbleTarget.episode( + showTitle: title, + showYear: seriesYear, + showIds: showIds, + season: season, + number: number, + episodeIds: episodeIds, + ); + } + + return null; + } + + + static TraktIds _idsFrom(Map providerIds) { + if (providerIds.isEmpty) return const TraktIds(); + final lower = {}; + for (final entry in providerIds.entries) { + final k = entry.key.trim().toLowerCase(); + final v = entry.value.trim(); + if (k.isNotEmpty && v.isNotEmpty) lower[k] = v; + } + return TraktIds( + trakt: _parseInt(lower['trakt']), + imdb: lower['imdb'], + tmdb: _parseInt(lower['tmdb']), + tvdb: _parseInt(lower['tvdb']), + slug: lower['slug'], + ); + } + + static int? _parseInt(String? raw) { + if (raw == null || raw.isEmpty) return null; + return int.tryParse(raw); + } +} diff --git a/lib/core/infra/trakt_api/trakt_pending_sync_queue.dart b/lib/core/infra/trakt_api/trakt_pending_sync_queue.dart new file mode 100644 index 0000000..a5a450d --- /dev/null +++ b/lib/core/infra/trakt_api/trakt_pending_sync_queue.dart @@ -0,0 +1,295 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:uuid/uuid.dart'; + +import '../../../shared/utils/app_logger.dart'; +import '../../../shared/utils/async_lock.dart'; +import '../../contracts/trakt/trakt_response.dart'; +import '../../contracts/trakt/trakt_sync_job.dart'; +import '../../domain/ports/authed_trakt_gateway.dart'; +import 'trakt_retry_policy.dart'; + +const _tag = 'TraktQueue'; + + +class TraktPendingSyncQueue { + TraktPendingSyncQueue({ + required this.store, + required this.sender, + required this.policy, + DateTime Function()? clock, + String Function()? idGenerator, + }) : _clock = clock ?? DateTime.now, + _idGenerator = idGenerator ?? (() => const Uuid().v4()); + + final JsonTraktSyncJobStore store; + final AuthedTraktGateway sender; + final TraktRetryPolicy policy; + final DateTime Function() _clock; + final String Function() _idGenerator; + + final _lock = AsyncLock(); + bool _draining = false; + + Future enqueue({ + required TraktSyncJobType type, + required Map body, + Duration? retryAfter, + }) async { + await _lock.run(() async { + final now = _clock(); + final jobs = await store.load(); + final nextJobs = _discardSupersededJobs(jobs, type: type, body: body); + final job = TraktSyncJob( + id: _idGenerator(), + type: type, + body: body, + nextRetryAt: retryAfter == null ? null : now.add(retryAfter), + ); + nextJobs.add(job); + await store.save(nextJobs); + AppLogger.debug(_tag, 'enqueue ${type.wire} (total=${nextJobs.length})'); + }); + } + + + Future discardSupersededBy({ + required TraktSyncJobType type, + required Map body, + }) async { + await _lock.run(() async { + final jobs = await store.load(); + final nextJobs = _discardSupersededJobs(jobs, type: type, body: body); + if (nextJobs.length != jobs.length) { + await store.save(nextJobs); + } + }); + } + + + Future pendingCount() async { + final jobs = await _lock.run(store.load); + return jobs.where((j) => !j.exhausted).length; + } + + + Future drain() async { + if (_draining) return; + _draining = true; + try { + await _lock.run(_drainLocked); + } finally { + _draining = false; + } + } + + Future _drainLocked() async { + final jobs = await store.load(); + if (jobs.isEmpty) return; + + bool connected; + try { + connected = await sender.isConnected(); + } catch (_) { + connected = false; + } + if (!connected) { + AppLogger.debug( + _tag, + 'drain skipped: not connected (${jobs.length} pending)', + ); + return; + } + + final now = _clock(); + final remaining = []; + var changed = false; + + for (final job in jobs) { + if (job.exhausted) { + remaining.add(job); + continue; + } + final due = job.nextRetryAt == null || !job.nextRetryAt!.isAfter(now); + if (!due) { + remaining.add(job); + continue; + } + + changed = true; + TraktScrobbleOutcome outcome; + try { + outcome = job.type.isHistory + ? await sender.syncHistory(job.type, job.body) + : await sender.scrobble(job.type, job.body); + } catch (e) { + outcome = const TraktScrobbleOutcome(TraktResponseClass.transient); + AppLogger.debug(_tag, 'send threw, treat as transient', e); + } + + final decision = policy.evaluate( + cls: outcome.cls, + retryCount: job.retryCount, + retryAfter: outcome.retryAfter, + ); + + switch (decision.decision) { + case TraktRetryDecision.complete: + AppLogger.debug(_tag, 'job ${job.id} ${job.type.wire} → complete'); + break; + case TraktRetryDecision.discard: + AppLogger.debug( + _tag, + 'job ${job.id} ${job.type.wire} → discard ' + '(${outcome.cls.name})', + ); + break; + case TraktRetryDecision.retry: + remaining.add( + job.copyWith( + retryCount: job.retryCount + 1, + nextRetryAt: now.add(decision.delay ?? policy.baseBackoff), + ), + ); + AppLogger.debug( + _tag, + 'job ${job.id} → retry in ' + '${(decision.delay ?? policy.baseBackoff).inSeconds}s ' + '(count=${job.retryCount + 1})', + ); + break; + case TraktRetryDecision.giveUp: + remaining.add(job.copyWith(clearNextRetryAt: true, exhausted: true)); + AppLogger.warn( + _tag, + 'job ${job.id} ${job.type.wire} → giveUp ' + '(exhausted after ${job.retryCount} retries)', + ); + break; + } + } + + if (changed) await store.save(remaining); + } + + List _discardSupersededJobs( + List jobs, { + required TraktSyncJobType type, + required Map body, + }) { + final key = _mediaKey(body); + if (key == null) return List.of(jobs); + if (type.isHistory) { + return jobs + .where((job) { + if (job.exhausted) return true; + if (!job.type.isHistory) return true; + return _mediaKey(job.body) != key; + }) + .toList(growable: true); + } + if (type != TraktSyncJobType.scrobbleStop) return List.of(jobs); + return jobs + .where((job) { + if (job.exhausted) return true; + if (job.type == TraktSyncJobType.scrobbleStop) return true; + return _mediaKey(job.body) != key; + }) + .toList(growable: true); + } + + static String? _mediaKey(Map body) { + final movie = body['movie']; + if (movie is Map) { + return 'movie:${jsonEncode(_canonical(movie.cast()))}'; + } + final movies = body['movies']; + if (movies is List && movies.isNotEmpty) { + final first = movies.first; + if (first is Map) { + return 'movie:${jsonEncode(_canonical(first, skipWatchedAt: true))}'; + } + } + final show = body['show']; + final episode = body['episode']; + if (show is Map && episode is Map) { + final identity = { + 'show': _canonical(show.cast()), + 'episode': _canonical(episode.cast()), + }; + return 'episode:${jsonEncode(identity)}'; + } + final shows = body['shows']; + if (shows is List && shows.isNotEmpty) { + final first = shows.first; + if (first is Map) { + return 'show:${jsonEncode(_canonical(first, skipWatchedAt: true))}'; + } + } + return null; + } + + static Object? _canonical(Object? value, {bool skipWatchedAt = false}) { + if (value is Map) { + var filtered = value.entries; + if (skipWatchedAt) { + filtered = filtered.where((e) => e.key.toString() != 'watched_at'); + } + final entries = + filtered + .map( + (e) => MapEntry( + e.key.toString(), + _canonical(e.value, skipWatchedAt: skipWatchedAt), + ), + ) + .toList() + ..sort((a, b) => a.key.compareTo(b.key)); + return { + for (final entry in entries) entry.key: entry.value, + }; + } + if (value is List) { + return value + .map((e) => _canonical(e, skipWatchedAt: skipWatchedAt)) + .toList(growable: false); + } + return value; + } +} + + +class JsonTraktSyncJobStore { + JsonTraktSyncJobStore({required this._file}); + + final File _file; + + Future> load() async { + if (!await _file.exists()) return []; + final raw = await _file.readAsString(); + if (raw.trim().isEmpty) return []; + try { + final list = jsonDecode(raw) as List; + return list + .whereType>() + .map(TraktSyncJob.fromJson) + .toList(); + } catch (_) { + return []; + } + } + + Future save(List jobs) async { + if (!await _file.parent.exists()) { + await _file.parent.create(recursive: true); + } + await _file.writeAsString( + const JsonEncoder.withIndent( + ' ', + ).convert(jobs.map((e) => e.toJson()).toList()), + flush: true, + ); + } +} diff --git a/lib/core/infra/trakt_api/trakt_rest_api.dart b/lib/core/infra/trakt_api/trakt_rest_api.dart new file mode 100644 index 0000000..229d9ec --- /dev/null +++ b/lib/core/infra/trakt_api/trakt_rest_api.dart @@ -0,0 +1,50 @@ +import 'package:dio/dio.dart'; + +import '../../contracts/trakt/trakt_token.dart'; +import '../../contracts/trakt/trakt_user.dart'; +import 'trakt_credentials.dart'; + + +class TraktRestApi { + TraktRestApi(this._dio) { + _dio.options.baseUrl = _dio.options.baseUrl.isEmpty + ? TraktCredentials.apiBaseUrl + : _dio.options.baseUrl; + } + + final Dio _dio; + + + Future exchangeToken(Map body) async { + final response = await _dio.post('/oauth/token', data: body); + return TraktToken.fromJson(_jsonObject(response.data)); + } + + + Future getUserSettings(String authorization) async { + final response = await _dio.get( + '/users/settings', + options: Options(headers: {'Authorization': authorization}), + ); + return TraktUserSettings.fromJson(_jsonObject(response.data)); + } + + Map _jsonObject(dynamic data) { + if (data is Map) return data; + if (data is Map) return Map.from(data); + throw FormatException( + 'Trakt response is not a JSON object: ${data.runtimeType}', + ); + } +} + + +class TraktHeaderInterceptor extends Interceptor { + @override + void onRequest(RequestOptions options, RequestInterceptorHandler handler) { + options.headers['trakt-api-key'] = TraktCredentials.clientId; + options.headers['trakt-api-version'] = TraktCredentials.apiVersion; + options.headers['Content-Type'] = 'application/json'; + handler.next(options); + } +} diff --git a/lib/core/infra/trakt_api/trakt_retry_policy.dart b/lib/core/infra/trakt_api/trakt_retry_policy.dart new file mode 100644 index 0000000..669a0fa --- /dev/null +++ b/lib/core/infra/trakt_api/trakt_retry_policy.dart @@ -0,0 +1,74 @@ +import '../../contracts/trakt/trakt_response.dart'; + +enum TraktRetryDecision { + + complete, + + + discard, + + + retry, + + + giveUp, +} + +class TraktRetryOutcome { + final TraktRetryDecision decision; + final Duration? delay; + const TraktRetryOutcome(this.decision, {this.delay}); +} + + +class TraktRetryPolicy { + TraktRetryPolicy({ + this.maxRetries = 8, + this.baseBackoff = const Duration(seconds: 5), + this.maxBackoff = const Duration(minutes: 30), + }); + + + final int maxRetries; + final Duration baseBackoff; + final Duration maxBackoff; + + TraktRetryOutcome evaluate({ + required TraktResponseClass cls, + required int retryCount, + Duration? retryAfter, + }) { + switch (cls) { + case TraktResponseClass.success: + case TraktResponseClass.duplicate: + return const TraktRetryOutcome(TraktRetryDecision.complete); + case TraktResponseClass.invalid: + return const TraktRetryOutcome(TraktRetryDecision.discard); + case TraktResponseClass.rateLimited: + if (retryCount >= maxRetries) { + return const TraktRetryOutcome(TraktRetryDecision.giveUp); + } + return TraktRetryOutcome( + TraktRetryDecision.retry, + delay: retryAfter ?? backoff(retryCount), + ); + case TraktResponseClass.unauthorized: + case TraktResponseClass.transient: + if (retryCount >= maxRetries) { + return const TraktRetryOutcome(TraktRetryDecision.giveUp); + } + return TraktRetryOutcome( + TraktRetryDecision.retry, + delay: backoff(retryCount), + ); + } + } + + + Duration backoff(int retryCount) { + final shift = retryCount.clamp(0, 20); + final millis = baseBackoff.inMilliseconds * (1 << shift); + if (millis >= maxBackoff.inMilliseconds || millis < 0) return maxBackoff; + return Duration(milliseconds: millis); + } +} diff --git a/lib/core/infra/trakt_api/trakt_sync_service.dart b/lib/core/infra/trakt_api/trakt_sync_service.dart new file mode 100644 index 0000000..b3b4a01 --- /dev/null +++ b/lib/core/infra/trakt_api/trakt_sync_service.dart @@ -0,0 +1,236 @@ +import '../../../shared/utils/app_logger.dart'; +import '../../contracts/library.dart'; +import '../../contracts/trakt/trakt_ids.dart'; +import '../../contracts/trakt/trakt_response.dart'; +import '../../contracts/trakt/trakt_scrobble_target.dart'; +import '../../contracts/trakt/trakt_sync_job.dart'; +import '../../domain/ports/authed_trakt_gateway.dart'; +import 'trakt_media_matcher.dart'; +import 'trakt_pending_sync_queue.dart'; + +const _tag = 'TraktSync'; + + +class TraktSyncService { + TraktSyncService({required this._traktGateway, required this._queue}); + + final AuthedTraktGateway _traktGateway; + final TraktPendingSyncQueue _queue; + + + Future syncEmbyPlayedChange({ + required EmbyRawItem item, + required bool played, + EmbyRawItem? seriesItem, + }) async { + try { + final target = TraktMediaMatcher.match( + itemType: item.Type, + itemName: item.Name, + productionYear: item.ProductionYear, + providerIds: providerIdsOfItem(item), + seriesProviderIds: seriesItem == null + ? const {} + : providerIdsOfItem(seriesItem), + seriesName: item.SeriesName ?? seriesItem?.Name, + seriesProductionYear: seriesItem?.ProductionYear, + parentIndexNumber: (item.extra['ParentIndexNumber'] as num?)?.toInt(), + itemIndexNumber: item.IndexNumber, + ); + if (target == null) return; + + switch (target.kind) { + case TraktMediaKind.movie: + if (played) { + await markWatched( + ids: target.ids, + title: target.title, + year: target.year, + ); + } else { + await markUnwatched( + ids: target.ids, + title: target.title, + year: target.year, + ); + } + case TraktMediaKind.episode: + if (played) { + await markWatched( + ids: target.episodeIds, + title: item.Name, + year: item.ProductionYear, + season: target.season, + episode: target.number, + showIds: target.ids, + showTitle: target.title, + showYear: target.year, + ); + } else { + await markUnwatched( + ids: target.episodeIds, + title: item.Name, + year: item.ProductionYear, + season: target.season, + episode: target.number, + showIds: target.ids, + showTitle: target.title, + showYear: target.year, + ); + } + } + } catch (e) { + AppLogger.warn(_tag, 'syncEmbyPlayedChange failed', e); + } + } + + + Future markWatched({ + required TraktIds ids, + required String title, + required int? year, + DateTime? watchedAt, + int? season, + int? episode, + TraktIds? showIds, + String? showTitle, + int? showYear, + }) async { + final body = _buildHistoryBody( + ids: ids, + title: title, + year: year, + watchedAt: watchedAt ?? DateTime.now().toUtc(), + season: season, + episode: episode, + showIds: showIds, + showTitle: showTitle, + showYear: showYear, + ); + + AppLogger.debug(_tag, 'markWatched: $title'); + + await _sendHistory(TraktSyncJobType.historyAdd, body, label: 'markWatched'); + } + + + Future markUnwatched({ + required TraktIds ids, + required String title, + required int? year, + int? season, + int? episode, + TraktIds? showIds, + String? showTitle, + int? showYear, + }) async { + final body = _buildHistoryBody( + ids: ids, + title: title, + year: year, + watchedAt: null, + season: season, + episode: episode, + showIds: showIds, + showTitle: showTitle, + showYear: showYear, + ); + + AppLogger.debug(_tag, 'markUnwatched: $title'); + + await _sendHistory( + TraktSyncJobType.historyRemove, + body, + label: 'markUnwatched', + ); + } + + + Future _sendHistory( + TraktSyncJobType type, + Map body, { + required String label, + }) async { + TraktScrobbleOutcome outcome; + try { + outcome = await _traktGateway.syncHistory(type, body); + } catch (e) { + AppLogger.debug(_tag, '$label threw; enqueue', e); + await _enqueue(type, body); + return; + } + + switch (outcome.cls) { + case TraktResponseClass.success: + case TraktResponseClass.duplicate: + AppLogger.debug(_tag, '$label ok (${outcome.cls.name})'); + return; + case TraktResponseClass.invalid: + AppLogger.debug(_tag, '$label invalid → drop'); + return; + case TraktResponseClass.rateLimited: + case TraktResponseClass.unauthorized: + case TraktResponseClass.transient: + await _enqueue(type, body, retryAfter: outcome.retryAfter); + } + } + + Map _buildHistoryBody({ + required TraktIds ids, + required String title, + required int? year, + required DateTime? watchedAt, + int? season, + int? episode, + TraktIds? showIds, + String? showTitle, + int? showYear, + }) { + final isEpisode = season != null && episode != null; + + if (!isEpisode) { + final movie = {'title': title, 'ids': ids.toJson()}; + if (year != null) movie['year'] = year; + if (watchedAt != null) movie['watched_at'] = watchedAt.toIso8601String(); + return { + 'movies': [movie], + 'shows': [], + 'episodes': [], + }; + } + + final show = { + 'title': showTitle ?? title, + 'ids': (showIds ?? const TraktIds()).toJson(), + }; + if (showYear != null) show['year'] = showYear; + + final epNode = {'number': episode}; + if (watchedAt != null) epNode['watched_at'] = watchedAt.toIso8601String(); + + show['seasons'] = [ + { + 'number': season, + 'episodes': [epNode], + }, + ]; + + return { + 'movies': [], + 'shows': [show], + 'episodes': [], + }; + } + + Future _enqueue( + TraktSyncJobType type, + Map body, { + Duration? retryAfter, + }) async { + try { + await _queue.enqueue(type: type, body: body, retryAfter: retryAfter); + } catch (e) { + AppLogger.warn(_tag, 'enqueue failed', e); + } + } +} diff --git a/lib/core/infra/trakt_api/trakt_token_store.dart b/lib/core/infra/trakt_api/trakt_token_store.dart new file mode 100644 index 0000000..1fafb3a --- /dev/null +++ b/lib/core/infra/trakt_api/trakt_token_store.dart @@ -0,0 +1,46 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +import '../../contracts/trakt/trakt_token.dart'; + + +class TraktTokenStore { + static const keyAccessToken = 'smplayer.trakt_access_token'; + static const keyRefreshToken = 'smplayer.trakt_refresh_token'; + static const keyExpiresIn = 'smplayer.trakt_expires_in'; + static const keyCreatedAt = 'smplayer.trakt_created_at'; + + Future _prefs() => SharedPreferences.getInstance(); + + + Future read() async { + final p = await _prefs(); + final access = p.getString(keyAccessToken)?.trim() ?? ''; + if (access.isEmpty) return null; + return TraktToken( + accessToken: access, + refreshToken: p.getString(keyRefreshToken)?.trim() ?? '', + expiresIn: p.getInt(keyExpiresIn) ?? 0, + createdAt: p.getInt(keyCreatedAt) ?? 0, + ); + } + + Future write(TraktToken token) async { + final p = await _prefs(); + await Future.wait([ + p.setString(keyAccessToken, token.accessToken), + p.setString(keyRefreshToken, token.refreshToken), + p.setInt(keyExpiresIn, token.expiresIn), + p.setInt(keyCreatedAt, token.createdAt), + ]); + } + + Future clear() async { + final p = await _prefs(); + await Future.wait([ + p.remove(keyAccessToken), + p.remove(keyRefreshToken), + p.remove(keyExpiresIn), + p.remove(keyCreatedAt), + ]); + } +} diff --git a/lib/core/media/danmaku_comment_merger.dart b/lib/core/media/danmaku_comment_merger.dart new file mode 100644 index 0000000..0abcfb2 --- /dev/null +++ b/lib/core/media/danmaku_comment_merger.dart @@ -0,0 +1,55 @@ +import '../contracts/danmaku.dart'; + +const defaultDuplicateDanmakuMergeWindowSeconds = 1.0; + +final _whitespacePattern = RegExp(r'\s+'); + +List mergeDuplicateDanmakuComments( + Iterable comments, { + double windowSeconds = defaultDuplicateDanmakuMergeWindowSeconds, +}) { + if (windowSeconds < 0) { + throw ArgumentError.value( + windowSeconds, + 'windowSeconds', + 'must be non-negative', + ); + } + + final sorted = comments.toList()..sort(_compareDanmakuComments); + if (sorted.length < 2) return sorted; + + + final keptByText = >{}; + final passthrough = []; + + for (final comment in sorted) { + final key = _mergeKey(comment); + if (key.isEmpty) { + passthrough.add(comment); + continue; + } + + final kept = keptByText.putIfAbsent(key, () => []); + final windowStart = kept.isEmpty ? null : kept.last.time; + if (windowStart == null || comment.time - windowStart > windowSeconds) { + kept.add(comment); + } + } + + return [ + ...passthrough, + for (final kept in keptByText.values) ...kept, + ]..sort(_compareDanmakuComments); +} + +int _compareDanmakuComments(DanmakuComment a, DanmakuComment b) { + final timeOrder = a.time.compareTo(b.time); + if (timeOrder != 0) return timeOrder; + return a.cid.compareTo(b.cid); +} + +String _mergeKey(DanmakuComment comment) { + return comment.text.trim().replaceAll(_whitespacePattern, ' '); +} + diff --git a/lib/core/media/danmaku_comment_pipeline.dart b/lib/core/media/danmaku_comment_pipeline.dart new file mode 100644 index 0000000..36275bf --- /dev/null +++ b/lib/core/media/danmaku_comment_pipeline.dart @@ -0,0 +1,35 @@ +import '../contracts/danmaku.dart'; +import 'danmaku_comment_merger.dart'; + + +List processDanmakuComments( + List comments, { + required List blockedKeywords, + required bool mergeDuplicates, +}) { + final visible = blockedKeywords.isEmpty + ? comments + : comments + .where((c) => blockedKeywords.every((kw) => !c.text.contains(kw))) + .toList(); + final processed = mergeDuplicates + ? mergeDuplicateDanmakuComments(visible) + : List.of(visible); + processed.sort((a, b) => a.time.compareTo(b.time)); + return processed; +} + + +int lowerBoundByTime(List sortedByTime, double posSec) { + var lo = 0; + var hi = sortedByTime.length; + while (lo < hi) { + final mid = (lo + hi) >> 1; + if (sortedByTime[mid].time < posSec) { + lo = mid + 1; + } else { + hi = mid; + } + } + return lo; +} diff --git a/lib/core/media/danmaku_episode_parser.dart b/lib/core/media/danmaku_episode_parser.dart new file mode 100644 index 0000000..d346111 --- /dev/null +++ b/lib/core/media/danmaku_episode_parser.dart @@ -0,0 +1,16 @@ +int? extractEpisodeNumberFromTitle(String title) { + for (final p in _episodeTitlePatterns) { + final m = p.firstMatch(title); + if (m != null) { + final n = int.tryParse(m.group(1)!); + if (n != null) return n; + } + } + return null; +} + +final List _episodeTitlePatterns = [ + RegExp(r'第\s*0*(\d+)\s*[话集話]'), + RegExp(r'\bS\d+E0*(\d+)\b', caseSensitive: false), + RegExp(r'\bEP?\s*0*(\d+)\b', caseSensitive: false), +]; diff --git a/lib/core/media/danmaku_heatmap.dart b/lib/core/media/danmaku_heatmap.dart new file mode 100644 index 0000000..e800020 --- /dev/null +++ b/lib/core/media/danmaku_heatmap.dart @@ -0,0 +1,111 @@ +import '../contracts/danmaku.dart'; + + +const int kDefaultHeatmapMinComments = 50; + + +const int kDefaultHeatmapBucketCount = 200; + + +class DanmakuHeatmapData { + final List buckets; + final int bucketCount; + final int totalComments; + final int maxCount; + final bool visible; + + const DanmakuHeatmapData({ + required this.buckets, + required this.bucketCount, + required this.totalComments, + required this.maxCount, + required this.visible, + }); + + static const empty = DanmakuHeatmapData( + buckets: [], + bucketCount: 0, + totalComments: 0, + maxCount: 0, + visible: false, + ); +} + + +DanmakuHeatmapData computeHeatmap({ + required List comments, + required double durationSec, + int bucketCount = kDefaultHeatmapBucketCount, + int minComments = kDefaultHeatmapMinComments, +}) { + if (bucketCount <= 0) return DanmakuHeatmapData.empty; + if (durationSec <= 0) return DanmakuHeatmapData.empty; + + final counts = List.filled(bucketCount, 0); + int total = 0; + for (final c in comments) { + final t = c.time; + if (t < 0 || t > durationSec) continue; + var idx = (t / durationSec * bucketCount).floor(); + if (idx >= bucketCount) idx = bucketCount - 1; + if (idx < 0) idx = 0; + counts[idx]++; + total++; + } + + if (total < minComments) { + return DanmakuHeatmapData( + buckets: const [], + bucketCount: bucketCount, + totalComments: total, + maxCount: 0, + visible: false, + ); + } + + var maxCount = 0; + for (final v in counts) { + if (v > maxCount) maxCount = v; + } + if (maxCount <= 0) { + return DanmakuHeatmapData( + buckets: const [], + bucketCount: bucketCount, + totalComments: total, + maxCount: 0, + visible: false, + ); + } + + final buckets = List.generate( + bucketCount, + (i) => counts[i] / maxCount, + growable: false, + ); + + return DanmakuHeatmapData( + buckets: buckets, + bucketCount: bucketCount, + totalComments: total, + maxCount: maxCount, + visible: true, + ); +} + + +List downsampleHeatmap(List buckets, int targetCount) { + if (buckets.isEmpty || targetCount <= 0) return const []; + if (targetCount >= buckets.length) return buckets; + final out = List.filled(targetCount, 0.0); + final src = buckets.length; + for (var i = 0; i < targetCount; i++) { + final start = (i * src / targetCount).floor(); + final end = ((i + 1) * src / targetCount).floor().clamp(start + 1, src); + var m = 0.0; + for (var j = start; j < end; j++) { + if (buckets[j] > m) m = buckets[j]; + } + out[i] = m; + } + return out; +} diff --git a/lib/core/media/danmaku_match_picker.dart b/lib/core/media/danmaku_match_picker.dart new file mode 100644 index 0000000..fa5349b --- /dev/null +++ b/lib/core/media/danmaku_match_picker.dart @@ -0,0 +1,12 @@ +import '../contracts/danmaku.dart'; +import 'danmaku_episode_parser.dart'; + + +int pickBestMatchIndex(List matches, int? targetEpisode) { + if (targetEpisode == null) return 0; + for (var i = 0; i < matches.length; i++) { + final n = extractEpisodeNumberFromTitle(matches[i].episodeTitle); + if (n == targetEpisode) return i; + } + return 0; +} diff --git a/lib/core/media/embedded_subtitle_cues.dart b/lib/core/media/embedded_subtitle_cues.dart new file mode 100644 index 0000000..0f9099e --- /dev/null +++ b/lib/core/media/embedded_subtitle_cues.dart @@ -0,0 +1,68 @@ + +class TimedSubtitle { + final Duration start; + final Duration end; + final String text; + + const TimedSubtitle({ + required this.start, + required this.end, + required this.text, + }); +} + + +class EmbeddedSubtitleCues { + final List _cues = []; + + bool get isEmpty => _cues.isEmpty; + + + void add(double startSec, double endSec, List lines) { + final text = lines + .map((s) => s.trim()) + .where((s) => s.isNotEmpty) + .join('\n'); + if (text.isEmpty) return; + + final start = _toDuration(startSec); + final end = _toDuration(endSec); + if (end < start) return; + + final at = _lowerBound(start); + for (var i = at; i < _cues.length && _cues[i].start == start; i++) { + if (_cues[i].end == end && _cues[i].text == text) return; + } + _cues.insert(at, TimedSubtitle(start: start, end: end, text: text)); + } + + + List textAt(Duration position) { + final out = []; + for (final cue in _cues) { + if (position < cue.start) break; + if (position <= cue.end) out.addAll(cue.text.split('\n')); + } + return out; + } + + void clear() => _cues.clear(); + + static Duration _toDuration(double seconds) => + Duration(milliseconds: (seconds * 1000).round()); + + + int _lowerBound(Duration start) { + int lo = 0; + int hi = _cues.length; + while (lo < hi) { + final mid = (lo + hi) >> 1; + if (_cues[mid].start < start) { + lo = mid + 1; + } else { + hi = mid; + } + } + return lo; + } +} diff --git a/lib/core/media/external_subtitle_loader.dart b/lib/core/media/external_subtitle_loader.dart new file mode 100644 index 0000000..0de0f4f --- /dev/null +++ b/lib/core/media/external_subtitle_loader.dart @@ -0,0 +1,394 @@ +import 'dart:convert'; + +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; + +import '../../shared/utils/app_logger.dart'; +import 'embedded_subtitle_cues.dart'; + +const _tag = 'ExternalSubtitleLoader'; +const _backgroundParseThresholdBytes = 256 * 1024; + + +class ExternalSubtitleLoadException implements Exception { + final String message; + final Object? cause; + + const ExternalSubtitleLoadException(this.message, [this.cause]); + + @override + String toString() => + 'ExternalSubtitleLoadException: $message' + '${cause != null ? ' ($cause)' : ''}'; +} + + +class ExternalSubtitleLoader { + final Dio _dio; + final Map> _cacheByUrl = {}; + final Map>> _inflightByUrl = {}; + + ExternalSubtitleLoader({Dio? dio}) + : _dio = + dio ?? + Dio( + BaseOptions( + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 20), + responseType: ResponseType.bytes, + ), + ); + + + Future> load( + String url, { + Map? headers, + Duration? receiveTimeout, + }) { + final cached = _cacheByUrl[url]; + if (cached != null) return Future.value(cached); + + final inflight = _inflightByUrl[url]; + if (inflight != null) return inflight; + + final future = _download(url, headers, receiveTimeout).whenComplete(() { + _inflightByUrl.remove(url); + }); + _inflightByUrl[url] = future; + return future; + } + + Future> _download( + String url, + Map? headers, + Duration? receiveTimeout, + ) async { + List bytes; + try { + final response = await _dio.get>( + url, + options: Options( + headers: headers, + responseType: ResponseType.bytes, + receiveTimeout: receiveTimeout, + ), + ); + bytes = response.data ?? const []; + } on DioException catch (error) { + AppLogger.warn( + _tag, + 'download failed url=$url status=${error.response?.statusCode} ' + 'type=${error.type}', + ); + throw ExternalSubtitleLoadException('外挂字幕下载失败', error); + } + if (bytes.isEmpty) { + AppLogger.warn(_tag, 'download returned empty body url=$url'); + throw const ExternalSubtitleLoadException('外挂字幕内容为空'); + } + + final content = _decodeText(bytes); + final cues = bytes.length >= _backgroundParseThresholdBytes + ? await compute(parseSubtitleContent, content) + : parseSubtitleContent(content); + if (cues.isEmpty) { + AppLogger.warn( + _tag, + 'parse produced no cues url=$url bytes=${bytes.length}', + ); + throw const ExternalSubtitleLoadException('外挂字幕解析结果为空'); + } + _cacheByUrl[url] = cues; + AppLogger.debug(_tag, 'loaded url=$url cues=${cues.length}'); + return cues; + } + + void clear() { + _cacheByUrl.clear(); + _inflightByUrl.clear(); + } + + + static String _decodeText(List bytes) { + var start = 0; + if (bytes.length >= 3 && + bytes[0] == 0xEF && + bytes[1] == 0xBB && + bytes[2] == 0xBF) { + start = 3; + } + final body = start == 0 ? bytes : bytes.sublist(start); + try { + return utf8.decode(body); + } on FormatException { + return latin1.decode(body); + } + } + + + static List parseSubtitleContent(String content) { + final normalized = content.replaceAll('\r\n', '\n').replaceAll('\r', '\n'); + final trimmed = normalized.trimLeft(); + if (trimmed.startsWith('WEBVTT')) { + return _parseVtt(normalized); + } + if (trimmed.startsWith('[Script Info]') || + normalized.contains('\nDialogue:') || + trimmed.startsWith('Dialogue:')) { + return _parseAss(normalized); + } + return _parseSrt(normalized); + } + + + static final RegExp _srtTimeLine = RegExp( + r'(\d{1,2}):(\d{2}):(\d{2})[,.](\d{1,3})\s*-->\s*(\d{1,2}):(\d{2}):(\d{2})[,.](\d{1,3})', + ); + + static List _parseSrt(String content) { + final cues = []; + final blocks = content.split(RegExp(r'\n{2,}')); + for (final block in blocks) { + final lines = block + .split('\n') + .where((line) => line.trim().isNotEmpty) + .toList(); + if (lines.isEmpty) continue; + + var timeLineIndex = -1; + RegExpMatch? timeMatch; + for (var i = 0; i < lines.length; i++) { + timeMatch = _srtTimeLine.firstMatch(lines[i]); + if (timeMatch != null) { + timeLineIndex = i; + break; + } + } + if (timeMatch == null) continue; + + final textLines = lines + .sublist(timeLineIndex + 1) + .map(_stripHtmlTags) + .where((line) => line.trim().isNotEmpty) + .toList(); + if (textLines.isEmpty) continue; + + cues.add( + TimedSubtitle( + start: _timeFromMatch(timeMatch, 1), + end: _timeFromMatch(timeMatch, 5), + text: textLines.join('\n'), + ), + ); + } + return _sortAndValidate(cues); + } + + static List _parseVtt(String content) { + final vttTimeLine = RegExp( + r'(?:(\d{1,2}):)?(\d{2}):(\d{2})\.(\d{3})\s*-->\s*(?:(\d{1,2}):)?(\d{2}):(\d{2})\.(\d{3})', + ); + final cues = []; + final blocks = content.split(RegExp(r'\n{2,}')); + for (final block in blocks) { + final lines = block + .split('\n') + .where((line) => line.trim().isNotEmpty) + .toList(); + if (lines.isEmpty) continue; + final first = lines.first.trim(); + if (first.startsWith('WEBVTT') || + first.startsWith('NOTE') || + first.startsWith('STYLE') || + first.startsWith('REGION')) { + continue; + } + + var timeLineIndex = -1; + RegExpMatch? timeMatch; + for (var i = 0; i < lines.length; i++) { + timeMatch = vttTimeLine.firstMatch(lines[i]); + if (timeMatch != null) { + timeLineIndex = i; + break; + } + } + if (timeMatch == null) continue; + + final textLines = lines + .sublist(timeLineIndex + 1) + .map(_stripHtmlTags) + .where((line) => line.trim().isNotEmpty) + .toList(); + if (textLines.isEmpty) continue; + + cues.add( + TimedSubtitle( + start: _vttTimeFromMatch(timeMatch, 1), + end: _vttTimeFromMatch(timeMatch, 5), + text: textLines.join('\n'), + ), + ); + } + return _sortAndValidate(cues); + } + + + static final RegExp _assTime = RegExp(r'^(\d+):(\d{2}):(\d{2})\.(\d{2})$'); + + static List _parseAss(String content) { + final cues = []; + var startFieldIndex = 1; + var endFieldIndex = 2; + var textFieldIndex = 9; + + for (final rawLine in content.split('\n')) { + final line = rawLine.trim(); + if (line.startsWith('Format:')) { + final fields = line + .substring('Format:'.length) + .split(',') + .map((field) => field.trim().toLowerCase()) + .toList(); + final start = fields.indexOf('start'); + final end = fields.indexOf('end'); + final text = fields.indexOf('text'); + if (start >= 0 && end >= 0 && text >= 0) { + startFieldIndex = start; + endFieldIndex = end; + textFieldIndex = text; + } + continue; + } + if (!line.startsWith('Dialogue:')) continue; + + final payload = line.substring('Dialogue:'.length).trim(); + final parts = payload.split(','); + if (parts.length <= textFieldIndex) continue; + final startRaw = parts[startFieldIndex].trim(); + final endRaw = parts[endFieldIndex].trim(); + final textRaw = parts.sublist(textFieldIndex).join(','); + + final start = _parseAssTime(startRaw); + final end = _parseAssTime(endRaw); + if (start == null || end == null) continue; + + final text = _cleanAssText(textRaw); + if (text.isEmpty) continue; + + cues.add(TimedSubtitle(start: start, end: end, text: text)); + } + return _sortAndValidate(cues); + } + + static Duration? _parseAssTime(String raw) { + final match = _assTime.firstMatch(raw); + if (match == null) return null; + return Duration( + hours: int.parse(match.group(1)!), + minutes: int.parse(match.group(2)!), + seconds: int.parse(match.group(3)!), + milliseconds: int.parse(match.group(4)!) * 10, + ); + } + + + static String _cleanAssText(String raw) { + var text = raw.replaceAll(RegExp(r'\{[^}]*\}'), ''); + text = text + .replaceAll(r'\N', '\n') + .replaceAll(r'\n', '\n') + .replaceAll(r'\h', ' '); + return text + .split('\n') + .map((line) => line.trim()) + .where((line) => line.isNotEmpty) + .join('\n') + .trim(); + } + + static String _stripHtmlTags(String line) => + line.replaceAll(RegExp(r']+>'), ''); + + static Duration _timeFromMatch(RegExpMatch match, int groupOffset) { + final millisRaw = match.group(groupOffset + 3)!; + final millis = int.parse(millisRaw.padRight(3, '0')); + return Duration( + hours: int.parse(match.group(groupOffset)!), + minutes: int.parse(match.group(groupOffset + 1)!), + seconds: int.parse(match.group(groupOffset + 2)!), + milliseconds: millis, + ); + } + + static Duration _vttTimeFromMatch(RegExpMatch match, int groupOffset) { + return Duration( + hours: int.parse(match.group(groupOffset) ?? '0'), + minutes: int.parse(match.group(groupOffset + 1)!), + seconds: int.parse(match.group(groupOffset + 2)!), + milliseconds: int.parse(match.group(groupOffset + 3)!), + ); + } + + + static List _sortAndValidate(List cues) { + final valid = cues.where((cue) => cue.end >= cue.start).toList() + ..sort((a, b) => a.start.compareTo(b.start)); + return valid; + } +} + + +class SubtitleCueSource { + final List _cues; + final List _prefixMaximumEnd; + + SubtitleCueSource(List sortedCues) + : _cues = sortedCues, + _prefixMaximumEnd = _buildPrefixMaximumEnd(sortedCues); + + bool get isEmpty => _cues.isEmpty; + + + List textAt(Duration position) { + final firstPossibleIndex = _firstCueThatMayOverlap(position); + if (firstPossibleIndex >= _cues.length) return const []; + + final lines = []; + for ( + var cueIndex = firstPossibleIndex; + cueIndex < _cues.length; + cueIndex++ + ) { + final cue = _cues[cueIndex]; + if (position < cue.start) break; + if (position <= cue.end) lines.addAll(cue.text.split('\n')); + } + return lines; + } + + + int _firstCueThatMayOverlap(Duration position) { + var lowerBound = 0; + var upperBound = _prefixMaximumEnd.length; + while (lowerBound < upperBound) { + final middleIndex = (lowerBound + upperBound) >> 1; + if (_prefixMaximumEnd[middleIndex] < position) { + lowerBound = middleIndex + 1; + } else { + upperBound = middleIndex; + } + } + return lowerBound; + } + + static List _buildPrefixMaximumEnd(List sortedCues) { + final prefixMaximumEnd = []; + var maximumEnd = Duration.zero; + for (final cue in sortedCues) { + if (cue.end > maximumEnd) maximumEnd = cue.end; + prefixMaximumEnd.add(maximumEnd); + } + return prefixMaximumEnd; + } +} diff --git a/lib/core/media/media_capability_analyzer.dart b/lib/core/media/media_capability_analyzer.dart new file mode 100644 index 0000000..2af527c --- /dev/null +++ b/lib/core/media/media_capability_analyzer.dart @@ -0,0 +1,170 @@ +import '../contracts/library.dart'; +import '../../shared/utils/app_logger.dart'; +import 'media_range_detector.dart'; + +enum VideoRangeCategory { dolbyVision, hdr10, hlg, sdr, unknown } + +class MediaCapability { + final VideoRangeCategory rangeCategory; + final String? codec; + + const MediaCapability({required this.rangeCategory, this.codec}); +} + +class ProbeResult { + final String? videoCodec; + final String? videoRange; + final String? videoRangeType; + + const ProbeResult({this.videoCodec, this.videoRange, this.videoRangeType}); +} + +class MediaCapabilityAnalyzer { + static const _tag = 'MediaCap'; + + static MediaCapability analyze( + EmbyRawMediaSource? source, { + ProbeResult? probeResult, + }) { + if (source == null && probeResult == null) { + AppLogger.debug(_tag, 'analyze: source=null probeResult=null -> unknown'); + return const MediaCapability(rangeCategory: VideoRangeCategory.unknown); + } + + final codec = _resolveCodec(source, probeResult); + final videoRange = _resolveVideoRange(source, probeResult); + final videoRangeType = _resolveVideoRangeType(source, probeResult); + + final isDV = _checkDolbyVision( + source, + probeResult, + codec, + videoRange, + videoRangeType, + ); + final isHdrDetected = _checkHdr( + source, + probeResult, + videoRange, + videoRangeType, + ); + + final VideoRangeCategory rangeCategory; + if (isDV) { + rangeCategory = VideoRangeCategory.dolbyVision; + } else if (isHdrDetected) { + rangeCategory = _classifyHdr(videoRangeType, videoRange); + } else { + rangeCategory = VideoRangeCategory.sdr; + } + + AppLogger.debug( + _tag, + 'analyze: codec=$codec ' + 'videoRange=$videoRange videoRangeType=$videoRangeType ' + '-> rangeCategory=${rangeCategory.name}', + ); + + return MediaCapability(rangeCategory: rangeCategory, codec: codec); + } + + static String? _resolveCodec(EmbyRawMediaSource? source, ProbeResult? probe) { + if (probe?.videoCodec != null) return probe!.videoCodec; + if (source == null) return null; + final video = findVideoStream(source); + return video?.Codec; + } + + static String? _resolveVideoRange( + EmbyRawMediaSource? source, + ProbeResult? probe, + ) { + if (probe?.videoRange != null) return probe!.videoRange; + if (source == null) return null; + final video = findVideoStream(source); + return video?.extra['VideoRange'] as String?; + } + + static String? _resolveVideoRangeType( + EmbyRawMediaSource? source, + ProbeResult? probe, + ) { + if (probe?.videoRangeType != null) return probe!.videoRangeType; + if (source == null) return null; + final video = findVideoStream(source); + return video?.extra['VideoRangeType'] as String?; + } + + static bool _checkDolbyVision( + EmbyRawMediaSource? source, + ProbeResult? probe, + String? codec, + String? videoRange, + String? videoRangeType, + ) { + if (probe != null) { + final lCodec = codec?.toLowerCase() ?? ''; + final lRange = videoRange?.toLowerCase() ?? ''; + final lRangeType = videoRangeType?.toLowerCase() ?? ''; + + if (lRangeType.contains('dovi') || lRangeType.contains('dolby')) { + return true; + } + if (lRange.contains('dovi') || lRange.contains('dolby')) return true; + if (lCodec.contains('dvh') || lCodec.contains('dovi')) return true; + + if (videoRange != null || videoRangeType != null) return false; + } + if (source != null) return isDolbyVision(source); + return false; + } + + static bool _checkHdr( + EmbyRawMediaSource? source, + ProbeResult? probe, + String? videoRange, + String? videoRangeType, + ) { + if (probe != null) { + final lRange = videoRange?.toLowerCase() ?? ''; + final lRangeType = videoRangeType?.toLowerCase() ?? ''; + final hasHdrSignal = + lRange.contains('hdr') || + lRange.contains('dovi') || + lRangeType.contains('hdr') || + lRangeType.contains('dovi') || + lRangeType.contains('hlg'); + + if (hasHdrSignal) return true; + + if (videoRange != null || videoRangeType != null) return false; + } + if (source != null) { + if (isHdr(source)) return true; + final video = findVideoStream(source); + if (video != null) { + final rangeType = (video.extra['VideoRangeType'] as String? ?? '') + .toLowerCase(); + if (rangeType.contains('hlg')) return true; + } + return false; + } + return false; + } + + static VideoRangeCategory _classifyHdr( + String? videoRangeType, + String? videoRange, + ) { + final lRangeType = videoRangeType?.toLowerCase() ?? ''; + if (lRangeType.contains('hlg')) return VideoRangeCategory.hlg; + if (lRangeType.contains('hdr10')) return VideoRangeCategory.hdr10; + if (lRangeType.contains('hdr')) return VideoRangeCategory.hdr10; + + final lRange = videoRange?.toLowerCase() ?? ''; + if (lRange.contains('hlg')) return VideoRangeCategory.hlg; + if (lRange.contains('hdr')) return VideoRangeCategory.hdr10; + + return VideoRangeCategory.unknown; + } +} diff --git a/lib/core/media/media_probe_service.dart b/lib/core/media/media_probe_service.dart new file mode 100644 index 0000000..5b28202 --- /dev/null +++ b/lib/core/media/media_probe_service.dart @@ -0,0 +1,75 @@ +import 'dart:async'; + +import 'package:fvp/mdk.dart' as mdk; + +import '../../shared/utils/app_logger.dart'; +import 'media_capability_analyzer.dart'; + + +class MediaProbeService { + static final MediaProbeService instance = MediaProbeService._(); + MediaProbeService._(); + + static const _tag = 'MediaProbe'; + + final Map _cache = {}; + + Future probe( + String url, { + Duration timeout = const Duration(seconds: 5), + }) async { + final cached = _cache[url]; + if (cached != null) { + AppLogger.debug(_tag, 'probe: cache hit url=${_truncate(url)}'); + return cached; + } + + AppLogger.debug(_tag, 'probe: start url=${_truncate(url)}'); + + mdk.Player? player; + try { + player = mdk.Player() + ..setProperty('avio.reconnect', '1') + ..setProperty('avformat.extension_picky', '0') + ..setProperty('avformat.allowed_segment_extensions', 'ALL'); + player.media = url; + + final ret = await player.prepare().timeout(timeout, onTimeout: () => -10); + + ProbeResult? result; + if (ret >= 0) { + final video = player.mediaInfo.video; + if (video != null && video.isNotEmpty) { + final codec = video.first.codec; + result = ProbeResult( + videoCodec: codec.codec.isNotEmpty ? codec.codec : null, + ); + } + } else { + AppLogger.warn( + _tag, + 'probe: prepare failed ret=$ret url=${_truncate(url)}', + ); + } + + if (result != null) { + _cache[url] = result; + AppLogger.debug( + _tag, + 'probe: success url=${_truncate(url)} codec=${result.videoCodec}', + ); + } + + return result; + } catch (e) { + AppLogger.warn(_tag, 'probe: error url=${_truncate(url)}', e); + return null; + } finally { + player?.dispose(); + } + } + + static String _truncate(String url) { + return url.length > 50 ? '${url.substring(0, 50)}...' : url; + } +} diff --git a/lib/core/media/media_range_detector.dart b/lib/core/media/media_range_detector.dart new file mode 100644 index 0000000..75bdeb6 --- /dev/null +++ b/lib/core/media/media_range_detector.dart @@ -0,0 +1,134 @@ +import '../contracts/library.dart'; +import '../../shared/utils/app_logger.dart'; + +EmbyRawMediaStream? findVideoStream(EmbyRawMediaSource source) { + final streams = source.MediaStreams; + if (streams == null) return null; + for (final stream in streams) { + if (stream.Type == 'Video') return stream; + } + return null; +} + +final RegExp _dvNameTokenRegex = RegExp(r'\bdv\b', caseSensitive: false); + +bool _nameLooksLikeDolbyVision(String? name) { + if (name == null || name.isEmpty) return false; + final lower = name.toLowerCase(); + if (lower.contains('dovi')) return true; + if (lower.contains('dolby vision')) return true; + if (_dvNameTokenRegex.hasMatch(name)) return true; + return false; +} + +bool _nameLooksLikeHdr(String? name) { + if (name == null || name.isEmpty) return false; + final lower = name.toLowerCase(); + if (lower.contains('hdr')) return true; + return _nameLooksLikeDolbyVision(name); +} + +bool isDolbyVision(EmbyRawMediaSource source) { + final video = findVideoStream(source); + final hasVideoStream = video != null; + final rawRange = video?.extra['VideoRange'] as String? ?? ''; + final rawRangeType = video?.extra['VideoRangeType'] as String? ?? ''; + final rawCodec = video?.Codec ?? ''; + final rawDisplayTitle = video?.DisplayTitle ?? ''; + final rawTitle = video?.Title ?? ''; + + bool result; + if (!hasVideoStream) { + result = false; + } else { + final range = rawRange.toLowerCase(); + final rangeType = rawRangeType.toLowerCase(); + final codec = rawCodec.toLowerCase(); + final displayTitle = rawDisplayTitle.toLowerCase(); + final title = rawTitle.toLowerCase(); + result = + rangeType.contains('dovi') || + rangeType.contains('dolby') || + range.contains('dovi') || + range.contains('dolby') || + codec.contains('dvh') || + codec.contains('dovi') || + displayTitle.contains('dovi') || + displayTitle.contains('dolby vision') || + title.contains('dovi') || + title.contains('dolby vision'); + } + + String fmt(String v) => v.isEmpty ? '' : v; + final sourceId = source.Id ?? ''; + AppLogger.debug( + 'DVDetect', + 'source=$sourceId hasVideo=$hasVideoStream ' + 'VideoRange="${fmt(rawRange)}" VideoRangeType="${fmt(rawRangeType)}" ' + 'Codec="${fmt(rawCodec)}" DisplayTitle="${fmt(rawDisplayTitle)}" ' + 'Title="${fmt(rawTitle)}" -> isDV=$result', + ); + + if (!result) { + final name = source.Name; + final fallback = _nameLooksLikeDolbyVision(name); + if (fallback) { + AppLogger.debug( + 'DVDetect', + 'name fallback hit: name="${name ?? ''}" -> isDV=true', + ); + result = true; + } else { + AppLogger.debug( + 'DVDetect', + 'name fallback miss: name="${name ?? ''}"', + ); + } + } + + return result; +} + +bool isHdr(EmbyRawMediaSource source) { + final video = findVideoStream(source); + bool result; + if (video == null) { + result = false; + } else { + final range = (video.extra['VideoRange'] as String? ?? '').toLowerCase(); + final rangeType = (video.extra['VideoRangeType'] as String? ?? '') + .toLowerCase(); + result = + range.contains('hdr') || + range.contains('dovi') || + rangeType.contains('hdr') || + rangeType.contains('dovi'); + } + + if (!result) { + final name = source.Name; + final fallback = _nameLooksLikeHdr(name); + if (fallback) { + AppLogger.debug( + 'HDRDetect', + 'name fallback hit: name="${name ?? ''}" -> isHdr=true', + ); + result = true; + } else { + AppLogger.debug( + 'HDRDetect', + 'name fallback miss: name="${name ?? ''}"', + ); + } + } + + return result; +} + +double getFrameRate(EmbyRawMediaSource source) { + final video = findVideoStream(source); + if (video == null) return 0; + return (video.extra['RealFrameRate'] as num?)?.toDouble() ?? + (video.extra['AverageFrameRate'] as num?)?.toDouble() ?? + 0; +} diff --git a/lib/core/media/media_source_metadata_resolver.dart b/lib/core/media/media_source_metadata_resolver.dart new file mode 100644 index 0000000..88b33d7 --- /dev/null +++ b/lib/core/media/media_source_metadata_resolver.dart @@ -0,0 +1,112 @@ +import '../contracts/library.dart'; +import '../../shared/utils/app_logger.dart'; +import 'media_capability_analyzer.dart'; +import 'media_probe_service.dart'; +import 'media_range_detector.dart'; + +const _tag = 'MediaSourceMeta'; + +class MediaSourceMetadataResolver { + MediaSourceMetadataResolver({required this._probeService}); + + final MediaProbeService _probeService; + + Future resolve({ + required EmbyRawMediaSource? source, + required String streamUrl, + EmbyRawItem? item, + Duration timeout = const Duration(seconds: 5), + }) async { + if (source == null) { + return _probeService.probe(streamUrl, timeout: timeout); + } + + final sourceMetadata = _metadataFromSource(source); + if (sourceMetadata != null) { + AppLogger.debug( + _tag, + 'source metadata hit sourceId=${source.Id ?? ''} ' + 'container=${source.Container}', + ); + return sourceMetadata; + } + + final itemMetadata = _metadataFromItem(item, source); + if (itemMetadata != null) return itemMetadata; + + AppLogger.debug( + _tag, + 'metadata empty for sourceId=${source.Id ?? ''} ' + 'container=${source.Container}, probing stream', + ); + return _probeService.probe(streamUrl, timeout: timeout); + } + + ProbeResult? _metadataFromItem(EmbyRawItem? item, EmbyRawMediaSource source) { + final itemSources = _itemMediaSources(item); + if (itemSources.isEmpty) return null; + + Map? matched; + var matchMode = 'none'; + for (final candidate in itemSources) { + if (candidate['Id'] == source.Id) { + matched = candidate; + matchMode = 'matchById'; + break; + } + } + if (matched == null && itemSources.length == 1) { + matched = itemSources.first; + matchMode = 'singleSource'; + } + if (matched == null) { + AppLogger.warn( + _tag, + 'no matching item MediaSource for sourceId=${source.Id ?? ''}', + ); + return null; + } + + final fallbackSource = EmbyRawMediaSource.fromJson(matched); + final metadata = _metadataFromSource(fallbackSource); + if (metadata == null) { + AppLogger.warn( + _tag, + 'item.MediaSources matched but its video metadata is empty ' + '(sourceId=${source.Id ?? ''}, $matchMode)', + ); + return null; + } + + AppLogger.debug( + _tag, + 'backfilled metadata from item.MediaSources ' + 'sourceId=${source.Id ?? ''} ($matchMode)', + ); + return metadata; + } + + List> _itemMediaSources(EmbyRawItem? item) { + final raw = item?.extra['MediaSources']; + if (raw is! List) return const >[]; + + final sources = >[]; + for (final entry in raw) { + if (entry is Map) { + sources.add(Map.from(entry)); + } + } + return sources; + } + + ProbeResult? _metadataFromSource(EmbyRawMediaSource source) { + final video = findVideoStream(source); + if (video == null) return null; + + return ProbeResult( + videoCodec: video.Codec, + videoRange: video.extra['VideoRange'] as String?, + videoRangeType: video.extra['VideoRangeType'] as String?, + ); + } +} diff --git a/lib/features/account/account_auth_page.dart b/lib/features/account/account_auth_page.dart new file mode 100644 index 0000000..ca53807 --- /dev/null +++ b/lib/features/account/account_auth_page.dart @@ -0,0 +1,598 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../providers/sm_account_provider.dart'; +import '../../shared/theme/app_theme.dart'; +import '../../shared/widgets/app_inline_alert.dart'; +import '../../shared/widgets/app_loading_ring.dart'; +import '../../shared/widgets/frosted_panel.dart'; +import 'widgets/otp_code_input.dart'; + + +class AccountAuthPage extends ConsumerStatefulWidget { + const AccountAuthPage({super.key}); + + @override + ConsumerState createState() => _AccountAuthPageState(); +} + +class _AccountAuthPageState extends ConsumerState { + bool _busy = false; + + @override + Widget build(BuildContext context) { + final tokens = context.appTokens; + final isDark = Theme.of(context).brightness == Brightness.dark; + + return Scaffold( + body: Stack( + children: [ + const _AuthBackground(), + SafeArea( + child: Stack( + children: [ + Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 32, + ), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 400), + child: FrostedPanel( + radius: tokens.radiusCardCompact, + color: isDark + ? Colors.white.withValues(alpha: 0.05) + : Colors.black.withValues(alpha: 0.03), + blurSigma: 15, + border: Border.all( + color: Colors.white.withValues(alpha: 0.08), + ), + padding: const EdgeInsets.fromLTRB(28, 32, 28, 28), + child: AccountAuthForm( + onBusyChanged: (v) => setState(() => _busy = v), + onSuccess: (message) => context.pop(message), + ), + ), + ), + ), + ), + Positioned( + top: 8, + left: 8, + child: IconButton( + onPressed: _busy ? null : () => context.pop(), + icon: Icon( + Icons.close_rounded, + color: Theme.of(context).colorScheme.onSurface, + ), + tooltip: '关闭', + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +class AccountAuthForm extends ConsumerStatefulWidget { + final ValueChanged onSuccess; + final ValueChanged? onBusyChanged; + final bool autofocus; + + const AccountAuthForm({ + super.key, + required this.onSuccess, + this.onBusyChanged, + this.autofocus = true, + }); + + @override + ConsumerState createState() => _AccountAuthFormState(); +} + +enum _AuthMode { login, register } + +class _AccountAuthFormState extends ConsumerState { + final _emailCtrl = TextEditingController(); + final _passwordCtrl = TextEditingController(); + final _emailFocus = FocusNode(); + final _passwordFocus = FocusNode(); + + _AuthMode _mode = _AuthMode.login; + + + bool _awaitingCode = false; + bool _busy = false; + bool _obscure = true; + + String _code = ''; + + + String? _formError; + + + String? _emailError; + String? _passwordError; + + + int _resendIn = 0; + Timer? _resendTimer; + + bool get _isRegister => _mode == _AuthMode.register; + + @override + void dispose() { + _resendTimer?.cancel(); + _emailCtrl.dispose(); + _passwordCtrl.dispose(); + _emailFocus.dispose(); + _passwordFocus.dispose(); + super.dispose(); + } + + void _startResendCountdown() { + _resendTimer?.cancel(); + setState(() => _resendIn = 60); + _resendTimer = Timer.periodic(const Duration(seconds: 1), (t) { + if (!mounted) { + t.cancel(); + return; + } + setState(() { + _resendIn -= 1; + if (_resendIn <= 0) t.cancel(); + }); + }); + } + + void _switchMode(_AuthMode mode) { + if (_busy) return; + setState(() { + _mode = mode; + _awaitingCode = false; + _formError = null; + _emailError = null; + _passwordError = null; + }); + } + + static final _emailRe = RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$'); + + bool _validateForm() { + final email = _emailCtrl.text.trim(); + final password = _passwordCtrl.text; + String? emailErr; + String? passErr; + if (email.isEmpty) { + emailErr = '请输入邮箱'; + } else if (!_emailRe.hasMatch(email)) { + emailErr = '邮箱格式不正确'; + } + if (password.isEmpty) { + passErr = '请输入密码'; + } else if (_isRegister && password.length < 6) { + passErr = '密码至少 6 位'; + } + setState(() { + _emailError = emailErr; + _passwordError = passErr; + _formError = null; + }); + return emailErr == null && passErr == null; + } + + bool _validateEmailOnly() { + final email = _emailCtrl.text.trim(); + String? emailErr; + if (email.isEmpty) { + emailErr = '请输入邮箱'; + } else if (!_emailRe.hasMatch(email)) { + emailErr = '邮箱格式不正确'; + } + setState(() { + _emailError = emailErr; + _formError = null; + }); + return emailErr == null; + } + + bool _validateRegisterPassword() { + final password = _passwordCtrl.text; + String? passErr; + if (password.isEmpty) { + passErr = '请输入密码'; + } else if (password.length < 6) { + passErr = '密码至少 6 位'; + } + setState(() { + _passwordError = passErr; + _formError = null; + }); + return passErr == null; + } + + void _setBusy(bool value) { + setState(() => _busy = value); + widget.onBusyChanged?.call(value); + } + + Future _login() async { + if (!_validateForm()) return; + _setBusy(true); + final result = await ref + .read(smAccountProvider.notifier) + .login(_emailCtrl.text.trim(), _passwordCtrl.text); + if (!mounted) return; + _setBusy(false); + + if (result.ok) { + widget.onSuccess('登录成功'); + } else if (result.needsVerification) { + setState(() { + _mode = _AuthMode.register; + _awaitingCode = true; + _code = ''; + _formError = result.message ?? '邮箱尚未验证,请输入验证码完成激活'; + }); + _startResendCountdown(); + } else { + setState(() => _formError = result.message ?? '登录失败'); + } + } + + Future _registerStart() async { + if (!_validateEmailOnly()) return; + _setBusy(true); + final result = await ref + .read(smAccountProvider.notifier) + .registerStart(_emailCtrl.text.trim()); + if (!mounted) return; + _setBusy(false); + + if (result.ok) { + setState(() { + _awaitingCode = true; + _code = ''; + _formError = null; + }); + _startResendCountdown(); + } else { + setState(() => _formError = result.message ?? '发送验证码失败'); + } + } + + Future _registerVerify() async { + if (!_validateRegisterPassword()) return; + _setBusy(true); + final result = await ref + .read(smAccountProvider.notifier) + .registerVerify(_emailCtrl.text.trim(), _passwordCtrl.text, _code); + if (!mounted) return; + _setBusy(false); + + if (result.ok) { + widget.onSuccess('注册成功,已登录'); + } else { + setState(() => _formError = result.message ?? '验证失败'); + } + } + + Future _resend() async { + if (_resendIn > 0 || _busy) return; + _setBusy(true); + final result = await ref + .read(smAccountProvider.notifier) + .resendRegisterCode(_emailCtrl.text.trim()); + if (!mounted) return; + _setBusy(false); + if (result.ok) { + _startResendCountdown(); + setState(() => _formError = null); + } else { + setState(() => _formError = result.message ?? '重发失败'); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final tokens = context.appTokens; + + return AnimatedSize( + duration: const Duration(milliseconds: 250), + curve: Curves.easeOut, + alignment: Alignment.topCenter, + child: Column( + key: const ValueKey('form'), + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const _BrandHeader(), + const SizedBox(height: 6), + Text( + _isRegister ? '创建 player 账号' : '登录以使用 player 账号服务', + textAlign: TextAlign.center, + style: theme.textTheme.bodySmall?.copyWith( + color: tokens.mutedForeground, + ), + ), + const SizedBox(height: 24), + if (_formError != null) ...[ + AppInlineAlert(message: _formError!), + const SizedBox(height: 16), + ], + TextField( + controller: _emailCtrl, + focusNode: _emailFocus, + enabled: !_busy, + autofocus: widget.autofocus, + keyboardType: TextInputType.emailAddress, + textInputAction: TextInputAction.next, + decoration: InputDecoration( + labelText: '邮箱', + hintText: 'your@email.com', + errorText: _emailError, + ), + onChanged: (_) { + if (_emailError != null) setState(() => _emailError = null); + }, + onSubmitted: (_) => _passwordFocus.requestFocus(), + ), + if (_isRegister) ...[ + const SizedBox(height: 6), + Align( + alignment: Alignment.centerRight, + child: TextButton( + onPressed: (_busy || _resendIn > 0) + ? null + : (_awaitingCode ? _resend : _registerStart), + style: TextButton.styleFrom( + foregroundColor: theme.colorScheme.primary, + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 4, + ), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: Text( + _awaitingCode + ? (_resendIn > 0 ? '重新发送(${_resendIn}s)' : '重新发送验证码') + : '发送验证码', + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ], + const SizedBox(height: 16), + TextField( + controller: _passwordCtrl, + focusNode: _passwordFocus, + enabled: !_busy, + obscureText: _obscure, + textInputAction: TextInputAction.done, + decoration: InputDecoration( + labelText: '密码', + hintText: _isRegister ? '设置密码(至少 6 位)' : '输入密码', + errorText: _passwordError, + suffixIcon: IconButton( + onPressed: () => setState(() => _obscure = !_obscure), + icon: Icon( + _obscure + ? Icons.visibility_off_outlined + : Icons.visibility_outlined, + ), + tooltip: _obscure ? '显示密码' : '隐藏密码', + ), + ), + onChanged: (_) { + if (_passwordError != null) { + setState(() => _passwordError = null); + } + }, + onSubmitted: (_) { + if (!_isRegister) _login(); + }, + ), + if (_isRegister) + AnimatedSize( + duration: const Duration(milliseconds: 250), + curve: Curves.easeOut, + alignment: Alignment.topCenter, + child: _awaitingCode + ? Padding( + padding: const EdgeInsets.only(top: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: 2, bottom: 6), + child: Text( + '验证码', + style: theme.textTheme.bodySmall?.copyWith( + color: tokens.mutedForeground, + ), + ), + ), + OtpCodeInput( + enabled: !_busy, + foreground: theme.colorScheme.onSurface, + accent: theme.colorScheme.primary, + onChanged: (v) { + setState(() { + _code = v; + if (_formError != null) _formError = null; + }); + }, + ), + ], + ), + ) + : const SizedBox.shrink(), + ), + const SizedBox(height: 24), + SizedBox( + height: 46, + child: FilledButton( + onPressed: _busy + ? null + : (_isRegister + ? ((!_awaitingCode || + _code.length < 6 || + _passwordCtrl.text.length < 6) + ? null + : _registerVerify) + : _login), + child: _busy + ? const AppLoadingRing(size: 18, color: Colors.white) + : Text( + _isRegister ? '完成注册' : '登录', + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + const SizedBox(height: 16), + _ModeSwitchLink( + hint: _isRegister ? '已有账号?' : '还没有账号?', + action: _isRegister ? '去登录' : '去注册', + onTap: _busy + ? null + : () => _switchMode( + _isRegister ? _AuthMode.login : _AuthMode.register, + ), + ), + ], + ), + ); + } +} + + +class _AuthBackground extends StatelessWidget { + const _AuthBackground(); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final tokens = context.appTokens; + return DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [tokens.pageGradientTop, tokens.pageGradientBottom], + ), + ), + child: Align( + alignment: const Alignment(0, -1.3), + child: Container( + width: 460, + height: 460, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: RadialGradient( + colors: [ + theme.colorScheme.primary.withValues(alpha: 0.22), + theme.colorScheme.primary.withValues(alpha: 0.0), + ], + ), + ), + ), + ), + ); + } +} + + +class _BrandHeader extends StatelessWidget { + const _BrandHeader(); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(16), + child: Image.asset( + 'assets/icon/app_icon.png', + width: 60, + height: 60, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => Container( + width: 60, + height: 60, + decoration: BoxDecoration( + color: theme.colorScheme.primary, + borderRadius: BorderRadius.circular(16), + ), + child: const Icon( + Icons.play_circle_fill_rounded, + color: Colors.white, + size: 34, + ), + ), + ), + ), + const SizedBox(height: 14), + Text('smPlayer', style: theme.textTheme.titleMedium), + ], + ); + } +} + + +class _ModeSwitchLink extends StatelessWidget { + final String hint; + final String action; + final VoidCallback? onTap; + + const _ModeSwitchLink({ + required this.hint, + required this.action, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final tokens = context.appTokens; + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + hint, + style: TextStyle(color: tokens.mutedForeground, fontSize: 13), + ), + GestureDetector( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), + child: Text( + action, + style: TextStyle( + color: theme.colorScheme.primary, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ], + ); + } +} diff --git a/lib/features/account/account_center_page.dart b/lib/features/account/account_center_page.dart new file mode 100644 index 0000000..02d6416 --- /dev/null +++ b/lib/features/account/account_center_page.dart @@ -0,0 +1,1045 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:forui/forui.dart'; +import 'package:go_router/go_router.dart'; + +import '../../providers/server_provider.dart'; +import '../../providers/server_sync_provider.dart'; +import '../../providers/sm_account_provider.dart'; +import '../../shared/constants/breakpoints.dart'; +import '../../shared/theme/app_theme.dart'; +import '../../shared/theme/app_theme_scope.dart'; +import '../../shared/widgets/app_card.dart'; +import '../../shared/widgets/app_dialog.dart'; +import '../../shared/widgets/app_inline_alert.dart'; +import '../../shared/widgets/app_sliver_header.dart'; +import '../../shared/widgets/app_loading_ring.dart'; +import '../../shared/widgets/app_snack_bar.dart'; +import '../../shared/widgets/page_background.dart'; +import 'account_auth_page.dart'; + +typedef AccountFeedback = void Function(String message, {AppAlertTone tone}); + +Future showAccountCenterSheet(BuildContext context) { + final isCompact = Breakpoints.isCompact(MediaQuery.sizeOf(context).width); + if (isCompact) return _showAccountBottomSheet(context); + return _showAccountSideSheet(context); +} + +Future _showAccountBottomSheet(BuildContext context) { + return showModalBottomSheet( + context: context, + useRootNavigator: true, + isScrollControlled: true, + useSafeArea: true, + isDismissible: true, + backgroundColor: Colors.transparent, + barrierColor: Colors.black38, + builder: (_) => const AppThemeScope(child: _AccountBottomSheet()), + ); +} + +Future _showAccountSideSheet(BuildContext context) { + return showGeneralDialog( + context: context, + useRootNavigator: true, + barrierDismissible: true, + barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel, + barrierColor: Colors.black38, + transitionDuration: const Duration(milliseconds: 220), + pageBuilder: (_, _, _) => const AppThemeScope(child: _AccountSideSheet()), + transitionBuilder: (_, animation, _, child) { + final curved = CurvedAnimation( + parent: animation, + curve: Curves.easeOutCubic, + reverseCurve: Curves.easeInCubic, + ); + return SlideTransition( + position: Tween( + begin: const Offset(1, 0), + end: Offset.zero, + ).animate(curved), + child: child, + ); + }, + ); +} + + +class AccountCenterPage extends ConsumerStatefulWidget { + const AccountCenterPage({super.key}); + + @override + ConsumerState createState() => _AccountCenterPageState(); +} + +class _AccountCenterPageState extends ConsumerState { + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.transparent, + extendBodyBehindAppBar: true, + body: Stack( + children: [ + const Positioned.fill(child: PageBackground()), + CustomScrollView( + physics: const AlwaysScrollableScrollPhysics( + parent: BouncingScrollPhysics(), + ), + slivers: [ + AppSliverHeader( + title: '账号', + leading: IconButton( + icon: const Icon(Icons.arrow_back_rounded), + onPressed: () => context.pop(), + ), + ), + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 100), + sliver: SliverToBoxAdapter( + child: AccountCenterContent( + onAuthRequested: _openAuth, + onFeedback: _pageFeedback, + syncConfirmInPopover: false, + ), + ), + ), + ], + ), + ], + ), + ); + } + + Future _openAuth() async { + final message = await context.push('/account/auth'); + if (!mounted || message == null || message.isEmpty) return; + showAppSnackBar(context, message, tone: AppSnackTone.success); + } + + void _pageFeedback(String message, {AppAlertTone tone = AppAlertTone.info}) { + showAppSnackBar( + context, + message, + tone: switch (tone) { + AppAlertTone.error => AppSnackTone.error, + AppAlertTone.warning => AppSnackTone.warning, + AppAlertTone.success => AppSnackTone.success, + AppAlertTone.info => AppSnackTone.neutral, + }, + ); + } +} + +class AccountCenterContent extends ConsumerStatefulWidget { + final VoidCallback onAuthRequested; + final AccountFeedback onFeedback; + final bool syncConfirmInPopover; + + const AccountCenterContent({ + super.key, + required this.onAuthRequested, + required this.onFeedback, + required this.syncConfirmInPopover, + }); + + @override + ConsumerState createState() => + _AccountCenterContentState(); +} + +class _AccountCenterContentState extends ConsumerState { + bool _logoutBusy = false; + + @override + Widget build(BuildContext context) { + final account = ref.watch(smAccountProvider).value; + final syncState = ref.watch(serverSyncProvider); + final status = account?.status ?? SmAuthStatus.unauthenticated; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildAccountSection(context, account, status), + const SizedBox(height: 24), + _buildSyncSection(context, account, syncState), + ], + ); + } + + Widget _buildAccountSection( + BuildContext context, + SmAccountState? account, + SmAuthStatus status, + ) { + return switch (status) { + SmAuthStatus.authenticated => _buildLoggedIn(context, account!), + SmAuthStatus.restoring => _buildRestoring(context, account!), + SmAuthStatus.expired => _buildReauth( + context, + account!, + title: '登录已过期', + subtitle: '请重新登录以继续使用扩展服务', + ), + SmAuthStatus.degraded => _buildReauth( + context, + account!, + title: '账号凭据不完整', + subtitle: '请重新登录以继续使用扩展服务', + ), + SmAuthStatus.unauthenticated => _buildEntry(context), + }; + } + + Widget _buildEntry(BuildContext context) { + final theme = Theme.of(context); + return AppCard( + onTap: widget.onAuthRequested, + padding: const EdgeInsets.all(20), + child: Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: theme.colorScheme.onSurface.withValues(alpha: 0.08), + shape: BoxShape.circle, + ), + child: Icon( + Icons.person_outline_rounded, + size: 24, + color: theme.colorScheme.onSurface.withValues(alpha: 0.4), + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '登录 Player 账号', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + fontSize: 16, + ), + ), + const SizedBox(height: 3), + Text( + '登录以使用云同步和 TMDB 等扩展服务', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurface.withValues(alpha: 0.58), + ), + ), + ], + ), + ), + const Icon(Icons.chevron_right_rounded), + ], + ), + ); + } + + Widget _buildRestoring(BuildContext context, SmAccountState s) { + final theme = Theme.of(context); + return AppCard( + padding: const EdgeInsets.all(20), + child: Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: theme.colorScheme.primary.withValues(alpha: 0.12), + shape: BoxShape.circle, + ), + child: Icon( + Icons.person_rounded, + size: 24, + color: theme.colorScheme.primary, + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + s.email.isEmpty ? 'Player 账号' : s.email, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + fontSize: 16, + ), + ), + const SizedBox(height: 3), + Text( + '正在恢复登录…', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurface.withValues(alpha: 0.58), + ), + ), + ], + ), + ), + const AppLoadingRing(size: 18), + ], + ), + ); + } + + Widget _buildReauth( + BuildContext context, + SmAccountState s, { + required String title, + required String subtitle, + }) { + final theme = Theme.of(context); + return AppCard( + onTap: widget.onAuthRequested, + padding: const EdgeInsets.all(20), + child: Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: const Color(0xFFFAAD14).withValues(alpha: 0.15), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.error_outline, + size: 24, + color: Color(0xFFFAAD14), + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + fontSize: 16, + ), + ), + const SizedBox(height: 3), + Text( + subtitle, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurface.withValues(alpha: 0.58), + ), + ), + ], + ), + ), + const Icon(Icons.chevron_right_rounded), + ], + ), + ); + } + + Widget _buildLoggedIn(BuildContext context, SmAccountState s) { + final theme = Theme.of(context); + return AppCard( + padding: const EdgeInsets.all(20), + child: Column( + children: [ + Row( + children: [ + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: s.isVip + ? const Color(0xFFFAAD14).withValues(alpha: 0.15) + : theme.colorScheme.primary.withValues(alpha: 0.12), + shape: BoxShape.circle, + border: s.isVip + ? Border.all( + color: const Color(0xFFFAAD14).withValues(alpha: 0.4), + width: 1.5, + ) + : null, + ), + child: Icon( + s.isVip + ? Icons.workspace_premium_rounded + : Icons.person_rounded, + size: 28, + color: s.isVip + ? const Color(0xFFFAAD14) + : theme.colorScheme.primary, + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + s.email.isNotEmpty ? s.email : 'Player 账号', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + fontSize: 17, + ), + ), + const SizedBox(height: 4), + Text( + _vipLabel(s), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurface.withValues( + alpha: 0.58, + ), + ), + ), + ], + ), + ), + if (s.isVip) + Container( + margin: const EdgeInsets.only(right: 8), + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + decoration: BoxDecoration( + color: const Color(0xFFFAAD14), + borderRadius: BorderRadius.circular(4), + ), + child: const Text( + 'VIP', + style: TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + const Divider(height: 32), + InkWell( + onTap: _logoutBusy ? null : _logout, + child: Row( + children: [ + Icon( + Icons.logout, + size: 18, + color: theme.colorScheme.onSurface.withValues(alpha: 0.6), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + '退出登录', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurface.withValues(alpha: 0.7), + ), + ), + ), + if (_logoutBusy) const AppLoadingRing(size: 18), + ], + ), + ), + ], + ), + ); + } + + Widget _buildSyncSection( + BuildContext context, + SmAccountState? account, + ServerSyncState syncState, + ) { + final theme = Theme.of(context); + final hasVip = account?.hasVip ?? false; + final busy = syncState.busy; + final localCount = ref.watch(serverListProvider).value?.length ?? 0; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(4, 0, 4, 8), + child: Text( + '云同步', + style: theme.textTheme.labelLarge?.copyWith( + color: theme.colorScheme.onSurface.withValues(alpha: 0.55), + fontWeight: FontWeight.w600, + ), + ), + ), + AppCard( + padding: EdgeInsets.zero, + child: Column( + children: [ + _buildSyncStatus(context, syncState, hasVip), + _divider(context), + _SyncActionRow( + icon: Icons.cloud_upload_outlined, + label: '上传到云端', + enabled: !busy && hasVip, + busy: busy, + usePopover: widget.syncConfirmInPopover, + confirmTitle: '上传到云端?', + confirmMessage: '这将把本地 $localCount 个服务器及对应登录态整包上传到云端,覆盖云端已有备份。', + confirmLabel: '确认上传', + onConfirm: () => _cloudUpload(localCount), + ), + _divider(context), + _SyncActionRow( + icon: Icons.cloud_download_outlined, + label: '从云端拉取', + enabled: !busy && hasVip, + busy: busy, + usePopover: widget.syncConfirmInPopover, + destructive: true, + confirmTitle: '从云端拉取?', + confirmMessage: + '这将用云端备份整包替换本地当前的 $localCount 个服务器及对应登录态;弹幕源按 URL 合并(云端为权威)。本地未上传到云端的改动会丢失。', + confirmLabel: '替换并拉取', + onConfirm: () => _cloudPull(localCount), + ), + ], + ), + ), + if (!hasVip) + Padding( + padding: const EdgeInsets.fromLTRB(4, 8, 4, 0), + child: Text( + '云同步需要 VIP 会员', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurface.withValues(alpha: 0.45), + ), + ), + ), + ], + ); + } + + Widget _buildSyncStatus( + BuildContext context, + ServerSyncState syncState, + bool hasVip, + ) { + final theme = Theme.of(context); + String statusText; + Color? statusColor; + + if (syncState.lastSyncError != null) { + statusText = '同步失败'; + statusColor = const Color(0xFFEF4444); + } else if (syncState.lastSyncAt != null) { + statusText = '上次同步:${_formatTime(syncState.lastSyncAt!)}'; + statusColor = null; + } else if (!hasVip) { + statusText = '未启用'; + statusColor = null; + } else { + statusText = '未同步'; + statusColor = null; + } + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13), + child: Row( + children: [ + Icon( + syncState.lastSyncError != null + ? Icons.error_outline + : Icons.cloud_done_outlined, + size: 20, + color: + statusColor ?? + theme.colorScheme.onSurface.withValues(alpha: 0.5), + ), + const SizedBox(width: 14), + Text( + statusText, + style: theme.textTheme.bodyMedium?.copyWith( + color: + statusColor ?? + theme.colorScheme.onSurface.withValues(alpha: 0.6), + ), + ), + ], + ), + ); + } + + Widget _divider(BuildContext context) { + return Divider( + height: 1, + indent: 50, + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.08), + ); + } + + String _vipLabel(SmAccountState s) { + if (s.vipPermanent) return '永久 VIP'; + if (s.isVip && s.vipExpiresAt != null) { + final d = DateTime.fromMillisecondsSinceEpoch(s.vipExpiresAt!); + String two(int v) => v.toString().padLeft(2, '0'); + return 'VIP 有效期至 ${d.year}-${two(d.month)}-${two(d.day)}'; + } + return '已登录'; + } + + String _formatTime(DateTime t) { + String two(int v) => v.toString().padLeft(2, '0'); + final now = DateTime.now(); + if (t.year == now.year && t.month == now.month && t.day == now.day) { + return '今天 ${two(t.hour)}:${two(t.minute)}'; + } + return '${t.month}/${t.day} ${two(t.hour)}:${two(t.minute)}'; + } + + Future _logout() async { + setState(() => _logoutBusy = true); + await ref.read(smAccountProvider.notifier).logout(); + if (!mounted) return; + setState(() => _logoutBusy = false); + widget.onFeedback('已退出登录', tone: AppAlertTone.success); + } + + Future _cloudUpload(int localCount) async { + if (!widget.syncConfirmInPopover) { + final confirmed = await showAppConfirmDialog( + context, + title: '上传到云端', + body: Text('这将把本地 $localCount 个服务器及对应登录态整包上传到云端,覆盖云端已有的备份。确定继续?'), + confirmLabel: '上传', + ); + if (!confirmed || !mounted) return; + } + + final outcome = await ref.read(serverSyncProvider.notifier).upload(); + if (!mounted) return; + widget.onFeedback( + outcome.message, + tone: outcome.isError ? AppAlertTone.error : AppAlertTone.info, + ); + } + + Future _cloudPull(int localCount) async { + if (!widget.syncConfirmInPopover) { + final confirmed = await showAppConfirmDialog( + context, + title: '从云端拉取', + body: Text( + '这将用云端备份整包替换本地当前的 $localCount 个服务器及对应登录态;' + '弹幕源按 URL 合并(云端为权威)。' + '本地未上传到云端的改动会丢失。确定继续?', + ), + confirmLabel: '替换并拉取', + destructive: true, + ); + if (!confirmed || !mounted) return; + } + + final outcome = await ref.read(serverSyncProvider.notifier).pull(); + if (!mounted) return; + widget.onFeedback( + outcome.message, + tone: outcome.isError ? AppAlertTone.error : AppAlertTone.info, + ); + } +} + +class _SyncActionRow extends StatelessWidget { + final IconData icon; + final String label; + final bool enabled; + final bool busy; + final bool usePopover; + final String confirmTitle; + final String confirmMessage; + final String confirmLabel; + final bool destructive; + final Future Function() onConfirm; + + const _SyncActionRow({ + required this.icon, + required this.label, + required this.enabled, + required this.busy, + required this.usePopover, + required this.confirmTitle, + required this.confirmMessage, + required this.confirmLabel, + required this.onConfirm, + this.destructive = false, + }); + + @override + Widget build(BuildContext context) { + final child = _SyncActionRowContent( + icon: icon, + label: label, + enabled: enabled, + busy: busy, + ); + + if (!usePopover) { + return InkWell( + onTap: enabled ? () => unawaited(onConfirm()) : null, + child: child, + ); + } + + return FPopover( + constraints: const FPortalConstraints(maxWidth: 300), + popoverAnchor: Alignment.topRight, + childAnchor: Alignment.bottomRight, + popoverBuilder: (_, controller) => _SyncConfirmBubble( + title: confirmTitle, + message: confirmMessage, + confirmLabel: confirmLabel, + destructive: destructive, + onCancel: () => unawaited(controller.hide()), + onConfirm: () { + unawaited(controller.hide()); + unawaited(onConfirm()); + }, + ), + builder: (_, controller, child) => + InkWell(onTap: enabled ? controller.toggle : null, child: child), + child: child, + ); + } +} + +class _SyncActionRowContent extends StatelessWidget { + final IconData icon; + final String label; + final bool enabled; + final bool busy; + + const _SyncActionRowContent({ + required this.icon, + required this.label, + required this.enabled, + required this.busy, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13), + child: Row( + children: [ + Icon( + icon, + size: 20, + color: enabled + ? theme.colorScheme.primary + : theme.colorScheme.onSurface.withValues(alpha: 0.3), + ), + const SizedBox(width: 14), + Expanded( + child: Text( + label, + style: theme.textTheme.bodyMedium?.copyWith( + color: enabled + ? null + : theme.colorScheme.onSurface.withValues(alpha: 0.4), + ), + ), + ), + if (busy) const AppLoadingRing(size: 18), + ], + ), + ); + } +} + +class _SyncConfirmBubble extends StatelessWidget { + final String title; + final String message; + final String confirmLabel; + final bool destructive; + final VoidCallback onCancel; + final VoidCallback onConfirm; + + const _SyncConfirmBubble({ + required this.title, + required this.message, + required this.confirmLabel, + required this.destructive, + required this.onCancel, + required this.onConfirm, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final tokens = context.appTokens; + return SizedBox( + width: 280, + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 6), + Text( + message, + style: theme.textTheme.bodySmall?.copyWith( + color: tokens.mutedForeground, + height: 1.35, + ), + ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + FButton( + onPress: onCancel, + variant: FButtonVariant.outline, + mainAxisSize: MainAxisSize.min, + child: const Text('取消'), + ), + const SizedBox(width: 8), + FButton( + onPress: onConfirm, + variant: destructive + ? FButtonVariant.destructive + : FButtonVariant.primary, + mainAxisSize: MainAxisSize.min, + child: Text(confirmLabel), + ), + ], + ), + ], + ), + ), + ); + } +} + +class _AccountSideSheet extends StatelessWidget { + const _AccountSideSheet(); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final screenWidth = MediaQuery.sizeOf(context).width; + final width = math.min( + math.min(480.0, math.max(400.0, screenWidth * 0.32)), + screenWidth * 0.92, + ); + + return Align( + alignment: Alignment.centerRight, + child: SizedBox( + width: width, + height: double.infinity, + child: Material( + color: theme.colorScheme.surface, + clipBehavior: Clip.antiAlias, + borderRadius: const BorderRadius.horizontal( + left: Radius.circular(24), + ), + child: const _AccountSheetFrame(isSideSheet: true), + ), + ), + ); + } +} + +class _AccountBottomSheet extends StatelessWidget { + const _AccountBottomSheet(); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final size = MediaQuery.sizeOf(context); + final viewInsets = MediaQuery.viewInsetsOf(context); + + final sheet = Material( + color: theme.colorScheme.surface, + clipBehavior: Clip.antiAlias, + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + child: const SafeArea( + top: false, + child: _AccountSheetFrame(isSideSheet: false), + ), + ); + + return AnimatedPadding( + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + padding: EdgeInsets.only(bottom: viewInsets.bottom), + child: Align( + alignment: Alignment.bottomCenter, + child: ConstrainedBox( + constraints: BoxConstraints(maxHeight: size.height * 0.92), + child: sheet, + ), + ), + ); + } +} + +class _AccountSheetFrame extends StatefulWidget { + final bool isSideSheet; + + const _AccountSheetFrame({required this.isSideSheet}); + + @override + State<_AccountSheetFrame> createState() => _AccountSheetFrameState(); +} + +class _AccountSheetFrameState extends State<_AccountSheetFrame> { + bool _authMode = false; + + @override + Widget build(BuildContext context) { + Widget body = AnimatedSwitcher( + duration: const Duration(milliseconds: 180), + + + layoutBuilder: (currentChild, previousChildren) => Stack( + alignment: Alignment.topCenter, + children: [ + ...previousChildren, + if (currentChild != null) currentChild, + ], + ), + child: _authMode ? _authBody(context) : _overviewBody(context), + ); + + body = widget.isSideSheet + ? Expanded(child: body) + : Flexible(fit: FlexFit.loose, child: body); + + return Column( + mainAxisSize: widget.isSideSheet ? MainAxisSize.max : MainAxisSize.min, + children: [ + if (widget.isSideSheet) + SizedBox(height: MediaQuery.paddingOf(context).top), + _AccountSheetTitleBar( + showBack: _authMode, + isSideSheet: widget.isSideSheet, + onBack: () => setState(() => _authMode = false), + onClose: () => Navigator.of(context).pop(), + ), + Divider( + height: 1, + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.08), + ), + body, + ], + ); + } + + Widget _overviewBody(BuildContext context) { + return SingleChildScrollView( + key: const ValueKey('overview'), + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), + child: AccountCenterContent( + onAuthRequested: () => setState(() => _authMode = true), + onFeedback: (message, {tone = AppAlertTone.info}) { + showAppToast(context, message, tone: tone); + }, + syncConfirmInPopover: true, + ), + ); + } + + Widget _authBody(BuildContext context) { + return SingleChildScrollView( + key: const ValueKey('auth'), + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.fromLTRB(24, 18, 24, 28), + child: Align( + alignment: Alignment.topCenter, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 400), + child: AccountAuthForm( + onSuccess: (message) => Navigator.of(context).pop(message), + ), + ), + ), + ); + } +} + +class _AccountSheetTitleBar extends StatelessWidget { + final bool showBack; + final bool isSideSheet; + final VoidCallback onBack; + final VoidCallback onClose; + + const _AccountSheetTitleBar({ + required this.showBack, + required this.isSideSheet, + required this.onBack, + required this.onClose, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + + final reservedTrailing = isSideSheet + ? MediaQuery.paddingOf(context).right + : 0.0; + + return SizedBox( + height: 56, + child: Stack( + alignment: Alignment.center, + children: [ + Align( + alignment: Alignment.centerLeft, + child: Padding( + padding: const EdgeInsets.only(left: 8), + child: showBack + ? IconButton( + onPressed: onBack, + icon: const Icon(Icons.arrow_back_rounded), + tooltip: '返回', + ) + : const SizedBox(width: 48), + ), + ), + Text( + '账号', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + Align( + alignment: Alignment.centerRight, + child: Padding( + padding: EdgeInsets.only(right: 8 + reservedTrailing), + child: IconButton( + onPressed: onClose, + icon: const Icon(Icons.close_rounded), + tooltip: '关闭', + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/account/widgets/otp_code_input.dart b/lib/features/account/widgets/otp_code_input.dart new file mode 100644 index 0000000..497438f --- /dev/null +++ b/lib/features/account/widgets/otp_code_input.dart @@ -0,0 +1,189 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + + +class OtpCodeInput extends StatefulWidget { + + final int length; + + + final ValueChanged onChanged; + + + final ValueChanged? onCompleted; + + + final bool enabled; + + + final bool autofocus; + + + final Color foreground; + + + final Color accent; + + const OtpCodeInput({ + super.key, + this.length = 6, + required this.onChanged, + this.onCompleted, + this.enabled = true, + this.autofocus = true, + this.foreground = Colors.white, + this.accent = const Color(0xFF1677FF), + }); + + @override + State createState() => _OtpCodeInputState(); +} + +class _OtpCodeInputState extends State { + late final List _controllers; + late final List _nodes; + + @override + void initState() { + super.initState(); + _controllers = List.generate(widget.length, (_) => TextEditingController()); + _nodes = List.generate(widget.length, (_) { + final node = FocusNode(); + node.addListener(() { + if (node.hasFocus) { + final i = _nodes.indexOf(node); + final c = _controllers[i]; + c.selection = TextSelection( + baseOffset: 0, + extentOffset: c.text.length, + ); + } + if (mounted) setState(() {}); + }); + return node; + }); + } + + @override + void dispose() { + for (final c in _controllers) { + c.dispose(); + } + for (final n in _nodes) { + n.dispose(); + } + super.dispose(); + } + + String get _value => _controllers.map((c) => c.text).join(); + + void _emit() { + final v = _value; + widget.onChanged(v); + if (v.length == widget.length) widget.onCompleted?.call(v); + } + + + void _spread(int startIndex, String digits) { + var idx = startIndex; + for (final ch in digits.split('')) { + if (idx >= widget.length) break; + _controllers[idx].text = ch; + idx++; + } + final focusIndex = idx >= widget.length ? widget.length - 1 : idx; + _nodes[focusIndex].requestFocus(); + _emit(); + } + + void _onChanged(int index, String raw) { + final digits = raw.replaceAll(RegExp(r'\D'), ''); + if (digits.isEmpty) { + _controllers[index].text = ''; + _emit(); + return; + } + if (digits.length == 1) { + final c = _controllers[index]; + c.text = digits; + c.selection = TextSelection.collapsed(offset: c.text.length); + if (index < widget.length - 1) _nodes[index + 1].requestFocus(); + _emit(); + } else { + _spread(index, digits); + } + } + + KeyEventResult _onKey(int index, KeyEvent event) { + if (event is KeyDownEvent && + event.logicalKey == LogicalKeyboardKey.backspace && + _controllers[index].text.isEmpty && + index > 0) { + _controllers[index - 1].text = ''; + _nodes[index - 1].requestFocus(); + _emit(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate(widget.length, (i) { + final focused = _nodes[i].hasFocus; + final filled = _controllers[i].text.isNotEmpty; + final borderColor = focused + ? widget.accent + : widget.foreground.withValues(alpha: filled ? 0.45 : 0.18); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 5), + child: SizedBox( + width: 46, + height: 56, + child: Focus( + canRequestFocus: false, + skipTraversal: true, + onKeyEvent: (_, event) => _onKey(i, event), + child: TextField( + controller: _controllers[i], + focusNode: _nodes[i], + enabled: widget.enabled, + autofocus: widget.autofocus && i == 0, + textAlign: TextAlign.center, + keyboardType: TextInputType.number, + cursorColor: widget.accent, + showCursor: true, + style: TextStyle( + color: widget.foreground, + fontSize: 22, + fontWeight: FontWeight.w600, + ), + decoration: InputDecoration( + counterText: '', + filled: true, + fillColor: widget.foreground.withValues(alpha: 0.06), + contentPadding: EdgeInsets.zero, + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: borderColor, width: 1.2), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: widget.accent, width: 1.6), + ), + disabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: borderColor, width: 1.2), + ), + ), + onChanged: (v) => _onChanged(i, v), + ), + ), + ), + ); + }), + ); + } +} diff --git a/lib/features/aggregated/aggregated_grouping.dart b/lib/features/aggregated/aggregated_grouping.dart new file mode 100644 index 0000000..491ebf6 --- /dev/null +++ b/lib/features/aggregated/aggregated_grouping.dart @@ -0,0 +1,272 @@ + + +library; + +import '../../core/contracts/library.dart'; +import '../../shared/utils/time_bucket.dart'; + + +enum GroupAxis { watchedTime, addedTime, mediaType } + + +class AggregatedEntrySource { + final EmbyRawItem item; + final GlobalSearchServerResult source; + + const AggregatedEntrySource({required this.item, required this.source}); + + + DateTime? dateFor(GroupAxis axis) { + switch (axis) { + case GroupAxis.watchedTime: + final userData = item.extra['UserData']; + return parseEmbyDate( + userData is Map ? userData['LastPlayedDate'] : null, + ); + case GroupAxis.addedTime: + return parseEmbyDate(item.extra['DateCreated']); + case GroupAxis.mediaType: + return null; + } + } +} + + +class AggregatedEntry { + final EmbyRawItem item; + final GlobalSearchServerResult source; + final List sources; + + AggregatedEntry({ + required this.item, + required this.source, + List? sources, + }) : assert(sources == null || sources.isNotEmpty), + sources = List.unmodifiable( + sources ?? [AggregatedEntrySource(item: item, source: source)], + ); + + + DateTime? dateFor(GroupAxis axis) { + return sources.first.dateFor(axis); + } +} + + +class AggregatedGroup { + final String key; + final String label; + final int order; + final List entries; + + const AggregatedGroup({ + required this.key, + required this.label, + required this.order, + required this.entries, + }); +} + + +List flatten( + List results, { + String? serverFilter, + bool deduplicateAcrossServers = false, +}) { + final out = []; + for (final sr in results) { + if (serverFilter != null && sr.serverId != serverFilter) continue; + for (final item in sr.items) { + out.add(AggregatedEntry(item: item, source: sr)); + } + } + if (deduplicateAcrossServers && serverFilter == null && out.length > 1) { + return _deduplicateEntries(out); + } + return out; +} + +const _typeOrder = { + 'Movie': (label: '电影', order: 0), + 'Series': (label: '剧集', order: 1), + 'Episode': (label: '单集', order: 2), +}; +const _typeOther = (label: '其它', order: 3); + + +List groupEntries( + List entries, + GroupAxis axis, + DateTime now, +) { + if (axis == GroupAxis.mediaType) { + return _groupByType(entries); + } + return _groupByTime(entries, axis, now); +} + +List _groupByTime( + List entries, + GroupAxis axis, + DateTime now, +) { + final scheme = axis == GroupAxis.watchedTime + ? TimeBucketScheme.watched + : TimeBucketScheme.added; + final buckets = + items})>{}; + + for (final e in entries) { + final b = bucketFor(e.dateFor(axis), now, scheme); + (buckets[b.key] ??= (bucket: b, items: [])).items.add(e); + } + + final groups = buckets.values.map((v) { + final items = [...v.items] + ..sort((a, b) { + final da = a.dateFor(axis); + final db = b.dateFor(axis); + if (da == null && db == null) return 0; + if (da == null) return 1; + if (db == null) return -1; + return db.compareTo(da); + }); + return AggregatedGroup( + key: v.bucket.key, + label: v.bucket.label, + order: v.bucket.order, + entries: items, + ); + }).toList()..sort((a, b) => a.order.compareTo(b.order)); + return groups; +} + +List _groupByType(List entries) { + final groups = + items})>{}; + + for (final e in entries) { + final meta = _typeOrder[e.item.Type] ?? _typeOther; + final key = e.item.Type ?? 'Other'; + (groups[key] ??= ( + label: meta.label, + order: meta.order, + items: [], + )).items.add(e); + } + + final out = groups.entries.map((kv) { + final items = [...kv.value.items] + ..sort((a, b) => (a.item.Name).compareTo(b.item.Name)); + return AggregatedGroup( + key: kv.key, + label: kv.value.label, + order: kv.value.order, + entries: items, + ); + }).toList()..sort((a, b) => a.order.compareTo(b.order)); + return out; +} + +Set _candidateKeys(EmbyRawItem item) { + final type = item.Type; + if (type == null || type.isEmpty) return const {}; + final ids = normalizedProviderIdsOfItem(item); + if (ids.isEmpty) return const {}; + return {for (final e in ids.entries) '$type:${e.key}:${e.value}'}; +} + +List _deduplicateEntries(List entries) { + final uf = _UnionFind(entries.length); + final keyToIndex = {}; + + for (var i = 0; i < entries.length; i++) { + final keys = _candidateKeys(entries[i].item); + for (final k in keys) { + final prev = keyToIndex[k]; + if (prev != null) { + uf.union(prev, i); + } else { + keyToIndex[k] = i; + } + } + } + + final groups = uf.groups(); + final survivors = []; + for (final group in groups) { + final orderedIndices = [...group] + ..sort((leftIndex, rightIndex) { + final leftDate = _lastPlayedDate(entries[leftIndex]); + final rightDate = _lastPlayedDate(entries[rightIndex]); + if (leftDate == null && rightDate == null) { + return leftIndex.compareTo(rightIndex); + } + if (leftDate == null) return 1; + if (rightDate == null) return -1; + + final dateComparison = rightDate.compareTo(leftDate); + return dateComparison != 0 + ? dateComparison + : leftIndex.compareTo(rightIndex); + }); + + final sourceServerIds = {}; + final sources = []; + for (final entryIndex in orderedIndices) { + final entry = entries[entryIndex]; + if (!sourceServerIds.add(entry.source.serverId)) continue; + sources.add( + AggregatedEntrySource(item: entry.item, source: entry.source), + ); + } + + final primarySource = sources.first; + survivors.add( + AggregatedEntry( + item: primarySource.item, + source: primarySource.source, + sources: sources, + ), + ); + } + return survivors; +} + +DateTime? _lastPlayedDate(AggregatedEntry e) => + e.dateFor(GroupAxis.watchedTime); + +class _UnionFind { + final List _parent; + final List _rank; + + _UnionFind(int n) + : _parent = List.generate(n, (i) => i), + _rank = List.filled(n, 0); + + int find(int x) { + if (_parent[x] != x) _parent[x] = find(_parent[x]); + return _parent[x]; + } + + void union(int a, int b) { + final ra = find(a), rb = find(b); + if (ra == rb) return; + if (_rank[ra] < _rank[rb]) { + _parent[ra] = rb; + } else if (_rank[ra] > _rank[rb]) { + _parent[rb] = ra; + } else { + _parent[rb] = ra; + _rank[ra]++; + } + } + + List> groups() { + final map = >{}; + for (var i = 0; i < _parent.length; i++) { + (map[find(i)] ??= []).add(i); + } + return map.values.toList(); + } +} diff --git a/lib/features/aggregated/aggregated_recent_page.dart b/lib/features/aggregated/aggregated_recent_page.dart new file mode 100644 index 0000000..46ef7eb --- /dev/null +++ b/lib/features/aggregated/aggregated_recent_page.dart @@ -0,0 +1,259 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/contracts/library.dart'; +import '../../providers/di_providers.dart'; +import '../../providers/session_provider.dart'; +import '../../shared/mappers/media_image_url.dart'; +import '../../shared/utils/media_nav.dart'; +import '../../shared/utils/time_bucket.dart'; +import '../../shared/widgets/app_sliver_header.dart'; +import '../../shared/widgets/app_loading_ring.dart'; +import '../../shared/widgets/empty_state.dart'; +import 'aggregated_grouping.dart'; +import 'widgets/aggregated_grouped_grid.dart'; +import 'widgets/aggregated_media_card.dart'; +import 'widgets/server_filter_bar.dart'; + +class AggregatedRecentPage extends ConsumerStatefulWidget { + final bool embedded; + + const AggregatedRecentPage({super.key, this.embedded = false}); + + @override + ConsumerState createState() => + _AggregatedRecentPageState(); +} + +class _AggregatedRecentPageState extends ConsumerState { + StreamSubscription? _subscription; + + List _serverResults = []; + bool _loading = false; + bool _switching = false; + + final Set _collapsed = {}; + String? _serverFilter; + + @override + void initState() { + super.initState(); + _load(); + } + + @override + void dispose() { + _subscription?.cancel(); + super.dispose(); + } + + Future _load() async { + _subscription?.cancel(); + _subscription = null; + + setState(() { + _loading = true; + _serverResults = []; + }); + + try { + final uc = await ref.read(aggregatedRecentUseCaseProvider.future); + final stream = uc.executeStream(); + + _subscription = stream.listen( + (result) { + if (mounted) { + setState(() => _serverResults = [..._serverResults, result]); + } + }, + onDone: () { + if (mounted) setState(() => _loading = false); + }, + onError: (_) { + if (mounted) setState(() => _loading = false); + }, + ); + } catch (_) { + if (mounted) setState(() => _loading = false); + } + } + + Future _onItemTap( + BuildContext context, + GlobalSearchServerResult serverResult, + EmbyRawItem item, + ) async { + if (_switching) return; + setState(() => _switching = true); + try { + await goMediaDetailOnServer( + context, + ref, + serverId: serverResult.serverId, + item: item, + ); + } finally { + if (mounted) setState(() => _switching = false); + } + } + + void _toggle(String key) { + setState(() { + if (!_collapsed.remove(key)) _collapsed.add(key); + }); + } + + @override + Widget build(BuildContext context) { + ref.listen(sessionProvider, (previous, next) { + final prevIds = + previous?.value?.sessions.map((s) => s.serverId).toSet() ?? + {}; + final nextIds = + next.value?.sessions.map((s) => s.serverId).toSet() ?? {}; + if (prevIds.length == nextIds.length && prevIds.containsAll(nextIds)) { + return; + } + if (_serverFilter != null && !nextIds.contains(_serverFilter)) { + setState(() => _serverFilter = null); + } + _load(); + }); + + if (widget.embedded) { + return Stack( + fit: StackFit.expand, + children: [ + CustomScrollView( + slivers: [ + SliverOverlapInjector( + handle: NestedScrollView.sliverOverlapAbsorberHandleFor( + context, + ), + ), + ..._buildContentSlivers(context), + ], + ), + if (_switching) _switchingOverlay(), + ], + ); + } + + return Stack( + fit: StackFit.expand, + children: [ + Scaffold( + extendBodyBehindAppBar: true, + body: CustomScrollView( + slivers: [ + AppSliverHeader( + title: '最近添加', + actions: [ + IconButton( + onPressed: _loading ? null : _load, + icon: _loading + ? const AppLoadingRing(size: 16) + : const Icon(Icons.refresh, size: 18), + tooltip: '刷新', + ), + ], + ), + ..._buildContentSlivers(context), + ], + ), + ), + if (_switching) _switchingOverlay(), + ], + ); + } + + Widget _switchingOverlay() => const Positioned.fill( + child: ColoredBox( + color: Color(0x44000000), + child: Center(child: AppLoadingRing()), + ), + ); + + List _buildContentSlivers(BuildContext context) { + if (_serverResults.isEmpty && !_loading) { + return const [ + SliverFillRemaining( + hasScrollBody: false, + child: EmptyState( + icon: Icons.new_releases_outlined, + title: '暂无最近添加内容', + ), + ), + ]; + } + if (_serverResults.isEmpty && _loading) { + return const [ + SliverFillRemaining( + hasScrollBody: false, + child: Center(child: AppLoadingRing()), + ), + ]; + } + + final now = DateTime.now(); + final entries = flatten( + _serverResults, + serverFilter: _serverFilter, + deduplicateAcrossServers: true, + ); + final groups = groupEntries(entries, GroupAxis.addedTime, now); + final servers = _serverResults + .map((sr) => (id: sr.serverId, name: sr.serverName)) + .toList(growable: false); + + return [ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.only(top: 8), + child: ServerFilterBar( + servers: servers, + selected: _serverFilter, + onSelect: (v) => setState(() => _serverFilter = v), + ), + ), + ), + ...buildAggregatedGridSlivers( + groups: groups, + collapsed: _collapsed, + onToggle: _toggle, + cardBuilder: (entry) => _card(context, entry, now), + ), + ]; + } + + Widget _card(BuildContext context, AggregatedEntry entry, DateTime now) { + final sr = entry.source; + final item = entry.item; + final subtitle = item.ProductionYear != null + ? '${item.ProductionYear}' + : null; + + return AggregatedMediaCard( + item: item, + imageUrl: EmbyImageUrl.landscapeCard( + baseUrl: sr.serverUrl, + token: sr.token, + item: item, + maxWidth: 400, + ), + imageHeaders: buildEmbyImageHeaders( + ref, + token: sr.token, + userId: sr.userId, + ), + title: item.Name, + subtitle: subtitle, + serverName: sr.serverName, + timeLabel: relativeTimeLabel(entry.dateFor(GroupAxis.addedTime), now), + showProgress: false, + onTap: () => _onItemTap(context, sr, item), + ); + } +} diff --git a/lib/features/aggregated/aggregated_view_page.dart b/lib/features/aggregated/aggregated_view_page.dart new file mode 100644 index 0000000..d31e2fa --- /dev/null +++ b/lib/features/aggregated/aggregated_view_page.dart @@ -0,0 +1,164 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../shared/theme/app_theme.dart'; +import '../../shared/widgets/app_sliver_header.dart'; +import '../../shared/widgets/page_background.dart'; +import '../../shared/widgets/pill_tab_bar.dart'; +import '../favorites/aggregated_favorites_page.dart'; +import '../history/history_page.dart'; +import 'aggregated_recent_page.dart'; + +const _kTabHistory = 'history'; +const _kTabFavorites = 'favorites'; +const _kTabRecent = 'recent'; + +int _tabIndexFromKey(String? key) => switch (key) { + _kTabFavorites => 1, + _kTabRecent => 2, + _ => 0, +}; + +String _tabKeyFromIndex(int index) => switch (index) { + 1 => _kTabFavorites, + 2 => _kTabRecent, + _ => _kTabHistory, +}; + +class AggregatedViewPage extends ConsumerStatefulWidget { + + final String? initialTab; + + + final bool isCompactMode; + + const AggregatedViewPage({ + super.key, + this.initialTab, + this.isCompactMode = false, + }); + + @override + ConsumerState createState() => _AggregatedViewPageState(); +} + +class _AggregatedViewPageState extends ConsumerState { + late int _index; + int _historyRefreshSeed = 0; + int _favoritesRefreshSeed = 0; + int _recentRefreshSeed = 0; + + String get _storagePrefix => widget.isCompactMode ? 'watch' : 'aggregated'; + + @override + void initState() { + super.initState(); + _index = _tabIndexFromKey(widget.initialTab); + } + + @override + void didUpdateWidget(covariant AggregatedViewPage oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.initialTab != oldWidget.initialTab) { + final next = _tabIndexFromKey(widget.initialTab); + if (next != _index) { + setState(() => _index = next); + } + } + } + + void _onTabChanged(int next) { + if (next == _index) return; + setState(() => _index = next); + final key = _tabKeyFromIndex(next); + context.go('/aggregated?tab=$key'); + } + + Future _refreshActive() async { + setState(() { + switch (_index) { + case 1: + _favoritesRefreshSeed++; + break; + case 2: + _recentRefreshSeed++; + break; + default: + _historyRefreshSeed++; + } + }); + await Future.delayed(const Duration(milliseconds: 250)); + } + + @override + Widget build(BuildContext context) { + final tokens = context.appTokens; + final firstTabLabel = widget.isCompactMode ? '继续观看' : '播放记录'; + + return Scaffold( + extendBodyBehindAppBar: true, + backgroundColor: Colors.transparent, + body: Stack( + children: [ + const Positioned.fill(child: PageBackground()), + RefreshIndicator( + onRefresh: _refreshActive, + child: NestedScrollView( + physics: const AlwaysScrollableScrollPhysics( + parent: BouncingScrollPhysics(), + ), + floatHeaderSlivers: false, + headerSliverBuilder: (context, innerBoxIsScrolled) => [ + SliverOverlapAbsorber( + handle: NestedScrollView.sliverOverlapAbsorberHandleFor( + context, + ), + sliver: AppSliverHeader( + toolbarHeight: tokens.titleBarHeight, + automaticallyImplyLeading: false, + titleWidget: _buildTitleRow(firstTabLabel), + ), + ), + ], + body: IndexedStack( + index: _index, + children: [ + KeyedSubtree( + key: PageStorageKey( + '${_storagePrefix}_history_$_historyRefreshSeed', + ), + child: const HistoryPage(embedded: true), + ), + KeyedSubtree( + key: PageStorageKey( + '${_storagePrefix}_favorites_$_favoritesRefreshSeed', + ), + child: const AggregatedFavoritesPage(embedded: true), + ), + KeyedSubtree( + key: PageStorageKey( + '${_storagePrefix}_recent_$_recentRefreshSeed', + ), + child: const AggregatedRecentPage(embedded: true), + ), + ], + ), + ), + ), + ], + ), + ); + } + + Widget _buildTitleRow(String firstTabLabel) { + final tabBar = PillTabBar( + padding: const EdgeInsets.only(left: 8), + tabs: [firstTabLabel, '我的收藏', '最近添加'], + selectedIndex: _index, + onSelect: _onTabChanged, + ); + + return Align(alignment: Alignment.centerLeft, child: tabBar); + } +} diff --git a/lib/features/aggregated/widgets/aggregated_grouped_grid.dart b/lib/features/aggregated/widgets/aggregated_grouped_grid.dart new file mode 100644 index 0000000..aa68949 --- /dev/null +++ b/lib/features/aggregated/widgets/aggregated_grouped_grid.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; + +import '../../../shared/layout/adaptive_card_grid.dart'; +import '../../../shared/widgets/section_header.dart'; +import 'aggregated_media_card.dart'; +import '../aggregated_grouping.dart'; + + +List buildAggregatedGridSlivers({ + required List groups, + required Set collapsed, + required ValueChanged onToggle, + required Widget Function(AggregatedEntry entry) cardBuilder, + EdgeInsets padding = const EdgeInsets.fromLTRB(24, 4, 24, 24), +}) { + final slivers = []; + for (final group in groups) { + final isCollapsed = collapsed.contains(group.key); + slivers.add( + SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.only(left: padding.left, right: padding.right), + child: SectionHeader( + label: group.label, + count: group.entries.length, + collapsible: true, + collapsed: isCollapsed, + onToggle: () => onToggle(group.key), + ), + ), + ), + ); + if (!isCollapsed) { + final entries = group.entries; + slivers.add( + SliverPadding( + padding: EdgeInsets.fromLTRB(padding.left, 0, padding.right, 20), + sliver: SliverLayoutBuilder( + builder: (context, constraints) { + final availableWidth = constraints.crossAxisExtent; + final columnCount = cardColumnsFor( + availableWidth, + minCardWidth: kRichCardMinWidth, + ); + final gapTotalWidth = kCardGap * (columnCount - 1); + final cardWidth = (availableWidth - gapTotalWidth) / columnCount; + + return SliverGrid( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: columnCount, + mainAxisExtent: AggregatedMediaCard.height, + crossAxisSpacing: kCardGap, + mainAxisSpacing: kCardGap, + ), + delegate: SliverChildBuilderDelegate( + (context, index) => SizedBox( + width: cardWidth, + child: cardBuilder(entries[index]), + ), + childCount: entries.length, + ), + ); + }, + ), + ), + ); + } + } + return slivers; +} diff --git a/lib/features/aggregated/widgets/aggregated_media_card.dart b/lib/features/aggregated/widgets/aggregated_media_card.dart new file mode 100644 index 0000000..cf15910 --- /dev/null +++ b/lib/features/aggregated/widgets/aggregated_media_card.dart @@ -0,0 +1,243 @@ +import 'package:flutter/material.dart'; +import 'package:forui/forui.dart'; + +import '../../../core/contracts/library.dart'; +import '../../../shared/theme/app_theme.dart'; +import '../../../shared/utils/emby_ticks.dart'; +import '../../../shared/widgets/app_card.dart'; +import '../../../shared/widgets/card_progress_bar.dart'; +import '../../../shared/widgets/metadata_chip.dart'; +import '../../../shared/widgets/smart_image.dart'; + + +class AggregatedMediaCard extends StatefulWidget { + final EmbyRawItem item; + final String? imageUrl; + final String? fallbackImageUrl; + final Map? imageHeaders; + + final String title; + final String? subtitle; + final String serverName; + final int serverSourceCount; + final String? timeLabel; + + final String? networkName; + final String? networkLogoUrl; + + final bool showProgress; + final VoidCallback? onTap; + final VoidCallback? onServerSourcesTap; + final List? contextMenuItems; + + const AggregatedMediaCard({ + super.key, + required this.item, + required this.imageUrl, + required this.title, + required this.serverName, + this.serverSourceCount = 1, + this.fallbackImageUrl, + this.imageHeaders, + this.subtitle, + this.timeLabel, + this.networkName, + this.networkLogoUrl, + this.showProgress = false, + this.onTap, + this.onServerSourcesTap, + this.contextMenuItems, + }) : assert(serverSourceCount > 0); + + static const double height = 84; + static const double _thumbWidth = 124; + + @override + State createState() => _AggregatedMediaCardState(); +} + +class _AggregatedMediaCardState extends State { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final tokens = context.appTokens; + final radius = tokens.radiusCard; + final progress = widget.showProgress ? playbackProgress(widget.item) : 0.0; + final remaining = widget.showProgress + ? formatRemainingLabel(widget.item) + : null; + + return AppCard( + onTap: widget.onTap, + contextMenuItems: widget.contextMenuItems, + radius: radius, + padding: EdgeInsets.zero, + onHoverChanged: (h) => setState(() => _hovered = h), + child: SizedBox( + height: AggregatedMediaCard.height, + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _thumbnail(radius, progress), + Expanded( + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 10, 8), + child: _info(theme, tokens, remaining), + ), + ), + ], + ), + ), + ); + } + + Widget _thumbnail(double radius, double progress) { + return SizedBox( + width: AggregatedMediaCard._thumbWidth, + child: Stack( + fit: StackFit.expand, + children: [ + SmartImage( + url: widget.imageUrl, + fallbackUrls: widget.fallbackImageUrl != null + ? [widget.fallbackImageUrl!] + : const [], + httpHeaders: widget.imageHeaders, + fit: BoxFit.cover, + borderRadius: 0, + ), + if (_hovered) + Container( + color: Colors.black.withValues(alpha: 0.32), + child: const Center( + child: Icon( + Icons.play_arrow_rounded, + size: 30, + color: Colors.white, + ), + ), + ), + if (progress > 0) CardProgressBar(progress: progress), + ], + ), + ); + } + + Widget _networkBadge(AppTokens tokens) { + if (widget.networkLogoUrl != null) { + return SizedBox( + height: 16, + child: SmartImage( + url: widget.networkLogoUrl, + fit: BoxFit.contain, + borderRadius: 0, + ), + ); + } + return MetadataChip( + label: widget.networkName!, + dense: true, + foreground: tokens.mutedForeground, + ); + } + + Widget _serverBadge() { + final hasMultipleSources = widget.serverSourceCount > 1; + if (!hasMultipleSources || widget.onServerSourcesTap == null) { + return MetadataChip(label: widget.serverName, dense: true); + } + + final label = '${widget.serverName} ${widget.serverSourceCount}服'; + return Tooltip( + message: '选择服务器并查看各自的播放进度', + child: Semantics( + button: true, + label: '$label,选择其他服务器', + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: widget.onServerSourcesTap, + child: MetadataChip( + label: label, + icon: Icons.keyboard_arrow_down_rounded, + dense: true, + ), + ), + ), + ), + ); + } + + Widget _info(ThemeData theme, AppTokens tokens, String? remaining) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + widget.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + if (widget.subtitle != null) ...[ + const SizedBox(height: 2), + Text( + widget.subtitle!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: tokens.mutedForeground, + ), + ), + ], + ], + ), + Row( + children: [ + _serverBadge(), + if (widget.networkName != null && + widget.networkName!.isNotEmpty) ...[ + const SizedBox(width: 6), + _networkBadge(tokens), + ], + if (remaining != null) ...[ + const SizedBox(width: 8), + Flexible( + child: Text( + remaining, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.labelSmall?.copyWith( + color: tokens.mutedForeground, + fontWeight: FontWeight.w600, + ), + ), + ), + ] else if (widget.timeLabel != null) ...[ + const SizedBox(width: 8), + Flexible( + child: Text( + widget.timeLabel!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.labelSmall?.copyWith( + color: tokens.mutedForeground, + ), + ), + ), + ], + ], + ), + ], + ); + } +} diff --git a/lib/features/aggregated/widgets/server_filter_bar.dart b/lib/features/aggregated/widgets/server_filter_bar.dart new file mode 100644 index 0000000..6618807 --- /dev/null +++ b/lib/features/aggregated/widgets/server_filter_bar.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; +import 'package:forui/forui.dart'; + + +class ServerFilterBar extends StatelessWidget { + final List<({String id, String name})> servers; + final String? selected; + final ValueChanged onSelect; + final EdgeInsetsGeometry padding; + + const ServerFilterBar({ + super.key, + required this.servers, + required this.selected, + required this.onSelect, + this.padding = const EdgeInsets.fromLTRB(24, 0, 24, 12), + }); + + @override + Widget build(BuildContext context) { + if (servers.length < 2) return const SizedBox.shrink(); + return Padding( + padding: padding, + child: Align( + alignment: Alignment.centerLeft, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + _chip(label: '全部', value: null), + for (final s in servers) _chip(label: s.name, value: s.id), + ], + ), + ), + ), + ); + } + + Widget _chip({required String label, required String? value}) { + final active = selected == value; + final child = Text(label); + return Padding( + padding: const EdgeInsets.only(right: 8), + child: FButton( + variant: active ? FButtonVariant.primary : FButtonVariant.ghost, + onPress: () => onSelect(value), + child: child, + ), + ); + } +} diff --git a/lib/features/calendar/trakt_calendar_page.dart b/lib/features/calendar/trakt_calendar_page.dart new file mode 100644 index 0000000..b016162 --- /dev/null +++ b/lib/features/calendar/trakt_calendar_page.dart @@ -0,0 +1,256 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/contracts/trakt/trakt_calendar_entry.dart'; +import '../../providers/di_providers.dart'; +import '../../providers/trakt_calendar_provider.dart'; +import '../../shared/utils/media_nav.dart'; +import '../../shared/widgets/app_loading_ring.dart'; +import '../../shared/widgets/app_error_state.dart'; +import '../../shared/widgets/app_sliver_header.dart'; +import '../../shared/widgets/page_background.dart'; +import '../../shared/widgets/pill_tab_bar.dart'; +import '../../shared/widgets/smart_image.dart'; + +const _kTabs = ['全部', '剧集', '电影']; + +class TraktCalendarPage extends ConsumerStatefulWidget { + const TraktCalendarPage({super.key}); + + @override + ConsumerState createState() => _TraktCalendarPageState(); +} + +class _TraktCalendarPageState extends ConsumerState { + int _tabIndex = 0; + + void _onTabChanged(int index) { + if (index == _tabIndex) return; + setState(() => _tabIndex = index); + final filter = switch (index) { + 1 => TraktCalendarFilter.shows, + 2 => TraktCalendarFilter.movies, + _ => TraktCalendarFilter.all, + }; + ref.read(traktCalendarFilterProvider.notifier).state = filter; + } + + @override + Widget build(BuildContext context) { + final asyncEntries = ref.watch(traktCalendarProvider); + + return Scaffold( + extendBodyBehindAppBar: true, + backgroundColor: Colors.transparent, + body: Stack( + children: [ + const Positioned.fill(child: PageBackground()), + RefreshIndicator( + onRefresh: () async { + ref.invalidate(traktCalendarProvider); + await ref.read(traktCalendarProvider.future); + }, + child: CustomScrollView( + slivers: [ + AppSliverHeader( + title: '日历', + automaticallyImplyLeading: false, + bottom: PreferredSize( + preferredSize: const Size.fromHeight(48), + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + child: PillTabBar( + tabs: _kTabs, + selectedIndex: _tabIndex, + onSelect: _onTabChanged, + ), + ), + ), + ), + asyncEntries.when( + loading: () => const SliverFillRemaining( + child: Center(child: AppLoadingRing()), + ), + error: (e, _) => SliverFillRemaining( + child: AppErrorState( + title: '加载失败', + message: '$e', + onRetry: () => ref.invalidate(traktCalendarProvider), + ), + ), + data: (entries) { + if (entries.isEmpty) { + return const SliverFillRemaining( + child: AppErrorState( + title: '暂无内容', + message: '未来 7 天没有新内容', + icon: Icons.calendar_today_outlined, + ), + ); + } + final groups = groupByDate(entries); + return SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) => + _buildGroupItem(context, groups, index), + childCount: groups.fold( + 0, + (sum, g) => sum + 1 + g.entries.length, + ), + ), + ); + }, + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildGroupItem( + BuildContext context, + List groups, + int flatIndex, + ) { + var cursor = 0; + for (final group in groups) { + if (flatIndex == cursor) { + return _DateHeader(label: group.label, count: group.entries.length); + } + cursor++; + final entryIndex = flatIndex - cursor; + if (entryIndex < group.entries.length) { + return _CalendarEntryTile(entry: group.entries[entryIndex]); + } + cursor += group.entries.length; + } + return const SizedBox.shrink(); + } +} + +class _DateHeader extends StatelessWidget { + final String label; + final int count; + + const _DateHeader({required this.label, required this.count}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.fromLTRB(24, 20, 24, 8), + child: Row( + children: [ + Text( + label, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(width: 8), + Text( + '$count', + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurface.withValues(alpha: 0.45), + ), + ), + ], + ), + ); + } +} + +class _CalendarEntryTile extends ConsumerWidget { + final TraktCalendarEntry entry; + + const _CalendarEntryTile({required this.entry}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final hasTmdb = entry.tmdbId != null && entry.tmdbId! > 0; + final airTime = _formatAirTime(entry.firstAired.toLocal()); + + String? networkName; + if (hasTmdb) { + final TmdbMediaKey key = ( + tmdbId: entry.tmdbId!.toString(), + mediaType: entry.mediaType, + ); + final enriched = ref.watch(tmdbEnrichedDetailProvider(key)).value; + networkName = enriched?.networks.firstOrNull?.name; + } + + return InkWell( + onTap: hasTmdb + ? () => goTmdbDetail( + context, + tmdbId: entry.tmdbId!, + mediaType: entry.mediaType, + ) + : null, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8), + child: Row( + children: [ + SizedBox( + width: 48, + height: 72, + child: SmartImage( + url: entry.posterUrl, + borderRadius: 6, + fallbackIcon: entry.type == 'movie' + ? Icons.movie_outlined + : Icons.tv_outlined, + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + entry.displayTitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 3), + Text( + [ + entry.displaySubtitle, + airTime, + if (networkName != null) networkName, + ].where((s) => s.isNotEmpty).join(' · '), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurface.withValues( + alpha: 0.55, + ), + ), + ), + ], + ), + ), + if (!hasTmdb) + Icon( + Icons.link_off, + size: 16, + color: theme.colorScheme.onSurface.withValues(alpha: 0.25), + ), + ], + ), + ), + ); + } + + static String _formatAirTime(DateTime local) { + return '${local.hour.toString().padLeft(2, '0')}:' + '${local.minute.toString().padLeft(2, '0')}'; + } +} diff --git a/lib/features/detail/adapter/tmdb_detail_view_builder.dart b/lib/features/detail/adapter/tmdb_detail_view_builder.dart new file mode 100644 index 0000000..c7055fc --- /dev/null +++ b/lib/features/detail/adapter/tmdb_detail_view_builder.dart @@ -0,0 +1,105 @@ +import 'package:flutter/widgets.dart'; + +import '../../../core/contracts/tmdb.dart'; +import '../../../shared/mappers/tmdb_image_selector.dart'; +import '../../../shared/mappers/tmdb_image_url.dart'; +import '../../../shared/utils/format_utils.dart'; +import '../utils/detail_helpers.dart'; +import '../model/detail_hero_model.dart'; +import '../model/detail_image_model.dart'; +import '../model/detail_section_model.dart'; +import '../model/detail_view_state.dart'; +import '../widgets/tmdb_cast_section.dart'; +import '../widgets/tmdb_recommendation_section.dart'; + + +DetailViewState buildTmdbDetailViewState({ + required String mediaType, + required TmdbEnrichedDetail? enriched, + required TmdbRecommendation? seed, + required String imageBaseUrl, + required String language, + required Widget? heroActions, + required List hitStateSections, + required bool showSpinner, +}) { + final detail = enriched?.detail; + + final title = (detail?.title.isNotEmpty ?? false) + ? detail!.title + : (seed?.title ?? ''); + final posterPath = detail?.posterPath ?? seed?.posterPath; + final backdropPath = detail?.backdropPath ?? seed?.backdropPath; + final voteAverage = detail?.voteAverage ?? seed?.voteAverage; + final overview = (detail?.overview?.trim().isNotEmpty ?? false) + ? detail!.overview!.trim() + : (seed?.overview?.trim() ?? ''); + final tagline = detail?.tagline?.trim(); + + final metaParts = []; + final year = yearOf(detail?.releaseDate); + if (year != null) metaParts.add(year); + final runtime = formatRuntimeMinutes(detail?.runtime); + if (runtime != null) metaParts.add(runtime); + + final genreNames = [ + for (final g in detail?.genres ?? const []) + if (g.name.isNotEmpty) g.name, + ]; + + final cast = enriched?.credits?.cast ?? const []; + final recommendations = + enriched?.recommendations?.results ?? const []; + + final posterUrl = TmdbImageUrl.poster(imageBaseUrl, posterPath); + final backdropUrl = TmdbImageUrl.backdrop(imageBaseUrl, backdropPath); + final logoUrl = TmdbImageSelector.logoUrl( + enriched?.images, + imageBaseUrl, + language: language, + ); + final fallbackUrls = TmdbImageSelector.buildFallbackUrls( + primaryUrl: backdropUrl, + candidates: [posterUrl], + ); + + final sections = [ + ...hitStateSections, + if (cast.isNotEmpty) + DetailSection( + id: 'tmdb_cast', + child: TmdbCastSection(cast: cast, imageBaseUrl: imageBaseUrl), + ), + if (recommendations.isNotEmpty) + DetailSection( + id: 'tmdb_recommendations', + child: TmdbRecommendationSection( + items: recommendations, + imageBaseUrl: imageBaseUrl, + fallbackMediaType: mediaType, + ), + ), + ]; + + return DetailViewState( + image: DetailImageModel( + backdropUrl: backdropUrl, + fallbackUrls: fallbackUrls, + ), + navTitle: title, + hero: DetailHeroModel( + title: title, + logoUrl: logoUrl, + posterUrl: posterUrl, + imageHeaders: null, + rating: voteAverage, + metaParts: metaParts, + genres: genreNames, + tagline: tagline, + overview: overview, + actions: heroActions, + ), + sections: sections, + showLoadingBelowHero: showSpinner, + ); +} diff --git a/lib/features/detail/android/android_detail_banner.dart b/lib/features/detail/android/android_detail_banner.dart new file mode 100644 index 0000000..34cb5e1 --- /dev/null +++ b/lib/features/detail/android/android_detail_banner.dart @@ -0,0 +1,108 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import '../../../shared/widgets/smart_image.dart'; + + +class AndroidDetailBanner extends StatefulWidget { + const AndroidDetailBanner({ + super.key, + required this.scrollProgress, + required this.backdropUrl, + required this.fallbackUrls, + this.embyBaseUrl = '', + this.imageHeaders, + }); + + final ValueListenable scrollProgress; + final String backdropUrl; + final List fallbackUrls; + final String embyBaseUrl; + final Map? imageHeaders; + + @override + State createState() => _AndroidDetailBannerState(); +} + +class _AndroidDetailBannerState extends State { + bool _allImagesFailed = false; + + @override + void didUpdateWidget(covariant AndroidDetailBanner oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.backdropUrl != widget.backdropUrl || + !listEquals(oldWidget.fallbackUrls, widget.fallbackUrls)) { + _allImagesFailed = false; + } + } + + @override + Widget build(BuildContext context) { + return Stack( + fit: StackFit.expand, + children: [ + if (!_allImagesFailed) + ShaderMask( + shaderCallback: (bounds) => const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + stops: [0.0, 0.68, 1.0], + colors: [Colors.black, Colors.black, Colors.transparent], + ).createShader(bounds), + blendMode: BlendMode.dstIn, + child: SmartImage( + url: widget.backdropUrl, + fallbackUrls: widget.fallbackUrls, + fit: BoxFit.cover, + alignment: Alignment.center, + borderRadius: 0, + placeholder: const SizedBox.expand(), + onError: () { + if (mounted && !_allImagesFailed) { + setState(() => _allImagesFailed = true); + } + }, + resolveHttpHeaders: (url) => + widget.embyBaseUrl.isNotEmpty && + url.startsWith(widget.embyBaseUrl) + ? widget.imageHeaders + : null, + ), + ), + Positioned( + top: 0, + left: 0, + right: 0, + height: 120, + child: IgnorePointer( + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.black.withValues(alpha: 0.38), + Colors.transparent, + ], + ), + ), + ), + ), + ), + Positioned.fill( + child: ValueListenableBuilder( + valueListenable: widget.scrollProgress, + builder: (context, rawProgress, child) { + final progress = rawProgress.clamp(0.0, 1.0); + return IgnorePointer( + child: ColoredBox( + color: Colors.black.withValues(alpha: progress * 0.5), + ), + ); + }, + ), + ), + ], + ); + } +} diff --git a/lib/features/detail/android/android_detail_colors.dart b/lib/features/detail/android/android_detail_colors.dart new file mode 100644 index 0000000..4fca5a1 --- /dev/null +++ b/lib/features/detail/android/android_detail_colors.dart @@ -0,0 +1,16 @@ +import 'dart:ui' show Color; + +class AndroidDetailColors { + AndroidDetailColors._(); + + static const background = Color(0xFF1A1A2E); + static const navBarBg = Color(0xFF141428); + static const accent = Color(0xFFE5A00D); + static const cardBg = Color(0x0AFFFFFF); + static const genrePillBg = Color(0x14FFFFFF); + static const genrePillBorder = Color(0x14FFFFFF); + static const divider = Color(0x1AFFFFFF); + static const starEmpty = Color(0x4DFFFFFF); + static const mediaTagBg = Color(0x1AFFFFFF); + static const mediaTagBorder = Color(0x1AFFFFFF); +} diff --git a/lib/features/detail/android/android_detail_geometry.dart b/lib/features/detail/android/android_detail_geometry.dart new file mode 100644 index 0000000..3bd4d9a --- /dev/null +++ b/lib/features/detail/android/android_detail_geometry.dart @@ -0,0 +1,27 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +const double androidDetailDesktopTitleBarHeight = 38.0; +const double androidDetailNavigationContentHeight = 30.0; + +double computeAndroidDetailNavigationHeight(BuildContext context) { + final topInset = MediaQuery.paddingOf(context).top; + return math.max(topInset, androidDetailDesktopTitleBarHeight) + + androidDetailNavigationContentHeight; +} + +double computeAndroidDetailBannerHeight(BuildContext context) { + final mediaQuery = MediaQuery.of(context); + final bodyHeight = mediaQuery.size.height - mediaQuery.padding.top; + final preferredHeight = (bodyHeight * 0.46).clamp(300.0, 390.0); + final landscapeSafeHeight = bodyHeight * 0.55; + + return math.min(preferredHeight, landscapeSafeHeight); +} + +double computeAndroidDetailCollapseExtent(BuildContext context) { + final bannerHeight = computeAndroidDetailBannerHeight(context); + final navigationHeight = computeAndroidDetailNavigationHeight(context); + return math.max(1.0, bannerHeight - navigationHeight); +} diff --git a/lib/features/detail/android/android_detail_layout.dart b/lib/features/detail/android/android_detail_layout.dart new file mode 100644 index 0000000..27d2759 --- /dev/null +++ b/lib/features/detail/android/android_detail_layout.dart @@ -0,0 +1,232 @@ +import 'dart:ui' show ImageFilter; + +import 'package:flutter/material.dart'; + +import '../../../shared/widgets/smart_image.dart'; +import '../widgets/detail_nav_bar.dart'; +import 'android_detail_banner.dart'; +import 'android_detail_colors.dart'; +import 'android_detail_geometry.dart'; + + +class AndroidDetailLayout extends StatelessWidget { + const AndroidDetailLayout({ + super.key, + required this.scrollController, + required this.scrollProgress, + this.backdropUrl, + this.fallbackUrls = const [], + + this.embyBaseUrl = '', + this.imageHeaders, + this.backgroundColor, + this.navigationColor, + required this.title, + required this.onBack, + required this.heroInfo, + this.heroActions, + this.overview, + this.belowOverview, + this.sections = const [], + }); + + final ScrollController scrollController; + final ValueNotifier scrollProgress; + + final String? backdropUrl; + final List fallbackUrls; + + final String embyBaseUrl; + final Map? imageHeaders; + final Color? backgroundColor; + final Color? navigationColor; + + final String title; + final VoidCallback onBack; + + final Widget heroInfo; + final Widget? heroActions; + final Widget? overview; + + + final Widget? belowOverview; + + + final List sections; + + @override + Widget build(BuildContext context) { + final topInset = MediaQuery.paddingOf(context).top; + final bannerHeight = computeAndroidDetailBannerHeight(context); + final backdrop = + backdropUrl ?? (fallbackUrls.isNotEmpty ? fallbackUrls.first : null); + final effectiveFallbackUrls = backdropUrl != null + ? fallbackUrls + : fallbackUrls.skip(1).toList(growable: false); + + final targetBackgroundColor = + backgroundColor ?? AndroidDetailColors.background; + final animationDuration = MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : const Duration(milliseconds: 350); + + return TweenAnimationBuilder( + tween: ColorTween( + begin: AndroidDetailColors.background, + end: targetBackgroundColor, + ), + duration: animationDuration, + curve: Curves.easeOutCubic, + builder: (context, animatedBackgroundColor, child) { + final effectiveBackgroundColor = + animatedBackgroundColor ?? targetBackgroundColor; + + return Scaffold( + backgroundColor: effectiveBackgroundColor, + body: Stack( + children: [ + Positioned.fill( + child: _AndroidDetailBlurredBackdrop( + imageUrl: backdrop, + fallbackUrls: effectiveFallbackUrls, + embyBaseUrl: embyBaseUrl, + imageHeaders: imageHeaders, + ), + ), + CustomScrollView( + controller: scrollController, + physics: const BouncingScrollPhysics( + parent: AlwaysScrollableScrollPhysics(), + ), + slivers: [ + if (backdrop != null) + SliverAppBar( + automaticallyImplyLeading: false, + primary: false, + pinned: false, + stretch: true, + toolbarHeight: 0, + collapsedHeight: 0, + expandedHeight: bannerHeight, + backgroundColor: Colors.transparent, + surfaceTintColor: Colors.transparent, + flexibleSpace: FlexibleSpaceBar( + collapseMode: CollapseMode.parallax, + stretchModes: const [StretchMode.zoomBackground], + background: AndroidDetailBanner( + scrollProgress: scrollProgress, + backdropUrl: backdrop, + fallbackUrls: effectiveFallbackUrls, + embyBaseUrl: embyBaseUrl, + imageHeaders: imageHeaders, + ), + ), + ) + else + SliverToBoxAdapter(child: SizedBox(height: topInset + 72)), + SliverToBoxAdapter(child: heroInfo), + if (heroActions != null) + SliverToBoxAdapter(child: heroActions!), + if (overview != null) SliverToBoxAdapter(child: overview!), + if (belowOverview != null) + SliverToBoxAdapter(child: belowOverview!), + SliverList.list( + children: [ + ...sections, + SizedBox( + height: 32 + MediaQuery.paddingOf(context).bottom, + ), + ], + ), + ], + ), + DetailNavBar( + scrollProgress: scrollProgress, + title: title, + onBack: onBack, + chromeColor: navigationColor ?? AndroidDetailColors.navBarBg, + ), + ], + ), + ); + }, + ); + } +} + + +class _AndroidDetailBlurredBackdrop extends StatelessWidget { + const _AndroidDetailBlurredBackdrop({ + required this.imageUrl, + required this.fallbackUrls, + required this.embyBaseUrl, + required this.imageHeaders, + }); + + final String? imageUrl; + final List fallbackUrls; + final String embyBaseUrl; + final Map? imageHeaders; + + static const double _blurSigma = 32; + + + static const LinearGradient _scrim = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + stops: [0.0, 0.4, 1.0], + colors: [Color(0x59000000), Color(0x8C000000), Color(0xD9000000)], + ); + + @override + Widget build(BuildContext context) { + final url = imageUrl; + + final Widget child; + if (url == null) { + child = const SizedBox.expand(key: ValueKey('__no_backdrop__')); + } else { + child = RepaintBoundary( + key: ValueKey(url), + child: Stack( + fit: StackFit.expand, + children: [ + ImageFiltered( + imageFilter: ImageFilter.blur( + sigmaX: _blurSigma, + sigmaY: _blurSigma, + tileMode: TileMode.clamp, + ), + child: SmartImage( + url: url, + fallbackUrls: fallbackUrls, + fit: BoxFit.cover, + borderRadius: 0, + memCacheWidth: 400, + fallbackIcon: null, + placeholder: const SizedBox.expand(), + resolveHttpHeaders: (candidateUrl) => + embyBaseUrl.isNotEmpty && + candidateUrl.startsWith(embyBaseUrl) + ? imageHeaders + : null, + ), + ), + const DecoratedBox(decoration: BoxDecoration(gradient: _scrim)), + ], + ), + ); + } + + return AnimatedSwitcher( + duration: const Duration(milliseconds: 350), + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeOut, + layoutBuilder: (currentChild, previousChildren) => Stack( + fit: StackFit.expand, + children: [...previousChildren, if (currentChild != null) currentChild], + ), + child: child, + ); + } +} diff --git a/lib/features/detail/android/android_external_links_section.dart b/lib/features/detail/android/android_external_links_section.dart new file mode 100644 index 0000000..b359e7d --- /dev/null +++ b/lib/features/detail/android/android_external_links_section.dart @@ -0,0 +1,122 @@ +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../widgets/section_container.dart'; + +class ExternalLinksSection extends StatelessWidget { + final String? imdbId; + final int? tmdbId; + final String? tmdbMediaType; + final int? traktId; + final bool embedded; + + + final Widget? leading; + + const ExternalLinksSection({ + super.key, + this.imdbId, + this.tmdbId, + this.tmdbMediaType, + this.traktId, + this.embedded = true, + this.leading, + }); + + bool get _hasAnyLink => imdbId != null || tmdbId != null || traktId != null; + + @override + Widget build(BuildContext context) { + if (!_hasAnyLink) return const SizedBox.shrink(); + return SectionContainer( + title: '外部链接', + embedded: embedded, + leading: leading, + child: Wrap( + spacing: 8, + runSpacing: 8, + children: [ + if (imdbId != null) + _LinkPill( + label: 'IMDb', + dotColor: const Color(0xFFF5C518), + onTap: () => + launchUrl(Uri.parse('https://www.imdb.com/title/$imdbId')), + ), + if (tmdbId != null) + _LinkPill( + label: 'TMDB', + dotColor: const Color(0xFF01B4E4), + onTap: () => launchUrl( + Uri.parse( + 'https://www.themoviedb.org/${tmdbMediaType ?? 'movie'}/$tmdbId', + ), + ), + ), + if (traktId != null) + _LinkPill( + label: 'Trakt', + dotColor: const Color(0xFFED1C24), + onTap: () => launchUrl( + Uri.parse( + 'https://trakt.tv/${tmdbMediaType == 'tv' ? 'shows' : 'movies'}/$traktId', + ), + ), + ), + ], + ), + ); + } +} + +class _LinkPill extends StatelessWidget { + final String label; + final Color dotColor; + final VoidCallback onTap; + + const _LinkPill({ + required this.label, + required this.dotColor, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.white.withValues(alpha: 0.06), + borderRadius: BorderRadius.circular(8), + child: InkWell( + borderRadius: BorderRadius.circular(8), + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.white.withValues(alpha: 0.06)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 6, + height: 6, + decoration: BoxDecoration( + color: dotColor, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 8), + Text( + label, + style: TextStyle( + fontSize: 13, + color: Colors.white.withValues(alpha: 0.65), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/detail/android/android_hero_actions.dart b/lib/features/detail/android/android_hero_actions.dart new file mode 100644 index 0000000..e0abe9a --- /dev/null +++ b/lib/features/detail/android/android_hero_actions.dart @@ -0,0 +1,193 @@ +import 'package:flutter/material.dart'; + +import 'android_detail_colors.dart'; +import '../../../shared/widgets/app_loading_ring.dart'; + +class AndroidHeroActions extends StatelessWidget { + final String playLabel; + final String? playTrailingLabel; + final double? progressPercent; + final bool isLoading; + final String loadingLabel; + final bool canPlay; + final VoidCallback? onPlay; + final bool showReplay; + final VoidCallback? onReplay; + final bool isPlayed; + final VoidCallback onTogglePlayed; + final bool isFavorite; + final VoidCallback onToggleFavorite; + + const AndroidHeroActions({ + super.key, + required this.playLabel, + this.playTrailingLabel, + this.progressPercent, + this.isLoading = false, + this.loadingLabel = '启动中', + this.canPlay = true, + this.onPlay, + this.showReplay = false, + this.onReplay, + required this.isPlayed, + required this.onTogglePlayed, + required this.isFavorite, + required this.onToggleFavorite, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + if (canPlay) + Expanded( + child: _GoldPlayButton( + label: playLabel, + trailingLabel: playTrailingLabel, + progressPercent: progressPercent, + isLoading: isLoading, + loadingLabel: loadingLabel, + onPressed: onPlay, + ), + ), + if (canPlay) const SizedBox(width: 8), + if (!canPlay) const Spacer(), + _SquareButton( + icon: isPlayed ? Icons.check_circle : Icons.check_circle_outline, + active: isPlayed, + onTap: onTogglePlayed, + ), + const SizedBox(width: 8), + _SquareButton( + icon: isFavorite ? Icons.favorite : Icons.favorite_border, + active: isFavorite, + onTap: onToggleFavorite, + ), + if (showReplay) ...[ + const SizedBox(width: 8), + _SquareButton(icon: Icons.replay, active: false, onTap: onReplay), + ], + ], + ), + ); + } +} + +class _GoldPlayButton extends StatelessWidget { + final String label; + final String? trailingLabel; + final bool isLoading; + final String loadingLabel; + final double? progressPercent; + final VoidCallback? onPressed; + + const _GoldPlayButton({ + required this.label, + this.trailingLabel, + this.isLoading = false, + this.loadingLabel = '启动中', + this.progressPercent, + this.onPressed, + }); + + @override + Widget build(BuildContext context) { + final progress = progressPercent; + final normalizedProgress = progress != null && progress > 0 + ? progress.clamp(0.0, 100.0) / 100.0 + : null; + final enabled = !isLoading && onPressed != null; + final radius = BorderRadius.circular(10); + final backgroundColor = enabled + ? AndroidDetailColors.accent + : Colors.white.withValues(alpha: 0.12); + final foregroundColor = enabled + ? Colors.black + : Colors.white.withValues(alpha: 0.38); + + return SizedBox( + height: 48, + child: Material( + color: backgroundColor, + borderRadius: radius, + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: enabled ? onPressed : null, + child: Stack( + fit: StackFit.expand, + children: [ + if (normalizedProgress != null) + FractionallySizedBox( + alignment: Alignment.centerLeft, + widthFactor: normalizedProgress, + child: ColoredBox( + color: AndroidDetailColors.accent.withValues(alpha: 0.25), + ), + ), + Center( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (isLoading) + AppLoadingRing(size: 16, color: foregroundColor) + else + Icon(Icons.play_arrow, color: foregroundColor, size: 20), + const SizedBox(width: 6), + Text( + isLoading + ? loadingLabel + : trailingLabel != null + ? '$label $trailingLabel' + : label, + style: TextStyle( + color: foregroundColor, + fontSize: 15, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} + +class _SquareButton extends StatelessWidget { + final IconData icon; + final bool active; + final VoidCallback? onTap; + + const _SquareButton({required this.icon, required this.active, this.onTap}); + + @override + Widget build(BuildContext context) { + final color = active + ? AndroidDetailColors.accent + : Colors.white.withValues(alpha: 0.8); + return SizedBox( + width: 48, + height: 48, + child: Material( + color: Colors.white.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(10), + child: InkWell( + borderRadius: BorderRadius.circular(10), + onTap: onTap, + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Colors.white.withValues(alpha: 0.15)), + ), + child: Center(child: Icon(icon, size: 20, color: color)), + ), + ), + ), + ); + } +} diff --git a/lib/features/detail/android/android_hero_info.dart b/lib/features/detail/android/android_hero_info.dart new file mode 100644 index 0000000..c806c44 --- /dev/null +++ b/lib/features/detail/android/android_hero_info.dart @@ -0,0 +1,232 @@ +import 'package:flutter/material.dart'; + +import '../../../shared/widgets/smart_image.dart'; +import 'android_detail_colors.dart'; + +class AndroidHeroInfo extends StatelessWidget { + final String? posterUrl; + final String embyBaseUrl; + final Map? imageHeaders; + final String title; + final double? rating; + final List metaParts; + final List genres; + final List mediaInfoTags; + + const AndroidHeroInfo({ + super.key, + this.posterUrl, + this.embyBaseUrl = '', + this.imageHeaders, + required this.title, + this.rating, + this.metaParts = const [], + this.genres = const [], + this.mediaInfoTags = const [], + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + boxShadow: const [ + BoxShadow( + color: Color(0x80000000), + blurRadius: 20, + offset: Offset(0, 10), + ), + ], + ), + child: SizedBox( + width: 120, + height: 180, + child: SmartImage( + url: posterUrl, + fit: BoxFit.cover, + borderRadius: 10, + placeholder: Container( + decoration: BoxDecoration( + color: const Color(0xFF2A2A4A), + borderRadius: BorderRadius.circular(10), + ), + ), + resolveHttpHeaders: (url) => + embyBaseUrl.isNotEmpty && url.startsWith(embyBaseUrl) + ? imageHeaders + : null, + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + title, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.w700, + color: Colors.white, + height: 1.2, + shadows: [ + Shadow( + color: Color(0xCC000000), + blurRadius: 12, + offset: Offset(0, 2), + ), + ], + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + if (metaParts.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + metaParts.join(' · '), + style: TextStyle( + fontSize: 13, + color: Colors.white.withValues(alpha: 0.55), + shadows: const [ + Shadow( + color: Color(0xCC000000), + blurRadius: 12, + offset: Offset(0, 2), + ), + ], + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (rating != null && rating! > 0) + Padding( + padding: const EdgeInsets.only(top: 8), + child: _StarRating(rating: rating!), + ), + if (genres.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Wrap( + spacing: 6, + runSpacing: 6, + children: genres + .map((g) => _GenrePill(label: g)) + .toList(), + ), + ), + if (mediaInfoTags.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Wrap( + spacing: 6, + runSpacing: 6, + children: mediaInfoTags + .map((t) => _MediaInfoPill(label: t)) + .toList(), + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +class _StarRating extends StatelessWidget { + final double rating; + const _StarRating({required this.rating}); + + @override + Widget build(BuildContext context) { + final stars = rating / 2.0; + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (int i = 0; i < 5; i++) + Icon( + Icons.star_rounded, + size: 16, + color: i < stars.round() + ? AndroidDetailColors.accent + : AndroidDetailColors.starEmpty, + ), + const SizedBox(width: 6), + Text( + rating.toStringAsFixed(1), + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: Colors.white.withValues(alpha: 0.8), + shadows: const [ + Shadow( + color: Color(0xCC000000), + blurRadius: 12, + offset: Offset(0, 2), + ), + ], + ), + ), + ], + ); + } +} + +class _GenrePill extends StatelessWidget { + final String label; + const _GenrePill({required this.label}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: AndroidDetailColors.genrePillBg, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: AndroidDetailColors.genrePillBorder), + ), + child: Text( + label, + style: TextStyle( + fontSize: 12, + color: Colors.white.withValues(alpha: 0.65), + ), + ), + ); + } +} + +class _MediaInfoPill extends StatelessWidget { + final String label; + const _MediaInfoPill({required this.label}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: AndroidDetailColors.mediaTagBg, + borderRadius: BorderRadius.circular(6), + border: Border.all(color: AndroidDetailColors.mediaTagBorder), + ), + child: Text( + label, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, + color: Colors.white.withValues(alpha: 0.7), + ), + ), + ); + } +} diff --git a/lib/features/detail/android/android_media_info_section.dart b/lib/features/detail/android/android_media_info_section.dart new file mode 100644 index 0000000..be13b9a --- /dev/null +++ b/lib/features/detail/android/android_media_info_section.dart @@ -0,0 +1,105 @@ +import 'package:flutter/material.dart'; + +import '../../../core/contracts/library.dart'; + +class AndroidMediaInfoSection extends StatelessWidget { + final EmbyRawMediaSource? source; + final List studioNames; + final List directors; + final Widget? leading; + + const AndroidMediaInfoSection({ + super.key, + this.source, + this.studioNames = const [], + this.directors = const [], + this.leading, + }); + + @override + Widget build(BuildContext context) { + final rows = _buildRows(); + if (rows.isEmpty) return const SizedBox.shrink(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (leading != null) leading!, + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: rows, + ), + ), + ], + ); + } + + List _buildRows() { + final src = source; + final streams = src?.MediaStreams ?? []; + final rows = []; + + final subCount = streams.where((s) => s.Type == 'Subtitle').length; + if (subCount > 0) { + _addRow(rows, '字幕', '$subCount 条字幕轨'); + } + + if (directors.isNotEmpty) { + _addRow(rows, '导演', directors.join(' · ')); + } + + if (studioNames.isNotEmpty) { + _addRow(rows, '工作室', studioNames.join(' · ')); + } + + return rows; + } + + void _addRow(List rows, String label, String value) { + if (rows.isNotEmpty) { + rows.add(Divider(color: Colors.white.withValues(alpha: 0.04), height: 1)); + } + rows.add(_InfoRow(label: label, value: value)); + } +} + +class _InfoRow extends StatelessWidget { + final String label; + final String value; + + const _InfoRow({required this.label, required this.value}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + SizedBox( + width: 56, + child: Text( + label, + style: TextStyle( + fontSize: 13, + color: Colors.white.withValues(alpha: 0.55), + ), + ), + ), + Expanded( + child: Text( + value, + style: TextStyle( + fontSize: 13, + color: Colors.white.withValues(alpha: 0.75), + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/detail/android/android_overview_section.dart b/lib/features/detail/android/android_overview_section.dart new file mode 100644 index 0000000..1d80dd2 --- /dev/null +++ b/lib/features/detail/android/android_overview_section.dart @@ -0,0 +1,105 @@ +import 'package:flutter/material.dart'; + +import '../../../shared/widgets/app_dialog.dart'; +import 'android_detail_colors.dart'; + +class AndroidOverviewSection extends StatelessWidget { + final String overview; + + const AndroidOverviewSection({super.key, required this.overview}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 12), + child: LayoutBuilder( + builder: (context, constraints) { + final textStyle = TextStyle( + fontSize: 14, + color: Colors.white.withValues(alpha: 0.6), + height: 1.65, + ); + final textSpan = TextSpan(text: overview, style: textStyle); + final tp = TextPainter( + text: textSpan, + maxLines: 1, + textDirection: TextDirection.ltr, + )..layout(maxWidth: constraints.maxWidth); + final hasOverflow = tp.didExceedMaxLines; + + return GestureDetector( + onTap: hasOverflow ? () => _showOverviewDialog(context) : null, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + overview, + style: textStyle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (hasOverflow) ...[ + const SizedBox(height: 6), + const Text( + '阅读全部', + style: TextStyle( + fontSize: 13, + color: AndroidDetailColors.accent, + ), + ), + ], + ], + ), + ); + }, + ), + ); + } + + void _showOverviewDialog(BuildContext context) { + showAppRawDialog( + context, + builder: (context) => ColoredBox( + color: const Color(0xFF1E1E2E), + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 24, 24, 16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '简介', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + const SizedBox(height: 16), + Flexible( + child: SingleChildScrollView( + child: Text( + overview, + style: TextStyle( + fontSize: 14, + color: Colors.white.withValues(alpha: 0.75), + height: 1.7, + ), + ), + ), + ), + const SizedBox(height: 12), + Align( + alignment: Alignment.centerRight, + child: TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('关闭'), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/detail/android/android_section_divider.dart b/lib/features/detail/android/android_section_divider.dart new file mode 100644 index 0000000..d99bf65 --- /dev/null +++ b/lib/features/detail/android/android_section_divider.dart @@ -0,0 +1,15 @@ +import 'package:flutter/material.dart'; +import 'package:forui/forui.dart'; + +import 'android_detail_colors.dart'; + + +class AndroidSectionDivider extends StatelessWidget { + const AndroidSectionDivider({super.key}); + + @override + Widget build(BuildContext context) => const Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: FDivider(style: .delta(color: AndroidDetailColors.divider)), + ); +} diff --git a/lib/features/detail/android/android_stream_selector_row.dart b/lib/features/detail/android/android_stream_selector_row.dart new file mode 100644 index 0000000..702d3db --- /dev/null +++ b/lib/features/detail/android/android_stream_selector_row.dart @@ -0,0 +1,168 @@ +import 'package:flutter/material.dart'; +import 'package:forui/forui.dart'; + +import '../../../core/contracts/library.dart'; +import '../../../shared/widgets/auto_dismiss_menu.dart'; +import '../widgets/stream_selector_actions.dart'; + +const _dropdownMenuMaxHeight = 320.0; + + +class AndroidStreamSelectorRow extends StatelessWidget { + + final List sources; + final int selectedSourceIdx; + final ValueChanged? onSelectSourceIndex; + + final List subtitles; + final List audios; + final int? selectedSubtitleIdx; + final int selectedAudioIdx; + final ValueChanged onSelectSubtitleIndex; + final ValueChanged onSelectAudioIndex; + + const AndroidStreamSelectorRow({ + super.key, + this.sources = const [], + this.selectedSourceIdx = 0, + this.onSelectSourceIndex, + required this.subtitles, + required this.audios, + required this.selectedSubtitleIdx, + required this.selectedAudioIdx, + required this.onSelectSubtitleIndex, + required this.onSelectAudioIndex, + }); + + @override + Widget build(BuildContext context) { + final buttons = []; + + final onSelectSource = onSelectSourceIndex; + if (sources.length > 1 && onSelectSource != null) { + buttons.add( + _SelectorButton( + icon: Icons.video_file_outlined, + label: versionSummaryLabel(sources, selectedSourceIdx), + menuChildrenBuilder: () => buildVersionMenuButtons( + sources: sources, + selectedSourceIdx: selectedSourceIdx, + onSelectSource: onSelectSource, + ), + ), + ); + } + + if (subtitles.length > 1) { + buttons.add( + _SelectorButton( + icon: Icons.subtitles_outlined, + label: subtitleSummaryLabel(subtitles, selectedSubtitleIdx), + menuChildrenBuilder: () => buildSubtitleMenuButtons( + subtitles: subtitles, + selectedSubtitleIdx: selectedSubtitleIdx, + onSelectSubtitle: onSelectSubtitleIndex, + ), + ), + ); + } + + if (audios.length > 1) { + buttons.add( + _SelectorButton( + icon: Icons.audiotrack_outlined, + label: audioSummaryLabel(audios, selectedAudioIdx), + menuChildrenBuilder: () => buildAudioMenuButtons( + audios: audios, + selectedAudioIdx: selectedAudioIdx, + onSelectAudio: onSelectAudioIndex, + ), + ), + ); + } + + if (buttons.isEmpty) return const SizedBox.shrink(); + + return SizedBox( + height: 36, + child: ListView.separated( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 16), + itemCount: buttons.length, + separatorBuilder: (_, _) => const SizedBox(width: 8), + itemBuilder: (_, index) => buttons[index], + ), + ); + } +} + +class _SelectorButton extends StatelessWidget { + final IconData icon; + final String label; + final List Function() menuChildrenBuilder; + + const _SelectorButton({ + required this.icon, + required this.label, + required this.menuChildrenBuilder, + }); + + @override + Widget build(BuildContext context) { + return FPopoverMenu( + menuBuilder: autoDismissMenuBuilder, + menuAnchor: Alignment.topCenter, + childAnchor: Alignment.bottomCenter, + maxHeight: _dropdownMenuMaxHeight, + menu: [FItemGroup(children: menuChildrenBuilder())], + builder: (context, controller, child) => GestureDetector( + onTap: controller.toggle, + child: child, + ), + child: Material( + color: Colors.white.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(10), + clipBehavior: Clip.antiAlias, + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Colors.white.withValues(alpha: 0.15)), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + size: 16, + color: Colors.white.withValues(alpha: 0.8), + ), + const SizedBox(width: 6), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 140), + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12, + color: Colors.white.withValues(alpha: 0.85), + fontWeight: FontWeight.w500, + ), + ), + ), + const SizedBox(width: 2), + Icon( + Icons.arrow_drop_down, + size: 18, + color: Colors.white.withValues(alpha: 0.6), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/detail/detail_entry.dart b/lib/features/detail/detail_entry.dart new file mode 100644 index 0000000..5dffa45 --- /dev/null +++ b/lib/features/detail/detail_entry.dart @@ -0,0 +1,14 @@ +import '../../core/contracts/tmdb.dart'; + + +class TmdbDetailEntry { + final String mediaType; + final String tmdbId; + final TmdbRecommendation? seed; + + const TmdbDetailEntry({ + required this.mediaType, + required this.tmdbId, + this.seed, + }); +} diff --git a/lib/features/detail/emby_detail_body_android.dart b/lib/features/detail/emby_detail_body_android.dart new file mode 100644 index 0000000..a64b719 --- /dev/null +++ b/lib/features/detail/emby_detail_body_android.dart @@ -0,0 +1,470 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/contracts/auth.dart'; +import '../../core/contracts/library.dart'; +import '../../providers/detail_palette_provider.dart'; +import '../../providers/di_providers.dart'; +import '../../providers/session_provider.dart'; +import '../../providers/tmdb_settings_provider.dart'; +import '../../providers/version_priority_provider.dart'; +import '../../shared/mappers/media_image_url.dart'; +import '../../shared/mappers/tmdb_image_selector.dart'; +import '../../shared/utils/format_utils.dart'; +import '../../shared/utils/user_error_formatter.dart'; +import '../../shared/widgets/app_error_state.dart'; +import '../../shared/widgets/app_loading_ring.dart'; +import 'android/android_detail_layout.dart'; +import 'android/android_detail_colors.dart'; +import 'android/android_external_links_section.dart'; +import 'android/android_hero_actions.dart'; +import 'android/android_hero_info.dart'; +import 'android/android_overview_section.dart'; +import 'android/android_section_divider.dart'; +import 'android/android_stream_selector_row.dart'; +import 'emby_detail_helpers.dart'; +import 'emby_detail_sections_builder.dart'; +import 'model/stream_selection.dart'; + +class EmbyDetailBodyAndroid extends ConsumerWidget { + const EmbyDetailBodyAndroid({ + super.key, + required this.snap, + required this.detailData, + required this.seedItem, + required this.selectedItem, + required this.tmdbSeriesItem, + required this.detailServerId, + required this.initialSeasonId, + required this.pendingMediaSourceId, + required this.scrollController, + required this.scrollProgress, + required this.imageHeaders, + required this.session, + required this.priorities, + required this.selectedSourceIdx, + required this.selectedSubtitleIdx, + required this.selectedAudioIdx, + required this.selectedCrossServerCard, + required this.isPlayed, + required this.isFavorite, + required this.isLaunchingPlayer, + required this.onBack, + required this.onRetry, + required this.onTogglePlayed, + required this.onToggleFavorite, + required this.onSelectSourceIndex, + required this.onSelectSubtitleIndex, + required this.onSelectAudioIndex, + required this.onEpisodeTap, + required this.onSelectCrossServerSource, + required this.onApplyPendingMediaSource, + required this.onClearPendingMediaSource, + required this.onLaunchPlayer, + }); + + final AsyncSnapshot snap; + final MediaDetailRes? detailData; + final EmbyRawItem? seedItem; + final EmbyRawItem? selectedItem; + final EmbyRawItem? tmdbSeriesItem; + final String? detailServerId; + final String? initialSeasonId; + final String? pendingMediaSourceId; + final ScrollController scrollController; + final ValueNotifier scrollProgress; + final Map? imageHeaders; + final AuthedSession session; + final List priorities; + final int selectedSourceIdx; + final int? selectedSubtitleIdx; + final int selectedAudioIdx; + final CrossServerSourceCard? selectedCrossServerCard; + final bool isPlayed; + final bool isFavorite; + final bool isLaunchingPlayer; + final VoidCallback onBack; + final VoidCallback onRetry; + final VoidCallback onTogglePlayed; + final VoidCallback onToggleFavorite; + final ValueChanged onSelectSourceIndex; + final ValueChanged onSelectSubtitleIndex; + final ValueChanged onSelectAudioIndex; + final ValueChanged onEpisodeTap; + final ValueChanged onSelectCrossServerSource; + final ValueChanged onApplyPendingMediaSource; + final VoidCallback onClearPendingMediaSource; + final Future Function( + EmbyRawItem displayItem, + List sources, { + bool fromStart, + int? overrideSubtitleIdx, + String? backdropUrl, + }) + onLaunchPlayer; + + @override + Widget build(BuildContext context, WidgetRef ref) { + if (snap.connectionState != ConnectionState.done && + detailData == null && + seedItem == null) { + return Scaffold( + appBar: AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: onBack, + ), + ), + body: const Center(child: AppLoadingRing()), + ); + } + + final data = detailData ?? snap.data; + if (snap.hasError && data == null) { + return Scaffold( + appBar: AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: onBack, + ), + ), + body: AppErrorState( + message: formatUserError(snap.error, fallback: '详情加载失败'), + onRetry: onRetry, + ), + ); + } + + final baseItem = data?.base ?? seedItem; + if (baseItem == null) return const Center(child: Text('未找到数据')); + final isSeedOnly = data == null; + final displayItem = selectedItem ?? baseItem; + + final tmdbMediaRef = tmdbMediaRefFor(baseItem, tmdbSeriesItem); + final tmdbSettings = tmdbMediaRef != null + ? ref.watch(tmdbSettingsProvider).value + : null; + final tmdbImageSet = + tmdbMediaRef != null && (tmdbSettings?.canRequest ?? false) + ? ref.watch( + tmdbImagesProvider(( + tmdbId: tmdbMediaRef.id, + mediaType: tmdbMediaRef.mediaType, + )), + ) + : null; + final tmdbImageBaseUrl = tmdbSettings?.effectiveImageBaseUrl; + final tmdbDetail = + tmdbMediaRef != null && (tmdbSettings?.canRequest ?? false) + ? ref.watch( + tmdbMediaDetailProvider(( + tmdbId: tmdbMediaRef.id, + mediaType: tmdbMediaRef.mediaType, + )), + ) + : null; + + final tmdbBackdropUrl = TmdbImageSelector.backdropUrl( + tmdbImageSet, + tmdbImageBaseUrl, + ); + final tmdbPosterUrl = TmdbImageSelector.posterUrl( + tmdbImageSet, + tmdbImageBaseUrl, + ); + + final embyBackdropUrl = EmbyImageUrl.detailBanner( + baseUrl: session.serverUrl, + token: session.token, + item: baseItem, + ); + + final String? embyPosterUrl; + if (baseItem.Type == 'Episode' && + (baseItem.SeriesId?.isNotEmpty ?? false)) { + embyPosterUrl = EmbyImageUrl.primaryById( + baseUrl: session.serverUrl, + token: session.token, + itemId: baseItem.SeriesId!, + maxHeight: 720, + ); + } else { + embyPosterUrl = EmbyImageUrl.primary( + baseUrl: session.serverUrl, + token: session.token, + item: baseItem, + maxHeight: 720, + ); + } + + final backdropUrl = tmdbBackdropUrl ?? embyBackdropUrl; + final posterUrl = tmdbPosterUrl ?? embyPosterUrl; + final backgroundFallbackUrls = TmdbImageSelector.buildFallbackUrls( + primaryUrl: backdropUrl, + candidates: [tmdbPosterUrl, embyBackdropUrl, embyPosterUrl], + ); + final paletteImageUrl = posterUrl ?? backdropUrl; + final paletteImageHeaders = paletteImageUrl.startsWith(session.serverUrl) + ? imageHeaders + : null; + final extractedBackgroundColor = ref + .watch( + detailPaletteProvider( + DetailPaletteRequest( + url: paletteImageUrl, + headers: paletteImageHeaders, + ), + ), + ) + .value; + final detailBackgroundColor = + extractedBackgroundColor ?? AndroidDetailColors.background; + + final canPlay = displayItem.Type != 'Series'; + final rawSources = mediaSourcesOfItem(displayItem); + final sortedSources = sortMediaSources(rawSources, priorities); + + final pendingId = pendingMediaSourceId; + if (pendingId != null && sortedSources.isNotEmpty) { + final idx = resolveDefaultSourceIndex( + sortedSources, + pendingId, + fallback: -1, + ); + if (idx >= 0 && idx != selectedSourceIdx) { + WidgetsBinding.instance.addPostFrameCallback((_) { + onApplyPendingMediaSource(idx); + }); + } else { + onClearPendingMediaSource(); + } + } + + final selectedSrc = resolveActiveMediaSource( + selectedCrossServerCard, + sortedSources, + selectedSourceIdx, + ); + final int? effectiveSubtitleIdx = selectedSubtitleIdx; + final streamSelection = resolveStreamSelection( + selectedSrc, + selectedSubtitleIdx, + selectedAudioIdx, + ); + + final String? seriesId; + if (baseItem.Type == 'Series') { + seriesId = baseItem.Id; + } else if (baseItem.Type == 'Episode' && + (baseItem.SeriesId?.isNotEmpty ?? false)) { + seriesId = baseItem.SeriesId; + } else { + seriesId = null; + } + + final similarItemId = + baseItem.Type == 'Episode' && (baseItem.SeriesId?.isNotEmpty ?? false) + ? baseItem.SeriesId! + : baseItem.Id; + + final frozenOverview = baseItem.Overview; + final embyOverview = (frozenOverview ?? displayItem.Overview)?.trim(); + final overviewFallback = + (tmdbDetail?.overview ?? + tmdbSeriesItem?.Overview ?? + (baseItem.Type == 'Series' ? baseItem.Overview : null)) + ?.trim(); + final overview = embyOverview != null && embyOverview.isNotEmpty + ? embyOverview + : overviewFallback; + + final metaParts = buildDetailMetaParts(displayItem, source: selectedSrc); + final genres = extractGenres(baseItem); + + final progressPercent = detailProgressPercent(displayItem); + final hasProgress = progressPercent != null && progressPercent > 0; + final pos = displayItem.UserData?.PlaybackPositionTicks; + final positionClock = pos != null && pos > 0 + ? formatTicksAsClock(pos) + : null; + + final studiosRaw = baseItem.extra['Studios']; + final studioNames = studiosRaw is List + ? studiosRaw + .whereType() + .map((s) => s['Name']?.toString() ?? '') + .where((n) => n.isNotEmpty) + .toList() + : []; + + final peopleRaw = baseItem.extra['People']; + final directors = peopleRaw is List + ? peopleRaw + .whereType() + .where((p) => p['Type'] == 'Director') + .map((p) => p['Name']?.toString() ?? '') + .where((n) => n.isNotEmpty) + .toList() + : []; + + final mediaInfoTags = _buildMediaInfoTags(selectedSrc); + + final sections = buildEmbyDetailSections( + ref: ref, + baseItem: baseItem, + displayItem: displayItem, + seriesId: seriesId, + similarItemId: similarItemId, + tmdbMediaRef: tmdbMediaRef, + detailServerId: detailServerId, + initialSeasonId: initialSeasonId, + tmdbSeriesItem: tmdbSeriesItem, + sortedSources: sortedSources, + selectedSourceIdx: selectedSourceIdx, + selectedCrossServerCard: selectedCrossServerCard, + currentServerName: ref.read(activeSessionProvider)?.serverName, + onEpisodeTap: onEpisodeTap, + onSelectSourceIndex: onSelectSourceIndex, + onSelectCrossServerSource: onSelectCrossServerSource, + activeSource: selectedSrc, + studioNames: studioNames, + directors: directors, + sectionLeading: const AndroidSectionDivider(), + ); + if (isSeedOnly) sections.removeRange(1, sections.length); + + final providerIds = providerIdsOfItem(baseItem); + final imdbId = + providerIds['Imdb'] ?? providerIds['IMDB'] ?? providerIds['imdb']; + final tmdbIdRaw = + providerIds['Tmdb'] ?? providerIds['TMDB'] ?? providerIds['tmdb']; + final tmdbIdInt = tmdbIdRaw != null ? int.tryParse(tmdbIdRaw) : null; + final traktRaw = + providerIds['TraktTv'] ?? providerIds['Trakt'] ?? providerIds['trakt']; + final traktIdInt = traktRaw != null ? int.tryParse(traktRaw) : null; + + final sectionWidgets = [ + for (final section in sections) section.child, + ]; + + sectionWidgets.add( + ExternalLinksSection( + imdbId: imdbId, + tmdbId: tmdbIdInt, + tmdbMediaType: tmdbMediaRef?.mediaType, + traktId: traktIdInt, + leading: const AndroidSectionDivider(), + ), + ); + + return AndroidDetailLayout( + scrollController: scrollController, + scrollProgress: scrollProgress, + backdropUrl: backdropUrl, + fallbackUrls: backgroundFallbackUrls, + embyBaseUrl: session.serverUrl, + imageHeaders: imageHeaders, + backgroundColor: detailBackgroundColor, + navigationColor: createDetailNavigationColor(detailBackgroundColor), + title: detailNavTitle(displayItem, tmdbSeriesItem), + onBack: onBack, + heroInfo: AndroidHeroInfo( + posterUrl: posterUrl, + embyBaseUrl: session.serverUrl, + imageHeaders: imageHeaders, + title: displayItem.Name, + rating: displayItem.CommunityRating, + metaParts: metaParts, + genres: genres, + mediaInfoTags: mediaInfoTags, + ), + heroActions: isSeedOnly + ? null + : Column( + mainAxisSize: MainAxisSize.min, + children: [ + AndroidHeroActions( + playLabel: hasProgress ? '继续播放' : '播放', + playTrailingLabel: positionClock, + progressPercent: progressPercent, + isLoading: isLaunchingPlayer, + canPlay: canPlay, + onPlay: canPlay + ? () => unawaited( + onLaunchPlayer( + displayItem, + sortedSources, + overrideSubtitleIdx: effectiveSubtitleIdx, + backdropUrl: backdropUrl, + ), + ) + : null, + showReplay: hasProgress && canPlay, + onReplay: canPlay + ? () => unawaited( + onLaunchPlayer( + displayItem, + sortedSources, + fromStart: true, + overrideSubtitleIdx: effectiveSubtitleIdx, + backdropUrl: backdropUrl, + ), + ) + : null, + isPlayed: isPlayed, + onTogglePlayed: onTogglePlayed, + isFavorite: isFavorite, + onToggleFavorite: onToggleFavorite, + ), + if (canPlay) + AndroidStreamSelectorRow( + sources: sortedSources, + selectedSourceIdx: selectedSourceIdx, + onSelectSourceIndex: onSelectSourceIndex, + subtitles: streamSelection.subtitles, + audios: streamSelection.audios, + selectedSubtitleIdx: selectedSubtitleIdx, + selectedAudioIdx: selectedAudioIdx, + onSelectSubtitleIndex: onSelectSubtitleIndex, + onSelectAudioIndex: onSelectAudioIndex, + ), + ], + ), + overview: overview != null && overview.isNotEmpty + ? AndroidOverviewSection(overview: overview) + : null, + belowOverview: null, + sections: sectionWidgets, + ); + } + + static List _buildMediaInfoTags(EmbyRawMediaSource? source) { + if (source == null) return const []; + final tags = []; + final streams = source.MediaStreams ?? []; + + final video = streams.where((s) => s.Type == 'Video').firstOrNull; + if (video != null) { + final codec = video.Codec?.toUpperCase(); + if (codec != null && codec.isNotEmpty) tags.add(codec); + final range = video.extra['VideoRange']?.toString(); + if (range != null && range.isNotEmpty && range != 'SDR') { + tags.add(range); + } + } + + final audio = streams.where((s) => s.Type == 'Audio').firstOrNull; + if (audio != null) { + final codec = audio.Codec?.toUpperCase(); + if (codec != null && codec.isNotEmpty) tags.add(codec); + } + + if (source.Size != null && source.Size! > 0) { + tags.add(formatFileSize(source.Size!)); + } + + return tags; + } +} diff --git a/lib/features/detail/emby_detail_body_builder.dart b/lib/features/detail/emby_detail_body_builder.dart new file mode 100644 index 0000000..dbfe0f9 --- /dev/null +++ b/lib/features/detail/emby_detail_body_builder.dart @@ -0,0 +1,371 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/contracts/auth.dart'; +import '../../core/contracts/library.dart'; +import '../../providers/di_providers.dart'; +import '../../providers/session_provider.dart'; +import '../../providers/tmdb_settings_provider.dart'; +import '../../providers/version_priority_provider.dart'; +import '../../shared/constants/breakpoints.dart'; +import '../../shared/mappers/media_image_url.dart'; +import '../../shared/mappers/tmdb_image_selector.dart'; +import '../../shared/utils/user_error_formatter.dart'; +import '../../shared/widgets/app_error_state.dart'; +import '../../shared/widgets/app_loading_ring.dart'; +import 'emby_detail_helpers.dart'; +import 'emby_detail_hero_builder.dart'; +import 'emby_detail_sections_builder.dart'; +import 'model/detail_hero_model.dart'; +import 'model/detail_image_model.dart'; +import 'model/detail_view_state.dart'; +import 'model/stream_selection.dart'; +import 'widgets/detail_immersive_scaffold.dart'; +import 'widgets/detail_nav_bar.dart'; +import 'widgets/detail_view_renderer.dart'; + +Widget buildEmbyDetailBody({ + required BuildContext context, + required WidgetRef ref, + required AsyncSnapshot snap, + required MediaDetailRes? detailData, + required EmbyRawItem? seedItem, + required EmbyRawItem? selectedItem, + required EmbyRawItem? tmdbSeriesItem, + required String? detailServerId, + required String? initialSeasonId, + required String? pendingMediaSourceId, + + required ScrollController scrollController, + required ValueNotifier scrollProgress, + required Map? imageHeaders, + required AuthedSession session, + required List priorities, + required int selectedSourceIdx, + required int? selectedSubtitleIdx, + required int selectedAudioIdx, + required CrossServerSourceCard? selectedCrossServerCard, + required bool isPlayed, + required bool isFavorite, + required bool isLaunchingPlayer, + required VoidCallback onBack, + required VoidCallback onRetry, + required VoidCallback onTogglePlayed, + required VoidCallback onToggleFavorite, + required ValueChanged onSelectSourceIndex, + required ValueChanged onSelectSubtitleIndex, + required ValueChanged onSelectAudioIndex, + required ValueChanged onEpisodeTap, + required ValueChanged onSelectCrossServerSource, + required ValueChanged onApplyPendingMediaSource, + required VoidCallback onClearPendingMediaSource, + required Future Function( + EmbyRawItem displayItem, + List sources, { + bool fromStart, + int? overrideSubtitleIdx, + String? backdropUrl, + }) + onLaunchPlayer, +}) { + if (snap.connectionState != ConnectionState.done && + detailData == null && + seedItem == null) { + return Stack( + children: [ + const Center(child: AppLoadingRing()), + Positioned( + top: 0, + left: 0, + right: 0, + child: DetailNavBar( + scrollProgress: scrollProgress, + title: '', + onBack: onBack, + ), + ), + ], + ); + } + + final data = detailData ?? snap.data; + if (snap.hasError && data == null) { + return Stack( + children: [ + AppErrorState( + message: formatUserError(snap.error, fallback: '详情加载失败'), + onRetry: onRetry, + ), + Positioned( + top: 0, + left: 0, + right: 0, + child: DetailNavBar( + scrollProgress: scrollProgress, + title: '', + onBack: onBack, + ), + ), + ], + ); + } + + final baseItem = data?.base ?? seedItem; + if (baseItem == null) return const Center(child: Text('未找到数据')); + final isSeedOnly = data == null; + final displayItem = selectedItem ?? baseItem; + final compact = MediaQuery.sizeOf(context).width < Breakpoints.detail; + + final tmdbMediaRef = tmdbMediaRefFor(baseItem, tmdbSeriesItem); + final tmdbSettings = tmdbMediaRef != null + ? ref.watch(tmdbSettingsProvider).value + : null; + final tmdbImageSet = + tmdbMediaRef != null && (tmdbSettings?.canRequest ?? false) + ? ref.watch( + tmdbImagesProvider(( + tmdbId: tmdbMediaRef.id, + mediaType: tmdbMediaRef.mediaType, + )), + ) + : null; + final tmdbImageBaseUrl = tmdbSettings?.effectiveImageBaseUrl; + final tmdbDetail = tmdbMediaRef != null && (tmdbSettings?.canRequest ?? false) + ? ref.watch( + tmdbMediaDetailProvider(( + tmdbId: tmdbMediaRef.id, + mediaType: tmdbMediaRef.mediaType, + )), + ) + : null; + + final tmdbBackdropUrl = TmdbImageSelector.backdropUrl( + tmdbImageSet, + tmdbImageBaseUrl, + ); + final tmdbPosterUrl = TmdbImageSelector.posterUrl( + tmdbImageSet, + tmdbImageBaseUrl, + ); + + final embyBackdropUrl = EmbyImageUrl.detailBanner( + baseUrl: session.serverUrl, + token: session.token, + item: baseItem, + ); + + final String? embyPosterUrl; + if (baseItem.Type == 'Episode' && (baseItem.SeriesId?.isNotEmpty ?? false)) { + embyPosterUrl = EmbyImageUrl.primaryById( + baseUrl: session.serverUrl, + token: session.token, + itemId: baseItem.SeriesId!, + maxHeight: 720, + ); + } else { + embyPosterUrl = EmbyImageUrl.primary( + baseUrl: session.serverUrl, + token: session.token, + item: baseItem, + maxHeight: 720, + ); + } + + final backdropUrl = tmdbBackdropUrl ?? embyBackdropUrl; + final backgroundFallbackUrls = TmdbImageSelector.buildFallbackUrls( + primaryUrl: backdropUrl, + candidates: [tmdbPosterUrl, embyBackdropUrl, embyPosterUrl], + ); + + final imageModel = DetailImageModel( + backdropUrl: backdropUrl, + fallbackUrls: backgroundFallbackUrls, + embyBaseUrl: session.serverUrl, + imageHeaders: imageHeaders, + ); + + final isEpisode = baseItem.Type == 'Episode'; + final logoItemId = isEpisode && (baseItem.SeriesId?.isNotEmpty ?? false) + ? baseItem.SeriesId! + : baseItem.Id; + final logoTag = isEpisode ? null : baseItem.ImageTags?['Logo']; + final logoUrl = !isEpisode && logoTag == null + ? null + : EmbyImageUrl.logo( + baseUrl: session.serverUrl, + token: session.token, + itemId: logoItemId, + tag: logoTag, + ); + + final canPlay = displayItem.Type != 'Series'; + final rawSources = mediaSourcesOfItem(displayItem); + final sortedSources = sortMediaSources(rawSources, priorities); + + final pendingId = pendingMediaSourceId; + if (pendingId != null && sortedSources.isNotEmpty) { + final idx = resolveDefaultSourceIndex( + sortedSources, + pendingId, + fallback: -1, + ); + if (idx >= 0 && idx != selectedSourceIdx) { + WidgetsBinding.instance.addPostFrameCallback((_) { + onApplyPendingMediaSource(idx); + }); + } else { + onClearPendingMediaSource(); + } + } + + final selectedSrc = resolveActiveMediaSource( + selectedCrossServerCard, + sortedSources, + selectedSourceIdx, + ); + + final streamSelection = resolveStreamSelection( + selectedSrc, + selectedSubtitleIdx, + selectedAudioIdx, + ); + final subtitles = streamSelection.subtitles; + final audios = streamSelection.audios; + final int? effectiveSubtitleIdx = selectedSubtitleIdx; + + final String? seriesId; + if (baseItem.Type == 'Series') { + seriesId = baseItem.Id; + } else if (baseItem.Type == 'Episode' && + (baseItem.SeriesId?.isNotEmpty ?? false)) { + seriesId = baseItem.SeriesId; + } else { + seriesId = null; + } + + final similarItemId = + baseItem.Type == 'Episode' && (baseItem.SeriesId?.isNotEmpty ?? false) + ? baseItem.SeriesId! + : baseItem.Id; + + final frozenOverview = baseItem.Overview; + final embyOverview = (frozenOverview ?? displayItem.Overview)?.trim(); + final overviewFallback = + (tmdbDetail?.overview ?? + tmdbSeriesItem?.Overview ?? + (baseItem.Type == 'Series' ? baseItem.Overview : null)) + ?.trim(); + final overview = embyOverview != null && embyOverview.isNotEmpty + ? embyOverview + : overviewFallback; + + final heroActions = isSeedOnly + ? null + : buildEmbyHeroActions( + displayItem: displayItem, + canPlay: canPlay, + sortedSources: sortedSources, + subtitles: subtitles, + audios: audios, + effectiveSubtitleIdx: effectiveSubtitleIdx, + compact: compact, + selectedSourceIdx: selectedSourceIdx, + selectedSubtitleIdx: selectedSubtitleIdx, + selectedAudioIdx: selectedAudioIdx, + isPlayed: isPlayed, + isFavorite: isFavorite, + isLaunchingPlayer: isLaunchingPlayer, + onSelectSource: onSelectSourceIndex, + onSelectSubtitle: onSelectSubtitleIndex, + onSelectAudio: onSelectAudioIndex, + onTogglePlayed: onTogglePlayed, + onToggleFavorite: onToggleFavorite, + onReplay: () => unawaited( + onLaunchPlayer( + displayItem, + sortedSources, + fromStart: true, + overrideSubtitleIdx: effectiveSubtitleIdx, + backdropUrl: backdropUrl, + ), + ), + onPlay: () => unawaited( + onLaunchPlayer( + displayItem, + sortedSources, + overrideSubtitleIdx: effectiveSubtitleIdx, + backdropUrl: backdropUrl, + ), + ), + ); + + final sections = buildEmbyDetailSections( + ref: ref, + baseItem: baseItem, + displayItem: displayItem, + seriesId: seriesId, + similarItemId: similarItemId, + tmdbMediaRef: tmdbMediaRef, + detailServerId: detailServerId, + initialSeasonId: initialSeasonId, + tmdbSeriesItem: tmdbSeriesItem, + sortedSources: sortedSources, + selectedSourceIdx: selectedSourceIdx, + selectedCrossServerCard: selectedCrossServerCard, + currentServerName: ref.read(activeSessionProvider)?.serverName, + onEpisodeTap: onEpisodeTap, + onSelectSourceIndex: onSelectSourceIndex, + onSelectCrossServerSource: onSelectCrossServerSource, + ); + + if (isSeedOnly) { + sections.removeRange(1, sections.length); + } + + final viewState = DetailViewState( + image: imageModel, + navTitle: detailNavTitle(displayItem, tmdbSeriesItem), + hero: DetailHeroModel( + title: displayItem.Name, + logoUrl: logoUrl, + posterUrl: embyPosterUrl, + imageHeaders: imageHeaders, + rating: displayItem.CommunityRating, + metaParts: buildDetailMetaParts(displayItem, source: selectedSrc), + genres: extractGenres(baseItem), + tagline: tmdbDetail?.tagline, + overview: overview, + actions: heroActions, + ), + sections: sections, + showLoadingBelowHero: isSeedOnly, + ); + + return Stack( + children: [ + DetailImmersiveScaffold( + scrollController: scrollController, + scrollProgress: scrollProgress, + backdropUrl: viewState.image.backdropUrl, + fallbackUrls: viewState.image.fallbackUrls, + embyBaseUrl: viewState.image.embyBaseUrl, + imageHeaders: viewState.image.imageHeaders, + compact: compact, + horizontalPadding: compact ? 16 : 32, + child: DetailViewRenderer(state: viewState), + ), + Positioned( + top: 0, + left: 0, + right: 0, + child: DetailNavBar( + scrollProgress: scrollProgress, + title: viewState.navTitle, + onBack: onBack, + ), + ), + ], + ); +} diff --git a/lib/features/detail/emby_detail_helpers.dart b/lib/features/detail/emby_detail_helpers.dart new file mode 100644 index 0000000..11deff3 --- /dev/null +++ b/lib/features/detail/emby_detail_helpers.dart @@ -0,0 +1,164 @@ +import '../../core/contracts/library.dart'; +import '../../shared/utils/cross_server_search_key.dart'; +import '../../shared/utils/emby_ticks.dart'; +import '../../shared/utils/format_utils.dart'; +import '../../shared/utils/tmdb_key_utils.dart'; + + +class TmdbMediaRef { + final String id; + final String mediaType; + + const TmdbMediaRef({required this.id, required this.mediaType}); +} + +List buildDetailMetaParts( + EmbyRawItem item, { + EmbyRawMediaSource? source, +}) { + final parts = []; + final seriesName = item.SeriesName; + if (seriesName != null && seriesName.isNotEmpty) { + parts.add(seriesName); + } + if (item.ProductionYear != null) { + parts.add('${item.ProductionYear}'); + } + final episode = episodeLabel(item); + if (episode != null) { + parts.add(episode); + } + final runtime = item.RunTimeTicks; + if (runtime != null && runtime > 0) { + final label = formatRuntimeMinutes(runtime ~/ kTicksPerMinute); + if (label != null) parts.add(label); + } + if (source != null) { + final videoStream = findVideoStream(source); + if (videoStream != null) { + final h = videoStream.extra['Height'] as int? ?? source.Height; + final range = videoStream.extra['VideoRange'] as String?; + if (h != null) { + final label = heightToLabel(h); + parts.add(range != null && range.isNotEmpty ? '$label $range' : label); + } + } + final size = source.Size; + if (size != null && size > 0) parts.add(formatFileSize(size)); + } + return parts; +} + +EmbyRawMediaStream? findVideoStream(EmbyRawMediaSource source) { + final streams = source.MediaStreams; + if (streams == null) return null; + for (final s in streams) { + if (s.Type == 'Video') return s; + } + return null; +} + +String heightToLabel(int h) { + if (h >= 2160) return '4K'; + if (h >= 1440) return '2K'; + if (h >= 1080) return '1080P'; + if (h >= 720) return '720P'; + return '${h}P'; +} + +String? episodeLabel(EmbyRawItem item) { + final seasonName = item.SeasonName; + final index = item.IndexNumber; + if (seasonName == null && index == null) return null; + if (seasonName != null && seasonName.isNotEmpty && index != null) { + return '$seasonName ${formatEpisodeNumber(index)}'; + } + if (seasonName != null && seasonName.isNotEmpty) return seasonName; + return formatEpisodeNumber(index); +} + +String detailNavTitle(EmbyRawItem item, EmbyRawItem? seriesItem) { + if (item.Type != 'Episode') return item.Name; + final seriesName = item.SeriesName; + if (seriesName != null && seriesName.isNotEmpty) return seriesName; + final loadedSeriesName = seriesItem?.Name; + if (loadedSeriesName != null && loadedSeriesName.isNotEmpty) { + return loadedSeriesName; + } + return item.Name; +} + +String? seriesIdForEpisode(EmbyRawItem item, String routeItemId) { + if (item.Type != 'Episode') return null; + final seriesId = item.SeriesId; + if (seriesId != null && seriesId.isNotEmpty) return seriesId; + if (routeItemId.isNotEmpty && routeItemId != item.Id) return routeItemId; + return null; +} + + +Map lowercaseProviderIds(Map ids) { + return { + for (final e in ids.entries) + if (e.key.isNotEmpty && e.value.isNotEmpty) e.key.toLowerCase(): e.value, + }; +} + +List extractGenres(EmbyRawItem item) { + final genresRaw = item.extra['Genres']; + if (genresRaw is List) { + return genresRaw.whereType().where((g) => g.isNotEmpty).toList(); + } + return const []; +} + +double? detailProgressPercent(EmbyRawItem item) { + final percent = playbackProgress(item) * 100; + return percent < 1 ? null : percent; +} + +String tmdbIdFromDetailItem(EmbyRawItem item) => tmdbIdFromItem(item); + +TmdbMediaRef? tmdbMediaRefFor(EmbyRawItem item, EmbyRawItem? tmdbSeriesItem) { + final String mediaType; + final String tmdbId; + switch (item.Type) { + case 'Movie': + mediaType = 'movie'; + tmdbId = tmdbIdFromDetailItem(item); + case 'Series': + mediaType = 'tv'; + tmdbId = tmdbIdFromDetailItem(item); + case 'Episode': + final seriesItem = tmdbSeriesItem; + if (seriesItem == null) return null; + mediaType = 'tv'; + tmdbId = tmdbIdFromDetailItem(seriesItem); + default: + return null; + } + if (tmdbId.isEmpty) return null; + return TmdbMediaRef(id: tmdbId, mediaType: mediaType); +} + +CrossServerSearchReq? buildCrossServerReqForDetail( + EmbyRawItem displayItem, + EmbyRawItem? seriesItem, +) { + final providerIds = providerIdsOfItem(displayItem); + if (displayItem.Type == 'Movie') { + if (providerIds.isEmpty && displayItem.Name.isEmpty) return null; + return CrossServerSearchReq( + anchorType: 'Movie', + itemName: displayItem.Name, + providerIds: providerIds, + ); + } + if (displayItem.Type == 'Episode') { + return buildEpisodeCrossServerReq( + episode: displayItem, + seriesItem: seriesItem, + ); + } + return null; +} diff --git a/lib/features/detail/emby_detail_hero_builder.dart b/lib/features/detail/emby_detail_hero_builder.dart new file mode 100644 index 0000000..aa570d7 --- /dev/null +++ b/lib/features/detail/emby_detail_hero_builder.dart @@ -0,0 +1,78 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../../core/contracts/library.dart'; +import '../../shared/utils/format_utils.dart'; +import 'emby_detail_helpers.dart'; +import 'widgets/hero_action_buttons.dart'; +import 'widgets/stream_selector_actions.dart'; + +Widget buildEmbyHeroActions({ + required EmbyRawItem displayItem, + required bool canPlay, + required List sortedSources, + required List subtitles, + required List audios, + required int? effectiveSubtitleIdx, + required bool compact, + required int selectedSourceIdx, + required int? selectedSubtitleIdx, + required int selectedAudioIdx, + required bool isPlayed, + required bool isFavorite, + required bool isLaunchingPlayer, + required ValueChanged onSelectSource, + required ValueChanged onSelectSubtitle, + required ValueChanged onSelectAudio, + required VoidCallback onTogglePlayed, + required VoidCallback onToggleFavorite, + required VoidCallback onPlay, + required VoidCallback onReplay, +}) { + final btnH = compact ? 44.0 : 52.0; + final progressPercent = detailProgressPercent(displayItem); + final hasProgress = progressPercent != null && progressPercent > 0; + final pos = displayItem.UserData?.PlaybackPositionTicks; + final positionClock = pos != null && pos > 0 ? formatTicksAsClock(pos) : null; + + final extraActions = buildStreamSelectorPills( + subtitles: subtitles, + audios: audios, + selectedSubtitleIdx: selectedSubtitleIdx, + selectedAudioIdx: selectedAudioIdx, + onSelectSubtitle: onSelectSubtitle, + onSelectAudio: onSelectAudio, + height: btnH, + ); + + if (sortedSources.length > 1) { + extraActions.add( + HeroPillDropdownButton( + height: btnH, + icon: Icons.video_file_outlined, + menuChildren: buildVersionMenuButtons( + sources: sortedSources, + selectedSourceIdx: selectedSourceIdx, + onSelectSource: onSelectSource, + ), + ), + ); + } + + return DetailHeroActions( + showReplay: hasProgress && canPlay, + onReplay: canPlay ? () => unawaited(Future.sync(onReplay)) : null, + isPlayed: isPlayed, + onTogglePlayed: () => unawaited(Future.sync(onTogglePlayed)), + isFavorite: isFavorite, + onToggleFavorite: () => unawaited(Future.sync(onToggleFavorite)), + extraIconActions: extraActions, + onPlay: canPlay ? () => unawaited(Future.sync(onPlay)) : null, + playLabel: hasProgress ? '继续播放' : '播放', + progressPercent: progressPercent, + trailingLabel: positionClock, + isPlayLoading: isLaunchingPlayer, + compact: compact, + ); +} diff --git a/lib/features/detail/emby_detail_sections_builder.dart b/lib/features/detail/emby_detail_sections_builder.dart new file mode 100644 index 0000000..50abca7 --- /dev/null +++ b/lib/features/detail/emby_detail_sections_builder.dart @@ -0,0 +1,204 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/contracts/library.dart'; +import '../../providers/di_providers.dart'; +import 'android/android_media_info_section.dart'; +import 'widgets/additional_parts_section.dart'; +import 'widgets/emby_cast_section.dart'; +import 'widgets/episode_list_section.dart'; +import 'widgets/similar_section.dart'; +import 'widgets/special_features_section.dart'; +import 'emby_detail_helpers.dart'; +import 'model/detail_section_model.dart'; +import 'model/episode_auto_select_args.dart'; +import 'widgets/source_selection_section.dart'; + +List buildEmbyDetailSections({ + required WidgetRef ref, + required EmbyRawItem baseItem, + required EmbyRawItem displayItem, + required String? seriesId, + required String similarItemId, + required TmdbMediaRef? tmdbMediaRef, + required String? detailServerId, + required String? initialSeasonId, + required EmbyRawItem? tmdbSeriesItem, + required List sortedSources, + required int selectedSourceIdx, + required CrossServerSourceCard? selectedCrossServerCard, + required String? currentServerName, + required ValueChanged onEpisodeTap, + required ValueChanged onSelectSourceIndex, + required ValueChanged onSelectCrossServerSource, + EmbyRawMediaSource? activeSource, + List studioNames = const [], + List directors = const [], + + + Widget? sectionLeading, +}) { + return [ + if (seriesId != null) + DetailSection( + id: 'emby_episodes', + child: _buildEpisodesSection( + ref: ref, + baseItem: baseItem, + displayItem: displayItem, + seriesId: seriesId, + tmdbMediaRef: tmdbMediaRef, + detailServerId: detailServerId, + initialSeasonId: initialSeasonId, + tmdbSeriesItem: tmdbSeriesItem, + onEpisodeTap: onEpisodeTap, + leading: sectionLeading, + ), + ), + if (displayItem.Type == 'Movie' || displayItem.Type == 'Episode') + DetailSection( + id: 'emby_source', + child: Padding( + padding: const EdgeInsets.only(top: 24), + child: _buildSourceSection( + baseItem: baseItem, + displayItem: displayItem, + tmdbSeriesItem: tmdbSeriesItem, + sortedSources: sortedSources, + selectedSourceIdx: selectedSourceIdx, + selectedCrossServerCard: selectedCrossServerCard, + currentServerName: currentServerName, + onSelectSourceIndex: onSelectSourceIndex, + onSelectCrossServerSource: onSelectCrossServerSource, + leading: sectionLeading, + ), + ), + ), + DetailSection( + id: 'emby_cast', + child: EmbyCastSection( + item: baseItem, + embedded: true, + leading: sectionLeading, + ), + ), + DetailSection( + id: 'emby_media_detail', + child: AndroidMediaInfoSection( + source: activeSource, + studioNames: studioNames, + directors: directors, + leading: sectionLeading, + ), + ), + if (baseItem.Type != 'Series') + DetailSection( + id: 'emby_additional_parts', + child: AdditionalPartsSection( + itemId: displayItem.Id, + embedded: true, + leading: sectionLeading, + ), + ), + if (baseItem.Type != 'Series') + DetailSection( + id: 'emby_special_features', + child: SpecialFeaturesSection( + itemId: displayItem.Id, + embedded: true, + leading: sectionLeading, + ), + ), + DetailSection( + id: 'emby_similar', + child: SimilarSection( + itemId: similarItemId, + embedded: true, + leading: sectionLeading, + ), + ), + ]; +} + +Widget _buildEpisodesSection({ + required WidgetRef ref, + required EmbyRawItem baseItem, + required EmbyRawItem displayItem, + required String seriesId, + required TmdbMediaRef? tmdbMediaRef, + required String? detailServerId, + required String? initialSeasonId, + required EmbyRawItem? tmdbSeriesItem, + required ValueChanged onEpisodeTap, + Widget? leading, +}) { + final showItem = baseItem.Type == 'Series' ? baseItem : tmdbSeriesItem; + final resumeAsync = baseItem.Type == 'Series' && detailServerId != null + ? ref.watch( + seriesResumeTargetProvider(( + serverId: detailServerId, + seriesId: seriesId, + )), + ) + : null; + final autoSelect = EpisodeAutoSelectArgs.fromResume(resumeAsync); + return EpisodeListSection( + seriesId: seriesId, + tmdbSeriesId: tmdbMediaRef != null && tmdbMediaRef.mediaType == 'tv' + ? tmdbMediaRef.id + : null, + currentEpisodeId: displayItem.Type == 'Episode' ? displayItem.Id : null, + currentEpisodeSeasonId: displayItem.Type == 'Episode' + ? displayItem.SeasonId + : null, + currentEpisodeSeasonNumber: displayItem.Type == 'Episode' + ? (displayItem.extra['ParentIndexNumber'] as num?)?.toInt() + : null, + currentEpisodeIndexNumber: displayItem.Type == 'Episode' + ? displayItem.IndexNumber + : null, + initialSeasonId: initialSeasonId, + autoSelectFirstEpisode: autoSelect.autoSelectFirstEpisode, + autoSelectEpisodeId: autoSelect.autoSelectEpisodeId, + autoSelectSeasonId: autoSelect.autoSelectSeasonId, + onEpisodeTap: onEpisodeTap, + embedded: true, + showProviderIds: showItem != null + ? lowercaseProviderIds(providerIdsOfItem(showItem)) + : null, + showTitle: showItem?.Name, + showYear: showItem?.ProductionYear, + leading: leading, + ); +} + +Widget _buildSourceSection({ + required EmbyRawItem baseItem, + required EmbyRawItem displayItem, + required EmbyRawItem? tmdbSeriesItem, + required List sortedSources, + required int selectedSourceIdx, + required CrossServerSourceCard? selectedCrossServerCard, + required String? currentServerName, + required ValueChanged onSelectSourceIndex, + required ValueChanged onSelectCrossServerSource, + Widget? leading, +}) { + final seriesItemForReq = baseItem.Type == 'Series' + ? baseItem + : tmdbSeriesItem; + final req = buildCrossServerReqForDetail(displayItem, seriesItemForReq); + return SourceSelectionSection( + req: req, + localSources: sortedSources, + selectedSourceIdx: selectedSourceIdx, + selectedCrossServerCard: selectedCrossServerCard, + currentServerName: currentServerName, + localMediaInfoItem: displayItem, + showLoadingWhenEmpty: true, + loadingText: '正在查找可用版本…', + onLocalSourceTap: (src, index) => onSelectSourceIndex(index), + onCrossServerSourceTap: onSelectCrossServerSource, + leading: leading, + ); +} diff --git a/lib/features/detail/emby_detail_view.dart b/lib/features/detail/emby_detail_view.dart new file mode 100644 index 0000000..fbcf3bb --- /dev/null +++ b/lib/features/detail/emby_detail_view.dart @@ -0,0 +1,806 @@ +import 'dart:async'; +import 'dart:io' show Platform; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../core/contracts/library.dart'; +import '../../providers/di_providers.dart'; +import '../../providers/emby_headers_provider.dart'; +import '../../providers/last_played_version_store.dart'; +import '../../providers/playback_info_prefetch_provider.dart'; +import '../../providers/playback_active_provider.dart'; +import '../../providers/session_provider.dart'; +import '../../providers/version_priority_provider.dart'; +import '../../shared/theme/app_theme.dart'; +import '../../shared/utils/cross_server_search_key.dart'; +import '../../shared/utils/app_logger.dart'; +import '../../shared/widgets/app_snack_bar.dart'; +import '../player/player_launcher.dart'; +import 'emby_detail_body_android.dart'; +import 'emby_detail_body_builder.dart'; +import 'emby_detail_helpers.dart'; +import 'android/android_detail_geometry.dart'; +import 'model/selected_episode_merge.dart'; +import 'model/stream_selection.dart'; +import 'widgets/detail_scroll_progress.dart'; + + +class EmbyDetailView extends ConsumerStatefulWidget { + final String itemId; + final String? initialSeriesId; + + + final String? initialSeasonId; + + + final EmbyRawItem? seedItem; + + const EmbyDetailView({ + super.key, + required this.itemId, + this.initialSeriesId, + this.initialSeasonId, + this.seedItem, + }); + + @override + ConsumerState createState() => _EmbyDetailViewState(); +} + +class _EmbyDetailViewState extends ConsumerState { + static const _tag = 'EmbyDetailView'; + + Future? _detailFuture; + MediaDetailRes? _data; + EmbyRawItem? _selectedItem; + EmbyRawItem? _tmdbSeriesItem; + bool _isFavorite = false; + bool _isPlayed = false; + int _selectedSourceIdx = 0; + int? _selectedSubtitleIdx; + int _selectedAudioIdx = 0; + bool _isLaunchingPlayer = false; + + String? _detailServerId; + String? _originServerId; + bool _serverReloadScheduled = false; + String? _overrideItemId; + String? _pendingMediaSourceId; + bool _isRestoringOriginServer = false; + + + CrossServerSourceCard? _selectedCrossServerCard; + + + bool _crossServerPlaybackInFlight = false; + bool _backInFlight = false; + final _scrollController = ScrollController(); + final _scrollProgress = ValueNotifier(0.0); + late final DetailPageActivity _detailActivity; + + + bool _didEnterDetailActivity = false; + + String get _detailItemId { + if (_overrideItemId != null && _overrideItemId!.isNotEmpty) { + return _overrideItemId!; + } + return widget.itemId; + } + + @override + void initState() { + super.initState(); + _detailActivity = ref.read(detailPageActivityProvider.notifier); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _didEnterDetailActivity = true; + _detailActivity.enter(); + }); + _originServerId = ref.read(activeSessionProvider)?.serverId; + _scrollController.addListener(_onScroll); + _reload(); + } + + @override + void dispose() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_didEnterDetailActivity) _detailActivity.leave(); + }); + _scrollController.removeListener(_onScroll); + _scrollController.dispose(); + _scrollProgress.dispose(); + super.dispose(); + } + + void _onScroll() { + _scrollProgress.value = computeDetailScrollProgress( + context, + _scrollController.offset, + collapseExtent: Platform.isAndroid + ? computeAndroidDetailCollapseExtent(context) + : null, + ); + } + + @override + void didUpdateWidget(covariant EmbyDetailView oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.itemId != widget.itemId || + oldWidget.initialSeriesId != widget.initialSeriesId || + oldWidget.initialSeasonId != widget.initialSeasonId) { + _overrideItemId = null; + _reload(); + } + } + + + void _reload({bool invalidateCache = false}) { + _detailServerId = ref.read(activeSessionProvider)?.serverId; + _serverReloadScheduled = false; + + _resetScrollState(); + if (invalidateCache) { + final serverId = _detailServerId ?? ''; + ref.invalidate( + mediaDetailDataProvider((serverId: serverId, itemId: _detailItemId)), + ); + } + setState(() { + _data = null; + _selectedItem = null; + _tmdbSeriesItem = null; + _selectedSourceIdx = 0; + _selectedSubtitleIdx = null; + _selectedAudioIdx = 0; + _selectedCrossServerCard = null; + _isLaunchingPlayer = false; + _detailFuture = _loadDetail(); + }); + } + + void _resetScrollState() { + _scrollProgress.value = 0.0; + if (_scrollController.hasClients) { + _scrollController.jumpTo(0); + } + } + + Future _loadDetail() async { + final capturedServerId = _detailServerId ?? ''; + final initialSeriesId = widget.initialSeriesId; + if (initialSeriesId != null && + initialSeriesId.isNotEmpty && + capturedServerId.isNotEmpty) { + unawaited( + ref.read( + mediaDetailDataProvider(( + serverId: capturedServerId, + itemId: initialSeriesId, + )).future, + ), + ); + } + final res = await ref.read( + mediaDetailDataProvider(( + serverId: capturedServerId, + itemId: _detailItemId, + )).future, + ); + if (!mounted) return res; + AppLogger.debug( + _tag, + '_loadDetail entry itemId=$_detailItemId ' + 'detailServerId=$capturedServerId ' + 'activeServerId=${ref.read(activeSessionProvider)?.serverId} ' + 'base.Id=${res.base.Id} type=${res.base.Type} ' + 'seriesId=${res.base.SeriesId} seasonId=${res.base.SeasonId} ' + 'index=${res.base.IndexNumber} ' + 'sources=${mediaSourcesOfItem(res.base).length}', + ); + setState(() { + _data = res; + _selectedItem = res.base; + _isFavorite = res.base.UserData?.IsFavorite ?? false; + _isPlayed = res.base.UserData?.Played ?? false; + }); + unawaited(_loadRememberedVersion(res.base)); + final seriesId = seriesIdForEpisode(res.base, widget.itemId); + if (seriesId != null) { + unawaited(_loadSeriesInfo(seriesId, capturedServerId, res.base)); + } else { + _firePrewarm(res.base); + } + _prefetchPlaybackInfoFor(res.base); + return res; + } + + Future _loadSeriesInfo( + String seriesId, + String serverId, + EmbyRawItem episode, + ) async { + try { + final seriesRes = await ref.read( + mediaDetailDataProvider((serverId: serverId, itemId: seriesId)).future, + ); + if (!mounted) return; + setState(() { + _tmdbSeriesItem = seriesRes.base; + }); + _firePrewarm(seriesRes.base); + } catch (_) { + _firePrewarm(episode); + } + } + + void _firePrewarm(EmbyRawItem refItem) { + if (refItem.Type != 'Series' && refItem.Type != 'Episode') return; + final pids = providerIdsOfItem(refItem); + if (pids.isEmpty) return; + final activeServerId = ref.read(activeSessionProvider)?.serverId ?? ''; + if (activeServerId.isEmpty) return; + unawaited( + ref.read( + seriesPrewarmProvider(( + activeServerId: activeServerId, + seriesProviderIdQuery: encodeProviderIdsForKey(pids), + )).future, + ), + ); + } + + void _syncDetailWithActiveServer(String serverId) { + final loadedServerId = _detailServerId; + if (loadedServerId == null) { + _detailServerId = serverId; + return; + } + if (_crossServerPlaybackInFlight) return; + if (loadedServerId == serverId || _serverReloadScheduled) return; + + _serverReloadScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _serverReloadScheduled = false; + + final currentServerId = ref.read(activeSessionProvider)?.serverId; + if (currentServerId == null || _detailServerId == currentServerId) { + return; + } + _reload(invalidateCache: true); + }); + } + + + void _selectCrossServerSource(CrossServerSourceCard card) { + setState(() { + _selectedCrossServerCard = card; + _selectedSubtitleIdx = null; + _selectedAudioIdx = 0; + }); + _prefetchPlaybackInfoForCrossServer(card); + } + + void _onEpisodeTap(EmbyRawItem episode) { + AppLogger.debug( + _tag, + '_onEpisodeTap id=${episode.Id} name=${episode.Name} ' + 'type=${episode.Type} seriesId=${episode.SeriesId} ' + 'seasonId=${episode.SeasonId} index=${episode.IndexNumber} ' + 'positionTicks=${episode.UserData?.PlaybackPositionTicks} ' + 'sources=${mediaSourcesOfItem(episode).length}', + ); + setState(() { + _selectedItem = episode; + _isFavorite = episode.UserData?.IsFavorite ?? false; + _isPlayed = episode.UserData?.Played ?? false; + _selectedSourceIdx = 0; + _selectedSubtitleIdx = null; + _selectedAudioIdx = 0; + _selectedCrossServerCard = null; + }); + unawaited(_loadSelectedEpisodeDetail(episode.Id)); + } + + + Future _loadSelectedEpisodeDetail(String episodeId) async { + final serverId = _detailServerId ?? ''; + if (serverId.isEmpty || episodeId.isEmpty) return; + try { + final res = await ref.read( + mediaDetailDataProvider((serverId: serverId, itemId: episodeId)).future, + ); + final listItem = _selectedItem; + if (!mounted || listItem?.Id != episodeId) return; + final merged = listItem != null + ? mergeSelectedEpisodeDetail(detail: res.base, listItem: listItem) + : res.base; + final mergedSources = mediaSourcesOfItem(merged); + AppLogger.debug( + _tag, + '_loadSelectedEpisodeDetail episodeId=$episodeId ' + 'listItem.Id=${listItem?.Id} detail.base.Id=${res.base.Id} ' + 'merged.Id=${merged.Id} mergedSources=${mergedSources.length} ' + 'firstSourceId=${mergedSources.isNotEmpty ? mergedSources.first.Id : null}', + ); + setState(() { + _selectedItem = merged; + _isFavorite = merged.UserData?.IsFavorite ?? false; + _isPlayed = merged.UserData?.Played ?? false; + }); + unawaited(_loadRememberedVersion(merged)); + _prefetchPlaybackInfoFor(merged); + } catch (_) {} + } + + void _selectSourceIndex(int index) { + setState(() { + _selectedSourceIdx = index; + _selectedSubtitleIdx = null; + _selectedAudioIdx = 0; + _selectedCrossServerCard = null; + _pendingMediaSourceId = null; + }); + final item = _selectedItem; + if (item != null) _prefetchPlaybackInfoFor(item); + } + + void _selectSubtitleIndex(int? index) { + setState(() => _selectedSubtitleIdx = index); + } + + void _selectAudioIndex(int index) { + setState(() => _selectedAudioIdx = index); + } + + void _applyPendingMediaSource(int index) { + if (!mounted) return; + setState(() { + _selectedSourceIdx = index; + _pendingMediaSourceId = null; + }); + final item = _selectedItem; + if (item != null) _prefetchPlaybackInfoFor(item); + } + + void _clearPendingMediaSource() { + _pendingMediaSourceId = null; + } + + int _prefetchStartTimeTicksForCrossServer(CrossServerSourceCard card) { + final selectedTicks = _selectedItem?.UserData?.PlaybackPositionTicks; + if (selectedTicks != null && selectedTicks > 0) return selectedTicks; + return 0; + } + + void _prefetchPlaybackInfoFor(EmbyRawItem item) { + final serverId = _detailServerId ?? ''; + if (serverId.isEmpty || item.Id.isEmpty || item.Type == 'Series') return; + final key = _playbackInfoPrefetchKeyFor( + item, + mediaSourceId: _currentLocalMediaSourceIdFor(item), + fromStart: false, + ); + AppLogger.debug( + _tag, + 'playbackInfo prefetch request local ' + '${describePlaybackInfoPrefetchKey(key)}', + ); + unawaited(ref.read(playbackInfoPrefetchProvider(key).future)); + } + + void _prefetchPlaybackInfoForCrossServer(CrossServerSourceCard card) { + if (card.serverId.isEmpty || + card.landingItemId.isEmpty || + card.item.Type == 'Series') { + return; + } + final key = ( + serverId: card.serverId, + itemId: card.landingItemId, + mediaSourceId: card.mediaSourceId.isEmpty ? null : card.mediaSourceId, + startTimeTicks: _prefetchStartTimeTicksForCrossServer(card), + ); + AppLogger.debug( + _tag, + 'playbackInfo prefetch request crossServer ' + '${describePlaybackInfoPrefetchKey(key)}', + ); + unawaited(ref.read(playbackInfoPrefetchProvider(key).future)); + } + + PlaybackInfoPrefetchKey _playbackInfoPrefetchKeyFor( + EmbyRawItem item, { + String? mediaSourceId, + required bool fromStart, + }) { + return ( + serverId: _detailServerId ?? '', + itemId: item.Id, + mediaSourceId: mediaSourceId, + startTimeTicks: fromStart + ? 0 + : (item.UserData?.PlaybackPositionTicks ?? 0), + ); + } + + String? _currentLocalMediaSourceIdFor(EmbyRawItem item) { + if (_selectedCrossServerCard != null) return null; + final priorities = + ref.read(versionPriorityProvider).value?.activePriorities ?? []; + final sources = sortMediaSources(mediaSourcesOfItem(item), priorities); + if (_selectedSourceIdx < 0 || _selectedSourceIdx >= sources.length) { + return null; + } + return sources[_selectedSourceIdx].Id; + } + + + Future _loadRememberedVersion(EmbyRawItem item) async { + final serverId = _detailServerId ?? ''; + if (serverId.isEmpty || item.Id.isEmpty || item.Type == 'Series') return; + try { + final remembered = await ref + .read(lastPlayedVersionStoreProvider) + .get(serverId: serverId, itemId: item.Id); + if (!mounted || remembered == null) return; + if (_selectedItem?.Id != item.Id) return; + if (_selectedSourceIdx != 0) return; + setState(() => _pendingMediaSourceId = remembered); + } catch (_) {} + } + + void _refreshSelectedItemState(EmbyRawItem item) { + setState(() { + _selectedItem = item; + _isFavorite = item.UserData?.IsFavorite ?? false; + _isPlayed = item.UserData?.Played ?? false; + }); + } + + Future _toggleFavorite() async { + final item = _selectedItem; + if (item == null) return; + final next = !_isFavorite; + final uc = await ref.read(setFavoriteUseCaseProvider.future); + final res = await uc.execute( + FavoriteSetReq(itemId: item.Id, isFavorite: next), + ); + if (!mounted) return; + setState(() => _isFavorite = res.isFavorite); + final serverId = _detailServerId; + if (serverId != null && serverId.isNotEmpty) { + ref.invalidate( + mediaDetailDataProvider((serverId: serverId, itemId: item.Id)), + ); + } + } + + Future _togglePlayed() async { + final item = _selectedItem; + if (item == null) return; + final next = !_isPlayed; + final uc = await ref.read(setPlayedUseCaseProvider.future); + final res = await uc.execute(PlayedSetReq(itemId: item.Id, played: next)); + if (!mounted) return; + setState(() => _isPlayed = res.played); + unawaited(_syncTraktPlayedChange(item, res.played)); + final serverId = _detailServerId; + if (serverId != null && serverId.isNotEmpty) { + ref.invalidate( + mediaDetailDataProvider((serverId: serverId, itemId: item.Id)), + ); + } + } + + Future _syncTraktPlayedChange(EmbyRawItem item, bool played) async { + try { + final service = await ref.read(traktSyncServiceProvider.future); + await service.syncEmbyPlayedChange( + item: item, + played: played, + seriesItem: _tmdbSeriesItem, + ); + } catch (_) {} + } + + Future _goBack() async { + if (_backInFlight || _isRestoringOriginServer) return; + _backInFlight = true; + final currentServerId = ref.read(activeSessionProvider)?.serverId; + if (_originServerId != null && currentServerId != _originServerId) { + setState(() => _isRestoringOriginServer = true); + try { + await ref + .read(sessionProvider.notifier) + .switchSession(_originServerId!); + } catch (_) { + if (!mounted) return; + setState(() => _isRestoringOriginServer = false); + _backInFlight = false; + showAppSnackBar(context, '恢复原服务器失败', tone: AppSnackTone.error); + return; + } + } + if (!mounted) return; + if (_isRestoringOriginServer) { + setState(() => _isRestoringOriginServer = false); + } + if (context.canPop()) { + context.pop(); + } else { + context.go('/'); + } + } + + Future _launchPlayer( + EmbyRawItem displayItem, + List sources, { + bool fromStart = false, + int? overrideSubtitleIdx, + String? backdropUrl, + }) async { + if (_isLaunchingPlayer) return; + setState(() => _isLaunchingPlayer = true); + try { + final crossServerCard = _selectedCrossServerCard; + final selectedSrc = _selectedSourceIdx < sources.length + ? sources[_selectedSourceIdx] + : null; + final subIdx = overrideSubtitleIdx ?? _selectedSubtitleIdx; + final audioIdx = _selectedAudioIdx; + final streamSelection = resolveStreamSelection( + selectedSrc, + subIdx, + audioIdx, + ); + final subStreamIndex = streamSelection.preferredSubtitleStreamIndex; + final audioStreamIndex = streamSelection.preferredAudioStreamIndex; + + final crossServerMediaSourceId = + crossServerCard?.mediaSourceId.isEmpty ?? true + ? null + : crossServerCard?.mediaSourceId; + final playedItemId = crossServerCard?.landingItemId ?? displayItem.Id; + final playServerId = crossServerCard?.serverId ?? _detailServerId; + final playMediaSourceId = crossServerCard != null + ? crossServerMediaSourceId + : selectedSrc?.Id; + final playStartTicks = crossServerCard != null + ? _prefetchStartTimeTicksForCrossServer(crossServerCard) + : (displayItem.UserData?.PlaybackPositionTicks ?? 0); + final effectiveFromStart = crossServerCard != null + ? (fromStart || playStartTicks <= 0) + : fromStart; + AppLogger.debug( + _tag, + '_launchPlayer displayItem.Id=$playedItemId name=${displayItem.Name} ' + 'type=${displayItem.Type} selectedSourceIdx=$_selectedSourceIdx ' + 'serverId=$playServerId crossServer=${crossServerCard != null} ' + 'selectedSrc.Id=${selectedSrc?.Id} sources=${sources.length} ' + 'subIdx=$subIdx audioIdx=$audioIdx ' + 'subStreamIndex=$subStreamIndex audioStreamIndex=$audioStreamIndex ' + 'fromStart=$effectiveFromStart ' + 'positionTicks=$playStartTicks', + ); + Future doLaunch() => PlayerLauncher.launch( + context: context, + itemId: playedItemId, + serverId: playServerId?.isEmpty ?? true ? null : playServerId, + mediaSourceId: playMediaSourceId, + preferredSubtitleStreamIndex: crossServerCard != null + ? -1 + : subStreamIndex, + preferredAudioStreamIndex: crossServerCard != null + ? null + : audioStreamIndex, + startPositionTicks: effectiveFromStart ? null : playStartTicks, + fromStart: effectiveFromStart, + backdropUrl: backdropUrl, + ); + final PlayerExitResult? exitResult; + if (crossServerCard != null) { + exitResult = await _withCrossServerSession( + crossServerCard, + action: doLaunch, + ); + } else { + exitResult = await doLaunch(); + } + final positionTicks = exitResult?.positionTicks; + if (mounted && positionTicks != null && positionTicks > 0) { + final item = _selectedItem; + if (item != null && item.Id == exitResult?.itemId) { + final userData = (item.UserData ?? const EmbyRawUserData()).copyWith( + PlaybackPositionTicks: positionTicks, + ); + _refreshSelectedItemState(item.copyWith(UserData: userData)); + final exitSourceId = exitResult?.mediaSourceId; + if (exitSourceId != null && exitSourceId.isNotEmpty) { + final idx = sources.indexWhere((s) => s.Id == exitSourceId); + if (idx != -1 && idx != _selectedSourceIdx) { + _selectSourceIndex(idx); + } + } + } + } + unawaited( + _refreshSelectedItemProgress( + displayItem.Id, + optimisticTicks: positionTicks, + ), + ); + } finally { + if (mounted && _isLaunchingPlayer) { + setState(() => _isLaunchingPlayer = false); + } + } + } + + Future _withCrossServerSession( + CrossServerSourceCard card, { + required Future Function() action, + }) async { + _crossServerPlaybackInFlight = true; + final detailServerId = _detailServerId; + try { + if (ref.read(activeSessionProvider)?.serverId != card.serverId) { + await ref.read(sessionProvider.notifier).switchSession(card.serverId); + } + return await action(); + } finally { + if (mounted && + detailServerId != null && + detailServerId.isNotEmpty && + ref.read(activeSessionProvider)?.serverId != detailServerId) { + try { + await ref + .read(sessionProvider.notifier) + .switchSession(detailServerId); + } catch (_) {} + } + if (mounted) _crossServerPlaybackInFlight = false; + } + } + + Future _refreshSelectedItemProgress( + String itemId, { + int? optimisticTicks, + }) async { + if (!mounted) return; + try { + final capturedServerId = _detailServerId ?? ''; + final uc = await ref.read(mediaDetailUseCaseProvider.future); + if (!mounted) return; + final res = await uc.execute(MediaDetailReq(itemId: itemId)); + final prev = _selectedItem; + if (!mounted || prev?.Id != itemId) return; + var merged = prev != null + ? mergeSelectedEpisodeDetail(detail: res.base, listItem: prev) + : res.base; + if (optimisticTicks != null && + optimisticTicks > 0 && + merged.UserData?.Played != true) { + final serverTicks = merged.UserData?.PlaybackPositionTicks ?? 0; + if (optimisticTicks > serverTicks) { + merged = merged.copyWith( + UserData: (merged.UserData ?? const EmbyRawUserData()).copyWith( + PlaybackPositionTicks: optimisticTicks, + ), + ); + } + } + _refreshSelectedItemState(merged); + if (capturedServerId.isNotEmpty) { + ref.invalidate( + mediaDetailDataProvider((serverId: capturedServerId, itemId: itemId)), + ); + } + } catch (_) {} + } + + @override + Widget build(BuildContext context) { + final session = ref.watch(activeSessionProvider); + if (session == null) return const SizedBox.shrink(); + _syncDetailWithActiveServer(session.serverId); + + final imageHeaders = ref.watch(embyImageHeadersProvider).value; + final priorities = + ref.watch(versionPriorityProvider).value?.activePriorities ?? []; + + Widget buildBody(AsyncSnapshot snap) { + if (Platform.isAndroid) { + return EmbyDetailBodyAndroid( + snap: snap, + detailData: _data, + seedItem: widget.seedItem, + selectedItem: _selectedItem, + tmdbSeriesItem: _tmdbSeriesItem, + detailServerId: _detailServerId, + initialSeasonId: widget.initialSeasonId, + pendingMediaSourceId: _pendingMediaSourceId, + + scrollController: _scrollController, + scrollProgress: _scrollProgress, + imageHeaders: imageHeaders, + session: session, + priorities: priorities, + selectedSourceIdx: _selectedSourceIdx, + selectedSubtitleIdx: _selectedSubtitleIdx, + selectedAudioIdx: _selectedAudioIdx, + selectedCrossServerCard: _selectedCrossServerCard, + isPlayed: _isPlayed, + isFavorite: _isFavorite, + isLaunchingPlayer: _isLaunchingPlayer, + onBack: () => unawaited(_goBack()), + onRetry: () => _reload(invalidateCache: true), + onTogglePlayed: () => unawaited(_togglePlayed()), + onToggleFavorite: () => unawaited(_toggleFavorite()), + onSelectSourceIndex: _selectSourceIndex, + onSelectSubtitleIndex: _selectSubtitleIndex, + onSelectAudioIndex: _selectAudioIndex, + onEpisodeTap: _onEpisodeTap, + onSelectCrossServerSource: _selectCrossServerSource, + onApplyPendingMediaSource: _applyPendingMediaSource, + onClearPendingMediaSource: _clearPendingMediaSource, + onLaunchPlayer: _launchPlayer, + ); + } + return buildEmbyDetailBody( + context: context, + ref: ref, + snap: snap, + detailData: _data, + seedItem: widget.seedItem, + selectedItem: _selectedItem, + tmdbSeriesItem: _tmdbSeriesItem, + detailServerId: _detailServerId, + initialSeasonId: widget.initialSeasonId, + pendingMediaSourceId: _pendingMediaSourceId, + + scrollController: _scrollController, + scrollProgress: _scrollProgress, + imageHeaders: imageHeaders, + session: session, + priorities: priorities, + selectedSourceIdx: _selectedSourceIdx, + selectedSubtitleIdx: _selectedSubtitleIdx, + selectedAudioIdx: _selectedAudioIdx, + selectedCrossServerCard: _selectedCrossServerCard, + isPlayed: _isPlayed, + isFavorite: _isFavorite, + isLaunchingPlayer: _isLaunchingPlayer, + onBack: () => unawaited(_goBack()), + onRetry: () => _reload(invalidateCache: true), + onTogglePlayed: () => unawaited(_togglePlayed()), + onToggleFavorite: () => unawaited(_toggleFavorite()), + onSelectSourceIndex: _selectSourceIndex, + onSelectSubtitleIndex: _selectSubtitleIndex, + onSelectAudioIndex: _selectAudioIndex, + onEpisodeTap: _onEpisodeTap, + onSelectCrossServerSource: _selectCrossServerSource, + onApplyPendingMediaSource: _applyPendingMediaSource, + onClearPendingMediaSource: _clearPendingMediaSource, + onLaunchPlayer: _launchPlayer, + ); + } + + final content = FutureBuilder( + future: _detailFuture, + builder: (_, snap) => buildBody(snap), + ); + + if (Platform.isAndroid) { + return Theme(data: AppTheme.dark(), child: content); + } + + return Theme( + data: AppTheme.dark(), + child: Scaffold(backgroundColor: Colors.black, body: content), + ); + } +} diff --git a/lib/features/detail/model/detail_hero_model.dart b/lib/features/detail/model/detail_hero_model.dart new file mode 100644 index 0000000..ed7ab4f --- /dev/null +++ b/lib/features/detail/model/detail_hero_model.dart @@ -0,0 +1,30 @@ +import 'package:flutter/widgets.dart'; + + +class DetailHeroModel { + final String title; + final String? logoUrl; + final String? posterUrl; + final double? rating; + final List metaParts; + final List genres; + final String? tagline; + final String? overview; + final Widget? actions; + + + final Map? imageHeaders; + + const DetailHeroModel({ + required this.title, + this.logoUrl, + this.posterUrl, + this.rating, + this.metaParts = const [], + this.genres = const [], + this.tagline, + this.overview, + this.actions, + this.imageHeaders, + }); +} diff --git a/lib/features/detail/model/detail_image_model.dart b/lib/features/detail/model/detail_image_model.dart new file mode 100644 index 0000000..066e1f1 --- /dev/null +++ b/lib/features/detail/model/detail_image_model.dart @@ -0,0 +1,15 @@ + + +class DetailImageModel { + final String? backdropUrl; + final List fallbackUrls; + final String embyBaseUrl; + final Map? imageHeaders; + + const DetailImageModel({ + this.backdropUrl, + this.fallbackUrls = const [], + this.embyBaseUrl = '', + this.imageHeaders, + }); +} diff --git a/lib/features/detail/model/detail_section_model.dart b/lib/features/detail/model/detail_section_model.dart new file mode 100644 index 0000000..b562a71 --- /dev/null +++ b/lib/features/detail/model/detail_section_model.dart @@ -0,0 +1,9 @@ +import 'package:flutter/widgets.dart'; + + +class DetailSection { + final String id; + final Widget child; + + const DetailSection({required this.id, required this.child}); +} diff --git a/lib/features/detail/model/detail_view_state.dart b/lib/features/detail/model/detail_view_state.dart new file mode 100644 index 0000000..203074e --- /dev/null +++ b/lib/features/detail/model/detail_view_state.dart @@ -0,0 +1,29 @@ +import 'detail_hero_model.dart'; +import 'detail_image_model.dart'; +import 'detail_section_model.dart'; + + +class DetailViewState { + + final DetailImageModel image; + + + final String navTitle; + + + final DetailHeroModel hero; + + + final List sections; + + + final bool showLoadingBelowHero; + + const DetailViewState({ + required this.image, + required this.navTitle, + required this.hero, + this.sections = const [], + this.showLoadingBelowHero = false, + }); +} diff --git a/lib/features/detail/model/episode_auto_select_args.dart b/lib/features/detail/model/episode_auto_select_args.dart new file mode 100644 index 0000000..86e288c --- /dev/null +++ b/lib/features/detail/model/episode_auto_select_args.dart @@ -0,0 +1,34 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../core/domain/usecases/series_resume_target.dart'; + + +class EpisodeAutoSelectArgs { + + final bool autoSelectFirstEpisode; + + + final String? autoSelectEpisodeId; + + + final String? autoSelectSeasonId; + + const EpisodeAutoSelectArgs({ + required this.autoSelectFirstEpisode, + required this.autoSelectEpisodeId, + required this.autoSelectSeasonId, + }); + + + factory EpisodeAutoSelectArgs.fromResume( + AsyncValue? resumeAsync, + ) { + final loading = resumeAsync?.isLoading ?? false; + final target = resumeAsync?.value; + return EpisodeAutoSelectArgs( + autoSelectFirstEpisode: !loading, + autoSelectEpisodeId: target?.episodeId, + autoSelectSeasonId: target?.seasonId, + ); + } +} diff --git a/lib/features/detail/model/selected_episode_merge.dart b/lib/features/detail/model/selected_episode_merge.dart new file mode 100644 index 0000000..bcfd1fc --- /dev/null +++ b/lib/features/detail/model/selected_episode_merge.dart @@ -0,0 +1,38 @@ +import '../../../core/contracts/library.dart'; + + +EmbyRawItem mergeSelectedEpisodeDetail({ + required EmbyRawItem detail, + required EmbyRawItem listItem, +}) { + final mergedRunTime = detail.RunTimeTicks ?? listItem.RunTimeTicks; + final mergedUserData = _mergeUserData(detail.UserData, listItem.UserData); + if (mergedRunTime == detail.RunTimeTicks && + identical(mergedUserData, detail.UserData)) { + return detail; + } + return detail.copyWith(RunTimeTicks: mergedRunTime, UserData: mergedUserData); +} + +EmbyRawUserData? _mergeUserData( + EmbyRawUserData? detail, + EmbyRawUserData? listItem, +) { + if (listItem == null) return detail; + if (detail == null) return listItem; + if (detail.PlaybackPositionTicks == null && + listItem.PlaybackPositionTicks != null) { + return detail.copyWith( + PlaybackPositionTicks: listItem.PlaybackPositionTicks, + ); + } + return detail; +} + +EmbyRawItem applyPlaybackReturnProgress(EmbyRawItem item, int? positionTicks) { + if (positionTicks == null || positionTicks <= 0) return item; + final userData = (item.UserData ?? const EmbyRawUserData()).copyWith( + PlaybackPositionTicks: positionTicks, + ); + return item.copyWith(UserData: userData); +} diff --git a/lib/features/detail/model/stream_selection.dart b/lib/features/detail/model/stream_selection.dart new file mode 100644 index 0000000..c32e192 --- /dev/null +++ b/lib/features/detail/model/stream_selection.dart @@ -0,0 +1,82 @@ +import '../../../core/contracts/library.dart'; +import '../../../core/infra/emby_api/playback_resolver.dart'; + + +class StreamSelection { + + final List subtitles; + + + final List audios; + + + final int preferredSubtitleStreamIndex; + + + final int? preferredAudioStreamIndex; + + const StreamSelection({ + required this.subtitles, + required this.audios, + required this.preferredSubtitleStreamIndex, + required this.preferredAudioStreamIndex, + }); +} + + +EmbyRawMediaSource? resolveActiveMediaSource( + CrossServerSourceCard? selectedCrossServerCard, + List localSources, + int selectedSourceIdx, +) { + return selectedCrossServerCard?.mediaSource ?? + (selectedSourceIdx < localSources.length + ? localSources[selectedSourceIdx] + : null); +} + + +StreamSelection resolveStreamSelection( + EmbyRawMediaSource? source, + int? selectedSubtitleIdx, + int selectedAudioIdx, +) { + final subtitles = (source?.MediaStreams ?? []) + .where((s) => s.Type == 'Subtitle') + .where((s) { + final isExternal = + (s.IsExternal ?? false) || + s.DeliveryMethod == 'External' || + (s.DeliveryUrl != null && s.DeliveryUrl!.isNotEmpty); + if (!isExternal) return true; + return PlaybackResolver.isTextSubtitleCodec(s.Codec); + }) + .toList(); + final audios = (source?.MediaStreams ?? []) + .where((s) => s.Type == 'Audio') + .toList(); + final preferredSubtitleStreamIndex = + selectedSubtitleIdx != null && selectedSubtitleIdx < subtitles.length + ? subtitles[selectedSubtitleIdx].Index ?? -1 + : -1; + final preferredAudioStreamIndex = selectedAudioIdx < audios.length + ? audios[selectedAudioIdx].Index + : null; + return StreamSelection( + subtitles: subtitles, + audios: audios, + preferredSubtitleStreamIndex: preferredSubtitleStreamIndex, + preferredAudioStreamIndex: preferredAudioStreamIndex, + ); +} + + +int resolveDefaultSourceIndex( + List sortedSources, + String? rememberedMediaSourceId, { + int fallback = 0, +}) { + if (rememberedMediaSourceId == null) return fallback; + final idx = sortedSources.indexWhere((s) => s.Id == rememberedMediaSourceId); + return idx >= 0 ? idx : fallback; +} diff --git a/lib/features/detail/tmdb_detail_body_android.dart b/lib/features/detail/tmdb_detail_body_android.dart new file mode 100644 index 0000000..2b83ac6 --- /dev/null +++ b/lib/features/detail/tmdb_detail_body_android.dart @@ -0,0 +1,239 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../core/contracts/library.dart'; +import '../../core/contracts/tmdb.dart'; +import '../../providers/detail_palette_provider.dart'; +import '../../shared/mappers/tmdb_image_selector.dart'; +import '../../shared/mappers/tmdb_image_url.dart'; +import '../../shared/utils/format_utils.dart'; +import '../../shared/utils/tmdb_cta_model.dart'; +import 'utils/detail_helpers.dart'; +import 'android/android_detail_layout.dart'; +import 'android/android_detail_colors.dart'; +import 'android/android_external_links_section.dart'; +import 'android/android_hero_actions.dart'; +import 'android/android_hero_info.dart'; +import 'android/android_overview_section.dart'; +import 'android/android_section_divider.dart'; +import 'android/android_stream_selector_row.dart'; +import 'detail_entry.dart'; +import 'model/detail_section_model.dart'; +import 'widgets/tmdb_cast_section.dart'; +import 'widgets/tmdb_recommendation_section.dart'; + +class TmdbDetailBodyAndroid extends ConsumerWidget { + const TmdbDetailBodyAndroid({ + super.key, + required this.entry, + required this.enriched, + required this.imageBaseUrl, + required this.language, + required this.showSpinner, + required this.scrollController, + required this.scrollProgress, + required this.primaryHit, + required this.availabilityResolving, + required this.isFavorite, + required this.isPlayed, + required this.subtitles, + required this.audios, + required this.selectedSubtitleIdx, + required this.selectedAudioIdx, + required this.onPlay, + required this.onReplay, + required this.onToggleFavorite, + required this.onTogglePlayed, + required this.onSelectSubtitleIndex, + required this.onSelectAudioIndex, + required this.hitCount, + required this.hitStateSections, + }); + + final TmdbDetailEntry entry; + final TmdbEnrichedDetail? enriched; + final String imageBaseUrl; + final String language; + final bool showSpinner; + final ScrollController scrollController; + final ValueNotifier scrollProgress; + final TmdbLibraryHit? primaryHit; + final bool availabilityResolving; + final bool isFavorite; + final bool isPlayed; + final List subtitles; + final List audios; + final int? selectedSubtitleIdx; + final int selectedAudioIdx; + final VoidCallback onPlay; + final VoidCallback onReplay; + final VoidCallback onToggleFavorite; + final VoidCallback onTogglePlayed; + final ValueChanged onSelectSubtitleIndex; + final ValueChanged onSelectAudioIndex; + final int hitCount; + final List hitStateSections; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final mediaType = entry.mediaType; + final detail = enriched?.detail; + final title = (detail?.title.isNotEmpty ?? false) + ? detail!.title + : (entry.seed?.title ?? ''); + + final posterPath = detail?.posterPath ?? entry.seed?.posterPath; + final backdropPath = detail?.backdropPath ?? entry.seed?.backdropPath; + final posterUrl = TmdbImageUrl.poster(imageBaseUrl, posterPath); + final backdropUrl = TmdbImageUrl.backdrop(imageBaseUrl, backdropPath); + final fallbackUrls = TmdbImageSelector.buildFallbackUrls( + primaryUrl: backdropUrl, + candidates: [posterUrl], + ); + final paletteImageUrl = posterUrl ?? backdropUrl; + final extractedBackgroundColor = paletteImageUrl == null + ? null + : ref + .watch( + detailPaletteProvider( + DetailPaletteRequest(url: paletteImageUrl), + ), + ) + .value; + final detailBackgroundColor = + extractedBackgroundColor ?? AndroidDetailColors.background; + + final metaParts = []; + final year = yearOf(detail?.releaseDate); + if (year != null) metaParts.add(year); + final runtime = formatRuntimeMinutes(detail?.runtime); + if (runtime != null) metaParts.add(runtime); + + final genreNames = [ + for (final g in detail?.genres ?? const []) + if (g.name.isNotEmpty) g.name, + ]; + + final overview = (detail?.overview?.trim().isNotEmpty ?? false) + ? detail!.overview!.trim() + : (entry.seed?.overview?.trim() ?? ''); + + final voteAverage = detail?.voteAverage ?? entry.seed?.voteAverage; + + String? playLabel; + bool showReplay = false; + double? progressPercent; + String? positionClock; + bool canPlay = false; + + if (!availabilityResolving) { + final ctaLabel = buildTmdbCtaLabel( + mediaType: mediaType, + primaryHit: primaryHit, + hitCount: hitCount, + ); + if (ctaLabel != null && primaryHit != null) { + canPlay = true; + playLabel = ctaLabel; + final pos = primaryHit!.playbackPositionTicks ?? 0; + final rt = primaryHit!.runTimeTicks ?? 0; + final hasProgress = pos > 0; + progressPercent = (hasProgress && rt > 0) + ? (pos / rt * 100).clamp(0.0, 100.0) + : null; + positionClock = hasProgress ? formatTicksAsClock(pos) : null; + showReplay = hasProgress; + } + } + + final tmdbIdInt = detail?.id; + final imdbIdStr = enriched?.imdbId; + + final cast = enriched?.credits?.cast ?? const []; + final recommendations = + enriched?.recommendations?.results ?? const []; + + final sectionWidgets = [ + for (final section in hitStateSections) + if (section.id != 'tmdb_movie_divider' && + section.id != 'tmdb_tv_divider') + section.child, + ]; + + sectionWidgets.add( + TmdbCastSection( + cast: cast, + imageBaseUrl: imageBaseUrl, + leading: const AndroidSectionDivider(), + ), + ); + sectionWidgets.add( + TmdbRecommendationSection( + items: recommendations, + imageBaseUrl: imageBaseUrl, + fallbackMediaType: mediaType, + leading: const AndroidSectionDivider(), + ), + ); + sectionWidgets.add( + ExternalLinksSection( + imdbId: imdbIdStr, + tmdbId: tmdbIdInt, + tmdbMediaType: mediaType, + leading: const AndroidSectionDivider(), + ), + ); + + return AndroidDetailLayout( + scrollController: scrollController, + scrollProgress: scrollProgress, + backdropUrl: backdropUrl, + fallbackUrls: fallbackUrls, + backgroundColor: detailBackgroundColor, + navigationColor: createDetailNavigationColor(detailBackgroundColor), + title: title, + onBack: () => context.pop(), + heroInfo: AndroidHeroInfo( + posterUrl: posterUrl, + title: title, + rating: voteAverage, + metaParts: metaParts, + genres: genreNames, + ), + heroActions: Column( + mainAxisSize: MainAxisSize.min, + children: [ + AndroidHeroActions( + playLabel: playLabel ?? '播放', + playTrailingLabel: positionClock, + progressPercent: progressPercent, + isLoading: availabilityResolving, + loadingLabel: '检测中', + canPlay: canPlay || availabilityResolving, + onPlay: canPlay ? onPlay : null, + showReplay: showReplay, + onReplay: onReplay, + isPlayed: isPlayed, + onTogglePlayed: onTogglePlayed, + isFavorite: isFavorite, + onToggleFavorite: onToggleFavorite, + ), + if (canPlay) + AndroidStreamSelectorRow( + subtitles: subtitles, + audios: audios, + selectedSubtitleIdx: selectedSubtitleIdx, + selectedAudioIdx: selectedAudioIdx, + onSelectSubtitleIndex: onSelectSubtitleIndex, + onSelectAudioIndex: onSelectAudioIndex, + ), + ], + ), + overview: overview.isNotEmpty + ? AndroidOverviewSection(overview: overview) + : null, + sections: sectionWidgets, + ); + } +} diff --git a/lib/features/detail/tmdb_detail_view.dart b/lib/features/detail/tmdb_detail_view.dart new file mode 100644 index 0000000..de8eff2 --- /dev/null +++ b/lib/features/detail/tmdb_detail_view.dart @@ -0,0 +1,904 @@ +import 'dart:async'; +import 'dart:io' show Platform; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:forui/forui.dart'; +import 'package:go_router/go_router.dart'; + +import '../../core/contracts/library.dart'; +import '../../core/contracts/tmdb.dart'; +import '../../providers/di_providers.dart'; +import '../../providers/last_played_version_store.dart'; +import '../../providers/session_provider.dart'; +import '../../providers/tmdb_settings_provider.dart'; +import '../../providers/version_priority_provider.dart'; +import '../../shared/constants/breakpoints.dart'; +import '../../shared/mappers/tmdb_image_url.dart'; +import '../../shared/theme/app_theme.dart'; +import '../../shared/utils/cross_server_search_key.dart'; +import '../../shared/utils/format_utils.dart'; +import '../../shared/utils/media_nav.dart'; +import '../player/player_launcher.dart'; +import '../../shared/utils/tmdb_cta_model.dart'; +import '../../shared/utils/tmdb_primary_hit.dart'; +import '../../shared/widgets/app_error_state.dart'; +import '../../shared/widgets/app_skeleton.dart'; +import '../../shared/widgets/empty_state.dart'; +import 'widgets/additional_parts_section.dart'; +import 'widgets/episode_list_section.dart'; +import 'adapter/tmdb_detail_view_builder.dart'; +import 'android/android_detail_geometry.dart'; +import 'detail_entry.dart'; +import 'model/detail_section_model.dart'; +import 'model/episode_auto_select_args.dart'; +import 'model/stream_selection.dart'; +import 'tmdb_detail_body_android.dart'; +import 'widgets/detail_immersive_scaffold.dart'; +import 'widgets/detail_nav_bar.dart'; +import 'widgets/detail_scroll_progress.dart'; +import 'widgets/detail_view_renderer.dart'; +import 'widgets/hero_action_buttons.dart'; +import 'widgets/source_selection_section.dart'; +import 'widgets/stream_selector_actions.dart'; + +const Color _kBg = Color(0xFF08090c); + + +const double _kHPad = 32.0; + + +class TmdbDetailView extends ConsumerStatefulWidget { + final TmdbDetailEntry entry; + + const TmdbDetailView({super.key, required this.entry}); + + @override + ConsumerState createState() => _TmdbDetailViewState(); +} + +class _TmdbDetailViewState extends ConsumerState { + static const _sectionDivider = FDivider( + style: .delta( + color: Color(0x1AFFFFFF), + padding: .value(EdgeInsets.only(top: 24)), + ), + ); + + final _scroll = ScrollController(); + final _scrollProgress = ValueNotifier(0); + + bool? _favoriteOverride; + bool? _playedOverride; + + + String? _overrideHitKey; + + int _selectedSourceIdx = 0; + CrossServerSourceCard? _selectedCrossServerCard; + EmbyRawItem? _selectedTvEpisode; + int? _selectedTvEpisodeSeasonNumber; + String? _selectedTvEpisodeHitKey; + int? _selectedSubtitleIdx; + int _selectedAudioIdx = 0; + + + String? _pendingMediaSourceId; + + + String? _versionMemoryQueriedKey; + + + String? _playbackOriginServerId; + + + int? _positionTicksOverride; + String? _positionOverrideItemKey; + + int? _positionOverrideFor(String? serverId, String? itemId) { + if (_positionTicksOverride == null) return null; + if (_positionOverrideItemKey != '$serverId|$itemId') return null; + return _positionTicksOverride; + } + + @override + void initState() { + super.initState(); + _scroll.addListener(_onScroll); + } + + void _onScroll() { + _scrollProgress.value = computeDetailScrollProgress( + context, + _scroll.offset, + collapseExtent: Platform.isAndroid + ? computeAndroidDetailCollapseExtent(context) + : null, + ); + } + + @override + void didUpdateWidget(covariant TmdbDetailView oldWidget) { + super.didUpdateWidget(oldWidget); + final old = oldWidget.entry; + final next = widget.entry; + if (old.mediaType != next.mediaType || old.tmdbId != next.tmdbId) { + setState(() { + _favoriteOverride = null; + _playedOverride = null; + _overrideHitKey = null; + _selectedSourceIdx = 0; + _selectedCrossServerCard = null; + _selectedTvEpisode = null; + _selectedTvEpisodeSeasonNumber = null; + _selectedTvEpisodeHitKey = null; + _selectedSubtitleIdx = null; + _selectedAudioIdx = 0; + _pendingMediaSourceId = null; + _versionMemoryQueriedKey = null; + _positionTicksOverride = null; + _positionOverrideItemKey = null; + }); + _scrollProgress.value = 0; + if (_scroll.hasClients) _scroll.jumpTo(0); + } + } + + @override + void dispose() { + _scroll.removeListener(_onScroll); + _scroll.dispose(); + _scrollProgress.dispose(); + super.dispose(); + } + + + void _selectLocalSource(int index) { + setState(() { + _selectedSourceIdx = index; + _selectedCrossServerCard = null; + _selectedSubtitleIdx = null; + _selectedAudioIdx = 0; + _pendingMediaSourceId = null; + }); + } + + + void _selectCrossServerSource(CrossServerSourceCard card) { + setState(() { + _selectedCrossServerCard = card; + _selectedSubtitleIdx = null; + _selectedAudioIdx = 0; + _pendingMediaSourceId = null; + }); + } + + + void _selectTvEpisode(EmbyRawItem episode, int? seasonNumber, String hitKey) { + setState(() { + _selectedTvEpisode = episode; + _selectedTvEpisodeSeasonNumber = seasonNumber; + _selectedTvEpisodeHitKey = hitKey; + _selectedSourceIdx = 0; + _selectedCrossServerCard = null; + _selectedSubtitleIdx = null; + _selectedAudioIdx = 0; + }); + } + + + Future _playRestoringActiveServer({ + required String serverId, + required String itemId, + String? mediaSourceId, + int preferredSubtitleStreamIndex = -1, + int? preferredAudioStreamIndex, + bool fromStart = false, + int? startPositionTicks, + String? backdropUrl, + }) async { + final originServerId = ref.read(activeSessionProvider)?.serverId; + setState(() => _playbackOriginServerId = originServerId); + PlayerExitResult? exitResult; + try { + exitResult = await playFromTmdbOnServer( + context, + ref, + serverId: serverId, + itemId: itemId, + mediaSourceId: mediaSourceId, + preferredSubtitleStreamIndex: preferredSubtitleStreamIndex, + preferredAudioStreamIndex: preferredAudioStreamIndex, + startPositionTicks: startPositionTicks, + fromStart: fromStart, + backdropUrl: backdropUrl, + ); + } finally { + if (mounted) { + if (originServerId != null && + originServerId.isNotEmpty && + ref.read(activeSessionProvider)?.serverId != originServerId) { + try { + await ref + .read(sessionProvider.notifier) + .switchSession(originServerId); + } catch (_) {} + } + if (mounted) setState(() => _playbackOriginServerId = null); + } + } + final positionTicks = exitResult?.positionTicks; + if (!mounted || positionTicks == null || positionTicks <= 0) return; + final exitServerId = exitResult!.serverId; + final exitItemId = exitResult.itemId; + setState(() { + _positionTicksOverride = positionTicks; + _positionOverrideItemKey = '$exitServerId|$exitItemId'; + final ep = _selectedTvEpisode; + if (ep != null && ep.Id == exitItemId) { + _selectedTvEpisode = ep.copyWith( + UserData: (ep.UserData ?? const EmbyRawUserData()).copyWith( + PlaybackPositionTicks: positionTicks, + ), + ); + } + }); + } + + void _selectSubtitleIndex(int? index) { + setState(() => _selectedSubtitleIdx = index); + } + + void _selectAudioIndex(int index) { + setState(() => _selectedAudioIdx = index); + } + + static String _hitKeyOf(TmdbLibraryHit hit) => + '${hit.serverId}|${hit.itemId}'; + + + Future _toggleTmdbFavorite(TmdbLibraryHit hit, bool current) async { + final next = !current; + setState(() { + _favoriteOverride = next; + _overrideHitKey = _hitKeyOf(hit); + }); + try { + final uc = await ref.read(setFavoriteUseCaseProvider.future); + final res = await uc.executeForServer( + serverId: hit.serverId, + input: FavoriteSetReq(itemId: hit.itemId, isFavorite: next), + ); + if (!mounted) return; + setState(() => _favoriteOverride = res.isFavorite); + ref.invalidate( + tmdbServerScopedDetailProvider(( + serverId: hit.serverId, + itemId: hit.itemId, + )), + ); + } catch (_) { + if (!mounted) return; + setState(() => _favoriteOverride = current); + } + } + + + Future _toggleTmdbPlayed(TmdbLibraryHit hit, bool current) async { + final next = !current; + setState(() { + _playedOverride = next; + _overrideHitKey = _hitKeyOf(hit); + }); + try { + final uc = await ref.read(setPlayedUseCaseProvider.future); + final res = await uc.executeForServer( + serverId: hit.serverId, + input: PlayedSetReq(itemId: hit.itemId, played: next), + ); + if (!mounted) return; + setState(() => _playedOverride = res.played); + ref.invalidate( + tmdbServerScopedDetailProvider(( + serverId: hit.serverId, + itemId: hit.itemId, + )), + ); + } catch (_) { + if (!mounted) return; + setState(() => _playedOverride = current); + } + } + + @override + Widget build(BuildContext context) { + return _buildTmdbDetail(widget.entry); + } + + Widget _buildTmdbDetail(TmdbDetailEntry entry) { + final key = (tmdbId: entry.tmdbId, mediaType: entry.mediaType); + final settings = ref.watch(tmdbSettingsProvider).value; + final imageBaseUrl = settings?.effectiveImageBaseUrl ?? ''; + final language = settings?.language ?? 'zh-CN'; + final asyncDetail = ref.watch(tmdbEnrichedDetailProvider(key)); + + if (Platform.isAndroid) { + return Theme( + data: AppTheme.dark(), + child: asyncDetail.when( + loading: () => _buildTmdbContent( + entry: entry, + enriched: null, + imageBaseUrl: imageBaseUrl, + language: language, + showSpinner: true, + ), + error: (_, _) => Scaffold( + appBar: AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => context.pop(), + ), + ), + body: AppErrorState( + title: '加载失败', + message: '无法获取该内容的 TMDB 详情', + onRetry: () => ref.invalidate(tmdbEnrichedDetailProvider(key)), + ), + ), + data: (enriched) { + if (enriched == null) { + return Scaffold( + appBar: AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => context.pop(), + ), + ), + body: const Center( + child: EmptyState( + icon: Icons.movie_filter_outlined, + title: '暂无详情', + message: '请检查 TMDB 配置后重试', + ), + ), + ); + } + return _buildTmdbContent( + entry: entry, + enriched: enriched, + imageBaseUrl: imageBaseUrl, + language: language, + showSpinner: false, + ); + }, + ), + ); + } + + final detail = asyncDetail.value?.detail; + final title = (detail?.title.isNotEmpty ?? false) + ? detail!.title + : (entry.seed?.title ?? ''); + + return Theme( + data: AppTheme.dark(), + child: Scaffold( + backgroundColor: _kBg, + body: Stack( + children: [ + Positioned.fill( + child: asyncDetail.when( + loading: () => _buildTmdbContent( + entry: entry, + enriched: null, + imageBaseUrl: imageBaseUrl, + language: language, + showSpinner: true, + ), + error: (_, _) => AppErrorState( + title: '加载失败', + message: '无法获取该内容的 TMDB 详情', + onRetry: () => + ref.invalidate(tmdbEnrichedDetailProvider(key)), + ), + data: (enriched) { + if (enriched == null) { + return const Center( + child: EmptyState( + icon: Icons.movie_filter_outlined, + title: '暂无详情', + message: '请检查 TMDB 配置后重试', + ), + ); + } + return _buildTmdbContent( + entry: entry, + enriched: enriched, + imageBaseUrl: imageBaseUrl, + language: language, + showSpinner: false, + ); + }, + ), + ), + Positioned( + top: 0, + left: 0, + right: 0, + child: DetailNavBar( + scrollProgress: _scrollProgress, + title: title, + onBack: () => context.pop(), + ), + ), + ], + ), + ), + ); + } + + Widget _buildTmdbContent({ + required TmdbDetailEntry entry, + required TmdbEnrichedDetail? enriched, + required String imageBaseUrl, + required String language, + required bool showSpinner, + }) { + final mediaType = entry.mediaType; + final tmdbId = entry.tmdbId; + final detail = enriched?.detail; + final title = (detail?.title.isNotEmpty ?? false) + ? detail!.title + : (entry.seed?.title ?? ''); + + final imdbIdForLookup = mediaType == 'movie' ? enriched?.imdbId : null; + final detailBackdropUrl = TmdbImageUrl.backdrop( + imageBaseUrl, + detail?.backdropPath ?? entry.seed?.backdropPath, + ); + + final availabilityTmdbAsync = ref.watch( + tmdbLibraryAvailabilityProvider(( + mediaType: mediaType, + tmdbId: tmdbId, + imdbId: '', + )), + ); + final tmdbResultEmpty = availabilityTmdbAsync.value?.isEmpty == true; + final needsImdbFallback = imdbIdForLookup != null && tmdbResultEmpty; + final availabilityImdbAsync = needsImdbFallback + ? ref.watch( + tmdbLibraryAvailabilityProvider(( + mediaType: mediaType, + tmdbId: tmdbId, + imdbId: imdbIdForLookup, + )), + ) + : null; + final availabilityAsync = availabilityImdbAsync ?? availabilityTmdbAsync; + final hits = availabilityAsync.value ?? const []; + final availabilityResolving = + (availabilityTmdbAsync.isLoading && + availabilityTmdbAsync.value == null) || + (availabilityImdbAsync?.isLoading == true && + availabilityImdbAsync?.value == null) || + (mediaType == 'movie' && enriched == null && tmdbResultEmpty); + final liveActiveServerId = ref.watch(activeSessionProvider)?.serverId; + final activeServerId = _playbackOriginServerId ?? liveActiveServerId; + final rawPrimaryHit = selectPrimaryTmdbHit( + hits, + activeServerId: activeServerId, + ); + final tvRecentHit = (rawPrimaryHit != null && mediaType == 'tv') + ? ref + .watch( + tmdbTvRecentHitProvider(( + mediaType: mediaType, + tmdbId: tmdbId, + imdbId: imdbIdForLookup ?? '', + )), + ) + .value + : null; + final primaryHit = tvRecentHit ?? rawPrimaryHit; + + AsyncValue? scopedDetailAsync; + EmbyRawItem? serverScopedItem; + if (primaryHit != null) { + final scopedKey = ( + serverId: primaryHit.serverId, + itemId: primaryHit.itemId, + ); + scopedDetailAsync = ref.watch(tmdbServerScopedDetailProvider(scopedKey)); + serverScopedItem = scopedDetailAsync?.value?.base; + } + if (_overrideHitKey != null && + (primaryHit == null || _overrideHitKey != _hitKeyOf(primaryHit))) { + _favoriteOverride = null; + _playedOverride = null; + _overrideHitKey = null; + } + final isFavorite = + _favoriteOverride ?? (serverScopedItem?.UserData?.IsFavorite ?? false); + final isPlayed = + _playedOverride ?? (serverScopedItem?.UserData?.Played ?? false); + + final isMovieHit = primaryHit != null && mediaType == 'movie'; + final isTvHit = primaryHit != null && mediaType == 'tv'; + final primaryHitKey = primaryHit == null ? null : _hitKeyOf(primaryHit); + final selectedTvEpisode = + isTvHit && _selectedTvEpisodeHitKey == primaryHitKey + ? _selectedTvEpisode + : null; + final selectedTvEpisodeSeasonNumber = selectedTvEpisode == null + ? null + : _selectedTvEpisodeSeasonNumber; + + final tvAnchorForResume = isTvHit ? primaryHit : null; + final tvResumeAsync = tvAnchorForResume == null + ? null + : ref.watch( + seriesResumeTargetProvider(( + serverId: tvAnchorForResume.serverId, + seriesId: tvAnchorForResume.itemId, + )), + ); + final tvAutoSelect = EpisodeAutoSelectArgs.fromResume(tvResumeAsync); + + final priorities = + ref.watch(versionPriorityProvider).value?.activePriorities ?? + const []; + final localSourceItem = isMovieHit ? serverScopedItem : selectedTvEpisode; + final sortedSources = localSourceItem != null + ? sortMediaSources(mediaSourcesOfItem(localSourceItem), priorities) + : const []; + + final memServerId = primaryHit?.serverId; + final memItemId = localSourceItem?.Id; + if (memServerId != null && + memServerId.isNotEmpty && + memItemId != null && + memItemId.isNotEmpty) { + final memKey = '$memServerId:$memItemId'; + if (memKey != _versionMemoryQueriedKey) { + _versionMemoryQueriedKey = memKey; + final store = ref.read(lastPlayedVersionStoreProvider); + unawaited( + store + .get(serverId: memServerId, itemId: memItemId) + .then((id) { + if (!mounted || id == null) return; + if (_versionMemoryQueriedKey != memKey) return; + if (_selectedSourceIdx != 0) return; + setState(() => _pendingMediaSourceId = id); + }) + .onError((e, s) {}), + ); + } + } + final pendingId = _pendingMediaSourceId; + if (pendingId != null && sortedSources.isNotEmpty) { + final idx = resolveDefaultSourceIndex( + sortedSources, + pendingId, + fallback: -1, + ); + if (idx >= 0 && idx != _selectedSourceIdx) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + setState(() { + _selectedSourceIdx = idx; + _pendingMediaSourceId = null; + }); + }); + } else { + _pendingMediaSourceId = null; + } + } + + final effectiveCrossServerCard = isTvHit && selectedTvEpisode == null + ? null + : _selectedCrossServerCard; + + final localSourceId = + effectiveCrossServerCard == null && + _selectedSourceIdx < sortedSources.length + ? sortedSources[_selectedSourceIdx].Id + : null; + final selectedMediaSource = resolveActiveMediaSource( + effectiveCrossServerCard, + sortedSources, + _selectedSourceIdx, + ); + final streamSelection = resolveStreamSelection( + selectedMediaSource, + _selectedSubtitleIdx, + _selectedAudioIdx, + ); + final subtitles = streamSelection.subtitles; + final audios = streamSelection.audios; + final preferredSubtitleStreamIndex = + streamSelection.preferredSubtitleStreamIndex; + final preferredAudioStreamIndex = streamSelection.preferredAudioStreamIndex; + + void play(bool fromStart) { + final card = effectiveCrossServerCard; + if (card != null) { + final pos = + selectedTvEpisode?.UserData?.PlaybackPositionTicks ?? + _positionOverrideFor(card.serverId, card.landingItemId) ?? + _positionOverrideFor(primaryHit?.serverId, primaryHit?.itemId) ?? + primaryHit?.playbackPositionTicks; + final resumeTicks = (!fromStart && pos != null && pos > 0) ? pos : null; + unawaited( + _playRestoringActiveServer( + serverId: card.serverId, + itemId: card.landingItemId, + mediaSourceId: card.mediaSourceId, + preferredSubtitleStreamIndex: preferredSubtitleStreamIndex, + preferredAudioStreamIndex: preferredAudioStreamIndex, + startPositionTicks: resumeTicks, + fromStart: resumeTicks == null, + backdropUrl: detailBackdropUrl, + ), + ); + return; + } + if (primaryHit == null) return; + final localItemId = selectedTvEpisode?.Id ?? primaryHit.itemId; + final localResume = fromStart + ? null + : _positionOverrideFor(primaryHit.serverId, localItemId); + unawaited( + _playRestoringActiveServer( + serverId: primaryHit.serverId, + itemId: localItemId, + mediaSourceId: localSourceId, + preferredSubtitleStreamIndex: preferredSubtitleStreamIndex, + preferredAudioStreamIndex: preferredAudioStreamIndex, + startPositionTicks: localResume, + fromStart: fromStart, + backdropUrl: detailBackdropUrl, + ), + ); + } + + final imdb = imdbIdForLookup?.trim() ?? ''; + final crossServerReq = CrossServerSearchReq( + anchorType: 'Movie', + itemName: title, + providerIds: {'Tmdb': tmdbId, if (imdb.isNotEmpty) 'Imdb': imdb}, + ); + final tvEpisodeReq = selectedTvEpisode == null || serverScopedItem == null + ? null + : buildEpisodeCrossServerReq( + episode: selectedTvEpisode, + seriesItem: serverScopedItem, + seasonNumberOverride: selectedTvEpisodeSeasonNumber, + ); + + final hitStateSections = [ + if (isMovieHit) ...[ + DetailSection( + id: 'tmdb_movie_source', + child: Padding( + padding: const EdgeInsets.fromLTRB(0, 24, 0, 0), + child: SourceSelectionSection( + req: crossServerReq, + localSources: sortedSources, + selectedSourceIdx: _selectedSourceIdx, + selectedCrossServerCard: effectiveCrossServerCard, + currentServerName: primaryHit.serverName, + excludedServerIdOverride: primaryHit.serverId, + localMediaInfoItem: localSourceItem, + showLoadingWhenEmpty: true, + externalLoading: scopedDetailAsync?.isLoading ?? false, + loadingText: '正在查找可用版本…', + onLocalSourceTap: (src, index) => _selectLocalSource(index), + onCrossServerSourceTap: _selectCrossServerSource, + ), + ), + ), + DetailSection( + id: 'tmdb_movie_additional_parts', + child: Padding( + padding: const EdgeInsets.fromLTRB(0, 24, 0, 0), + child: AdditionalPartsSection( + itemId: primaryHit.itemId, + embedded: true, + serverId: primaryHit.serverId, + onItemTap: (item) => unawaited( + _playRestoringActiveServer( + serverId: primaryHit.serverId, + itemId: item.Id, + backdropUrl: detailBackdropUrl, + ), + ), + ), + ), + ), + const DetailSection(id: 'tmdb_movie_divider', child: _sectionDivider), + ], + if (isTvHit) ...[ + DetailSection( + id: 'tmdb_tv_episodes', + child: Padding( + padding: const EdgeInsets.fromLTRB(0, 24, 0, 0), + child: EpisodeListSection( + seriesId: primaryHit.itemId, + tmdbSeriesId: tmdbId, + serverId: primaryHit.serverId, + embedded: true, + autoSelectFirstEpisode: tvAutoSelect.autoSelectFirstEpisode, + autoSelectEpisodeId: tvAutoSelect.autoSelectEpisodeId, + autoSelectSeasonId: tvAutoSelect.autoSelectSeasonId, + currentEpisodeId: selectedTvEpisode?.Id, + onEpisodeTap: (episode) => + _selectTvEpisode(episode, null, primaryHitKey!), + onEpisodeTapWithSeason: (episode, seasonNumber) => + _selectTvEpisode(episode, seasonNumber, primaryHitKey!), + ), + ), + ), + if (tvEpisodeReq != null) + DetailSection( + id: 'tmdb_tv_source', + child: Padding( + padding: const EdgeInsets.fromLTRB(0, 24, 0, 0), + child: SourceSelectionSection( + req: tvEpisodeReq, + localSources: sortedSources, + selectedSourceIdx: _selectedSourceIdx, + selectedCrossServerCard: effectiveCrossServerCard, + currentServerName: primaryHit.serverName, + excludedServerIdOverride: primaryHit.serverId, + localMediaInfoItem: localSourceItem, + showLoadingWhenEmpty: true, + loadingText: '正在查找该集的可用版本…', + onLocalSourceTap: (src, index) => _selectLocalSource(index), + onCrossServerSourceTap: _selectCrossServerSource, + ), + ), + ), + const DetailSection(id: 'tmdb_tv_divider', child: _sectionDivider), + ], + ]; + + final heroActions = _buildHeroActions( + mediaType: mediaType, + primaryHit: primaryHit, + hitCount: hits.length, + availabilityResolving: availabilityResolving, + isFavorite: isFavorite, + isPlayed: isPlayed, + subtitles: subtitles, + audios: audios, + onPlay: () => play(false), + onReplay: () => play(true), + ); + + final viewState = buildTmdbDetailViewState( + mediaType: mediaType, + enriched: enriched, + seed: entry.seed, + imageBaseUrl: imageBaseUrl, + language: language, + heroActions: heroActions, + hitStateSections: hitStateSections, + showSpinner: showSpinner, + ); + + if (Platform.isAndroid) { + return TmdbDetailBodyAndroid( + entry: entry, + enriched: enriched, + imageBaseUrl: imageBaseUrl, + language: language, + showSpinner: showSpinner, + scrollController: _scroll, + scrollProgress: _scrollProgress, + primaryHit: primaryHit, + availabilityResolving: availabilityResolving, + isFavorite: isFavorite, + isPlayed: isPlayed, + subtitles: subtitles, + audios: audios, + selectedSubtitleIdx: _selectedSubtitleIdx, + selectedAudioIdx: _selectedAudioIdx, + onPlay: () => play(false), + onReplay: () => play(true), + onToggleFavorite: primaryHit != null + ? () => unawaited(_toggleTmdbFavorite(primaryHit, isFavorite)) + : () {}, + onTogglePlayed: primaryHit != null + ? () => unawaited(_toggleTmdbPlayed(primaryHit, isPlayed)) + : () {}, + onSelectSubtitleIndex: _selectSubtitleIndex, + onSelectAudioIndex: _selectAudioIndex, + hitCount: hits.length, + hitStateSections: hitStateSections, + ); + } + + final isCompact = MediaQuery.sizeOf(context).width < Breakpoints.detail; + return DetailImmersiveScaffold( + scrollController: _scroll, + scrollProgress: _scrollProgress, + backdropUrl: viewState.image.backdropUrl, + fallbackUrls: viewState.image.fallbackUrls, + embyBaseUrl: viewState.image.embyBaseUrl, + imageHeaders: viewState.image.imageHeaders, + compact: isCompact, + horizontalPadding: isCompact ? 16 : _kHPad, + child: DetailViewRenderer(state: viewState), + ); + } + + + Widget? _buildHeroActions({ + required String mediaType, + required TmdbLibraryHit? primaryHit, + required int hitCount, + required bool availabilityResolving, + required bool isFavorite, + required bool isPlayed, + required List subtitles, + required List audios, + required VoidCallback onPlay, + required VoidCallback onReplay, + }) { + final compact = MediaQuery.sizeOf(context).width < Breakpoints.detail; + final btnH = compact ? 44.0 : 52.0; + + if (availabilityResolving) { + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 360), + child: AppSkeleton(height: btnH, radius: 12), + ); + } + + final ctaLabel = buildTmdbCtaLabel( + mediaType: mediaType, + primaryHit: primaryHit, + hitCount: hitCount, + ); + final hit = primaryHit; + if (ctaLabel == null || hit == null) return null; + + final pos = + _positionOverrideFor(hit.serverId, hit.itemId) ?? + hit.playbackPositionTicks ?? + 0; + final runtime = hit.runTimeTicks ?? 0; + final hasProgress = pos > 0; + final progressPercent = (hasProgress && runtime > 0) + ? (pos / runtime * 100).clamp(0.0, 100.0) + : null; + final positionClock = hasProgress ? formatTicksAsClock(pos) : null; + final extraActions = buildStreamSelectorPills( + subtitles: subtitles, + audios: audios, + selectedSubtitleIdx: _selectedSubtitleIdx, + selectedAudioIdx: _selectedAudioIdx, + onSelectSubtitle: _selectSubtitleIndex, + onSelectAudio: _selectAudioIndex, + height: btnH, + ); + + return DetailHeroActions( + showReplay: hasProgress, + onReplay: onReplay, + isPlayed: isPlayed, + onTogglePlayed: () => _toggleTmdbPlayed(hit, isPlayed), + isFavorite: isFavorite, + onToggleFavorite: () => _toggleTmdbFavorite(hit, isFavorite), + extraIconActions: extraActions, + onPlay: onPlay, + playLabel: ctaLabel, + progressPercent: progressPercent, + trailingLabel: positionClock, + compact: compact, + ); + } +} diff --git a/lib/features/detail/utils/detail_helpers.dart b/lib/features/detail/utils/detail_helpers.dart new file mode 100644 index 0000000..04199a1 --- /dev/null +++ b/lib/features/detail/utils/detail_helpers.dart @@ -0,0 +1,5 @@ + +String? yearOf(String? date) { + if (date == null || date.length < 4) return null; + return date.substring(0, 4); +} diff --git a/lib/features/detail/widgets/additional_parts_section.dart b/lib/features/detail/widgets/additional_parts_section.dart new file mode 100644 index 0000000..9615fc5 --- /dev/null +++ b/lib/features/detail/widgets/additional_parts_section.dart @@ -0,0 +1,141 @@ +import 'dart:io' show Platform; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../core/contracts/library.dart'; +import '../../../providers/di_providers.dart'; +import '../../../providers/emby_headers_provider.dart'; +import '../../../providers/session_provider.dart'; +import '../../../shared/mappers/media_image_url.dart'; +import '../../../shared/theme/app_theme.dart'; +import '../../../shared/widgets/hover_scrollable_row.dart'; +import '../../../shared/widgets/media_poster_card.dart'; +import 'section_container.dart'; + + +typedef AdditionalPartTap = void Function(EmbyRawItem item); + +class AdditionalPartsSection extends ConsumerWidget { + final String itemId; + final bool embedded; + + + final String? serverId; + + + final AdditionalPartTap? onItemTap; + + + final Widget? leading; + + const AdditionalPartsSection({ + super.key, + required this.itemId, + this.embedded = false, + this.serverId, + this.onItemTap, + this.leading, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final scopedServerId = serverId; + + final AsyncValue> asyncItems; + final String? baseUrl; + final String? token; + final Map? imageHeaders; + + if (scopedServerId != null) { + asyncItems = ref.watch( + tmdbServerScopedAdditionalPartsProvider(( + serverId: scopedServerId, + itemId: itemId, + )), + ); + final auth = ref + .watch(tmdbServerScopedAuthProvider(scopedServerId)) + .value; + baseUrl = auth?.baseUrl; + token = auth?.token; + imageHeaders = auth?.imageHeaders; + } else { + final session = ref.watch(activeSessionProvider); + if (session == null) return const SizedBox.shrink(); + asyncItems = ref.watch(additionalPartsDataProvider(itemId)); + baseUrl = session.serverUrl; + token = session.token; + imageHeaders = ref.watch(embyImageHeadersProvider).value; + } + + return asyncItems.when( + loading: () => const SizedBox.shrink(), + error: (error, stackTrace) => const SizedBox.shrink(), + data: (items) { + if (items.isEmpty || baseUrl == null || token == null) { + return const SizedBox.shrink(); + } + final hPad = SectionContainer.hPadOf(context); + final resolvedBaseUrl = baseUrl; + final resolvedToken = token; + + final isAndroid = Platform.isAndroid; + return SectionContainer( + title: '附加片段', + embedded: embedded, + leadingDivider: embedded, + leading: leading, + child: SizedBox( + height: isAndroid ? 210 : 300, + child: Theme( + data: AppTheme.dark(), + child: HoverScrollableRow( + builder: (_, controller) => ListView.builder( + controller: controller, + scrollDirection: Axis.horizontal, + padding: EdgeInsets.symmetric(horizontal: hPad), + itemCount: items.length, + itemBuilder: (_, i) { + final item = items[i]; + final imageUrl = EmbyImageUrl.primary( + baseUrl: resolvedBaseUrl, + token: resolvedToken, + item: item, + maxHeight: 480, + ); + return Padding( + padding: EdgeInsets.only( + right: i < items.length - 1 ? 12 : 0, + ), + child: MediaPosterCard( + item: item, + imageUrl: imageUrl, + width: isAndroid ? 105 : 160, + showHoverPlayIcon: false, + enableContextMenu: false, + imageHeaders: imageHeaders, + onTap: () { + final tap = onItemTap; + if (tap != null) { + tap(item); + } else { + context.pushReplacement( + '/media/${item.Id}', + extra: item, + ); + } + }, + ), + ); + }, + ), + ), + ), + ), + ); + }, + ); + } +} diff --git a/lib/features/detail/widgets/detail_backdrop.dart b/lib/features/detail/widgets/detail_backdrop.dart new file mode 100644 index 0000000..2995305 --- /dev/null +++ b/lib/features/detail/widgets/detail_backdrop.dart @@ -0,0 +1,201 @@ +import 'dart:io' show Platform; +import 'dart:ui' show ImageFilter; + +import 'package:flutter/material.dart'; + +import '../../../shared/widgets/smart_image.dart'; + + +class DetailBackdrop extends StatefulWidget { + const DetailBackdrop({ + super.key, + required this.scrollProgress, + required this.backdropUrl, + required this.fallbackUrls, + required this.embyBaseUrl, + required this.imageHeaders, + this.gradientEndColor = const Color(0xFF08090c), + }); + + final ValueNotifier scrollProgress; + final String backdropUrl; + final List fallbackUrls; + + + final String embyBaseUrl; + final Map? imageHeaders; + + final Color gradientEndColor; + + @override + State createState() => _DetailBackdropState(); +} + +class _DetailBackdropState extends State { + bool _backdropFailed = false; + + @override + void didUpdateWidget(DetailBackdrop oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.backdropUrl != widget.backdropUrl) { + _backdropFailed = false; + } + } + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + Positioned.fill( + child: _backdropFailed + ? _buildFallbackGradient() + : RepaintBoundary( + child: ValueListenableBuilder( + valueListenable: widget.scrollProgress, + builder: (_, p, child) { + final maxSigma = Platform.isAndroid ? 6.0 : 10.0; + var sigma = p > 0.01 ? p * maxSigma : 0.0; + if (Platform.isAndroid && sigma > 0) { + sigma = (sigma / 1.5).roundToDouble() * 1.5; + } + final blurred = ImageFiltered( + imageFilter: ImageFilter.blur( + sigmaX: sigma, + sigmaY: sigma, + ), + child: child, + ); + if (Platform.isAndroid) return blurred; + return Transform.scale( + scale: 1.0 + p * 0.15, + child: blurred, + ); + }, + child: _buildImageStack(), + ), + ), + ), + + Positioned.fill( + child: ValueListenableBuilder( + valueListenable: widget.scrollProgress, + builder: (_, p, _) { + return IgnorePointer( + child: ColoredBox( + color: Colors.black.withValues(alpha: 0.10 + p * 0.3), + ), + ); + }, + ), + ), + + Positioned.fill( + child: IgnorePointer(child: _buildBottomGradient(context)), + ), + ], + ); + } + + + Widget _buildBottomGradient(BuildContext context) { + final size = MediaQuery.sizeOf(context); + + final coverage = (size.width * 9.0 / 16.0 / size.height).clamp(0.0, 1.0); + + + final rampStart = coverage >= 0.95 + ? 0.5 + : (coverage * 0.55).clamp(0.1, 0.5); + final rampEnd = coverage >= 0.95 + ? 0.75 + : (coverage * 0.85).clamp(0.25, 0.75); + + return DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + stops: [0.0, rampStart, rampEnd, 1.0], + colors: [ + Colors.transparent, + Colors.transparent, + widget.gradientEndColor.withValues(alpha: 0.85), + widget.gradientEndColor, + ], + ), + ), + ); + } + + Widget _buildFallbackGradient() { + return IgnorePointer( + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + stops: const [0.0, 0.6, 1.0], + colors: [ + widget.gradientEndColor.withValues(alpha: 0.5), + widget.gradientEndColor.withValues(alpha: 0.85), + widget.gradientEndColor, + ], + ), + ), + ), + ); + } + + Widget _buildImageStack() { + return _buildArtworkImage( + fit: BoxFit.fitWidth, + alignment: Alignment.topCenter, + ); + } + + Widget _buildArtworkImage({ + required BoxFit fit, + required Alignment alignment, + int? memCacheWidth, + }) { + final placeholder = DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + widget.gradientEndColor.withValues(alpha: 0.3), + widget.gradientEndColor, + ], + ), + ), + ); + + final imageStack = Stack( + fit: StackFit.expand, + children: [ + ColoredBox(color: widget.gradientEndColor), + SmartImage( + url: widget.backdropUrl, + fallbackUrls: widget.fallbackUrls, + fit: fit, + alignment: alignment, + borderRadius: 0, + memCacheWidth: memCacheWidth, + placeholder: placeholder, + onError: () { + if (mounted && !_backdropFailed) { + setState(() => _backdropFailed = true); + } + }, + resolveHttpHeaders: (url) => + widget.embyBaseUrl.isNotEmpty && + url.startsWith(widget.embyBaseUrl) + ? widget.imageHeaders + : null, + ), + ], + ); + return imageStack; + } +} diff --git a/lib/features/detail/widgets/detail_hero_panel.dart b/lib/features/detail/widgets/detail_hero_panel.dart new file mode 100644 index 0000000..f3cf88e --- /dev/null +++ b/lib/features/detail/widgets/detail_hero_panel.dart @@ -0,0 +1,402 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; + +import '../../../shared/constants/breakpoints.dart'; +import '../../../shared/theme/app_theme.dart'; +import '../../../shared/widgets/app_dialog.dart'; +import '../../../shared/widgets/smart_image.dart'; + + +class DetailHeroPanel extends StatelessWidget { + final String title; + final String? logoUrl; + final String? posterUrl; + + + final Map? imageHeaders; + + + final double? rating; + final List metaParts; + final List genres; + final String? overview; + final String? tagline; + + + final Widget? actions; + + const DetailHeroPanel({ + super.key, + required this.title, + this.logoUrl, + this.posterUrl, + this.imageHeaders, + this.rating, + this.metaParts = const [], + this.genres = const [], + this.overview, + this.tagline, + this.actions, + }); + + bool get _hasOverview => + (overview?.trim().isNotEmpty ?? false) || + (tagline?.trim().isNotEmpty ?? false); + + @override + Widget build(BuildContext context) { + final compact = MediaQuery.sizeOf(context).width < Breakpoints.detail; + + final titleBlock = _HeroTitle( + title: title, + logoUrl: logoUrl, + posterUrl: posterUrl, + imageHeaders: imageHeaders, + compact: compact, + ); + final pills = _MetaPills( + rating: rating, + metaParts: metaParts, + genres: genres, + ); + + if (!compact && _hasOverview) { + return Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 380), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + titleBlock, + if (actions != null) ...[const SizedBox(height: 24), actions!], + ], + ), + ), + const SizedBox(width: 48), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.end, + children: [ + pills, + const SizedBox(height: 16), + _Overview(overview: overview, tagline: tagline), + ], + ), + ), + ], + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + titleBlock, + const SizedBox(height: 12), + pills, + if (actions != null) ...[const SizedBox(height: 20), actions!], + if (_hasOverview) ...[ + const SizedBox(height: 20), + _Overview(overview: overview, tagline: tagline), + ], + ], + ); + } +} + + +class _HeroTitle extends StatefulWidget { + final String title; + final String? logoUrl; + final String? posterUrl; + final Map? imageHeaders; + final bool compact; + + const _HeroTitle({ + required this.title, + required this.logoUrl, + required this.posterUrl, + required this.imageHeaders, + required this.compact, + }); + + @override + State<_HeroTitle> createState() => _HeroTitleState(); +} + +class _HeroTitleState extends State<_HeroTitle> { + + + bool _logoFailed = false; + + @override + void didUpdateWidget(covariant _HeroTitle oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.logoUrl != widget.logoUrl) _logoFailed = false; + } + + @override + Widget build(BuildContext context) { + final logo = widget.logoUrl; + if (logo != null && logo.isNotEmpty && !_logoFailed) { + return ConstrainedBox( + constraints: BoxConstraints( + maxWidth: widget.compact ? 240 : 400, + maxHeight: widget.compact ? 80 : 120, + ), + child: CachedNetworkImage( + imageUrl: logo, + httpHeaders: widget.imageHeaders, + fit: BoxFit.contain, + alignment: Alignment.bottomLeft, + memCacheWidth: widget.compact ? 720 : 1200, + fadeInDuration: const Duration(milliseconds: 300), + placeholder: (_, _) => _TitleText(title: widget.title), + errorWidget: (_, _, _) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && !_logoFailed) { + setState(() => _logoFailed = true); + } + }); + return _TitleText(title: widget.title); + }, + ), + ); + } + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (widget.posterUrl != null && widget.posterUrl!.isNotEmpty) ...[ + SizedBox( + width: widget.compact ? 104 : 130, + child: AspectRatio( + aspectRatio: 2 / 3, + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.5), + blurRadius: 16, + offset: const Offset(0, 6), + ), + ], + ), + child: SmartImage( + url: widget.posterUrl, + httpHeaders: widget.imageHeaders, + fallbackIcon: Icons.movie_outlined, + borderRadius: 10, + ), + ), + ), + ), + const SizedBox(height: 14), + ], + _TitleText(title: widget.title), + ], + ); + } +} + +class _TitleText extends StatelessWidget { + final String title; + + const _TitleText({required this.title}); + + @override + Widget build(BuildContext context) { + return Text( + title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, + fontSize: 26, + fontWeight: FontWeight.w700, + height: 1.15, + ), + ); + } +} + + +class _MetaPills extends StatelessWidget { + final double? rating; + final List metaParts; + final List genres; + + const _MetaPills({ + required this.rating, + required this.metaParts, + required this.genres, + }); + + @override + Widget build(BuildContext context) { + final tokens = context.appTokens; + final r = rating; + + return Wrap( + spacing: 8, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + if (r != null && r > 0) + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: tokens.ratingColor.withValues(alpha: 0.9), + borderRadius: BorderRadius.circular(6), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.star_rounded, size: 14, color: Colors.black87), + const SizedBox(width: 3), + Text( + r.toStringAsFixed(1), + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: Colors.black87, + ), + ), + ], + ), + ), + for (final part in [ + ...metaParts, + for (final g in genres.take(4)) + if (g.isNotEmpty) g, + ]) + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: Colors.white.withValues(alpha: 0.15)), + ), + child: Text( + part, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: Colors.white.withValues(alpha: 0.85), + ), + ), + ), + ], + ); + } +} + + +class _Overview extends StatelessWidget { + final String? overview; + final String? tagline; + + const _Overview({required this.overview, required this.tagline}); + + void _showDialog(BuildContext context, String text) { + showAppRawDialog( + context, + constraints: const BoxConstraints(maxWidth: 560, maxHeight: 480), + builder: (ctx) => ColoredBox( + color: const Color(0xFF1a1c22), + child: Padding( + padding: const EdgeInsets.fromLTRB(28, 28, 28, 20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '简介', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.white.withValues(alpha: 0.9), + ), + ), + const SizedBox(height: 16), + Flexible( + child: SingleChildScrollView( + child: Text( + text, + style: TextStyle( + fontSize: 14, + height: 1.8, + color: Colors.white.withValues(alpha: 0.82), + ), + ), + ), + ), + const SizedBox(height: 16), + Align( + alignment: Alignment.centerRight, + child: TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: Text( + '关闭', + style: TextStyle( + color: Colors.white.withValues(alpha: 0.6), + ), + ), + ), + ), + ], + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final body = overview?.trim(); + final line = tagline?.trim(); + if ((body == null || body.isEmpty) && (line == null || line.isEmpty)) { + return const SizedBox.shrink(); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (line != null && line.isNotEmpty) + Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Text( + line, + style: TextStyle( + fontSize: 15, + fontStyle: FontStyle.italic, + color: Colors.white.withValues(alpha: 0.6), + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + if (body != null && body.isNotEmpty) + GestureDetector( + onTap: () => _showDialog(context, body), + child: Text( + body, + style: TextStyle( + fontSize: 14, + height: 1.7, + color: Colors.white.withValues(alpha: 0.82), + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ); + } +} diff --git a/lib/features/detail/widgets/detail_immersive_scaffold.dart b/lib/features/detail/widgets/detail_immersive_scaffold.dart new file mode 100644 index 0000000..7ffbe42 --- /dev/null +++ b/lib/features/detail/widgets/detail_immersive_scaffold.dart @@ -0,0 +1,153 @@ +import 'dart:io' show Platform; +import 'dart:ui' show ImageFilter; + +import 'package:flutter/material.dart'; + +import 'detail_backdrop.dart'; + + +class DetailImmersiveScaffold extends StatelessWidget { + final ScrollController scrollController; + final ValueNotifier scrollProgress; + final String? backdropUrl; + final List fallbackUrls; + final String embyBaseUrl; + final Map? imageHeaders; + final Widget child; + final bool compact; + final double horizontalPadding; + + const DetailImmersiveScaffold({ + super.key, + required this.scrollController, + required this.scrollProgress, + required this.child, + this.backdropUrl, + this.fallbackUrls = const [], + this.embyBaseUrl = '', + this.imageHeaders, + this.compact = false, + this.horizontalPadding = 32, + }); + + @override + Widget build(BuildContext context) { + final screenSize = MediaQuery.sizeOf(context); + final screenHeight = screenSize.height; + final screenWidth = screenSize.width; + final bannerHeight = compact + ? screenHeight * 0.30 + : (screenWidth * 9.0 / 16.0).clamp(280.0, screenHeight * 0.6); + final backdrop = backdropUrl; + + return Stack( + children: [ + if (backdrop != null) + DetailBackdrop( + scrollProgress: scrollProgress, + backdropUrl: backdrop, + fallbackUrls: fallbackUrls, + embyBaseUrl: embyBaseUrl, + imageHeaders: imageHeaders, + ), + SingleChildScrollView( + controller: scrollController, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(height: bannerHeight - 80), + Stack( + children: [ + Positioned.fill(child: _FrostedPanelVeil()), + Padding( + padding: EdgeInsets.fromLTRB( + horizontalPadding, + 64, + horizontalPadding, + 48, + ), + child: Align( + alignment: Alignment.topCenter, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 1200), + child: child, + ), + ), + ), + ], + ), + ], + ), + ), + ], + ); + } +} + +class _FrostedPanelVeil extends StatelessWidget { + @override + Widget build(BuildContext context) { + if (Platform.isAndroid) { + return IgnorePointer( + child: ShaderMask( + shaderCallback: (rect) { + final ratio = (140.0 / rect.height).clamp(0.02, 0.5); + return LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: const [Colors.transparent, Colors.black], + stops: [0.0, ratio], + ).createShader(rect); + }, + blendMode: BlendMode.dstIn, + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + stops: const [0.0, 0.12, 1.0], + colors: [ + const Color(0xFF0a0c10).withValues(alpha: 0.0), + const Color(0xFF0a0c10).withValues(alpha: 0.88), + const Color(0xFF0a0c10).withValues(alpha: 0.96), + ], + ), + ), + ), + ), + ); + } + + return IgnorePointer( + child: ShaderMask( + shaderCallback: (rect) { + final ratio = (140.0 / rect.height).clamp(0.02, 0.5); + return LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: const [Colors.transparent, Colors.black], + stops: [0.0, ratio], + ).createShader(rect); + }, + blendMode: BlendMode.dstIn, + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 40, sigmaY: 40), + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + stops: const [0.0, 0.20, 1.0], + colors: [ + const Color(0xFF0a0c10).withValues(alpha: 0.0), + const Color(0xFF0a0c10).withValues(alpha: 0.12), + const Color(0xFF0a0c10).withValues(alpha: 0.20), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/detail/widgets/detail_nav_bar.dart b/lib/features/detail/widgets/detail_nav_bar.dart new file mode 100644 index 0000000..2fb8c2d --- /dev/null +++ b/lib/features/detail/widgets/detail_nav_bar.dart @@ -0,0 +1,135 @@ +import 'dart:io' show Platform; +import 'dart:math' as math; +import 'dart:ui' show ImageFilter; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../providers/appearance_provider.dart'; +import '../../../shared/theme/app_theme.dart'; +import '../../../shared/widgets/top_drag_area.dart'; +import '../../../shared/widgets/window_chrome.dart'; +import '../../../shared/widgets/window_controls.dart'; + +const double _kDesktopTitleBarHeight = 38.0; + +class DetailNavBar extends ConsumerWidget { + final ValueListenable scrollProgress; + final String title; + final VoidCallback onBack; + final Color chromeColor; + + const DetailNavBar({ + super.key, + required this.scrollProgress, + required this.title, + required this.onBack, + this.chromeColor = const Color(0xFF08090c), + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isDesktop = WindowChrome.isDesktop; + final topPadding = isDesktop + ? _kDesktopTitleBarHeight + : math.max(MediaQuery.paddingOf(context).top, _kDesktopTitleBarHeight); + final tokens = context.appTokens; + + final headerMode = + ref.watch(appearanceProvider).value?.headerMode ?? HeaderMode.frosted; + final isFrosted = headerMode == HeaderMode.frosted; + + final navHeight = topPadding + 30; + + return Stack( + children: [ + SizedBox( + height: navHeight, + width: double.infinity, + child: ValueListenableBuilder( + valueListenable: scrollProgress, + builder: (_, raw, _) { + final p = raw.clamp(0.0, 1.0); + if (isFrosted) { + if (Platform.isAndroid && p < 0.05) { + return const SizedBox.expand(); + } + final maxSigma = Platform.isAndroid ? 10.0 : 18.0; + return ClipRect( + child: BackdropFilter( + filter: ImageFilter.blur( + sigmaX: p * maxSigma, + sigmaY: p * maxSigma, + ), + child: DecoratedBox( + decoration: BoxDecoration( + color: chromeColor.withValues(alpha: p * 0.7), + border: Border( + bottom: BorderSide( + color: const Color( + 0xFFFFFFFF, + ).withValues(alpha: p * 0.08), + width: 1, + ), + ), + ), + ), + ), + ); + } + return ColoredBox(color: chromeColor.withValues(alpha: p)); + }, + ), + ), + const Positioned.fill(child: TopDragArea(child: SizedBox.expand())), + Positioned( + top: isDesktop + ? tokens.chromeInset + : MediaQuery.paddingOf(context).top, + left: 0, + right: 0, + child: Row( + children: [ + SizedBox(width: tokens.chromeInset), + IconButton( + icon: const Icon( + Icons.arrow_back, + color: Colors.white, + size: 18, + ), + onPressed: onBack, + tooltip: '返回', + constraints: const BoxConstraints(minWidth: 30, minHeight: 30), + padding: const EdgeInsets.all(6), + ), + const SizedBox(width: 4), + Expanded( + child: IgnorePointer( + child: ValueListenableBuilder( + valueListenable: scrollProgress, + builder: (_, p, child) { + return Opacity(opacity: p.clamp(0.0, 1.0), child: child); + }, + child: Text( + title, + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ), + ), + const SizedBox(width: 4), + if (isDesktop) const WindowControls.forDarkSurface(), + SizedBox(width: tokens.chromeInset), + ], + ), + ), + ], + ); + } +} diff --git a/lib/features/detail/widgets/detail_scroll_progress.dart b/lib/features/detail/widgets/detail_scroll_progress.dart new file mode 100644 index 0000000..38f7c36 --- /dev/null +++ b/lib/features/detail/widgets/detail_scroll_progress.dart @@ -0,0 +1,14 @@ +import 'package:flutter/widgets.dart'; + + +double computeDetailScrollProgress( + BuildContext context, + double offset, { + double? collapseExtent, +}) { + final mq = MediaQuery.of(context); + if (mq.disableAnimations) return offset > 10 ? 1.0 : 0.0; + final maxScroll = collapseExtent ?? mq.size.height * 0.8; + final raw = (offset / maxScroll).clamp(0.0, 1.0); + return (raw * 100).roundToDouble() / 100; +} diff --git a/lib/features/detail/widgets/detail_view_renderer.dart b/lib/features/detail/widgets/detail_view_renderer.dart new file mode 100644 index 0000000..56d796e --- /dev/null +++ b/lib/features/detail/widgets/detail_view_renderer.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; + +import 'detail_hero_panel.dart'; +import '../model/detail_view_state.dart'; +import '../../../shared/widgets/app_loading_ring.dart'; + + +class DetailViewRenderer extends StatelessWidget { + final DetailViewState state; + + const DetailViewRenderer({super.key, required this.state}); + + @override + Widget build(BuildContext context) { + final hero = state.hero; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DetailHeroPanel( + title: hero.title, + logoUrl: hero.logoUrl, + posterUrl: hero.posterUrl, + imageHeaders: hero.imageHeaders, + rating: hero.rating, + metaParts: hero.metaParts, + genres: hero.genres, + overview: hero.overview, + tagline: hero.tagline, + actions: hero.actions, + ), + for (final section in state.sections) section.child, + if (state.showLoadingBelowHero) + const Padding( + padding: EdgeInsets.symmetric(vertical: 32), + child: Center(child: AppLoadingRing()), + ), + const SizedBox(height: 32), + ], + ); + } +} diff --git a/lib/features/detail/widgets/emby_cast_section.dart b/lib/features/detail/widgets/emby_cast_section.dart new file mode 100644 index 0000000..7750a91 --- /dev/null +++ b/lib/features/detail/widgets/emby_cast_section.dart @@ -0,0 +1,94 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../core/contracts/library.dart'; +import '../../../providers/emby_headers_provider.dart'; +import '../../../providers/session_provider.dart'; +import '../../../shared/mappers/media_image_url.dart'; +import '../../../shared/widgets/cast_scroller.dart'; +import 'section_container.dart'; + +class EmbyCastSection extends ConsumerWidget { + final EmbyRawItem item; + final bool embedded; + + + final Widget? leading; + + const EmbyCastSection({ + super.key, + required this.item, + this.embedded = false, + this.leading, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final people = _extractActors(item); + if (people.isEmpty) return const SizedBox.shrink(); + + final session = ref.watch(activeSessionProvider); + if (session == null) return const SizedBox.shrink(); + + final headers = ref.watch(embyImageHeadersProvider).value; + final hPad = embedded ? 0.0 : SectionContainer.hPadOf(context); + + final entries = people.map((person) { + final name = person['Name'] as String? ?? ''; + final role = person['Role'] as String?; + final personId = person['Id'] as String?; + final imageTag = person['PrimaryImageTag'] as String?; + + final photoUrl = personId != null + ? _personImageUrl( + session.serverUrl, + session.token, + personId, + imageTag, + ) + : null; + final tappable = personId != null && personId.isNotEmpty; + + return CastEntry( + name: name, + subtitle: role, + imageUrl: photoUrl, + imageHeaders: headers, + onTap: tappable ? () => context.push('/person/$personId') : null, + ); + }).toList(); + + return SectionContainer( + title: '演职人员', + embedded: embedded, + leadingDivider: embedded, + leading: leading, + child: CastScroller( + entries: entries, + padding: EdgeInsets.symmetric(horizontal: hPad), + ), + ); + } + + static List> _extractActors(EmbyRawItem item) { + final raw = item.extra['People']; + if (raw is! List) return const []; + return raw.whereType>().toList(); + } + + static String? _personImageUrl( + String baseUrl, + String token, + String personId, + String? tag, + ) { + if (tag == null) return null; + return EmbyImageUrl.primaryById( + baseUrl: baseUrl, + token: token, + itemId: personId, + maxHeight: 200, + ); + } +} diff --git a/lib/features/detail/widgets/episode_list_section.dart b/lib/features/detail/widgets/episode_list_section.dart new file mode 100644 index 0000000..fb733a1 --- /dev/null +++ b/lib/features/detail/widgets/episode_list_section.dart @@ -0,0 +1,853 @@ +import 'dart:io' show Platform; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../core/contracts/library.dart'; +import '../../../providers/di_providers.dart'; +import '../../../providers/session_provider.dart'; +import '../../../providers/tmdb_settings_provider.dart'; +import '../../../shared/constants/breakpoints.dart'; +import '../../../shared/utils/emby_ticks.dart'; +import '../../../shared/utils/format_utils.dart'; +import '../../../shared/utils/tmdb_episode_helpers.dart'; +import '../../../shared/widgets/adaptive_modal.dart'; +import '../../../shared/widgets/app_dialog.dart'; +import '../../../shared/widgets/app_inline_alert.dart'; +import '../../../shared/widgets/app_loading_ring.dart'; +import '../../../shared/widgets/card_progress_bar.dart'; +import '../../../shared/widgets/episode_thumb.dart'; +import '../../../shared/widgets/frosted_panel.dart'; +import '../../../shared/widgets/hover_scrollable_row.dart'; +import '../../../shared/widgets/season_selector.dart'; +import 'episode_selection_resolver.dart'; + +typedef EpisodeTapWithSeason = + void Function(EmbyRawItem episode, int? seasonNumber); + +class EpisodeListSection extends ConsumerStatefulWidget { + final String seriesId; + final String? tmdbSeriesId; + final String? currentEpisodeId; + + + final String? currentEpisodeSeasonId; + + + final int? currentEpisodeSeasonNumber; + final int? currentEpisodeIndexNumber; + final String? initialSeasonId; + final void Function(EmbyRawItem episode) onEpisodeTap; + final EpisodeTapWithSeason? onEpisodeTapWithSeason; + final void Function(EmbyRawItem episode)? onPlaybackPrefetch; + final bool embedded; + + + final String? serverId; + + + final bool autoSelectFirstEpisode; + + + final String? autoSelectEpisodeId; + + + final String? autoSelectSeasonId; + + + final Map? showProviderIds; + final String? showTitle; + final int? showYear; + + + final Widget? leading; + + const EpisodeListSection({ + super.key, + required this.seriesId, + this.tmdbSeriesId, + this.currentEpisodeId, + this.currentEpisodeSeasonId, + this.currentEpisodeSeasonNumber, + this.currentEpisodeIndexNumber, + this.initialSeasonId, + required this.onEpisodeTap, + this.onEpisodeTapWithSeason, + this.onPlaybackPrefetch, + this.embedded = false, + this.serverId, + this.autoSelectFirstEpisode = true, + this.autoSelectEpisodeId, + this.autoSelectSeasonId, + this.showProviderIds, + this.showTitle, + this.showYear, + this.leading, + }); + + @override + ConsumerState createState() => _EpisodeListSectionState(); +} + +class _EpisodeListSectionState extends ConsumerState { + String? _selectedSeasonId; + + void _selectSeason(String seasonId) { + if (_selectedSeasonId == seasonId) return; + setState(() => _selectedSeasonId = seasonId); + } + + @override + Widget build(BuildContext context) { + final scopedServerId = widget.serverId; + late final String targetServerId; + late final String baseUrl; + late final String token; + + if (scopedServerId != null) { + final authAsync = ref.watch(tmdbServerScopedAuthProvider(scopedServerId)); + final auth = authAsync.value; + if (auth == null) { + return authAsync.isLoading + ? const Padding( + padding: EdgeInsets.symmetric(vertical: 32), + child: Center(child: AppLoadingRing()), + ) + : const SizedBox.shrink(); + } + targetServerId = scopedServerId; + baseUrl = auth.baseUrl; + token = auth.token; + } else { + final session = ref.watch(activeSessionProvider); + if (session == null) return const SizedBox.shrink(); + targetServerId = session.serverId; + baseUrl = session.serverUrl; + token = session.token; + } + + final width = MediaQuery.sizeOf(context).width; + final compact = width < Breakpoints.detail; + final hPad = compact ? 16.0 : 32.0; + + final seasonsAsync = ref.watch( + seriesSeasonsDataProvider(( + serverId: targetServerId, + seriesId: widget.seriesId, + )), + ); + + return seasonsAsync.when( + loading: () => const Padding( + padding: EdgeInsets.symmetric(vertical: 32), + child: Center(child: AppLoadingRing()), + ), + error: (error, stackTrace) => const SizedBox.shrink(), + data: (seasons) { + if (seasons.isEmpty) return const SizedBox.shrink(); + + final String? effectiveSeasonId = + _selectedSeasonId ?? + resolveInitialSeasonId( + seasons: seasons, + autoSelectSeasonId: widget.autoSelectSeasonId, + initialSeasonId: widget.initialSeasonId, + currentEpisodeSeasonId: widget.currentEpisodeSeasonId, + currentEpisodeSeasonNumber: widget.currentEpisodeSeasonNumber, + ); + EmbyRawSeason? selectedSeason; + for (final season in seasons) { + if (season.Id == effectiveSeasonId) { + selectedSeason = season; + break; + } + } + + final content = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (widget.embedded && widget.leading != null) widget.leading!, + const SizedBox(height: 16), + if (effectiveSeasonId != null) + _EpisodesBody( + seasonTabsWidget: seasons.length > 1 + ? SeasonSelector( + seasons: seasons, + selectedId: effectiveSeasonId, + onSelect: _selectSeason, + ) + : null, + seriesId: widget.seriesId, + seasonId: effectiveSeasonId, + seasonNumber: selectedSeason == null + ? null + : seasonNumberFrom(selectedSeason), + tmdbSeriesId: widget.tmdbSeriesId, + currentEpisodeId: widget.currentEpisodeId, + currentEpisodeSeasonId: widget.currentEpisodeSeasonId, + currentEpisodeSeasonNumber: widget.currentEpisodeSeasonNumber, + currentEpisodeIndexNumber: widget.currentEpisodeIndexNumber, + serverId: targetServerId, + baseUrl: baseUrl, + token: token, + autoSelectFirstEpisode: widget.autoSelectFirstEpisode, + autoSelectEpisodeId: widget.autoSelectEpisodeId, + onEpisodeTap: widget.onEpisodeTap, + onEpisodeTapWithSeason: widget.onEpisodeTapWithSeason, + onPlaybackPrefetch: widget.onPlaybackPrefetch, + showProviderIds: widget.showProviderIds, + showTitle: widget.showTitle, + showYear: widget.showYear, + ), + ], + ); + + if (widget.embedded) return content; + + return Padding( + padding: EdgeInsets.symmetric(horizontal: hPad), + child: FrostedPanel( + radius: 16, + padding: EdgeInsets.all(hPad), + blurSigma: 15, + border: Border.all(color: Colors.white.withValues(alpha: 0.08)), + color: Colors.black.withValues(alpha: 0.3), + child: content, + ), + ); + }, + ); + } +} + +class _EpisodesBody extends ConsumerStatefulWidget { + final String seriesId; + final String seasonId; + final int? seasonNumber; + final String? tmdbSeriesId; + final String? currentEpisodeId; + final String? currentEpisodeSeasonId; + final int? currentEpisodeSeasonNumber; + final int? currentEpisodeIndexNumber; + final String serverId; + final String baseUrl; + final String token; + final bool autoSelectFirstEpisode; + final String? autoSelectEpisodeId; + final void Function(EmbyRawItem episode) onEpisodeTap; + final EpisodeTapWithSeason? onEpisodeTapWithSeason; + final void Function(EmbyRawItem episode)? onPlaybackPrefetch; + final Map? showProviderIds; + final String? showTitle; + final int? showYear; + final Widget? seasonTabsWidget; + + const _EpisodesBody({ + required this.seriesId, + required this.seasonId, + this.seasonNumber, + this.tmdbSeriesId, + required this.currentEpisodeId, + this.currentEpisodeSeasonId, + this.currentEpisodeSeasonNumber, + this.currentEpisodeIndexNumber, + required this.serverId, + required this.baseUrl, + required this.token, + required this.autoSelectFirstEpisode, + this.autoSelectEpisodeId, + required this.onEpisodeTap, + this.onEpisodeTapWithSeason, + this.onPlaybackPrefetch, + this.showProviderIds, + this.showTitle, + this.showYear, + this.seasonTabsWidget, + }); + + @override + ConsumerState<_EpisodesBody> createState() => _EpisodesBodyState(); +} + +class _EpisodesBodyState extends ConsumerState<_EpisodesBody> { + static final double _itemWidth = Platform.isAndroid ? 180.0 : 260.0; + static const _itemGap = 12.0; + static final double _rowHeight = Platform.isAndroid ? 150.0 : 210.0; + + final ScrollController _scrollController = ScrollController(); + bool _hasScrolledToActive = false; + bool _hasAutoSelected = false; + + @override + void didUpdateWidget(covariant _EpisodesBody oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.seasonId != widget.seasonId || + oldWidget.currentEpisodeId != widget.currentEpisodeId) { + _hasScrolledToActive = false; + } + if (oldWidget.seasonId != widget.seasonId || + oldWidget.autoSelectEpisodeId != widget.autoSelectEpisodeId) { + _hasAutoSelected = false; + } + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + + String? _resolveActiveEpisodeId(List episodes) => + resolveActiveEpisodeId( + episodes: episodes, + currentEpisodeId: widget.currentEpisodeId, + displayedSeasonId: widget.seasonId, + displayedSeasonNumber: widget.seasonNumber, + currentEpisodeSeasonId: widget.currentEpisodeSeasonId, + currentEpisodeSeasonNumber: widget.currentEpisodeSeasonNumber, + currentEpisodeIndexNumber: widget.currentEpisodeIndexNumber, + ); + + void _scrollToActive(List episodes, String? activeEpisodeId) { + if (_hasScrolledToActive) return; + if (activeEpisodeId == null) return; + final idx = episodes.indexWhere((e) => e.Id == activeEpisodeId); + if (idx < 0) return; + _hasScrolledToActive = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !_scrollController.hasClients) return; + final position = _scrollController.position; + final itemExtent = _itemWidth + _itemGap; + final viewport = position.viewportDimension; + final target = idx * itemExtent - (viewport - _itemWidth) / 2; + _scrollController.jumpTo(target.clamp(0.0, position.maxScrollExtent)); + }); + } + + EmbyRawItem? _prefetchTarget( + List episodes, + String? activeEpisodeId, + ) { + if (episodes.isEmpty) return null; + if (activeEpisodeId != null && activeEpisodeId.isNotEmpty) { + for (final episode in episodes) { + if (episode.Id == activeEpisodeId) return episode; + } + } + return episodes.first; + } + + void _handleEpisodeTap(EmbyRawItem episode) { + final tapWithSeason = widget.onEpisodeTapWithSeason; + if (tapWithSeason != null) { + tapWithSeason(episode, widget.seasonNumber); + return; + } + widget.onEpisodeTap(episode); + } + + void _showAllEpisodes( + List episodes, + String? activeEpisodeId, + Map tmdbStillUrls, + ) { + showAdaptiveModal( + context: context, + maxWidth: 720, + builder: (sheetContext) => _EpisodeGridSheet( + episodes: episodes, + seriesId: widget.seriesId, + activeEpisodeId: activeEpisodeId, + tmdbStillUrls: tmdbStillUrls, + baseUrl: widget.baseUrl, + token: widget.token, + onEpisodeTap: (ep) { + Navigator.of(sheetContext).pop(); + _handleEpisodeTap(ep); + }, + ), + ); + } + + @override + Widget build(BuildContext context) { + final tmdbSettings = ref.watch(tmdbSettingsProvider).value; + final canUseTmdbSeason = + (tmdbSettings?.canRequest ?? false) && + widget.tmdbSeriesId != null && + widget.tmdbSeriesId!.isNotEmpty && + widget.seasonNumber != null; + final tmdbSeason = canUseTmdbSeason + ? ref + .watch( + tmdbSeasonDetailProvider(( + tmdbId: widget.tmdbSeriesId!, + seasonNumber: widget.seasonNumber!, + )), + ) + .value + : null; + final tmdbStillUrls = tmdbStillUrlsByEpisode( + tmdbSeason, + tmdbSettings?.effectiveImageBaseUrl, + ); + final episodesAsync = ref.watch( + seriesEpisodesDataProvider(( + serverId: widget.serverId, + seriesId: widget.seriesId, + seasonId: widget.seasonId, + )), + ); + + return episodesAsync.when( + loading: () => const Padding( + padding: EdgeInsets.symmetric(vertical: 32), + child: Center(child: AppLoadingRing()), + ), + error: (error, stackTrace) => const Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: AppInlineAlert(message: '剧集列表加载失败'), + ), + data: (episodes) { + if (episodes.isEmpty) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Text( + '暂无分集', + style: TextStyle( + fontSize: 13, + color: Colors.white.withValues(alpha: 0.6), + ), + ), + ); + } + if (widget.currentEpisodeId == null && + !_hasAutoSelected && + episodes.isNotEmpty) { + final targetId = widget.autoSelectEpisodeId; + EmbyRawItem? target; + if (targetId != null && targetId.isNotEmpty) { + for (final e in episodes) { + if (e.Id == targetId) { + target = e; + break; + } + } + } + final pick = + target ?? (widget.autoSelectFirstEpisode ? episodes.first : null); + if (pick != null) { + _hasAutoSelected = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _handleEpisodeTap(pick); + }); + } + } + final activeEpisodeId = _resolveActiveEpisodeId(episodes); + final target = _prefetchTarget(episodes, activeEpisodeId); + if (target != null) widget.onPlaybackPrefetch?.call(target); + _scrollToActive(episodes, activeEpisodeId); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + if (widget.seasonTabsWidget != null) + Expanded(child: widget.seasonTabsWidget!) + else + const Spacer(), + const SizedBox(width: 12), + TextButton( + onPressed: () => _showAllEpisodes( + episodes, + activeEpisodeId, + tmdbStillUrls, + ), + style: TextButton.styleFrom( + foregroundColor: Colors.white.withValues(alpha: 0.6), + minimumSize: Size.zero, + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 6, + ), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + ), + child: const Text('查看全部', style: TextStyle(fontSize: 13)), + ), + ], + ), + const SizedBox(height: 8), + SizedBox( + height: _rowHeight, + child: HoverScrollableRow( + controller: _scrollController, + buttonInset: 4, + scrollFraction: 0.5, + builder: (_, controller) => ListView.builder( + controller: controller, + scrollDirection: Axis.horizontal, + itemCount: episodes.length, + itemBuilder: (_, i) { + final ep = episodes[i]; + final tmdbStillUrl = ep.IndexNumber == null + ? null + : tmdbStillUrls[ep.IndexNumber]; + return Padding( + padding: EdgeInsets.only( + right: i < episodes.length - 1 ? _itemGap : 0, + ), + child: _EpisodeCard( + episode: ep, + seriesId: widget.seriesId, + baseUrl: widget.baseUrl, + token: widget.token, + tmdbStillUrl: tmdbStillUrl, + isActive: ep.Id == activeEpisodeId, + onTap: () => _handleEpisodeTap(ep), + ), + ); + }, + ), + ), + ), + ], + ); + }, + ); + } +} + +class _EpisodeCard extends StatefulWidget { + final EmbyRawItem episode; + final String seriesId; + final String baseUrl; + final String token; + final String? tmdbStillUrl; + final bool isActive; + final VoidCallback onTap; + + + final double? width; + final int overviewMaxLines; + + const _EpisodeCard({ + required this.episode, + required this.seriesId, + required this.baseUrl, + required this.token, + required this.tmdbStillUrl, + required this.isActive, + required this.onTap, + this.width, + this.overviewMaxLines = 1, + }); + + @override + State<_EpisodeCard> createState() => _EpisodeCardState(); +} + +class _EpisodeCardState extends State<_EpisodeCard> { + bool _hovered = false; + + void _showEpisodeOverviewDialog(BuildContext context, String overview) { + showAppRawDialog( + context, + constraints: const BoxConstraints(maxWidth: 480, maxHeight: 400), + builder: (ctx) => ColoredBox( + color: const Color(0xFF1a1c22), + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 24, 24, 16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.episode.IndexNumber != null + ? '${formatEpisodeNumber(widget.episode.IndexNumber)} ${widget.episode.Name}' + : widget.episode.Name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + const SizedBox(height: 14), + Flexible( + child: SingleChildScrollView( + child: Text( + overview, + style: TextStyle( + fontSize: 13, + height: 1.75, + color: Colors.white.withValues(alpha: 0.82), + ), + ), + ), + ), + const SizedBox(height: 12), + Align( + alignment: Alignment.centerRight, + child: TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: Text( + '关闭', + style: TextStyle( + color: Colors.white.withValues(alpha: 0.6), + ), + ), + ), + ), + ], + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final ep = widget.episode; + final progress = playbackProgress(widget.episode); + final epNum = formatEpisodeNumber(ep.IndexNumber); + + return MouseRegion( + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: GestureDetector( + onTap: widget.onTap, + child: SizedBox( + width: widget.width ?? _EpisodesBodyState._itemWidth, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AspectRatio( + aspectRatio: 16 / 9, + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Stack( + fit: StackFit.expand, + children: [ + EpisodeThumb( + episode: widget.episode, + seriesId: widget.seriesId, + baseUrl: widget.baseUrl, + token: widget.token, + tmdbStillUrl: widget.tmdbStillUrl, + aspectRatio: 16 / 9, + ), + + if (_hovered) + Container( + color: Colors.black.withValues(alpha: 0.35), + child: const Center( + child: Icon( + Icons.play_circle_outline, + color: Colors.white, + size: 36, + ), + ), + ), + + if (progress > 0) CardProgressBar(progress: progress), + + if (epNum != null) + Positioned( + top: 6, + left: 6, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.65), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + epNum, + style: const TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + + if (widget.isActive) + Positioned.fill( + child: Container( + decoration: BoxDecoration( + border: Border.all( + color: Colors.white, + width: 2.5, + ), + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ], + ), + ), + ), + const SizedBox(height: 6), + Text( + ep.IndexNumber != null + ? '${formatEpisodeNumber(ep.IndexNumber)} ${ep.Name}' + : ep.Name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 13, + fontWeight: widget.isActive + ? FontWeight.w600 + : FontWeight.w500, + color: Colors.white.withValues(alpha: 0.9), + ), + ), + if (ep.Overview != null && ep.Overview!.isNotEmpty) + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => + _showEpisodeOverviewDialog(context, ep.Overview!), + child: Text( + ep.Overview!, + maxLines: widget.overviewMaxLines, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12, + color: Colors.white.withValues(alpha: 0.6), + ), + ), + ), + ], + ), + ), + ), + ); + } +} + + +class _EpisodeGridSheet extends StatefulWidget { + final List episodes; + final String seriesId; + final String? activeEpisodeId; + final Map tmdbStillUrls; + final String baseUrl; + final String token; + final void Function(EmbyRawItem episode) onEpisodeTap; + + const _EpisodeGridSheet({ + required this.episodes, + required this.seriesId, + required this.activeEpisodeId, + required this.tmdbStillUrls, + required this.baseUrl, + required this.token, + required this.onEpisodeTap, + }); + + @override + State<_EpisodeGridSheet> createState() => _EpisodeGridSheetState(); +} + +class _EpisodeGridSheetState extends State<_EpisodeGridSheet> { + static const _maxCrossAxisExtent = 240.0; + static const _gridSpacing = 12.0; + + static const _childAspectRatio = 1.05; + static const _hPad = 20.0; + + ScrollController? _controller; + + @override + void dispose() { + _controller?.dispose(); + super.dispose(); + } + + ScrollController _controllerFor(double gridWidth) { + final existing = _controller; + if (existing != null) return existing; + + double offset = 0; + final idx = widget.episodes.indexWhere( + (e) => e.Id == widget.activeEpisodeId, + ); + if (idx > 0) { + + final columns = (gridWidth / (_maxCrossAxisExtent + _gridSpacing)) + .ceil() + .clamp(1, 100); + final itemWidth = (gridWidth - (columns - 1) * _gridSpacing) / columns; + final rowHeight = itemWidth / _childAspectRatio + _gridSpacing; + offset = (idx ~/ columns) * rowHeight; + } + return _controller = ScrollController(initialScrollOffset: offset); + } + + @override + Widget build(BuildContext context) { + + return ColoredBox( + color: const Color(0xFF1a1c22), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(_hPad, 16, _hPad, 12), + child: Text( + '${widget.episodes.length} 集', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ), + Flexible( + child: LayoutBuilder( + builder: (context, constraints) { + final gridWidth = constraints.maxWidth - _hPad * 2; + return GridView.builder( + controller: _controllerFor(gridWidth), + padding: const EdgeInsets.fromLTRB(_hPad, 0, _hPad, _hPad), + gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: _maxCrossAxisExtent, + mainAxisSpacing: _gridSpacing, + crossAxisSpacing: _gridSpacing, + childAspectRatio: _childAspectRatio, + ), + itemCount: widget.episodes.length, + itemBuilder: (_, i) { + final ep = widget.episodes[i]; + final tmdbStillUrl = ep.IndexNumber == null + ? null + : widget.tmdbStillUrls[ep.IndexNumber]; + return _EpisodeCard( + episode: ep, + seriesId: widget.seriesId, + baseUrl: widget.baseUrl, + token: widget.token, + tmdbStillUrl: tmdbStillUrl, + isActive: ep.Id == widget.activeEpisodeId, + width: double.infinity, + overviewMaxLines: 2, + onTap: () => widget.onEpisodeTap(ep), + ); + }, + ); + }, + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/detail/widgets/episode_selection_resolver.dart b/lib/features/detail/widgets/episode_selection_resolver.dart new file mode 100644 index 0000000..f652ad8 --- /dev/null +++ b/lib/features/detail/widgets/episode_selection_resolver.dart @@ -0,0 +1,50 @@ +import '../../../core/contracts/library.dart'; +import '../../../shared/utils/tmdb_episode_helpers.dart'; + + +String? resolveInitialSeasonId({ + required List seasons, + String? autoSelectSeasonId, + String? initialSeasonId, + String? currentEpisodeSeasonId, + int? currentEpisodeSeasonNumber, +}) { + if (seasons.isEmpty) return null; + bool has(String? id) => id != null && seasons.any((s) => s.Id == id); + if (has(autoSelectSeasonId)) return autoSelectSeasonId; + if (has(initialSeasonId)) return initialSeasonId; + if (has(currentEpisodeSeasonId)) return currentEpisodeSeasonId; + if (currentEpisodeSeasonNumber != null) { + for (final s in seasons) { + if (seasonNumberFrom(s) == currentEpisodeSeasonNumber) return s.Id; + } + } + return seasons.first.Id; +} + + +String? resolveActiveEpisodeId({ + required List episodes, + String? currentEpisodeId, + String? displayedSeasonId, + int? displayedSeasonNumber, + String? currentEpisodeSeasonId, + int? currentEpisodeSeasonNumber, + int? currentEpisodeIndexNumber, +}) { + final id = currentEpisodeId; + if (id == null || id.isEmpty) return null; + if (episodes.any((e) => e.Id == id)) return id; + final seasonMatches = + (currentEpisodeSeasonId != null && + currentEpisodeSeasonId == displayedSeasonId) || + (currentEpisodeSeasonNumber != null && + displayedSeasonNumber != null && + currentEpisodeSeasonNumber == displayedSeasonNumber); + if (seasonMatches && currentEpisodeIndexNumber != null) { + for (final e in episodes) { + if (e.IndexNumber == currentEpisodeIndexNumber) return e.Id; + } + } + return id; +} diff --git a/lib/features/detail/widgets/hero_action_buttons.dart b/lib/features/detail/widgets/hero_action_buttons.dart new file mode 100644 index 0000000..19b7ad0 --- /dev/null +++ b/lib/features/detail/widgets/hero_action_buttons.dart @@ -0,0 +1,348 @@ +import 'package:flutter/material.dart'; +import 'package:forui/forui.dart'; + +import '../../../shared/widgets/app_loading_ring.dart'; +import '../../../shared/widgets/auto_dismiss_menu.dart'; + + +class HeroIconActionButton extends StatelessWidget { + final IconData icon; + final double height; + final VoidCallback onPressed; + final String? label; + + const HeroIconActionButton({ + super.key, + required this.icon, + required this.height, + required this.onPressed, + this.label, + }); + + @override + Widget build(BuildContext context) { + if (label != null) { + return GestureDetector( + onTap: onPressed, + behavior: HitTestBehavior.opaque, + child: SizedBox( + height: height, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: 24, color: Colors.white), + const SizedBox(height: 4), + Text( + label!, + style: TextStyle( + fontSize: 11, + color: Colors.white.withValues(alpha: 0.7), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ); + } + + return SizedBox( + height: height, + child: OutlinedButton( + onPressed: onPressed, + style: OutlinedButton.styleFrom( + foregroundColor: Colors.white, + side: BorderSide(color: Colors.white.withValues(alpha: 0.4)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + padding: EdgeInsets.zero, + ), + child: Icon(icon, size: height * 0.44), + ), + ); + } +} + + +final _heroPillBorder = StadiumBorder( + side: BorderSide(color: Colors.white.withValues(alpha: 0.55)), +); + +const _heroDropdownMenuMaxHeight = 320.0; + +class HeroPillDropdownButton extends StatelessWidget { + final IconData icon; + final List menuChildren; + final double height; + + const HeroPillDropdownButton({ + super.key, + required this.icon, + required this.menuChildren, + this.height = 52, + }); + + @override + Widget build(BuildContext context) { + return FPopoverMenu( + menuBuilder: autoDismissMenuBuilder, + menuAnchor: Alignment.topCenter, + childAnchor: Alignment.bottomCenter, + maxHeight: _heroDropdownMenuMaxHeight, + menu: [FItemGroup(children: menuChildren)], + builder: (context, controller, child) => GestureDetector( + onTap: controller.toggle, + child: child, + ), + child: Material( + color: Colors.transparent, + shape: _heroPillBorder, + clipBehavior: Clip.antiAlias, + child: SizedBox( + height: height, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Icon(icon, size: 22, color: Colors.white), + ), + ), + ), + ); + } +} + + +class DetailHeroActions extends StatelessWidget { + + final bool showReplay; + + + final VoidCallback? onReplay; + + final bool isPlayed; + final VoidCallback onTogglePlayed; + final bool isFavorite; + final VoidCallback onToggleFavorite; + + + final List extraIconActions; + + + final VoidCallback? onPlay; + + + final String playLabel; + + + final double? progressPercent; + + + final String? trailingLabel; + + final bool isPlayLoading; + final bool compact; + + const DetailHeroActions({ + super.key, + this.showReplay = false, + this.onReplay, + required this.isPlayed, + required this.onTogglePlayed, + required this.isFavorite, + required this.onToggleFavorite, + this.extraIconActions = const [], + this.onPlay, + this.playLabel = '播放', + this.progressPercent, + this.trailingLabel, + this.isPlayLoading = false, + required this.compact, + }); + + @override + Widget build(BuildContext context) { + final btnH = compact ? 44.0 : 52.0; + + final iconRow = Row( + children: [ + if (showReplay) ...[ + Expanded( + child: HeroIconActionButton( + icon: Icons.replay, + height: btnH, + label: compact ? '重播' : null, + onPressed: onReplay ?? () {}, + ), + ), + SizedBox(width: compact ? 0 : 10), + ], + Expanded( + child: HeroIconActionButton( + icon: isPlayed ? Icons.check_circle : Icons.check_circle_outline, + height: btnH, + label: compact ? '已看' : null, + onPressed: onTogglePlayed, + ), + ), + SizedBox(width: compact ? 0 : 10), + Expanded( + child: HeroIconActionButton( + icon: isFavorite ? Icons.favorite : Icons.favorite_border, + height: btnH, + label: compact ? '收藏' : null, + onPressed: onToggleFavorite, + ), + ), + for (final extra in extraIconActions) ...[ + SizedBox(width: compact ? 0 : 10), + Expanded(child: extra), + ], + ], + ); + + final playButton = onPlay != null + ? HeroPlayButton( + height: btnH, + label: playLabel, + progressPercent: progressPercent, + trailingLabel: trailingLabel, + isLoading: isPlayLoading, + compact: compact, + onPressed: onPlay, + ) + : null; + + return ConstrainedBox( + constraints: BoxConstraints(maxWidth: compact ? double.infinity : 360), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: compact + ? [ + if (playButton != null) ...[ + playButton, + const SizedBox(height: 16), + ], + iconRow, + ] + : [ + iconRow, + if (playButton != null) ...[ + const SizedBox(height: 12), + playButton, + ], + ], + ), + ); + } +} + + +class HeroPlayButton extends StatelessWidget { + final double height; + final String label; + final IconData icon; + final double? progressPercent; + final String? trailingLabel; + final bool isLoading; + final String loadingLabel; + final bool compact; + final VoidCallback? onPressed; + + const HeroPlayButton({ + super.key, + required this.height, + required this.label, + this.icon = Icons.play_arrow, + this.progressPercent, + this.trailingLabel, + this.isLoading = false, + this.loadingLabel = '启动中', + this.compact = false, + required this.onPressed, + }); + + @override + Widget build(BuildContext context) { + final radius = BorderRadius.circular(12); + final hasProgress = progressPercent != null && progressPercent! > 0; + + final buttonChild = Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + isLoading + ? const AppLoadingRing(size: 18, color: Colors.black54) + : Icon(icon, size: compact ? 22 : 26), + const SizedBox(width: 6), + Text( + isLoading ? loadingLabel : label, + style: TextStyle( + fontSize: compact ? 15 : 16, + fontWeight: FontWeight.w600, + ), + ), + if (trailingLabel != null && !isLoading) ...[ + const SizedBox(width: 10), + Text( + trailingLabel!, + style: TextStyle( + fontSize: compact ? 13 : 14, + color: Colors.black.withValues(alpha: 0.5), + ), + ), + ], + ], + ); + + if (hasProgress) { + final fraction = (progressPercent! / 100).clamp(0.0, 1.0); + return ClipRRect( + borderRadius: radius, + child: SizedBox( + height: height, + child: Stack( + children: [ + Positioned.fill( + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: const [Color(0xFFE3E3E3), Colors.white], + stops: [fraction, fraction], + ), + ), + ), + ), + Positioned.fill( + child: FilledButton( + onPressed: isLoading ? null : onPressed, + style: FilledButton.styleFrom( + backgroundColor: Colors.transparent, + foregroundColor: Colors.black, + overlayColor: Colors.black.withValues(alpha: 0.08), + shadowColor: Colors.transparent, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: radius), + ), + child: buttonChild, + ), + ), + ], + ), + ), + ); + } + + return SizedBox( + height: height, + child: FilledButton( + onPressed: isLoading ? null : onPressed, + style: FilledButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: Colors.black, + shape: RoundedRectangleBorder(borderRadius: radius), + ), + child: buttonChild, + ), + ); + } +} diff --git a/lib/features/detail/widgets/media_info_section.dart b/lib/features/detail/widgets/media_info_section.dart new file mode 100644 index 0000000..7c30c94 --- /dev/null +++ b/lib/features/detail/widgets/media_info_section.dart @@ -0,0 +1,282 @@ +import 'package:flutter/material.dart'; +import 'package:forui/forui.dart'; + +import '../../../core/contracts/library.dart'; +import '../../../shared/constants/breakpoints.dart'; +import '../../../shared/widgets/frosted_panel.dart'; +import '../../../shared/widgets/hover_scrollable_row.dart'; + +class MediaInfoSection extends StatelessWidget { + final EmbyRawItem item; + final int selectedSourceIndex; + final EmbyRawMediaSource? selectedSource; + final bool embedded; + + const MediaInfoSection({ + super.key, + required this.item, + this.selectedSourceIndex = 0, + this.selectedSource, + this.embedded = false, + }); + + static bool hasVisibleContent({ + required EmbyRawItem item, + int selectedSourceIndex = 0, + EmbyRawMediaSource? selectedSource, + }) { + if (item.Type == 'Series') return false; + + final EmbyRawMediaSource source; + if (selectedSource != null) { + source = selectedSource; + } else { + final sources = mediaSourcesOfItem(item); + if (sources.isEmpty) return false; + source = sources[selectedSourceIndex.clamp(0, sources.length - 1)]; + } + final streams = source.MediaStreams ?? []; + if (streams.isEmpty) return false; + + return streams.any( + (stream) => + stream.Type == 'Video' || + stream.Type == 'Audio' || + stream.Type == 'Subtitle', + ); + } + + @override + Widget build(BuildContext context) { + if (!hasVisibleContent( + item: item, + selectedSourceIndex: selectedSourceIndex, + selectedSource: selectedSource, + )) { + return const SizedBox.shrink(); + } + + final EmbyRawMediaSource source; + if (selectedSource != null) { + source = selectedSource!; + } else { + final sources = mediaSourcesOfItem(item); + if (sources.isEmpty) return const SizedBox.shrink(); + source = sources[selectedSourceIndex.clamp(0, sources.length - 1)]; + } + final streams = source.MediaStreams ?? []; + if (streams.isEmpty) return const SizedBox.shrink(); + + final hPad = embedded + ? 0.0 + : (MediaQuery.sizeOf(context).width < Breakpoints.detail ? 16.0 : 32.0); + + final defaultAudioIdx = source.DefaultAudioStreamIndex; + final cards = []; + + for (final s in streams.where((s) => s.Type == 'Video')) { + cards.add(_StreamCard(stream: s, kind: _Kind.video)); + } + for (final s in streams.where((s) => s.Type == 'Audio')) { + final isDefault = + (s.Index != null && s.Index == defaultAudioIdx) || + (s.IsDefault ?? false); + cards.add( + _StreamCard(stream: s, kind: _Kind.audio, isDefault: isDefault), + ); + } + for (final s in streams.where((s) => s.Type == 'Subtitle')) { + cards.add( + _StreamCard( + stream: s, + kind: _Kind.subtitle, + isDefault: s.IsDefault ?? false, + ), + ); + } + + if (cards.isEmpty) return const SizedBox.shrink(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const FDivider(style: .delta(color: Color(0x1AFFFFFF), padding: .value(EdgeInsets.symmetric(vertical: 24)))), + Padding( + padding: EdgeInsets.fromLTRB(hPad, 0, hPad, 16), + child: Text( + '媒体信息', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w600, + color: Colors.white.withValues(alpha: 0.9), + ), + ), + ), + SizedBox( + height: 280, + child: HoverScrollableRow( + builder: (_, controller) => ListView.builder( + controller: controller, + scrollDirection: Axis.horizontal, + padding: EdgeInsets.symmetric(horizontal: hPad), + itemCount: cards.length, + itemBuilder: (_, i) => Padding( + padding: EdgeInsets.only(right: i < cards.length - 1 ? 12 : 0), + child: cards[i], + ), + ), + ), + ), + ], + ); + } +} + +enum _Kind { video, audio, subtitle } + +class _StreamCard extends StatelessWidget { + final EmbyRawMediaStream stream; + final _Kind kind; + final bool isDefault; + + const _StreamCard({ + required this.stream, + required this.kind, + this.isDefault = false, + }); + + List<(String label, String value)> _fields() { + final ex = stream.extra; + final rows = <(String, String)>[]; + + switch (kind) { + case _Kind.video: + _add(rows, '编码', stream.Codec); + final w = ex['Width'] as int?; + final h = ex['Height'] as int?; + if (w != null && h != null) rows.add(('分辨率', '${w}x$h')); + final fr = + (ex['RealFrameRate'] as num?) ?? (ex['AverageFrameRate'] as num?); + if (fr != null) rows.add(('帧率', fr.toDouble().toStringAsFixed(1))); + final br = ex['BitRate'] as int?; + if (br != null) rows.add(('比特率', '${(br / 1000).round()}kbps')); + _add(rows, '动态范围', ex['VideoRange'] as String?); + _add(rows, '配置', ex['Profile'] as String?); + case _Kind.audio: + _add(rows, '标题', stream.Title); + _add(rows, '内嵌标题', stream.DisplayTitle); + _add(rows, '语言', stream.Language); + _add(rows, '布局', ex['ChannelLayout'] as String?); + if (stream.Channels != null) rows.add(('声道', '${stream.Channels}')); + _add(rows, '编码', stream.Codec); + final br = ex['BitRate'] as int?; + if (br != null) rows.add(('比特率', '${(br / 1000).round()}kbps')); + final sr = ex['SampleRate'] as int?; + if (sr != null) rows.add(('采样率', '${sr}Hz')); + rows.add(('外部', (stream.IsExternal ?? false) ? '是' : '否')); + rows.add(('默认', (stream.IsDefault ?? false) ? '是' : '否')); + case _Kind.subtitle: + _add(rows, '标题', stream.Title); + _add(rows, '内嵌标题', stream.DisplayTitle); + _add(rows, '语言', stream.Language); + _add(rows, '格式', stream.Codec); + rows.add(('外部', (stream.IsExternal ?? false) ? '是' : '否')); + if (stream.IsForced ?? false) rows.add(('强制', '是')); + rows.add(('默认', (stream.IsDefault ?? false) ? '是' : '否')); + } + return rows; + } + + static void _add(List<(String, String)> rows, String label, String? value) { + if (value != null && value.isNotEmpty) rows.add((label, value)); + } + + @override + Widget build(BuildContext context) { + final fields = _fields(); + + final (IconData icon, String name) = switch (kind) { + _Kind.video => (Icons.videocam_outlined, '视频'), + _Kind.audio => (Icons.music_note_outlined, '音频'), + _Kind.subtitle => (Icons.subtitles_outlined, '字幕'), + }; + + return SizedBox( + width: 260, + height: 280, + child: FrostedPanel( + radius: 12, + padding: const EdgeInsets.all(16), + blurSigma: 15, + border: Border.all(color: Colors.white.withValues(alpha: 0.08)), + color: Colors.black26, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, size: 16, color: Colors.white), + const SizedBox(width: 6), + Text( + name, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + if (isDefault) ...[ + const SizedBox(width: 6), + Icon( + Icons.check, + size: 14, + color: Colors.white.withValues(alpha: 0.7), + ), + ], + ], + ), + const SizedBox(height: 12), + Expanded( + child: SingleChildScrollView( + child: Column( + children: [ + for (final (label, value) in fields) + Padding( + padding: const EdgeInsets.only(bottom: 5), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 60, + child: Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w400, + color: Colors.white.withValues(alpha: 0.55), + ), + ), + ), + Expanded( + child: Text( + value, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w400, + color: Colors.white.withValues(alpha: 0.85), + ), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/detail/widgets/multi_source_ordering.dart b/lib/features/detail/widgets/multi_source_ordering.dart new file mode 100644 index 0000000..349be67 --- /dev/null +++ b/lib/features/detail/widgets/multi_source_ordering.dart @@ -0,0 +1,87 @@ + + +library; + +import '../../../core/contracts/library.dart'; +import '../../../providers/version_priority_provider.dart'; +import '../../../shared/widgets/version_filter.dart'; +import '../../../shared/widgets/version_source_card.dart'; + + +class MultiSourceVersionEntry { + final String bucket; + final VersionSourceCardData data; + final EmbyRawMediaSource source; + final bool isLocal; + + + final bool isActive; + + + final int localIndex; + + final String serverName; + final String serverId; + final String itemId; + final String mediaSourceId; + + const MultiSourceVersionEntry({ + required this.bucket, + required this.data, + required this.source, + required this.isLocal, + required this.isActive, + required this.localIndex, + required this.serverName, + required this.serverId, + required this.itemId, + required this.mediaSourceId, + }); +} + +void sortMultiSourceEntries( + List entries, + List priorities, +) { + entries.sort((a, b) => compareMultiSourceEntries(a, b, priorities)); +} + + +int compareMultiSourceEntries( + MultiSourceVersionEntry a, + MultiSourceVersionEntry b, + List priorities, +) { + final ba = kVersionBucketOrder.indexOf(a.bucket); + final bb = kVersionBucketOrder.indexOf(b.bucket); + if (ba != bb) return ba.compareTo(bb); + + if (priorities.isNotEmpty) { + final cmp = compareMediaSourcesByPriority(a.source, b.source, priorities); + if (cmp != 0) return cmp; + } + + if (a.isLocal != b.isLocal) return a.isLocal ? -1 : 1; + + if (a.isLocal && b.isLocal) return a.localIndex.compareTo(b.localIndex); + final byServerName = a.serverName.compareTo(b.serverName); + if (byServerName != 0) return byServerName; + final byServerId = a.serverId.compareTo(b.serverId); + if (byServerId != 0) return byServerId; + final byItemId = a.itemId.compareTo(b.itemId); + if (byItemId != 0) return byItemId; + return a.mediaSourceId.compareTo(b.mediaSourceId); +} + + +String? defaultSelectedBucketForEntries(List entries) { + if (entries.isEmpty) return null; + for (final e in entries) { + if (e.isActive) return e.bucket; + } + final present = entries.map((e) => e.bucket).toSet(); + for (final bucket in kVersionBucketOrder) { + if (present.contains(bucket)) return bucket; + } + return entries.first.bucket; +} diff --git a/lib/features/detail/widgets/multi_source_scroll.dart b/lib/features/detail/widgets/multi_source_scroll.dart new file mode 100644 index 0000000..c36e071 --- /dev/null +++ b/lib/features/detail/widgets/multi_source_scroll.dart @@ -0,0 +1,19 @@ + +library; + + +double multiSourceScrollOffset({ + required int activeIndex, + required double viewportWidth, + required double leadingPad, + required double maxScrollExtent, + double cardWidth = 220.0, + double cardGap = 12.0, +}) { + if (activeIndex <= 0) return 0.0; + final target = + leadingPad + + activeIndex * (cardWidth + cardGap) - + (viewportWidth - cardWidth) / 2; + return target.clamp(0.0, maxScrollExtent).toDouble(); +} diff --git a/lib/features/detail/widgets/multi_source_section.dart b/lib/features/detail/widgets/multi_source_section.dart new file mode 100644 index 0000000..01d4a1e --- /dev/null +++ b/lib/features/detail/widgets/multi_source_section.dart @@ -0,0 +1,536 @@ +import 'dart:io' show Platform; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:forui/forui.dart' as forui; + +import '../../../core/contracts/library.dart'; +import '../../../providers/di_providers.dart'; +import '../../../providers/session_provider.dart'; +import '../../../providers/version_priority_provider.dart'; +import '../../../shared/constants/breakpoints.dart'; +import '../../../shared/utils/cross_server_search_key.dart'; +import '../../../shared/widgets/all_versions_sheet.dart'; +import '../../../shared/widgets/app_loading_ring.dart'; +import '../../../shared/widgets/frosted_panel.dart'; +import '../../../shared/widgets/hover_scrollable_row.dart'; +import '../../../shared/widgets/version_filter.dart'; +import '../../../shared/widgets/version_grouping.dart'; +import '../../../shared/widgets/version_source_card.dart'; +import 'multi_source_ordering.dart'; +import 'multi_source_scroll.dart'; + + +typedef MultiSourceTap = + Future Function( + String serverId, + String itemId, { + String? mediaSourceId, + CrossServerSourceCard? card, + }); + + +typedef LocalSourceTap = void Function(EmbyRawMediaSource src, int index); + +class MultiSourceSection extends ConsumerStatefulWidget { + final CrossServerSearchReq req; + final MultiSourceTap? onSourceTap; + final bool embedded; + + + final List localSources; + + + final String? currentLocalSourceId; + + + final String? currentServerName; + + + final LocalSourceTap? onLocalSourceTap; + + + final String? excludedServerIdOverride; + + + final String title; + + + final String? selectedCrossServerKey; + + + final bool showLoadingWhenEmpty; + + + final bool externalLoading; + + + final String loadingText; + + const MultiSourceSection({ + super.key, + required this.req, + this.onSourceTap, + this.embedded = false, + this.localSources = const [], + this.currentLocalSourceId, + this.currentServerName, + this.onLocalSourceTap, + this.excludedServerIdOverride, + this.title = '多源聚合', + this.selectedCrossServerKey, + this.showLoadingWhenEmpty = false, + this.externalLoading = false, + this.loadingText = '正在查找可用版本…', + }); + + @override + ConsumerState createState() => _MultiSourceSectionState(); +} + +String _bucketOf(CrossServerSourceCard card) { + return versionBucketOf(card.quality?.resolutionLabel); +} + +String _bucketOfLocal(EmbyRawMediaSource src) { + return versionBucketOf(MediaQualityInfo.fromMediaSource(src).resolutionLabel); +} + +class _MultiSourceSectionState extends ConsumerState { + static final double _cardWidth = Platform.isAndroid ? 170.0 : 220.0; + static const double _cardGap = 12.0; + + final ScrollController _scrollController = ScrollController(); + String? _selectedBucket; + + String? _autoScrolledKey; + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final session = ref.watch(activeSessionProvider); + if (session == null) return const SizedBox.shrink(); + + final req = widget.req; + final key = ( + activeServerId: session.serverId, + anchorType: req.anchorType, + providerIdQuery: encodeProviderIdsForKey(req.providerIds), + seriesProviderIdQuery: encodeProviderIdsForKey( + req.seriesProviderIds ?? const {}, + ), + parentIndexNumber: req.parentIndexNumber ?? -1, + indexNumber: req.indexNumber ?? -1, + itemName: req.itemName, + excludedServerId: widget.excludedServerIdOverride ?? session.serverId, + ); + + final asyncResult = ref.watch(crossServerSearchDataProvider(key)); + + return _renderContent( + asyncResult, + widget.excludedServerIdOverride ?? session.serverId, + ); + } + + Widget _renderContent( + AsyncValue> asyncResult, + String currentServerId, + ) { + final cards = asyncResult.maybeWhen( + data: (list) => list, + orElse: () => asyncResult.value ?? const [], + ); + + final priorities = + ref.watch(versionPriorityProvider).value?.activePriorities ?? + const []; + + final entries = []; + final localSources = widget.localSources; + for (var i = 0; i < localSources.length; i++) { + final src = localSources[i]; + final isCurrent = + widget.currentLocalSourceId != null && + src.Id == widget.currentLocalSourceId; + entries.add( + MultiSourceVersionEntry( + bucket: _bucketOfLocal(src), + data: _localCardData(src, i, currentServerId), + source: src, + isLocal: true, + isActive: isCurrent, + localIndex: i, + serverName: widget.currentServerName ?? '本服务器', + serverId: '', + itemId: '', + mediaSourceId: src.Id ?? '', + ), + ); + } + for (final c in cards) { + final isSelected = + widget.selectedCrossServerKey != null && + widget.selectedCrossServerKey == '${c.serverId}|${c.mediaSourceId}'; + entries.add( + MultiSourceVersionEntry( + bucket: _bucketOf(c), + data: _crossServerCardData(c), + source: c.mediaSource, + isLocal: false, + isActive: isSelected, + localIndex: -1, + serverName: c.serverName, + serverId: c.serverId, + itemId: c.landingItemId, + mediaSourceId: c.mediaSourceId, + ), + ); + } + + sortMultiSourceEntries(entries, priorities); + final allGroups = groupVersionEntries([ + for (final entry in entries) entry.data, + ]); + + final bucketsPresent = {}; + for (final e in entries) { + bucketsPresent.add(e.bucket); + } + final buckets = kVersionBucketOrder + .where(bucketsPresent.contains) + .toList(growable: false); + + final canonicalSelected = + _selectedBucket != null && bucketsPresent.contains(_selectedBucket) + ? _selectedBucket! + : defaultSelectedBucketForEntries(entries); + + final filteredGroups = canonicalSelected == null + ? allGroups + : allGroups + .where( + (group) => + versionBucketOf(group.representative.resolutionLabel) == + canonicalSelected, + ) + .toList(growable: false); + + final hasMergedGroup = allGroups.any((group) => group.members.length > 1); + final showAllVersionsButton = + filteredGroups.length > inlineVersionLimit() || hasMergedGroup; + + final width = MediaQuery.sizeOf(context).width; + final compact = width < Breakpoints.detail; + final hPad = compact ? 16.0 : 32.0; + + if (entries.isEmpty) { + final loading = widget.externalLoading || asyncResult.isLoading; + if (widget.showLoadingWhenEmpty && loading) { + return _wrapContent( + _buildLoadingContent(widget.embedded ? 0.0 : hPad), + hPad, + ); + } + return const SizedBox.shrink(); + } + + final content = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildHeader( + buckets, + canonicalSelected, + allGroups, + entries.length, + showAllVersionsButton, + widget.embedded ? 0.0 : hPad, + ), + const SizedBox(height: 16), + _buildCardList( + filteredGroups, + canonicalSelected, + widget.embedded ? 0.0 : hPad, + ), + ], + ); + + return _wrapContent(content, hPad); + } + + Widget _wrapContent(Widget content, double hPad) { + if (widget.embedded) return content; + return Padding( + padding: EdgeInsets.symmetric(horizontal: hPad), + child: FrostedPanel( + radius: 16, + padding: EdgeInsets.all(hPad), + blurSigma: 15, + border: Border.all(color: Colors.white.withValues(alpha: 0.08)), + color: Colors.black.withValues(alpha: 0.3), + child: content, + ), + ); + } + + Widget _buildLoadingContent(double hPad) { + final compact = MediaQuery.sizeOf(context).width < Breakpoints.compact; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: EdgeInsets.symmetric(horizontal: hPad), + child: Row( + children: [ + Text( + widget.title, + style: TextStyle( + fontSize: compact ? 17 : 20, + fontWeight: FontWeight.w600, + color: Colors.white.withValues(alpha: 0.9), + ), + ), + const Spacer(), + const AppLoadingRing(size: 16, color: Colors.white), + const SizedBox(width: 8), + Flexible( + child: Text( + widget.loadingText, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 13, + color: Colors.white.withValues(alpha: 0.62), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + SizedBox(height: Platform.isAndroid ? 100 : 120), + ], + ); + } + + Widget _buildHeader( + List buckets, + String? selectedBucket, + List allGroups, + int sourceCount, + bool showAllVersionsButton, + double hPad, + ) { + final compact = MediaQuery.sizeOf(context).width < Breakpoints.compact; + final titleAndAction = Wrap( + spacing: 10, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Text( + widget.title, + style: TextStyle( + fontSize: compact ? 17 : 20, + fontWeight: FontWeight.w600, + color: Colors.white.withValues(alpha: 0.9), + ), + ), + if (showAllVersionsButton) + forui.FButton( + variant: forui.FButtonVariant.secondary, + size: forui.FButtonSizeVariant.xs, + mainAxisSize: MainAxisSize.min, + onPress: () => + showAllVersionsSheet(context: context, groups: allGroups), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text('全部 $sourceCount 个版本'), + const SizedBox(width: 2), + const Icon(Icons.chevron_right_rounded, size: 16), + ], + ), + ), + ], + ); + final filterChips = Row( + mainAxisSize: MainAxisSize.min, + children: [ + for ( + var bucketIndex = 0; + bucketIndex < buckets.length; + bucketIndex++ + ) ...[ + if (bucketIndex > 0) const SizedBox(width: 8), + VersionFilterChip( + label: buckets[bucketIndex], + selected: selectedBucket == buckets[bucketIndex], + showDot: true, + onTap: () => setState(() => _selectedBucket = buckets[bucketIndex]), + ), + ], + ], + ); + + return Padding( + padding: EdgeInsets.symmetric(horizontal: hPad), + child: compact + ? Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + titleAndAction, + if (buckets.isNotEmpty) ...[ + const SizedBox(height: 12), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: filterChips, + ), + ], + ], + ) + : Row( + children: [ + titleAndAction, + if (buckets.isNotEmpty) ...[ + const SizedBox(width: 16), + Expanded( + child: Align( + alignment: Alignment.centerRight, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + reverse: true, + child: filterChips, + ), + ), + ), + ], + ], + ), + ); + } + + Widget _buildCardList( + List groups, + String? bucket, + double hPad, + ) { + if (groups.isEmpty) return const SizedBox.shrink(); + + final inlineLimit = inlineVersionLimit(); + final inlineGroups = groups.take(inlineLimit).toList(growable: true); + final activeGroupIndex = groups.indexWhere( + (group) => group.hasActiveMember, + ); + if (activeGroupIndex >= inlineLimit && inlineGroups.isNotEmpty) { + inlineGroups[inlineGroups.length - 1] = groups[activeGroupIndex]; + } + + _maybeScrollActiveIntoView(inlineGroups, bucket, hPad); + final itemCount = inlineGroups.length; + + return SizedBox( + height: Platform.isAndroid ? 100 : 120, + child: HoverScrollableRow( + controller: _scrollController, + builder: (_, controller) => ListView.builder( + controller: controller, + scrollDirection: Axis.horizontal, + padding: EdgeInsets.fromLTRB(hPad, 0, hPad, 0), + itemCount: itemCount, + itemBuilder: (_, i) => Padding( + padding: EdgeInsets.only(right: i < itemCount - 1 ? 12 : 0), + child: VersionSourceCard( + data: inlineGroups[i].displayData, + onMergedServersTap: inlineGroups[i].members.length > 1 + ? () => showAllVersionsSheet( + context: context, + groups: [inlineGroups[i]], + title: '选择服务器', + ) + : null, + ), + ), + ), + ), + ); + } + + void _maybeScrollActiveIntoView( + List groups, + String? bucket, + double leadingPad, + ) { + final activeGroupIndex = groups.indexWhere( + (group) => group.hasActiveMember, + ); + final activeSourceKey = activeGroupIndex < 0 + ? '' + : groups[activeGroupIndex].activeMember?.sourceKey ?? ''; + final key = '${bucket ?? ''}|$activeSourceKey'; + if (key == _autoScrolledKey) return; + _autoScrolledKey = key; + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !_scrollController.hasClients) return; + final position = _scrollController.position; + _scrollController.jumpTo( + multiSourceScrollOffset( + activeIndex: activeGroupIndex, + viewportWidth: position.viewportDimension, + leadingPad: leadingPad, + maxScrollExtent: position.maxScrollExtent, + cardWidth: _cardWidth, + cardGap: _cardGap, + ), + ); + }); + } + + VersionSourceCardData _localCardData( + EmbyRawMediaSource src, + int index, + String currentServerId, + ) { + final tap = widget.onLocalSourceTap; + final isCurrent = + widget.currentLocalSourceId != null && + src.Id == widget.currentLocalSourceId; + return VersionSourceCardData.fromLocalSource( + src, + serverName: widget.currentServerName ?? '本服务器', + serverKey: currentServerId, + isCurrent: isCurrent, + tooltip: isCurrent ? null : '切换到该版本', + onTap: (tap == null || isCurrent) ? null : () async => tap(src, index), + ); + } + + VersionSourceCardData _crossServerCardData(CrossServerSourceCard card) { + final selectedKey = widget.selectedCrossServerKey; + final isSelected = + selectedKey != null && + selectedKey == '${card.serverId}|${card.mediaSourceId}'; + final data = VersionSourceCardData.fromCrossServer( + card, + selected: isSelected, + onTap: _tapHandlerFor(card), + ); + return data; + } + + Future Function()? _tapHandlerFor(CrossServerSourceCard card) { + final tap = widget.onSourceTap; + if (tap == null) return null; + return () => tap( + card.serverId, + card.landingItemId, + mediaSourceId: card.mediaSourceId, + card: card, + ); + } +} diff --git a/lib/features/detail/widgets/section_container.dart b/lib/features/detail/widgets/section_container.dart new file mode 100644 index 0000000..576cdc5 --- /dev/null +++ b/lib/features/detail/widgets/section_container.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; +import 'package:forui/forui.dart'; + +import '../../../shared/constants/breakpoints.dart'; +import '../../../shared/widgets/frosted_panel.dart'; + +class SectionContainer extends StatelessWidget { + final String title; + final Widget child; + final bool embedded; + final bool leadingDivider; + + + final Widget? leading; + + const SectionContainer({ + super.key, + required this.title, + required this.child, + this.embedded = false, + this.leadingDivider = false, + this.leading, + }); + + static double hPadOf(BuildContext context) => + MediaQuery.sizeOf(context).width < Breakpoints.detail ? 16.0 : 32.0; + + @override + Widget build(BuildContext context) { + final hPad = embedded ? 0.0 : hPadOf(context); + final innerPad = embedded ? 0.0 : hPadOf(context); + final compact = MediaQuery.sizeOf(context).width < Breakpoints.compact; + + final content = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: EdgeInsets.fromLTRB( + innerPad, + 0, + innerPad, + compact ? 12 : 16, + ), + child: Text( + title, + style: TextStyle( + fontSize: compact ? 17 : 20, + fontWeight: FontWeight.w600, + color: Colors.white.withValues(alpha: 0.9), + ), + ), + ), + child, + ], + ); + + if (embedded) { + if (leading != null) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [leading!, content], + ); + } + if (leadingDivider) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const FDivider(style: .delta(color: Color(0x1AFFFFFF), padding: .value(EdgeInsets.symmetric(vertical: 24)))), + content, + ], + ); + } + return content; + } + + return Padding( + padding: EdgeInsets.symmetric(horizontal: hPad), + child: FrostedPanel( + radius: 16, + padding: EdgeInsets.symmetric(vertical: hPad), + blurSigma: 15, + border: Border.all(color: Colors.white.withValues(alpha: 0.08)), + color: Colors.black.withValues(alpha: 0.3), + child: content, + ), + ); + } +} diff --git a/lib/features/detail/widgets/similar_section.dart b/lib/features/detail/widgets/similar_section.dart new file mode 100644 index 0000000..c94a5af --- /dev/null +++ b/lib/features/detail/widgets/similar_section.dart @@ -0,0 +1,102 @@ +import 'dart:io' show Platform; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../providers/di_providers.dart'; +import '../../../providers/emby_headers_provider.dart'; +import '../../../providers/session_provider.dart'; +import '../../../shared/mappers/media_image_url.dart'; +import '../../../shared/theme/app_theme.dart'; +import '../../../shared/utils/media_nav.dart'; +import '../../../shared/widgets/hover_scrollable_row.dart'; +import '../../../shared/widgets/media_poster_card.dart'; +import 'section_container.dart'; + +class SimilarSection extends ConsumerWidget { + final String itemId; + final bool embedded; + + + final Widget? leading; + + const SimilarSection({ + super.key, + required this.itemId, + this.embedded = false, + this.leading, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final session = ref.watch(activeSessionProvider); + if (session == null) return const SizedBox.shrink(); + final imageHeaders = ref.watch(embyImageHeadersProvider).value; + final asyncItems = ref.watch(similarItemsDataProvider(itemId)); + + return asyncItems.when( + loading: () => const SizedBox.shrink(), + error: (error, stackTrace) => const SizedBox.shrink(), + data: (items) { + if (items.isEmpty) return const SizedBox.shrink(); + final sectionPad = SectionContainer.hPadOf(context); + final listPadding = EdgeInsetsDirectional.fromSTEB( + embedded ? 0.0 : sectionPad, + 0, + sectionPad, + 0, + ); + + final isAndroid = Platform.isAndroid; + return SectionContainer( + title: '相似推荐', + embedded: embedded, + leadingDivider: embedded, + leading: leading, + child: SizedBox( + height: isAndroid ? 210 : 300, + child: Theme( + data: AppTheme.dark(), + child: HoverScrollableRow( + builder: (_, controller) => ListView.builder( + controller: controller, + scrollDirection: Axis.horizontal, + padding: listPadding, + itemCount: items.length, + itemBuilder: (_, i) { + final item = items[i]; + final imageUrl = EmbyImageUrl.primary( + baseUrl: session.serverUrl, + token: session.token, + item: item, + maxHeight: 480, + ); + return Padding( + padding: EdgeInsetsDirectional.only( + end: i < items.length - 1 ? 12 : 0, + ), + child: MediaPosterCard( + item: item, + imageUrl: imageUrl, + width: isAndroid ? 105 : 160, + showHoverPlayIcon: false, + enableContextMenu: false, + preloadImageUrl: EmbyImageUrl.detailBanner( + baseUrl: session.serverUrl, + token: session.token, + item: item, + ), + imageHeaders: imageHeaders, + onTap: () => goMediaDetail(context, item), + ), + ); + }, + ), + ), + ), + ), + ); + }, + ); + } +} diff --git a/lib/features/detail/widgets/source_selection_section.dart b/lib/features/detail/widgets/source_selection_section.dart new file mode 100644 index 0000000..40f8463 --- /dev/null +++ b/lib/features/detail/widgets/source_selection_section.dart @@ -0,0 +1,172 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../core/contracts/library.dart'; +import '../../../providers/di_providers.dart'; +import '../../../providers/session_provider.dart'; +import '../../../shared/utils/cross_server_search_key.dart'; +import 'media_info_section.dart'; +import 'multi_source_section.dart'; + + +class SourceSelectionSection extends ConsumerWidget { + + + final CrossServerSearchReq? req; + + + final List localSources; + + + final int selectedSourceIdx; + + + final CrossServerSourceCard? selectedCrossServerCard; + + + final String? currentServerName; + + + final String? excludedServerIdOverride; + + + final EmbyRawItem? localMediaInfoItem; + + + final void Function(EmbyRawMediaSource src, int index) onLocalSourceTap; + + + final void Function(CrossServerSourceCard card) onCrossServerSourceTap; + + + final String title; + + + final bool showLoadingWhenEmpty; + final bool externalLoading; + final String loadingText; + + + final Widget? leading; + + const SourceSelectionSection({ + super.key, + required this.req, + required this.localSources, + required this.selectedSourceIdx, + required this.selectedCrossServerCard, + required this.onLocalSourceTap, + required this.onCrossServerSourceTap, + this.currentServerName, + this.excludedServerIdOverride, + this.localMediaInfoItem, + this.title = '多源聚合', + this.showLoadingWhenEmpty = false, + this.externalLoading = false, + this.loadingText = '正在查找可用版本…', + this.leading, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final searchReq = req; + final card = selectedCrossServerCard; + + final currentLocalSourceId = + card == null && selectedSourceIdx < localSources.length + ? localSources[selectedSourceIdx].Id + : null; + final selectedCrossServerKey = card != null + ? '${card.serverId}|${card.mediaSourceId}' + : null; + + final EmbyRawMediaSource? mediaSource = + card?.mediaSource ?? + (selectedSourceIdx < localSources.length + ? localSources[selectedSourceIdx] + : null); + final EmbyRawItem? mediaItem = card?.item ?? localMediaInfoItem; + final multiSourceVisibility = searchReq == null + ? _MultiSourceVisibility.hidden + : _multiSourceVisibility(ref, searchReq); + final mediaInfo = + mediaSource != null && + mediaItem != null && + MediaInfoSection.hasVisibleContent( + item: mediaItem, + selectedSource: mediaSource, + ) + ? MediaInfoSection( + item: mediaItem, + selectedSource: mediaSource, + embedded: true, + ) + : null; + final bool hasContent = + multiSourceVisibility != _MultiSourceVisibility.hidden || + mediaInfo != null; + if (!hasContent) return const SizedBox.shrink(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (leading != null) ...[leading!, const SizedBox(height: 16)], + if (searchReq != null) + MultiSourceSection( + req: searchReq, + embedded: true, + localSources: localSources, + currentLocalSourceId: currentLocalSourceId, + currentServerName: currentServerName, + excludedServerIdOverride: excludedServerIdOverride, + selectedCrossServerKey: selectedCrossServerKey, + title: title, + showLoadingWhenEmpty: showLoadingWhenEmpty, + externalLoading: externalLoading, + loadingText: loadingText, + onLocalSourceTap: onLocalSourceTap, + onSourceTap: (serverId, itemId, {mediaSourceId, card}) async { + if (card != null) onCrossServerSourceTap(card); + }, + ), + if (mediaInfo != null) mediaInfo, + ], + ); + } + + _MultiSourceVisibility _multiSourceVisibility( + WidgetRef ref, + CrossServerSearchReq searchReq, + ) { + final session = ref.watch(activeSessionProvider); + if (session == null) return _MultiSourceVisibility.hidden; + + final key = ( + activeServerId: session.serverId, + anchorType: searchReq.anchorType, + providerIdQuery: encodeProviderIdsForKey(searchReq.providerIds), + seriesProviderIdQuery: encodeProviderIdsForKey( + searchReq.seriesProviderIds ?? const {}, + ), + parentIndexNumber: searchReq.parentIndexNumber ?? -1, + indexNumber: searchReq.indexNumber ?? -1, + itemName: searchReq.itemName, + excludedServerId: excludedServerIdOverride ?? session.serverId, + ); + final asyncResult = ref.watch(crossServerSearchDataProvider(key)); + final remoteCards = asyncResult.maybeWhen( + data: (list) => list, + orElse: () => asyncResult.value ?? const [], + ); + if (localSources.isNotEmpty || remoteCards.isNotEmpty) { + return _MultiSourceVisibility.content; + } + + final loading = externalLoading || asyncResult.isLoading; + if (showLoadingWhenEmpty && loading) return _MultiSourceVisibility.loading; + + return _MultiSourceVisibility.hidden; + } +} + +enum _MultiSourceVisibility { hidden, loading, content } diff --git a/lib/features/detail/widgets/special_features_section.dart b/lib/features/detail/widgets/special_features_section.dart new file mode 100644 index 0000000..b8689ed --- /dev/null +++ b/lib/features/detail/widgets/special_features_section.dart @@ -0,0 +1,97 @@ +import 'dart:io' show Platform; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../providers/di_providers.dart'; +import '../../../providers/emby_headers_provider.dart'; +import '../../../providers/session_provider.dart'; +import '../../../shared/mappers/media_image_url.dart'; +import '../../../shared/theme/app_theme.dart'; +import '../../../shared/utils/media_nav.dart'; +import '../../../shared/widgets/hover_scrollable_row.dart'; +import '../../../shared/widgets/media_poster_card.dart'; +import 'section_container.dart'; + +class SpecialFeaturesSection extends ConsumerWidget { + final String itemId; + final bool embedded; + + + final Widget? leading; + + const SpecialFeaturesSection({ + super.key, + required this.itemId, + this.embedded = false, + this.leading, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final session = ref.watch(activeSessionProvider); + if (session == null) return const SizedBox.shrink(); + final imageHeaders = ref.watch(embyImageHeadersProvider).value; + final asyncItems = ref.watch(specialFeaturesDataProvider(itemId)); + + return asyncItems.when( + loading: () => const SizedBox.shrink(), + error: (error, stackTrace) => const SizedBox.shrink(), + data: (items) { + if (items.isEmpty) return const SizedBox.shrink(); + final sectionPad = SectionContainer.hPadOf(context); + final listPadding = EdgeInsetsDirectional.fromSTEB( + embedded ? 0.0 : sectionPad, + 0, + sectionPad, + 0, + ); + + final isAndroid = Platform.isAndroid; + return SectionContainer( + title: '花絮', + embedded: embedded, + leadingDivider: embedded, + leading: leading, + child: SizedBox( + height: isAndroid ? 210 : 300, + child: Theme( + data: AppTheme.dark(), + child: HoverScrollableRow( + builder: (_, controller) => ListView.builder( + controller: controller, + scrollDirection: Axis.horizontal, + padding: listPadding, + itemCount: items.length, + itemBuilder: (_, i) { + final item = items[i]; + final imageUrl = EmbyImageUrl.primary( + baseUrl: session.serverUrl, + token: session.token, + item: item, + maxHeight: 480, + ); + return Padding( + padding: EdgeInsetsDirectional.only( + end: i < items.length - 1 ? 12 : 0, + ), + child: MediaPosterCard( + item: item, + imageUrl: imageUrl, + width: isAndroid ? 105 : 160, + showHoverPlayIcon: false, + enableContextMenu: false, + imageHeaders: imageHeaders, + onTap: () => goMediaDetail(context, item, ref: ref), + ), + ); + }, + ), + ), + ), + ), + ); + }, + ); + } +} diff --git a/lib/features/detail/widgets/stream_selector_actions.dart b/lib/features/detail/widgets/stream_selector_actions.dart new file mode 100644 index 0000000..224929a --- /dev/null +++ b/lib/features/detail/widgets/stream_selector_actions.dart @@ -0,0 +1,164 @@ +import 'package:flutter/material.dart'; +import 'package:forui/forui.dart'; + +import '../../../core/contracts/library.dart'; +import '../../../shared/utils/format_utils.dart'; +import 'hero_action_buttons.dart'; + + +List buildSubtitleMenuButtons({ + required List subtitles, + required int? selectedSubtitleIdx, + required ValueChanged onSelectSubtitle, +}) { + return [ + FItem( + prefix: selectedSubtitleIdx == null + ? const Icon(Icons.check, size: 14) + : const SizedBox(width: 14), + title: const Text('关闭字幕'), + selected: selectedSubtitleIdx == null, + onPress: () => onSelectSubtitle(null), + ), + ...subtitles.asMap().entries.map( + (e) => FItem( + prefix: selectedSubtitleIdx == e.key + ? const Icon(Icons.check, size: 14) + : const SizedBox(width: 14), + title: Text( + e.value.DisplayTitle ?? e.value.Language ?? '字幕 ${e.key + 1}', + ), + selected: selectedSubtitleIdx == e.key, + onPress: () => onSelectSubtitle(e.key), + ), + ), + ]; +} + + +List buildAudioMenuButtons({ + required List audios, + required int selectedAudioIdx, + required ValueChanged onSelectAudio, +}) { + return audios + .asMap() + .entries + .map( + (e) => FItem( + prefix: selectedAudioIdx == e.key + ? const Icon(Icons.check, size: 14) + : const SizedBox(width: 14), + title: Text( + e.value.DisplayTitle ?? e.value.Language ?? '音轨 ${e.key + 1}', + ), + selected: selectedAudioIdx == e.key, + onPress: () => onSelectAudio(e.key), + ), + ) + .toList(); +} + + +List buildVersionMenuButtons({ + required List sources, + required int selectedSourceIdx, + required ValueChanged onSelectSource, +}) { + return sources.asMap().entries.map((e) { + final src = e.value; + final name = src.Name ?? '版本 ${e.key + 1}'; + final size = src.Size; + final sizeStr = size != null && size > 0 ? formatFileSize(size) : null; + return FItem( + prefix: selectedSourceIdx == e.key + ? const Icon(Icons.check, size: 14) + : const SizedBox(width: 14), + title: Text(sizeStr != null ? '$name $sizeStr' : name), + selected: selectedSourceIdx == e.key, + onPress: () => onSelectSource(e.key), + ); + }).toList(); +} + + +String subtitleSummaryLabel( + List subtitles, + int? selectedSubtitleIdx, +) => _streamSummaryLabel( + subtitles, + selectedSubtitleIdx, + empty: '关闭', + prefix: '字幕', +); + + +String audioSummaryLabel( + List audios, + int selectedAudioIdx, +) => _streamSummaryLabel(audios, selectedAudioIdx, empty: '默认', prefix: '音轨'); + +String _streamSummaryLabel( + List streams, + int? idx, { + required String empty, + required String prefix, +}) { + if (idx == null || idx < 0 || idx >= streams.length) return empty; + final s = streams[idx]; + return s.Language ?? s.DisplayTitle ?? '$prefix ${idx + 1}'; +} + + +String versionSummaryLabel( + List sources, + int selectedSourceIdx, +) { + if (selectedSourceIdx < 0 || selectedSourceIdx >= sources.length) { + return '版本'; + } + return sources[selectedSourceIdx].Name ?? '版本 ${selectedSourceIdx + 1}'; +} + + +List buildStreamSelectorPills({ + required List subtitles, + required List audios, + required int? selectedSubtitleIdx, + required int selectedAudioIdx, + required ValueChanged onSelectSubtitle, + required ValueChanged onSelectAudio, + required double height, +}) { + final actions = []; + + if (subtitles.length > 1) { + actions.add( + HeroPillDropdownButton( + height: height, + icon: Icons.subtitles_outlined, + menuChildren: buildSubtitleMenuButtons( + subtitles: subtitles, + selectedSubtitleIdx: selectedSubtitleIdx, + onSelectSubtitle: onSelectSubtitle, + ), + ), + ); + } + + if (audios.length > 1) { + actions.add( + HeroPillDropdownButton( + height: height, + icon: Icons.audiotrack_outlined, + menuChildren: buildAudioMenuButtons( + audios: audios, + selectedAudioIdx: selectedAudioIdx, + onSelectAudio: onSelectAudio, + ), + ), + ); + } + + return actions; +} diff --git a/lib/features/detail/widgets/tmdb_cast_section.dart b/lib/features/detail/widgets/tmdb_cast_section.dart new file mode 100644 index 0000000..e9df391 --- /dev/null +++ b/lib/features/detail/widgets/tmdb_cast_section.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../core/contracts/tmdb.dart'; +import '../../../providers/person_items_provider.dart'; +import '../../../shared/mappers/tmdb_image_url.dart'; +import '../../../shared/widgets/cast_scroller.dart'; +import '../../../shared/widgets/section_header.dart'; + + +class TmdbCastSection extends ConsumerWidget { + final List cast; + final String imageBaseUrl; + + + final Widget? leading; + + const TmdbCastSection({ + super.key, + required this.cast, + required this.imageBaseUrl, + this.leading, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + if (cast.isEmpty) return const SizedBox.shrink(); + final entries = cast.map((member) { + return CastEntry( + name: member.name, + subtitle: member.character, + imageUrl: TmdbImageUrl.profile(imageBaseUrl, member.profilePath), + onTap: () => _openPerson(context, ref, member.name), + ); + }).toList(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (leading != null) leading!, + const Padding( + padding: EdgeInsets.fromLTRB(0, 16, 0, 4), + child: SectionHeader(label: '演职人员'), + ), + CastScroller(entries: entries), + ], + ); + } + + Future _openPerson( + BuildContext context, + WidgetRef ref, + String name, + ) async { + final messenger = ScaffoldMessenger.maybeOf(context); + String? personId; + try { + personId = await ref.read(embyPersonIdByNameProvider(name).future); + } catch (_) { + personId = null; + } + if (!context.mounted) return; + + if (personId != null && personId.isNotEmpty) { + context.push('/person/$personId'); + } else { + messenger?.showSnackBar(SnackBar(content: Text('未在媒体库中找到「$name」的作品'))); + } + } +} diff --git a/lib/features/detail/widgets/tmdb_recommendation_section.dart b/lib/features/detail/widgets/tmdb_recommendation_section.dart new file mode 100644 index 0000000..a0f67ba --- /dev/null +++ b/lib/features/detail/widgets/tmdb_recommendation_section.dart @@ -0,0 +1,83 @@ +import 'dart:io' show Platform; + +import 'package:flutter/material.dart'; + +import '../../../core/contracts/tmdb.dart'; +import '../../../shared/utils/media_nav.dart'; +import '../../../shared/widgets/hover_scrollable_row.dart'; +import '../../../shared/widgets/section_header.dart'; +import '../../../shared/widgets/tmdb_poster_card.dart'; + + +class TmdbRecommendationSection extends StatelessWidget { + final List items; + final String imageBaseUrl; + final String fallbackMediaType; + + + final Widget? leading; + + const TmdbRecommendationSection({ + super.key, + required this.items, + required this.imageBaseUrl, + required this.fallbackMediaType, + this.leading, + }); + + @override + Widget build(BuildContext context) { + if (items.isEmpty) return const SizedBox.shrink(); + final list = items.length > 20 ? items.sublist(0, 20) : items; + final isAndroid = Platform.isAndroid; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (leading != null) leading!, + const Padding( + padding: EdgeInsets.fromLTRB(0, 16, 0, 4), + child: SectionHeader(label: '推荐'), + ), + SizedBox( + height: isAndroid ? 210 : 280, + child: HoverScrollableRow( + builder: (_, controller) => ListView.separated( + controller: controller, + scrollDirection: Axis.horizontal, + padding: EdgeInsets.zero, + itemCount: list.length, + separatorBuilder: (_, _) => const SizedBox(width: 12), + itemBuilder: (_, i) { + final item = list[i]; + final type = item.mediaType ?? fallbackMediaType; + final tappable = type == 'movie' || type == 'tv'; + return TmdbPosterCard( + title: item.title, + posterPath: item.posterPath, + voteAverage: item.voteAverage, + mediaTypeLabel: _typeLabel(item.mediaType), + imageBaseUrl: imageBaseUrl, + width: isAndroid ? 105 : 150, + onTap: tappable + ? () => goTmdbDetail( + context, + tmdbId: item.id, + mediaType: type, + seed: item, + ) + : null, + ); + }, + ), + ), + ), + ], + ); + } + + static String? _typeLabel(String? mediaType) => switch (mediaType) { + 'tv' => '剧集', + 'movie' => '电影', + _ => null, + }; +} diff --git a/lib/features/discover/discover_helpers.dart b/lib/features/discover/discover_helpers.dart new file mode 100644 index 0000000..bdbeb33 --- /dev/null +++ b/lib/features/discover/discover_helpers.dart @@ -0,0 +1,6 @@ + +String? mediaTypeLabel(String? mediaType) => switch (mediaType) { + 'tv' => '剧集', + 'movie' => '电影', + _ => null, +}; diff --git a/lib/features/discover/discover_page.dart b/lib/features/discover/discover_page.dart new file mode 100644 index 0000000..d125045 --- /dev/null +++ b/lib/features/discover/discover_page.dart @@ -0,0 +1,546 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_riverpod/misc.dart'; +import '../../core/contracts/tmdb.dart'; +import '../../providers/di_providers.dart'; +import '../../providers/home_page_mode_provider.dart'; +import '../../providers/shell_backdrop_provider.dart'; +import '../../providers/sm_account_provider.dart'; +import '../../providers/tmdb_settings_provider.dart'; +import '../../shared/constants/breakpoints.dart'; +import '../../shared/mappers/tmdb_image_url.dart'; +import '../../shared/utils/color_utils.dart'; +import '../../shared/utils/media_nav.dart'; +import '../../shared/widgets/app_error_state.dart'; +import '../../shared/widgets/empty_state.dart'; +import '../../shared/widgets/hover_scrollable_row.dart'; +import '../../shared/widgets/media_row_metrics.dart'; +import '../../shared/widgets/media_poster_card.dart'; +import '../../shared/widgets/scroll_fade_header.dart'; +import '../../shared/theme/app_theme.dart'; +import '../../shared/widgets/section_header.dart'; +import '../../shared/widgets/skeleton_media_row.dart'; +import '../../shared/widgets/tmdb_poster_card.dart'; +import '../home/widgets/banner_carousel.dart' show HomeBannerSkeleton; +import '../home/widgets/server_dropdown_button.dart'; +import 'discover_helpers.dart'; +import 'widgets/tmdb_banner_carousel.dart'; +import 'widgets/trakt_continue_watching_row.dart'; + + +String _smAccountHint(SmAuthStatus? status) => switch (status) { + SmAuthStatus.expired => '登录已过期,请重新登录后再浏览推荐内容。', + SmAuthStatus.degraded => '账号凭据不完整,请重新登录后再浏览推荐内容。', + _ => '需要登录 sm-server 账号后才能浏览推荐内容。', +}; + + +class _DiscoverRowSpec { + final String title; + final String mediaType; + final String sortBy; + final String? withGenres; + final String? voteCountGte; + + const _DiscoverRowSpec({ + required this.title, + required this.mediaType, + this.sortBy = 'popularity.desc', + this.withGenres, + this.voteCountGte, + }); + + TmdbDiscoverKey get key => ( + mediaType: mediaType, + sortBy: sortBy, + withGenres: withGenres, + voteCountGte: voteCountGte, + ); +} + +const _discoverRows = <_DiscoverRowSpec>[ + _DiscoverRowSpec( + title: '高分剧集', + mediaType: 'tv', + sortBy: 'vote_average.desc', + voteCountGte: '200', + ), + _DiscoverRowSpec(title: '科幻电影', mediaType: 'movie', withGenres: '878'), + _DiscoverRowSpec(title: '动画', mediaType: 'movie', withGenres: '16'), + _DiscoverRowSpec(title: '真人秀', mediaType: 'tv', withGenres: '10764'), + _DiscoverRowSpec(title: '纪录片', mediaType: 'movie', withGenres: '99'), +]; + +class DiscoverPage extends ConsumerStatefulWidget { + final bool embedInHome; + + const DiscoverPage({super.key, this.embedInHome = false}); + + @override + ConsumerState createState() => _DiscoverPageState(); +} + +class _DiscoverPageState extends ConsumerState { + TmdbRecommendation? _lastBannerItem; + String? _lastBaseUrl; + + final ScrollController _scrollController = ScrollController(); + final ValueNotifier _scrollProgress = ValueNotifier(0.0); + + @override + void initState() { + super.initState(); + _scrollController.addListener(_handleScroll); + } + + @override + void dispose() { + _scrollController.removeListener(_handleScroll); + _scrollController.dispose(); + _scrollProgress.dispose(); + super.dispose(); + } + + void _handleScroll() { + if (!_scrollController.hasClients) return; + const threshold = 120.0; + final progress = (_scrollController.offset / threshold).clamp(0.0, 1.0); + _scrollProgress.value = progress; + } + + + void _onBannerActiveItem(TmdbRecommendation item, String baseUrl) { + _lastBannerItem = item; + _lastBaseUrl = baseUrl; + _writeBackdropIfActive(); + } + + void _writeBackdropIfActive() { + if (!_isActiveRoute()) return; + final item = _lastBannerItem; + final baseUrl = _lastBaseUrl; + if (item == null || baseUrl == null) return; + final url = TmdbImageUrl.backdrop(baseUrl, item.backdropPath); + if (url == null) return; + final state = ShellBackdropState( + primaryImageUrl: url, + fallbackImageUrls: [url], + accentColor: _accentColorFor(item.id.toString()), + ); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + if (!_isActiveRoute()) return; + ref.read(shellBackdropProvider.notifier).show(state); + }); + } + + bool _isActiveRoute() { + final location = ref.read(shellLocationProvider); + if (location == '/discover') return true; + return widget.embedInHome && + location == '/' && + ref.read(homePageModeProvider) == HomePageMode.recommend; + } + + + void _onNoBanner() { + _lastBannerItem = null; + _lastBaseUrl = null; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + if (!_isActiveRoute()) return; + if (ref.read(shellBackdropProvider) == null) return; + ref.read(shellBackdropProvider.notifier).clear(); + }); + } + + Color _accentColorFor(String seed) => accentColorForSeed( + seed, + isDark: Theme.of(context).brightness == Brightness.dark, + ); + + Future _refresh() async { + ref.invalidate(tmdbTrendingDayProvider); + ref.invalidate(tmdbTrendingWeekProvider); + ref.invalidate(tmdbPopularMoviesProvider); + ref.invalidate(tmdbPopularTvProvider); + ref.invalidate(tmdbAiringTodayTvProvider); + ref.invalidate(tmdbTopRatedMoviesProvider); + for (final spec in _discoverRows) { + ref.invalidate(tmdbDiscoverProvider(spec.key)); + } + ref.invalidate(traktContinueWatchingProvider); + await Future.delayed(const Duration(milliseconds: 250)); + } + + @override + Widget build(BuildContext context) { + final settingsAsync = ref.watch(tmdbSettingsProvider); + + ref.listen(shellLocationProvider, (_, loc) { + if (loc != '/discover' && !(widget.embedInHome && loc == '/')) return; + if (_lastBannerItem != null) { + _writeBackdropIfActive(); + } else { + _onNoBanner(); + } + }); + + return Scaffold( + backgroundColor: Colors.transparent, + extendBodyBehindAppBar: true, + body: settingsAsync.when( + loading: () { + _onNoBanner(); + return const _DiscoverSkeleton(); + }, + error: (_, _) { + _onNoBanner(); + return AppErrorState( + title: 'TMDB 设置加载失败', + onRetry: () => ref.invalidate(tmdbSettingsProvider), + ); + }, + data: (settings) { + final accountAsync = ref.watch(smAccountProvider); + final account = accountAsync.value; + final needsAccount = + settings.accessMode == TmdbAccessMode.smAccount && + settings.enabled; + if (needsAccount && account == null && accountAsync.isLoading) { + _onNoBanner(); + return const _DiscoverSkeleton(); + } + final canRequest = settings.accessMode == TmdbAccessMode.smAccount + ? settings.enabled && (account?.hasActiveSession ?? false) + : settings.canRequest; + if (!canRequest) { + _onNoBanner(); + final message = settings.accessMode == TmdbAccessMode.smAccount + ? _smAccountHint(account?.status) + : '需要在本地配置 TMDB API Key 或读取令牌后才能浏览推荐内容。'; + return Center( + child: EmptyState( + icon: Icons.explore_off_outlined, + title: 'TMDB 未配置', + message: message, + ), + ); + } + + final sections = [ + _DiscoverSection( + title: '今日热门', + provider: tmdbTrendingDayProvider, + imageBaseUrl: settings.effectiveImageBaseUrl, + ), + _DiscoverSection( + title: '本周热门', + provider: tmdbTrendingWeekProvider, + imageBaseUrl: settings.effectiveImageBaseUrl, + ), + _DiscoverSection( + title: '热门电影', + provider: tmdbPopularMoviesProvider, + imageBaseUrl: settings.effectiveImageBaseUrl, + fallbackMediaType: 'movie', + ), + _DiscoverSection( + title: '热门剧集', + provider: tmdbPopularTvProvider, + imageBaseUrl: settings.effectiveImageBaseUrl, + fallbackMediaType: 'tv', + ), + _DiscoverSection( + title: '高分电影', + provider: tmdbTopRatedMoviesProvider, + imageBaseUrl: settings.effectiveImageBaseUrl, + fallbackMediaType: 'movie', + ), + for (final spec in _discoverRows) + _DiscoverSection( + title: spec.title, + provider: tmdbDiscoverProvider(spec.key), + imageBaseUrl: settings.effectiveImageBaseUrl, + fallbackMediaType: spec.mediaType, + ), + ]; + + final tokens = context.appTokens; + final isDesktop = Platform.isMacOS || Platform.isWindows; + final isCompact = Breakpoints.isCompact( + MediaQuery.sizeOf(context).width, + ); + final topPad = isDesktop + ? tokens.titleBarHeight + : math.max( + MediaQuery.paddingOf(context).top, + tokens.titleBarHeight, + ); + final listTopPad = isCompact ? 0.0 : topPad + 30; + final listBottomPad = widget.embedInHome ? 116.0 : 32.0; + + return Stack( + children: [ + RefreshIndicator( + onRefresh: _refresh, + child: ListView( + controller: _scrollController, + physics: const AlwaysScrollableScrollPhysics( + parent: BouncingScrollPhysics(), + ), + padding: EdgeInsets.only( + top: listTopPad, + bottom: listBottomPad, + ), + children: [ + Padding( + padding: EdgeInsets.only( + top: isCompact ? 0 : 12, + bottom: 4, + ), + child: _DiscoverBanner( + onActiveItem: _onBannerActiveItem, + onNoBanner: _onNoBanner, + ), + ), + const TraktContinueWatchingRow(), + ...sections, + const SizedBox(height: 24), + ], + ), + ), + Positioned( + top: 0, + left: 0, + right: 0, + child: ScrollFadeHeader( + scrollProgress: _scrollProgress, + actions: [ + SizedBox( + width: 38, + height: 38, + child: IconButton( + icon: const Icon(Icons.refresh, size: 18), + tooltip: '刷新', + padding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + onPressed: () => unawaited(_refresh()), + ), + ), + ], + ), + ), + if (widget.embedInHome) + Positioned( + top: MediaQuery.paddingOf(context).top + 8, + left: 16, + child: const ServerDropdownButton(), + ), + ], + ); + }, + ), + ); + } +} + +class _DiscoverSkeleton extends StatelessWidget { + const _DiscoverSkeleton(); + + @override + Widget build(BuildContext context) { + final tokens = context.appTokens; + final isDesktop = Platform.isMacOS || Platform.isWindows; + final topPad = isDesktop + ? tokens.titleBarHeight + : math.max(MediaQuery.paddingOf(context).top, tokens.titleBarHeight); + final isCompact = Breakpoints.isCompact(MediaQuery.sizeOf(context).width); + final listTopPad = isCompact ? 0.0 : topPad + 30; + + return ListView( + physics: const AlwaysScrollableScrollPhysics( + parent: BouncingScrollPhysics(), + ), + padding: EdgeInsets.only(top: listTopPad, bottom: 32), + children: [ + Padding( + padding: EdgeInsets.only(top: isCompact ? 0 : 12, bottom: 4), + child: const HomeBannerSkeleton(), + ), + const SkeletonMediaRow( + title: '继续观看', + cardVariant: MediaCardVariant.landscape, + titleColor: Colors.white, + ), + const SkeletonMediaRow(title: '今日热门', titleColor: Colors.white), + const SkeletonMediaRow(title: '本周热门', titleColor: Colors.white), + const SkeletonMediaRow(title: '热门电影', titleColor: Colors.white), + const SkeletonMediaRow(title: '热门剧集', titleColor: Colors.white), + ], + ); + } +} + + +class _DiscoverBanner extends ConsumerWidget { + + final void Function(TmdbRecommendation item, String baseUrl) onActiveItem; + + + final VoidCallback onNoBanner; + + const _DiscoverBanner({required this.onActiveItem, required this.onNoBanner}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final settings = ref.watch(tmdbSettingsProvider).value; + final asyncData = ref.watch(tmdbAiringTodayTvProvider); + + return asyncData.when( + loading: () { + onNoBanner(); + return const HomeBannerSkeleton(); + }, + error: (_, _) { + onNoBanner(); + return const SizedBox.shrink(); + }, + data: (res) { + if (res == null || settings == null) { + onNoBanner(); + return const SizedBox.shrink(); + } + final items = res.results + .where((e) => e.backdropPath?.isNotEmpty ?? false) + .map((e) => e.mediaType == 'tv' ? e : e.copyWith(mediaType: 'tv')) + .take(6) + .toList(); + if (items.isEmpty) { + onNoBanner(); + return const SizedBox.shrink(); + } + + final baseUrl = settings.effectiveImageBaseUrl; + return TmdbBannerCarousel( + items: items, + imageBaseUrl: baseUrl, + onActiveItemChanged: (item) => onActiveItem(item, baseUrl), + onItemTap: (item) => goTmdbDetail( + context, + tmdbId: item.id, + mediaType: 'tv', + seed: item, + ), + ); + }, + ); + } +} + +class _DiscoverSection extends ConsumerWidget { + final String title; + final ProviderListenable> provider; + final String imageBaseUrl; + + + final String? fallbackMediaType; + + const _DiscoverSection({ + required this.title, + required this.provider, + required this.imageBaseUrl, + this.fallbackMediaType, + }); + + Widget _sectionTitle() => Padding( + padding: const EdgeInsets.fromLTRB(24, 16, 24, 4), + child: SectionHeader(label: title, labelColor: Colors.white), + ); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final asyncData = ref.watch(provider); + + return asyncData.when( + loading: () => SkeletonMediaRow(title: title, titleColor: Colors.white), + error: (_, _) => const SizedBox.shrink(), + data: (res) { + if (res == null) return const SizedBox.shrink(); + + if (res.results.isEmpty) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sectionTitle(), + const EmptyState( + icon: Icons.movie_filter_outlined, + title: '暂无内容', + compact: true, + ), + ], + ); + } + + final items = res.results.length > 20 + ? res.results.sublist(0, 20) + : res.results; + + final compact = Breakpoints.isCompact(MediaQuery.sizeOf(context).width); + final cardWidth = MediaRowMetrics.cardWidth( + MediaCardVariant.poster, + compact: compact, + ); + final rowHeight = cardWidth / 0.66 + 46.0; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sectionTitle(), + SizedBox( + height: rowHeight, + child: HoverScrollableRow( + builder: (_, controller) => ListView.separated( + controller: controller, + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 24), + itemCount: items.length, + separatorBuilder: (_, _) => const SizedBox(width: 14), + itemBuilder: (_, i) { + final item = items[i]; + final type = item.mediaType ?? fallbackMediaType; + final tappable = type == 'movie' || type == 'tv'; + return TmdbPosterCard( + key: ValueKey('$type:${item.id}'), + title: item.title, + posterPath: item.posterPath, + voteAverage: item.voteAverage, + mediaTypeLabel: _mediaTypeLabel(item.mediaType), + imageBaseUrl: imageBaseUrl, + width: cardWidth, + textColor: Colors.white, + onTap: tappable + ? () => goTmdbDetail( + context, + tmdbId: item.id, + mediaType: type!, + seed: item, + ) + : null, + ); + }, + ), + ), + ), + ], + ); + }, + ); + } + + static String? _mediaTypeLabel(String? type) => mediaTypeLabel(type); +} diff --git a/lib/features/discover/trakt_continue_watching_item.dart b/lib/features/discover/trakt_continue_watching_item.dart new file mode 100644 index 0000000..bb9212c --- /dev/null +++ b/lib/features/discover/trakt_continue_watching_item.dart @@ -0,0 +1,102 @@ +import '../../core/contracts/trakt/trakt_playback_entry.dart'; + + +class TraktContinueWatchingItem { + + final int tmdbId; + + + final String mediaType; + + final String title; + final String? subtitle; + + + final double progress; + + final String? backdropUrl; + + const TraktContinueWatchingItem({ + required this.tmdbId, + required this.mediaType, + required this.title, + required this.subtitle, + required this.progress, + required this.backdropUrl, + }); + + + static TraktContinueWatchingItem? fromEntry( + TraktPlaybackEntry e, { + String? backdropUrl, + }) { + final progress = (e.progress / 100.0).clamp(0.0, 1.0).toDouble(); + + if (e.type == 'movie') { + final tmdb = e.movieIds?.tmdb; + if (tmdb == null || tmdb <= 0) return null; + return TraktContinueWatchingItem( + tmdbId: tmdb, + mediaType: 'movie', + title: e.movieTitle ?? '', + subtitle: e.movieYear?.toString(), + progress: progress, + backdropUrl: backdropUrl, + ); + } + + final tmdb = e.showIds?.tmdb; + if (tmdb == null || tmdb <= 0) return null; + return TraktContinueWatchingItem( + tmdbId: tmdb, + mediaType: 'tv', + title: e.showTitle ?? '', + subtitle: _episodeSubtitle( + e.episodeSeason, + e.episodeNumber, + e.episodeTitle, + ), + progress: progress, + backdropUrl: backdropUrl, + ); + } + + TraktContinueWatchingItem withBackdrop(String? url) { + return TraktContinueWatchingItem( + tmdbId: tmdbId, + mediaType: mediaType, + title: title, + subtitle: subtitle, + progress: progress, + backdropUrl: url, + ); + } + + + TraktContinueWatchingItem withTitle(String title) { + return TraktContinueWatchingItem( + tmdbId: tmdbId, + mediaType: mediaType, + title: title, + subtitle: subtitle, + progress: progress, + backdropUrl: backdropUrl, + ); + } + + static String? _episodeSubtitle(int? season, int? number, String? title) { + final String base; + if (season != null && season > 0 && number != null) { + base = '第$season季 第$number集'; + } else if (number != null) { + base = '第$number集'; + } else { + base = ''; + } + final t = title?.trim(); + if (t != null && t.isNotEmpty) { + return base.isEmpty ? t : '$base · $t'; + } + return base.isEmpty ? null : base; + } +} diff --git a/lib/features/discover/widgets/tmdb_banner_carousel.dart b/lib/features/discover/widgets/tmdb_banner_carousel.dart new file mode 100644 index 0000000..da452c5 --- /dev/null +++ b/lib/features/discover/widgets/tmdb_banner_carousel.dart @@ -0,0 +1,99 @@ +import 'dart:async'; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; + +import '../../../core/contracts/tmdb.dart'; +import '../../../shared/mappers/tmdb_image_url.dart'; +import '../../../shared/widgets/banner_carousel_mixin.dart'; +import '../../../shared/widgets/banner_carousel_scaffold.dart'; +import '../discover_helpers.dart'; + + +class TmdbBannerCarousel extends StatefulWidget { + final List items; + final String imageBaseUrl; + final ValueChanged onItemTap; + final ValueChanged? onActiveItemChanged; + + const TmdbBannerCarousel({ + super.key, + required this.items, + required this.imageBaseUrl, + required this.onItemTap, + this.onActiveItemChanged, + }); + + @override + State createState() => _TmdbBannerCarouselState(); +} + +class _TmdbBannerCarouselState extends State + with WidgetsBindingObserver, BannerCarouselMixin { + @override + int get itemCount => widget.items.length; + + @override + bool get isActive => true; + + @override + void emitActiveItem() { + if (widget.items.isEmpty) return; + widget.onActiveItemChanged?.call(widget.items[currentIndex]); + } + + @override + void precacheAdjacentSlides() { + if (!mounted || widget.items.length <= 1) return; + final next = (currentIndex + 1) % widget.items.length; + final nextUrl = TmdbImageUrl.backdrop( + widget.imageBaseUrl, + widget.items[next].backdropPath, + ); + if (nextUrl == null) return; + final cacheWidth = bannerCacheWidth(); + unawaited( + precacheImage( + ResizeImage.resizeIfNeeded( + cacheWidth, + null, + CachedNetworkImageProvider(nextUrl), + ), + context, + onError: (_, _) {}, + ), + ); + } + + @override + void didUpdateWidget(covariant TmdbBannerCarousel oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.items != widget.items) { + resetCarouselItems(); + } + } + + BannerSlideData _slideDataFor(TmdbRecommendation item) { + final typeLabel = _mediaTypeLabel(item.mediaType); + return BannerSlideData( + imageUrl: TmdbImageUrl.backdrop(widget.imageBaseUrl, item.backdropPath), + title: item.title, + overview: item.overview, + rating: (item.voteAverage != null && item.voteAverage! > 0) + ? item.voteAverage!.toStringAsFixed(1) + : null, + primaryMetaLabels: [if (typeLabel != null) typeLabel], + ); + } + + @override + Widget build(BuildContext context) { + return BannerCarouselScaffold( + controller: this, + slides: [for (final item in widget.items) _slideDataFor(item)], + onSlideTap: (index) => widget.onItemTap(widget.items[index]), + ); + } + + static String? _mediaTypeLabel(String? type) => mediaTypeLabel(type); +} diff --git a/lib/features/discover/widgets/trakt_continue_watching_row.dart b/lib/features/discover/widgets/trakt_continue_watching_row.dart new file mode 100644 index 0000000..d8b8650 --- /dev/null +++ b/lib/features/discover/widgets/trakt_continue_watching_row.dart @@ -0,0 +1,142 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../providers/di_providers.dart'; +import '../../../shared/utils/media_nav.dart'; +import '../../../shared/widgets/card_progress_bar.dart'; +import '../../../shared/widgets/hover_scrollable_row.dart'; +import '../../../shared/widgets/media_row_metrics.dart'; +import '../../../shared/widgets/media_poster_card.dart'; +import '../../../shared/widgets/section_header.dart'; +import '../../../shared/widgets/smart_image.dart'; +import '../../../shared/widgets/skeleton_media_row.dart'; +import '../trakt_continue_watching_item.dart'; + + +class TraktContinueWatchingRow extends ConsumerWidget { + const TraktContinueWatchingRow({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final asyncItems = ref.watch(traktContinueWatchingProvider); + final compact = MediaRowMetrics.isCompact(context); + final rowHeight = MediaRowMetrics.rowHeight( + MediaCardVariant.landscape, + compact: compact, + ); + final cardWidth = MediaRowMetrics.cardWidth( + MediaCardVariant.landscape, + compact: compact, + ); + + return asyncItems.when( + loading: () => const SkeletonMediaRow( + title: '继续观看', + cardVariant: MediaCardVariant.landscape, + titleColor: Colors.white, + ), + error: (_, _) => const SizedBox.shrink(), + data: (items) { + if (items.isEmpty) return const SizedBox.shrink(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Padding( + padding: EdgeInsets.fromLTRB(24, 16, 24, 4), + child: SectionHeader(label: '继续观看', labelColor: Colors.white), + ), + SizedBox( + height: rowHeight, + child: HoverScrollableRow( + builder: (_, controller) => ListView.separated( + controller: controller, + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 24), + itemCount: items.length, + separatorBuilder: (_, _) => const SizedBox(width: 14), + itemBuilder: (_, i) { + final item = items[i]; + return _TraktContinueCard( + key: ValueKey('${item.mediaType}:${item.tmdbId}'), + item: item, + width: cardWidth, + onTap: () => goTmdbDetail( + context, + tmdbId: item.tmdbId, + mediaType: item.mediaType, + ), + ); + }, + ), + ), + ), + ], + ); + }, + ); + } +} + +class _TraktContinueCard extends StatelessWidget { + final TraktContinueWatchingItem item; + final double width; + final VoidCallback onTap; + + const _TraktContinueCard({ + super.key, + required this.item, + required this.width, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return SizedBox( + width: width, + child: GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Flexible( + child: Stack( + children: [ + SmartImage( + url: item.backdropUrl, + aspectRatio: 16 / 9, + borderRadius: 8, + fallbackIcon: Icons.movie_outlined, + ), + if (item.progress > 0) + CardProgressBar(progress: item.progress), + ], + ), + ), + const SizedBox(height: 10), + Text( + item.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + color: Colors.white, + fontWeight: FontWeight.w500, + ), + ), + if (item.subtitle != null) + Text( + item.subtitle!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: Colors.white.withValues(alpha: 0.6), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/favorites/aggregated_favorites_page.dart b/lib/features/favorites/aggregated_favorites_page.dart new file mode 100644 index 0000000..bc9a328 --- /dev/null +++ b/lib/features/favorites/aggregated_favorites_page.dart @@ -0,0 +1,370 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:forui/forui.dart'; + +import '../../core/contracts/library.dart'; +import '../../core/domain/ports/emby_gateway.dart'; +import '../../providers/di_providers.dart'; +import '../../providers/session_provider.dart'; +import '../../shared/mappers/media_image_url.dart'; +import '../../shared/utils/format_utils.dart'; +import '../../shared/utils/media_nav.dart'; +import '../../shared/widgets/app_sliver_header.dart'; +import '../../shared/widgets/app_loading_ring.dart'; +import '../../shared/widgets/empty_state.dart'; +import '../aggregated/aggregated_grouping.dart'; +import '../aggregated/widgets/aggregated_grouped_grid.dart'; +import '../aggregated/widgets/aggregated_media_card.dart'; +import '../aggregated/widgets/server_filter_bar.dart'; + +const _favoriteTypeFilters = <_FavoriteTypeFilter>[ + _FavoriteTypeFilter(includeItemTypes: 'Movie,Series,Episode', label: '全部'), + _FavoriteTypeFilter(includeItemTypes: 'Movie', label: '电影'), + _FavoriteTypeFilter(includeItemTypes: 'Series', label: '剧集'), + _FavoriteTypeFilter(includeItemTypes: 'Episode', label: '单集'), +]; + +class AggregatedFavoritesPage extends ConsumerStatefulWidget { + + final bool embedded; + + const AggregatedFavoritesPage({super.key, this.embedded = false}); + + @override + ConsumerState createState() => + _AggregatedFavoritesPageState(); +} + +class _AggregatedFavoritesPageState + extends ConsumerState { + StreamSubscription? _subscription; + + _FavoriteTypeFilter _selectedFilter = _favoriteTypeFilters.first; + List _serverResults = []; + bool _loading = false; + bool _switching = false; + final Map _favoriteLoadingMap = {}; + + final Set _collapsed = {}; + String? _serverFilter; + + @override + void initState() { + super.initState(); + _load(); + } + + @override + void dispose() { + _subscription?.cancel(); + super.dispose(); + } + + Future _load() async { + _subscription?.cancel(); + _subscription = null; + + setState(() { + _loading = true; + _serverResults = []; + }); + + try { + final uc = await ref.read(aggregatedFavoritesUseCaseProvider.future); + final stream = uc.executeStream( + includeItemTypes: _selectedFilter.includeItemTypes, + ); + + _subscription = stream.listen( + (result) { + if (mounted) { + setState(() => _serverResults = [..._serverResults, result]); + } + }, + onDone: () { + if (mounted) setState(() => _loading = false); + }, + onError: (_) { + if (mounted) setState(() => _loading = false); + }, + ); + } catch (_) { + if (mounted) setState(() => _loading = false); + } + } + + Future _onItemTap( + BuildContext context, + GlobalSearchServerResult serverResult, + EmbyRawItem item, + ) async { + if (_switching) return; + setState(() => _switching = true); + try { + await goMediaDetailOnServer( + context, + ref, + serverId: serverResult.serverId, + item: item, + ); + } finally { + if (mounted) setState(() => _switching = false); + } + } + + Future _removeFavorite( + GlobalSearchServerResult serverResult, + EmbyRawItem item, + ) async { + if (_favoriteLoadingMap[item.Id] == true) return; + setState(() => _favoriteLoadingMap[item.Id] = true); + try { + final gateway = await ref.read(embyGatewayProvider.future); + final ctx = AuthedRequestContext( + baseUrl: serverResult.serverUrl, + token: serverResult.token, + userId: serverResult.userId, + ); + await gateway.setFavoriteStatus( + ctx: ctx, + itemId: item.Id, + isFavorite: false, + ); + if (mounted) { + setState(() { + _serverResults = _serverResults + .map((sr) { + if (sr.serverId != serverResult.serverId) return sr; + final filtered = sr.items + .where((i) => i.Id != item.Id) + .toList(); + return GlobalSearchServerResult( + serverId: sr.serverId, + serverName: sr.serverName, + serverUrl: sr.serverUrl, + userId: sr.userId, + token: sr.token, + items: filtered, + ); + }) + .where((sr) => sr.items.isNotEmpty) + .toList(); + }); + } + } finally { + if (mounted) setState(() => _favoriteLoadingMap[item.Id] = false); + } + } + + List _cardMenuItems( + GlobalSearchServerResult sr, + EmbyRawItem item, + ) => [ + FItem( + prefix: const Icon(Icons.favorite, size: 16), + enabled: !(_favoriteLoadingMap[item.Id] ?? false), + title: const Text('取消收藏'), + onPress: () => _removeFavorite(sr, item), + ), + ]; + + void _changeType(_FavoriteTypeFilter filter) { + if (_selectedFilter == filter) return; + setState(() => _selectedFilter = filter); + _load(); + } + + void _toggle(String key) { + setState(() { + if (!_collapsed.remove(key)) _collapsed.add(key); + }); + } + + @override + Widget build(BuildContext context) { + ref.listen(sessionProvider, (previous, next) { + final prevIds = + previous?.value?.sessions.map((s) => s.serverId).toSet() ?? + {}; + final nextIds = + next.value?.sessions.map((s) => s.serverId).toSet() ?? {}; + if (prevIds.length == nextIds.length && prevIds.containsAll(nextIds)) { + return; + } + if (_serverFilter != null && !nextIds.contains(_serverFilter)) { + setState(() => _serverFilter = null); + } + _load(); + }); + + if (widget.embedded) { + return Stack( + fit: StackFit.expand, + children: [ + CustomScrollView( + slivers: [ + SliverOverlapInjector( + handle: NestedScrollView.sliverOverlapAbsorberHandleFor( + context, + ), + ), + SliverToBoxAdapter(child: _buildTypeFilterRow()), + ..._buildContentSlivers(context), + ], + ), + if (_switching) _switchingOverlay(), + ], + ); + } + + return Stack( + fit: StackFit.expand, + children: [ + Scaffold( + extendBodyBehindAppBar: true, + body: CustomScrollView( + slivers: [ + AppSliverHeader( + title: '我的收藏', + actions: [ + IconButton( + onPressed: _loading ? null : _load, + icon: _loading + ? const AppLoadingRing(size: 16) + : const Icon(Icons.refresh, size: 18), + tooltip: '刷新', + ), + ], + ), + SliverToBoxAdapter(child: _buildTypeFilterRow()), + ..._buildContentSlivers(context), + ], + ), + ), + if (_switching) _switchingOverlay(), + ], + ); + } + + Widget _switchingOverlay() => const Positioned.fill( + child: ColoredBox( + color: Color(0x44000000), + child: Center(child: AppLoadingRing()), + ), + ); + + Widget _buildTypeFilterRow() => Padding( + padding: const EdgeInsets.fromLTRB(24, 8, 24, 8), + child: Align( + alignment: Alignment.centerLeft, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: _favoriteTypeFilters.map(_buildTypeButton).toList(), + ), + ), + ), + ); + + Widget _buildTypeButton(_FavoriteTypeFilter filter) { + final selected = _selectedFilter == filter; + final child = Text(filter.label); + + return Padding( + padding: const EdgeInsets.only(right: 8), + child: FButton( + variant: selected ? FButtonVariant.primary : FButtonVariant.ghost, + onPress: () => _changeType(filter), + child: child, + ), + ); + } + + List _buildContentSlivers(BuildContext context) { + if (_serverResults.isEmpty && !_loading) { + return const [ + SliverFillRemaining( + hasScrollBody: false, + child: EmptyState(icon: Icons.favorite_border, title: '暂无收藏内容'), + ), + ]; + } + if (_serverResults.isEmpty && _loading) { + return const [ + SliverFillRemaining( + hasScrollBody: false, + child: Center(child: AppLoadingRing()), + ), + ]; + } + + final now = DateTime.now(); + final entries = flatten( + _serverResults, + serverFilter: _serverFilter, + deduplicateAcrossServers: true, + ); + final groups = groupEntries(entries, GroupAxis.mediaType, now); + final servers = _serverResults + .map((sr) => (id: sr.serverId, name: sr.serverName)) + .toList(growable: false); + + return [ + SliverToBoxAdapter( + child: ServerFilterBar( + servers: servers, + selected: _serverFilter, + onSelect: (v) => setState(() => _serverFilter = v), + ), + ), + ...buildAggregatedGridSlivers( + groups: groups, + collapsed: _collapsed, + onToggle: _toggle, + cardBuilder: (entry) => _card(context, entry), + ), + ]; + } + + Widget _card(BuildContext context, AggregatedEntry entry) { + final sr = entry.source; + final item = entry.item; + final episode = formatEpisodeNumber(item.IndexNumber); + final subtitle = item.Type == 'Episode' && episode != null + ? '${item.SeriesName ?? ''} $episode'.trim() + : (item.ProductionYear != null ? '${item.ProductionYear}' : null); + + return AggregatedMediaCard( + item: item, + imageUrl: EmbyImageUrl.landscapeCard( + baseUrl: sr.serverUrl, + token: sr.token, + item: item, + maxWidth: 400, + ), + imageHeaders: buildEmbyImageHeaders( + ref, + token: sr.token, + userId: sr.userId, + ), + title: item.SeriesName ?? item.Name, + subtitle: subtitle, + serverName: sr.serverName, + showProgress: false, + onTap: () => _onItemTap(context, sr, item), + contextMenuItems: _cardMenuItems(sr, item), + ); + } +} + +class _FavoriteTypeFilter { + final String includeItemTypes; + final String label; + + const _FavoriteTypeFilter({ + required this.includeItemTypes, + required this.label, + }); +} diff --git a/lib/features/history/history_page.dart b/lib/features/history/history_page.dart new file mode 100644 index 0000000..06a5e27 --- /dev/null +++ b/lib/features/history/history_page.dart @@ -0,0 +1,332 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/contracts/library.dart'; +import '../../providers/di_providers.dart'; +import '../../providers/session_provider.dart'; +import '../../providers/tmdb_settings_provider.dart'; +import '../../shared/mappers/tmdb_image_url.dart'; +import '../../shared/utils/format_utils.dart'; +import '../../shared/utils/media_nav.dart'; +import '../../shared/mappers/media_image_url.dart'; +import '../../shared/utils/time_bucket.dart'; +import '../../shared/widgets/app_loading_ring.dart'; +import '../../shared/utils/tmdb_key_utils.dart'; +import '../../shared/widgets/app_sliver_header.dart'; +import '../../shared/widgets/empty_state.dart'; +import '../aggregated/aggregated_grouping.dart'; +import '../aggregated/widgets/aggregated_grouped_grid.dart'; +import '../aggregated/widgets/aggregated_media_card.dart'; +import '../aggregated/widgets/server_filter_bar.dart'; +import 'widgets/history_source_picker.dart'; + +class HistoryPage extends ConsumerStatefulWidget { + + final bool embedded; + + const HistoryPage({super.key, this.embedded = false}); + + @override + ConsumerState createState() => _HistoryPageState(); +} + +class _HistoryPageState extends ConsumerState { + StreamSubscription? _subscription; + + List _serverResults = []; + bool _loading = false; + bool _switching = false; + + final Set _collapsed = {}; + String? _serverFilter; + + @override + void initState() { + super.initState(); + _load(); + } + + @override + void dispose() { + _subscription?.cancel(); + super.dispose(); + } + + Future _load() async { + _subscription?.cancel(); + _subscription = null; + + setState(() { + _loading = true; + _serverResults = []; + }); + + try { + final uc = await ref.read(aggregatedResumeUseCaseProvider.future); + final stream = uc.executeStream(); + + _subscription = stream.listen( + (result) { + if (mounted) { + setState(() => _serverResults = [..._serverResults, result]); + } + }, + onDone: () { + if (mounted) setState(() => _loading = false); + }, + onError: (_) { + if (mounted) setState(() => _loading = false); + }, + ); + } catch (_) { + if (mounted) setState(() => _loading = false); + } + } + + Future _onItemTap( + BuildContext context, + GlobalSearchServerResult serverResult, + EmbyRawItem item, + ) async { + if (_switching) return; + setState(() => _switching = true); + try { + await goMediaDetailOnServer( + context, + ref, + serverId: serverResult.serverId, + item: item, + ); + } finally { + if (mounted) setState(() => _switching = false); + } + } + + Future _onServerSourcesTap( + BuildContext context, + AggregatedEntry entry, + DateTime now, + ) async { + if (_switching || entry.sources.length < 2) return; + final selectedSource = await showHistorySourcePicker( + context, + entry: entry, + now: now, + ); + if (selectedSource == null || !mounted || !context.mounted) return; + await _onItemTap(context, selectedSource.source, selectedSource.item); + } + + void _toggle(String key) { + setState(() { + if (!_collapsed.remove(key)) _collapsed.add(key); + }); + } + + @override + Widget build(BuildContext context) { + ref.listen(sessionProvider, (previous, next) { + final prevIds = + previous?.value?.sessions.map((s) => s.serverId).toSet() ?? + {}; + final nextIds = + next.value?.sessions.map((s) => s.serverId).toSet() ?? {}; + if (prevIds.length == nextIds.length && prevIds.containsAll(nextIds)) { + return; + } + if (_serverFilter != null && !nextIds.contains(_serverFilter)) { + setState(() => _serverFilter = null); + } + _load(); + }); + + if (widget.embedded) { + return Stack( + fit: StackFit.expand, + children: [ + CustomScrollView( + slivers: [ + SliverOverlapInjector( + handle: NestedScrollView.sliverOverlapAbsorberHandleFor( + context, + ), + ), + ..._buildContentSlivers(context), + ], + ), + if (_switching) _switchingOverlay(), + ], + ); + } + + return Stack( + fit: StackFit.expand, + children: [ + Scaffold( + extendBodyBehindAppBar: true, + body: CustomScrollView( + slivers: [ + AppSliverHeader( + title: '播放记录', + actions: [ + IconButton( + onPressed: _loading ? null : _load, + icon: _loading + ? const AppLoadingRing(size: 16) + : const Icon(Icons.refresh, size: 18), + tooltip: '刷新', + ), + ], + ), + ..._buildContentSlivers(context), + ], + ), + ), + if (_switching) _switchingOverlay(), + ], + ); + } + + Widget _switchingOverlay() => const Positioned.fill( + child: ColoredBox( + color: Color(0x44000000), + child: Center(child: AppLoadingRing()), + ), + ); + + List _buildContentSlivers(BuildContext context) { + if (_serverResults.isEmpty && !_loading) { + return const [ + SliverFillRemaining( + hasScrollBody: false, + child: EmptyState(icon: Icons.play_circle_outline, title: '暂无播放记录'), + ), + ]; + } + if (_serverResults.isEmpty && _loading) { + return const [ + SliverFillRemaining( + hasScrollBody: false, + child: Center(child: AppLoadingRing()), + ), + ]; + } + + final now = DateTime.now(); + final entries = flatten( + _serverResults, + serverFilter: _serverFilter, + deduplicateAcrossServers: true, + ); + final groups = groupEntries(entries, GroupAxis.watchedTime, now); + final servers = _serverResults + .map((sr) => (id: sr.serverId, name: sr.serverName)) + .toList(growable: false); + + return [ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.only(top: 8), + child: ServerFilterBar( + servers: servers, + selected: _serverFilter, + onSelect: (v) => setState(() => _serverFilter = v), + ), + ), + ), + ...buildAggregatedGridSlivers( + groups: groups, + collapsed: _collapsed, + onToggle: _toggle, + cardBuilder: (entry) => _card(context, entry, now), + ), + ]; + } + + Widget _card(BuildContext context, AggregatedEntry entry, DateTime now) { + return _TmdbAwareHistoryCard( + entry: entry, + now: now, + imageHeaders: buildEmbyImageHeaders( + ref, + token: entry.source.token, + userId: entry.source.userId, + ), + onTap: () => _onItemTap(context, entry.source, entry.item), + onServerSourcesTap: entry.sources.length > 1 + ? () => unawaited(_onServerSourcesTap(context, entry, now)) + : null, + ); + } +} + +class _TmdbAwareHistoryCard extends ConsumerWidget { + final AggregatedEntry entry; + final DateTime now; + final Map imageHeaders; + final VoidCallback onTap; + final VoidCallback? onServerSourcesTap; + + const _TmdbAwareHistoryCard({ + required this.entry, + required this.now, + required this.imageHeaders, + required this.onTap, + this.onServerSourcesTap, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final sr = entry.source; + final item = entry.item; + final episode = formatEpisodeNumber(item.IndexNumber); + final subtitle = episode != null + ? '$episode · ${item.Name}' + : (item.ProductionYear != null ? '${item.ProductionYear}' : null); + + final key = tmdbMediaKeyFrom(item); + String? networkName; + String? networkLogoUrl; + if (key != null) { + final enriched = ref.watch(tmdbEnrichedDetailProvider(key)).value; + final network = enriched?.networks.firstOrNull; + if (network != null) { + networkName = network.name; + final imageBase = ref + .watch(tmdbSettingsProvider) + .value + ?.effectiveImageBaseUrl; + if (imageBase != null) { + networkLogoUrl = TmdbImageUrl.logo( + imageBase, + network.logoPath, + size: 'w92', + ); + } + } + } + + return AggregatedMediaCard( + item: item, + imageUrl: EmbyImageUrl.landscapeCard( + baseUrl: sr.serverUrl, + token: sr.token, + item: item, + maxWidth: 400, + ), + imageHeaders: imageHeaders, + title: item.SeriesName ?? item.Name, + subtitle: subtitle, + serverName: sr.serverName, + serverSourceCount: entry.sources.length, + networkName: networkName, + networkLogoUrl: networkLogoUrl, + timeLabel: relativeTimeLabel(entry.dateFor(GroupAxis.watchedTime), now), + showProgress: true, + onTap: onTap, + onServerSourcesTap: onServerSourcesTap, + ); + } +} diff --git a/lib/features/history/widgets/history_source_picker.dart b/lib/features/history/widgets/history_source_picker.dart new file mode 100644 index 0000000..f88ba7f --- /dev/null +++ b/lib/features/history/widgets/history_source_picker.dart @@ -0,0 +1,254 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +import '../../../core/contracts/library.dart'; +import '../../../shared/theme/app_theme.dart'; +import '../../../shared/utils/emby_ticks.dart'; +import '../../../shared/utils/time_bucket.dart'; +import '../../../shared/widgets/adaptive_modal.dart'; +import '../../aggregated/aggregated_grouping.dart'; + + +Future showHistorySourcePicker( + BuildContext context, { + required AggregatedEntry entry, + DateTime? now, +}) { + return showAdaptiveModal( + context: context, + maxWidth: 520, + maxHeight: 640, + builder: (modalContext) => HistorySourcePickerContent( + entry: entry, + now: now ?? DateTime.now(), + onSourceSelected: (source) { + Navigator.of(modalContext).pop(source); + }, + ), + ); +} + + +class HistorySourcePickerContent extends StatelessWidget { + final AggregatedEntry entry; + final DateTime now; + final ValueChanged onSourceSelected; + + const HistorySourcePickerContent({ + super.key, + required this.entry, + required this.now, + required this.onSourceSelected, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final desiredHeight = 104.0 + (entry.sources.length * 92.0); + final contentHeight = math.min(desiredHeight, 560.0); + + return SizedBox( + height: contentHeight, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 8, 12), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '选择服务器', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + Text( + entry.item.SeriesName ?? entry.item.Name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: context.appTokens.mutedForeground, + ), + ), + ], + ), + ), + IconButton( + tooltip: '关闭', + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close, size: 20), + ), + ], + ), + ), + Divider(height: 1, color: context.appTokens.separatorColor), + Expanded( + child: ListView.separated( + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: entry.sources.length, + separatorBuilder: (_, _) => const SizedBox(height: 2), + itemBuilder: (context, index) { + final source = entry.sources[index]; + return _HistorySourceRow( + source: source, + now: now, + isPrimary: index == 0, + onTap: () => onSourceSelected(source), + ); + }, + ), + ), + ], + ), + ); + } +} + +class _HistorySourceRow extends StatelessWidget { + final AggregatedEntrySource source; + final DateTime now; + final bool isPrimary; + final VoidCallback onTap; + + const _HistorySourceRow({ + required this.source, + required this.now, + required this.isPrimary, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final tokens = context.appTokens; + final progress = _HistorySourceProgress.fromItem(source.item); + final lastPlayedLabel = + relativeTimeLabel(source.dateFor(GroupAxis.watchedTime), now) ?? + '未记录播放时间'; + + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + child: Text( + source.source.serverName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + if (isPrimary) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 7, + vertical: 2, + ), + decoration: BoxDecoration( + color: theme.colorScheme.primary.withValues( + alpha: 0.12, + ), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + '最近播放', + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.primary, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ], + ), + const SizedBox(height: 7), + ClipRRect( + borderRadius: BorderRadius.circular(999), + child: LinearProgressIndicator( + value: progress.value ?? 0, + minHeight: 4, + backgroundColor: theme.colorScheme.onSurface.withValues( + alpha: 0.08, + ), + valueColor: AlwaysStoppedAnimation( + theme.colorScheme.primary, + ), + ), + ), + const SizedBox(height: 6), + Text( + '${progress.label} · $lastPlayedLabel', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: tokens.mutedForeground, + ), + ), + ], + ), + ), + const SizedBox(width: 12), + Icon( + Icons.chevron_right_rounded, + size: 20, + color: tokens.mutedForeground, + ), + ], + ), + ), + ), + ); + } +} + +class _HistorySourceProgress { + final double? value; + final String label; + + const _HistorySourceProgress({required this.value, required this.label}); + + factory _HistorySourceProgress.fromItem(EmbyRawItem item) { + final userData = item.UserData; + if (userData?.Played == true) { + return const _HistorySourceProgress(value: 1, label: '已看完'); + } + + final positionTicks = userData?.PlaybackPositionTicks; + final runtimeTicks = item.RunTimeTicks; + if (positionTicks == null || runtimeTicks == null || runtimeTicks <= 0) { + return const _HistorySourceProgress(value: null, label: '进度未知'); + } + + final progress = (positionTicks / runtimeTicks).clamp(0.0, 1.0); + final watchedPercent = (progress * 100).round(); + final remainingTicks = runtimeTicks - positionTicks; + if (remainingTicks <= 0) { + return const _HistorySourceProgress(value: 1, label: '已看完'); + } + + final remainingMinutes = (remainingTicks / kTicksPerMinute).ceil(); + return _HistorySourceProgress( + value: progress, + label: '已看 $watchedPercent% · 剩余 $remainingMinutes 分钟', + ); + } +} diff --git a/lib/features/home/home_page.dart b/lib/features/home/home_page.dart new file mode 100644 index 0000000..f1b5cc9 --- /dev/null +++ b/lib/features/home/home_page.dart @@ -0,0 +1,637 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:forui/forui.dart'; +import 'package:go_router/go_router.dart'; + +import '../../core/contracts/auth.dart'; +import '../../core/contracts/library.dart'; +import '../../providers/di_providers.dart'; +import '../../providers/emby_headers_provider.dart'; +import '../../providers/home_banner_provider.dart'; +import '../../providers/home_page_mode_provider.dart'; +import '../../providers/library_overview_provider.dart'; +import '../../providers/playback_active_provider.dart'; +import '../../providers/server_provider.dart'; +import '../../providers/session_provider.dart'; +import '../../providers/shell_backdrop_provider.dart'; +import '../../providers/tmdb_prefetch_provider.dart'; +import '../../shared/mappers/media_image_url.dart'; +import '../../shared/utils/color_utils.dart'; +import '../../shared/utils/user_error_formatter.dart'; +import '../../shared/widgets/add_server_modal.dart'; +import '../../shared/widgets/app_dialog.dart'; +import '../../shared/widgets/app_error_state.dart'; +import '../../shared/widgets/app_loading_ring.dart'; +import '../../shared/widgets/empty_state.dart'; +import '../../shared/widgets/error_media_row.dart'; +import '../../shared/utils/media_nav.dart'; +import '../../shared/widgets/frosted_panel.dart'; +import '../../shared/widgets/latest_media_row.dart'; +import '../../shared/widgets/login_form.dart'; +import '../../shared/widgets/media_poster_card.dart'; +import '../../shared/constants/breakpoints.dart'; +import '../../shared/widgets/scroll_fade_header.dart'; +import '../../shared/widgets/skeleton_media_row.dart'; +import '../discover/discover_page.dart'; +import 'widgets/banner_carousel.dart'; +import 'widgets/server_dropdown_button.dart'; + +class HomePage extends ConsumerWidget { + const HomePage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + ref.listen(activeSessionProvider, (previous, next) { + if (previous != null && next == null) { + ref.read(shellBackdropProvider.notifier).clear(); + } + }); + + final sessionAsync = ref.watch(sessionProvider); + if (sessionAsync.isLoading && !sessionAsync.hasValue) { + return const Scaffold( + backgroundColor: Colors.transparent, + body: SizedBox.shrink(), + ); + } + + final session = ref.watch(activeSessionProvider); + if (session == null) return const _UnauthenticatedHome(); + return _AuthenticatedHome(session: session); + } +} + +class _UnauthenticatedHome extends ConsumerWidget { + const _UnauthenticatedHome(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final servers = ref.watch(serverListProvider); + return Scaffold( + backgroundColor: Colors.transparent, + body: servers.when( + data: (list) { + final availableList = list.where((s) => !s.paused).toList(); + + if (list.isEmpty || availableList.isEmpty) { + return Center( + child: EmptyState( + icon: Icons.cloud_outlined, + title: list.isEmpty ? '尚未添加 Emby 服务器' : '所有服务器已暂停', + message: list.isEmpty + ? '添加第一个服务器,开始构建你的影库。' + : '前往「服务器」页恢复使用,或添加新服务器。', + action: FButton( + variant: FButtonVariant.primary, + onPress: () => showAddServerModal(context), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.add, size: 16), + SizedBox(width: 6), + Text('添加服务器'), + ], + ), + ), + ), + ); + } + + return Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 540), + child: FrostedPanel( + radius: 32, + padding: const EdgeInsets.all(28), + color: Theme.of( + context, + ).colorScheme.surface.withValues(alpha: 0.72), + border: Border.all( + color: Colors.white.withValues(alpha: 0.22), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + '选择服务器登录', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 8), + Text( + '所有内容都会以当前服务器为中心进行同步与推荐。', + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 18), + ...availableList.map( + (server) => Padding( + padding: const EdgeInsets.only(bottom: 10), + child: FCard( + child: ListTile( + leading: const Icon(Icons.dns_outlined), + title: Text(server.name), + subtitle: Text(server.baseUrl), + onTap: () async { + await showAppRawDialog( + context, + constraints: const BoxConstraints( + maxWidth: 460, + ), + builder: (_) => Padding( + padding: const EdgeInsets.all(24), + child: LoginForm( + server: server, + onBack: () => Navigator.pop(context), + onSuccess: () => Navigator.pop(context), + ), + ), + ); + }, + ), + ), + ), + ), + const SizedBox(height: 8), + FButton( + variant: FButtonVariant.outline, + onPress: () => showAddServerModal(context), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.add, size: 16), + SizedBox(width: 6), + Text('添加新服务器'), + ], + ), + ), + ], + ), + ), + ), + ), + ); + }, + loading: () => const Center(child: AppLoadingRing()), + error: (error, _) => AppErrorState( + message: formatUserError(error, fallback: '登录态加载失败'), + onRetry: () => ref.invalidate(sessionProvider), + ), + ), + ); + } +} + +class _AuthenticatedHome extends ConsumerStatefulWidget { + final AuthedSession session; + + const _AuthenticatedHome({required this.session}); + + @override + ConsumerState<_AuthenticatedHome> createState() => _AuthenticatedHomeState(); +} + +class _AuthenticatedHomeState extends ConsumerState<_AuthenticatedHome> { + final Map _favoriteMap = {}; + final Map _playedMap = {}; + final Map _favoriteLoadingMap = {}; + final Map _playedLoadingMap = {}; + final Map _hideResumeLoadingMap = {}; + final Set _hiddenResumeIds = {}; + + final ScrollController _scrollController = ScrollController(); + final ValueNotifier _scrollProgress = ValueNotifier(0.0); + + @override + void initState() { + super.initState(); + _scrollController.addListener(_handleScroll); + } + + @override + void dispose() { + _scrollController.removeListener(_handleScroll); + _scrollController.dispose(); + _scrollProgress.dispose(); + super.dispose(); + } + + void _handleScroll() { + if (!_scrollController.hasClients) return; + const threshold = 120.0; + final progress = (_scrollController.offset / threshold).clamp(0.0, 1.0); + _scrollProgress.value = progress; + } + + Future _toggleFavorite(EmbyRawItem item) async { + if (_favoriteLoadingMap[item.Id] == true) return; + final next = !(_favoriteMap[item.Id] ?? item.UserData?.IsFavorite ?? false); + setState(() => _favoriteLoadingMap[item.Id] = true); + try { + final useCase = await ref.read(setFavoriteUseCaseProvider.future); + final result = await useCase.execute( + FavoriteSetReq(itemId: item.Id, isFavorite: next), + ); + setState(() => _favoriteMap[item.Id] = result.isFavorite); + } finally { + setState(() => _favoriteLoadingMap[item.Id] = false); + } + } + + Future _togglePlayed(EmbyRawItem item) async { + if (_playedLoadingMap[item.Id] == true) return; + final next = !(_playedMap[item.Id] ?? item.UserData?.Played ?? false); + setState(() => _playedLoadingMap[item.Id] = true); + try { + final useCase = await ref.read(setPlayedUseCaseProvider.future); + final result = await useCase.execute( + PlayedSetReq(itemId: item.Id, played: next), + ); + setState(() => _playedMap[item.Id] = result.played); + unawaited(_syncTraktPlayedChange(item, result.played)); + } finally { + setState(() => _playedLoadingMap[item.Id] = false); + } + } + + Future _syncTraktPlayedChange(EmbyRawItem item, bool played) async { + try { + final service = await ref.read(traktSyncServiceProvider.future); + await service.syncEmbyPlayedChange(item: item, played: played); + } catch (_) {} + } + + Future _hideFromResume(EmbyRawItem item) async { + if (_hideResumeLoadingMap[item.Id] == true) return; + setState(() { + _hideResumeLoadingMap[item.Id] = true; + _hiddenResumeIds.add(item.Id); + }); + try { + final useCase = await ref.read(hideFromResumeUseCaseProvider.future); + final result = await useCase.execute( + HideFromResumeReq(itemId: item.Id, hide: true), + ); + if (!result.hide) { + setState(() => _hiddenResumeIds.remove(item.Id)); + } + } catch (_) { + setState(() => _hiddenResumeIds.remove(item.Id)); + } finally { + setState(() => _hideResumeLoadingMap[item.Id] = false); + } + } + + bool _resolveFavorite(EmbyRawItem item) { + return _favoriteMap[item.Id] ?? item.UserData?.IsFavorite ?? false; + } + + bool _resolvePlayed(EmbyRawItem item) { + return _playedMap[item.Id] ?? item.UserData?.Played ?? false; + } + + Map _buildFavoriteMap(List items) { + return {for (final item in items) item.Id: _resolveFavorite(item)}; + } + + Map _buildPlayedMap(List items) { + return {for (final item in items) item.Id: _resolvePlayed(item)}; + } + + void _openMediaDetail(EmbyRawItem item) { + goMediaDetail(context, item, ref: ref); + } + + EmbyRawItem? _lastBannerItem; + + bool _isHomeForeground() { + return ref.read(shellLocationProvider) == '/' && + !ref.read(detailPageActiveProvider); + } + + void _updateShellBackdrop(EmbyRawItem item) { + _lastBannerItem = item; + if (!_isHomeForeground()) return; + + final backdropUrl = EmbyImageUrl.detailBanner( + baseUrl: widget.session.serverUrl, + token: widget.session.token, + item: item, + ); + final fallbackId = (item.SeriesId?.isNotEmpty ?? false) + ? item.SeriesId! + : item.Id; + final fallbackUrls = { + EmbyImageUrl.fanart( + baseUrl: widget.session.serverUrl, + token: widget.session.token, + itemId: fallbackId, + ), + backdropUrl, + }.toList(); + final accentColor = _accentColorFor(item.Id); + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + if (!_isHomeForeground()) return; + ref + .read(shellBackdropProvider.notifier) + .show( + ShellBackdropState( + primaryImageUrl: backdropUrl, + fallbackImageUrls: fallbackUrls, + accentColor: accentColor, + ), + ); + }); + } + + void _clearShellBackdropDeferred() { + _lastBannerItem = null; + if (ref.read(shellBackdropProvider) == null) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + if (!_isHomeForeground()) return; + ref.read(shellBackdropProvider.notifier).clear(); + }); + } + + Color _accentColorFor(String seed) => accentColorForSeed( + seed, + isDark: Theme.of(context).brightness == Brightness.dark, + ); + + @override + Widget build(BuildContext context) { + final isCompact = MediaQuery.sizeOf(context).width < Breakpoints.compact; + final mode = ref.watch(homePageModeProvider); + if (isCompact && mode == HomePageMode.recommend) { + return const DiscoverPage(embedInHome: true); + } + + final state = ref.watch(libraryOverviewProvider); + final bannerAsync = ref.watch(homeBannerProvider); + final imageHeaders = ref.watch(embyImageHeadersProvider).value; + final dpr = MediaQuery.devicePixelRatioOf(context); + final homeForeground = + ref.watch(shellLocationProvider) == '/' && + !ref.watch(detailPageActiveProvider); + ref.watch(tmdbPrefetchProvider); + + ref.listen(shellLocationProvider, (_, loc) { + if (loc != '/') return; + if (ref.read(detailPageActiveProvider)) return; + if (_lastBannerItem != null) { + _updateShellBackdrop(_lastBannerItem!); + } else { + _clearShellBackdropDeferred(); + } + }); + ref.listen(detailPageActiveProvider, (_, active) { + if (active) return; + if (ref.read(shellLocationProvider) != '/') return; + ref.read(libraryOverviewProvider.notifier).softRefresh(); + if (_lastBannerItem != null) { + _updateShellBackdrop(_lastBannerItem!); + } else { + _clearShellBackdropDeferred(); + } + }); + + return Scaffold( + backgroundColor: Colors.transparent, + body: state.when( + data: (data) { + if (data.libraries.isEmpty) { + _clearShellBackdropDeferred(); + return const Center( + child: EmptyState( + icon: Icons.movie_filter_outlined, + title: '没有可用的媒体库', + ), + ); + } + + final resumeItems = data.resumeItems + .where((item) => !_hiddenResumeIds.contains(item.Id)) + .toList(); + + return Stack( + children: [ + RefreshIndicator( + onRefresh: () => Future.wait([ + ref.read(libraryOverviewProvider.notifier).softRefresh(), + ref.read(homeBannerProvider.notifier).softRefresh(), + ]), + displacement: 72, + child: ListView( + controller: _scrollController, + physics: const AlwaysScrollableScrollPhysics( + parent: BouncingScrollPhysics(), + ), + padding: EdgeInsets.only(top: isCompact ? 0 : 60, bottom: 32), + children: [ + bannerAsync.when( + data: (bannerItems) { + if (bannerItems.isEmpty) { + _clearShellBackdropDeferred(); + return const SizedBox.shrink( + key: ValueKey('home-banner-empty'), + ); + } + return Padding( + key: const ValueKey('home-banner'), + padding: const EdgeInsets.only(bottom: 16), + child: HomeBannerCarousel( + baseUrl: widget.session.serverUrl, + token: widget.session.token, + items: bannerItems, + imageHeaders: imageHeaders, + onItemTap: _openMediaDetail, + onActiveItemChanged: _updateShellBackdrop, + active: homeForeground, + ), + ); + }, + loading: () => const Padding( + key: ValueKey('home-banner-loading'), + padding: EdgeInsets.only(bottom: 16), + child: HomeBannerSkeleton(), + ), + error: (_, _) { + _clearShellBackdropDeferred(); + return const SizedBox.shrink( + key: ValueKey('home-banner-error'), + ); + }, + ), + if (data.resumeLoading) + const SkeletonMediaRow( + key: ValueKey('home-resume-row'), + title: '继续观看', + cardVariant: MediaCardVariant.landscape, + titleColor: Colors.white, + ) + else if (data.resumeError != null) + ErrorMediaRow( + key: const ValueKey('home-resume-row'), + title: '继续观看', + cardVariant: MediaCardVariant.landscape, + titleColor: Colors.white, + onRetry: () => ref + .read(libraryOverviewProvider.notifier) + .retryResume(), + ) + else if (resumeItems.isNotEmpty) + LatestMediaRow( + key: const ValueKey('home-resume-row'), + title: '继续观看', + items: resumeItems, + cardVariant: MediaCardVariant.landscape, + enableContextMenu: true, + favoriteMap: _buildFavoriteMap(resumeItems), + playedMap: _buildPlayedMap(resumeItems), + favoriteLoadingMap: _favoriteLoadingMap, + playedLoadingMap: _playedLoadingMap, + onAddFavorite: _toggleFavorite, + onMarkPlayed: _togglePlayed, + onHideFromResume: _hideFromResume, + hideFromResumeLoadingMap: _hideResumeLoadingMap, + imageHeaders: imageHeaders, + titleColor: Colors.white, + imageUrlBuilder: (item) => EmbyImageUrl.landscapeCard( + baseUrl: widget.session.serverUrl, + token: widget.session.token, + item: item, + maxWidth: EmbyImageUrl.targetPixelWidth(232, dpr), + ), + preloadImageUrlBuilder: (item) => + EmbyImageUrl.detailBanner( + baseUrl: widget.session.serverUrl, + token: widget.session.token, + item: item, + ), + onItemTap: _openMediaDetail, + ), + if (data.libraries.isNotEmpty) + LatestMediaRow( + key: const ValueKey('home-library-row'), + title: '我的媒体列表', + items: data.libraries + .map( + (library) => EmbyRawItem( + Id: library.Id, + Name: library.Name, + PrimaryImageTag: library.ImageTags?['Primary'], + ImageTags: library.ImageTags, + ), + ) + .toList(), + cardVariant: MediaCardVariant.landscape, + showHoverPlayIcon: false, + imageHeaders: imageHeaders, + titleColor: Colors.white, + imageUrlBuilder: (item) => EmbyImageUrl.primary( + baseUrl: widget.session.serverUrl, + token: widget.session.token, + item: item, + maxWidth: EmbyImageUrl.targetPixelWidth(232, dpr), + ), + onItemTap: (item) => + context.push('/library/${item.Id}'), + ), + ...data.libraries.expand((library) { + final isLoading = data.loadingLibraryIds.contains( + library.Id, + ); + if (isLoading) { + return [ + SkeletonMediaRow( + key: ValueKey('home-latest-row-${library.Id}'), + title: '${library.Name} · 最新更新', + titleColor: Colors.white, + ), + ]; + } + final error = data.latestErrorByLibrary[library.Id]; + if (error != null) { + return [ + ErrorMediaRow( + key: ValueKey('home-latest-row-${library.Id}'), + title: '${library.Name} · 最新更新', + titleColor: Colors.white, + onRetry: () => ref + .read(libraryOverviewProvider.notifier) + .retryLatest(library.Id), + ), + ]; + } + final items = + data.latestByLibrary[library.Id] ?? + const []; + if (items.isEmpty) return const []; + return [ + LatestMediaRow( + key: ValueKey('home-latest-row-${library.Id}'), + title: library.Name, + subtitle: '最新更新', + items: items, + totalCount: data.totalCountByLibrary[library.Id], + enableContextMenu: true, + favoriteMap: _buildFavoriteMap(items), + playedMap: _buildPlayedMap(items), + favoriteLoadingMap: _favoriteLoadingMap, + playedLoadingMap: _playedLoadingMap, + onAddFavorite: _toggleFavorite, + onMarkPlayed: _togglePlayed, + imageHeaders: imageHeaders, + titleColor: Colors.white, + imageUrlBuilder: (item) => EmbyImageUrl.primary( + baseUrl: widget.session.serverUrl, + token: widget.session.token, + item: item, + maxWidth: EmbyImageUrl.targetPixelWidth(158, dpr), + ), + preloadImageUrlBuilder: (item) => + EmbyImageUrl.detailBanner( + baseUrl: widget.session.serverUrl, + token: widget.session.token, + item: item, + ), + onItemTap: _openMediaDetail, + onSeeAll: () => + context.push('/library/${library.Id}'), + ), + ]; + }), + ], + ), + ), + if (MediaQuery.sizeOf(context).width < Breakpoints.compact) + Positioned( + top: MediaQuery.paddingOf(context).top + 8, + left: 16, + child: const ServerDropdownButton(), + ) + else + Positioned( + top: 0, + left: 0, + right: 0, + child: ScrollFadeHeader(scrollProgress: _scrollProgress), + ), + ], + ); + }, + loading: () => const Center(child: AppLoadingRing()), + error: (error, _) => AppErrorState( + message: formatUserError(error, fallback: '首页内容加载失败'), + panel: true, + onRetry: () => ref.read(libraryOverviewProvider.notifier).refresh(), + ), + ), + ); + } +} diff --git a/lib/features/home/widgets/banner_carousel.dart b/lib/features/home/widgets/banner_carousel.dart new file mode 100644 index 0000000..a6c8b54 --- /dev/null +++ b/lib/features/home/widgets/banner_carousel.dart @@ -0,0 +1,170 @@ +import 'dart:async'; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; + +import '../../../core/contracts/library.dart'; +import '../../../shared/constants/breakpoints.dart'; +import '../../../shared/mappers/media_image_url.dart'; +import '../../../shared/widgets/banner_carousel_mixin.dart'; +import '../../../shared/widgets/banner_carousel_scaffold.dart'; + + +class HomeBannerCarousel extends StatefulWidget { + final String baseUrl; + final String token; + final List items; + final Map? imageHeaders; + final ValueChanged onItemTap; + final ValueChanged? onActiveItemChanged; + final bool active; + + const HomeBannerCarousel({ + super.key, + required this.baseUrl, + required this.token, + required this.items, + required this.onItemTap, + this.imageHeaders, + this.onActiveItemChanged, + this.active = true, + }); + + @override + State createState() => _HomeBannerCarouselState(); +} + +class _HomeBannerCarouselState extends State + with WidgetsBindingObserver, BannerCarouselMixin { + @override + int get itemCount => widget.items.length; + + @override + bool get isActive => widget.active; + + @override + void emitActiveItem() { + if (!mounted || !widget.active) return; + if (widget.items.isEmpty) return; + final safeIndex = currentIndex.clamp(0, widget.items.length - 1); + widget.onActiveItemChanged?.call(widget.items[safeIndex]); + } + + @override + void precacheAdjacentSlides() { + if (!mounted || !widget.active || widget.items.length <= 1) return; + final next = (currentIndex + 1) % widget.items.length; + final nextLogo = _logoUrl(widget.items[next]); + final cacheWidth = bannerCacheWidth(); + + void precacheUrl(String url, {int? cacheWidth}) { + unawaited( + precacheImage( + ResizeImage.resizeIfNeeded( + cacheWidth, + null, + CachedNetworkImageProvider(url, headers: widget.imageHeaders), + ), + context, + onError: (_, _) {}, + ), + ); + } + + precacheUrl( + EmbyImageUrl.detailBanner( + baseUrl: widget.baseUrl, + token: widget.token, + item: widget.items[next], + ), + cacheWidth: cacheWidth, + ); + if (nextLogo != null) precacheUrl(nextLogo, cacheWidth: 960); + } + + @override + void didUpdateWidget(covariant HomeBannerCarousel oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.items != widget.items) { + resetCarouselItems(); + } + if (oldWidget.active != widget.active) { + widget.active ? activateCarousel() : deactivateCarousel(); + } + } + + BannerSlideData _slideDataFor(EmbyRawItem item) { + final fallbackItemId = (item.SeriesId?.isNotEmpty ?? false) + ? item.SeriesId! + : item.Id; + final genres = + (item.extra['Genres'] as List?)?.cast().take(2).toList() ?? + const []; + return BannerSlideData( + imageUrl: EmbyImageUrl.detailBanner( + baseUrl: widget.baseUrl, + token: widget.token, + item: item, + ), + fallbackImageUrl: EmbyImageUrl.fanart( + baseUrl: widget.baseUrl, + token: widget.token, + itemId: fallbackItemId, + ), + imageHeaders: widget.imageHeaders, + logoUrl: _logoUrl(item), + title: item.Name, + overview: item.Overview, + rating: item.CommunityRating?.toStringAsFixed(1), + primaryMetaLabels: [ + if (item.ProductionYear != null) item.ProductionYear!.toString(), + ], + secondaryMetaLabels: genres, + ); + } + + String? _logoUrl(EmbyRawItem item) { + final logoItemId = (item.SeriesId?.isNotEmpty ?? false) + ? item.SeriesId! + : item.Id; + return EmbyImageUrl.logo( + baseUrl: widget.baseUrl, + token: widget.token, + itemId: logoItemId, + tag: logoItemId == item.Id ? (item.ImageTags?['Logo']) : null, + ); + } + + @override + Widget build(BuildContext context) { + return BannerCarouselScaffold( + controller: this, + reserveLogoArea: true, + slides: [for (final item in widget.items) _slideDataFor(item)], + onSlideTap: (index) => widget.onItemTap(widget.items[index]), + ); + } +} + + +class HomeBannerSkeleton extends StatelessWidget { + const HomeBannerSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + final compact = Breakpoints.isCompact(MediaQuery.sizeOf(context).width); + if (compact) { + final height = MediaQuery.sizeOf(context).height * 0.55; + return SizedBox(height: height); + } + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: LayoutBuilder( + builder: (context, constraints) { + final height = (constraints.maxWidth * 7 / 16).clamp(260.0, 500.0); + return SizedBox(height: height); + }, + ), + ); + } +} diff --git a/lib/features/home/widgets/server_dropdown_button.dart b/lib/features/home/widgets/server_dropdown_button.dart new file mode 100644 index 0000000..025f0c4 --- /dev/null +++ b/lib/features/home/widgets/server_dropdown_button.dart @@ -0,0 +1,165 @@ +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:forui/forui.dart'; + +import '../../../core/contracts/auth.dart'; +import '../../../providers/home_page_mode_provider.dart'; +import '../../../providers/server_provider.dart'; +import '../../../providers/session_provider.dart'; +import '../../../shared/widgets/auto_dismiss_menu.dart'; +import '../../../shared/widgets/server_avatar.dart'; + +class ServerDropdownButton extends ConsumerWidget { + const ServerDropdownButton({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final activeSession = ref.watch(activeSessionProvider); + final sessionState = ref.watch(sessionProvider); + final serverList = ref.watch(serverListProvider); + final mode = ref.watch(homePageModeProvider); + + final isRecommend = mode == HomePageMode.recommend; + final currentName = isRecommend + ? '推荐' + : (activeSession?.serverName ?? '未连接'); + + final pausedIds = serverList.maybeWhen( + data: (list) => { + for (final s in list) + if (s.paused) s.id, + }, + orElse: () => {}, + ); + final sessions = sessionState.maybeWhen( + data: (data) => + data.sessions.where((s) => !pausedIds.contains(s.serverId)).toList(), + orElse: () => [], + ); + + return FPopoverMenu( + menuBuilder: autoDismissMenuBuilder, + menuAnchor: Alignment.topLeft, + childAnchor: Alignment.bottomLeft, + menu: [FItemGroup(children: [ + FItem( + prefix: Icon( + isRecommend ? Icons.radio_button_checked : Icons.radio_button_unchecked, + size: 20, + color: isRecommend ? Theme.of(context).colorScheme.primary : null, + ), + title: const Row( + children: [ + Icon(Icons.explore_outlined, size: 20), + SizedBox(width: 8), + Text('推荐'), + ], + ), + selected: isRecommend, + onPress: () { + ref.read(homePageModeProvider.notifier).state = + HomePageMode.recommend; + }, + ), + for (final s in sessions) + FItem( + prefix: Icon( + !isRecommend && s.serverId == activeSession?.serverId + ? Icons.radio_button_checked + : Icons.radio_button_unchecked, + size: 20, + color: !isRecommend && s.serverId == activeSession?.serverId + ? Theme.of(context).colorScheme.primary + : null, + ), + title: Text( + s.serverName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + selected: !isRecommend && s.serverId == activeSession?.serverId, + onPress: () { + ref.read(homePageModeProvider.notifier).state = + HomePageMode.server; + ref.read(sessionProvider.notifier).switchSession(s.serverId); + }, + ), + ])], + builder: (context, controller, child) => GestureDetector( + onTap: controller.toggle, + child: child, + ), + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: MediaQuery.sizeOf(context).width - 32, + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(20), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12), + child: Container( + padding: const EdgeInsets.fromLTRB(6, 6, 8, 6), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.25), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: Colors.white.withValues(alpha: 0.35)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (isRecommend) + Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + Icons.explore_outlined, + size: 16, + color: Theme.of(context).colorScheme.primary, + ), + ) + else + ServerAvatar( + name: activeSession?.serverName ?? '', + iconUrl: '', + size: 28, + isActive: true, + ), + const SizedBox(width: 8), + Flexible( + child: Text( + currentName, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: Colors.white, + shadows: [ + Shadow(blurRadius: 8, color: Color(0x80000000)), + ], + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 2), + const Icon( + Icons.arrow_drop_down, + size: 18, + color: Colors.white, + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/library/library_list_page.dart b/lib/features/library/library_list_page.dart new file mode 100644 index 0000000..8c2c56b --- /dev/null +++ b/lib/features/library/library_list_page.dart @@ -0,0 +1,316 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../core/contracts/library.dart'; +import '../../providers/di_providers.dart'; +import '../../providers/emby_headers_provider.dart'; +import '../../providers/library_overview_provider.dart'; +import '../../providers/session_provider.dart'; +import '../../providers/shell_backdrop_provider.dart'; +import '../../shared/constants/layout_constants.dart'; +import '../../shared/mappers/media_image_url.dart'; +import '../../shared/utils/user_error_formatter.dart'; +import '../../shared/widgets/app_loading_ring.dart'; +import '../../shared/widgets/app_error_state.dart'; +import '../../shared/utils/media_nav.dart'; +import '../../shared/widgets/app_sliver_header.dart'; +import '../../shared/widgets/empty_state.dart'; +import '../../shared/widgets/media_poster_card.dart'; + +const _kPageSize = 20; +const _kScrollThreshold = 200.0; +const _kFields = + 'BasicSyncInfo,CommunityRating,ProductionYear,ChildCount,Container,' + 'Overview,UserDataLastPlayedDate,OfficialRating,Genres,Status,' + 'People,Studios,ProviderIds'; +const _kImageTypes = 'Primary,Backdrop,Thumb,Logo'; +const _kIncludeTypes = 'Series,Movie,Video,MusicVideo'; +const _kCollectionIncludeTypes = 'BoxSet,Folder,Series,Movie,Video,MusicVideo'; +const _kSortBy = 'DateLastContentAdded,DateCreated,SortName'; + +class LibraryListPage extends ConsumerStatefulWidget { + final String libraryId; + + + final String? title; + + + final bool isCollection; + + const LibraryListPage({ + super.key, + required this.libraryId, + this.title, + this.isCollection = false, + }); + + @override + ConsumerState createState() => _LibraryListPageState(); +} + +class _LibraryListPageState extends ConsumerState { + final List _items = []; + int _startIndex = 0; + bool _hasMore = true; + bool _isFirstLoad = true; + bool _isLoadingMore = false; + String? _loadError; + + final _scrollController = ScrollController(); + + @override + void initState() { + super.initState(); + _fetchPage(); + _scrollController.addListener(_onScroll); + } + + @override + void didUpdateWidget(LibraryListPage oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.libraryId != widget.libraryId) { + _resetAndFetch(); + } + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + void _onScroll() { + if (!_scrollController.hasClients) return; + final remaining = + _scrollController.position.maxScrollExtent - + _scrollController.position.pixels; + if (remaining <= _kScrollThreshold && _hasMore && !_isLoadingMore) { + _fetchPage(); + } + } + + void _resetAndFetch() { + setState(() { + _items.clear(); + _startIndex = 0; + _hasMore = true; + _isFirstLoad = true; + _isLoadingMore = false; + _loadError = null; + }); + _fetchPage(); + } + + Future _refresh() async { + setState(() { + _items.clear(); + _startIndex = 0; + _hasMore = true; + _isFirstLoad = true; + _isLoadingMore = false; + _loadError = null; + }); + await _fetchPage(); + } + + Future _fetchPage() async { + if (_isLoadingMore || !_hasMore) return; + setState(() { + _isLoadingMore = true; + _loadError = null; + }); + try { + final uc = await ref.read(libraryItemsUseCaseProvider.future); + final res = await uc.execute( + LibraryItemsReq( + parentId: widget.libraryId, + includeItemTypes: widget.isCollection + ? _kCollectionIncludeTypes + : _kIncludeTypes, + sortBy: _kSortBy, + sortOrder: SortOrder.descending, + fields: _kFields, + enableImageTypes: _kImageTypes, + startIndex: _startIndex, + limit: _kPageSize, + recursive: !widget.isCollection, + ), + ); + if (!mounted) return; + final fetched = res.items; + setState(() { + _items.addAll(fetched); + _startIndex += fetched.length; + _hasMore = fetched.length == _kPageSize; + _isFirstLoad = false; + _isLoadingMore = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _loadError = e.toString(); + _isFirstLoad = false; + _isLoadingMore = false; + }); + } + } + + @override + Widget build(BuildContext context) { + final session = ref.watch(activeSessionProvider); + if (session == null) return const SizedBox.shrink(); + final imageHeaders = ref.watch(embyImageHeadersProvider).value; + final hasBackdrop = ref.watch( + shellBackdropProvider.select((state) => state != null), + ); + final immersiveTextColor = hasBackdrop ? Colors.white : null; + final libraryName = + ref + .watch(libraryOverviewProvider) + .value + ?.libraries + .where((l) => l.Id == widget.libraryId) + .firstOrNull + ?.Name ?? + widget.title ?? + ''; + + return Scaffold( + backgroundColor: Colors.transparent, + extendBodyBehindAppBar: true, + body: RefreshIndicator( + onRefresh: _refresh, + child: CustomScrollView( + controller: _scrollController, + physics: const AlwaysScrollableScrollPhysics( + parent: BouncingScrollPhysics(), + ), + slivers: [ + AppSliverHeader( + title: libraryName, + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => context.pop(), + ), + ), + ..._buildContentSlivers( + session, + imageHeaders, + immersiveTextColor: immersiveTextColor, + ), + ], + ), + ), + ); + } + + List _buildContentSlivers( + dynamic session, + Map? imageHeaders, { + required Color? immersiveTextColor, + }) { + if (_isFirstLoad) { + if (_loadError != null) { + return [ + SliverFillRemaining( + hasScrollBody: false, + child: AppErrorState( + message: formatUserError(_loadError, fallback: '媒体列表加载失败'), + foregroundColor: immersiveTextColor, + onRetry: _resetAndFetch, + ), + ), + ]; + } + return const [ + SliverFillRemaining( + hasScrollBody: false, + child: Center(child: AppLoadingRing()), + ), + ]; + } + + if (_items.isEmpty && !_isLoadingMore && _loadError != null) { + return [ + SliverFillRemaining( + hasScrollBody: false, + child: AppErrorState( + message: formatUserError(_loadError, fallback: '媒体列表加载失败'), + foregroundColor: immersiveTextColor, + onRetry: _resetAndFetch, + ), + ), + ]; + } + + if (_items.isEmpty && !_isLoadingMore) { + return [ + SliverFillRemaining( + hasScrollBody: false, + child: EmptyState( + icon: Icons.inbox_outlined, + title: '暂无媒体内容', + foregroundColor: immersiveTextColor, + ), + ), + ]; + } + + return [ + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + sliver: SliverGrid( + delegate: SliverChildBuilderDelegate( + (_, i) => MediaPosterCard( + item: _items[i], + width: double.infinity, + textColor: immersiveTextColor, + imageUrl: EmbyImageUrl.primary( + baseUrl: session.serverUrl, + token: session.token, + item: _items[i], + maxHeight: 360, + ), + preloadImageUrl: EmbyImageUrl.detailBanner( + baseUrl: session.serverUrl, + token: session.token, + item: _items[i], + ), + imageHeaders: imageHeaders, + onTap: () => goMediaDetail(context, _items[i]), + ), + childCount: _items.length, + ), + gridDelegate: mediaGridDelegate, + ), + ), + SliverToBoxAdapter( + child: _buildFooter(immersiveTextColor: immersiveTextColor), + ), + ]; + } + + Widget _buildFooter({required Color? immersiveTextColor}) { + if (_isLoadingMore) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 24), + child: Center(child: AppLoadingRing()), + ); + } + if (!_hasMore && _items.isNotEmpty) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 24), + child: Center( + child: Text( + '没有更多了', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: immersiveTextColor?.withValues(alpha: 0.55), + ), + ), + ), + ); + } + return const SizedBox.shrink(); + } +} diff --git a/lib/features/modules/module_all_page.dart b/lib/features/modules/module_all_page.dart new file mode 100644 index 0000000..7a5c6d2 --- /dev/null +++ b/lib/features/modules/module_all_page.dart @@ -0,0 +1,486 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../core/contracts/script_widget.dart'; +import '../../core/domain/usecases/script_widget/script_widget_service.dart'; +import '../../providers/di_providers.dart'; +import '../../shared/constants/layout_constants.dart'; +import '../../shared/theme/app_theme.dart'; +import '../../shared/utils/user_error_formatter.dart'; +import '../../shared/widgets/app_card.dart'; +import '../../shared/widgets/app_error_state.dart'; +import '../../shared/widgets/app_loading_ring.dart'; +import '../../shared/widgets/app_sliver_header.dart'; +import '../../shared/widgets/empty_state.dart'; +import '../../shared/widgets/page_background.dart'; +import '../../shared/widgets/script_video_card.dart'; +import '../../shared/widgets/section_header.dart'; + + +const double _kInfiniteScrollThreshold = 200; + +class ModuleAllPage extends ConsumerStatefulWidget { + final String widgetId; + final String moduleKey; + + + final Map initialParams; + + const ModuleAllPage({ + super.key, + required this.widgetId, + required this.moduleKey, + this.initialParams = const {}, + }); + + @override + ConsumerState createState() => _ModuleAllPageState(); +} + +class _ModuleAllPageState extends ConsumerState { + final ScrollController _scrollController = ScrollController(); + + ScriptWidgetModule? _module; + Map _parameters = {}; + + + final List _sections = + []; + + String? _initializedWidgetId; + String? _initializationError; + + + bool _isFirstLoad = true; + bool _isLoadingMore = false; + bool _hasMore = true; + String? _loadError; + + @override + void initState() { + super.initState(); + _scrollController.addListener(_onScroll); + } + + @override + void dispose() { + _scrollController.removeListener(_onScroll); + _scrollController.dispose(); + super.dispose(); + } + + void _onScroll() { + if (!_scrollController.hasClients) return; + final remaining = + _scrollController.position.maxScrollExtent - + _scrollController.position.pixels; + if (remaining <= _kInfiniteScrollThreshold) { + unawaited(_fetchNextPage()); + } + } + + Future _initialize(InstalledScriptWidget installedWidget) async { + if (_initializedWidgetId == installedWidget.manifest.id) return; + _initializedWidgetId = installedWidget.manifest.id; + + final module = installedWidget.manifest.modules + .where((candidate) => _moduleKey(candidate) == widget.moduleKey) + .firstOrNull; + if (module == null) { + setState(() { + _initializationError = '未找到模块功能:${widget.moduleKey}'; + }); + return; + } + + final resolvedParameters = buildScriptWidgetParameterDefaults(module.params); + for (final entry in widget.initialParams.entries) { + if (resolvedParameters.containsKey(entry.key)) { + resolvedParameters[entry.key] = entry.value; + } + } + setState(() { + _module = module; + _parameters = resolvedParameters; + _initializationError = null; + }); + await _fetchNextPage(); + } + + String _moduleKey(ScriptWidgetModule module) { + return module.id.isEmpty ? module.functionName : module.id; + } + + ScriptWidgetParam? get _pagingParameter { + return _module?.params + .where( + (parameter) => parameter.type == 'page' || parameter.type == 'offset', + ) + .firstOrNull; + } + + + bool get _supportsPagination => _pagingParameter != null; + + Future _fetchNextPage() async { + if (_isLoadingMore || !_hasMore) return; + final module = _module; + if (module == null) return; + + setState(() { + _isLoadingMore = true; + _loadError = null; + }); + + try { + final service = await ref.read(scriptWidgetServiceProvider.future); + final freshSections = await service.loadModuleSections( + widget.widgetId, + module, + _parameters, + ); + if (!mounted) return; + + final contentItemCount = freshSections.fold( + 0, + (count, section) => + count + + section.items + .where((item) => item.type.toLowerCase() != 'text') + .length, + ); + + setState(() { + _mergeSections(freshSections); + if (!_supportsPagination || contentItemCount == 0) { + _hasMore = false; + } else { + _advancePagingCursor(); + } + _isFirstLoad = false; + _isLoadingMore = false; + }); + } catch (error) { + if (!mounted) return; + setState(() { + _loadError = formatUserError(error); + _isFirstLoad = false; + _isLoadingMore = false; + }); + } + } + + + void _mergeSections(List freshSections) { + for (final freshSection in freshSections) { + final existingIndex = _sections.indexWhere( + (existing) => existing.title == freshSection.title, + ); + if (existingIndex == -1) { + _sections.add(freshSection); + continue; + } + final merged = [ + ..._sections[existingIndex].items, + ...freshSection.items, + ]; + _sections[existingIndex] = ScriptWidgetContentSection( + title: freshSection.title, + items: merged, + ); + } + } + + + void _advancePagingCursor() { + final module = _module; + final pagingParameter = _pagingParameter; + if (module == null || pagingParameter == null) return; + + final minimumValue = pagingParameter.type == 'page' ? 1 : 0; + final currentValue = + int.tryParse(_parameters[pagingParameter.name]?.toString() ?? '') ?? + minimumValue; + final countParameter = module.params + .where((parameter) => parameter.type == 'count') + .firstOrNull; + final pageStep = pagingParameter.type == 'offset' + ? int.tryParse(_parameters[countParameter?.name]?.toString() ?? '') ?? + 20 + : 1; + _parameters[pagingParameter.name] = currentValue + pageStep; + } + + Future _refresh() async { + setState(() { + _sections.clear(); + _isFirstLoad = true; + _isLoadingMore = false; + _hasMore = true; + _loadError = null; + final module = _module; + if (module != null) { + _parameters = buildScriptWidgetParameterDefaults(module.params); + for (final entry in widget.initialParams.entries) { + if (_parameters.containsKey(entry.key)) { + _parameters[entry.key] = entry.value; + } + } + } + }); + await _fetchNextPage(); + } + + @override + Widget build(BuildContext context) { + final installedWidget = ref.watch( + installedScriptWidgetProvider(widget.widgetId), + ); + return Scaffold( + backgroundColor: Colors.transparent, + extendBodyBehindAppBar: true, + body: Stack( + children: [ + const Positioned.fill(child: PageBackground()), + installedWidget.when( + loading: () => const Center(child: AppLoadingRing()), + error: (error, _) => Center( + child: AppErrorState( + message: formatUserError(error), + onRetry: () => ref.invalidate( + installedScriptWidgetProvider(widget.widgetId), + ), + ), + ), + data: (widgetData) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) unawaited(_initialize(widgetData)); + }); + return _buildContent(widgetData); + }, + ), + ], + ), + ); + } + + Widget _buildContent(InstalledScriptWidget installedWidget) { + final moduleTitle = _module?.title.trim(); + final title = moduleTitle == null || moduleTitle.isEmpty + ? installedWidget.manifest.title + : moduleTitle; + return RefreshIndicator( + onRefresh: _refresh, + child: CustomScrollView( + controller: _scrollController, + physics: const AlwaysScrollableScrollPhysics( + parent: BouncingScrollPhysics(), + ), + slivers: [ + AppSliverHeader(title: title), + ..._buildBodySlivers(), + ], + ), + ); + } + + List _buildBodySlivers() { + if (_initializationError case final initializationError?) { + return [ + SliverFillRemaining( + hasScrollBody: false, + child: AppErrorState(message: initializationError), + ), + ]; + } + if (_module == null || (_isFirstLoad && _isLoadingMore)) { + return const [ + SliverFillRemaining( + hasScrollBody: false, + child: Center(child: AppLoadingRing()), + ), + ]; + } + final firstLoadError = _isFirstLoad ? _loadError : null; + if (firstLoadError != null) { + return [ + SliverFillRemaining( + hasScrollBody: false, + child: AppErrorState( + message: firstLoadError, + onRetry: _fetchNextPage, + ), + ), + ]; + } + if (_sections.every((section) => section.items.isEmpty)) { + return const [ + SliverFillRemaining( + hasScrollBody: false, + child: EmptyState( + compact: true, + icon: Icons.video_library_outlined, + title: '没有可显示的内容', + ), + ), + ]; + } + + return [ + SliverPadding( + padding: const EdgeInsets.fromLTRB(20, 14, 20, 0), + sliver: SliverList( + delegate: SliverChildListDelegate.fixed([ + for (final section in _sections) ...[ + _ModuleGridSection( + section: section, + widgetId: widget.widgetId, + ), + const SizedBox(height: 28), + ], + ]), + ), + ), + SliverToBoxAdapter(child: _buildFooter()), + ]; + } + + Widget _buildFooter() { + if (_isLoadingMore) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 24), + child: Center(child: AppLoadingRing()), + ); + } + if (_loadError case final loadError?) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 20), + child: AppErrorState( + compact: true, + message: loadError, + onRetry: _fetchNextPage, + ), + ); + } + if (!_hasMore) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 24), + child: Center( + child: Text( + '没有更多了', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: context.appTokens.mutedForeground, + ), + ), + ), + ); + } + return const SizedBox(height: 40); + } +} + +class _ModuleGridSection extends StatelessWidget { + final ScriptWidgetContentSection section; + final String widgetId; + + const _ModuleGridSection({required this.section, required this.widgetId}); + + @override + Widget build(BuildContext context) { + final statusItems = section.items + .where((item) => item.type.toLowerCase() == 'text') + .toList(growable: false); + final contentItems = section.items + .where((item) => item.type.toLowerCase() != 'text') + .toList(growable: false); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SectionHeader(label: section.title, count: contentItems.length), + for (final statusItem in statusItems) ...[ + _ModuleStatusCard(item: statusItem), + const SizedBox(height: 12), + ], + if (contentItems.isNotEmpty) + GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: contentItems.length, + gridDelegate: mediaGridDelegate, + itemBuilder: (context, index) { + final item = contentItems[index]; + return ScriptVideoCard( + item: item, + width: double.infinity, + onTap: () => context.push( + '/modules/${Uri.encodeComponent(widgetId)}/detail', + extra: item, + ), + ); + }, + ), + ], + ); + } +} + +class _ModuleStatusCard extends StatelessWidget { + final ScriptVideoItem item; + + const _ModuleStatusCard({required this.item}); + + @override + Widget build(BuildContext context) { + final normalizedId = item.id.toLowerCase(); + final indicatesError = + normalizedId.startsWith('err') || + item.title.contains('失败') || + item.title.contains('错误') || + item.title.contains('拦截'); + final foregroundColor = indicatesError + ? Theme.of(context).colorScheme.error + : context.appTokens.mutedForeground; + + return AppCard( + padding: const EdgeInsets.all(16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + indicatesError + ? Icons.error_outline_rounded + : Icons.info_outline_rounded, + color: foregroundColor, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.title.isEmpty ? '模块提示' : item.title, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: foregroundColor, + fontWeight: FontWeight.w700, + ), + ), + if (item.description.trim().isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + item.description, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: context.appTokens.mutedForeground, + ), + ), + ], + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/modules/module_browse_page.dart b/lib/features/modules/module_browse_page.dart new file mode 100644 index 0000000..fb5e0d2 --- /dev/null +++ b/lib/features/modules/module_browse_page.dart @@ -0,0 +1,961 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:forui/forui.dart'; +import 'package:go_router/go_router.dart'; + +import '../../core/contracts/script_widget.dart'; +import '../../core/domain/usecases/script_widget/script_widget_service.dart'; +import '../../providers/di_providers.dart'; +import '../../shared/constants/layout_constants.dart'; +import '../../shared/theme/app_theme.dart'; +import '../../shared/utils/user_error_formatter.dart'; +import '../../shared/widgets/app_card.dart'; +import '../../shared/widgets/app_error_state.dart'; +import '../../shared/widgets/app_loading_ring.dart'; +import '../../shared/widgets/app_sliver_header.dart'; +import '../../shared/widgets/empty_state.dart'; +import '../../shared/widgets/error_media_row.dart'; +import '../../shared/widgets/hover_scrollable_row.dart'; +import '../../shared/widgets/media_poster_card.dart'; +import '../../shared/widgets/media_row_metrics.dart'; +import '../../shared/widgets/page_background.dart'; +import '../../shared/widgets/script_video_card.dart'; +import '../../shared/widgets/section_header.dart'; +import '../../shared/widgets/skeleton_media_row.dart'; +import 'script_widget_parameter_form.dart'; + +class ModuleBrowsePage extends ConsumerStatefulWidget { + final String widgetId; + + const ModuleBrowsePage({super.key, required this.widgetId}); + + @override + ConsumerState createState() => _ModuleBrowsePageState(); +} + +class _ModuleBrowsePageState extends ConsumerState { + final TextEditingController _searchController = TextEditingController(); + final Map> _moduleParameters = + >{}; + + InstalledScriptWidget? _installedWidget; + List _searchSections = + const []; + bool _searchMode = false; + bool _filtersExpanded = false; + bool _loadingSearch = false; + String? _searchError; + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + List _contentModules(ScriptWidgetManifest manifest) { + return manifest.modules + .where( + (module) => + module.type != 'danmu' && + module.type != 'stream' && + module.id != 'loadResource', + ) + .toList(growable: false); + } + + Map _parametersFor(ScriptWidgetModule module) { + return _moduleParameters.putIfAbsent( + _moduleKey(module), + () => buildScriptWidgetParameterDefaults(module.params), + ); + } + + List _searchParameters(ScriptWidgetModule? searchModule) { + if (searchModule == null) return const []; + return searchModule.params + .where((parameter) { + final normalizedName = parameter.name.toLowerCase(); + final isKeywordParameter = const { + 'keyword', + 'query', + 'search', + 'title', + }.contains(normalizedName); + final isInternalParameter = const { + 'constant', + 'page', + 'offset', + }.contains(parameter.type); + return !isKeywordParameter && !isInternalParameter; + }) + .toList(growable: false); + } + + Future _search() async { + final searchModule = _installedWidget?.manifest.search; + if (searchModule == null) return; + final keyword = _searchController.text.trim(); + if (keyword.isEmpty) return; + + final searchParameters = { + ...buildScriptWidgetParameterDefaults(searchModule.params), + ..._parametersFor(searchModule), + }; + final keywordParameter = searchModule.params + .where( + (parameter) => const { + 'keyword', + 'query', + 'search', + 'title', + }.contains(parameter.name.toLowerCase()), + ) + .firstOrNull; + searchParameters[keywordParameter?.name ?? 'keyword'] = keyword; + + setState(() { + _loadingSearch = true; + _searchError = null; + }); + try { + final service = await ref.read(scriptWidgetServiceProvider.future); + final items = await service.search(widget.widgetId, searchParameters); + if (!mounted) return; + setState(() { + _searchSections = [ + ScriptWidgetContentSection(title: '搜索结果', items: items), + ]; + }); + } catch (error) { + if (!mounted) return; + setState(() { + _searchError = formatUserError(error); + _searchSections = const []; + }); + } finally { + if (mounted) { + setState(() => _loadingSearch = false); + } + } + } + + ScriptWidgetParam? get _searchPagingParameter { + final searchModule = _installedWidget?.manifest.search; + return searchModule?.params + .where( + (parameter) => parameter.type == 'page' || parameter.type == 'offset', + ) + .firstOrNull; + } + + Future _changeSearchPage(int direction) async { + final searchModule = _installedWidget?.manifest.search; + final pagingParameter = _searchPagingParameter; + if (searchModule == null || pagingParameter == null) return; + + final parameters = _parametersFor(searchModule); + final minimumValue = pagingParameter.type == 'page' ? 1 : 0; + final currentValue = + int.tryParse(parameters[pagingParameter.name]?.toString() ?? '') ?? + minimumValue; + final countParameter = searchModule.params + .where((parameter) => parameter.type == 'count') + .firstOrNull; + final pageStep = pagingParameter.type == 'offset' + ? int.tryParse(parameters[countParameter?.name]?.toString() ?? '') ?? 20 + : 1; + parameters[pagingParameter.name] = (currentValue + direction * pageStep) + .clamp(minimumValue, 1 << 31); + await _search(); + } + + bool get _canGoBackSearchPage { + final searchModule = _installedWidget?.manifest.search; + final pagingParameter = _searchPagingParameter; + if (searchModule == null || pagingParameter == null) return false; + final minimumValue = pagingParameter.type == 'page' ? 1 : 0; + final currentValue = + int.tryParse( + _parametersFor(searchModule)[pagingParameter.name]?.toString() ?? '', + ) ?? + minimumValue; + return currentValue > minimumValue; + } + + String get _currentSearchPageLabel { + final searchModule = _installedWidget?.manifest.search; + final pagingParameter = _searchPagingParameter; + if (searchModule == null || pagingParameter == null) return '第 1 页'; + final parameters = _parametersFor(searchModule); + final minimumValue = pagingParameter.type == 'page' ? 1 : 0; + final currentValue = + int.tryParse(parameters[pagingParameter.name]?.toString() ?? '') ?? + minimumValue; + if (pagingParameter.type == 'page') return '第 $currentValue 页'; + + final countParameter = searchModule.params + .where((parameter) => parameter.type == 'count') + .firstOrNull; + final pageSize = + int.tryParse(parameters[countParameter?.name]?.toString() ?? '') ?? 20; + return '第 ${(currentValue ~/ pageSize) + 1} 页'; + } + + @override + Widget build(BuildContext context) { + final installedWidget = ref.watch( + installedScriptWidgetProvider(widget.widgetId), + ); + return Scaffold( + backgroundColor: Colors.transparent, + extendBodyBehindAppBar: true, + body: Stack( + children: [ + const Positioned.fill(child: PageBackground()), + installedWidget.when( + loading: () => const Center(child: AppLoadingRing()), + error: (error, _) => Center( + child: AppErrorState( + message: formatUserError(error), + onRetry: () => ref.invalidate( + installedScriptWidgetProvider(widget.widgetId), + ), + ), + ), + data: (widgetData) { + _installedWidget = widgetData; + return _buildContent(widgetData); + }, + ), + ], + ), + ); + } + + Widget _buildContent(InstalledScriptWidget installedWidget) { + final manifest = installedWidget.manifest; + final contentModules = _contentModules(manifest); + final searchParameters = _searchParameters(manifest.search); + return CustomScrollView( + physics: const AlwaysScrollableScrollPhysics( + parent: BouncingScrollPhysics(), + ), + slivers: [ + AppSliverHeader( + title: manifest.title, + bottom: _searchMode + ? PreferredSize( + preferredSize: const Size.fromHeight(60), + child: _SearchControlBar( + searchController: _searchController, + searchHint: manifest.search?.description, + filtersExpanded: _filtersExpanded, + hasFilters: searchParameters.isNotEmpty, + onSearch: _search, + onToggleFilters: () { + setState(() => _filtersExpanded = !_filtersExpanded); + }, + ), + ) + : null, + actions: [ + if (manifest.search != null) + IconButton( + onPressed: () { + setState(() { + _searchMode = !_searchMode; + _filtersExpanded = false; + }); + }, + tooltip: _searchMode ? '关闭搜索' : '搜索', + icon: Icon( + _searchMode ? Icons.close_rounded : Icons.search_rounded, + ), + ), + ], + ), + if (manifest.description.trim().isNotEmpty) + SliverPadding( + padding: const EdgeInsets.fromLTRB(20, 14, 20, 6), + sliver: SliverToBoxAdapter( + child: _ModuleIdentityCard(installedWidget: installedWidget), + ), + ), + if (_searchMode) + SliverPadding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 56), + sliver: SliverList( + delegate: SliverChildListDelegate.fixed([ + _buildSearchFilterPanel(manifest), + if (_filtersExpanded && searchParameters.isNotEmpty) + const SizedBox(height: 18), + _buildSearchResults(), + ]), + ), + ) + else if (contentModules.isEmpty) + const SliverFillRemaining( + hasScrollBody: false, + child: EmptyState( + icon: Icons.video_library_outlined, + title: '没有内容功能', + message: '该模块没有可浏览的内容功能。', + ), + ) + else + SliverList( + delegate: SliverChildListDelegate.fixed([ + for (final module in contentModules) + _ModulePreviewSection( + widgetId: widget.widgetId, + module: module, + ), + const SizedBox(height: 56), + ]), + ), + ], + ); + } + + Widget _buildSearchFilterPanel(ScriptWidgetManifest manifest) { + final searchModule = manifest.search; + final parameters = _searchParameters(searchModule); + return AnimatedSize( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOutCubic, + child: !_filtersExpanded || searchModule == null || parameters.isEmpty + ? const SizedBox.shrink() + : AppCard( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (searchModule.description.trim().isNotEmpty) ...[ + Text( + searchModule.description, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: context.appTokens.mutedForeground, + ), + ), + const SizedBox(height: 14), + ], + ScriptWidgetParameterForm( + parameters: parameters, + initialValues: _parametersFor(searchModule), + onChanged: (values) { + _parametersFor(searchModule).addAll(values); + }, + ), + const SizedBox(height: 14), + Align( + alignment: Alignment.centerRight, + child: FButton( + onPress: _search, + child: const Text('应用并搜索'), + ), + ), + ], + ), + ), + ); + } + + Widget _buildSearchResults() { + if (_loadingSearch) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 64), + child: Center(child: AppLoadingRing()), + ); + } + if (_searchError case final searchError?) { + return AppErrorState( + compact: true, + message: searchError, + onRetry: _search, + ); + } + if (_searchSections.every((section) => section.items.isEmpty)) { + return const EmptyState( + compact: true, + icon: Icons.search_rounded, + title: '输入关键词开始搜索', + message: '搜索结果会显示在这里。', + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final section in _searchSections) ...[ + _ModuleSearchSection(section: section, widgetId: widget.widgetId), + const SizedBox(height: 28), + ], + if (_searchPagingParameter != null) + _PaginationBar( + canGoBack: _canGoBackSearchPage, + pageLabel: _currentSearchPageLabel, + onPrevious: () => unawaited(_changeSearchPage(-1)), + onNext: () => unawaited(_changeSearchPage(1)), + ), + ], + ); + } +} + +String _moduleKey(ScriptWidgetModule module) { + return module.id.isEmpty ? module.functionName : module.id; +} + +class _ModulePreviewSection extends ConsumerWidget { + final String widgetId; + final ScriptWidgetModule module; + + const _ModulePreviewSection({required this.widgetId, required this.module}); + + + ScriptWidgetParam? get _primaryEnumerationParam { + return module.params + .where( + (parameter) => + parameter.type == 'enumeration' && + parameter.enumOptions.length >= 2, + ) + .firstOrNull; + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final previewKey = (widgetId: widgetId, moduleKey: _moduleKey(module)); + final sections = ref.watch(scriptWidgetModulePreviewProvider(previewKey)); + final enumerationParam = _primaryEnumerationParam; + final categoryRow = enumerationParam == null + ? const SizedBox.shrink() + : _ModuleCategoryRow( + widgetId: widgetId, + moduleKey: _moduleKey(module), + moduleTitle: module.title, + parameter: enumerationParam, + ); + return sections.when( + loading: () => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [categoryRow, SkeletonMediaRow(title: module.title)], + ), + error: (error, _) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + categoryRow, + ErrorMediaRow( + title: module.title, + message: formatUserError(error), + onRetry: () => + ref.invalidate(scriptWidgetModulePreviewProvider(previewKey)), + ), + ], + ), + data: (loadedSections) { + final visibleSections = loadedSections + .where((section) => section.items.isNotEmpty) + .toList(growable: false); + if (visibleSections.isEmpty && enumerationParam == null) { + return const SizedBox.shrink(); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + categoryRow, + for (final section in visibleSections) + _ModulePreviewRow( + widgetId: widgetId, + moduleKey: _moduleKey(module), + section: section, + ), + ], + ); + }, + ); + } +} + + +class _ModuleCategoryRow extends StatelessWidget { + final String widgetId; + final String moduleKey; + final String moduleTitle; + final ScriptWidgetParam parameter; + + const _ModuleCategoryRow({ + required this.widgetId, + required this.moduleKey, + required this.moduleTitle, + required this.parameter, + }); + + static const double _cardHeight = 74; + static const double _cardWidth = 168; + + @override + Widget build(BuildContext context) { + final options = parameter.enumOptions; + final headerLabel = parameter.title.trim().isEmpty + ? '$moduleTitle · 类型' + : '$moduleTitle · ${parameter.title.trim()}'; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(24, 12, 24, 4), + child: SectionHeader(label: headerLabel, count: options.length), + ), + SizedBox( + height: _cardHeight, + child: HoverScrollableRow( + builder: (_, scrollController) => ListView.separated( + controller: scrollController, + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 24), + itemCount: options.length, + separatorBuilder: (_, _) => const SizedBox(width: 12), + itemBuilder: (context, index) { + final option = options[index]; + return _CategoryCard( + label: option.title.isEmpty ? option.value : option.title, + width: _cardWidth, + onTap: () => _openCategory(context, option), + ); + }, + ), + ), + ), + ], + ); + } + + void _openCategory(BuildContext context, ScriptWidgetOption option) { + final targetUri = Uri( + path: + '/modules/${Uri.encodeComponent(widgetId)}/module/' + '${Uri.encodeComponent(moduleKey)}', + queryParameters: {parameter.name: option.value}, + ); + context.push(targetUri.toString()); + } +} + +class _CategoryCard extends StatefulWidget { + final String label; + final double width; + final VoidCallback onTap; + + const _CategoryCard({ + required this.label, + required this.width, + required this.onTap, + }); + + @override + State<_CategoryCard> createState() => _CategoryCardState(); +} + +class _CategoryCardState extends State<_CategoryCard> { + bool _isHovered = false; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final tokens = context.appTokens; + return SizedBox( + width: widget.width, + child: AppCard( + filled: true, + enableHover: false, + radius: tokens.radiusCard, + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + onTap: widget.onTap, + onHoverChanged: (isHovered) { + if (_isHovered == isHovered) return; + setState(() => _isHovered = isHovered); + }, + child: Row( + children: [ + Icon( + Icons.style_outlined, + size: 20, + color: theme.colorScheme.primary.withValues( + alpha: _isHovered ? 0.9 : 0.65, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + widget.label, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + ); + } +} + +class _ModulePreviewRow extends StatelessWidget { + final String widgetId; + final String moduleKey; + final ScriptWidgetContentSection section; + + const _ModulePreviewRow({ + required this.widgetId, + required this.moduleKey, + required this.section, + }); + + @override + Widget build(BuildContext context) { + final statusItems = section.items + .where((item) => item.type.toLowerCase() == 'text') + .toList(growable: false); + final contentItems = section.items + .where((item) => item.type.toLowerCase() != 'text') + .take(20) + .toList(growable: false); + final compact = MediaRowMetrics.isCompact(context); + final cardWidth = MediaRowMetrics.cardWidth( + MediaCardVariant.poster, + compact: compact, + ); + final rowHeight = MediaRowMetrics.rowHeight( + MediaCardVariant.poster, + compact: compact, + ); + final allRoute = + '/modules/${Uri.encodeComponent(widgetId)}/module/' + '${Uri.encodeComponent(moduleKey)}'; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (contentItems.isNotEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(24, 12, 24, 4), + child: SectionHeader( + label: section.title, + count: contentItems.length, + trailing: TextButton( + onPressed: () => context.push(allRoute), + child: const Text('查看全部'), + ), + ), + ), + for (final statusItem in statusItems) + Padding( + padding: const EdgeInsets.fromLTRB(24, 4, 24, 12), + child: _ModuleStatusCard(item: statusItem), + ), + if (contentItems.isNotEmpty) + SizedBox( + height: rowHeight, + child: HoverScrollableRow( + builder: (_, scrollController) => ListView.separated( + controller: scrollController, + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 24), + itemCount: contentItems.length, + separatorBuilder: (_, _) => const SizedBox(width: 14), + itemBuilder: (context, index) { + final item = contentItems[index]; + return ScriptVideoCard( + item: item, + width: cardWidth, + onTap: () => context.push( + '/modules/${Uri.encodeComponent(widgetId)}/detail', + extra: item, + ), + ); + }, + ), + ), + ), + ], + ); + } +} + +class _ModuleSearchSection extends StatelessWidget { + final ScriptWidgetContentSection section; + final String widgetId; + + const _ModuleSearchSection({required this.section, required this.widgetId}); + + @override + Widget build(BuildContext context) { + final statusItems = section.items + .where((item) => item.type.toLowerCase() == 'text') + .toList(growable: false); + final contentItems = section.items + .where((item) => item.type.toLowerCase() != 'text') + .toList(growable: false); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SectionHeader(label: section.title, count: contentItems.length), + for (final statusItem in statusItems) ...[ + _ModuleStatusCard(item: statusItem), + const SizedBox(height: 12), + ], + if (contentItems.isNotEmpty) + GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: contentItems.length, + gridDelegate: mediaGridDelegate, + itemBuilder: (context, index) { + final item = contentItems[index]; + return ScriptVideoCard( + item: item, + width: double.infinity, + onTap: () => context.push( + '/modules/${Uri.encodeComponent(widgetId)}/detail', + extra: item, + ), + ); + }, + ), + ], + ); + } +} + +class _ModuleIdentityCard extends StatelessWidget { + final InstalledScriptWidget installedWidget; + + const _ModuleIdentityCard({required this.installedWidget}); + + @override + Widget build(BuildContext context) { + final manifest = installedWidget.manifest; + return AppCard( + padding: const EdgeInsets.all(16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(14), + ), + child: Icon( + Icons.extension_rounded, + color: Theme.of(context).colorScheme.primary, + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + manifest.description, + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 6), + Text( + [ + if (manifest.author.isNotEmpty) manifest.author, + 'v${manifest.version}', + ].join(' · '), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: context.appTokens.mutedForeground, + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +class _ModuleStatusCard extends StatelessWidget { + final ScriptVideoItem item; + + const _ModuleStatusCard({required this.item}); + + @override + Widget build(BuildContext context) { + final normalizedId = item.id.toLowerCase(); + final indicatesError = + normalizedId.startsWith('err') || + item.title.contains('失败') || + item.title.contains('错误') || + item.title.contains('拦截'); + final foregroundColor = indicatesError + ? Theme.of(context).colorScheme.error + : context.appTokens.mutedForeground; + return AppCard( + padding: const EdgeInsets.all(16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + indicatesError + ? Icons.error_outline_rounded + : Icons.info_outline_rounded, + color: foregroundColor, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.title.isEmpty ? '模块提示' : item.title, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: foregroundColor, + fontWeight: FontWeight.w700, + ), + ), + if (item.description.trim().isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + item.description, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: context.appTokens.mutedForeground, + ), + ), + ], + ], + ), + ), + ], + ), + ); + } +} + +class _SearchControlBar extends StatelessWidget { + final TextEditingController searchController; + final String? searchHint; + final bool filtersExpanded; + final bool hasFilters; + final VoidCallback onSearch; + final VoidCallback onToggleFilters; + + const _SearchControlBar({ + required this.searchController, + required this.searchHint, + required this.filtersExpanded, + required this.hasFilters, + required this.onSearch, + required this.onToggleFilters, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final tokens = context.appTokens; + return SizedBox( + height: 60, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 6, 12, 8), + child: Row( + children: [ + Expanded( + child: TextField( + controller: searchController, + autofocus: true, + textInputAction: TextInputAction.search, + decoration: InputDecoration( + hintText: searchHint?.trim().isNotEmpty == true + ? searchHint + : '输入关键词', + prefixIcon: const Icon(Icons.search_rounded, size: 19), + filled: true, + fillColor: theme.colorScheme.onSurface.withValues( + alpha: 0.05, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + isDense: true, + ), + onSubmitted: (_) => onSearch(), + ), + ), + const SizedBox(width: 8), + IconButton( + onPressed: onSearch, + icon: const Icon(Icons.arrow_forward_rounded), + tooltip: '搜索', + ), + if (hasFilters) + IconButton( + onPressed: onToggleFilters, + icon: Icon( + Icons.tune_rounded, + color: filtersExpanded + ? theme.colorScheme.primary + : tokens.mutedForeground, + ), + tooltip: filtersExpanded ? '收起筛选' : '展开筛选', + ), + ], + ), + ), + ); + } +} + +class _PaginationBar extends StatelessWidget { + final bool canGoBack; + final String pageLabel; + final VoidCallback onPrevious; + final VoidCallback onNext; + + const _PaginationBar({ + required this.canGoBack, + required this.pageLabel, + required this.onPrevious, + required this.onNext, + }); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + FButton( + variant: FButtonVariant.outline, + onPress: canGoBack ? onPrevious : null, + child: const Text('上一页'), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 14), + child: Text( + pageLabel, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: context.appTokens.mutedForeground, + ), + ), + ), + FButton(onPress: onNext, child: const Text('下一页')), + ], + ); + } +} diff --git a/lib/features/modules/module_detail_page.dart b/lib/features/modules/module_detail_page.dart new file mode 100644 index 0000000..5cf0f16 --- /dev/null +++ b/lib/features/modules/module_detail_page.dart @@ -0,0 +1,518 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:forui/forui.dart'; +import 'package:go_router/go_router.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../../core/contracts/script_widget.dart'; +import '../../providers/di_providers.dart'; +import '../../shared/theme/app_theme.dart'; +import '../../shared/utils/user_error_formatter.dart'; +import '../../shared/widgets/app_dialog.dart'; +import '../../shared/widgets/app_error_state.dart'; +import '../../shared/widgets/app_inline_alert.dart'; +import '../../shared/widgets/app_loading_ring.dart'; +import '../../shared/widgets/app_snack_bar.dart'; +import '../../shared/widgets/empty_state.dart'; +import '../../shared/widgets/hover_scrollable_row.dart'; +import '../../shared/widgets/script_video_card.dart'; +import '../detail/widgets/detail_hero_panel.dart'; +import '../detail/widgets/detail_immersive_scaffold.dart'; +import '../detail/widgets/detail_nav_bar.dart'; +import '../player/player_launcher.dart'; + +const _moduleDetailChromeColor = Color(0xFF08090C); + +class ModuleDetailPage extends ConsumerStatefulWidget { + final String widgetId; + final ScriptVideoItem seedItem; + + const ModuleDetailPage({ + super.key, + required this.widgetId, + required this.seedItem, + }); + + @override + ConsumerState createState() => _ModuleDetailPageState(); +} + +class _ModuleDetailPageState extends ConsumerState { + final ScrollController _scrollController = ScrollController(); + final ValueNotifier _scrollProgress = ValueNotifier(0); + + late ScriptVideoItem _item; + bool _loadingDetail = false; + bool _loadingResource = false; + String? _detailError; + + @override + void initState() { + super.initState(); + _item = widget.seedItem; + _scrollController.addListener(_updateScrollProgress); + unawaited(_loadDetail()); + } + + @override + void dispose() { + _scrollController.removeListener(_updateScrollProgress); + _scrollController.dispose(); + _scrollProgress.dispose(); + super.dispose(); + } + + void _updateScrollProgress() { + _scrollProgress.value = (_scrollController.offset / 180).clamp(0, 1); + } + + Future _loadDetail() async { + final link = widget.seedItem.link.trim(); + if (link.isEmpty) return; + setState(() { + _loadingDetail = true; + _detailError = null; + }); + try { + final service = await ref.read(scriptWidgetServiceProvider.future); + final loadedItem = await service.loadDetail(widget.widgetId, link); + if (!mounted || loadedItem == null) return; + setState(() { + _item = _mergeSeedWithDetail(widget.seedItem, loadedItem); + }); + } catch (error) { + if (!mounted) return; + setState(() { + _detailError = formatUserError(error); + }); + } finally { + if (mounted) { + setState(() { + _loadingDetail = false; + }); + } + } + } + + Future _playItem(ScriptVideoItem playableItem) async { + if (_loadingResource) return; + setState(() { + _loadingResource = true; + }); + try { + final resources = await _resolveResources(playableItem); + if (!mounted) return; + if (resources.isEmpty) { + showAppToast(context, '模块没有返回可播放地址', tone: AppAlertTone.error); + return; + } + + final selectedResource = resources.length == 1 + ? resources.first + : await showAppRawDialog( + context, + builder: (_) => _ResourcePickerDialog(resources: resources), + ); + if (selectedResource == null || !mounted) return; + await _launchResource(playableItem, selectedResource); + } catch (error) { + if (mounted) { + showAppToast(context, formatUserError(error), tone: AppAlertTone.error); + } + } finally { + if (mounted) { + setState(() { + _loadingResource = false; + }); + } + } + } + + Future> _resolveResources( + ScriptVideoItem playableItem, + ) async { + final service = await ref.read(scriptWidgetServiceProvider.future); + final itemJson = playableItem.toJson(); + final resources = await service + .loadResources(widget.widgetId, { + ...itemJson, + 'item': itemJson, + 'link': playableItem.link, + 'url': playableItem.videoUrl, + 'videoUrl': playableItem.videoUrl, + }); + if (resources.isNotEmpty) return resources; + final videoUrl = playableItem.videoUrl.trim(); + if (videoUrl.isEmpty) return const []; + return [ + ScriptVideoResource( + name: playableItem.title, + url: videoUrl, + playerType: playableItem.playerType, + ), + ]; + } + + Future _launchResource( + ScriptVideoItem playableItem, + ScriptVideoResource resource, + ) async { + final playerType = resource.playerType.trim().toLowerCase(); + final shouldUseInternalPlayer = + playerType.isEmpty || playerType == 'app' || playerType == 'internal'; + if (!shouldUseInternalPlayer) { + final launched = await launchUrl( + Uri.parse(resource.url), + mode: LaunchMode.externalApplication, + ); + if (!launched && mounted) { + showAppToast(context, '无法打开外部播放器', tone: AppAlertTone.error); + } + return; + } + + final itemIdentity = base64Url + .encode( + utf8.encode( + '${widget.widgetId}:${playableItem.id}:${playableItem.link}', + ), + ) + .replaceAll('=', ''); + await PlayerLauncher.launch( + context: context, + itemId: 'script-$itemIdentity', + directStream: DirectStreamLaunchData( + url: resource.url, + headers: resource.customHeaders, + title: playableItem.title.isEmpty ? _item.title : playableItem.title, + durationSeconds: playableItem.duration, + seriesName: playableItem == _item ? null : _item.title, + episode: playableItem.episode, + ), + ); + } + + @override + Widget build(BuildContext context) { + final backdropUrl = _firstNonEmpty([ + _item.backdropPath, + ..._item.backdropPaths, + _item.detailPoster, + _item.posterPath, + _item.coverUrl, + ]); + final posterUrl = _firstNonEmpty([ + _item.detailPoster, + _item.posterPath, + _item.coverUrl, + ]); + + return Scaffold( + backgroundColor: _moduleDetailChromeColor, + body: Stack( + children: [ + DetailImmersiveScaffold( + scrollController: _scrollController, + scrollProgress: _scrollProgress, + backdropUrl: backdropUrl, + fallbackUrls: _item.backdropPaths, + compact: MediaQuery.sizeOf(context).width < 700, + horizontalPadding: MediaQuery.sizeOf(context).width < 700 ? 18 : 32, + child: _buildDetailContent(posterUrl), + ), + DetailNavBar( + scrollProgress: _scrollProgress, + title: _item.title, + onBack: () => context.pop(), + ), + ], + ), + ); + } + + Widget _buildDetailContent(String? posterUrl) { + final metaParts = [ + if (_item.releaseDate.isNotEmpty) _item.releaseDate, + if (_item.mediaType.isNotEmpty) _item.mediaType, + if (_item.durationText.isNotEmpty) _item.durationText, + ]; + final genres = _item.genreItems + .map((genre) => genre.title) + .where((title) => title.trim().isNotEmpty) + .toList(); + if (_item.genreTitle.trim().isNotEmpty) genres.add(_item.genreTitle); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DetailHeroPanel( + title: _item.title.isEmpty ? _item.id : _item.title, + posterUrl: posterUrl, + rating: _item.rating, + metaParts: metaParts, + genres: genres, + overview: _item.description, + actions: _buildPlayActions(), + ), + if (_loadingDetail) + const Padding( + padding: EdgeInsets.only(top: 28), + child: Center(child: AppLoadingRing(size: 28)), + ), + if (_detailError != null) ...[ + const SizedBox(height: 24), + AppErrorState( + compact: true, + message: _detailError!, + onRetry: _loadDetail, + ), + ], + if (_item.episodeItems.isNotEmpty || _item.childItems.isNotEmpty) ...[ + const SizedBox(height: 36), + _EpisodeSection( + items: _item.episodeItems.isNotEmpty + ? _item.episodeItems + : _item.childItems, + onPlay: _playItem, + ), + ], + if (_item.relatedItems.isNotEmpty) ...[ + const SizedBox(height: 36), + _RelatedSection(widgetId: widget.widgetId, items: _item.relatedItems), + ], + if (!_loadingDetail && + _item.episodeItems.isEmpty && + _item.childItems.isEmpty && + _item.relatedItems.isEmpty && + _item.description.trim().isEmpty) ...[ + const SizedBox(height: 32), + const EmptyState( + compact: true, + icon: Icons.info_outline_rounded, + title: '暂无更多详情', + message: '仍可尝试解析并播放该条目。', + ), + ], + ], + ); + } + + Widget _buildPlayActions() { + return FButton( + onPress: _loadingResource ? null : () => _playItem(_item), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (_loadingResource) + const AppLoadingRing(size: 17) + else + const Icon(Icons.play_arrow_rounded, size: 20), + const SizedBox(width: 7), + Text(_loadingResource ? '正在解析' : '播放'), + ], + ), + ); + } +} + +class _ResourcePickerDialog extends StatelessWidget { + final List resources; + + const _ResourcePickerDialog({required this.resources}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('选择播放线路', style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 8), + Text( + '模块返回了 ${resources.length} 条可播放线路。', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: context.appTokens.mutedForeground, + ), + ), + const SizedBox(height: 16), + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 420), + child: ListView.separated( + shrinkWrap: true, + itemCount: resources.length, + separatorBuilder: (_, _) => const Divider(height: 1), + itemBuilder: (context, index) { + final resource = resources[index]; + return FItemGroup( + children: [ + FItem( + prefix: Container( + width: 30, + height: 30, + alignment: Alignment.center, + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + '${index + 1}', + style: Theme.of(context).textTheme.labelMedium + ?.copyWith( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.w700, + ), + ), + ), + title: Text( + resource.name.isEmpty + ? '线路 ${index + 1}' + : resource.name, + ), + subtitle: Text( + resource.description.trim().isEmpty + ? resource.playerType == 'app' + ? '应用内播放' + : '外部播放' + : resource.description, + ), + suffix: const Icon(Icons.chevron_right_rounded), + onPress: () => Navigator.of(context).pop(resource), + ), + ], + ); + }, + ), + ), + const SizedBox(height: 16), + Align( + alignment: Alignment.centerRight, + child: FButton( + variant: FButtonVariant.outline, + onPress: () => Navigator.of(context).pop(), + child: const Text('取消'), + ), + ), + ], + ), + ); + } +} + +class _EpisodeSection extends StatelessWidget { + final List items; + final ValueChanged onPlay; + + const _EpisodeSection({required this.items, required this.onPlay}); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '选集', + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(height: 12), + Wrap( + spacing: 10, + runSpacing: 10, + children: [ + for (var index = 0; index < items.length; index++) + FButton( + variant: FButtonVariant.outline, + onPress: () => onPlay(items[index]), + child: Text( + items[index].title.isNotEmpty + ? items[index].title + : '第 ${items[index].episode ?? index + 1} 集', + ), + ), + ], + ), + ], + ); + } +} + +class _RelatedSection extends StatelessWidget { + final String widgetId; + final List items; + + const _RelatedSection({required this.widgetId, required this.items}); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '相关推荐', + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(height: 12), + SizedBox( + height: 250, + child: HoverScrollableRow( + builder: (context, scrollController) => ListView.separated( + controller: scrollController, + scrollDirection: Axis.horizontal, + itemCount: items.length, + separatorBuilder: (_, _) => const SizedBox(width: 14), + itemBuilder: (context, index) { + final item = items[index]; + return ScriptVideoCard( + item: item, + width: 138, + textColor: Colors.white, + onTap: () => context.push( + '/modules/${Uri.encodeComponent(widgetId)}/detail', + extra: item, + ), + ); + }, + ), + ), + ), + ], + ); + } +} + +ScriptVideoItem _mergeSeedWithDetail( + ScriptVideoItem seed, + ScriptVideoItem detail, +) { + return detail.copyWith( + id: detail.id.isEmpty ? seed.id : detail.id, + title: detail.title.isEmpty ? seed.title : detail.title, + coverUrl: detail.coverUrl.isEmpty ? seed.coverUrl : detail.coverUrl, + posterPath: detail.posterPath.isEmpty ? seed.posterPath : detail.posterPath, + detailPoster: detail.detailPoster.isEmpty + ? seed.detailPoster + : detail.detailPoster, + backdropPath: detail.backdropPath.isEmpty + ? seed.backdropPath + : detail.backdropPath, + link: detail.link.isEmpty ? seed.link : detail.link, + videoUrl: detail.videoUrl.isEmpty ? seed.videoUrl : detail.videoUrl, + ); +} + +String? _firstNonEmpty(List values) { + for (final value in values) { + if (value.trim().isNotEmpty) return value; + } + return null; +} diff --git a/lib/features/modules/modules_home_page.dart b/lib/features/modules/modules_home_page.dart new file mode 100644 index 0000000..e100888 --- /dev/null +++ b/lib/features/modules/modules_home_page.dart @@ -0,0 +1,816 @@ +import 'dart:async'; + +import 'package:file_selector/file_selector.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:forui/forui.dart'; +import 'package:go_router/go_router.dart'; + +import '../../core/contracts/script_widget.dart'; +import '../../providers/di_providers.dart'; +import '../../shared/theme/app_theme.dart'; +import '../../shared/utils/user_error_formatter.dart'; +import '../../shared/widgets/adaptive_modal.dart'; +import '../../shared/widgets/app_card.dart'; +import '../../shared/widgets/app_dialog.dart'; +import '../../shared/widgets/app_error_state.dart'; +import '../../shared/widgets/app_inline_alert.dart'; +import '../../shared/widgets/app_loading_ring.dart'; +import '../../shared/widgets/app_snack_bar.dart'; +import '../../shared/widgets/auto_dismiss_menu.dart'; +import '../../shared/widgets/empty_state.dart'; +import '../../shared/widgets/pill_tab_bar.dart'; +import 'script_widget_parameter_form.dart'; + + +Future showModuleCenterSheet(BuildContext context) { + return showAdaptiveModal( + context: context, + maxWidth: 720, + compactHeightFactor: 0.92, + useRootNavigator: true, + builder: (sheetContext) => const _ModuleCenterSheet(), + ); +} + +class _ModuleCenterSheet extends StatelessWidget { + const _ModuleCenterSheet(); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Material( + color: theme.colorScheme.surface, + child: const ModuleCenterContent(), + ); + } +} + +class _SheetHeader extends StatelessWidget { + final String title; + final List actions; + final bool showCloseButton; + + const _SheetHeader({ + required this.title, + required this.actions, + this.showCloseButton = true, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 8, 8), + child: Row( + children: [ + Expanded( + child: Text( + title, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + ...actions, + if (showCloseButton) + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close_rounded), + tooltip: '关闭', + ), + ], + ), + ); + } +} + + +class ModuleCenterContent extends ConsumerStatefulWidget { + final bool embedded; + + const ModuleCenterContent({super.key, this.embedded = false}); + + @override + ConsumerState createState() => + _ModuleCenterContentState(); +} + +class _ModuleCenterContentState extends ConsumerState { + int _selectedSectionIndex = 0; + + @override + Widget build(BuildContext context) { + final installedWidgets = ref.watch(installedScriptWidgetsProvider); + final subscriptions = ref.watch(scriptWidgetSubscriptionsProvider); + final embedded = widget.embedded; + + final headerActions = [ + IconButton( + onPressed: () => _addFromFile(context), + icon: const Icon(Icons.file_open_outlined), + tooltip: '从本地文件导入', + ), + FButton( + onPress: () => _addFromUrl(context), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.add_link_rounded, size: 17), + SizedBox(width: 6), + Text('添加 URL'), + ], + ), + ), + ]; + + + final headerHorizontalPadding = embedded + ? const EdgeInsets.fromLTRB(4, 0, 4, 4) + : EdgeInsets.zero; + final tabPadding = embedded + ? const EdgeInsets.fromLTRB(4, 8, 4, 4) + : const EdgeInsets.fromLTRB(20, 14, 20, 4); + final sectionPadding = embedded + ? const EdgeInsets.fromLTRB(4, 12, 4, 24) + : const EdgeInsets.fromLTRB(20, 12, 20, 48); + + return RefreshIndicator( + onRefresh: _refresh, + child: CustomScrollView( + physics: const AlwaysScrollableScrollPhysics( + parent: BouncingScrollPhysics(), + ), + slivers: [ + SliverToBoxAdapter( + child: Padding( + padding: headerHorizontalPadding, + child: _SheetHeader( + title: embedded ? '' : '模块中心', + actions: headerActions, + showCloseButton: !embedded, + ), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: tabPadding, + child: PillTabBar( + tabs: const ['已安装', '模块订阅'], + selectedIndex: _selectedSectionIndex, + onSelect: (selectedIndex) { + setState(() => _selectedSectionIndex = selectedIndex); + }, + ), + ), + ), + SliverPadding( + padding: sectionPadding, + sliver: SliverToBoxAdapter( + child: _selectedSectionIndex == 0 + ? installedWidgets.when( + data: (widgets) => _InstalledWidgetsSection( + widgets: widgets, + onChanged: () => ref.invalidate( + installedScriptWidgetsProvider, + ), + ), + loading: () => const Padding( + padding: EdgeInsets.all(32), + child: Center(child: AppLoadingRing()), + ), + error: (error, _) => AppErrorState( + compact: true, + message: formatUserError(error), + onRetry: () => ref.invalidate( + installedScriptWidgetsProvider, + ), + ), + ) + : subscriptions.when( + data: (items) => _SubscriptionsSection( + subscriptions: items, + onChanged: () { + ref.invalidate(scriptWidgetSubscriptionsProvider); + ref.invalidate(installedScriptWidgetsProvider); + }, + ), + loading: () => const Padding( + padding: EdgeInsets.all(24), + child: Center(child: AppLoadingRing(size: 30)), + ), + error: (error, _) => AppErrorState( + compact: true, + message: formatUserError(error), + onRetry: () => ref.invalidate( + scriptWidgetSubscriptionsProvider, + ), + ), + ), + ), + ), + ], + ), + ); + } + + Future _refresh() async { + ref.invalidate(installedScriptWidgetsProvider); + ref.invalidate(scriptWidgetSubscriptionsProvider); + await Future.wait([ + ref.read(installedScriptWidgetsProvider.future), + ref.read(scriptWidgetSubscriptionsProvider.future), + ]); + } + + Future _addFromUrl(BuildContext context) async { + final url = await showModuleUrlImportSheet(context); + if (url == null || url.trim().isEmpty || !context.mounted) return; + try { + final service = await ref.read(scriptWidgetServiceProvider.future); + if (url.toLowerCase().split('?').first.endsWith('.fwd')) { + await service.importSubscription(url.trim()); + ref.invalidate(scriptWidgetSubscriptionsProvider); + if (context.mounted) showAppToast(context, '模块订阅已添加'); + } else { + final installed = await service.installFromUrl(url.trim()); + ref.invalidate(installedScriptWidgetsProvider); + if (context.mounted) { + showAppToast(context, '已安装 ${installed.manifest.title}'); + } + } + } catch (error) { + if (context.mounted) { + showAppToast(context, formatUserError(error), tone: AppAlertTone.error); + } + } + } + + Future _addFromFile(BuildContext context) async { + const acceptedFiles = XTypeGroup( + label: 'ForwardWidgets', + extensions: ['js', 'fwd'], + ); + final selectedFile = await openFile( + acceptedTypeGroups: const [acceptedFiles], + ); + if (selectedFile == null || !context.mounted) return; + try { + final source = await selectedFile.readAsString(); + if (!context.mounted) return; + final service = await ref.read(scriptWidgetServiceProvider.future); + if (selectedFile.name.toLowerCase().endsWith('.fwd')) { + await service.importSubscriptionSource( + source, + sourceId: 'local:${selectedFile.name}', + ); + ref.invalidate(scriptWidgetSubscriptionsProvider); + if (context.mounted) showAppToast(context, '本地模块订阅已添加'); + return; + } + final installed = await service.installFromSource(source); + ref.invalidate(installedScriptWidgetsProvider); + if (context.mounted) { + showAppToast(context, '已安装 ${installed.manifest.title}'); + } + } catch (error) { + if (context.mounted) { + showAppToast(context, formatUserError(error), tone: AppAlertTone.error); + } + } + } +} + +class _InstalledWidgetsSection extends ConsumerWidget { + final List widgets; + final VoidCallback onChanged; + + const _InstalledWidgetsSection({ + required this.widgets, + required this.onChanged, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + if (widgets.isEmpty) { + return const EmptyState( + compact: true, + icon: Icons.widgets_outlined, + title: '尚未安装模块', + message: '可导入 ForwardWidgets 的 JS 地址、.fwd 订阅或本地脚本。', + ); + } + return Column( + children: [ + for (var index = 0; index < widgets.length; index++) ...[ + _InstalledWidgetTile( + installedWidget: widgets[index], + onOpen: () { + final widgetId = widgets[index].manifest.id; + + + Navigator.of(context).pop(); + context.go('/modules/${Uri.encodeComponent(widgetId)}'); + }, + onToggle: (enabled) async { + final service = await ref.read( + scriptWidgetServiceProvider.future, + ); + await service.setWidgetEnabled( + widgets[index].manifest.id, + enabled, + ); + onChanged(); + }, + onConfigure: widgets[index].manifest.globalParams.isEmpty + ? null + : () async { + final values = await showAppRawDialog>( + context, + builder: (_) => _GlobalParametersDialog( + parameters: widgets[index].manifest.globalParams, + initialValues: widgets[index].globalParams, + ), + ); + if (values == null || !context.mounted) return; + final service = await ref.read( + scriptWidgetServiceProvider.future, + ); + await service.updateGlobalParams( + widgets[index].manifest.id, + values, + ); + ref.invalidate( + installedScriptWidgetProvider(widgets[index].manifest.id), + ); + onChanged(); + if (context.mounted) { + showAppToast(context, '模块参数已保存'); + } + }, + onRefresh: widgets[index].sourceUrl.isEmpty + ? null + : () async { + try { + final service = await ref.read( + scriptWidgetServiceProvider.future, + ); + await service.refreshWidget(widgets[index].manifest.id); + onChanged(); + if (context.mounted) { + showAppToast(context, '模块已更新'); + } + } catch (error) { + if (context.mounted) { + showAppToast( + context, + formatUserError(error), + tone: AppAlertTone.error, + ); + } + } + }, + onDelete: () async { + final confirmed = await showAppConfirmDialog( + context, + title: '删除模块', + body: Text('确定删除“${widgets[index].manifest.title}”吗?'), + confirmLabel: '删除', + destructive: true, + ); + if (!confirmed) return; + final service = await ref.read( + scriptWidgetServiceProvider.future, + ); + await service.deleteWidget(widgets[index].manifest.id); + onChanged(); + }, + ), + if (index < widgets.length - 1) const SizedBox(height: 10), + ], + ], + ); + } +} + +class _InstalledWidgetTile extends StatelessWidget { + final InstalledScriptWidget installedWidget; + final VoidCallback onOpen; + final ValueChanged onToggle; + final VoidCallback? onConfigure; + final VoidCallback? onRefresh; + final VoidCallback onDelete; + + const _InstalledWidgetTile({ + required this.installedWidget, + required this.onOpen, + required this.onToggle, + required this.onConfigure, + required this.onRefresh, + required this.onDelete, + }); + + @override + Widget build(BuildContext context) { + final manifest = installedWidget.manifest; + final theme = Theme.of(context); + final tokens = context.appTokens; + final menuItems = [ + if (onConfigure != null) + FItem( + prefix: const Icon(Icons.settings_outlined, size: 17), + title: const Text('配置全局参数'), + onPress: onConfigure, + ), + if (onRefresh != null) + FItem( + prefix: const Icon(Icons.refresh_rounded, size: 17), + title: const Text('检查更新'), + onPress: onRefresh, + ), + FItem( + prefix: Icon( + Icons.delete_outline_rounded, + size: 17, + color: theme.colorScheme.error, + ), + title: Text('删除模块', style: TextStyle(color: theme.colorScheme.error)), + onPress: onDelete, + ), + ]; + + return Opacity( + opacity: installedWidget.enabled ? 1 : 0.62, + child: AppCard( + onTap: installedWidget.enabled ? onOpen : null, + padding: const EdgeInsets.all(14), + child: Row( + children: [ + Container( + width: 42, + height: 42, + alignment: Alignment.center, + decoration: BoxDecoration( + color: theme.colorScheme.primary.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(tokens.radiusCard), + ), + child: Text( + manifest.title.isEmpty ? '?' : manifest.title[0], + style: theme.textTheme.titleMedium?.copyWith( + color: theme.colorScheme.primary, + fontWeight: FontWeight.w700, + ), + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + manifest.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Text( + [ + if (manifest.author.isNotEmpty) manifest.author, + 'v${manifest.version}', + '${manifest.modules.length} 个功能', + ].join(' · '), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: tokens.mutedForeground, + ), + ), + ], + ), + ), + FSwitch(value: installedWidget.enabled, onChange: onToggle), + const SizedBox(width: 6), + FPopoverMenu( + menuBuilder: autoDismissMenuBuilder, + menuAnchor: Alignment.topRight, + childAnchor: Alignment.bottomRight, + menu: [FItemGroup(children: menuItems)], + builder: (context, controller, child) => + GestureDetector(onTap: controller.toggle, child: child), + child: const Padding( + padding: EdgeInsets.all(8), + child: Icon(Icons.more_vert_rounded, size: 19), + ), + ), + ], + ), + ), + ); + } +} + +class _SubscriptionsSection extends ConsumerWidget { + final List subscriptions; + final VoidCallback onChanged; + + const _SubscriptionsSection({ + required this.subscriptions, + required this.onChanged, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + if (subscriptions.isEmpty) { + return const EmptyState( + compact: true, + icon: Icons.inventory_2_outlined, + title: '尚未添加订阅', + message: '添加 .fwd URL 后,可从仓库中安装和更新模块。', + ); + } + + return Column( + children: [ + for (final subscription in subscriptions) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: AppCard( + padding: EdgeInsets.zero, + child: ExpansionTile( + shape: const Border(), + collapsedShape: const Border(), + title: Text(subscription.catalog.title), + subtitle: Text( + '${subscription.catalog.widgets.length} 个模块 · ${subscription.url}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + trailing: IconButton( + icon: const Icon(Icons.refresh_rounded), + tooltip: '刷新订阅', + onPressed: subscription.url.startsWith('http') + ? () async { + try { + final service = await ref.read( + scriptWidgetServiceProvider.future, + ); + await service.importSubscription(subscription.url); + onChanged(); + } catch (error) { + if (context.mounted) { + showAppToast( + context, + formatUserError(error), + tone: AppAlertTone.error, + ); + } + } + } + : null, + ), + children: [ + for (final entry in subscription.catalog.widgets) + _CatalogEntryRow( + entry: entry, + onInstall: () async { + try { + final service = await ref.read( + scriptWidgetServiceProvider.future, + ); + await service.installFromUrl(entry.url); + onChanged(); + if (context.mounted) { + showAppToast(context, '已安装 ${entry.title}'); + } + } catch (error) { + if (context.mounted) { + showAppToast( + context, + formatUserError(error), + tone: AppAlertTone.error, + ); + } + } + }, + ), + ], + ), + ), + ), + ], + ); + } +} + +class _CatalogEntryRow extends StatelessWidget { + final ScriptWidgetCatalogEntry entry; + final VoidCallback onInstall; + + const _CatalogEntryRow({required this.entry, required this.onInstall}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.fromLTRB(16, 10, 16, 12), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + entry.title, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 3), + Text( + entry.description.isNotEmpty + ? entry.description + : '${entry.author} · v${entry.version}', + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: context.appTokens.mutedForeground, + ), + ), + ], + ), + ), + const SizedBox(width: 12), + FButton( + variant: FButtonVariant.outline, + onPress: onInstall, + child: const Text('安装'), + ), + ], + ), + ); + } +} + +class _GlobalParametersDialog extends StatefulWidget { + final List parameters; + final Map initialValues; + + const _GlobalParametersDialog({ + required this.parameters, + required this.initialValues, + }); + + @override + State<_GlobalParametersDialog> createState() => + _GlobalParametersDialogState(); +} + +class _GlobalParametersDialogState extends State<_GlobalParametersDialog> { + late Map _values; + + @override + void initState() { + super.initState(); + _values = { + ...buildScriptWidgetParameterDefaults(widget.parameters), + ...widget.initialValues, + }; + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('全局参数', style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 8), + const Text('这些值会自动合并到该模块的每次调用中。'), + const SizedBox(height: 18), + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 440), + child: SingleChildScrollView( + child: ScriptWidgetParameterForm( + parameters: widget.parameters, + initialValues: _values, + onChanged: (values) { + _values = Map.from(values); + }, + ), + ), + ), + const SizedBox(height: 20), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + FButton( + variant: FButtonVariant.outline, + onPress: () => Navigator.of(context).pop(), + child: const Text('取消'), + ), + const SizedBox(width: 8), + FButton( + onPress: () => Navigator.of(context).pop(_values), + child: const Text('保存'), + ), + ], + ), + ], + ), + ); + } +} + +Future showModuleUrlImportSheet(BuildContext context) { + return showAdaptiveModal( + context: context, + maxWidth: 520, + useRootNavigator: true, + compactMaxHeightFactor: 0.6, + builder: (sheetContext) => const _UrlImportSheet(), + ); +} + +class _UrlImportSheet extends StatefulWidget { + const _UrlImportSheet(); + + @override + State<_UrlImportSheet> createState() => _UrlImportSheetState(); +} + +class _UrlImportSheetState extends State<_UrlImportSheet> { + final TextEditingController _urlController = TextEditingController(); + + @override + void dispose() { + _urlController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final tokens = context.appTokens; + final inputBorder = OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide( + color: tokens.mutedForeground.withValues(alpha: 0.28), + ), + ); + + return Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('添加模块', style: theme.textTheme.titleLarge), + const SizedBox(height: 8), + const Text('支持单个 .js 脚本地址或 ForwardWidgets .fwd 订阅地址。'), + const SizedBox(height: 18), + TextField( + controller: _urlController, + autofocus: true, + decoration: InputDecoration( + labelText: 'HTTP(S) URL', + hintText: 'https://example.com/widgets.fwd', + filled: true, + fillColor: theme.colorScheme.surface.withValues(alpha: 0.42), + border: inputBorder, + enabledBorder: inputBorder, + focusedBorder: inputBorder.copyWith( + borderSide: BorderSide(color: theme.colorScheme.primary), + ), + isDense: true, + ), + onSubmitted: (_) => _submit(), + ), + const SizedBox(height: 20), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + FButton( + variant: FButtonVariant.outline, + onPress: () => Navigator.of(context).pop(), + child: const Text('取消'), + ), + const SizedBox(width: 8), + FButton(onPress: _submit, child: const Text('继续')), + ], + ), + ], + ), + ); + } + + void _submit() { + final url = _urlController.text.trim(); + if (url.isEmpty) return; + Navigator.of(context).pop(url); + } +} diff --git a/lib/features/modules/script_widget_parameter_form.dart b/lib/features/modules/script_widget_parameter_form.dart new file mode 100644 index 0000000..9dd035d --- /dev/null +++ b/lib/features/modules/script_widget_parameter_form.dart @@ -0,0 +1,236 @@ +import 'package:flutter/material.dart'; + +import '../../core/contracts/script_widget.dart'; +import '../../shared/theme/app_theme.dart'; +import '../../shared/widgets/setting_select.dart'; + +class ScriptWidgetParameterForm extends StatefulWidget { + final List parameters; + final Map initialValues; + final ValueChanged> onChanged; + final bool hidePagingParameters; + + const ScriptWidgetParameterForm({ + super.key, + required this.parameters, + required this.initialValues, + required this.onChanged, + this.hidePagingParameters = false, + }); + + @override + State createState() => + _ScriptWidgetParameterFormState(); +} + +class _ScriptWidgetParameterFormState extends State { + final Map _textControllers = + {}; + late Map _values; + + @override + void initState() { + super.initState(); + _values = _buildInitialValues(); + _synchronizeTextControllers(); + } + + @override + void didUpdateWidget(covariant ScriptWidgetParameterForm oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.parameters != widget.parameters || + oldWidget.initialValues != widget.initialValues) { + _values = _buildInitialValues(); + _synchronizeTextControllers(); + } + } + + @override + void dispose() { + for (final controller in _textControllers.values) { + controller.dispose(); + } + super.dispose(); + } + + Map _buildInitialValues() { + final defaultValues = buildScriptWidgetParameterDefaults(widget.parameters); + return { + for (final parameter in widget.parameters) + parameter.name: + widget.initialValues[parameter.name] ?? + defaultValues[parameter.name], + }; + } + + void _synchronizeTextControllers() { + final activeNames = widget.parameters + .where((parameter) => !_usesOptionSelector(parameter)) + .map((parameter) => parameter.name) + .toSet(); + final staleNames = _textControllers.keys + .where((name) => !activeNames.contains(name)) + .toList(growable: false); + for (final name in staleNames) { + _textControllers.remove(name)?.dispose(); + } + for (final parameter in widget.parameters) { + if (_usesOptionSelector(parameter)) continue; + final value = _values[parameter.name]?.toString() ?? ''; + final controller = _textControllers.putIfAbsent( + parameter.name, + TextEditingController.new, + ); + if (controller.text != value) { + controller.value = TextEditingValue( + text: value, + selection: TextSelection.collapsed(offset: value.length), + ); + } + } + } + + bool _usesOptionSelector(ScriptWidgetParam parameter) { + return parameter.type == 'enumeration' || + parameter.enumOptions.isNotEmpty || + parameter.placeholders.isNotEmpty; + } + + bool _isVisible(ScriptWidgetParam parameter) { + if (parameter.type == 'constant') return false; + if (widget.hidePagingParameters && + const {'page', 'offset'}.contains(parameter.type)) { + return false; + } + final condition = parameter.belongTo; + if (condition == null || condition.paramName.isEmpty) return true; + final parentValue = _values[condition.paramName]?.toString() ?? ''; + return condition.value.contains(parentValue); + } + + void _updateValue(String name, Object? value) { + setState(() { + _values[name] = value; + }); + widget.onChanged(Map.unmodifiable(_values)); + } + + @override + Widget build(BuildContext context) { + final visibleParameters = widget.parameters + .where(_isVisible) + .toList(growable: false); + if (visibleParameters.isEmpty) return const SizedBox.shrink(); + + return Wrap( + spacing: 12, + runSpacing: 12, + children: [ + for (final parameter in visibleParameters) + SizedBox( + width: _fieldWidth(parameter), + child: _buildField(parameter), + ), + ], + ); + } + + double _fieldWidth(ScriptWidgetParam parameter) { + if (const {'count', 'page', 'offset'}.contains(parameter.type)) { + return 132; + } + return 240; + } + + Widget _buildField(ScriptWidgetParam parameter) { + final options = parameter.enumOptions.isNotEmpty + ? parameter.enumOptions + : parameter.placeholders; + if (_usesOptionSelector(parameter)) { + final selectedValue = _values[parameter.name]?.toString() ?? ''; + final optionValues = options.map((option) => option.value).toSet(); + final effectiveValue = optionValues.contains(selectedValue) + ? selectedValue + : null; + final labelsByValue = { + for (final option in options) + option.value: option.title.isEmpty ? option.value : option.title, + }; + final theme = Theme.of(context); + final tokens = context.appTokens; + final helper = _helperFor(parameter); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _labelFor(parameter), + style: theme.textTheme.labelMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 6), + SettingSelect( + value: effectiveValue, + options: options + .map((option) => option.value) + .toList(growable: false), + labelOf: (value) => labelsByValue[value] ?? value, + onChanged: (value) => _updateValue(parameter.name, value ?? ''), + ), + if (helper != null) ...[ + const SizedBox(height: 4), + Text( + helper, + style: theme.textTheme.bodySmall?.copyWith( + color: tokens.mutedForeground, + ), + ), + ], + ], + ); + } + + final isNumeric = const { + 'count', + 'page', + 'offset', + }.contains(parameter.type); + final theme = Theme.of(context); + final tokens = context.appTokens; + final inputBorder = OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide( + color: tokens.mutedForeground.withValues(alpha: 0.28), + ), + ); + return TextField( + controller: _textControllers[parameter.name], + keyboardType: isNumeric ? TextInputType.number : TextInputType.text, + decoration: InputDecoration( + labelText: _labelFor(parameter), + helperText: _helperFor(parameter), + filled: true, + fillColor: theme.colorScheme.surface.withValues(alpha: 0.42), + border: inputBorder, + enabledBorder: inputBorder, + focusedBorder: inputBorder.copyWith( + borderSide: BorderSide(color: theme.colorScheme.primary), + ), + isDense: true, + ), + onChanged: (value) { + final parsedValue = isNumeric ? int.tryParse(value) ?? value : value; + _updateValue(parameter.name, parsedValue); + }, + ); + } + + String _labelFor(ScriptWidgetParam parameter) { + return parameter.title.isEmpty ? parameter.name : parameter.title; + } + + String? _helperFor(ScriptWidgetParam parameter) { + final description = parameter.description.trim(); + return description.isEmpty ? null : description; + } +} diff --git a/lib/features/person_detail/person_detail_page.dart b/lib/features/person_detail/person_detail_page.dart new file mode 100644 index 0000000..17bc3dc --- /dev/null +++ b/lib/features/person_detail/person_detail_page.dart @@ -0,0 +1,197 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../core/contracts/auth.dart'; +import '../../core/contracts/library.dart'; +import '../../providers/emby_headers_provider.dart'; +import '../../providers/person_items_provider.dart'; +import '../../providers/session_provider.dart'; +import '../../shared/constants/layout_constants.dart'; +import '../../shared/mappers/media_image_url.dart'; +import '../../shared/utils/media_nav.dart'; +import '../../shared/widgets/app_error_state.dart'; +import '../../shared/widgets/app_loading_ring.dart'; +import '../../shared/widgets/app_sliver_header.dart'; +import '../../shared/widgets/empty_state.dart'; +import '../../shared/widgets/media_poster_card.dart'; + + +class PersonDetailPage extends ConsumerWidget { + final String embyPersonId; + + const PersonDetailPage({super.key, required this.embyPersonId}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final session = ref.watch(activeSessionProvider); + if (session == null) { + return Scaffold( + extendBodyBehindAppBar: true, + body: CustomScrollView( + slivers: [ + AppSliverHeader(title: '演员', leading: _buildBackButton(context)), + const SliverFillRemaining( + hasScrollBody: false, + child: _EmptyState(message: '请先登录服务器'), + ), + ], + ), + ); + } + + final asyncDetail = ref.watch(personItemDetailProvider(embyPersonId)); + final asyncItems = ref.watch(personItemsProvider(embyPersonId)); + final imageHeaders = ref.watch(embyImageHeadersProvider).value; + + final personName = asyncDetail.value?.Name; + final title = (personName != null && personName.isNotEmpty) + ? '演员 $personName 的全部作品' + : '演员'; + + final leadingAvatar = _personAvatar( + context: context, + session: session, + person: asyncDetail.value, + headers: imageHeaders, + ); + + final Widget titleWidget = Row( + children: [ + if (leadingAvatar != null) ...[ + leadingAvatar, + const SizedBox(width: 12), + ], + Expanded( + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ); + + final Widget contentSliver = asyncItems.when( + loading: () => const SliverFillRemaining( + hasScrollBody: false, + child: Center(child: AppLoadingRing()), + ), + error: (_, _) => SliverFillRemaining( + hasScrollBody: false, + child: AppErrorState( + title: '查询失败', + onRetry: () => ref.invalidate(personItemsProvider(embyPersonId)), + ), + ), + data: (items) { + if (items.isEmpty) { + return const SliverFillRemaining( + hasScrollBody: false, + child: EmptyState(icon: Icons.movie_filter_outlined, title: '暂无作品'), + ); + } + return SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + sliver: SliverGrid( + gridDelegate: mediaGridDelegate, + delegate: SliverChildBuilderDelegate((_, i) { + final item = items[i]; + return MediaPosterCard( + item: item, + width: double.infinity, + imageUrl: EmbyImageUrl.primary( + baseUrl: session.serverUrl, + token: session.token, + item: item, + maxHeight: 360, + ), + preloadImageUrl: EmbyImageUrl.detailBanner( + baseUrl: session.serverUrl, + token: session.token, + item: item, + ), + imageHeaders: imageHeaders, + onTap: () => goMediaDetail(context, item, ref: ref), + ); + }, childCount: items.length), + ), + ); + }, + ); + + return Scaffold( + extendBodyBehindAppBar: true, + body: CustomScrollView( + slivers: [ + AppSliverHeader( + titleWidget: titleWidget, + leading: _buildBackButton(context), + ), + contentSliver, + ], + ), + ); + } + + Widget _buildBackButton(BuildContext context) { + return IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () { + if (context.canPop()) { + context.pop(); + } else { + context.go('/'); + } + }, + ); + } + + Widget? _personAvatar({ + required BuildContext context, + required AuthedSession session, + required EmbyRawItem? person, + required Map? headers, + }) { + if (person == null) return null; + final hasImage = + (person.PrimaryImageTag != null) || + (person.ImageTags?['Primary'] != null); + if (!hasImage) return null; + final url = EmbyImageUrl.primaryById( + baseUrl: session.serverUrl, + token: session.token, + itemId: person.Id, + maxHeight: 80, + ); + return CircleAvatar( + radius: 16, + backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, + backgroundImage: ResizeImage.resizeIfNeeded( + 96, + null, + CachedNetworkImageProvider(url, headers: headers), + ), + ); + } +} + +class _EmptyState extends StatelessWidget { + final String message; + const _EmptyState({required this.message}); + + @override + Widget build(BuildContext context) { + return Center( + child: Text( + message, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontSize: 14, + ), + ), + ); + } +} diff --git a/lib/features/player/gestures/gesture_action.dart b/lib/features/player/gestures/gesture_action.dart new file mode 100644 index 0000000..f33ce92 --- /dev/null +++ b/lib/features/player/gestures/gesture_action.dart @@ -0,0 +1,42 @@ +import '../../../core/contracts/player_gestures.dart'; + +export '../../../core/contracts/player_gestures.dart' show GestureAction; + +String gestureActionLabel(GestureAction a) => switch (a) { + GestureAction.none => '无操作', + GestureAction.playPause => '播放/暂停', + GestureAction.fullscreen => '全屏', + GestureAction.toggleControls => '显示/隐藏控制栏', + GestureAction.seekBackward => '后退', + GestureAction.seekForward => '前进', + GestureAction.previousItem => '上一个', + GestureAction.nextItem => '下一个', + GestureAction.exit => '退出', +}; + +enum GestureKind { leftClick, leftDoubleClick, rightClick, rightDoubleClick } + + +List allowedActionsFor(GestureKind kind) { + switch (kind) { + case GestureKind.leftClick: + case GestureKind.rightClick: + return const [ + GestureAction.playPause, + GestureAction.toggleControls, + GestureAction.none, + ]; + case GestureKind.leftDoubleClick: + case GestureKind.rightDoubleClick: + return const [ + GestureAction.playPause, + GestureAction.fullscreen, + GestureAction.seekBackward, + GestureAction.seekForward, + GestureAction.previousItem, + GestureAction.nextItem, + GestureAction.exit, + GestureAction.none, + ]; + } +} diff --git a/lib/features/player/gestures/gesture_bindings.dart b/lib/features/player/gestures/gesture_bindings.dart new file mode 100644 index 0000000..5962141 --- /dev/null +++ b/lib/features/player/gestures/gesture_bindings.dart @@ -0,0 +1,35 @@ +import 'gesture_action.dart'; + +class GestureBindings { + final double keyboardHoldLeftSpeed; + final double keyboardHoldRightSpeed; + final GestureAction mouseLeftClickAction; + final GestureAction mouseLeftDoubleClickAction; + final GestureAction mouseRightClickAction; + final GestureAction mouseRightDoubleClickAction; + + const GestureBindings({ + required this.keyboardHoldLeftSpeed, + required this.keyboardHoldRightSpeed, + required this.mouseLeftClickAction, + required this.mouseLeftDoubleClickAction, + required this.mouseRightClickAction, + required this.mouseRightDoubleClickAction, + }); + + static const defaults = GestureBindings( + keyboardHoldLeftSpeed: 0.5, + keyboardHoldRightSpeed: 3.0, + mouseLeftClickAction: GestureAction.playPause, + mouseLeftDoubleClickAction: GestureAction.fullscreen, + mouseRightClickAction: GestureAction.toggleControls, + mouseRightDoubleClickAction: GestureAction.none, + ); + + GestureAction actionFor(GestureKind kind) => switch (kind) { + GestureKind.leftClick => mouseLeftClickAction, + GestureKind.leftDoubleClick => mouseLeftDoubleClickAction, + GestureKind.rightClick => mouseRightClickAction, + GestureKind.rightDoubleClick => mouseRightDoubleClickAction, + }; +} diff --git a/lib/features/player/player_launcher.dart b/lib/features/player/player_launcher.dart new file mode 100644 index 0000000..20dce8a --- /dev/null +++ b/lib/features/player/player_launcher.dart @@ -0,0 +1,179 @@ +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../shared/utils/app_logger.dart'; +import 'playlist/cross_server_playlist.dart'; +import 'playlist/pending_cross_server_sequence_provider.dart'; + + +class PlayerExitResult { + final int? positionTicks; + final String serverId; + final String itemId; + final String? mediaSourceId; + + const PlayerExitResult({ + required this.positionTicks, + required this.serverId, + required this.itemId, + required this.mediaSourceId, + }); +} + + +class DirectStreamLaunchData { + final String url; + final Map headers; + final String title; + final int? durationSeconds; + final String? seriesName; + final int? episode; + + const DirectStreamLaunchData({ + required this.url, + this.headers = const {}, + required this.title, + this.durationSeconds, + this.seriesName, + this.episode, + }); +} + +class PlayerLauncher { + static const _tag = 'PlayerLauncher'; + + + static Future launch({ + required BuildContext context, + required String itemId, + String? serverId, + String? mediaSourceId, + int preferredSubtitleStreamIndex = -1, + int? preferredAudioStreamIndex, + int? startPositionTicks, + bool fromStart = false, + String? backdropUrl, + WidgetRef? ref, + List? crossServerSequence, + DirectStreamLaunchData? directStream, + }) async { + if (crossServerSequence != null && crossServerSequence.isNotEmpty) { + assert( + ref != null, + 'PlayerLauncher.launch: ref is required when crossServerSequence is provided', + ); + ref!.read(pendingCrossServerSequenceProvider.notifier).state = + List.unmodifiable(crossServerSequence); + } + AppLogger.debug( + _tag, + 'launch serverId=$serverId itemId=$itemId mediaSourceId=$mediaSourceId ' + 'subtitleIndex=$preferredSubtitleStreamIndex ' + 'audioIndex=$preferredAudioStreamIndex ' + 'startPositionTicks=$startPositionTicks ' + 'fromStart=$fromStart ' + 'hasBackdrop=${backdropUrl?.isNotEmpty ?? false} ' + 'directStream=${directStream != null} ' + 'crossServerSequence=${crossServerSequence?.length ?? 0}', + ); + + final queryParams = {}; + if (serverId != null && serverId.isNotEmpty) { + queryParams['serverId'] = serverId; + } + if (mediaSourceId != null) queryParams['mediaSourceId'] = mediaSourceId; + if (backdropUrl != null && backdropUrl.isNotEmpty) { + queryParams['backdropUrl'] = backdropUrl; + } + queryParams['subtitleIndex'] = '$preferredSubtitleStreamIndex'; + if (preferredAudioStreamIndex != null) { + queryParams['audioIndex'] = '$preferredAudioStreamIndex'; + } + if (fromStart) { + queryParams['fromStart'] = '1'; + } else if (startPositionTicks != null && startPositionTicks > 0) { + queryParams['startPositionTicks'] = '$startPositionTicks'; + } + final uri = Uri( + path: '/player/$itemId', + queryParameters: queryParams.isNotEmpty ? queryParams : null, + ); + AppLogger.debug(_tag, 'navigating to: ${uri.toString()}'); + + OverlayEntry? androidLaunchCover; + void removeAndroidLaunchCover() { + final launchCover = androidLaunchCover; + androidLaunchCover = null; + launchCover?.remove(); + } + + try { + if (Platform.isAndroid) { + final playerView = View.of(context); + androidLaunchCover = _insertAndroidLaunchCover(context); + if (androidLaunchCover != null) { + await WidgetsBinding.instance.endOfFrame; + if (!context.mounted) return null; + } + + await SystemChrome.setPreferredOrientations(const [ + DeviceOrientation.landscapeLeft, + DeviceOrientation.landscapeRight, + ]); + if (!context.mounted) return null; + + await _waitForAndroidLandscape(playerView); + if (!context.mounted) return null; + } + + final playerResult = context.push( + uri.toString(), + extra: directStream, + ); + if (androidLaunchCover != null) { + await WidgetsBinding.instance.endOfFrame; + removeAndroidLaunchCover(); + } + return await playerResult; + } finally { + removeAndroidLaunchCover(); + if (Platform.isAndroid) { + await SystemChrome.setPreferredOrientations( + const [], + ); + } + } + } + + static OverlayEntry? _insertAndroidLaunchCover(BuildContext context) { + final overlay = Overlay.maybeOf(context, rootOverlay: true); + if (overlay == null) return null; + + final launchCover = OverlayEntry( + builder: (context) => const Positioned.fill( + child: IgnorePointer(child: ColoredBox(color: Color(0xFF08090C))), + ), + ); + overlay.insert(launchCover); + return launchCover; + } + + static Future _waitForAndroidLandscape( + ui.FlutterView playerView, + ) async { + const pollInterval = Duration(milliseconds: 16); + final deadline = DateTime.now().add(const Duration(milliseconds: 500)); + + while (true) { + final windowSize = playerView.physicalSize; + if (windowSize.width > windowSize.height) return; + if (DateTime.now().isAfter(deadline)) return; + await Future.delayed(pollInterval); + } + } +} diff --git a/lib/features/player/player_page.dart b/lib/features/player/player_page.dart new file mode 100644 index 0000000..4d3b13b --- /dev/null +++ b/lib/features/player/player_page.dart @@ -0,0 +1,1276 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:math' as math; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_riverpod/legacy.dart'; +import 'package:go_router/go_router.dart'; +import 'package:canvas_danmaku/canvas_danmaku.dart'; +import 'package:wakelock_plus/wakelock_plus.dart'; +import 'package:window_manager/window_manager.dart'; + +import '../../core/contracts/cancellation.dart'; +import '../../core/contracts/danmaku.dart'; +import '../../core/contracts/auth.dart'; +import '../../core/contracts/library.dart'; +import '../../core/domain/ports/authed_trakt_gateway.dart'; +import '../../core/infra/player_engine/media3_player_engine.dart'; +import '../../core/infra/player_engine/player_engine.dart'; +import '../../core/infra/system_audio/system_audio_controller.dart'; +import '../../core/infra/trakt_api/trakt_pending_sync_queue.dart'; +import '../../core/media/media_probe_service.dart'; +import '../../core/media/media_source_metadata_resolver.dart'; +import '../../providers/trakt_scrobble_status_provider.dart'; +import '../../shared/app_info.dart'; +import '../../shared/constants/breakpoints.dart'; +import '../settings/settings_page.dart'; +import '../../providers/audio_settings_provider.dart'; +import '../../providers/danmaku_settings_provider.dart'; +import '../../providers/di_providers.dart'; +import '../../providers/last_played_version_store.dart'; +import '../../providers/playback_active_provider.dart'; +import '../../providers/player_engine_override_provider.dart'; +import '../../providers/player_settings_provider.dart'; +import '../../providers/emby_headers_provider.dart'; +import '../../providers/playback_info_prefetch_provider.dart'; +import '../../providers/session_provider.dart'; +import '../../providers/subtitle_settings_provider.dart'; +import '../../shared/utils/app_logger.dart'; +import '../../shared/utils/cross_server_search_key.dart'; +import '../../shared/utils/emby_ticks.dart'; +import '../../shared/utils/format_utils.dart'; +import '../../shared/utils/platform_device_name.dart'; +import '../../shared/utils/user_error_formatter.dart'; +import '../../shared/widgets/app_dialog.dart'; +import '../../shared/widgets/app_loading_ring.dart'; +import '../../shared/widgets/app_snack_bar.dart'; +import '../../shared/widgets/version_source_card.dart'; +import '../../shared/widgets/window_controls.dart'; +import 'gestures/gesture_action.dart'; +import 'gestures/gesture_bindings.dart'; +import 'player_launcher.dart'; +import 'playlist/cross_server_playlist.dart'; +import 'playlist/pending_cross_server_sequence_provider.dart'; +import 'playlist/playlist.dart'; +import 'playlist/playlist_factory.dart'; +import 'playlist/series_episode_playlist.dart'; +import 'playlist/single_item_playlist.dart'; +import 'services/danmaku/danmaku_feeder.dart'; +import 'services/danmaku/danmaku_session_notifier.dart'; +import 'services/frame_jank_monitor.dart'; +import 'services/fullscreen_manager.dart'; +import 'services/android_pip_manager.dart'; +import 'services/pip_manager.dart'; +import '../../core/infra/pip/android_pip_controller.dart'; +import 'session/playback_phase.dart'; +import 'session/playback_request.dart'; +import 'session/playback_session.dart'; +import 'session/playback_target.dart'; +import 'session/playback_session_builder.dart'; +import 'session/player_engine_holder.dart'; +import 'session/player_engine_resolution.dart'; +import 'session/relink_scheduler.dart'; +import 'widgets/buffering_overlay.dart'; +import 'widgets/danmaku_search_dialog.dart'; +import 'widgets/debounced_seek_bar.dart'; +import 'widgets/network_speed_indicator.dart'; +import 'widgets/player_clock_text.dart'; +import 'widgets/panel_toggle_affordance.dart'; +import 'widgets/popover/panel_category_meta.dart'; +import 'widgets/popover/player_panel_controller.dart'; +import 'widgets/popover/player_panel_popover.dart'; +import 'widgets/panels/danmaku_panel_body.dart'; +import 'widgets/panels/episode_panel_body.dart'; +import 'widgets/panels/panel_selection_list.dart'; +import 'widgets/panels/selection_panel_entry.dart'; +import 'widgets/player_panel_shell.dart'; +import 'widgets/panels/tracks_panel_body.dart'; +import 'widgets/fit_button.dart'; +import 'widgets/player_debug_hud.dart'; +import 'widgets/player_play_pause_button.dart'; +import 'widgets/player_position_indicator.dart'; +import 'widgets/player_video_widget.dart'; +import 'widgets/player_volume_button.dart'; +import 'widgets/player_status_scaffold.dart'; +import 'widgets/quality_badge.dart'; +import 'widgets/speed_button.dart'; + +import 'widgets/android/android_bottom_transport_controls.dart'; +import 'widgets/android/android_gesture_feedback.dart'; +import 'widgets/android/android_gesture_layer.dart'; +import 'widgets/android/android_lock_overlay.dart'; +import 'widgets/android/android_speed_strip.dart'; +import 'package:screen_brightness/screen_brightness.dart'; +import 'widgets/styled_subtitle_overlay.dart'; +import 'widgets/track_selector_button.dart'; +import 'widgets/version_panel.dart'; + +part 'player_page_bottom_bar.dart'; +part 'player_page_danmaku.dart'; +part 'player_page_lifecycle.dart'; +part 'player_page_overlays.dart'; +part 'player_page_relink.dart'; +part 'player_page_session.dart'; +part 'player_page_sources.dart'; +part 'player_page_top_bar.dart'; +part 'player_page_version_bar.dart'; +part 'player_page_android_chrome.dart'; + +const _tag = 'PlayerPage'; + +class PlayerPage extends ConsumerStatefulWidget { + final String itemId; + final String? serverId; + final String? mediaSourceId; + final String? backdropUrl; + final int? preferredSubtitleStreamIndex; + final int? preferredAudioStreamIndex; + final int? startPositionTicks; + final bool fromStart; + final DirectStreamLaunchData? directStream; + + const PlayerPage({ + super.key, + required this.itemId, + this.serverId, + this.mediaSourceId, + this.backdropUrl, + this.preferredSubtitleStreamIndex, + this.preferredAudioStreamIndex, + this.startPositionTicks, + this.fromStart = false, + this.directStream, + }); + + @override + ConsumerState createState() => _PlayerPageState(); +} + +class _PlayerPageState extends ConsumerState + with WidgetsBindingObserver { + int _maxVolumeLevel = 15; + double _volumeFraction = 0.0; + + PlaybackSession? _session; + PlaybackRequest? _lastRequest; + bool _videoVisible = false; + PlaybackSessionBuilder? _builder; + Playlist? _playlist; + ({int startTimeTicks, Map info})? _prefetchedPlaybackInfo; + bool _switchInFlight = false; + bool _engineFallbackInFlight = false; + + + final Map _forcedEngineBySource = + {}; + + + final PlayerEngineHolder _engineHolder = PlayerEngineHolder(); + + + bool _pendingResync = false; + String? _errorMessage; + + + final RelinkScheduler _relink = RelinkScheduler(); + + + final FrameJankMonitor _frameJankMonitor = FrameJankMonitor(); + + late final FullscreenManager _fullscreen = FullscreenManager( + onStateChanged: () { + if (mounted) { + setState(() {}); + _invalidateBottomBar(); + } + }, + ); + late final PipManager _pip = PipManager( + onStateChanged: () { + if (mounted) { + setState(() {}); + _invalidateBottomBar(); + } + }, + ); + + + late final AndroidPipManager _androidPip = AndroidPipManager( + onStateChanged: () { + if (mounted) { + setState(() {}); + _invalidateBottomBar(); + } + }, + ); + StreamSubscription? _androidPipPlayingSub; + + + bool get _isAndroidPip => Platform.isAndroid && _androidPip.isInPip; + + final ValueNotifier _videoFit = ValueNotifier(BoxFit.contain); + final ValueNotifier _barVersion = ValueNotifier(0); + + + final ValueNotifier _debugHudVisible = ValueNotifier(false); + + + final PlayerPanelController _drawer = PlayerPanelController(); + + + final Map _panelLinks = { + for (final c in PlayerPanelCategory.values) c: LayerLink(), + }; + + + final GlobalKey _episodeBodyKey = + GlobalKey(); + + + final ValueNotifier _versionBarOpen = ValueNotifier(false); + + + final ValueNotifier _seekPreview = ValueNotifier(null); + final ValueNotifier _danmakuOverlayVersion = ValueNotifier(0); + final ValueNotifier _subtitleOverlayVersion = ValueNotifier(0); + final ValueNotifier _bufferingOverlayVisible = ValueNotifier( + false, + ); + + + List _crossServerCards = const []; + + + CrossServerSearchKey? _lastCrossServerSearchKey; + + bool _isDanmakuSearchOpen = false; + bool _backNavigationInFlight = false; + + + bool _playerStartupBegun = false; + bool _isAndroidLocked = false; + int _brightnessGen = 0; + double? _lastSetBrightness; + GestureFeedbackData _androidFeedbackData = const GestureFeedbackData.none(); + StreamSubscription? _systemVolumeSub; + Timer? _systemVolumeOsdTimer; + double? _savedRateBeforeLongPress; + double _playbackRate = 1.0; + SubtitleStyleSettings _subtitleSettings = const SubtitleStyleSettings(); + PlayerSettingsState _initialPlayerSettings = const PlayerSettingsState(); + DanmakuSettingsState _initialDanmakuSettings = const DanmakuSettingsState(); + AudioSettingsState _initialAudioSettings = const AudioSettingsState(); + + final DanmakuFeeder _feeder = DanmakuFeeder(); + + DanmakuSessionState _sessionState = const DanmakuSessionIdle(); + DanmakuPanelSettings _panelSettings = const DanmakuPanelSettings(); + double? _danmakuOverlayHeight; + double? _danmakuOverlaySyncedHeight; + DanmakuPanelSettings? _danmakuOverlaySyncedSettings; + Timer? _danmakuPanelPersistTimer; + DanmakuPanelSettings? _pendingDanmakuPanelSettings; + DanmakuSessionState? _lastSyncedDanmakuSession; + + late final StateController _playbackActiveNotifier; + late final PlayerSettingsNotifier _playerSettingsNotifier; + late final AudioSettingsNotifier _audioSettingsNotifier; + late final SubtitleSettingsNotifier _subtitleSettingsNotifier; + late final DanmakuSettingsNotifier _danmakuSettingsNotifier; + late final LastPlayedVersionStore _lastPlayedVersionStore; + StreamSubscription? _wakelockPlayingSub; + PlaybackSession? _wakelockSession; + bool _wakelockEnabled = false; + + bool get _isDesktopWindow => Platform.isWindows || Platform.isMacOS; + + double get _topBarLeftInset => _fullscreen.isFullscreen ? 8 : 12; + double get _topBarTopInset => _fullscreen.isFullscreen ? 8 : 12; + + + String get _fallbackTitle => _playlist?.current.value.displayLabel ?? ''; + + bool get _showFallbackWindowControls => + !_fullscreen.isFullscreen && !_pip.isPip && _isDesktopWindow; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + _playbackActiveNotifier = ref.read(playbackActiveProvider.notifier); + _playerSettingsNotifier = ref.read(playerSettingsProvider.notifier); + _audioSettingsNotifier = ref.read(audioSettingsProvider.notifier); + _subtitleSettingsNotifier = ref.read(subtitleSettingsProvider.notifier); + _danmakuSettingsNotifier = ref.read(danmakuSettingsProvider.notifier); + _lastPlayedVersionStore = ref.read(lastPlayedVersionStoreProvider); + + _drawer.addListener(_onPanelChanged); + _frameJankMonitor.start(); + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + ref.read(playbackActiveProvider.notifier).state = true; + }); + + + if (Platform.isAndroid || Platform.isIOS) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _beginStartupAfterRouteAnimation(); + }); + } else { + _beginPlayerStartup(); + } + } + + + void _beginStartupAfterRouteAnimation() { + final routeAnimation = ModalRoute.of(context)?.animation; + if (routeAnimation == null || + routeAnimation.status == AnimationStatus.completed) { + _beginPlayerStartup(); + return; + } + late final AnimationStatusListener statusListener; + statusListener = (AnimationStatus status) { + if (status == AnimationStatus.completed || + status == AnimationStatus.dismissed) { + routeAnimation.removeStatusListener(statusListener); + if (mounted && status == AnimationStatus.completed) { + _beginPlayerStartup(); + } + } + }; + routeAnimation.addStatusListener(statusListener); + } + + + void _beginPlayerStartup() { + if (_playerStartupBegun || !mounted) return; + _playerStartupBegun = true; + + if (Platform.isAndroid) { + unawaited(_beginAndroidPlayerStartup()); + return; + } + + unawaited(_initialize()); + } + + Future _beginAndroidPlayerStartup() async { + try { + await SystemChrome.setPreferredOrientations(const [ + DeviceOrientation.landscapeLeft, + DeviceOrientation.landscapeRight, + ]); + if (!mounted || _backNavigationInFlight) return; + await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); + if (!mounted || _backNavigationInFlight) return; + } catch (error, stack) { + AppLogger.warn(_tag, 'configure Android immersive mode failed', error); + AppLogger.debug(_tag, stack.toString()); + } + + if (!mounted || _backNavigationInFlight) return; + _enableAndroidSystemAudio(); + _restoreAndroidBrightness(); + _androidPip.onAction = _handleAndroidPipAction; + unawaited( + _androidPip + .initialize() + .then((_) { + if (!mounted) return; + _configureAndroidPip(); + }) + .catchError((Object error, StackTrace stack) { + AppLogger.warn(_tag, 'initialize Android PiP failed', error); + AppLogger.debug(_tag, stack.toString()); + }), + ); + unawaited(_initialize()); + } + + void _restoreAndroidBrightness() { + final savedBrightness = ref + .read(playerSettingsProvider) + .value + ?.androidBrightness; + if (savedBrightness == null) return; + unawaited( + ScreenBrightness.instance + .setApplicationScreenBrightness(savedBrightness) + .catchError((_) {}), + ); + } + + void _enableAndroidSystemAudio() { + unawaited( + SystemAudioController.instance + .getVolume() + .then((event) { + _volumeFraction = event.percent; + _maxVolumeLevel = event.max; + }) + .catchError((Object error, StackTrace stack) { + AppLogger.warn(_tag, 'initial volume fetch failed', error); + }), + ); + unawaited( + SystemAudioController.instance.setKeyIntercept(true).catchError(( + Object error, + StackTrace stack, + ) { + AppLogger.warn(_tag, 'enable volume key intercept failed', error); + AppLogger.debug(_tag, stack.toString()); + }), + ); + _systemVolumeSub = SystemAudioController.instance.volumeChanges.listen( + _showAndroidSystemVolumeOsd, + onError: (Object error, StackTrace stack) { + AppLogger.warn(_tag, 'system volume event failed', error); + AppLogger.debug(_tag, stack.toString()); + }, + ); + } + + void _showAndroidSystemVolumeOsd(SystemVolumeEvent event) { + if (!mounted) return; + _volumeFraction = event.percent; + _maxVolumeLevel = event.max; + _systemVolumeOsdTimer?.cancel(); + setState( + () => _androidFeedbackData = GestureFeedbackData( + kind: GestureFeedbackKind.volume, + value: event.percent, + ), + ); + _systemVolumeOsdTimer = Timer(const Duration(milliseconds: 800), () { + if (!mounted || _androidFeedbackData.kind != GestureFeedbackKind.volume) { + return; + } + setState(() => _androidFeedbackData = const GestureFeedbackData.none()); + }); + } + + void _disableAndroidSystemAudio() { + if (!Platform.isAndroid) return; + unawaited( + SystemAudioController.instance.setKeyIntercept(false).catchError(( + Object error, + StackTrace stack, + ) { + AppLogger.warn(_tag, 'disable volume key intercept failed', error); + AppLogger.debug(_tag, stack.toString()); + }), + ); + unawaited(_systemVolumeSub?.cancel()); + _systemVolumeSub = null; + _systemVolumeOsdTimer?.cancel(); + _systemVolumeOsdTimer = null; + } + + void _onMatchSelected(int index) { + if (index == _danmakuSelectedMatchIndex) return; + unawaited(ref.read(danmakuSessionProvider.notifier).selectMatch(index)); + } + + Future _openDanmakuSearchDialog() async { + final session = _session; + if (session == null) return; + if (_sessionState is DanmakuSessionIdle) return; + final dialogItemId = session.context.itemId; + final matches = _danmakuMatches; + final dCtx = session.context.danmakuMatchContext; + final initialQuery = + dCtx.type == 'Episode' && (dCtx.seriesName?.isNotEmpty ?? false) + ? dCtx.seriesName! + : session.context.itemName; + + setState(() => _isDanmakuSearchOpen = true); + _invalidateBottomBar(); + try { + final size = MediaQuery.sizeOf(context); + await showAppRawDialog( + context, + useRootNavigator: true, + constraints: BoxConstraints( + maxWidth: math.min(size.width - 48, 520.0), + maxHeight: math.min(size.height - 112, 560.0), + ), + builder: (_) => DanmakuSearchDialog( + currentItemId: dialogItemId, + isItemStillCurrent: () => + mounted && _session?.context.itemId == dialogItemId, + initialQuery: initialQuery, + initialResults: matches, + initialSelectedIndex: _danmakuSelectedMatchIndex, + targetEpisode: dCtx.episode, + ), + ); + } finally { + if (mounted) { + setState(() => _isDanmakuSearchOpen = false); + _invalidateBottomBar(); + } + } + } + + void _attachWakelockSession(PlaybackSession? session) { + if (identical(_wakelockSession, session)) { + _syncWakelockForSession(session); + return; + } + + unawaited(_wakelockPlayingSub?.cancel()); + _wakelockPlayingSub = null; + _wakelockSession = session; + + if (session == null) { + _setWakelockEnabled(false); + return; + } + + _wakelockPlayingSub = session.engine.stream.playing.listen((_) { + _syncWakelockForSession(session); + }); + _syncWakelockForSession(session); + } + + void _syncWakelockForSession(PlaybackSession? session) { + if (!identical(_wakelockSession, session)) return; + final shouldEnable = + session != null && + session.phase.value == PlaybackPhase.playing && + session.engine.state.playing; + _setWakelockEnabled(shouldEnable); + } + + void _setWakelockEnabled(bool enabled) { + if (_wakelockEnabled == enabled) return; + _wakelockEnabled = enabled; + + unawaited( + (enabled ? WakelockPlus.enable() : WakelockPlus.disable()).catchError(( + Object error, + StackTrace stack, + ) { + AppLogger.warn( + _tag, + 'wakelock ${enabled ? 'enable' : 'disable'} failed', + error, + ); + AppLogger.debug(_tag, stack.toString()); + }), + ); + } + + void _onDanmakuPanelChanged(DanmakuPanelSettings next) { + final enabledChanged = _panelSettings.enabled != next.enabled; + final offsetChanged = _panelSettings.offset != next.offset; + final layoutChanged = _isDanmakuLayoutChange(_panelSettings, next); + _applyDanmakuPanelSettings(next); + if (enabledChanged) { + _invalidateBottomBar(); + } + if (enabledChanged || layoutChanged) { + _invalidateDanmakuOverlay(); + } + if (enabledChanged || offsetChanged || layoutChanged) { + final s = _session; + if (s != null) { + final posSec = s.engine.state.position.inMilliseconds / 1000.0; + _syncDanmakuFeederForSession(_feeder, s, posSec, clear: true); + } + } + _refreshPanel(); + _danmakuSettingsNotifier.setPanelSettings(next); + _scheduleDanmakuPanelSettingsPersist(next); + } + + void _scheduleDanmakuPanelSettingsPersist(DanmakuPanelSettings next) { + _pendingDanmakuPanelSettings = next; + _danmakuPanelPersistTimer?.cancel(); + _danmakuPanelPersistTimer = Timer(const Duration(milliseconds: 250), () { + final pending = _pendingDanmakuPanelSettings; + _pendingDanmakuPanelSettings = null; + if (pending == null) return; + unawaited(_danmakuSettingsNotifier.persistPanelSettings(pending)); + }); + } + + void _flushDanmakuPanelSettingsPersist() { + _danmakuPanelPersistTimer?.cancel(); + _danmakuPanelPersistTimer = null; + final pending = _pendingDanmakuPanelSettings; + _pendingDanmakuPanelSettings = null; + if (pending == null) return; + unawaited(_danmakuSettingsNotifier.persistPanelSettings(pending)); + } + + void _retryDanmaku() { + final session = _session; + if (session == null) return; + unawaited( + ref + .read(danmakuSessionProvider.notifier) + .loadFor(session.context.danmakuMatchContext), + ); + } + + void _goToDanmakuSettings() { + _drawer.close(); + showSettingsDialog(context, initialTab: 'danmaku'); + } + + void _toggleDanmakuGlobalEnabled() { + unawaited(_enableDanmakuForCurrentSession()); + } + + Future _enableDanmakuForCurrentSession() async { + final notifier = ref.read(danmakuSettingsProvider.notifier); + await notifier.setEnabled(true); + if (!mounted) return; + final session = _session; + if (session == null) return; + await ref + .read(danmakuSessionProvider.notifier) + .loadFor(session.context.danmakuMatchContext); + } + + void _onSubtitleSettingsChanged(SubtitleStyleSettings next) { + _subtitleSettings = next; + _refreshPanel(); + _invalidateSubtitleOverlay(); + _session?.applySubtitleStyle(next); + ref.read(subtitleSettingsProvider.notifier).setSettings(next); + } + + void _setPlaybackRate(double rate) { + _playbackRate = rate; + unawaited(_session?.setRate(rate) ?? Future.value()); + _feeder.setPanelSettings( + _panelSettings, + option: _buildDanmakuOption(_panelSettings), + ); + _invalidateBottomBar(); + _refreshPanel(); + } + + void _setVideoFit(BoxFit fit) { + _videoFit.value = fit; + _invalidateBottomBar(); + _refreshPanel(); + } + + + void _togglePanel(PlayerPanelCategory c) { + _versionBarOpen.value = false; + _drawer.toggle(c); + } + + + Widget _panelIcon(PlayerPanelCategory c) { + return CompositedTransformTarget( + link: _panelLinks[c]!, + child: PanelToggleAffordance( + isOpen: _drawer.category == c, + onTap: () => _togglePanel(c), + tooltip: panelCategoryLabel(c), + child: Icon(panelCategoryIcon(c), color: Colors.white, size: 26), + ), + ); + } + + + Widget _panelBody(PlaybackSession session, PlayerPanelCategory c) { + switch (c) { + case PlayerPanelCategory.subtitle: + session.prefetchExternalSubtitles(); + return SubtitlePanelBody( + subtitleSettings: _subtitleSettings, + onSubtitleSettingsChanged: _onSubtitleSettingsChanged, + subtitles: session.context.embySubtitles, + activeSubtitleStreamIndex: session.activeSubtitleStreamIndex, + subtitleLoading: session.subtitleLoading, + onSubtitleSelected: (streamIndex) => + _onSubtitleSelected(session, streamIndex), + ); + case PlayerPanelCategory.audio: + return AudioPanelBody( + player: session.engine, + onAudioTrackSelected: (track) => + _onAudioTrackSelected(session, track), + ); + case PlayerPanelCategory.speed: + return PanelSelectionList( + entries: [ + for (final speed in SpeedButton.speeds) + SelectionPanelEntry.item( + value: speed, + label: SpeedButton.formatRate(speed), + isActive: speed == _playbackRate, + ), + ], + onSelected: _setPlaybackRate, + ); + case PlayerPanelCategory.fit: + return PanelSelectionList( + entries: [ + for (final option in FitButton.options) + SelectionPanelEntry.item( + value: option.fit, + label: option.label, + subtitle: option.subtitle, + isActive: option.fit == _videoFit.value, + ), + ], + onSelected: _setVideoFit, + ); + case PlayerPanelCategory.episode: + return EpisodePanelBody( + key: _episodeBodyKey, + seriesId: session.context.seriesId!, + initialSeasonId: session.context.seasonId, + currentEpisodeId: session.context.itemId, + serverId: session.context.serverId, + baseUrl: session.context.baseUrl, + token: session.context.token, + onEpisodeSelected: _onEpisodePicked, + ); + case PlayerPanelCategory.danmaku: + final danmakuState = ref.read(danmakuSettingsProvider).value; + return DanmakuPanelBody( + settings: _panelSettings, + onChanged: _onDanmakuPanelChanged, + sessionState: _sessionState, + danmakuGlobalEnabled: danmakuState?.enabled ?? false, + danmakuSourceConfigured: + (danmakuState?.enabledSources ?? const []).isNotEmpty, + matches: _danmakuMatches, + selectedMatchIndex: _danmakuSelectedMatchIndex, + onMatchSelected: _onMatchSelected, + onOpenSearch: _openDanmakuSearchDialog, + isLoading: _danmakuSearching, + commentCount: _danmakuCommentCount, + blockedKeywords: danmakuState?.blockedKeywords ?? const [], + onAddKeyword: (kw) => + ref.read(danmakuSettingsProvider.notifier).addBlockedKeyword(kw), + onRemoveKeyword: (kw) => ref + .read(danmakuSettingsProvider.notifier) + .removeBlockedKeyword(kw), + onRetry: _retryDanmaku, + onGoToSettings: _goToDanmakuSettings, + onToggleDanmakuEnabled: _toggleDanmakuGlobalEnabled, + ); + } + } + + Future _toggleFullscreen() async { + if (_pip.isPip) { + await _pip.leave(); + } + await _fullscreen.toggle(); + } + + Future _togglePip() async { + if (!_isDesktopWindow) return; + if (_fullscreen.isFullscreen) { + await _fullscreen.leave(); + } + await _pip.toggle(); + } + + void _goBack() { + if (_backNavigationInFlight) return; + _backNavigationInFlight = true; + unawaited(_goBackAfterLeavingPip()); + } + + Future _goBackAfterLeavingPip() async { + try { + try { + unawaited(_session?.pause() ?? Future.value()); + } catch (_) {} + + if (_pip.isPip) { + await _pip.leave(); + if (!mounted) return; + } + + if (_fullscreen.isFullscreen) { + await _fullscreen.leave(); + if (!mounted) return; + } + + if (context.canPop()) { + final session = _session; + final ticks = session != null + ? ticksFromDuration(session.engine.state.position) + : null; + final result = session != null + ? PlayerExitResult( + positionTicks: ticks, + serverId: session.context.serverId, + itemId: session.context.itemId, + mediaSourceId: session.context.mediaSourceId, + ) + : null; + context.pop(result); + } else { + context.go('/'); + } + } catch (error, stack) { + AppLogger.warn(_tag, 'goBack failed', error); + AppLogger.debug(_tag, stack.toString()); + } finally { + _backNavigationInFlight = false; + } + } + + + Future _showPlayerContextMenu(Offset globalPosition) async { + final overlay = + Overlay.of(context).context.findRenderObject() as RenderBox?; + if (overlay == null) return; + final selected = await showMenu( + context: context, + position: RelativeRect.fromRect( + globalPosition & const Size(40, 40), + Offset.zero & overlay.size, + ), + items: [ + CheckedPopupMenuItem( + value: 'hud', + checked: _debugHudVisible.value, + child: const Text('调试信息 HUD'), + ), + ], + ); + if (selected == 'hud') { + _debugHudVisible.value = !_debugHudVisible.value; + } + } + + void _invalidateBottomBar() => _barVersion.value++; + + void _setAndroidLocked(bool locked) { + if (!mounted || _isAndroidLocked == locked) return; + setState(() => _isAndroidLocked = locked); + _configureAndroidPip(); + } + + void _handleAndroidPipAction(PipAction action) { + final session = _session; + if (session == null) return; + switch (action) { + case PipAction.play: + unawaited(session.engine.play()); + case PipAction.pause: + unawaited(session.engine.pause()); + case PipAction.next: + final pl = _playlist; + if (pl != null && pl.hasNext) unawaited(pl.next()); + case PipAction.prev: + final pl = _playlist; + if (pl != null && pl.hasPrev) unawaited(pl.prev()); + case PipAction.seekForward: + final secs = + ref.read(playerSettingsProvider).value?.seekForwardSeconds ?? 15; + final target = session.engine.state.position + Duration(seconds: secs); + unawaited(session.engine.seek(target)); + case PipAction.seekBackward: + final secs = + ref.read(playerSettingsProvider).value?.seekBackwardSeconds ?? 15; + final target = session.engine.state.position - Duration(seconds: secs); + unawaited( + session.engine.seek(target < Duration.zero ? Duration.zero : target), + ); + } + } + + void _configureAndroidPip() { + if (!Platform.isAndroid) return; + final session = _session; + final settings = ref.read(playerSettingsProvider).value; + + const int aspectX = 16; + const int aspectY = 9; + + unawaited( + _androidPip.configure( + aspectX: aspectX, + aspectY: aspectY, + autoEnterOnLeave: + (settings?.autoPip ?? true) && + session != null && + session.engine.state.playing && + !_isAndroidLocked, + isPlaying: session?.engine.state.playing ?? false, + hasNext: _playlist?.hasNext ?? false, + hasPrev: _playlist?.hasPrev ?? false, + useEpisodeActions: settings?.pipShowEpisodeActions ?? true, + ), + ); + } + + void _attachAndroidPipPlayingListener(PlaybackSession? session) { + unawaited(_androidPipPlayingSub?.cancel()); + _androidPipPlayingSub = null; + if (session == null || !Platform.isAndroid) return; + _androidPipPlayingSub = session.engine.stream.playing.listen((_) { + _configureAndroidPip(); + if (_androidPip.isInPip) { + final settings = ref.read(playerSettingsProvider).value; + unawaited( + _androidPip.updatePlaybackState( + isPlaying: session.engine.state.playing, + hasNext: _playlist?.hasNext ?? false, + hasPrev: _playlist?.hasPrev ?? false, + useEpisodeActions: settings?.pipShowEpisodeActions ?? true, + ), + ); + } + }); + } + + Future _enterAndroidPip() async { + if (!Platform.isAndroid || !_androidPip.isSupported) return; + _drawer.close(); + _versionBarOpen.value = false; + await _androidPip.enter(aspectX: 16, aspectY: 9); + } + + + void _refreshPanel() { + if (mounted) setState(() {}); + } + + void _invalidateDanmakuOverlay() => _danmakuOverlayVersion.value++; + void _invalidateSubtitleOverlay() => _subtitleOverlayVersion.value++; + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (Platform.isAndroid && state == AppLifecycleState.paused) { + _session?.engine.pause(); + } + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + _disableAndroidSystemAudio(); + + if (Platform.isAndroid) { + SystemChrome.setPreferredOrientations(const []); + SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + unawaited( + ScreenBrightness.instance.resetApplicationScreenBrightness().catchError( + (_) {}, + ), + ); + } + + _flushDanmakuPanelSettingsPersist(); + _relink.dispose(); + _frameJankMonitor.dispose(); + unawaited(_androidPipPlayingSub?.cancel()); + _androidPipPlayingSub = null; + + Future.microtask(() { + try { + _playbackActiveNotifier.state = false; + } catch (_) {} + }); + + unawaited(_wakelockPlayingSub?.cancel()); + _wakelockPlayingSub = null; + _wakelockSession = null; + _setWakelockEnabled(false); + + _session?.phase.removeListener(_onSessionPhaseChanged); + _playlist?.current.removeListener(_onCurrentItemChanged); + final playlist = _playlist; + _playlist = null; + try { + playlist?.dispose(); + } catch (e) { + AppLogger.warn(_tag, 'dispose: playlist failed', e); + } + + _drawer.removeListener(_onPanelChanged); + + for (final step in <(String, void Function())>[ + ('fullscreen', _fullscreen.dispose), + ('pip', _pip.dispose), + ('androidPip', _androidPip.dispose), + ('feederDetach', _feeder.detach), + ('videoFit', _videoFit.dispose), + ('barVersion', _barVersion.dispose), + ('debugHudVisible', _debugHudVisible.dispose), + ('drawer', _drawer.dispose), + ('versionBarOpen', _versionBarOpen.dispose), + ('seekPreview', _seekPreview.dispose), + ('danmakuOverlayVer', _danmakuOverlayVersion.dispose), + ('subtitleOverlayVer', _subtitleOverlayVersion.dispose), + ('bufferingOverlayVisible', _bufferingOverlayVisible.dispose), + ]) { + try { + step.$2(); + } catch (e) { + AppLogger.warn(_tag, 'dispose: ${step.$1} failed', e); + } + } + + final session = _session; + _session = null; + final holder = _engineHolder; + if (session != null) { + unawaited(session.dispose().whenComplete(holder.dispose)); + } else { + unawaited(holder.dispose()); + } + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + _wireDanmakuListeners(); + + ref.listen( + audioSettingsProvider.select((s) => s.value?.volumeBoost ?? false), + (prev, next) { + if (prev == true && next == false) { + final s = _session; + if (s != null && s.engine.state.volume > 100) { + unawaited(s.engine.setVolume(100)); + } + } + }, + ); + + final session = _session; + final error = _errorMessage; + final currentCrossServerSearchKey = session == null + ? null + : _buildCrossServerKey(session.context); + if (currentCrossServerSearchKey != null) { + _lastCrossServerSearchKey = currentCrossServerSearchKey; + } + final retainedCrossServerSearchKey = session == null + ? _lastCrossServerSearchKey + : currentCrossServerSearchKey; + final asyncCrossServerCards = retainedCrossServerSearchKey == null + ? null + : ref.watch( + crossServerSearchDataProvider(retainedCrossServerSearchKey), + ); + if (session != null) { + if (currentCrossServerSearchKey != null && + asyncCrossServerCards != null) { + _crossServerCards = _filteredCrossServerCards( + asyncCrossServerCards.maybeWhen( + data: (list) => list, + orElse: () => + asyncCrossServerCards.value ?? const [], + ), + session.context, + ); + } else { + _crossServerCards = const []; + } + } + + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) _goBack(); + }, + child: Scaffold( + backgroundColor: Colors.black, + body: Stack( + fit: StackFit.expand, + children: [ + if (session != null) + Positioned.fill(child: _buildPlayingStack(session)), + if (session == null || !_videoVisible) ...[ + PlayerLoadingBackdrop(backdropUrl: widget.backdropUrl), + PlayerStatusTopBar( + title: _fallbackTitle, + onBack: _goBack, + topInset: _topBarTopInset, + leftInset: _topBarLeftInset, + showWindowControls: _showFallbackWindowControls, + enableWindowDrag: _showFallbackWindowControls, + ), + ], + if ((session == null || !_videoVisible) && error == null) + const PlayerLoadingIndicator(), + if (error != null && session == null) ...[ + const Positioned.fill(child: ColoredBox(color: Colors.black)), + PlayerStatusTopBar( + title: _fallbackTitle, + onBack: _goBack, + topInset: _topBarTopInset, + leftInset: _topBarLeftInset, + showWindowControls: _showFallbackWindowControls, + enableWindowDrag: _showFallbackWindowControls, + ), + PlayerErrorCard(message: error, onBack: _goBack, onRetry: _retry), + ], + ], + ), + ), + ); + } + + + Widget _buildPlayingStack(PlaybackSession session) { + final seekSettings = ref.watch( + playerSettingsProvider.select( + (s) => s.whenData( + (d) => (fwd: d.seekForwardSeconds, bwd: d.seekBackwardSeconds), + ), + ), + ); + final seekFwd = seekSettings.value?.fwd ?? 15; + final seekBwd = seekSettings.value?.bwd ?? 15; + final volumeBoost = ref.watch( + audioSettingsProvider.select((s) => s.value?.volumeBoost ?? false), + ); + + final playerSettings = ref.watch(playerSettingsProvider).value; + final desktopBindings = playerSettings == null + ? GestureBindings.defaults + : GestureBindings( + keyboardHoldLeftSpeed: playerSettings.keyboardHoldLeftSpeed, + keyboardHoldRightSpeed: playerSettings.keyboardHoldRightSpeed, + mouseLeftClickAction: playerSettings.mouseLeftClickAction, + mouseLeftDoubleClickAction: + playerSettings.mouseLeftDoubleClickAction, + mouseRightClickAction: playerSettings.mouseRightClickAction, + mouseRightDoubleClickAction: + playerSettings.mouseRightDoubleClickAction, + ); + final gestureBindings = _isDesktopWindow + ? desktopBindings + : GestureBindings( + keyboardHoldLeftSpeed: desktopBindings.keyboardHoldLeftSpeed, + keyboardHoldRightSpeed: desktopBindings.keyboardHoldRightSpeed, + mouseLeftClickAction: GestureAction.toggleControls, + mouseLeftDoubleClickAction: GestureAction.playPause, + mouseRightClickAction: desktopBindings.mouseRightClickAction, + mouseRightDoubleClickAction: + desktopBindings.mouseRightDoubleClickAction, + ); + + return Stack( + fit: StackFit.expand, + children: [ + AnimatedBuilder( + animation: _drawer, + builder: (context, _) { + final panelOpen = _drawer.isOpen; + return PlayerVideoWidget( + key: ValueKey( + 'mpv:${session.context.itemId}:' + '${session.context.mediaSourceId ?? ''}', + ), + player: session.engine, + fit: _videoFit, + topBar: Platform.isAndroid + ? (_isAndroidPip + ? const [] + : _buildAndroidTopBar(session)) + : _buildTopBar(session), + topBarLeftInset: _topBarLeftInset, + topBarTopInset: _topBarTopInset, + bottomBar: Platform.isAndroid + ? (_isAndroidPip + ? const SizedBox.shrink() + : _buildAndroidBottomBar(session)) + : _buildBottomBar(session), + centerBar: Platform.isAndroid + ? null + : (_pip.isPip ? _buildPipCenterControls(session) : null), + leftBar: Platform.isAndroid && !_isAndroidLocked && !_isAndroidPip + ? _buildAndroidLeftBar() + : null, + rightBar: + Platform.isAndroid && !_isAndroidLocked && !_isAndroidPip + ? _buildAndroidRightBar(session) + : null, + mobileGestureBuilder: + Platform.isAndroid && !_isAndroidLocked && !_isAndroidPip + ? (toggleControls, showControls) => _buildAndroidGestureLayer( + session, + toggleControls, + showControls, + ) + : null, + gestureFeedback: Platform.isAndroid && !_isAndroidPip + ? _buildAndroidGestureFeedback() + : null, + overlays: _isAndroidPip + ? _buildPipOverlays(session) + : _buildVideoOverlays(session), + onControlsAreaTap: panelOpen ? _drawer.close : null, + isFullscreen: _fullscreen.isFullscreen, + onToggleFullscreen: _toggleFullscreen, + isPip: _pip.isPip, + onTogglePip: _isDesktopWindow ? _togglePip : null, + lockControls: panelOpen, + suppressControlsChrome: + Platform.isAndroid && (_isAndroidLocked || _isAndroidPip), + keyboardEnabled: !_isDanmakuSearchOpen && !_drawer.isOpen, + seekForwardSeconds: seekFwd, + seekBackwardSeconds: seekBwd, + volumeBoost: volumeBoost, + gestureBindings: gestureBindings, + currentRate: _playbackRate, + onSetRate: _setPlaybackRate, + onPrevItem: () { + final pl = _playlist; + if (pl != null && pl.hasPrev) unawaited(pl.prev()); + }, + onNextItem: () { + final pl = _playlist; + if (pl != null && pl.hasNext) unawaited(pl.next()); + }, + onExit: _goBack, + onSecondaryContextMenu: kDebugMode + ? _showPlayerContextMenu + : null, + ); + }, + ), + _buildVersionBarLayer(session), + if (!Platform.isAndroid) + PlayerPanelPopover( + controller: _drawer, + anchorLinks: _panelLinks, + bodyBuilder: (c) => _panelBody(session, c), + ), + if (Platform.isAndroid && !_isAndroidPip) + _buildAndroidDrawerLayer(session), + if (Platform.isAndroid && _isAndroidLocked && !_isAndroidPip) + AndroidLockedOverlay(onUnlock: () => _setAndroidLocked(false)), + if (_switchInFlight) + const Positioned.fill( + child: IgnorePointer( + child: ColoredBox( + color: Colors.black54, + child: Center( + child: AppLoadingRing(size: 44, color: Colors.white), + ), + ), + ), + ), + ], + ); + } +} diff --git a/lib/features/player/player_page_android_chrome.dart b/lib/features/player/player_page_android_chrome.dart new file mode 100644 index 0000000..e938715 --- /dev/null +++ b/lib/features/player/player_page_android_chrome.dart @@ -0,0 +1,613 @@ + + +part of 'player_page.dart'; + +extension _AndroidChrome on _PlayerPageState { + static const _androidSpeeds = [ + 0.25, + 0.5, + 0.75, + 1.0, + 1.25, + 1.5, + 1.75, + 2.0, + 2.5, + 3.0, + ]; + + void _seekBy(int seconds) { + final session = _session; + if (session == null) return; + final target = session.engine.state.position + Duration(seconds: seconds); + unawaited( + session.engine.seek(target < Duration.zero ? Duration.zero : target), + ); + } + + void _onAndroidSpeedIncrement() { + final idx = _androidSpeeds.indexWhere((s) => s > _playbackRate); + if (idx >= 0) _setPlaybackRate(_androidSpeeds[idx]); + } + + void _onAndroidSpeedDecrement() { + final idx = _androidSpeeds.lastIndexWhere((s) => s < _playbackRate); + if (idx >= 0) _setPlaybackRate(_androidSpeeds[idx]); + } + + List _buildAndroidTopBar(PlaybackSession session) { + return [ + IconButton( + icon: const Icon(Icons.arrow_back, color: Colors.white), + onPressed: _goBack, + ), + const SizedBox(width: 4), + Expanded( + child: Text( + _buildTitleText(session), + style: const TextStyle(color: Colors.white, fontSize: 15), + overflow: TextOverflow.ellipsis, + ), + ), + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + QualityBadge(quality: _activeQualityInfo(session)), + const SizedBox(width: 8), + NetworkSpeedIndicator(monitor: session.speedMonitor), + ], + ), + const SizedBox(height: 6), + const PlayerClockText(), + ], + ), + const SizedBox(width: 4), + PopupMenuButton( + icon: const Icon(Icons.more_vert, color: Colors.white), + onSelected: (value) { + switch (value) { + case 'fit:contain': + _setVideoFit(BoxFit.contain); + case 'fit:fill': + _setVideoFit(BoxFit.fill); + case 'fit:cover': + _setVideoFit(BoxFit.cover); + case 'pip': + unawaited(_enterAndroidPip()); + case 'debug_hud': + _debugHudVisible.value = !_debugHudVisible.value; + } + }, + itemBuilder: (context) => [ + CheckedPopupMenuItem( + value: 'fit:contain', + checked: _videoFit.value == BoxFit.contain, + child: const Text('画面比例 - 适应'), + ), + CheckedPopupMenuItem( + value: 'fit:fill', + checked: _videoFit.value == BoxFit.fill, + child: const Text('画面比例 - 拉伸'), + ), + CheckedPopupMenuItem( + value: 'fit:cover', + checked: _videoFit.value == BoxFit.cover, + child: const Text('画面比例 - 裁切'), + ), + if (_androidPip.isSupported && !_isAndroidLocked) + const PopupMenuItem(value: 'pip', child: Text('画中画')), + if (kDebugMode) + CheckedPopupMenuItem( + value: 'debug_hud', + checked: _debugHudVisible.value, + child: const Text('调试 HUD'), + ), + ], + ), + ]; + } + + Widget _buildAndroidBottomBar(PlaybackSession session) { + final seekSettings = ref.read(playerSettingsProvider).value; + final seekFwd = seekSettings?.seekForwardSeconds ?? 15; + final seekBwd = seekSettings?.seekBackwardSeconds ?? 15; + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + _AndroidTimeLabel( + player: session.engine, + showPosition: true, + seekPreview: _seekPreview, + ), + const SizedBox(width: 8), + Expanded( + child: DebouncedSeekBar( + player: session.engine, + onDragChanged: (t) => _seekPreview.value = t, + ), + ), + const SizedBox(width: 8), + _AndroidTimeLabel( + player: session.engine, + showPosition: false, + seekPreview: _seekPreview, + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(8, 4, 8, 0), + child: LayoutBuilder( + builder: (context, constraints) { + final hasVersionSwitch = + session.context.allMediaSources.length > 1 || + _crossServerCards.isNotEmpty; + final hasEpisode = session.context.seriesId != null; + + const alwaysVisibleWidth = 120.0 + 96.0 + 96.0; + + final secondaryMenuEntries = >[ + if (hasVersionSwitch) + PopupMenuItem( + value: _toggleVersionBar, + child: const ListTile( + leading: Icon(Icons.switch_video), + title: Text('切换版本'), + dense: true, + ), + ), + PopupMenuItem( + value: () => _togglePanel(PlayerPanelCategory.danmaku), + child: const ListTile( + leading: Icon(Icons.comment_outlined), + title: Text('弹幕'), + dense: true, + ), + ), + PopupMenuItem( + value: () => _togglePanel(PlayerPanelCategory.speed), + child: const ListTile( + leading: Icon(Icons.speed), + title: Text('倍速'), + dense: true, + ), + ), + if (hasEpisode) + PopupMenuItem( + value: () => _togglePanel(PlayerPanelCategory.episode), + child: const ListTile( + leading: Icon(Icons.playlist_play), + title: Text('选集'), + dense: true, + ), + ), + ]; + + final secondaryInlineCount = + (hasVersionSwitch ? 1 : 0) + 2 + (hasEpisode ? 1 : 0); + const iconButtonWidth = 48.0; + final overflowMenuButtonWidth = secondaryMenuEntries.isNotEmpty + ? iconButtonWidth + : 0.0; + final showAllInline = + constraints.maxWidth >= + alwaysVisibleWidth + + secondaryInlineCount * iconButtonWidth + + overflowMenuButtonWidth; + + return Row( + children: [ + AndroidBottomTransportControls( + player: session.engine, + hasPrev: _playlist?.hasPrev ?? false, + hasNext: _playlist?.hasNext ?? false, + onPrevItem: () => unawaited(_playlist!.prev()), + onNextItem: () => unawaited(_playlist!.next()), + ), + IconButton( + tooltip: '快退', + icon: const Icon( + Icons.fast_rewind, + color: Colors.white, + size: 24, + ), + onPressed: () => _seekBy(-seekBwd), + ), + IconButton( + tooltip: '快进', + icon: const Icon( + Icons.fast_forward, + color: Colors.white, + size: 24, + ), + onPressed: () => _seekBy(seekFwd), + ), + const Spacer(), + if (showAllInline) ...[ + if (hasVersionSwitch) + IconButton( + icon: const Icon( + Icons.switch_video, + color: Colors.white, + size: 24, + ), + onPressed: _toggleVersionBar, + tooltip: '切换版本', + ), + ], + IconButton( + icon: const Icon( + Icons.subtitles_outlined, + color: Colors.white, + size: 24, + ), + onPressed: () => _togglePanel(PlayerPanelCategory.subtitle), + tooltip: '字幕', + ), + IconButton( + icon: const Icon( + Icons.audiotrack, + color: Colors.white, + size: 24, + ), + onPressed: () => _togglePanel(PlayerPanelCategory.audio), + tooltip: '音轨', + ), + if (showAllInline) ...[ + IconButton( + icon: const Icon( + Icons.comment_outlined, + color: Colors.white, + size: 24, + ), + onPressed: () => + _togglePanel(PlayerPanelCategory.danmaku), + tooltip: '弹幕', + ), + IconButton( + icon: const Icon( + Icons.speed, + color: Colors.white, + size: 24, + ), + onPressed: () => _togglePanel(PlayerPanelCategory.speed), + tooltip: '倍速', + ), + if (hasEpisode) + IconButton( + icon: const Icon( + Icons.playlist_play, + color: Colors.white, + size: 24, + ), + onPressed: () => + _togglePanel(PlayerPanelCategory.episode), + tooltip: '选集', + ), + ] else if (secondaryMenuEntries.isNotEmpty) + PopupMenuButton( + icon: const Icon( + Icons.more_vert, + color: Colors.white, + size: 24, + ), + tooltip: '更多', + onSelected: (callback) => callback(), + itemBuilder: (_) => secondaryMenuEntries, + ), + ], + ); + }, + ), + ), + ], + ); + } + + Widget _buildAndroidLeftBar() { + return AndroidLockButton(onLock: () => _setAndroidLocked(true)); + } + + Widget _buildAndroidRightBar(PlaybackSession session) { + return AndroidSpeedStrip( + currentRate: _playbackRate, + onIncrement: _onAndroidSpeedIncrement, + onDecrement: _onAndroidSpeedDecrement, + onTapRate: () => _togglePanel(PlayerPanelCategory.speed), + ); + } + + Widget _buildAndroidGestureLayer( + PlaybackSession session, + VoidCallback toggleControls, + VoidCallback showControls, + ) { + return AndroidGestureLayer( + enabled: !_isAndroidLocked, + player: session.engine, + onToggleControls: toggleControls, + onDoubleTapCenter: () { + unawaited( + session.engine.state.playing + ? session.engine.pause() + : session.engine.play(), + ); + showControls(); + }, + onDoubleTapLeft: () { + final secs = + ref.read(playerSettingsProvider).value?.seekBackwardSeconds ?? 15; + _seekBy(-secs); + setState( + () => _androidFeedbackData = GestureFeedbackData( + kind: GestureFeedbackKind.doubleTapSeek, + doubleTapSeconds: secs, + doubleTapIsLeft: true, + ), + ); + Future.delayed(const Duration(milliseconds: 600), () { + if (mounted) { + setState( + () => _androidFeedbackData = const GestureFeedbackData.none(), + ); + } + }); + }, + onDoubleTapRight: () { + final secs = + ref.read(playerSettingsProvider).value?.seekForwardSeconds ?? 15; + _seekBy(secs); + setState( + () => _androidFeedbackData = GestureFeedbackData( + kind: GestureFeedbackKind.doubleTapSeek, + doubleTapSeconds: secs, + doubleTapIsLeft: false, + ), + ); + Future.delayed(const Duration(milliseconds: 600), () { + if (mounted) { + setState( + () => _androidFeedbackData = const GestureFeedbackData.none(), + ); + } + }); + }, + onBrightnessChange: (delta) async { + final gen = _brightnessGen; + try { + final current = await ScreenBrightness.instance.application; + if (!mounted || gen != _brightnessGen) return; + final next = (current + delta).clamp(0.0, 1.0); + await ScreenBrightness.instance.setApplicationScreenBrightness(next); + if (!mounted || gen != _brightnessGen) return; + _lastSetBrightness = next; + setState( + () => _androidFeedbackData = GestureFeedbackData( + kind: GestureFeedbackKind.brightness, + value: next, + ), + ); + } catch (_) {} + }, + onVolumeChange: (delta) { + final oldStep = (_volumeFraction * _maxVolumeLevel).round(); + _volumeFraction = (_volumeFraction + delta).clamp(0.0, 1.0); + final newStep = (_volumeFraction * _maxVolumeLevel).round(); + if (newStep != oldStep) { + unawaited( + SystemAudioController.instance.adjustVolume(newStep - oldStep), + ); + } + _systemVolumeOsdTimer?.cancel(); + setState( + () => _androidFeedbackData = GestureFeedbackData( + kind: GestureFeedbackKind.volume, + value: _volumeFraction, + ), + ); + }, + onVerticalDragEnd: () { + _brightnessGen++; + if (_lastSetBrightness != null) { + unawaited( + _playerSettingsNotifier.setAndroidBrightness(_lastSetBrightness!), + ); + _lastSetBrightness = null; + } + setState(() => _androidFeedbackData = const GestureFeedbackData.none()); + }, + onHorizontalSeekStart: () {}, + onHorizontalSeekUpdate: (seekDelta) { + final current = session.engine.state.position; + final duration = session.engine.state.duration; + final target = current + seekDelta; + final clamped = target < Duration.zero + ? Duration.zero + : (target > duration ? duration : target); + _seekPreview.value = clamped; + setState( + () => _androidFeedbackData = GestureFeedbackData( + kind: GestureFeedbackKind.seek, + seekTarget: clamped, + totalDuration: duration, + seekIsForward: !seekDelta.isNegative, + ), + ); + }, + onHorizontalSeekEnd: () { + final target = _seekPreview.value; + if (target != null) { + unawaited(session.engine.seek(target)); + } + _seekPreview.value = null; + setState(() => _androidFeedbackData = const GestureFeedbackData.none()); + }, + onLongPressStart: (isLeftSide) { + final settings = ref.read(playerSettingsProvider).value; + final holdSpeed = isLeftSide + ? (settings?.keyboardHoldLeftSpeed ?? 0.5) + : (settings?.keyboardHoldRightSpeed ?? 3.0); + _savedRateBeforeLongPress = _playbackRate; + _setPlaybackRate(holdSpeed); + setState( + () => _androidFeedbackData = GestureFeedbackData( + kind: GestureFeedbackKind.longPressSpeed, + speedRate: holdSpeed, + ), + ); + }, + onLongPressEnd: () { + final restore = _savedRateBeforeLongPress ?? 1.0; + _savedRateBeforeLongPress = null; + _setPlaybackRate(restore); + if (_androidFeedbackData.kind == GestureFeedbackKind.longPressSpeed) { + setState( + () => _androidFeedbackData = const GestureFeedbackData.none(), + ); + } + }, + ); + } + + Widget _buildAndroidGestureFeedback() { + return AndroidGestureFeedback(data: _androidFeedbackData); + } + + Widget _buildAndroidDrawerLayer(PlaybackSession session) { + return AnimatedBuilder( + animation: _drawer, + builder: (context, _) { + final category = _drawer.category; + if (category == null) return const SizedBox.shrink(); + return Stack( + children: [ + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _drawer.close, + child: const ColoredBox( + color: Colors.black38, + child: SizedBox.expand(), + ), + ), + Positioned( + right: 0, + top: 0, + bottom: 0, + width: 320, + child: Material( + color: const Color(0xF0181818), + child: PlayerPanelShell( + icon: panelCategoryIcon(category), + title: panelCategoryLabel(category), + width: double.infinity, + onClose: _drawer.close, + child: _panelBody(session, category), + ), + ), + ), + ], + ); + }, + ); + } +} + +class _AndroidTimeLabel extends StatefulWidget { + const _AndroidTimeLabel({ + required this.player, + required this.showPosition, + required this.seekPreview, + }); + + final PlayerEngine player; + final bool showPosition; + final ValueNotifier seekPreview; + + @override + State<_AndroidTimeLabel> createState() => _AndroidTimeLabelState(); +} + +class _AndroidTimeLabelState extends State<_AndroidTimeLabel> { + late Duration _position; + late Duration _duration; + StreamSubscription? _posSub; + StreamSubscription? _durSub; + + @override + void initState() { + super.initState(); + _position = widget.player.state.position; + _duration = widget.player.state.duration; + _posSub = widget.player.stream.position.listen((p) { + if (mounted) setState(() => _position = p); + }); + _durSub = widget.player.stream.duration.listen((d) { + if (mounted) setState(() => _duration = d); + }); + if (widget.showPosition) { + widget.seekPreview.addListener(_onSeekPreviewChanged); + } + } + + @override + void didUpdateWidget(covariant _AndroidTimeLabel oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.player != widget.player) { + unawaited(_posSub?.cancel()); + unawaited(_durSub?.cancel()); + _position = widget.player.state.position; + _duration = widget.player.state.duration; + _posSub = widget.player.stream.position.listen((p) { + if (mounted) setState(() => _position = p); + }); + _durSub = widget.player.stream.duration.listen((d) { + if (mounted) setState(() => _duration = d); + }); + } + if (oldWidget.seekPreview != widget.seekPreview) { + oldWidget.seekPreview.removeListener(_onSeekPreviewChanged); + if (widget.showPosition) { + widget.seekPreview.addListener(_onSeekPreviewChanged); + } + } + } + + void _onSeekPreviewChanged() { + if (mounted) setState(() {}); + } + + @override + void dispose() { + unawaited(_posSub?.cancel()); + unawaited(_durSub?.cancel()); + widget.seekPreview.removeListener(_onSeekPreviewChanged); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final Duration display; + if (widget.showPosition) { + display = widget.seekPreview.value ?? _position; + } else { + display = _duration; + } + return Text( + formatDurationClock(display), + style: const TextStyle( + color: Colors.white70, + fontSize: 12, + fontFeatures: [FontFeature.tabularFigures()], + ), + ); + } +} diff --git a/lib/features/player/player_page_bottom_bar.dart b/lib/features/player/player_page_bottom_bar.dart new file mode 100644 index 0000000..bdb1742 --- /dev/null +++ b/lib/features/player/player_page_bottom_bar.dart @@ -0,0 +1,465 @@ + + +part of 'player_page.dart'; + +extension _PlayerPageBottomBar on _PlayerPageState { + Widget _buildBottomBar(PlaybackSession session) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (!_pip.isPip) _buildLogoRow(session), + DebouncedSeekBar( + player: session.engine, + onDragChanged: (t) => _seekPreview.value = t, + ), + SizedBox( + height: _pip.isPip ? 32 : 52, + child: ValueListenableBuilder( + valueListenable: _barVersion, + builder: (context, _, _) { + if (_pip.isPip) return _buildPipBottomRow(session); + return _buildFullBottomRow(session); + }, + ), + ), + ], + ); + } + + Widget _buildLogoRow(PlaybackSession session) { + final logoUrl = session.context.logoUrl; + if (logoUrl == null) return const SizedBox.shrink(); + final imageHeaders = ref.watch(embyImageHeadersProvider).value; + return Padding( + padding: const EdgeInsets.only(left: 16, bottom: 8), + child: Align( + alignment: Alignment.centerLeft, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 200, maxHeight: 48), + child: CachedNetworkImage( + imageUrl: logoUrl, + httpHeaders: imageHeaders, + fit: BoxFit.contain, + alignment: Alignment.bottomLeft, + memCacheWidth: 600, + fadeInDuration: const Duration(milliseconds: 200), + placeholder: (_, _) => const SizedBox.shrink(), + errorWidget: (_, _, _) => const SizedBox.shrink(), + ), + ), + ), + ); + } + + + List _transportControls( + PlaybackSession session, { + double? iconSize, + bool compact = false, + }) { + final pl = _playlist; + final hasPrev = pl?.hasPrev ?? false; + final hasNext = pl?.hasNext ?? false; + final btnConstraints = compact + ? const BoxConstraints(minWidth: 36, minHeight: 36) + : null; + final btnPadding = compact ? EdgeInsets.zero : null; + return [ + IconButton( + onPressed: hasPrev ? () => unawaited(pl!.prev()) : null, + icon: Icon( + Icons.skip_previous, + color: hasPrev ? Colors.white : Colors.white38, + size: iconSize, + ), + tooltip: '上一集', + constraints: btnConstraints, + padding: btnPadding, + ), + PlayerPlayPauseButton( + player: session.engine, + iconSize: compact ? 24.0 : null, + constraints: btnConstraints, + ), + IconButton( + onPressed: hasNext ? () => unawaited(pl!.next()) : null, + icon: Icon( + Icons.skip_next, + color: hasNext ? Colors.white : Colors.white38, + size: iconSize, + ), + tooltip: '下一集', + constraints: btnConstraints, + padding: btnPadding, + ), + ]; + } + + Widget _buildPipBottomRow(PlaybackSession session) { + return Row( + children: [ + const Spacer(), + PlayerPositionIndicator( + player: session.engine, + seekPreview: _seekPreview, + ), + const SizedBox(width: 4), + IconButton( + onPressed: () => unawaited(_togglePip()), + icon: const Icon(Icons.open_in_full, color: Colors.white, size: 20), + tooltip: '退出画中画 (P)', + constraints: const BoxConstraints(minWidth: 32, minHeight: 32), + padding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + ), + const SizedBox(width: 8), + ], + ); + } + + Widget _buildPipCenterControls(PlaybackSession session) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () {}, + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.35), + borderRadius: BorderRadius.circular(40), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: _transportControls(session, iconSize: 26, compact: true), + ), + ), + ), + ); + } + + Widget _buildFullBottomRow(PlaybackSession session) { + final allMediaSources = session.context.allMediaSources; + final embySubtitles = session.context.embySubtitles; + final hasPicker = session.context.seriesId != null; + + final compactRow = Row( + children: [ + ..._transportControls(session, iconSize: 24, compact: true), + Expanded( + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: PlayerPositionIndicator( + player: session.engine, + seekPreview: _seekPreview, + ), + ), + ), + _compactMoreButton(session), + IconButton( + tooltip: _fullscreen.isFullscreen ? '退出全屏' : '全屏', + constraints: const BoxConstraints(minWidth: 40, minHeight: 40), + padding: EdgeInsets.zero, + onPressed: () => unawaited(_toggleFullscreen()), + icon: Icon( + _fullscreen.isFullscreen ? Icons.fullscreen_exit : Icons.fullscreen, + color: Colors.white, + size: 28, + ), + ), + ], + ); + + final row = Row( + children: [ + ..._transportControls(session), + if (!Platform.isAndroid) + PlayerVolumeButton( + player: session.engine, + volumeBoost: ref.watch( + audioSettingsProvider.select( + (s) => s.value?.volumeBoost ?? false, + ), + ), + ), + PlayerPositionIndicator( + player: session.engine, + seekPreview: _seekPreview, + ), + const Spacer(), + if (allMediaSources.length > 1 || _crossServerCards.isNotEmpty) + ValueListenableBuilder( + valueListenable: _versionBarOpen, + builder: (context, open, _) => PanelToggleAffordance( + isOpen: open, + onTap: _toggleVersionBar, + tooltip: '切换版本', + child: const Icon( + Icons.switch_video, + color: Colors.white, + size: 26, + ), + ), + ), + CompositedTransformTarget( + link: _panelLinks[PlayerPanelCategory.subtitle]!, + child: SubtitleTracksButton( + player: session.engine, + embySubtitles: embySubtitles, + isPanelOpen: _drawer.category == PlayerPanelCategory.subtitle, + onTogglePanel: () => _togglePanel(PlayerPanelCategory.subtitle), + ), + ), + CompositedTransformTarget( + link: _panelLinks[PlayerPanelCategory.audio]!, + child: AudioTracksButton( + player: session.engine, + isPanelOpen: _drawer.category == PlayerPanelCategory.audio, + onTogglePanel: () => _togglePanel(PlayerPanelCategory.audio), + ), + ), + CompositedTransformTarget( + link: _panelLinks[PlayerPanelCategory.speed]!, + child: SpeedButton( + currentRate: _playbackRate, + isPanelOpen: _drawer.category == PlayerPanelCategory.speed, + onTogglePanel: () => _togglePanel(PlayerPanelCategory.speed), + ), + ), + CompositedTransformTarget( + link: _panelLinks[PlayerPanelCategory.fit]!, + child: FitButton( + currentFit: _videoFit.value, + isPanelOpen: _drawer.category == PlayerPanelCategory.fit, + onTogglePanel: () => _togglePanel(PlayerPanelCategory.fit), + ), + ), + if (hasPicker) _panelIcon(PlayerPanelCategory.episode), + _panelIcon(PlayerPanelCategory.danmaku), + if (_isDesktopWindow) + IconButton( + onPressed: () => unawaited(_togglePip()), + icon: const Icon( + Icons.picture_in_picture, + color: Colors.white, + size: 24, + ), + tooltip: '画中画 (P)', + ), + IconButton( + tooltip: _fullscreen.isFullscreen ? '退出全屏' : '全屏', + onPressed: () => unawaited(_toggleFullscreen()), + icon: Icon( + _fullscreen.isFullscreen + ? Icons.fullscreen_exit + : Icons.fullscreen, + color: Colors.white, + size: 28, + ), + ), + ], + ); + + return LayoutBuilder( + builder: (context, constraints) { + if (constraints.maxWidth < Breakpoints.compact) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: compactRow, + ); + } + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: ConstrainedBox( + constraints: BoxConstraints(minWidth: constraints.maxWidth), + child: IntrinsicWidth(child: row), + ), + ); + }, + ); + } + + Widget _compactMoreButton(PlaybackSession session) { + final hasVersion = + session.context.allMediaSources.length > 1 || + _crossServerCards.isNotEmpty; + final hasPicker = session.context.seriesId != null; + final items = <_CompactMoreItem>[ + if (hasVersion) + _CompactMoreItem( + icon: Icons.switch_video, + label: '切换版本', + onTap: _toggleVersionBar, + ), + _CompactMoreItem.panel( + PlayerPanelCategory.subtitle, + (c) => _showCompactPanelSheet(session, c), + ), + _CompactMoreItem.panel( + PlayerPanelCategory.audio, + (c) => _showCompactPanelSheet(session, c), + ), + _CompactMoreItem.panel( + PlayerPanelCategory.speed, + (c) => _showCompactPanelSheet(session, c), + ), + _CompactMoreItem.panel( + PlayerPanelCategory.fit, + (c) => _showCompactPanelSheet(session, c), + ), + if (hasPicker) + _CompactMoreItem.panel( + PlayerPanelCategory.episode, + (c) => _showCompactPanelSheet(session, c), + ), + _CompactMoreItem.panel( + PlayerPanelCategory.danmaku, + (c) => _showCompactPanelSheet(session, c), + ), + ]; + + return IconButton( + tooltip: '更多', + constraints: const BoxConstraints(minWidth: 40, minHeight: 40), + padding: EdgeInsets.zero, + icon: const Icon(Icons.more_horiz, color: Colors.white, size: 26), + onPressed: () => _showCompactMoreSheet(items), + ); + } + + Future _showCompactPanelSheet( + PlaybackSession session, + PlayerPanelCategory category, + ) async { + _versionBarOpen.value = false; + _drawer.close(); + await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) { + if (category == PlayerPanelCategory.episode) { + WidgetsBinding.instance.addPostFrameCallback( + (_) => _episodeBodyKey.currentState?.ensureLoaded(), + ); + } + return SafeArea( + top: false, + child: Container( + margin: const EdgeInsets.fromLTRB(12, 0, 12, 12), + constraints: BoxConstraints( + maxHeight: MediaQuery.sizeOf(context).height * 0.72, + ), + child: PlayerPanelShell( + icon: panelCategoryIcon(category), + title: panelCategoryLabel(category), + width: double.infinity, + onClose: () => Navigator.of(context).pop(), + child: _panelBody(session, category), + ), + ), + ); + }, + ); + } + + Future _showCompactMoreSheet(List<_CompactMoreItem> items) async { + await showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + builder: (sheetContext) => SafeArea( + top: false, + child: Container( + margin: const EdgeInsets.fromLTRB(12, 0, 12, 12), + decoration: BoxDecoration( + color: kPanelBg, + borderRadius: BorderRadius.circular(18), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 8), + child: Row( + children: [ + const Text( + '更多控制', + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + const Spacer(), + IconButton( + onPressed: () => Navigator.of(sheetContext).pop(), + icon: const Icon( + Icons.close, + color: Colors.white70, + size: 20, + ), + ), + ], + ), + ), + panelDivider(), + ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.sizeOf(context).height * 0.56, + ), + child: ListView.separated( + shrinkWrap: true, + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: items.length, + separatorBuilder: (_, _) => + panelDivider(), + itemBuilder: (context, i) { + final item = items[i]; + return ListTile( + leading: Icon(item.icon, color: Colors.white70), + title: Text( + item.label, + style: const TextStyle(color: Colors.white), + ), + trailing: const Icon( + Icons.chevron_right_rounded, + color: Colors.white38, + ), + onTap: () { + Navigator.of(sheetContext).pop(); + item.onTap(); + }, + ); + }, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _CompactMoreItem { + final IconData icon; + final String label; + final VoidCallback onTap; + + const _CompactMoreItem({ + required this.icon, + required this.label, + required this.onTap, + }); + + factory _CompactMoreItem.panel( + PlayerPanelCategory category, + ValueChanged onToggle, + ) { + return _CompactMoreItem( + icon: panelCategoryIcon(category), + label: panelCategoryLabel(category), + onTap: () => onToggle(category), + ); + } +} diff --git a/lib/features/player/player_page_danmaku.dart b/lib/features/player/player_page_danmaku.dart new file mode 100644 index 0000000..ed99674 --- /dev/null +++ b/lib/features/player/player_page_danmaku.dart @@ -0,0 +1,164 @@ + + +part of 'player_page.dart'; + + +bool _isDanmakuLayoutChange(DanmakuPanelSettings a, DanmakuPanelSettings b) { + return a.maxRows != b.maxRows || + a.fontSize != b.fontSize || + a.lineHeight != b.lineHeight || + a.fontFamily != b.fontFamily || + a.area != b.area; +} + +void _syncDanmakuFeederForSession( + DanmakuFeeder feeder, + PlaybackSession session, + double posSec, { + bool clear = false, +}) { + feeder.seekTo(posSec, clear: clear); + if (session.isDanmakuActive) { + feeder.playFrom(posSec); + } else { + feeder.freezeAt(posSec); + } +} + +extension _PlayerPageDanmaku on _PlayerPageState { + DanmakuOption _buildDanmakuOption(DanmakuPanelSettings settings) { + final base = settings.toDanmakuOption(screenHeight: _danmakuOverlayHeight); + if (_playbackRate == 1.0 || _playbackRate <= 0) return base; + return base.copyWith(duration: base.duration / _playbackRate); + } + + void _applyDanmakuPanelSettings(DanmakuPanelSettings settings) { + _panelSettings = settings; + _feeder.setPanelSettings(settings, option: _buildDanmakuOption(settings)); + } + + + void _wireDanmakuListeners() { + final session = ref.read(danmakuSessionProvider).value; + if (session != null) { + _sessionState = session; + if (session is DanmakuSessionMatched && + session.commentStatus == DanmakuCommentStatus.ready && + !identical(session, _lastSyncedDanmakuSession)) { + _lastSyncedDanmakuSession = session; + _feeder.setComments(session.comments); + final s = _session; + if (s != null) { + _syncDanmakuFeederForSession( + _feeder, + s, + s.engine.state.position.inMilliseconds / 1000.0, + ); + } + } + } + final settingsPanel = ref + .read(danmakuSettingsProvider) + .value + ?.panelSettings; + if (settingsPanel != null && settingsPanel != _panelSettings) { + _applyDanmakuPanelSettings(settingsPanel); + } + + ref.listen>(danmakuSessionProvider, ( + prev, + next, + ) { + final state = next.value; + if (state == null) return; + _sessionState = state; + + if (state is DanmakuSessionMatched && + state.commentStatus == DanmakuCommentStatus.ready) { + _lastSyncedDanmakuSession = state; + _feeder.setComments(state.comments); + final s = _session; + if (s != null) { + final posSec = s.engine.state.position.inMilliseconds / 1000.0; + _syncDanmakuFeederForSession(_feeder, s, posSec); + } + } else { + _lastSyncedDanmakuSession = null; + _feeder.setComments(const []); + } + + _invalidateBottomBar(); + _refreshPanel(); + _invalidateDanmakuOverlay(); + }); + + ref.listen>(danmakuSettingsProvider, ( + prev, + next, + ) { + final settings = next.value; + if (settings == null) return; + final newPanel = settings.panelSettings; + final oldPanel = _panelSettings; + final panelChanged = oldPanel != newPanel; + final previousSettings = prev?.value; + final keywordsChanged = + previousSettings != null && + !listEquals( + previousSettings.blockedKeywords, + settings.blockedKeywords, + ); + + final enabledChanged = oldPanel.enabled != newPanel.enabled; + final offsetChanged = oldPanel.offset != newPanel.offset; + final layoutChanged = _isDanmakuLayoutChange(oldPanel, newPanel); + + + final opacityChanged = oldPanel.opacity != newPanel.opacity; + if (panelChanged) { + _applyDanmakuPanelSettings(newPanel); + if (enabledChanged) { + _invalidateBottomBar(); + } + _invalidateDanmakuOverlay(); + final s = _session; + if ((enabledChanged || + offsetChanged || + layoutChanged || + opacityChanged) && + s != null) { + final posSec = s.engine.state.position.inMilliseconds / 1000.0; + _syncDanmakuFeederForSession(_feeder, s, posSec, clear: true); + } + } + if (panelChanged || keywordsChanged) { + _refreshPanel(); + } + }); + } + + bool get _danmakuLoaded => + _sessionState is DanmakuSessionMatched || + _sessionState is DanmakuSessionEmpty; + + bool get _danmakuSearching => + _sessionState is DanmakuSessionMatching || + (_sessionState is DanmakuSessionMatched && + (_sessionState as DanmakuSessionMatched).commentStatus == + DanmakuCommentStatus.loading); + + List get _danmakuMatches { + final s = _sessionState; + return s is DanmakuSessionMatched ? s.matches : const []; + } + + int get _danmakuSelectedMatchIndex { + final s = _sessionState; + return s is DanmakuSessionMatched ? s.selectedIndex : -1; + } + + int get _danmakuCommentCount { + final s = _sessionState; + return s is DanmakuSessionMatched ? s.comments.length : 0; + } +} diff --git a/lib/features/player/player_page_lifecycle.dart b/lib/features/player/player_page_lifecycle.dart new file mode 100644 index 0000000..6dbb7f4 --- /dev/null +++ b/lib/features/player/player_page_lifecycle.dart @@ -0,0 +1,308 @@ + + +part of 'player_page.dart'; + +extension _PlayerPageLifecycle on _PlayerPageState { + void _onPanelChanged() { + if (_drawer.isOpen && _drawer.category == PlayerPanelCategory.episode) { + WidgetsBinding.instance.addPostFrameCallback( + (_) => _episodeBodyKey.currentState?.ensureLoaded(), + ); + } + if (mounted) setState(() {}); + } + + Future _initialize() async { + try { + final directStream = widget.directStream; + final activeSession = ref.read(activeSessionProvider); + if (activeSession == null && directStream == null) { + if (mounted) { + setState(() => _errorMessage = '没有活跃的会话'); + } + return; + } + AppLogger.debug( + _tag, + directStream != null + ? 'init direct stream' + : 'init server=${activeSession!.serverUrl} ' + 'userId=${activeSession.userId}', + ); + + _prewarmPlayerEngine(); + + final bootstrap = await ( + ref.read(deviceIdProvider.future), + ref.read(appVersionProvider.future), + ref.read(playbackResolverProvider.future), + ref.read(embyGatewayProvider.future), + ).wait; + final deviceId = bootstrap.$1; + final appVersion = bootstrap.$2; + final resolver = bootstrap.$3; + final gateway = bootstrap.$4; + if (!mounted) return; + + final traktDepsFuture = directStream == null + ? _loadTraktDeps() + : Future.value(null); + final prefetchFuture = directStream == null + ? _loadPrefetchedPlaybackInfo(activeSession!) + : Future.value(null); + + final scrobbleStatus = ref.read(traktScrobbleStatusProvider.notifier); + + final pendingSeq = ref.read(pendingCrossServerSequenceProvider); + if (pendingSeq != null) { + ref.read(pendingCrossServerSequenceProvider.notifier).state = null; + } + + final settingsFuture = ( + ref + .read(subtitleSettingsProvider.future) + .then( + (value) => value, + onError: (Object e) { + AppLogger.warn(_tag, 'load subtitle settings failed', e); + return const SubtitleStyleSettings(); + }, + ), + Future.value( + ref.read(playerSettingsProvider).value ?? const PlayerSettingsState(), + ), + ref + .read(danmakuSettingsProvider.future) + .then( + (value) => value, + onError: (Object e) { + AppLogger.warn(_tag, 'load danmaku settings failed', e); + return const DanmakuSettingsState(); + }, + ), + ref + .read(audioSettingsProvider.future) + .then( + (value) => value, + onError: (Object e) { + AppLogger.warn(_tag, 'load audio settings failed', e); + return const AudioSettingsState(); + }, + ), + ).wait; + final Future playlistFuture; + if (directStream != null) { + final itemType = directStream.episode != null ? 'Episode' : 'Movie'; + final syntheticItem = EmbyRawItem( + Id: widget.itemId, + Name: directStream.title, + Type: itemType, + SeriesName: directStream.seriesName, + IndexNumber: directStream.episode, + RunTimeTicks: directStream.durationSeconds == null + ? null + : directStream.durationSeconds! * kTicksPerSecond, + ); + playlistFuture = Future.value( + SingleItemPlaylist( + item: PlaylistItem( + itemId: widget.itemId, + displayLabel: directStream.title, + playFromStart: true, + preloadedItem: syntheticItem, + ), + ), + ); + } else { + playlistFuture = playlistFromLaunch( + itemId: widget.itemId, + mediaSourceId: widget.mediaSourceId, + startPositionTicks: widget.startPositionTicks, + preferredAudioStreamIndex: widget.preferredAudioStreamIndex, + preferredSubtitleStreamIndex: widget.preferredSubtitleStreamIndex, + playFromStart: widget.fromStart, + crossServerSequence: pendingSeq, + loadDetail: (id) { + final serverId = ref.read(activeSessionProvider)?.serverId ?? ''; + return ref.read( + mediaDetailDataProvider((serverId: serverId, itemId: id)).future, + ); + }, + loadEpisodes: ({required seriesId, required seasonId}) { + final serverId = ref.read(activeSessionProvider)?.serverId ?? ''; + return ref.read( + seriesEpisodesDataProvider(( + serverId: serverId, + seriesId: seriesId, + seasonId: seasonId, + )).future, + ); + }, + loadSeasons: (seriesId) { + final serverId = ref.read(activeSessionProvider)?.serverId ?? ''; + return ref.read( + seriesSeasonsDataProvider(( + serverId: serverId, + seriesId: seriesId, + )).future, + ); + }, + ); + } + final ready = await (settingsFuture, playlistFuture).wait; + final settings = ready.$1; + _subtitleSettings = settings.$1; + final playerSettings = + ref.read(playerSettingsProvider).value ?? settings.$2; + final danmakuSettings = settings.$3; + if (!mounted) return; + + final audioSettings = settings.$4; + _prefetchedPlaybackInfo = await prefetchFuture; + final traktDeps = await traktDepsFuture; + if (!mounted) return; + + _builder = PlaybackSessionBuilder( + resolver: resolver, + gateway: gateway, + metadataResolver: MediaSourceMetadataResolver( + probeService: MediaProbeService.instance, + ), + engineHolder: _engineHolder, + readActiveSession: () { + + + if (!mounted) throw const CancelledException('player unmounted'); + return ref.read(activeSessionProvider)!; + }, + deviceId: deviceId, + deviceName: kEmbyDeviceName, + playerSettingsNotifier: _playerSettingsNotifier, + audioSettingsNotifier: _audioSettingsNotifier, + subtitleSettingsNotifier: ref.read(subtitleSettingsProvider.notifier), + loadItemDetail: (serverId, itemId) async { + if (!mounted) throw const CancelledException('player unmounted'); + final res = await ref.read( + mediaDetailDataProvider(( + serverId: serverId, + itemId: itemId, + )).future, + ); + return res.base; + }, + readEngineOverride: () { + if (!mounted) throw const CancelledException('player unmounted'); + return ref.read(playerEngineOverrideProvider).value ?? + PlayerEngineOverride.auto; + }, + traktGateway: traktDeps?.gateway, + traktQueue: traktDeps?.queue, + traktAppVersion: appVersion, + traktOnSuccess: scrobbleStatus.recordSuccess, + traktOnError: scrobbleStatus.recordError, + ); + + _initialPlayerSettings = playerSettings; + _initialDanmakuSettings = danmakuSettings; + _initialAudioSettings = audioSettings; + _panelSettings = danmakuSettings.panelSettings; + + _playlist = ready.$2; + if (!mounted) { + _playlist?.dispose(); + _playlist = null; + return; + } + _playlist!.current.addListener(_onCurrentItemChanged); + _onCurrentItemChanged(); + } catch (e, s) { + AppLogger.error(_tag, 'initialize failed', e, s); + if (mounted) setState(() => _errorMessage = formatUserError(e)); + } + } + + + void _prewarmPlayerEngine() { + if (_engineHolder.current != null) return; + final override = + ref.read(playerEngineOverrideProvider).value ?? + PlayerEngineOverride.auto; + final kind = resolvePlayerEngineKind( + override, + isDolbyVision: false, + videoCodec: null, + ); + unawaited(() async { + try { + final engine = await _engineHolder.acquire( + kind, + () => PlaybackSessionBuilder.createEngine(kind), + ); + AppLogger.debug(_tag, 'engine prewarmed kind=${kind.name}'); + if (engine is Media3PlayerEngine) { + await engine.prewarmNative(); + } + } catch (e) { + AppLogger.debug(_tag, 'engine prewarm failed', e); + } + }()); + } + + Future<({AuthedTraktGateway gateway, TraktPendingSyncQueue queue})?> + _loadTraktDeps() async { + try { + final traktGateway = ref.read(authedTraktGatewayProvider); + final queue = await ref.read(traktSyncQueueProvider.future); + unawaited(queue.drain()); + return (gateway: traktGateway, queue: queue); + } catch (e) { + AppLogger.warn(_tag, 'trakt deps unavailable, scrobble disabled', e); + return null; + } + } + + Future<({int startTimeTicks, Map info})?> + _loadPrefetchedPlaybackInfo(AuthedSession activeSession) async { + try { + final startTimeTicks = widget.fromStart + ? 0 + : (widget.startPositionTicks ?? 0); + final serverId = widget.serverId ?? activeSession.serverId; + final key = ( + serverId: serverId, + itemId: widget.itemId, + mediaSourceId: widget.mediaSourceId, + startTimeTicks: startTimeTicks, + ); + AppLogger.debug( + _tag, + 'playbackInfo prefetch await ${describePlaybackInfoPrefetchKey(key)}', + ); + final prefetched = await ref.read( + playbackInfoPrefetchProvider(key).future, + ); + if (prefetched == null || prefetched.info.isEmpty) { + AppLogger.debug( + _tag, + 'playbackInfo prefetch miss ${describePlaybackInfoPrefetchKey(key)}', + ); + return null; + } + AppLogger.debug( + _tag, + 'playbackInfo prefetch hit ${describePlaybackInfoPrefetchKey(key)}', + ); + return (startTimeTicks: prefetched.startTimeTicks, info: prefetched.info); + } catch (e) { + AppLogger.debug(_tag, 'playbackInfo prefetch failed; fallback', e); + return null; + } + } + + int _prefetchStartTimeTicksFor(PlaybackRequest req) { + if (req.playFromStart) return 0; + final explicitTicks = req.startPositionTicks; + if (explicitTicks != null && explicitTicks > 0) return explicitTicks; + return req.preloadedItem?.UserData?.PlaybackPositionTicks ?? 0; + } +} diff --git a/lib/features/player/player_page_overlays.dart b/lib/features/player/player_page_overlays.dart new file mode 100644 index 0000000..5abd726 --- /dev/null +++ b/lib/features/player/player_page_overlays.dart @@ -0,0 +1,121 @@ + + +part of 'player_page.dart'; + +extension _PlayerPageOverlays on _PlayerPageState { + List _buildVideoOverlays(PlaybackSession session) { + return [ + Positioned( + top: 20, + left: 0, + right: 0, + bottom: 0, + child: ValueListenableBuilder( + valueListenable: _danmakuOverlayVersion, + builder: (context, value, child) { + if (!_danmakuLoaded || !_panelSettings.enabled) { + return const SizedBox.shrink(); + } + return IgnorePointer( + child: ValueListenableBuilder( + valueListenable: _bufferingOverlayVisible, + builder: (context, buffering, child) { + return Opacity(opacity: buffering ? 0.0 : 1.0, child: child); + }, + child: LayoutBuilder( + builder: (context, constraints) { + final screenHeight = constraints.maxHeight.isFinite + ? constraints.maxHeight + : null; + _danmakuOverlayHeight = screenHeight; + final option = _buildDanmakuOption(_panelSettings); + if (screenHeight != null && + (screenHeight != _danmakuOverlaySyncedHeight || + !identical( + _panelSettings, + _danmakuOverlaySyncedSettings, + ))) { + _danmakuOverlaySyncedHeight = screenHeight; + _danmakuOverlaySyncedSettings = _panelSettings; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _feeder.setPanelSettings( + _panelSettings, + option: _buildDanmakuOption(_panelSettings), + ); + }); + } + return DanmakuScreen( + option: option, + createdController: (c) { + _feeder.attach(c); + _feeder.setPanelSettings( + _panelSettings, + option: option, + ); + final pos = session.engine.state.position; + _syncDanmakuFeederForSession( + _feeder, + session, + pos.inMilliseconds / 1000.0, + ); + }, + ); + }, + ), + ), + ); + }, + ), + ), + Positioned.fill( + child: ValueListenableBuilder( + valueListenable: _subtitleOverlayVersion, + builder: (context, value, child) { + return StyledSubtitleOverlay( + player: session.engine, + settings: _subtitleSettings, + externalLines: session.externalSubtitleLines, + externalActive: session.activeExternalSubtitle, + ); + }, + ), + ), + Positioned.fill( + child: BufferingOverlay( + player: session.engine, + speedMonitor: session.speedMonitor, + suppress: _switchInFlight, + onBufferingChanged: (value) { + if (mounted) _bufferingOverlayVisible.value = value; + }, + ), + ), + ValueListenableBuilder( + valueListenable: _debugHudVisible, + builder: (context, visible, _) { + if (!visible) return const SizedBox.shrink(); + return PlayerDebugHud( + engine: session.engine, + frameMonitor: _frameJankMonitor, + ); + }, + ), + ]; + } + + + List _buildPipOverlays(PlaybackSession session) { + return [ + Positioned.fill( + child: BufferingOverlay( + player: session.engine, + speedMonitor: session.speedMonitor, + onBufferingChanged: (value) { + if (mounted) _bufferingOverlayVisible.value = value; + }, + ), + ), + ]; + } +} diff --git a/lib/features/player/player_page_relink.dart b/lib/features/player/player_page_relink.dart new file mode 100644 index 0000000..dbe29f2 --- /dev/null +++ b/lib/features/player/player_page_relink.dart @@ -0,0 +1,87 @@ + + +part of 'player_page.dart'; + +extension _PlayerPageRelink on _PlayerPageState { + + + bool _handleStreamFailure() { + if (!mounted || + _switchInFlight || + _engineFallbackInFlight || + _relink.pending) { + return false; + } + final s = _session; + if (s == null || + s.phase.value != PlaybackPhase.playing || + !s.engine.state.playing) { + return false; + } + + final ticks = ticksFromDuration(s.engine.state.position); + + + if (s.lastFailureTerminal) { + AppLogger.warn(_tag, 'stream failure terminal → skip relink, surface error'); + _rememberFailurePosition(ticks); + s.phase.removeListener(_onSessionPhaseChanged); + _attachWakelockSession(null); + unawaited(s.dispose()); + setState(() { + _session = null; + _errorMessage = '当前版本源无法播放(服务器拒绝,HTTP 4xx)。请切换其他版本源重试。'; + }); + _invalidateBottomBar(); + return true; + } + + _relink.schedule( + onGiveUp: () { + AppLogger.warn( + _tag, + 'relink gave up after ${_relink.attempts} attempts', + ); + _rememberFailurePosition(ticks); + s.phase.removeListener(_onSessionPhaseChanged); + _attachWakelockSession(null); + unawaited(s.dispose()); + setState(() { + _session = null; + _errorMessage = '播放链接已失效,多次重连失败'; + }); + _invalidateBottomBar(); + }, + onRelink: (delay) { + AppLogger.debug( + _tag, + 'relink scheduled attempt=${_relink.attempts} ' + 'delay=${delay.inSeconds}s posTicks=$ticks', + ); + final base = _lastRequest; + if (!mounted || base == null) return; + final req = base.copyWith( + startPositionTicks: ticks > 0 ? ticks : null, + playFromStart: false, + ); + unawaited(_switchTo(req)); + }, + ); + return true; + } + + + void _rememberFailurePosition(int ticks) { + final base = _lastRequest; + if (base == null || ticks <= 0) return; + _lastRequest = base.copyWith( + startPositionTicks: ticks, + playFromStart: false, + ); + } + + + void _armRelinkSustain() { + _relink.armSustain(); + } +} diff --git a/lib/features/player/player_page_session.dart b/lib/features/player/player_page_session.dart new file mode 100644 index 0000000..3e5368d --- /dev/null +++ b/lib/features/player/player_page_session.dart @@ -0,0 +1,381 @@ + + +part of 'player_page.dart'; + +extension _PlayerPageSession on _PlayerPageState { + Future _switchTo( + PlaybackRequest req, { + PlayerEngineKind? forcedEngineKind, + }) async { + if (_switchInFlight) { + _pendingResync = true; + AppLogger.debug(_tag, '_switchTo busy → will resync to current'); + return; + } + final builder = _builder; + if (builder == null) { + AppLogger.warn(_tag, '_switchTo before initialize'); + return; + } + _switchInFlight = true; + final stickyOverride = + ref.read(playerEngineOverrideProvider).value ?? + PlayerEngineOverride.auto; + final effectiveForcedEngineKind = + forcedEngineKind ?? + (stickyOverride == PlayerEngineOverride.auto + ? _forcedEngineBySource[_fallbackKeyForRequest(req)] + : null); + PlayerEngineKind? resolvedEngineKind; + var resolvedIsDolbyVision = false; + _relink.cancelPending(); + AppLogger.debug( + _tag, + '_switchTo itemId=${req.itemId} ' + 'mediaSourceId=${req.mediaSourceId} ' + 'forced=${effectiveForcedEngineKind?.name ?? ''}', + ); + + final old = _session; + if (old != null) { + old.phase.removeListener(_onSessionPhaseChanged); + } + _attachWakelockSession(null); + + if (mounted) { + setState(() { + _session = null; + + + _videoVisible = false; + _errorMessage = null; + }); + _drawer.close(); + _versionBarOpen.value = false; + _invalidateBottomBar(); + } + + PlaybackSession? next; + try { + _lastRequest = req; + final priorDisposeFuture = old?.dispose(); + if (!mounted) return; + + final prefetched = _prefetchedPlaybackInfo; + _prefetchedPlaybackInfo = null; + final expectedStartTimeTicks = _prefetchStartTimeTicksFor(req); + final usablePrefetch = + prefetched != null && + prefetched.info.isNotEmpty && + req.itemId == widget.itemId && + prefetched.startTimeTicks == expectedStartTimeTicks + ? prefetched.info + : null; + if (prefetched != null && usablePrefetch == null) { + AppLogger.debug( + _tag, + 'drop prefetched PlaybackInfo: item=${req.itemId} ' + 'prefetchTicks=${prefetched.startTimeTicks} ' + 'expectedTicks=$expectedStartTimeTicks', + ); + } + + next = await builder.build( + request: req, + feeder: _feeder, + priorDisposeFuture: priorDisposeFuture, + preloadedPlaybackInfo: usablePrefetch, + forcedEngineKind: effectiveForcedEngineKind, + onEngineResolved: (kind, isDolbyVision) { + resolvedEngineKind = kind; + resolvedIsDolbyVision = isDolbyVision; + }, + ); + if (!mounted) { + await next.dispose(); + return; + } + + next.phase.addListener(_onSessionPhaseChanged); + next.onStreamFailure = _handleStreamFailure; + next.onExternalSubtitleError = _showExternalSubtitleError; + setState(() => _session = next); + unawaited( + next.engine.stream.position + .firstWhere((pos) => pos > Duration.zero) + .then((_) { + if (!mounted || !identical(_session, next)) return; + setState(() => _videoVisible = true); + }) + .catchError((_) {}), + ); + _attachWakelockSession(next); + _attachAndroidPipPlayingListener(next); + + unawaited( + ref + .read(danmakuSessionProvider.notifier) + .loadFor(next.context.danmakuMatchContext), + ); + + await next.start(); + if (!mounted) return; + + final manuallyConfirmedMedia3 = + stickyOverride == PlayerEngineOverride.media3 && + playerEngineKindFromDisplayName(next.engine.displayName) == + PlayerEngineKind.media3; + if (manuallyConfirmedMedia3) { + _forcedEngineBySource.remove(_fallbackKeyForRequest(req)); + } + + if (req.targetServer == null) { + final ctx = next.context; + final msid = ctx.mediaSourceId; + if (msid != null && msid.isNotEmpty) { + unawaited( + _lastPlayedVersionStore.save( + serverId: ctx.serverId, + itemId: ctx.itemId, + mediaSourceId: msid, + ), + ); + } + } + + if (_playbackRate != 1.0) { + await next.setRate(_playbackRate); + } + + _configureAndroidPip(); + + _armRelinkSustain(); + } catch (e, s) { + + + if (e is CancelledException || !mounted) { + AppLogger.debug(_tag, '_switchTo aborted (unmounted/cancelled)'); + if (next != null) { + next.phase.removeListener(_onSessionPhaseChanged); + try { + await next.dispose(); + } catch (_) {} + } + return; + } + AppLogger.error(_tag, '_switchTo failed', e, s); + final failedKind = next == null + ? resolvedEngineKind + : playerEngineKindFromDisplayName(next.engine.displayName); + if (mounted && + await _tryFallbackFromSwitchFailure( + request: req, + failedKind: failedKind, + failedSession: next, + isDolbyVision: next?.context.isDolbyVision ?? resolvedIsDolbyVision, + )) { + return; + } + if (next != null) { + next.phase.removeListener(_onSessionPhaseChanged); + try { + await next.dispose(); + } catch (disposeErr) { + AppLogger.warn( + _tag, + '_switchTo failed: dispose next failed', + disposeErr, + ); + } + } + if (mounted) { + setState(() { + _session = null; + _errorMessage = formatUserError(e); + }); + } + } finally { + _switchInFlight = false; + if (mounted && _session?.phase.value == PlaybackPhase.failed) { + _onSessionPhaseChanged(); + } + if (mounted) { + setState(() {}); + _invalidateBottomBar(); + } + } + + await _drainPendingResync(); + } + + + Future _drainPendingResync() async { + if (!_pendingResync || !mounted) return; + _pendingResync = false; + final current = _playlist?.current.value; + if (current == null || current.itemId.isEmpty) return; + if (current.itemId == _session?.context.itemId) return; + AppLogger.debug(_tag, '_drainPendingResync → realign to ${current.itemId}'); + _onCurrentItemChanged(); + } + + void _onSessionPhaseChanged() { + final phase = _session?.phase.value; + AppLogger.debug(_tag, 'session phase: $phase'); + _syncWakelockForSession(_session); + if (phase == PlaybackPhase.ended && !_switchInFlight) { + final pl = _playlist; + if (pl != null && pl.hasNext) { + unawaited(pl.next()); + } + } else if (phase == PlaybackPhase.failed && mounted) { + if (_switchInFlight) return; + final failed = _session; + failed?.phase.removeListener(_onSessionPhaseChanged); + _attachWakelockSession(null); + if (_tryFallbackFromFailedSession(failed)) { + return; + } + if (failed != null) { + _rememberFailurePosition(ticksFromDuration(failed.engine.state.position)); + } + setState(() { + _session = null; + _errorMessage = formatUserError(failed?.lastError); + }); + if (failed != null) { + unawaited(failed.dispose()); + } + _invalidateBottomBar(); + } + } + + Future _retry() async { + final req = _lastRequest; + if (req == null) return; + + + _relink.reset(); + if (mounted) setState(() => _errorMessage = null); + await _switchTo(req); + } + + Future _tryFallbackFromSwitchFailure({ + required PlaybackRequest request, + required PlayerEngineKind? failedKind, + required bool isDolbyVision, + PlaybackSession? failedSession, + }) async { + if (_engineFallbackInFlight || failedKind == null) return false; + final override = + ref.read(playerEngineOverrideProvider).value ?? + PlayerEngineOverride.auto; + final key = _fallbackKeyForRequest(request); + final fallbackTarget = nextAutoFallbackEngine( + override: override, + failedEngineKind: failedKind, + isDolbyVision: isDolbyVision, + ); + if (fallbackTarget == null) return false; + + _forcedEngineBySource[key] = fallbackTarget; + _engineFallbackInFlight = true; + _relink.cancelPending(); + failedSession?.phase.removeListener(_onSessionPhaseChanged); + AppLogger.warn( + _tag, + '${failedKind.name} switch failed for ${request.itemId} ' + 'mediaSource=${request.mediaSourceId ?? ''}; ' + 'fallback to ${fallbackTarget.name}', + ); + if (failedSession != null) { + await failedSession.dispose(); + } + if (!mounted) { + _engineFallbackInFlight = false; + return true; + } + setState(() { + _session = null; + _errorMessage = null; + }); + unawaited( + Future.microtask(() async { + try { + await _switchTo(request, forcedEngineKind: fallbackTarget); + } finally { + _engineFallbackInFlight = false; + } + }), + ); + _invalidateBottomBar(); + return true; + } + + bool _tryFallbackFromFailedSession(PlaybackSession? failed) { + if (failed == null || _switchInFlight || _engineFallbackInFlight) { + return false; + } + final base = _lastRequest; + if (base == null) return false; + final override = + ref.read(playerEngineOverrideProvider).value ?? + PlayerEngineOverride.auto; + final failedKind = playerEngineKindFromDisplayName( + failed.engine.displayName, + ); + final key = _fallbackKeyForRequest(base); + final fallbackTarget = nextAutoFallbackEngine( + override: override, + failedEngineKind: failedKind, + isDolbyVision: failed.context.isDolbyVision, + ); + if (fallbackTarget == null) return false; + + _forcedEngineBySource[key] = fallbackTarget; + _engineFallbackInFlight = true; + _relink.cancelPending(); + + final ticks = ticksFromDuration(failed.engine.state.position); + final req = ticks > 0 + ? base.copyWith(startPositionTicks: ticks, playFromStart: false) + : base; + AppLogger.warn( + _tag, + '${failedKind?.name ?? "unknown"} failed for ${failed.context.itemId} ' + 'mediaSource=${failed.context.mediaSourceId ?? ''}; ' + 'fallback to ${fallbackTarget.name}', + ); + setState(() { + _session = null; + _errorMessage = null; + }); + unawaited( + failed.dispose().whenComplete(() async { + try { + await _switchTo(req, forcedEngineKind: fallbackTarget); + } finally { + _engineFallbackInFlight = false; + } + }), + ); + _invalidateBottomBar(); + return true; + } + + String _fallbackKeyForRequest(PlaybackRequest request) { + return [ + request.targetServer?.serverId ?? '', + request.itemId, + request.mediaSourceId ?? '', + ].join(':'); + } + + + void _showExternalSubtitleError(String message) { + if (!mounted) return; + showAppSnackBar(context, message, tone: AppSnackTone.warning); + } + + +} diff --git a/lib/features/player/player_page_sources.dart b/lib/features/player/player_page_sources.dart new file mode 100644 index 0000000..7cc8d03 --- /dev/null +++ b/lib/features/player/player_page_sources.dart @@ -0,0 +1,245 @@ + + +part of 'player_page.dart'; + +extension _PlayerPageSources on _PlayerPageState { + PlaybackRequest _itemToRequest(PlaylistItem item) { + return PlaybackRequest( + itemId: item.itemId, + mediaSourceId: item.mediaSourceId, + startPositionTicks: item.startPositionTicks, + preferredAudioStreamIndex: item.preferredAudioStreamIndex, + preferredSubtitleStreamIndex: item.preferredSubtitleStreamIndex, + subtitleSettings: _subtitleSettings, + playerSettings: _lastRequest?.playerSettings ?? _initialPlayerSettings, + danmakuSettings: _lastRequest?.danmakuSettings ?? _initialDanmakuSettings, + audioSettings: _lastRequest?.audioSettings ?? _initialAudioSettings, + playFromStart: item.playFromStart, + targetServer: item.serverIdentity, + preloadedItem: item.preloadedItem, + directStreamUrl: widget.directStream?.url, + directStreamHeaders: + widget.directStream?.headers ?? const {}, + ); + } + + void _onCurrentItemChanged() { + final pl = _playlist; + if (pl == null) return; + final item = pl.current.value; + if (item.itemId.isEmpty) return; + + _relink.reset(); + unawaited(_switchTo(_itemToRequest(item))); + } + + Future _onEpisodePicked(EmbyRawItem episode) async { + final pl = _playlist; + if (pl == null) return; + if (episode.Id == _session?.context.itemId) return; + if (pl is SeriesEpisodePlaylist && episode.SeasonId != null) { + await pl.goToInSeason(seasonId: episode.SeasonId!, episodeId: episode.Id); + } else { + await pl.goToItemId(episode.Id); + } + } + + Future _switchVersion(String sourceId) async { + if (sourceId == _session?.context.mediaSourceId) return; + final base = _lastRequest; + if (base == null) return; + _relink.reset(); + final pos = _session?.engine.state.position; + final resumeTicks = pos == null ? null : ticksFromDuration(pos); + + + final req = base.copyWith( + mediaSourceId: sourceId, + startPositionTicks: (resumeTicks != null && resumeTicks > 0) + ? resumeTicks + : null, + playFromStart: resumeTicks != null && resumeTicks <= 0, + ); + await _switchTo(req); + } + + + int? _currentResumeTicks() { + final pos = _session?.engine.state.position ?? Duration.zero; + final ticks = ticksFromDuration(pos); + return ticks > 0 ? ticks : null; + } + + + CrossServerSearchKey? _buildCrossServerKey(PlaybackContext ctx) { + final activeServerId = ctx.serverId.isEmpty ? null : ctx.serverId; + if (activeServerId == null) return null; + final providerIds = ctx.providerIds; + final type = ctx.itemType; + if (type == 'Movie') { + if (providerIds.isEmpty && ctx.itemName.isEmpty) return null; + return ( + activeServerId: activeServerId, + anchorType: 'Movie', + providerIdQuery: encodeProviderIdsForKey(providerIds), + seriesProviderIdQuery: '', + parentIndexNumber: -1, + indexNumber: -1, + itemName: ctx.itemName, + excludedServerId: ctx.serverId, + ); + } + if (type == 'Episode') { + final s = ctx.parentIndexNumber; + final e = ctx.itemIndexNumber; + if (s == null || e == null) return null; + final seriesProviderIds = ctx.seriesProviderIds; + if (providerIds.isEmpty && seriesProviderIds.isEmpty) return null; + return ( + activeServerId: activeServerId, + anchorType: 'Episode', + providerIdQuery: encodeProviderIdsForKey(providerIds), + seriesProviderIdQuery: encodeProviderIdsForKey(seriesProviderIds), + parentIndexNumber: s, + indexNumber: e, + itemName: ctx.itemName, + excludedServerId: ctx.serverId, + ); + } + return null; + } + + + List _filteredCrossServerCards( + List cards, + PlaybackContext ctx, + ) { + final curServerId = ctx.serverId; + final curSourceId = ctx.mediaSourceId; + return cards + .where( + (c) => !(c.serverId == curServerId && c.mediaSourceId == curSourceId), + ) + .toList(growable: false); + } + + + Future _switchToCrossServer(CrossServerSourceCard card) async { + if (_switchInFlight) return; + _relink.reset(); + final resumeTicks = _currentResumeTicks(); + final identity = PlaybackServerIdentity.fromCard(card); + + final oldPlaylist = _playlist; + oldPlaylist?.current.removeListener(_onCurrentItemChanged); + final newPlaylist = CrossServerPlaylist( + sequence: [ + CrossServerPlaylistEntry( + serverId: card.serverId, + serverName: card.serverName, + serverUrl: card.serverUrl, + token: card.token, + userId: card.userId, + itemId: card.landingItemId, + label: card.item.Name, + preloadedItem: card.item.Id == card.landingItemId ? card.item : null, + ), + ], + initialIndex: 0, + ); + _playlist = newPlaylist; + try { + oldPlaylist?.dispose(); + } catch (e) { + AppLogger.warn(_tag, 'dispose old playlist failed', e); + } + + final base = _lastRequest; + final req = PlaybackRequest( + itemId: card.landingItemId, + mediaSourceId: card.mediaSourceId, + startPositionTicks: resumeTicks, + subtitleSettings: _subtitleSettings, + playerSettings: base?.playerSettings ?? _initialPlayerSettings, + danmakuSettings: base?.danmakuSettings ?? _initialDanmakuSettings, + audioSettings: base?.audioSettings ?? _initialAudioSettings, + playFromStart: resumeTicks == null, + targetServer: identity, + ); + newPlaylist.current.addListener(_onCurrentItemChanged); + await _switchTo(req); + } + + + void _toggleVersionBar() { + if (_versionBarOpen.value) { + _versionBarOpen.value = false; + } else { + _drawer.close(); + _versionBarOpen.value = true; + } + } + + void _onAudioTrackSelected(PlaybackSession session, AudioTrack track) { + final audioSettings = _lastRequest?.audioSettings ?? _initialAudioSettings; + if (!audioSettings.rememberAudioTrack) return; + final embyTracks = session.context.embyAudioTracks; + if (embyTracks.isEmpty) return; + var embyTrack = embyTracks.where((t) => t.index == track.index).firstOrNull; + if (embyTrack == null) { + final engineTracks = session.engine.state.tracks.embeddedAudio; + final pos = engineTracks.indexWhere((t) => t.id == track.id); + if (pos >= 0 && pos < embyTracks.length) embyTrack = embyTracks[pos]; + } + if (embyTrack == null) return; + unawaited( + _audioSettingsNotifier.saveLastAudioTrack( + session.context.memoryKey, + RememberedAudioTrack( + streamIndex: embyTrack.index, + language: embyTrack.language, + codec: embyTrack.codec, + channels: embyTrack.channels, + displayTitle: embyTrack.displayTitle, + updatedAt: DateTime.now(), + ), + ), + ); + } + + + void _onSubtitleSelected(PlaybackSession session, int? streamIndex) { + unawaited(session.selectSubtitleByStreamIndex(streamIndex)); + if (streamIndex == null) { + unawaited( + _subtitleSettingsNotifier.saveLastSubtitleTrack( + session.context.memoryKey, + RememberedSubtitleTrack(streamIndex: -1, updatedAt: DateTime.now()), + ), + ); + return; + } + final embyTrack = session.context.embySubtitles + .where((track) => track.index == streamIndex) + .firstOrNull; + if (embyTrack == null) { + AppLogger.warn( + _tag, + 'subtitle memory: streamIdx=$streamIndex not in context.embySubtitles; ' + 'skipping memory write', + ); + return; + } + unawaited( + _subtitleSettingsNotifier.saveLastSubtitleTrack( + session.context.memoryKey, + RememberedSubtitleTrack( + streamIndex: embyTrack.index, + language: embyTrack.language, + title: embyTrack.title, + updatedAt: DateTime.now(), + ), + ), + ); + } +} diff --git a/lib/features/player/player_page_top_bar.dart b/lib/features/player/player_page_top_bar.dart new file mode 100644 index 0000000..574a5da --- /dev/null +++ b/lib/features/player/player_page_top_bar.dart @@ -0,0 +1,100 @@ + + +part of 'player_page.dart'; + +extension _PlayerPageTopBar on _PlayerPageState { + String _buildTitleText(PlaybackSession session) { + final idx = session.context.itemIndexNumber; + final name = session.context.itemName; + final series = session.context.seriesName; + if (series != null && series.isNotEmpty) { + if (idx != null) return '$series ${formatEpisodeNumber(idx)} $name'; + return '$series $name'; + } + if (idx != null) return '${formatEpisodeNumber(idx)} $name'; + return name; + } + + MediaQualityInfo? _activeQualityInfo(PlaybackSession session) { + final srcId = session.context.mediaSourceId; + for (final src in session.context.allMediaSources) { + if (src.Id == srcId) return MediaQualityInfo.fromMediaSource(src); + } + if (session.context.allMediaSources.isNotEmpty) { + return MediaQualityInfo.fromMediaSource( + session.context.allMediaSources.first, + ); + } + return null; + } + + List _buildTopBar(PlaybackSession session) { + if (_pip.isPip) { + return [ + IconButton( + onPressed: _goBack, + icon: const Icon(Icons.arrow_back, color: Colors.white), + tooltip: '退出播放', + ), + const Expanded(child: SizedBox.shrink()), + IconButton( + onPressed: () => unawaited(_pip.toggleAlwaysOnTop()), + icon: Icon( + _pip.isAlwaysOnTop ? Icons.push_pin : Icons.push_pin_outlined, + color: _pip.isAlwaysOnTop ? Colors.white : Colors.white54, + size: 20, + ), + tooltip: _pip.isAlwaysOnTop ? '取消置顶' : '置顶', + ), + IconButton( + onPressed: () => unawaited(windowManager.close()), + icon: const Icon(Icons.close, color: Colors.white, size: 20), + tooltip: '关闭', + ), + ]; + } + return [ + IconButton( + onPressed: _goBack, + icon: const Icon(Icons.arrow_back, color: Colors.white), + ), + const SizedBox(width: 4), + Expanded( + child: SizedBox( + height: kMinInteractiveDimension, + child: Align( + alignment: Alignment.centerLeft, + child: Text( + _buildTitleText(session), + style: const TextStyle(color: Colors.white, fontSize: 15), + overflow: TextOverflow.ellipsis, + ), + ), + ), + ), + const SizedBox(width: 8), + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + QualityBadge(quality: _activeQualityInfo(session)), + const SizedBox(width: 8), + NetworkSpeedIndicator(monitor: session.speedMonitor), + if (!_fullscreen.isFullscreen && + !_pip.isPip && + _isDesktopWindow) ...[ + const SizedBox(width: 8), + const WindowControls.forDarkSurface(), + ], + ], + ), + const SizedBox(height: 6), + const PlayerClockText(), + ], + ), + ]; + } +} diff --git a/lib/features/player/player_page_version_bar.dart b/lib/features/player/player_page_version_bar.dart new file mode 100644 index 0000000..4668e23 --- /dev/null +++ b/lib/features/player/player_page_version_bar.dart @@ -0,0 +1,108 @@ + + +part of 'player_page.dart'; + +extension _PlayerPageVersionBar on _PlayerPageState { + + + Widget _buildVersionBarLayer(PlaybackSession session) { + return ValueListenableBuilder( + valueListenable: _versionBarOpen, + builder: (context, open, _) { + return Stack( + fit: StackFit.expand, + children: [ + if (open) + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => _versionBarOpen.value = false, + child: const SizedBox.expand(), + ), + ), + Positioned( + left: 16, + right: 16, + bottom: Platform.isAndroid ? 96 : 72, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + transitionBuilder: (child, anim) => FadeTransition( + opacity: anim, + child: SlideTransition( + position: Tween( + begin: const Offset(0, 0.15), + end: Offset.zero, + ).animate(anim), + child: child, + ), + ), + child: open + ? _buildVersionBar(session) + : const SizedBox.shrink(key: ValueKey('version-bar-empty')), + ), + ), + ], + ); + }, + ); + } + + + Widget _buildVersionBar(PlaybackSession session) { + final entries = _buildVersionCardData(session); + if (entries.isEmpty) { + return const SizedBox.shrink(key: ValueKey('version-bar-empty')); + } + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () {}, + child: KeyedSubtree( + key: const ValueKey('version-bar'), + child: VersionPanel( + entries: entries, + onClose: () => _versionBarOpen.value = false, + ), + ), + ); + } + + + List _buildVersionCardData(PlaybackSession session) { + final ctx = session.context; + final localSources = ctx.allMediaSources; + final currentSourceId = ctx.mediaSourceId; + final currentServerName = + _lastRequest?.targetServer?.serverName ?? + ref.read(activeSessionProvider)?.serverName ?? + '本服务器'; + + final entries = []; + for (final src in localSources) { + final isCurrent = src.Id != null && src.Id == currentSourceId; + final sourceId = src.Id; + entries.add( + VersionSourceCardData.fromLocalSource( + src, + serverName: currentServerName, + serverKey: ctx.serverId, + isCurrent: isCurrent, + tooltip: isCurrent ? null : '以该版本播放', + onTap: (isCurrent || sourceId == null) + ? null + : () => _switchVersion(sourceId), + ), + ); + } + for (final card in _crossServerCards) { + entries.add( + VersionSourceCardData.fromCrossServer( + card, + onTap: () => _switchToCrossServer(card), + ), + ); + } + return entries; + } +} diff --git a/lib/features/player/playlist/cross_server_playlist.dart b/lib/features/player/playlist/cross_server_playlist.dart new file mode 100644 index 0000000..a1af7aa --- /dev/null +++ b/lib/features/player/playlist/cross_server_playlist.dart @@ -0,0 +1,134 @@ +import 'package:flutter/foundation.dart'; + +import '../../../core/contracts/library.dart'; +import '../session/playback_target.dart'; +import 'playlist.dart'; + + +@immutable +class CrossServerPlaylistEntry { + const CrossServerPlaylistEntry({ + required this.serverId, + required this.serverName, + required this.serverUrl, + required this.token, + required this.userId, + required this.itemId, + this.label = '', + this.preloadedItem, + }); + + final String serverId; + final String serverName; + final String serverUrl; + final String token; + final String userId; + final String itemId; + final String label; + final EmbyRawItem? preloadedItem; + + PlaybackServerIdentity toIdentity() => PlaybackServerIdentity( + serverId: serverId, + baseUrl: serverUrl, + token: token, + userId: userId, + serverName: serverName, + ); + + @override + bool operator ==(Object other) => + other is CrossServerPlaylistEntry && + other.serverId == serverId && + other.serverName == serverName && + other.serverUrl == serverUrl && + other.token == token && + other.userId == userId && + other.itemId == itemId && + other.label == label && + other.preloadedItem == preloadedItem; + + @override + int get hashCode => Object.hash( + serverId, + serverName, + serverUrl, + token, + userId, + itemId, + label, + preloadedItem, + ); +} + + +class CrossServerPlaylist implements Playlist { + CrossServerPlaylist({ + required List sequence, + required int initialIndex, + bool initialPlayFromStart = false, + }) : assert(sequence.isNotEmpty, 'cross-server sequence cannot be empty'), + assert(initialIndex >= 0 && initialIndex < sequence.length), + _entries = List.unmodifiable(sequence), + _currentIndex = ValueNotifier(initialIndex), + _current = ValueNotifier( + _toItem(sequence[initialIndex], playFromStart: initialPlayFromStart), + ); + + final List _entries; + final ValueNotifier _current; + final ValueNotifier _currentIndex; + + bool _disposed = false; + + @override + ValueListenable get current => _current; + @override + ValueListenable get currentIndex => _currentIndex; + @override + int get length => _entries.length; + @override + bool get hasNext => _currentIndex.value < _entries.length - 1; + @override + bool get hasPrev => _currentIndex.value > 0; + + @override + Future next() => goTo(_currentIndex.value + 1); + @override + Future prev() => goTo(_currentIndex.value - 1); + + @override + Future goTo(int index) async { + if (_disposed) return; + if (index < 0 || index >= _entries.length) return; + if (index == _currentIndex.value) return; + final target = _entries[index]; + _currentIndex.value = index; + _current.value = _toItem(target); + } + + @override + Future goToItemId(String itemId) async { + final idx = _entries.indexWhere((e) => e.itemId == itemId); + if (idx < 0) return; + await goTo(idx); + } + + static PlaylistItem _toItem( + CrossServerPlaylistEntry e, { + bool playFromStart = false, + }) => PlaylistItem( + itemId: e.itemId, + serverId: e.serverId, + serverIdentity: e.toIdentity(), + displayLabel: e.label, + playFromStart: playFromStart, + preloadedItem: e.preloadedItem, + ); + + @override + void dispose() { + _disposed = true; + _current.dispose(); + _currentIndex.dispose(); + } +} diff --git a/lib/features/player/playlist/pending_cross_server_sequence_provider.dart b/lib/features/player/playlist/pending_cross_server_sequence_provider.dart new file mode 100644 index 0000000..a79fa20 --- /dev/null +++ b/lib/features/player/playlist/pending_cross_server_sequence_provider.dart @@ -0,0 +1,7 @@ +import 'package:flutter_riverpod/legacy.dart'; + +import 'cross_server_playlist.dart'; + + +final pendingCrossServerSequenceProvider = + StateProvider?>((ref) => null); diff --git a/lib/features/player/playlist/playlist.dart b/lib/features/player/playlist/playlist.dart new file mode 100644 index 0000000..e72c7d1 --- /dev/null +++ b/lib/features/player/playlist/playlist.dart @@ -0,0 +1,93 @@ +import 'package:flutter/foundation.dart'; + +import '../../../core/contracts/library.dart'; +import '../session/playback_target.dart'; + + +@immutable +class PlaylistItem { + const PlaylistItem({ + required this.itemId, + this.mediaSourceId, + this.startPositionTicks, + this.preferredAudioStreamIndex, + this.preferredSubtitleStreamIndex, + this.serverId, + this.serverIdentity, + this.displayLabel = '', + this.playFromStart = false, + this.preloadedItem, + }); + + final String itemId; + final String? mediaSourceId; + final int? startPositionTicks; + final int? preferredAudioStreamIndex; + final int? preferredSubtitleStreamIndex; + + + final String? serverId; + + + final PlaybackServerIdentity? serverIdentity; + + + final String displayLabel; + + + final bool playFromStart; + + + final EmbyRawItem? preloadedItem; + + @override + bool operator ==(Object other) => + other is PlaylistItem && + other.itemId == itemId && + other.mediaSourceId == mediaSourceId && + other.startPositionTicks == startPositionTicks && + other.preferredAudioStreamIndex == preferredAudioStreamIndex && + other.preferredSubtitleStreamIndex == preferredSubtitleStreamIndex && + other.serverId == serverId && + other.serverIdentity == serverIdentity && + other.displayLabel == displayLabel && + other.playFromStart == playFromStart && + other.preloadedItem == preloadedItem; + + @override + int get hashCode => Object.hash( + itemId, + mediaSourceId, + startPositionTicks, + preferredAudioStreamIndex, + preferredSubtitleStreamIndex, + serverId, + serverIdentity, + displayLabel, + playFromStart, + preloadedItem, + ); + + @override + String toString() => + 'PlaylistItem(itemId=$itemId, serverId=$serverId, label=$displayLabel)'; +} + + +abstract class Playlist { + + ValueListenable get current; + + + ValueListenable get currentIndex; + + int get length; + bool get hasNext; + bool get hasPrev; + + Future next(); + Future prev(); + Future goTo(int index); + Future goToItemId(String itemId); + void dispose(); +} diff --git a/lib/features/player/playlist/playlist_factory.dart b/lib/features/player/playlist/playlist_factory.dart new file mode 100644 index 0000000..f71f1e5 --- /dev/null +++ b/lib/features/player/playlist/playlist_factory.dart @@ -0,0 +1,69 @@ +import '../../../core/contracts/library.dart'; +import 'cross_server_playlist.dart'; +import 'playlist.dart'; +import 'series_episode_playlist.dart'; +import 'single_item_playlist.dart'; + +typedef LoadDetailFn = Future Function(String itemId); + + +Future playlistFromLaunch({ + required String itemId, + String? mediaSourceId, + int? startPositionTicks, + int? preferredAudioStreamIndex, + int? preferredSubtitleStreamIndex, + bool playFromStart = false, + List? crossServerSequence, + required LoadDetailFn loadDetail, + required LoadEpisodesFn loadEpisodes, + required LoadSeasonsFn loadSeasons, +}) async { + if (crossServerSequence != null && crossServerSequence.isNotEmpty) { + var initialIndex = crossServerSequence.indexWhere( + (e) => e.itemId == itemId, + ); + if (initialIndex < 0) initialIndex = 0; + return CrossServerPlaylist( + sequence: crossServerSequence, + initialIndex: initialIndex, + initialPlayFromStart: playFromStart, + ); + } + + final detail = await loadDetail(itemId); + final base = detail.base; + if (base.Type == 'Episode' && + base.SeriesId != null && + base.SeasonId != null) { + final seriesPlaylist = SeriesEpisodePlaylist( + seriesId: base.SeriesId!, + loadEpisodes: loadEpisodes, + loadSeasons: loadSeasons, + ); + await seriesPlaylist.initialize( + seasonId: base.SeasonId!, + episodeId: itemId, + initialIndexNumber: base.IndexNumber, + initialMediaSourceId: mediaSourceId, + initialStartPositionTicks: startPositionTicks, + initialPreferredAudioStreamIndex: preferredAudioStreamIndex, + initialPreferredSubtitleStreamIndex: preferredSubtitleStreamIndex, + initialPlayFromStart: playFromStart, + preloadedItem: base, + ); + return seriesPlaylist; + } + + return SingleItemPlaylist( + item: PlaylistItem( + itemId: itemId, + mediaSourceId: mediaSourceId, + startPositionTicks: startPositionTicks, + preferredAudioStreamIndex: preferredAudioStreamIndex, + preferredSubtitleStreamIndex: preferredSubtitleStreamIndex, + playFromStart: playFromStart, + preloadedItem: base, + ), + ); +} diff --git a/lib/features/player/playlist/series_episode_playlist.dart b/lib/features/player/playlist/series_episode_playlist.dart new file mode 100644 index 0000000..cfc74c5 --- /dev/null +++ b/lib/features/player/playlist/series_episode_playlist.dart @@ -0,0 +1,253 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +import '../../../core/contracts/library.dart'; +import '../../../shared/utils/app_logger.dart'; +import '../../../shared/utils/format_utils.dart'; +import 'playlist.dart'; + +typedef LoadEpisodesFn = + Future> Function({ + required String seriesId, + required String seasonId, + }); + +typedef LoadSeasonsFn = Future> Function(String seriesId); + + +class SeriesEpisodePlaylist implements Playlist { + SeriesEpisodePlaylist({ + required this.seriesId, + required this.loadEpisodes, + required this.loadSeasons, + }) : _current = ValueNotifier(_placeholder), + _currentIndex = ValueNotifier(-1); + + final String seriesId; + final LoadEpisodesFn loadEpisodes; + final LoadSeasonsFn loadSeasons; + + final ValueNotifier _current; + final ValueNotifier _currentIndex; + + String? _currentSeasonId; + List _episodes = const []; + + + String? _initialMediaSourceId; + int? _initialStartPositionTicks; + int? _initialPreferredAudio; + int? _initialPreferredSub; + bool _initialPlayFromStart = false; + bool _initialApplied = false; + + bool _disposed = false; + bool _userNavigated = false; + + static const PlaylistItem _placeholder = PlaylistItem(itemId: ''); + + + Future initialize({ + required String seasonId, + required String episodeId, + int? initialIndexNumber, + String? initialMediaSourceId, + int? initialStartPositionTicks, + int? initialPreferredAudioStreamIndex, + int? initialPreferredSubtitleStreamIndex, + bool initialPlayFromStart = false, + EmbyRawItem? preloadedItem, + }) async { + _initialMediaSourceId = initialMediaSourceId; + _initialStartPositionTicks = initialStartPositionTicks; + _initialPreferredAudio = initialPreferredAudioStreamIndex; + _initialPreferredSub = initialPreferredSubtitleStreamIndex; + _initialPlayFromStart = initialPlayFromStart; + _initialApplied = true; + _currentSeasonId = seasonId; + _current.value = PlaylistItem( + itemId: episodeId, + mediaSourceId: initialMediaSourceId, + startPositionTicks: initialStartPositionTicks, + preferredAudioStreamIndex: initialPreferredAudioStreamIndex, + preferredSubtitleStreamIndex: initialPreferredSubtitleStreamIndex, + playFromStart: initialPlayFromStart, + displayLabel: preloadedItem != null ? _labelOf(preloadedItem) : '', + preloadedItem: preloadedItem, + ); + unawaited(_resolveEpisodeList(seasonId, episodeId, initialIndexNumber)); + } + + + Future _resolveEpisodeList( + String seasonId, + String episodeId, + int? initialIndexNumber, + ) async { + try { + await _loadSeasonEpisodes(seasonId); + } catch (e) { + AppLogger.warn( + 'SeriesEpisodePlaylist', + 'background episode list load failed', + e, + ); + return; + } + if (_disposed || _userNavigated) return; + + var idx = _episodes.indexWhere((e) => e.Id == episodeId); + if (idx < 0 && initialIndexNumber != null) { + idx = _episodes.indexWhere((e) => e.IndexNumber == initialIndexNumber); + } + AppLogger.debug( + 'SeriesEpisodePlaylist', + 'episodeListReady seriesId=$seriesId seasonId=$seasonId ' + 'episodeId=$episodeId idx=$idx ' + 'episodes=${_episodes.length} ' + 'episodeIds=${_episodes.map((e) => e.Id).toList()}', + ); + if (_episodes.isEmpty) return; + _currentIndex.value = idx < 0 ? 0 : idx; + } + + @override + ValueListenable get current => _current; + @override + ValueListenable get currentIndex => _currentIndex; + @override + int get length => _episodes.length; + + + @override + bool get hasNext => _episodes.isNotEmpty; + @override + bool get hasPrev => _episodes.isNotEmpty; + + @override + Future next() async { + if (_disposed || _episodes.isEmpty) return; + final cur = _currentIndex.value; + if (cur < _episodes.length - 1) { + _markMoved(); + _setCurrent(cur + 1); + return; + } + await _crossSeason(direction: 1); + } + + @override + Future prev() async { + if (_disposed || _episodes.isEmpty) return; + final cur = _currentIndex.value; + if (cur > 0) { + _markMoved(); + _setCurrent(cur - 1); + return; + } + await _crossSeason(direction: -1); + } + + @override + Future goTo(int index) async { + if (_disposed || index < 0 || index >= _episodes.length) return; + if (index == _currentIndex.value) return; + _markMoved(); + _setCurrent(index); + } + + @override + Future goToItemId(String itemId) async { + if (_disposed) return; + final idx = _episodes.indexWhere((e) => e.Id == itemId); + if (idx < 0) return; + if (idx == _currentIndex.value) return; + _markMoved(); + _setCurrent(idx); + } + + + Future goToInSeason({ + required String seasonId, + required String episodeId, + }) async { + if (_disposed) return; + if (seasonId != _currentSeasonId || _episodes.isEmpty) { + await _loadSeasonEpisodes(seasonId); + if (_disposed) return; + } + final idx = _episodes.indexWhere((e) => e.Id == episodeId); + if (idx < 0) return; + if (_currentSeasonId == seasonId && idx == _currentIndex.value) return; + _markMoved(); + _setCurrent(idx); + } + + Future _crossSeason({required int direction}) async { + final curSeason = _currentSeasonId; + if (curSeason == null) return; + final seasons = await loadSeasons(seriesId); + if (_disposed) return; + final ordered = [...seasons] + ..sort((a, b) => (a.IndexNumber ?? 0).compareTo(b.IndexNumber ?? 0)); + final curPos = ordered.indexWhere((s) => s.Id == curSeason); + if (curPos < 0) return; + final targetPos = curPos + direction; + if (targetPos < 0 || targetPos >= ordered.length) return; + final target = ordered[targetPos]; + await _loadSeasonEpisodes(target.Id); + if (_disposed || _episodes.isEmpty) return; + _markMoved(); + _setCurrent(direction > 0 ? 0 : _episodes.length - 1); + } + + Future _loadSeasonEpisodes(String seasonId) async { + final eps = await loadEpisodes(seriesId: seriesId, seasonId: seasonId); + if (_disposed) return; + _currentSeasonId = seasonId; + _episodes = List.unmodifiable(eps); + } + + void _markMoved() { + _initialApplied = true; + _userNavigated = true; + } + + void _setCurrent(int index) { + _currentIndex.value = index; + _current.value = _itemFromEpisode(_episodes[index]); + } + + PlaylistItem _itemFromEpisode(EmbyRawItem ep) { + final useInitial = !_initialApplied; + _initialApplied = true; + return PlaylistItem( + itemId: ep.Id, + mediaSourceId: useInitial ? _initialMediaSourceId : null, + startPositionTicks: useInitial ? _initialStartPositionTicks : null, + preferredAudioStreamIndex: useInitial ? _initialPreferredAudio : null, + preferredSubtitleStreamIndex: useInitial ? _initialPreferredSub : null, + displayLabel: _labelOf(ep), + playFromStart: useInitial && _initialPlayFromStart, + preloadedItem: ep, + ); + } + + String _labelOf(EmbyRawItem ep) { + final ep0 = ep.IndexNumber; + final name = ep.Name; + if (ep0 != null && name.isNotEmpty) { + return '${formatEpisodeNumber(ep0)} $name'; + } + if (name.isNotEmpty) return name; + return ep.Id; + } + + @override + void dispose() { + _disposed = true; + _current.dispose(); + _currentIndex.dispose(); + } +} diff --git a/lib/features/player/playlist/single_item_playlist.dart b/lib/features/player/playlist/single_item_playlist.dart new file mode 100644 index 0000000..399b052 --- /dev/null +++ b/lib/features/player/playlist/single_item_playlist.dart @@ -0,0 +1,39 @@ +import 'package:flutter/foundation.dart'; + +import 'playlist.dart'; + + +class SingleItemPlaylist implements Playlist { + SingleItemPlaylist({required PlaylistItem item}) + : _current = ValueNotifier(item), + _currentIndex = ValueNotifier(0); + + final ValueNotifier _current; + final ValueNotifier _currentIndex; + + @override + ValueListenable get current => _current; + @override + ValueListenable get currentIndex => _currentIndex; + @override + int get length => 1; + @override + bool get hasNext => false; + @override + bool get hasPrev => false; + + @override + Future next() async {} + @override + Future prev() async {} + @override + Future goTo(int index) async {} + @override + Future goToItemId(String itemId) async {} + + @override + void dispose() { + _current.dispose(); + _currentIndex.dispose(); + } +} diff --git a/lib/features/player/services/android_pip_manager.dart b/lib/features/player/services/android_pip_manager.dart new file mode 100644 index 0000000..c3045c6 --- /dev/null +++ b/lib/features/player/services/android_pip_manager.dart @@ -0,0 +1,138 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; + +import '../../../core/infra/pip/android_pip_controller.dart'; +import '../../../shared/utils/app_logger.dart'; + +const _tag = 'AndroidPipManager'; + + +class AndroidPipManager { + AndroidPipManager({required this._onStateChanged}); + + final VoidCallback _onStateChanged; + + bool _isInPip = false; + bool _isDisposed = false; + bool _supported = false; + StreamSubscription? _modeSub; + StreamSubscription? _actionSub; + + + void Function(PipAction action)? onAction; + + bool get isInPip => _isInPip; + bool get isSupported => _supported; + + + Future initialize() async { + if (!Platform.isAndroid) return; + final supported = await AndroidPipController.instance.isSupported(); + if (_isDisposed) return; + if (_supported != supported) { + _supported = supported; + _onStateChanged(); + } + if (!_supported) return; + if (_modeSub != null || _actionSub != null) return; + + final isInPip = await AndroidPipController.instance.isInPipMode(); + if (_isDisposed) return; + if (_isInPip != isInPip) { + _isInPip = isInPip; + _onStateChanged(); + } + + _modeSub = AndroidPipController.instance.pipModeChanges.listen( + (isInPip) { + if (_isDisposed) return; + if (_isInPip == isInPip) return; + _isInPip = isInPip; + AppLogger.info(_tag, 'PiP mode changed: $isInPip'); + _onStateChanged(); + }, + onError: (Object e) { + AppLogger.warn(_tag, 'PiP mode stream error', e); + }, + ); + + _actionSub = AndroidPipController.instance.actionStream.listen( + (action) { + if (_isDisposed) return; + AppLogger.debug(_tag, 'PiP action: $action'); + onAction?.call(action); + }, + onError: (Object e) { + AppLogger.warn(_tag, 'PiP action stream error', e); + }, + ); + } + + + Future configure({ + int aspectX = 16, + int aspectY = 9, + bool autoEnterOnLeave = false, + bool isPlaying = false, + bool hasNext = false, + bool hasPrev = false, + bool useEpisodeActions = true, + }) async { + if (!_supported || _isDisposed) return; + await AndroidPipController.instance.configure( + aspectX: aspectX, + aspectY: aspectY, + autoEnterOnLeave: autoEnterOnLeave, + isPlaying: isPlaying, + hasNext: hasNext, + hasPrev: hasPrev, + useEpisodeActions: useEpisodeActions, + ); + } + + + Future enter({int? aspectX, int? aspectY}) async { + if (!_supported || _isDisposed || _isInPip) return false; + return AndroidPipController.instance.enterPip( + aspectX: aspectX, + aspectY: aspectY, + ); + } + + + Future updatePlaybackState({ + required bool isPlaying, + required bool hasNext, + required bool hasPrev, + required bool useEpisodeActions, + }) async { + if (!_supported || _isDisposed || !_isInPip) return; + await AndroidPipController.instance.updatePlaybackState( + isPlaying: isPlaying, + hasNext: hasNext, + hasPrev: hasPrev, + useEpisodeActions: useEpisodeActions, + ); + } + + void dispose() { + if (_isDisposed) return; + _isDisposed = true; + final modeSub = _modeSub; + _modeSub = null; + final actionSub = _actionSub; + _actionSub = null; + onAction = null; + unawaited(() async { + await AndroidPipController.instance.configure( + autoEnterOnLeave: false, + isPlaying: false, + ); + await modeSub?.cancel(); + await actionSub?.cancel(); + await AndroidPipController.instance.dispose(); + }()); + } +} diff --git a/lib/features/player/services/danmaku/danmaku_feeder.dart b/lib/features/player/services/danmaku/danmaku_feeder.dart new file mode 100644 index 0000000..d8fed83 --- /dev/null +++ b/lib/features/player/services/danmaku/danmaku_feeder.dart @@ -0,0 +1,186 @@ +import 'package:canvas_danmaku/canvas_danmaku.dart'; +import 'package:flutter/foundation.dart' show debugPrint; +import 'package:flutter/material.dart' show Colors; + +import '../../../../core/contracts/danmaku.dart'; +import '../../../../core/media/danmaku_comment_pipeline.dart'; + + +const double _kBackwardSeekThreshold = -1.0; +const double _kForwardSeekThreshold = 5.0; + + +const int _kMaxFeedPerTick = 10; + + +const double _kMaxFeedLagSec = 1.0; + +enum DanmakuPlaybackState { detached, disabled, playing, frozen, seeking } + +class DanmakuFeeder { + DanmakuController? _canvas; + List _comments = const []; + DanmakuPanelSettings _settings = const DanmakuPanelSettings(); + int _feedIndex = 0; + double _lastPosSec = -1; + DanmakuPlaybackState _state = DanmakuPlaybackState.detached; + double? _frozenPosSec; + + void attach(DanmakuController controller) { + _canvas = controller; + _state = _settings.enabled + ? DanmakuPlaybackState.frozen + : DanmakuPlaybackState.disabled; + } + + void detach() { + _canvas = null; + _state = DanmakuPlaybackState.detached; + } + + + void setComments(List commentsSortedByTime) { + _comments = commentsSortedByTime; + _canvas?.clear(); + _feedIndex = 0; + _lastPosSec = -1; + _frozenPosSec = null; + } + + void setPanelSettings( + DanmakuPanelSettings settings, { + DanmakuOption? option, + }) { + _settings = settings; + if (!_settings.enabled) { + _state = DanmakuPlaybackState.disabled; + } else if (_state == DanmakuPlaybackState.disabled) { + _state = _canvas == null + ? DanmakuPlaybackState.detached + : DanmakuPlaybackState.frozen; + } + if (option != null) { + try { + _canvas?.updateOption(option); + } catch (e) { + debugPrint('[DanmakuFeeder] updateOption failed: $e; detaching canvas'); + _canvas = null; + _state = DanmakuPlaybackState.detached; + } + } + } + + + void onPosition(double posSec) { + final canvas = _canvas; + if (canvas == null || + _comments.isEmpty || + !_settings.enabled || + _state != DanmakuPlaybackState.playing) { + return; + } + final effectivePos = posSec - _settings.offset; + final last = _lastPosSec; + if (last >= 0 && _isTimelineJump(posSec - last)) { + canvas.clear(); + _feedIndex = lowerBoundByTime(_comments, effectivePos); + } + _lastPosSec = posSec; + + var fedThisTick = 0; + while (_feedIndex < _comments.length && fedThisTick < _kMaxFeedPerTick) { + final comment = _comments[_feedIndex]; + if (comment.time > effectivePos) break; + _feedIndex++; + if (effectivePos - comment.time > _kMaxFeedLagSec) continue; + canvas.addDanmaku(_toContentItem(comment)); + fedThisTick++; + } + } + + void freezeAt(double posSec) { + final canvas = _canvas; + if (canvas == null) { + _state = DanmakuPlaybackState.detached; + return; + } + if (!_settings.enabled) { + _state = DanmakuPlaybackState.disabled; + return; + } + _state = DanmakuPlaybackState.frozen; + _frozenPosSec = posSec; + _lastPosSec = posSec; + if (canvas.running) { + canvas.pause(); + } + } + + void playFrom(double posSec) { + final canvas = _canvas; + if (canvas == null) { + _state = DanmakuPlaybackState.detached; + return; + } + if (!_settings.enabled) { + _state = DanmakuPlaybackState.disabled; + return; + } + final frozenPos = _frozenPosSec; + if (_state == DanmakuPlaybackState.frozen && + frozenPos != null && + posSec > frozenPos) { + final targetIndex = lowerBoundByTime( + _comments, + posSec - _settings.offset, + ); + if (targetIndex > _feedIndex) { + _feedIndex = targetIndex; + } + } + _lastPosSec = posSec; + _frozenPosSec = null; + _state = DanmakuPlaybackState.playing; + if (!canvas.running) { + canvas.resume(); + } + } + + void seekTo(double posSec, {bool clear = true}) { + if (clear) _canvas?.clear(); + _feedIndex = lowerBoundByTime(_comments, posSec - _settings.offset); + _lastPosSec = posSec; + _frozenPosSec = null; + if (_canvas == null) { + _state = DanmakuPlaybackState.detached; + } else { + _state = _settings.enabled + ? DanmakuPlaybackState.seeking + : DanmakuPlaybackState.disabled; + } + } + + bool _isTimelineJump(double deltaSec) { + return deltaSec < _kBackwardSeekThreshold || + deltaSec > _kForwardSeekThreshold; + } + + DanmakuContentItem _toContentItem(DanmakuComment c) { + final baseColor = _settings.colored ? c.color : Colors.white; + + + final opacity = _settings.opacity.clamp(0.0, 1.0); + final color = opacity >= 1.0 + ? baseColor + : baseColor.withValues(alpha: opacity); + return DanmakuContentItem( + c.text, + color: color, + type: switch (c.type) { + 4 when !_settings.fixedToScroll => DanmakuItemType.bottom, + 5 when !_settings.fixedToScroll => DanmakuItemType.top, + _ => DanmakuItemType.scroll, + }, + ); + } +} diff --git a/lib/features/player/services/danmaku/danmaku_matcher.dart b/lib/features/player/services/danmaku/danmaku_matcher.dart new file mode 100644 index 0000000..376ed66 --- /dev/null +++ b/lib/features/player/services/danmaku/danmaku_matcher.dart @@ -0,0 +1,212 @@ +import '../../../../core/contracts/cancellation.dart'; +import '../../../../core/contracts/danmaku.dart'; +import '../../../../core/domain/ports/danmaku_gateway.dart'; +import '../../../../core/infra/danmaku_api/danmaku_client.dart'; +import '../../../../core/media/danmaku_episode_parser.dart'; +import '../../../../core/media/danmaku_match_picker.dart'; +import '../../../../shared/utils/app_logger.dart'; + +const _tag = 'DanmakuMatcher'; + +typedef _SingleSourceMatcherResult = ({ + List matches, + int pickedIndex, +}); + +const _emptySingleSourceResult = (matches: [], pickedIndex: -1); + +class DanmakuMatcherResult { + final List matches; + final int pickedIndex; + + const DanmakuMatcherResult({ + required this.matches, + required this.pickedIndex, + }); + + static const empty = DanmakuMatcherResult(matches: [], pickedIndex: -1); +} + +class DanmakuMatcher { + DanmakuMatcher({required this._gateway}); + + final DanmakuGateway _gateway; + + Future resolve({ + required List sources, + required DanmakuMatchContext context, + CancellationToken? cancelToken, + }) async { + if (sources.isEmpty) return DanmakuMatcherResult.empty; + + final allMatches = []; + var pickedIndex = -1; + + for (final source in sources) { + cancelToken?.throwIfCancelled(); + _SingleSourceMatcherResult result; + try { + result = await _resolveSingle( + sourceUrl: source.url, + context: context, + cancelToken: cancelToken, + ); + } on CancelledException { + rethrow; + } catch (e) { + AppLogger.warn( + _tag, + 'source "${source.url}" match failed, skipping', + e, + ); + continue; + } + cancelToken?.throwIfCancelled(); + if (result.matches.isEmpty) continue; + + final baseIndex = allMatches.length; + allMatches.addAll( + result.matches.map( + (match) => DanmakuMatchCandidate(source: source, match: match), + ), + ); + if (pickedIndex < 0 && result.pickedIndex >= 0) { + pickedIndex = baseIndex + result.pickedIndex; + } + } + + if (allMatches.isEmpty) return DanmakuMatcherResult.empty; + return DanmakuMatcherResult( + matches: allMatches, + pickedIndex: pickedIndex < 0 ? 0 : pickedIndex, + ); + } + + Future<_SingleSourceMatcherResult> _resolveSingle({ + required String sourceUrl, + required DanmakuMatchContext context, + CancellationToken? cancelToken, + }) async { + final fileName = buildDanmakuFileName( + name: context.name, + type: context.type, + seriesName: context.seriesName, + season: context.season, + episode: context.episode, + ); + final episodeIndex = context.episode; + + AppLogger.debug( + _tag, + 'match request source="$sourceUrl" fileName="$fileName" ' + 'durationSec=${context.durationSec} targetEp=$episodeIndex', + ); + + var matches = await _gateway.match( + sourceUrl, + fileName, + context.durationSec, + ); + cancelToken?.throwIfCancelled(); + + AppLogger.debug( + _tag, + 'match received from "$sourceUrl": ${matches.length} entries', + ); + + if (matches.isEmpty && + context.type == 'Episode' && + context.seriesName != null && + context.seriesName!.isNotEmpty) { + AppLogger.info( + _tag, + 'exact match empty, falling back to searchEpisodes source="$sourceUrl" ' + 'seriesName="${context.seriesName}" ep=$episodeIndex', + ); + matches = await _gateway.searchEpisodes( + sourceUrl, + context.seriesName!, + episodeIndex, + ); + cancelToken?.throwIfCancelled(); + AppLogger.debug( + _tag, + 'searchEpisodes fallback: ${matches.length} entries', + ); + } + + if (matches.isEmpty) return _emptySingleSourceResult; + + final pickIndex = pickBestMatchIndex(matches, episodeIndex); + + if (episodeIndex != null) { + final pickedEp = extractEpisodeNumberFromTitle( + matches[pickIndex].episodeTitle, + ); + if (pickedEp != episodeIndex) { + final corrected = await _correctViaBangumi( + sourceUrl: sourceUrl, + animeId: matches[pickIndex].animeId, + targetEpisode: episodeIndex, + animeTitle: matches[pickIndex].animeTitle, + cancelToken: cancelToken, + ); + if (corrected != null) { + AppLogger.info( + _tag, + 'bangumi correction applied: episodeId=${corrected.episodeId} ' + 'title="${corrected.episodeTitle}"', + ); + return (matches: [corrected, ...matches], pickedIndex: 0); + } + } + } + + return (matches: matches, pickedIndex: pickIndex); + } + + Future _correctViaBangumi({ + required String sourceUrl, + required int animeId, + required int targetEpisode, + required String animeTitle, + CancellationToken? cancelToken, + }) async { + if (animeId <= 0) { + AppLogger.warn( + _tag, + 'skip bangumi correction: animeId missing in match response', + ); + return null; + } + AppLogger.debug( + _tag, + 'bangumi correction probing: animeId=$animeId targetEp=$targetEpisode', + ); + final episodes = await _gateway.getBangumiEpisodes(sourceUrl, animeId); + cancelToken?.throwIfCancelled(); + if (episodes.isEmpty) { + AppLogger.warn(_tag, 'bangumi $animeId returned no episodes'); + return null; + } + for (final ep in episodes) { + final n = + int.tryParse(ep.episodeNumber) ?? + extractEpisodeNumberFromTitle(ep.episodeTitle); + if (n == targetEpisode) { + return DanmakuMatch( + episodeId: ep.episodeId, + animeId: animeId, + animeTitle: animeTitle, + episodeTitle: ep.episodeTitle, + ); + } + } + AppLogger.warn( + _tag, + 'bangumi $animeId has ${episodes.length} episodes but none matches ' + 'targetEp=$targetEpisode', + ); + return null; + } +} diff --git a/lib/features/player/services/danmaku/danmaku_session_notifier.dart b/lib/features/player/services/danmaku/danmaku_session_notifier.dart new file mode 100644 index 0000000..42b4105 --- /dev/null +++ b/lib/features/player/services/danmaku/danmaku_session_notifier.dart @@ -0,0 +1,279 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../core/contracts/cancellation.dart'; +import '../../../../core/contracts/danmaku.dart'; +import '../../../../core/domain/ports/danmaku_gateway.dart'; +import '../../../../core/media/danmaku_comment_pipeline.dart'; +import '../../../../providers/danmaku_settings_provider.dart'; +import '../../../../providers/di_providers.dart'; +import '../../../../shared/utils/app_logger.dart'; +import 'danmaku_matcher.dart'; + +const _tag = 'DanmakuSession'; + +class DanmakuSessionNotifier extends AsyncNotifier { + late final DanmakuGateway _gateway; + late final DanmakuMatcher _matcher; + + CancellationToken? _token; + String? _currentItemId; + List _sources = const []; + int _chConvert = 0; + + @override + Future build() async { + _gateway = ref.read(danmakuGatewayProvider); + _matcher = DanmakuMatcher(gateway: _gateway); + ref.onDispose(() => _token?.cancel('notifier disposed')); + return const DanmakuSessionIdle(); + } + + Future loadFor(DanmakuMatchContext context) async { + final token = _swapToken('loadFor:${context.itemId}'); + _currentItemId = context.itemId; + + final settings = _readSettings(); + final sources = settings.enabledSources; + if (!settings.enabled || sources.isEmpty) { + AppLogger.debug( + _tag, + 'loadFor(${context.itemId}) -> Idle: ' + 'enabled=${settings.enabled} sources=${sources.length}', + ); + _sources = const []; + state = const AsyncData(DanmakuSessionIdle()); + return; + } + + _sources = sources; + _chConvert = _chConvertFromMode(settings.chineseConvert); + state = const AsyncData(DanmakuSessionMatching()); + + try { + final result = await _matcher.resolve( + sources: _sources, + context: context, + cancelToken: token, + ); + if (_isStale(token, context.itemId)) return; + + if (result.matches.isEmpty || result.pickedIndex < 0) { + AppLogger.info( + _tag, + 'loadFor(${context.itemId}) -> Empty: no matches found', + ); + state = const AsyncData(DanmakuSessionEmpty()); + return; + } + + state = AsyncData( + DanmakuSessionMatched( + matches: result.matches, + selectedIndex: result.pickedIndex, + commentStatus: DanmakuCommentStatus.loading, + ), + ); + + AppLogger.debug( + _tag, + 'auto-select index=${result.pickedIndex} ' + '(target ep=${context.episode})', + ); + await _loadCommentsForCurrent(token: token); + } on CancelledException { + return; + } catch (e, st) { + if (_isStale(token, context.itemId)) return; + AppLogger.error(_tag, 'loadFor failed', e, st); + state = AsyncData(DanmakuSessionFailed(e.toString())); + } + } + + Future selectMatch(int index) async { + final current = state.value; + if (current is! DanmakuSessionMatched) return; + if (index < 0 || index >= current.matches.length) return; + if (index == current.selectedIndex && + current.commentStatus == DanmakuCommentStatus.ready) { + return; + } + + final token = _swapToken('selectMatch:$index'); + state = AsyncData( + current.copyWith( + selectedIndex: index, + commentStatus: DanmakuCommentStatus.loading, + ), + ); + await _loadCommentsForCurrent(token: token); + } + + Future applyManualSelection({ + required DanmakuSource source, + required int episodeId, + required int animeId, + required String animeTitle, + required String episodeTitle, + }) async { + if (source.url.isEmpty || episodeId <= 0) return; + final current = state.value; + final picked = DanmakuMatchCandidate( + source: source, + match: DanmakuMatch( + episodeId: episodeId, + animeId: animeId, + animeTitle: animeTitle, + episodeTitle: episodeTitle, + ), + ); + + final previous = current is DanmakuSessionMatched ? current : null; + final newMatches = previous == null + ? [picked] + : [ + picked, + ...previous.matches.where( + (m) => m.episodeId != episodeId || m.source.url != source.url, + ), + ]; + + final token = _swapToken('applyManual:$episodeId'); + state = AsyncData( + DanmakuSessionMatched( + matches: newMatches, + selectedIndex: 0, + commentStatus: DanmakuCommentStatus.loading, + ), + ); + + try { + await _loadCommentsForCurrent(token: token); + } catch (_) { + if (_isStale(token, _currentItemId)) return; + if (previous != null) { + state = AsyncData(previous); + } else { + state = const AsyncData(DanmakuSessionEmpty()); + } + rethrow; + } + } + + Future?> manualSearch(String query) async { + final trimmed = query.trim(); + if (_sources.isEmpty || trimmed.isEmpty) return const []; + final token = _token; + final results = []; + + for (final source in _sources) { + List matches; + try { + matches = await _gateway.match(source.url, trimmed, 0); + } on CancelledException { + return null; + } catch (e) { + if (token != null && token.isCancelled) return null; + AppLogger.warn(_tag, 'manualSearch source "${source.url}" failed', e); + continue; + } + if (token != null && token.isCancelled) return null; + results.addAll( + matches.map( + (match) => DanmakuMatchCandidate(source: source, match: match), + ), + ); + } + + return results; + } + + Future?> listEpisodesForAnime( + DanmakuSource source, + int animeId, + ) async { + if (source.url.isEmpty || animeId <= 0) return const []; + final token = _token; + final res = await _gateway.getBangumiEpisodes(source.url, animeId); + if (token != null && token.isCancelled) return null; + return res; + } + + Future _loadCommentsForCurrent({ + required CancellationToken token, + }) async { + final current = state.value; + if (current is! DanmakuSessionMatched) return; + final selected = current.matches[current.selectedIndex]; + final episodeId = selected.episodeId; + final settings = _readSettings(); + + try { + final raw = await _gateway.fetchComments( + selected.source.url, + episodeId, + chConvert: _chConvert, + ); + token.throwIfCancelled(); + final comments = processDanmakuComments( + raw, + blockedKeywords: settings.blockedKeywords, + mergeDuplicates: settings.mergeDuplicates, + ); + if (_isStale(token, _currentItemId)) return; + AppLogger.debug( + _tag, + 'comments ready from "${selected.source.displayName}" ' + 'for ep=$episodeId: ${comments.length} items', + ); + final cur = state.value; + if (cur is DanmakuSessionMatched) { + state = AsyncData( + cur.copyWith( + commentStatus: DanmakuCommentStatus.ready, + comments: comments, + ), + ); + } + } on CancelledException { + return; + } catch (e) { + if (_isStale(token, _currentItemId)) return; + AppLogger.warn(_tag, 'load comments failed for ep=$episodeId', e); + final cur = state.value; + if (cur is DanmakuSessionMatched) { + state = AsyncData( + cur.copyWith(commentStatus: DanmakuCommentStatus.failed), + ); + } + } + } + + CancellationToken _swapToken(String reason) { + _token?.cancel(reason); + final t = CancellationToken(); + _token = t; + return t; + } + + bool _isStale(CancellationToken token, String? itemIdAtStart) { + if (token.isCancelled) return true; + if (token != _token) return true; + if (itemIdAtStart != null && itemIdAtStart != _currentItemId) return true; + return false; + } + + DanmakuSettingsState _readSettings() => + ref.read(danmakuSettingsProvider).value ?? const DanmakuSettingsState(); + + static int _chConvertFromMode(ChineseConvertMode mode) => switch (mode) { + ChineseConvertMode.toSimplified => 1, + ChineseConvertMode.toTraditional => 2, + ChineseConvertMode.none => 0, + }; +} + +final danmakuSessionProvider = + AsyncNotifierProvider.autoDispose< + DanmakuSessionNotifier, + DanmakuSessionState + >(DanmakuSessionNotifier.new); diff --git a/lib/features/player/services/emby_playback_reporter.dart b/lib/features/player/services/emby_playback_reporter.dart new file mode 100644 index 0000000..98135a6 --- /dev/null +++ b/lib/features/player/services/emby_playback_reporter.dart @@ -0,0 +1,105 @@ + + +import 'dart:async'; + +import '../../../core/contracts/playback.dart'; +import '../../../core/domain/ports/emby_gateway.dart'; +import '../../../shared/utils/app_logger.dart'; + +typedef PlaybackReportPayloadBuilder = + PlaybackReportPayload Function({required bool isPaused, String? eventName}); + +class EmbyPlaybackReporter { + EmbyPlaybackReporter({ + required EmbyGateway gateway, + required AuthedRequestContext context, + this.enabled = true, + }) : _gateway = gateway, + _context = context; + + static const _tag = 'Reporter'; + + final EmbyGateway _gateway; + final AuthedRequestContext _context; + + + final bool enabled; + + bool _startedReported = false; + bool _stoppedReported = false; + Timer? _progressTimer; + + bool get startedReported => _startedReported; + bool get stoppedReported => _stoppedReported; + + Future reportStarted(PlaybackReportPayload payload) async { + if (!enabled) return; + if (_startedReported) return; + _startedReported = true; + try { + await _gateway.reportPlaybackStarted(ctx: _context, payload: payload); + } catch (e) { + AppLogger.warn(_tag, 'Start report failed', e); + } + } + + Future reportProgress(PlaybackReportPayload payload) async { + if (!_startedReported) return; + try { + await _gateway.reportPlaybackProgress(ctx: _context, payload: payload); + } catch (e) { + AppLogger.warn(_tag, 'Progress report failed', e); + } + } + + Future reportStopped(PlaybackReportPayload payload) async { + if (!enabled) return; + if (!_startedReported) { + AppLogger.debug(_tag, 'stop report skipped: start not reported'); + return; + } + if (_stoppedReported) { + AppLogger.debug(_tag, 'stop report skipped: already stopped'); + return; + } + _stoppedReported = true; + _progressTimer?.cancel(); + final sw = Stopwatch()..start(); + try { + await _gateway.reportPlaybackStopped(ctx: _context, payload: payload); + AppLogger.debug(_tag, 'stop report ok in ${sw.elapsedMilliseconds}ms'); + } catch (e) { + AppLogger.warn( + _tag, + 'Stop report failed after ${sw.elapsedMilliseconds}ms', + e, + ); + } + } + + void startProgressTimer(PlaybackReportPayloadBuilder payloadBuilder) { + if (!enabled) return; + if (_progressTimer != null) return; + _progressTimer = Timer.periodic(const Duration(seconds: 8), (_) { + if (_startedReported && !_stoppedReported) { + unawaited( + reportProgress( + payloadBuilder(isPaused: false, eventName: 'TimeUpdate'), + ), + ); + } + }); + } + + void reset() { + _progressTimer?.cancel(); + _progressTimer = null; + _startedReported = false; + _stoppedReported = false; + } + + void dispose() { + _progressTimer?.cancel(); + _progressTimer = null; + } +} diff --git a/lib/features/player/services/frame_jank_monitor.dart b/lib/features/player/services/frame_jank_monitor.dart new file mode 100644 index 0000000..247b202 --- /dev/null +++ b/lib/features/player/services/frame_jank_monitor.dart @@ -0,0 +1,104 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/scheduler.dart'; + +import '../../../shared/utils/app_logger.dart'; + +const _tag = 'FrameJank'; + + +@immutable +class FrameDiag { + + final double fps; + + + final double lastBuildMs; + + + final double lastRasterMs; + + + final int jankCount; + + const FrameDiag({ + required this.fps, + required this.lastBuildMs, + required this.lastRasterMs, + required this.jankCount, + }); + + static const zero = FrameDiag( + fps: 0, + lastBuildMs: 0, + lastRasterMs: 0, + jankCount: 0, + ); +} + + +class FrameJankMonitor { + FrameJankMonitor({this.jankThreshold = const Duration(milliseconds: 32)}); + + + final Duration jankThreshold; + + final ValueNotifier diag = ValueNotifier( + FrameDiag.zero, + ); + + TimingsCallback? _callback; + int _jankCount = 0; + int _windowFrames = 0; + final Stopwatch _windowClock = Stopwatch()..start(); + double _fps = 0; + + void start() { + if (kReleaseMode || _callback != null) return; + final cb = _onTimings; + _callback = cb; + SchedulerBinding.instance.addTimingsCallback(cb); + } + + void _onTimings(List timings) { + if (timings.isEmpty) return; + for (final t in timings) { + if (t.buildDuration >= jankThreshold || + t.rasterDuration >= jankThreshold) { + _jankCount++; + AppLogger.warn( + _tag, + 'janky frame build=${_ms(t.buildDuration)} ' + 'raster=${_ms(t.rasterDuration)} total=${_ms(t.totalSpan)}', + ); + } + } + + _windowFrames += timings.length; + final elapsedMs = _windowClock.elapsedMilliseconds; + if (elapsedMs >= 500) { + _fps = _windowFrames * 1000 / elapsedMs; + _windowFrames = 0; + _windowClock.reset(); + } + + final last = timings.last; + diag.value = FrameDiag( + fps: _fps, + lastBuildMs: last.buildDuration.inMicroseconds / 1000, + lastRasterMs: last.rasterDuration.inMicroseconds / 1000, + jankCount: _jankCount, + ); + } + + void dispose() { + final cb = _callback; + if (cb != null) { + SchedulerBinding.instance.removeTimingsCallback(cb); + _callback = null; + } + diag.dispose(); + } + + static String _ms(Duration d) => + '${(d.inMicroseconds / 1000).toStringAsFixed(1)}ms'; +} diff --git a/lib/features/player/services/fullscreen_manager.dart b/lib/features/player/services/fullscreen_manager.dart new file mode 100644 index 0000000..60d1709 --- /dev/null +++ b/lib/features/player/services/fullscreen_manager.dart @@ -0,0 +1,222 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:screen_retriever/screen_retriever.dart'; +import 'package:window_manager/window_manager.dart'; + +import '../../../shared/utils/app_logger.dart'; + +class FullscreenManager { + static const _tag = 'Fullscreen'; + + + static bool get _isDesktop => Platform.isWindows || Platform.isMacOS; + + FullscreenManager({required this._onStateChanged}); + + final VoidCallback _onStateChanged; + + bool _isFullscreen = false; + bool _isChanging = false; + Rect? _windowBoundsBeforeFullscreen; + bool _wasMaximized = false; + bool _wasResizable = true; + bool _wasAlwaysOnTop = false; + bool _isDisposed = false; + Future? _disposeRestoreFuture; + + bool get isFullscreen => _isFullscreen; + + Future toggle() async { + if (_isDisposed) return; + if (!_isDesktop) return; + if (_isChanging) return; + _isChanging = true; + final wasFullscreen = _isFullscreen; + + try { + if (Platform.isWindows) { + if (wasFullscreen) { + await _leaveWindowsFullscreen(); + } else { + await _enterWindowsFullscreen(); + } + } else { + await _toggleNativeFullscreen(); + } + } catch (error) { + AppLogger.warn(_tag, 'Failed to toggle', error); + if (Platform.isWindows && !wasFullscreen) { + try { + await _leaveWindowsFullscreen(updateState: false); + } catch (_) {} + _setFullscreenState(false); + } else if (!Platform.isWindows) { + final actual = await _readNativeFullscreenState(); + if (actual != null) _setFullscreenState(actual); + } + } finally { + _isChanging = false; + } + } + + Future leave({bool updateState = true}) async { + if (_isDisposed && updateState) return; + if (!_isFullscreen) return; + try { + if (Platform.isWindows) { + await _leaveWindowsFullscreen(updateState: updateState); + } else { + await windowManager.setFullScreen(false); + } + } catch (_) {} + _isFullscreen = false; + } + + void dispose() { + if (_isDisposed) return; + _isDisposed = true; + if (_isFullscreen) { + _scheduleDisposeRestore(); + } + } + + @visibleForTesting + Future? get disposeRestoreFuture => _disposeRestoreFuture; + + void _scheduleDisposeRestore() { + if (_disposeRestoreFuture != null) return; + final restore = _restoreAfterDispose(); + _disposeRestoreFuture = restore; + unawaited(restore); + } + + Future _restoreAfterDispose() async { + while (_isChanging) { + await Future.delayed(Duration.zero); + } + if (!_isFullscreen) return; + + _isChanging = true; + try { + if (Platform.isWindows) { + await _leaveWindowsFullscreen(updateState: false); + } else { + await windowManager.setFullScreen(false); + _isFullscreen = false; + } + } catch (error) { + AppLogger.warn(_tag, 'Failed to restore window on dispose', error); + } finally { + _isChanging = false; + } + } + + void _setFullscreenState(bool fullscreen) { + if (_isFullscreen == fullscreen) return; + _isFullscreen = fullscreen; + if (!_isDisposed) { + _onStateChanged(); + } + } + + Future _readNativeFullscreenState() async { + try { + return await windowManager.isFullScreen(); + } catch (error) { + AppLogger.warn(_tag, 'Failed to read state', error); + return null; + } + } + + Future _toggleNativeFullscreen() async { + final current = await _readNativeFullscreenState() ?? _isFullscreen; + final next = !current; + await windowManager.setFullScreen(next); + final actual = await _readNativeFullscreenState() ?? next; + _setFullscreenState(actual); + if (_isDisposed) { + _scheduleDisposeRestore(); + } + } + + Future _enterWindowsFullscreen() async { + final currentBounds = await windowManager.getBounds(); + final display = await _displayForBounds(currentBounds); + + _windowBoundsBeforeFullscreen = currentBounds; + _wasMaximized = await windowManager.isMaximized(); + _wasResizable = await windowManager.isResizable(); + _wasAlwaysOnTop = await windowManager.isAlwaysOnTop(); + + if (_wasMaximized) { + await windowManager.unmaximize(); + } + await windowManager.setResizable(false); + await windowManager.setAlwaysOnTop(true); + await windowManager.setTitleBarStyle( + TitleBarStyle.hidden, + windowButtonVisibility: false, + ); + await windowManager.setBounds(fullscreenBoundsForDisplay(display)); + await windowManager.focus(); + _setFullscreenState(true); + if (_isDisposed) { + _scheduleDisposeRestore(); + } + } + + Future _leaveWindowsFullscreen({bool updateState = true}) async { + await windowManager.setTitleBarStyle( + TitleBarStyle.hidden, + windowButtonVisibility: false, + ); + await windowManager.setResizable(_wasResizable); + await windowManager.setAlwaysOnTop(_wasAlwaysOnTop); + + if (_wasMaximized) { + await windowManager.maximize(); + } else { + final bounds = _windowBoundsBeforeFullscreen; + if (bounds != null) { + await windowManager.setBounds(bounds); + } + } + await windowManager.focus(); + + if (updateState) { + _setFullscreenState(false); + } else { + _isFullscreen = false; + } + } + + Future _displayForBounds(Rect bounds) async { + final displays = await screenRetriever.getAllDisplays(); + final center = bounds.center; + for (final display in displays) { + final rect = fullscreenBoundsForDisplay(display); + if (rect.contains(center)) return display; + } + if (displays.isNotEmpty) return displays.first; + return screenRetriever.getPrimaryDisplay(); + } + + @visibleForTesting + static Rect fullscreenBoundsForDisplay(Display display) { + final position = display.visiblePosition ?? Offset.zero; + final size = display.size; + if (Platform.isWindows) { + const border = 8.0; + return Rect.fromLTWH( + position.dx - border, + position.dy, + size.width + border * 2, + size.height + border, + ); + } + return position & size; + } +} diff --git a/lib/features/player/services/network_speed_monitor.dart b/lib/features/player/services/network_speed_monitor.dart new file mode 100644 index 0000000..70dc58c --- /dev/null +++ b/lib/features/player/services/network_speed_monitor.dart @@ -0,0 +1,103 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + + +class NetworkSpeedMonitor { + NetworkSpeedMonitor({required this._totalRxBytes, DateTime Function()? now}) + : _now = now ?? DateTime.now; + + final Future Function() _totalRxBytes; + final DateTime Function() _now; + final ValueNotifier speed = ValueNotifier(0); + final ValueNotifier available = ValueNotifier(false); + + Timer? _timer; + int _lastBytes = 0; + int? _latestTotalBytes; + int? get latestTotalBytes => _latestTotalBytes; + DateTime? _lastSampleAt; + int _sampleSessionId = 0; + bool _disposed = false; + bool _sampleInFlight = false; + + + void start() { + if (_disposed) return; + _stopActiveSampling(resetValue: true); + final sessionId = _sampleSessionId; + _timer = Timer.periodic(const Duration(seconds: 1), (_) { + if (_disposed || sessionId != _sampleSessionId || _sampleInFlight) { + return; + } + _sampleInFlight = true; + unawaited(_sample(sessionId)); + }); + } + + void stop() { + _stopActiveSampling(resetValue: true); + } + + Future _sample(int sessionId) async { + try { + final totalBytes = await _totalRxBytes(); + if (_disposed || sessionId != _sampleSessionId) return; + if (totalBytes == null) { + available.value = false; + speed.value = 0.0; + return; + } + available.value = true; + final sampled = _sampleSpeedFromTotalBytes(totalBytes); + if (sampled != null) { + speed.value = sampled; + } + } finally { + if (!_disposed && sessionId == _sampleSessionId) { + _sampleInFlight = false; + } + } + } + + + double? _sampleSpeedFromTotalBytes(int totalBytes) { + final now = _now(); + final lastSampleAt = _lastSampleAt; + final lastBytes = _lastBytes; + + _lastBytes = totalBytes; + _latestTotalBytes = totalBytes; + _lastSampleAt = now; + + if (lastSampleAt == null) return null; + if (totalBytes < lastBytes) return 0.0; + + final elapsedMs = now.difference(lastSampleAt).inMilliseconds; + final deltaBytes = totalBytes - lastBytes; + if (elapsedMs <= 0 || deltaBytes <= 0) return 0.0; + return deltaBytes * 1000.0 / elapsedMs; + } + + void _stopActiveSampling({required bool resetValue}) { + _timer?.cancel(); + _timer = null; + _sampleSessionId++; + _sampleInFlight = false; + _lastBytes = 0; + _latestTotalBytes = null; + _lastSampleAt = null; + if (resetValue && !_disposed) { + available.value = false; + speed.value = 0; + } + } + + void dispose() { + if (_disposed) return; + _stopActiveSampling(resetValue: false); + _disposed = true; + speed.dispose(); + available.dispose(); + } +} diff --git a/lib/features/player/services/pip_manager.dart b/lib/features/player/services/pip_manager.dart new file mode 100644 index 0000000..de85e5f --- /dev/null +++ b/lib/features/player/services/pip_manager.dart @@ -0,0 +1,330 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:screen_retriever/screen_retriever.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:window_manager/window_manager.dart'; + +import '../../../shared/utils/app_logger.dart'; + + +class PipManager { + static const _tag = 'Pip'; + static const double _aspectRatio = 16 / 9; + static const Size _minPipSize = Size(360, 202.5); + static const Size _defaultPipSize = Size(480, 270); + static const Size _maxPipSize = Size(960, 540); + static const Size _unboundedMaximumSize = Size(100000, 100000); + static const double _edgeMargin = 24; + + static const _keyPipX = 'smplayer.pip_last_x'; + static const _keyPipY = 'smplayer.pip_last_y'; + static const _keyPipWidth = 'smplayer.pip_last_width'; + static const _keyPipHeight = 'smplayer.pip_last_height'; + static const _keyPipAlwaysOnTop = 'smplayer.pip_always_on_top'; + + PipManager({required this._onStateChanged}); + + final VoidCallback _onStateChanged; + + bool _isPip = false; + bool _isChanging = false; + bool _isDisposed = false; + Rect? _windowBoundsBefore; + bool _wasMaximized = false; + bool _wasResizable = true; + bool _wasAlwaysOnTop = false; + Future? _disposeRestoreFuture; + Rect? _lastPipBounds; + + + bool _pipAlwaysOnTop = true; + + + static bool isActive = false; + + bool get isPip => _isPip; + + + bool get isAlwaysOnTop => _pipAlwaysOnTop; + + Future toggle() async { + if (_isDisposed) return; + if (_isChanging) return; + if (_isPip) { + await leave(); + } else { + await enter(); + } + } + + Future enter() async { + if (_isDisposed) return; + if (_isPip || _isChanging) return; + _isChanging = true; + try { + _windowBoundsBefore = await windowManager.getBounds(); + _wasMaximized = await windowManager.isMaximized(); + _wasResizable = await windowManager.isResizable(); + _wasAlwaysOnTop = await windowManager.isAlwaysOnTop(); + _pipAlwaysOnTop = await _loadPersistedAlwaysOnTop(); + final display = await _tryPrimaryDisplay(); + + if (_wasMaximized) { + await windowManager.unmaximize(); + } + + await windowManager.setMinimumSize(_minPipSize); + await windowManager.setMaximumSize( + display == null ? _maxPipSize : pipMaximumSizeForDisplay(display), + ); + await windowManager.setAspectRatio(_aspectRatio); + await windowManager.setResizable(true); + await windowManager.setAlwaysOnTop(_pipAlwaysOnTop); + + final lastBounds = _lastPipBounds ?? await _loadPersistedPipBounds(); + final pipSize = lastBounds != null + ? Size(lastBounds.width, lastBounds.height) + : _defaultPipSize; + final bounds = + lastBounds ?? + (display == null + ? _fallbackPipBounds(pipSize) + : pipBoundsForDisplay(display, pipSize)); + await windowManager.setBounds(bounds); + await windowManager.focus(); + + _setPipState(true); + if (_isDisposed) { + _scheduleDisposeRestore(); + } + } catch (error, stack) { + AppLogger.warn(_tag, 'Failed to enter pip', error); + AppLogger.debug(_tag, stack.toString()); + try { + await _restoreWindowState(updateState: false); + } catch (_) {} + } finally { + _isChanging = false; + } + } + + Future leave({bool updateState = true}) async { + if (_isDisposed && updateState) return; + if (!_isPip) return; + if (_isChanging) { + return; + } + _isChanging = true; + try { + await _restoreWindowState(updateState: updateState); + } finally { + _isChanging = false; + } + } + + + Future toggleAlwaysOnTop() => setAlwaysOnTop(!_pipAlwaysOnTop); + + Future setAlwaysOnTop(bool value) async { + if (_isDisposed) return; + if (!_isPip) return; + if (_pipAlwaysOnTop == value) return; + if (_isChanging) return; + _isChanging = true; + try { + if (_isDisposed || !_isPip) return; + await windowManager.setAlwaysOnTop(value); + _pipAlwaysOnTop = value; + await _persistAlwaysOnTop(value); + if (!_isDisposed) { + _onStateChanged(); + } + } catch (error) { + AppLogger.warn(_tag, 'Failed to set pip always-on-top', error); + } finally { + _isChanging = false; + } + } + + void dispose() { + if (_isDisposed) return; + _isDisposed = true; + if (_isPip) { + _scheduleDisposeRestore(); + } + } + + @visibleForTesting + Future? get disposeRestoreFuture => _disposeRestoreFuture; + + void _scheduleDisposeRestore() { + if (_disposeRestoreFuture != null) return; + final restore = _restoreAfterDispose(); + _disposeRestoreFuture = restore; + unawaited(restore); + } + + Future _restoreAfterDispose() async { + while (_isChanging) { + await Future.delayed(Duration.zero); + } + if (!_isPip) return; + + _isChanging = true; + try { + await _restoreWindowState(updateState: false); + } finally { + _isChanging = false; + } + } + + Future _restoreWindowState({required bool updateState}) async { + try { + final bounds = await windowManager.getBounds(); + _lastPipBounds = bounds; + unawaited(_persistPipBounds(bounds)); + } catch (_) {} + try { + await windowManager.setAspectRatio(-1); + await windowManager.setMaximumSize(_unboundedMaximumSize); + + await windowManager.setMinimumSize(const Size(960, 640)); + await windowManager.setAlwaysOnTop(_wasAlwaysOnTop); + await windowManager.setResizable(_wasResizable); + + if (_wasMaximized) { + await windowManager.maximize(); + } else { + final bounds = _windowBoundsBefore; + if (bounds != null) { + await windowManager.setBounds(bounds); + } + } + await windowManager.focus(); + } catch (error) { + AppLogger.warn(_tag, 'Failed to restore window', error); + } + + _setPipState(false); + } + + void _setPipState(bool pip) { + if (_isPip == pip) return; + _isPip = pip; + PipManager.isActive = pip; + if (!_isDisposed) { + _onStateChanged(); + } + } + + Future _tryPrimaryDisplay() async { + try { + return await screenRetriever.getPrimaryDisplay(); + } catch (error) { + AppLogger.warn(_tag, 'Failed to read primary display', error); + return null; + } + } + + Rect _fallbackPipBounds(Size size) { + final fallback = + _windowBoundsBefore ?? Rect.fromLTWH(0, 0, size.width, size.height); + return Rect.fromLTWH(fallback.left, fallback.top, size.width, size.height); + } + + @visibleForTesting + static Rect pipBoundsForDisplay(Display display, Size size) { + final visibleRect = _visibleRectForDisplay(display); + final fitted = _fitSizeToVisibleArea(size, visibleRect.size); + var left = visibleRect.right - fitted.width - _edgeMargin; + final top = visibleRect.bottom - fitted.height - _edgeMargin; + + if (Platform.isWindows) { + const border = 8.0; + left -= border; + } + + return Rect.fromLTWH(left, top, fitted.width, fitted.height); + } + + @visibleForTesting + static Size pipMaximumSizeForDisplay(Display display) { + final visibleRect = _visibleRectForDisplay(display); + final fitted = _fitSizeToVisibleArea(_maxPipSize, visibleRect.size); + if (fitted.width < _minPipSize.width || + fitted.height < _minPipSize.height) { + return _minPipSize; + } + return fitted; + } + + static Rect _visibleRectForDisplay(Display display) { + final visiblePos = display.visiblePosition ?? Offset.zero; + final visibleSize = display.visibleSize ?? display.size; + return visiblePos & visibleSize; + } + + static Size _fitSizeToVisibleArea(Size preferred, Size visibleSize) { + final maxWidth = visibleSize.width - _edgeMargin * 2; + final maxHeight = visibleSize.height - _edgeMargin * 2; + if (maxWidth <= 0 || maxHeight <= 0) return preferred; + + var width = preferred.width; + var height = preferred.height; + if (width > maxWidth) { + width = maxWidth; + height = width / _aspectRatio; + } + if (height > maxHeight) { + height = maxHeight; + width = height * _aspectRatio; + } + return Size(width, height); + } + + Future _loadPersistedPipBounds() async { + try { + final p = await SharedPreferences.getInstance(); + final x = p.getDouble(_keyPipX); + final y = p.getDouble(_keyPipY); + final w = p.getDouble(_keyPipWidth); + final h = p.getDouble(_keyPipHeight); + if (x == null || y == null || w == null || h == null) return null; + if (w <= 0 || h <= 0) return null; + return Rect.fromLTWH(x, y, w, h); + } catch (_) { + return null; + } + } + + Future _persistPipBounds(Rect bounds) async { + try { + final p = await SharedPreferences.getInstance(); + await Future.wait([ + p.setDouble(_keyPipX, bounds.left), + p.setDouble(_keyPipY, bounds.top), + p.setDouble(_keyPipWidth, bounds.width), + p.setDouble(_keyPipHeight, bounds.height), + ]); + } catch (_) {} + } + + Future _loadPersistedAlwaysOnTop() async { + try { + final p = await SharedPreferences.getInstance(); + return p.getBool(_keyPipAlwaysOnTop) ?? true; + } catch (_) { + return true; + } + } + + Future _persistAlwaysOnTop(bool value) async { + try { + final p = await SharedPreferences.getInstance(); + await p.setBool(_keyPipAlwaysOnTop, value); + } catch (_) {} + } +} diff --git a/lib/features/player/services/trakt_scrobble_reporter.dart b/lib/features/player/services/trakt_scrobble_reporter.dart new file mode 100644 index 0000000..15dc843 --- /dev/null +++ b/lib/features/player/services/trakt_scrobble_reporter.dart @@ -0,0 +1,115 @@ +import 'dart:async'; + +import '../../../core/contracts/trakt/trakt_response.dart'; +import '../../../core/contracts/trakt/trakt_scrobble_target.dart'; +import '../../../core/contracts/trakt/trakt_sync_job.dart'; +import '../../../core/domain/ports/authed_trakt_gateway.dart'; +import '../../../core/infra/trakt_api/trakt_pending_sync_queue.dart'; +import '../../../shared/utils/app_logger.dart'; +import 'trakt_scrobble_state_machine.dart'; + +const _tag = 'TraktScrobble'; + + +class TraktScrobbleReporter { + TraktScrobbleReporter({ + required this._gateway, + required this._queue, + required this._target, + required this._readProgress, + this._appVersion, + this._appDate, + this._onSuccess, + this._onError, + }); + + final AuthedTraktGateway _gateway; + final TraktPendingSyncQueue _queue; + final TraktScrobbleTarget? _target; + final double Function() _readProgress; + final String? _appVersion; + final String? _appDate; + final void Function()? _onSuccess; + final void Function(String error)? _onError; + + final _sm = TraktScrobbleStateMachine(); + + bool get _enabled => _target != null; + + Future onPlay() => _dispatch(_sm.onPlay(), _readProgress()); + + Future onPause() => _dispatch(_sm.onPause(), _readProgress()); + + Future onCompleted() => _dispatch(_sm.onCompleted(), 100); + + + Future onStop() => + _dispatch(_sm.onStop(progress: _readProgress()), _readProgress()); + + Future _dispatch(TraktScrobbleAction action, double progress) async { + if (!_enabled) return; + final type = _typeFor(action); + if (type == null) return; + + final clamped = progress.clamp(0.0, 100.0).toDouble(); + final body = _target!.toBody( + progress: clamped, + appVersion: _appVersion, + appDate: _appDate, + ); + + AppLogger.debug( + _tag, + 'scrobble ${type.wire} progress=${clamped.toStringAsFixed(1)} ' + '(${_target.kind.name})', + ); + + TraktScrobbleOutcome outcome; + try { + outcome = await _gateway.scrobble(type, body); + } catch (e) { + AppLogger.debug(_tag, 'scrobble threw, enqueue', e); + await _enqueue(type, body); + return; + } + + switch (outcome.cls) { + case TraktResponseClass.success: + case TraktResponseClass.duplicate: + AppLogger.debug(_tag, 'scrobble ${type.wire} ok (${outcome.cls.name})'); + await _queue.discardSupersededBy(type: type, body: body); + _onSuccess?.call(); + case TraktResponseClass.invalid: + AppLogger.debug(_tag, 'scrobble ${type.wire} invalid → drop'); + case TraktResponseClass.rateLimited: + case TraktResponseClass.unauthorized: + case TraktResponseClass.transient: + await _enqueue(type, body); + _onError?.call(outcome.cls.name); + } + } + + Future _enqueue( + TraktSyncJobType type, + Map body, + ) async { + try { + await _queue.enqueue(type: type, body: body); + } catch (e) { + AppLogger.warn(_tag, 'enqueue failed', e); + } + } + + static TraktSyncJobType? _typeFor(TraktScrobbleAction action) { + switch (action) { + case TraktScrobbleAction.none: + return null; + case TraktScrobbleAction.start: + return TraktSyncJobType.scrobbleStart; + case TraktScrobbleAction.pause: + return TraktSyncJobType.scrobblePause; + case TraktScrobbleAction.stop: + return TraktSyncJobType.scrobbleStop; + } + } +} diff --git a/lib/features/player/services/trakt_scrobble_state_machine.dart b/lib/features/player/services/trakt_scrobble_state_machine.dart new file mode 100644 index 0000000..6888631 --- /dev/null +++ b/lib/features/player/services/trakt_scrobble_state_machine.dart @@ -0,0 +1,45 @@ + + +enum TraktScrobbleState { idle, started, paused, stopped } + +enum TraktScrobbleAction { none, start, pause, stop } + +class TraktScrobbleStateMachine { + TraktScrobbleState _state = TraktScrobbleState.idle; + + TraktScrobbleState get state => _state; + + + TraktScrobbleAction onPlay() { + if (_state == TraktScrobbleState.started) return TraktScrobbleAction.none; + if (_state == TraktScrobbleState.stopped) return TraktScrobbleAction.none; + _state = TraktScrobbleState.started; + return TraktScrobbleAction.start; + } + + + TraktScrobbleAction onPause() { + if (_state != TraktScrobbleState.started) return TraktScrobbleAction.none; + _state = TraktScrobbleState.paused; + return TraktScrobbleAction.pause; + } + + + TraktScrobbleAction onCompleted() { + if (_state == TraktScrobbleState.stopped) return TraktScrobbleAction.none; + if (_state == TraktScrobbleState.idle) return TraktScrobbleAction.none; + _state = TraktScrobbleState.stopped; + return TraktScrobbleAction.stop; + } + + + TraktScrobbleAction onStop({required double progress}) { + if (_state == TraktScrobbleState.idle || + _state == TraktScrobbleState.stopped) { + return TraktScrobbleAction.none; + } + _state = TraktScrobbleState.stopped; + if (progress < 1) return TraktScrobbleAction.none; + return TraktScrobbleAction.stop; + } +} diff --git a/lib/features/player/session/initial_subtitle_resolver.dart b/lib/features/player/session/initial_subtitle_resolver.dart new file mode 100644 index 0000000..107263e --- /dev/null +++ b/lib/features/player/session/initial_subtitle_resolver.dart @@ -0,0 +1,124 @@ +import 'package:flutter/foundation.dart'; + +import '../../../core/contracts/playback.dart'; +import '../../../providers/subtitle_settings_provider.dart'; + +enum InitialSubtitleSelectionType { unchanged, disabled, track } + + +class InitialSubtitleSelection { + final InitialSubtitleSelectionType type; + final EmbySubtitleTrack? track; + + const InitialSubtitleSelection._({required this.type, this.track}); + + static const unchanged = InitialSubtitleSelection._( + type: InitialSubtitleSelectionType.unchanged, + ); + + static const disabled = InitialSubtitleSelection._( + type: InitialSubtitleSelectionType.disabled, + ); + + const InitialSubtitleSelection.track(EmbySubtitleTrack selectedTrack) + : this._(type: InitialSubtitleSelectionType.track, track: selectedTrack); +} + + +InitialSubtitleSelection resolveInitialSubtitleSelection({ + required List subtitles, + required int? preferredSubtitleStreamIndex, + required int? defaultSubtitleStreamIndex, + required RememberedSubtitleTrack? rememberedSubtitle, +}) { + if (preferredSubtitleStreamIndex != null && + preferredSubtitleStreamIndex >= 0) { + return _selectionForStreamIndex(subtitles, preferredSubtitleStreamIndex); + } + + if (rememberedSubtitle != null) { + if (rememberedSubtitle.isNone) { + return InitialSubtitleSelection.disabled; + } + + final rememberedStreamIndex = matchRememberedSubtitle( + rememberedSubtitle, + subtitles, + ); + if (rememberedStreamIndex != null) { + return _selectionForStreamIndex(subtitles, rememberedStreamIndex); + } + } + + if (preferredSubtitleStreamIndex != null && + preferredSubtitleStreamIndex < 0) { + return InitialSubtitleSelection.disabled; + } + + if (defaultSubtitleStreamIndex != null && defaultSubtitleStreamIndex >= 0) { + return _selectionForStreamIndex(subtitles, defaultSubtitleStreamIndex); + } + + if (defaultSubtitleStreamIndex == -1) { + return InitialSubtitleSelection.disabled; + } + + return InitialSubtitleSelection.unchanged; +} + +InitialSubtitleSelection _selectionForStreamIndex( + List subtitles, + int streamIndex, +) { + final selectedTrack = subtitles + .where((subtitle) => subtitle.index == streamIndex) + .firstOrNull; + return selectedTrack == null + ? InitialSubtitleSelection.unchanged + : InitialSubtitleSelection.track(selectedTrack); +} + + +@visibleForTesting +int? matchRememberedSubtitle( + RememberedSubtitleTrack remembered, + List subtitles, +) { + if (subtitles.isEmpty) return null; + + final wantedLanguage = remembered.language?.toLowerCase(); + final wantedTitle = remembered.title?.toLowerCase(); + + if (wantedLanguage != null && wantedTitle != null) { + for (final subtitle in subtitles) { + if (subtitle.language?.toLowerCase() == wantedLanguage && + subtitle.title?.toLowerCase() == wantedTitle) { + return subtitle.index; + } + } + } + + if (wantedTitle != null) { + for (final subtitle in subtitles) { + if (subtitle.title?.toLowerCase() == wantedTitle) { + return subtitle.index; + } + } + } + + if (wantedLanguage != null) { + for (final subtitle in subtitles) { + if (subtitle.language?.toLowerCase() == wantedLanguage) { + return subtitle.index; + } + } + } + + for (final subtitle in subtitles) { + if (subtitle.index == remembered.streamIndex) { + return subtitle.index; + } + } + + return null; +} diff --git a/lib/features/player/session/playback_phase.dart b/lib/features/player/session/playback_phase.dart new file mode 100644 index 0000000..c26b5c3 --- /dev/null +++ b/lib/features/player/session/playback_phase.dart @@ -0,0 +1,3 @@ + + +enum PlaybackPhase { preparing, playing, ended, failed, disposed } diff --git a/lib/features/player/session/playback_request.dart b/lib/features/player/session/playback_request.dart new file mode 100644 index 0000000..5ed94a0 --- /dev/null +++ b/lib/features/player/session/playback_request.dart @@ -0,0 +1,198 @@ +import '../../../core/contracts/danmaku.dart'; +import '../../../core/contracts/library.dart'; +import '../../../core/contracts/playback.dart'; +import '../../../providers/audio_settings_provider.dart'; +import '../../../providers/danmaku_settings_provider.dart'; +import '../../../providers/player_settings_provider.dart'; +import '../../../providers/subtitle_settings_provider.dart'; +import 'playback_target.dart'; + +String playbackMemoryKey({ + required String serverId, + required String userId, + required String itemId, + String? seriesId, +}) => '$serverId:$userId:${seriesId ?? itemId}'; + + +class PlaybackRequest { + final String itemId; + final String? mediaSourceId; + final int? startPositionTicks; + + final int? preferredSubtitleStreamIndex; + final int? preferredAudioStreamIndex; + + + final SubtitleStyleSettings subtitleSettings; + final PlayerSettingsState playerSettings; + final DanmakuSettingsState danmakuSettings; + final AudioSettingsState audioSettings; + + + final bool playFromStart; + + + final PlaybackServerIdentity? targetServer; + + + final EmbyRawItem? preloadedItem; + + + final String? directStreamUrl; + + + final Map directStreamHeaders; + + const PlaybackRequest({ + required this.itemId, + this.mediaSourceId, + this.startPositionTicks, + this.preferredSubtitleStreamIndex, + this.preferredAudioStreamIndex, + required this.subtitleSettings, + required this.playerSettings, + required this.danmakuSettings, + this.audioSettings = const AudioSettingsState(), + this.playFromStart = false, + this.targetServer, + this.preloadedItem, + this.directStreamUrl, + this.directStreamHeaders = const {}, + }); + + + factory PlaybackRequest.fromEpisode({ + required EmbyRawItem episode, + required SubtitleStyleSettings subtitleSettings, + required PlayerSettingsState playerSettings, + required DanmakuSettingsState danmakuSettings, + AudioSettingsState audioSettings = const AudioSettingsState(), + }) { + return PlaybackRequest( + itemId: episode.Id, + startPositionTicks: episode.UserData?.PlaybackPositionTicks, + subtitleSettings: subtitleSettings, + playerSettings: playerSettings, + danmakuSettings: danmakuSettings, + audioSettings: audioSettings, + preloadedItem: episode, + ); + } + + PlaybackRequest copyWith({ + String? itemId, + String? mediaSourceId, + int? startPositionTicks, + int? preferredSubtitleStreamIndex, + int? preferredAudioStreamIndex, + SubtitleStyleSettings? subtitleSettings, + PlayerSettingsState? playerSettings, + DanmakuSettingsState? danmakuSettings, + AudioSettingsState? audioSettings, + bool? playFromStart, + PlaybackServerIdentity? targetServer, + Object? preloadedItem = _noChange, + }) { + return PlaybackRequest( + itemId: itemId ?? this.itemId, + mediaSourceId: mediaSourceId ?? this.mediaSourceId, + startPositionTicks: startPositionTicks ?? this.startPositionTicks, + preferredSubtitleStreamIndex: + preferredSubtitleStreamIndex ?? this.preferredSubtitleStreamIndex, + preferredAudioStreamIndex: + preferredAudioStreamIndex ?? this.preferredAudioStreamIndex, + subtitleSettings: subtitleSettings ?? this.subtitleSettings, + playerSettings: playerSettings ?? this.playerSettings, + danmakuSettings: danmakuSettings ?? this.danmakuSettings, + audioSettings: audioSettings ?? this.audioSettings, + playFromStart: playFromStart ?? this.playFromStart, + targetServer: targetServer ?? this.targetServer, + preloadedItem: identical(preloadedItem, _noChange) + ? this.preloadedItem + : preloadedItem as EmbyRawItem?, + directStreamUrl: directStreamUrl, + directStreamHeaders: directStreamHeaders, + ); + } +} + +const Object _noChange = Object(); + + +class PlaybackContext { + final String itemId; + final String itemName; + final String? itemType; + final int? itemIndexNumber; + final String? seriesName; + final String? seriesId; + final String? seasonId; + final String? mediaSourceId; + final String? playSessionId; + final int? runTimeTicks; + final String playMethod; + + + final bool isDolbyVision; + + final List embySubtitles; + final List embyAudioTracks; + final List allMediaSources; + + + final Map providerIds; + + + final Map seriesProviderIds; + + final int? parentIndexNumber; + + + final DanmakuMatchContext danmakuMatchContext; + + + final String serverId; + final String userId; + + + final String baseUrl; + final String token; + + final String? logoUrl; + + const PlaybackContext({ + required this.itemId, + required this.itemName, + required this.itemType, + required this.itemIndexNumber, + required this.seriesName, + required this.seriesId, + required this.seasonId, + required this.mediaSourceId, + required this.playSessionId, + required this.runTimeTicks, + required this.playMethod, + this.isDolbyVision = false, + required this.danmakuMatchContext, + required this.serverId, + required this.userId, + required this.baseUrl, + required this.token, + this.embySubtitles = const [], + this.embyAudioTracks = const [], + this.allMediaSources = const [], + this.providerIds = const {}, + this.seriesProviderIds = const {}, + this.parentIndexNumber, + this.logoUrl, + }); + + + String get memoryKey => playbackMemoryKey( + serverId: serverId, + userId: userId, + itemId: itemId, + seriesId: seriesId, + ); +} diff --git a/lib/features/player/session/playback_session.dart b/lib/features/player/session/playback_session.dart new file mode 100644 index 0000000..339965e --- /dev/null +++ b/lib/features/player/session/playback_session.dart @@ -0,0 +1,1053 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +import '../../../core/contracts/cancellation.dart'; +import '../../../core/contracts/playback.dart'; +import '../../../core/infra/emby_api/playback_resolver.dart'; +import '../../../core/infra/player_engine/player_engine.dart'; +import '../../../core/media/external_subtitle_loader.dart'; +import '../../../providers/audio_settings_provider.dart'; +import '../../../providers/player_settings_provider.dart'; +import '../../../providers/subtitle_settings_provider.dart'; +import '../../../shared/utils/app_logger.dart'; +import '../../../shared/utils/emby_ticks.dart'; +import '../services/danmaku/danmaku_feeder.dart'; +import '../services/emby_playback_reporter.dart'; +import '../services/network_speed_monitor.dart'; +import '../services/trakt_scrobble_reporter.dart'; +import 'initial_subtitle_resolver.dart'; +import 'playback_phase.dart'; +import 'playback_request.dart'; +import 'stall_detector.dart'; + +const _tag = 'PlaybackSession'; +const _openTimeout = Duration(seconds: 60); +const _engineErrorGrace = Duration(seconds: 5); +const _stallPollInterval = Duration(seconds: 2); + + +const embeddedSubtitleFetchTimeout = Duration(seconds: 8); + + +class PlaybackSession { + PlaybackSession({ + required this.engine, + required this._reporter, + required this.speedMonitor, + required this.feeder, + required this.context, + required this.request, + required this._prepareResult, + required this._streamHeaders, + required this._playerSettingsNotifier, + this.pendingEngineOpenFuture, + TraktScrobbleReporter Function(double Function() readProgress)? + traktReporterFactory, + this.rememberedAudioTrack, + this.rememberedPrimarySubtitle, + ExternalSubtitleLoader? externalSubtitleLoader, + }) : _externalSubtitleLoader = + externalSubtitleLoader ?? ExternalSubtitleLoader() { + _subtitleDelaySeconds = request.subtitleSettings.delay; + _traktReporter = traktReporterFactory?.call(_progressPercent); + _subscriptions = >[ + engine.stream.position.listen(_onPosition), + engine.stream.buffer.listen(_onBufferChanged), + engine.stream.playing.listen(_onPlayingChanged), + engine.stream.completed.listen(_onCompleted), + engine.stream.volume.listen(_onVolume), + engine.stream.tracks.listen(_onTracksChanged), + engine.stream.error.listen(_onEngineError), + ]; + } + + + TraktScrobbleReporter? _traktReporter; + + final PlayerEngine engine; + final EmbyPlaybackReporter _reporter; + final NetworkSpeedMonitor speedMonitor; + final DanmakuFeeder feeder; + final PlaybackContext context; + final PlaybackRequest request; + + final PlaybackRequestResult _prepareResult; + final Map? _streamHeaders; + + + final Future? pendingEngineOpenFuture; + final PlayerSettingsNotifier _playerSettingsNotifier; + final RememberedAudioTrack? rememberedAudioTrack; + final RememberedSubtitleTrack? rememberedPrimarySubtitle; + + final ValueNotifier _phase = ValueNotifier( + PlaybackPhase.preparing, + ); + final CancellationToken _cancel = CancellationToken(); + late final List> _subscriptions; + + bool _disposed = false; + bool _started = false; + bool _isPaused = false; + bool _lastFailureTerminal = false; + bool _defaultsApplied = false; + bool _engineOpenCompleted = false; + Object? _lastError; + Timer? _volumeSaveTimer; + Timer? _engineErrorTimer; + Timer? _stallPollTimer; + Duration? _engineErrorAnchorPosition; + String? _pendingEngineError; + + ValueListenable get phase => _phase; + CancellationToken get cancelToken => _cancel; + Object? get lastError => _lastError; + + + bool get lastFailureTerminal => _lastFailureTerminal; + bool get isPaused => _isPaused; + bool get isDanmakuActive => _danmakuActive; + + + bool Function()? onStreamFailure; + + final ExternalSubtitleLoader _externalSubtitleLoader; + + + final ValueNotifier> externalSubtitleLines = + ValueNotifier>(const []); + + + final ValueNotifier activeExternalSubtitle = + ValueNotifier(null); + + + final ValueNotifier activeSubtitleStreamIndex = ValueNotifier( + null, + ); + + + final ValueNotifier subtitleLoading = ValueNotifier(false); + + + void Function(String message)? onExternalSubtitleError; + + SubtitleCueSource? _externalCueSource; + double _subtitleDelaySeconds = 0; + + + int _externalLoadGeneration = 0; + + + final Set _prefetchedExternalSubtitleUrls = {}; + + + bool _embeddedExtractionUnavailable = false; + + + final StallDetector _stallDetector = StallDetector(); + final Stopwatch _clock = Stopwatch()..start(); + + + bool _failureSignalled = false; + + Timer? _danmakuInterp; + Duration _danmakuAnchorPos = Duration.zero; + Duration _danmakuAnchorClock = Duration.zero; + Duration? _lastDanmakuPos; + bool _danmakuAwaitingMovementBaseline = true; + bool _danmakuMoving = false; + bool _danmakuActive = false; + static const Duration _danmakuInterpInterval = Duration(milliseconds: 32); + + + static const Duration _danmakuStallGrace = Duration(milliseconds: 600); + + + Duration _lastPositionAdvanceClock = Duration.zero; + + + Future start() async { + if (_disposed) { + throw StateError('PlaybackSession already disposed'); + } + if (_started) { + AppLogger.warn(_tag, 'start() called twice; ignoring'); + return; + } + _started = true; + + try { + _cancel.throwIfCancelled(); + + speedMonitor.stop(); + + final startSeconds = _prepareResult.startPositionSeconds; + if (startSeconds > 0) { + feeder.seekTo(startSeconds, clear: true); + } + + + if (pendingEngineOpenFuture != null) { + speedMonitor.start(); + AppLogger.debug( + _tag, + 'awaiting pending engine.open (started in build)', + ); + try { + await _runWithTimeout(pendingEngineOpenFuture!, _openTimeout); + } catch (_) { + speedMonitor.stop(); + try { + await engine.stop(); + } catch (e) { + AppLogger.warn(_tag, 'engine.stop after open failure failed', e); + } + rethrow; + } + _cancel.throwIfCancelled(); + } else { + speedMonitor.start(); + AppLogger.debug(_tag, 'engine.open already completed before start'); + } + _engineOpenCompleted = true; + AppLogger.debug(_tag, 'engine.open done'); + + _applySubtitleStyleFromRequest(); + + _tryApplyDefaultTracks(engine.state.tracks); + + _cancel.throwIfCancelled(); + await engine.play(); + _isPaused = false; + _setPhase(PlaybackPhase.playing); + _startDanmakuInterpolator(); + _startStallPolling(); + + await _reporter.reportStarted(_buildReportPayload(isPaused: false)); + _reporter.startProgressTimer(_buildReportPayload); + + unawaited(_traktReporter?.onPlay() ?? Future.value()); + } catch (e, s) { + _lastError = e; + AppLogger.error(_tag, 'session start failed', e, s); + _setPhase(PlaybackPhase.failed); + rethrow; + } + } + + + Future dispose() async { + if (_disposed) return; + _disposed = true; + _cancel.cancel('session disposed'); + final sw = Stopwatch()..start(); + AppLogger.debug(_tag, 'dispose start'); + + _volumeSaveTimer?.cancel(); + _volumeSaveTimer = null; + _engineErrorTimer?.cancel(); + _engineErrorTimer = null; + _stallPollTimer?.cancel(); + _stallPollTimer = null; + _danmakuInterp?.cancel(); + _danmakuInterp = null; + speedMonitor.stop(); + + final stopReport = _reporter.reportStopped( + _buildReportPayload(isPaused: _isPaused), + ); + + unawaited(_traktReporter?.onStop() ?? Future.value()); + + for (final sub in _subscriptions) { + try { + await sub.cancel(); + } catch (e) { + AppLogger.warn(_tag, 'subscription cancel failed', e); + } + } + AppLogger.debug(_tag, 'subs cancelled @${sw.elapsedMilliseconds}ms'); + + final stopSw = Stopwatch()..start(); + try { + await engine.stop(); + } catch (e) { + AppLogger.warn(_tag, 'engine.stop failed', e); + } + AppLogger.debug( + _tag, + 'engine.stop done in ${stopSw.elapsedMilliseconds}ms @${sw.elapsedMilliseconds}ms', + ); + + feeder.detach(); + _reporter.dispose(); + speedMonitor.dispose(); + + + AppLogger.debug(_tag, 'children disposed @${sw.elapsedMilliseconds}ms'); + + final awaitSw = Stopwatch()..start(); + try { + await stopReport.timeout(const Duration(seconds: 5)); + } catch (e) { + AppLogger.debug( + _tag, + 'stop report timed out / failed after ${awaitSw.elapsedMilliseconds}ms ' + '(dispose total ${sw.elapsedMilliseconds}ms)', + e, + ); + } + + _setPhase(PlaybackPhase.disposed); + _phase.dispose(); + _externalSubtitleLoader.clear(); + externalSubtitleLines.dispose(); + activeExternalSubtitle.dispose(); + activeSubtitleStreamIndex.dispose(); + subtitleLoading.dispose(); + AppLogger.debug(_tag, 'dispose done in ${sw.elapsedMilliseconds}ms'); + } + + Future play() async { + if (_disposed) return; + await engine.play(); + } + + Future pause() async { + if (_disposed) return; + await engine.pause(); + _danmakuAwaitingMovementBaseline = true; + _danmakuMoving = false; + _syncDanmakuActive(); + } + + Future seek(Duration position) async { + if (_disposed) return; + _danmakuAwaitingMovementBaseline = true; + _danmakuMoving = false; + _danmakuActive = false; + feeder.seekTo(position.inMilliseconds / 1000.0, clear: true); + await engine.seek(position); + } + + Future setRate(double rate) async { + if (_disposed) return; + await engine.setRate(rate); + } + + Future setVolume(double volume) async { + if (_disposed) return; + await engine.setVolume(volume); + } + + Future setAudioTrack(AudioTrack track) async { + if (_disposed) return; + await engine.setAudioTrack(track); + } + + + void prefetchExternalSubtitles() { + if (_disposed) return; + for (final track in _prepareResult.subtitles) { + final url = track.deliveryUrl; + if (!track.isExternal || + url == null || + url.isEmpty || + !PlaybackResolver.isTextSubtitleCodec(track.codec)) { + continue; + } + if (!_prefetchedExternalSubtitleUrls.add(url)) continue; + unawaited(_prefetchExternalSubtitle(url, track.index)); + } + } + + Future _prefetchExternalSubtitle(String url, int streamIndex) async { + try { + await _externalSubtitleLoader.load(url, headers: _streamHeaders); + if (!_disposed) { + AppLogger.debug( + _tag, + 'external subtitle prefetched index=$streamIndex', + ); + } + } catch (error) { + AppLogger.debug( + _tag, + 'external subtitle prefetch failed index=$streamIndex', + error, + ); + } + } + + + Future selectSubtitleByStreamIndex(int? streamIndex) async { + if (_disposed) return; + if (streamIndex == null || streamIndex < 0) { + AppLogger.info( + _tag, + 'selectSubtitle: user requested disable (streamIdx=$streamIndex)', + ); + await _disableSubtitle(); + return; + } + final embyTrack = _prepareResult.subtitles + .where((track) => track.index == streamIndex) + .firstOrNull; + if (embyTrack == null) { + final availableIndices = _prepareResult.subtitles + .map((track) => track.index) + .toList(growable: false); + AppLogger.warn( + _tag, + 'selectSubtitle: streamIdx=$streamIndex not found in emby subtitles ' + '(available=$availableIndices)', + ); + return; + } + AppLogger.info( + _tag, + 'selectSubtitle: user requested streamIdx=$streamIndex ' + 'external=${embyTrack.isExternal} codec=${embyTrack.codec} ' + 'lang=${embyTrack.language} title="${embyTrack.title}"', + ); + await _applyEmbySubtitleTrack(embyTrack); + } + + + Future _disableSubtitle() async { + _clearDartSubtitleState(); + activeSubtitleStreamIndex.value = null; + engine.setNativeSubtitleRendering(false); + await engine.setSubtitleTrack(const SubtitleTrack.no()); + AppLogger.debug(_tag, 'subtitle disabled'); + } + + + Future _applyEmbySubtitleTrack(EmbySubtitleTrack track) async { + final url = track.deliveryUrl; + final isText = PlaybackResolver.isTextSubtitleCodec(track.codec); + final hasUrl = url != null && url.isNotEmpty; + final skipExtraction = !track.isExternal && _embeddedExtractionUnavailable; + if (hasUrl && isText && !skipExtraction) { + AppLogger.info( + _tag, + 'applySubtitle: route=dart streamIdx=${track.index} ' + 'external=${track.isExternal} codec=${track.codec} ' + '(text codec with delivery url)', + ); + return _loadDartSubtitle(track); + } + final routeReason = !hasUrl + ? 'no delivery url' + : !isText + ? 'bitmap codec' + : 'embedded extraction unavailable (this session)'; + AppLogger.info( + _tag, + 'applySubtitle: route=engine streamIdx=${track.index} ' + 'external=${track.isExternal} codec=${track.codec} ' + 'reason="$routeReason"', + ); + return _applySubtitleViaEngine(track); + } + + + Future _loadDartSubtitle(EmbySubtitleTrack track) async { + final url = track.deliveryUrl; + if (url == null || url.isEmpty) return false; + final generation = ++_externalLoadGeneration; + subtitleLoading.value = true; + activeExternalSubtitle.value = track; + activeSubtitleStreamIndex.value = track.index; + _externalCueSource = null; + _setExternalSubtitleLines(const []); + engine.setNativeSubtitleRendering(false); + await engine.setSubtitleTrack(const SubtitleTrack.no()); + if (_disposed || generation != _externalLoadGeneration) return true; + + try { + + + final cues = await _externalSubtitleLoader.load( + url, + headers: _streamHeaders, + receiveTimeout: track.isExternal ? null : embeddedSubtitleFetchTimeout, + ); + if (_disposed || generation != _externalLoadGeneration) return true; + _externalCueSource = SubtitleCueSource(cues); + _updateExternalSubtitleLines(engine.state.position); + subtitleLoading.value = false; + AppLogger.debug( + _tag, + 'dart subtitle active index=${track.index} ' + 'external=${track.isExternal} cues=${cues.length}', + ); + return true; + } catch (e) { + if (_disposed || generation != _externalLoadGeneration) return true; + AppLogger.warn(_tag, 'dart subtitle load failed idx=${track.index}', e); + _clearDartSubtitleState(); + if (!track.isExternal) { + + + _embeddedExtractionUnavailable = true; + if (_applySubtitleViaEngine(track)) { + AppLogger.info( + _tag, + 'embedded extraction unavailable; fell back to engine switch ' + 'idx=${track.index} (subsequent embedded subs skip extraction)', + ); + return true; + } + } + activeSubtitleStreamIndex.value = null; + engine.setNativeSubtitleRendering(false); + onExternalSubtitleError?.call('字幕加载失败,已回退为无字幕'); + return false; + } + } + + + bool _applySubtitleViaEngine(EmbySubtitleTrack track) { + final engineSubs = engine.state.tracks.embeddedSubtitles; + final embeddedIndices = _embeddedEmbySubtitles + .map((embyTrack) => embyTrack.index) + .toList(growable: false); + final pos = embeddedIndices.indexOf(track.index); + if (pos < 0 || pos >= engineSubs.length) { + AppLogger.warn( + _tag, + 'applySubtitleViaEngine: streamIdx=${track.index} not found in engine ' + 'tracks (embeddedIndices=$embeddedIndices, ' + 'engineSubCount=${engineSubs.length})', + ); + return false; + } + _clearDartSubtitleState(); + activeSubtitleStreamIndex.value = track.index; + _configureSubtitleRenderingModeForCodec(track.codec); + engine.setSubtitleTrack(engineSubs[pos]); + AppLogger.debug( + _tag, + 'engine subtitle activated streamIdx=${track.index} pos=$pos ' + 'engineTrack=${engineSubs[pos].id} codec=${track.codec}', + ); + return true; + } + + + void _clearDartSubtitleState() { + _externalLoadGeneration++; + subtitleLoading.value = false; + activeExternalSubtitle.value = null; + _externalCueSource = null; + _setExternalSubtitleLines(const []); + } + + + void _updateExternalSubtitleLines(Duration position) { + final source = _externalCueSource; + if (source == null) return; + final effectivePosition = + position - + Duration(milliseconds: (_subtitleDelaySeconds * 1000).round()); + final lines = effectivePosition.isNegative + ? const [] + : source.textAt(effectivePosition); + _setExternalSubtitleLines(lines); + } + + void _setExternalSubtitleLines(List lines) { + if (listEquals(externalSubtitleLines.value, lines)) return; + externalSubtitleLines.value = lines; + } + + void applySubtitleStyle(SubtitleStyleSettings settings) { + if (_disposed) return; + _subtitleDelaySeconds = settings.delay; + _updateExternalSubtitleLines(engine.state.position); + engine.setSubtitleStyle( + fontSize: settings.fontSize, + bgOpacity: settings.bgOpacity, + position: settings.position, + ); + } + + void _onPosition(Duration position) { + if (_disposed) return; + _updateExternalSubtitleLines(position); + if (_engineErrorAnchorPosition != null && + position != _engineErrorAnchorPosition) { + _engineErrorTimer?.cancel(); + _engineErrorTimer = null; + _engineErrorAnchorPosition = null; + _pendingEngineError = null; + } + final last = _lastDanmakuPos; + _lastDanmakuPos = position; + final positionAdvanced = last != null && position != last; + if (positionAdvanced) { + + + _lastPositionAdvanceClock = _clock.elapsed; + _danmakuAnchorPos = position; + _danmakuAnchorClock = _clock.elapsed; + } + _checkStall(position); + if (_danmakuAwaitingMovementBaseline) { + _danmakuAwaitingMovementBaseline = false; + _danmakuMoving = false; + _lastPositionAdvanceClock = _clock.elapsed; + _syncDanmakuActive(); + return; + } + final withinStallGrace = + _clock.elapsed - _lastPositionAdvanceClock <= _danmakuStallGrace; + _danmakuMoving = positionAdvanced || (_danmakuMoving && withinStallGrace); + _syncDanmakuActive(); + if (_danmakuActive) { + feeder.onPosition(position.inMilliseconds / 1000.0); + } + } + + void _onBufferChanged(Duration buffer) { + if (_disposed) return; + if (!engine.state.playing || buffer > engine.state.position) return; + + + final positionStalled = + _clock.elapsed - _lastPositionAdvanceClock > _danmakuStallGrace; + if (!positionStalled) return; + _danmakuAwaitingMovementBaseline = true; + _danmakuMoving = false; + _syncDanmakuActive(); + } + + + void _syncDanmakuActive() { + final active = engine.state.playing && _danmakuMoving; + final posSec = engine.state.position.inMilliseconds / 1000.0; + if (active && !_danmakuActive) { + _danmakuAnchorPos = engine.state.position; + _danmakuAnchorClock = _clock.elapsed; + feeder.playFrom(posSec); + } else if (!active) { + feeder.freezeAt(posSec); + } + _danmakuActive = active; + } + + + void _startDanmakuInterpolator() { + _danmakuAnchorPos = engine.state.position; + _danmakuAnchorClock = _clock.elapsed; + _danmakuInterp ??= Timer.periodic(_danmakuInterpInterval, (_) { + if (_disposed) return; + if (!_danmakuActive) return; + final elapsed = _clock.elapsed - _danmakuAnchorClock; + if (elapsed.isNegative) return; + final rate = engine.state.rate; + final est = + _danmakuAnchorPos + + Duration(microseconds: (elapsed.inMicroseconds * rate).round()); + feeder.onPosition(est.inMilliseconds / 1000.0); + }); + } + + void _startStallPolling() { + _stallPollTimer ??= Timer.periodic(_stallPollInterval, (_) { + if (_disposed) return; + _checkStall(engine.state.position); + }); + } + + + void _checkStall(Duration position) { + if (_failureSignalled) return; + final activelyPlaying = + _phase.value == PlaybackPhase.playing && engine.state.playing; + final fired = _stallDetector.update( + position: position, + bufferedPosition: engine.state.buffer, + bytesDownloaded: speedMonitor.latestTotalBytes, + isActivelyPlaying: activelyPlaying, + now: _clock.elapsed, + ); + if (fired) { + AppLogger.warn( + _tag, + 'stream failure: stall detector (${_stallDetector.threshold.inSeconds}s) → relink', + ); + _signalFailure(); + } + } + + + void _signalFailure() { + if (_disposed || _failureSignalled) return; + final handled = onStreamFailure?.call() ?? false; + if (handled) _failureSignalled = true; + } + + void _onEngineError(String error) { + if (_disposed || _phase.value == PlaybackPhase.failed) return; + AppLogger.warn(_tag, 'engine error: $error'); + if (engine.isNetworkStreamError(error)) { + _lastFailureTerminal = isTerminalStreamOpenError(error); + AppLogger.warn( + _tag, + 'stream failure: engine network error → ' + '${_lastFailureTerminal ? 'terminal (skip relink)' : 'relink'}', + ); + _signalFailure(); + return; + } + _pendingEngineError = error; + _engineErrorAnchorPosition ??= engine.state.position; + _engineErrorTimer?.cancel(); + _engineErrorTimer = Timer(_engineErrorGrace, () { + if (_disposed || _phase.value == PlaybackPhase.failed) return; + final anchor = _engineErrorAnchorPosition; + if (anchor != null && engine.state.position == anchor) { + final message = _pendingEngineError ?? error; + _lastError = Exception('播放失败: $message'); + _setPhase(PlaybackPhase.failed); + } + _engineErrorAnchorPosition = null; + _pendingEngineError = null; + _engineErrorTimer = null; + }); + } + + void _onPlayingChanged(bool playing) { + if (_disposed) return; + if (playing) { + _danmakuAwaitingMovementBaseline = true; + _danmakuMoving = false; + } else { + _danmakuAwaitingMovementBaseline = true; + _danmakuMoving = false; + } + _syncDanmakuActive(); + final wasPaused = _isPaused; + _isPaused = !playing; + if (_reporter.startedReported && + !_reporter.stoppedReported && + _isPaused != wasPaused) { + unawaited( + _reporter.reportProgress(_buildReportPayload(isPaused: _isPaused)), + ); + if (_isPaused) { + unawaited(_traktReporter?.onPause() ?? Future.value()); + } else { + unawaited(_traktReporter?.onPlay() ?? Future.value()); + } + } + } + + void _onCompleted(bool completed) { + if (_disposed) return; + if (!completed) return; + unawaited( + _reporter.reportStopped(_buildReportPayload(isPaused: _isPaused)), + ); + unawaited(_traktReporter?.onCompleted() ?? Future.value()); + _setPhase(PlaybackPhase.ended); + } + + void _onVolume(double volume) { + if (_disposed) return; + _volumeSaveTimer?.cancel(); + _volumeSaveTimer = Timer(const Duration(milliseconds: 500), () { + if (!_disposed) _playerSettingsNotifier.setVolume(volume); + }); + } + + void _onTracksChanged(PlayerTracks tracks) { + if (_disposed || _defaultsApplied || !_engineOpenCompleted) return; + _tryApplyDefaultTracks(tracks); + } + + void _tryApplyDefaultTracks(PlayerTracks tracks) { + if (_defaultsApplied || _disposed) return; + final audio = tracks.audio + .where((t) => t.id != 'auto' && t.id != 'no') + .toList(growable: false); + final subs = tracks.subtitle + .where((t) => t.id != 'auto' && t.id != 'no') + .toList(growable: false); + if (audio.isEmpty && subs.isEmpty) return; + _defaultsApplied = true; + _applyDefaultTracks(audio); + } + + void _applyDefaultTracks(List audioTracks) { + if (audioTracks.isNotEmpty) { + final audioIndices = _prepareResult.audioTracks + .map((e) => e.index) + .toList(growable: false); + final selectedIndex = _resolveAudioTrackIndex(audioIndices); + if (selectedIndex != null) { + var target = audioTracks + .where((t) => t.index == selectedIndex) + .firstOrNull; + if (target == null) { + final pos = audioIndices.indexOf(selectedIndex); + if (pos >= 0 && pos < audioTracks.length) { + target = audioTracks[pos]; + } + } + if (target != null) { + engine.setAudioTrack(target); + } + } + } + + final subtitleSelection = resolveInitialSubtitleSelection( + subtitles: _prepareResult.subtitles, + preferredSubtitleStreamIndex: request.preferredSubtitleStreamIndex, + defaultSubtitleStreamIndex: _prepareResult.defaultSubtitleIndex, + rememberedSubtitle: rememberedPrimarySubtitle, + ); + final remembered = rememberedPrimarySubtitle; + final rememberedSummary = remembered == null + ? 'null' + : remembered.isNone + ? 'none-latch' + : 'idx=${remembered.streamIndex} lang=${remembered.language} ' + 'title="${remembered.title}"'; + AppLogger.info( + _tag, + 'initialSubtitle: type=${subtitleSelection.type.name} ' + 'preferred=${request.preferredSubtitleStreamIndex} ' + 'default=${_prepareResult.defaultSubtitleIndex} ' + 'remembered=$rememberedSummary ' + 'availableCount=${_prepareResult.subtitles.length}', + ); + switch (subtitleSelection.type) { + case InitialSubtitleSelectionType.unchanged: + break; + case InitialSubtitleSelectionType.disabled: + unawaited(_disableSubtitle()); + break; + case InitialSubtitleSelectionType.track: + final selectedTrack = subtitleSelection.track; + if (selectedTrack != null) { + AppLogger.info( + _tag, + 'initialSubtitle: applying streamIdx=${selectedTrack.index} ' + 'external=${selectedTrack.isExternal} codec=${selectedTrack.codec}', + ); + unawaited(_applyEmbySubtitleTrack(selectedTrack)); + } + break; + } + } + + + List get _embeddedEmbySubtitles => _prepareResult.subtitles + .where((track) => !track.isExternal) + .toList(growable: false); + + void _configureSubtitleRenderingModeForCodec(String? codec) { + final isBitmap = + codec != null && !PlaybackResolver.isTextSubtitleCodec(codec); + engine.setNativeSubtitleRendering(isBitmap); + } + + int? _resolveAudioTrackIndex(List audioIndices) { + final embyTracks = _prepareResult.audioTracks; + + final routeIndex = request.preferredAudioStreamIndex; + if (routeIndex != null && audioIndices.contains(routeIndex)) { + return routeIndex; + } + + final remembered = rememberedAudioTrack; + if (remembered != null) { + final rememberedIsCommentary = _isCommentary(remembered.displayTitle); + + final exact = embyTracks + .where( + (t) => + t.index == remembered.streamIndex && + _langMatch(t.language, remembered.language) && + t.codec == remembered.codec, + ) + .firstOrNull; + if (exact != null) return exact.index; + + final candidates = rememberedIsCommentary + ? embyTracks + : embyTracks.where((t) => !_isCommentary(t.displayTitle)).toList(); + + if (remembered.language != null) { + final byLangCodecCh = candidates + .where( + (t) => + _langMatch(t.language, remembered.language) && + t.codec == remembered.codec && + t.channels == remembered.channels, + ) + .firstOrNull; + if (byLangCodecCh != null) return byLangCodecCh.index; + + if (remembered.channels != null) { + final byLangCh = candidates + .where( + (t) => + _langMatch(t.language, remembered.language) && + t.channels == remembered.channels, + ) + .firstOrNull; + if (byLangCh != null) return byLangCh.index; + } + + final byLang = candidates + .where((t) => _langMatch(t.language, remembered.language)) + .firstOrNull; + if (byLang != null) return byLang.index; + } + } + + final prefLang = request.audioSettings.preferredAudioLanguage; + if (prefLang.isNotEmpty) { + var langMatches = embyTracks + .where( + (t) => + _langMatch(t.language, prefLang) && + !_isCommentary(t.displayTitle), + ) + .toList(); + if (langMatches.isEmpty) { + langMatches = embyTracks + .where((t) => _langMatch(t.language, prefLang)) + .toList(); + } + if (langMatches.isNotEmpty) { + langMatches.sort((a, b) { + final defaultCmp = (b.isDefault ? 1 : 0) - (a.isDefault ? 1 : 0); + if (defaultCmp != 0) return defaultCmp; + return (b.channels ?? 0).compareTo(a.channels ?? 0); + }); + return langMatches.first.index; + } + } + + return _prepareResult.mediaSource?.DefaultAudioStreamIndex; + } + + static bool _isCommentary(String? title) { + if (title == null) return false; + final lower = title.toLowerCase(); + return lower.contains('commentary') || lower.contains('comment'); + } + + static bool _langMatch(String? trackLang, String? targetLang) { + if (trackLang == null || targetLang == null) return false; + final a = trackLang.toLowerCase(); + final b = targetLang.toLowerCase(); + if (a == b) return true; + + const subGroups = >{ + 'cmn': {'mandarin', '国语', '普通话'}, + 'yue': {'cantonese', '粤语'}, + }; + for (final entry in subGroups.entries) { + final all = {entry.key, ...entry.value}; + if (all.contains(a) && all.contains(b)) return true; + } + + const macroGroups = >{ + 'chi': { + 'zho', + 'chinese', + 'zh', + '中文', + 'cmn', + 'mandarin', + '国语', + '普通话', + 'yue', + 'cantonese', + '粤语', + }, + 'eng': {'english', 'en'}, + 'jpn': {'japanese', 'ja', 'jp'}, + 'kor': {'korean', 'ko'}, + 'fra': {'french', 'fr', 'fre'}, + 'deu': {'german', 'de', 'ger'}, + 'spa': {'spanish', 'es'}, + 'ita': {'italian', 'it'}, + 'por': {'portuguese', 'pt'}, + 'rus': {'russian', 'ru'}, + 'tha': {'thai', 'th'}, + 'vie': {'vietnamese', 'vi'}, + }; + for (final entry in macroGroups.entries) { + final all = {entry.key, ...entry.value}; + if (all.contains(a) && all.contains(b)) return true; + } + return false; + } + + void _applySubtitleStyleFromRequest() { + final s = request.subtitleSettings; + engine.setSubtitleStyle( + fontSize: s.fontSize, + bgOpacity: s.bgOpacity, + position: s.position, + ); + } + + + double _progressPercent() { + final pos = engine.state.position; + final ticks = context.runTimeTicks; + final dur = (ticks != null && ticks > 0) + ? durationFromTicks(ticks) + : engine.state.duration; + if (dur <= Duration.zero || pos <= Duration.zero) return 0; + final pct = pos.inMilliseconds / dur.inMilliseconds * 100; + return pct.clamp(0.0, 100.0); + } + + PlaybackReportPayload _buildReportPayload({ + required bool isPaused, + String? eventName, + }) { + final pos = engine.state.position; + final dur = engine.state.duration; + return PlaybackReportPayload( + itemId: context.itemId, + mediaSourceId: context.mediaSourceId, + playSessionId: context.playSessionId, + positionTicks: ticksFromDuration(pos), + runTimeTicks: + context.runTimeTicks ?? + (dur > Duration.zero ? ticksFromDuration(dur) : null), + playMethod: context.playMethod, + isPaused: isPaused, + isMuted: engine.state.volume <= 0, + playbackRate: engine.state.rate, + eventName: eventName, + ); + } + + void _setPhase(PlaybackPhase next) { + if (_phase.value == next) return; + if (_phase.value == PlaybackPhase.disposed) return; + _phase.value = next; + } + + Future _runWithTimeout(Future future, Duration timeout) { + return future.timeout( + timeout, + onTimeout: () { + throw TimeoutException('engine.open exceeded $timeout', timeout); + }, + ); + } +} diff --git a/lib/features/player/session/playback_session_builder.dart b/lib/features/player/session/playback_session_builder.dart new file mode 100644 index 0000000..fdb495b --- /dev/null +++ b/lib/features/player/session/playback_session_builder.dart @@ -0,0 +1,616 @@ +import 'dart:async'; + +import '../../../core/contracts/auth.dart'; + +import '../../../core/contracts/cancellation.dart'; +import '../../../core/contracts/danmaku.dart'; +import '../../../core/contracts/library.dart'; +import '../../../core/contracts/playback.dart'; +import '../../../core/contracts/trakt/trakt_scrobble_target.dart'; +import '../../../core/domain/ports/authed_trakt_gateway.dart'; +import '../../../core/domain/ports/emby_gateway.dart'; +import '../../../core/infra/emby_api/emby_headers.dart'; +import '../../../core/infra/trakt_api/trakt_media_matcher.dart'; +import '../../../core/infra/trakt_api/trakt_pending_sync_queue.dart'; +import '../../../core/infra/emby_api/playback_resolver.dart'; +import '../../../core/infra/player_engine/fvp_player_engine.dart'; +import '../../../core/infra/player_engine/media3_player_engine.dart'; +import '../../../core/infra/player_engine/media_kit_player_engine.dart'; +import '../../../core/infra/player_engine/player_engine.dart'; +import '../../../core/media/external_subtitle_loader.dart'; +import '../../../core/media/media_capability_analyzer.dart'; +import '../../../core/media/media_source_metadata_resolver.dart'; +import '../../../providers/audio_settings_provider.dart'; +import '../../../providers/player_engine_override_provider.dart'; +import '../../../providers/player_settings_provider.dart'; +import '../../../providers/subtitle_settings_provider.dart'; +import '../../../shared/mappers/media_image_url.dart'; +import '../../../shared/utils/app_logger.dart'; +import '../../../shared/utils/emby_ticks.dart'; +import '../services/danmaku/danmaku_feeder.dart'; +import '../services/emby_playback_reporter.dart'; +import '../services/network_speed_monitor.dart'; +import '../services/trakt_scrobble_reporter.dart'; +import 'initial_subtitle_resolver.dart'; +import 'playback_request.dart'; +import 'playback_session.dart'; +import 'player_engine_holder.dart'; +import 'player_engine_resolution.dart'; + +const _tag = 'PlaybackSessionBuilder'; +const _seriesIdentitySoftTimeout = Duration(milliseconds: 800); +const _engineOpenTimeout = Duration(seconds: 60); + + +class PlaybackSessionBuilder { + PlaybackSessionBuilder({ + required this.resolver, + required this.gateway, + required this.metadataResolver, + required this.engineHolder, + required this.readActiveSession, + required this.deviceId, + required this.deviceName, + required this.playerSettingsNotifier, + required this.audioSettingsNotifier, + required this.subtitleSettingsNotifier, + required this.readEngineOverride, + this.loadItemDetail, + this.traktGateway, + this.traktQueue, + this.traktAppVersion, + this.traktOnSuccess, + this.traktOnError, + }); + + final PlaybackResolver resolver; + final EmbyGateway gateway; + final MediaSourceMetadataResolver metadataResolver; + + + final PlayerEngineHolder engineHolder; + + + final AuthedTraktGateway? traktGateway; + final TraktPendingSyncQueue? traktQueue; + final String? traktAppVersion; + final void Function()? traktOnSuccess; + final void Function(String error)? traktOnError; + Future? _traktConnectedCache; + + + final AuthedSession Function() readActiveSession; + + + final PlayerEngineOverride Function() readEngineOverride; + final String deviceId; + final String deviceName; + final PlayerSettingsNotifier playerSettingsNotifier; + final AudioSettingsNotifier audioSettingsNotifier; + final SubtitleSettingsNotifier subtitleSettingsNotifier; + final Future Function(String serverId, String itemId)? + loadItemDetail; + + + Future build({ + required PlaybackRequest request, + required DanmakuFeeder feeder, + CancellationToken? externalCancel, + Future? priorDisposeFuture, + Map? preloadedPlaybackInfo, + PlayerEngineKind? forcedEngineKind, + void Function(PlayerEngineKind kind, bool isDolbyVision)? onEngineResolved, + }) async { + externalCancel?.throwIfCancelled(); + + final directStreamUrl = request.directStreamUrl?.trim(); + final isDirectStream = directStreamUrl?.isNotEmpty ?? false; + + final identity = request.targetServer; + final String effectiveServerId; + final String effectiveServerUrl; + final String effectiveToken; + final String effectiveUserId; + if (identity != null) { + effectiveServerId = identity.serverId; + effectiveServerUrl = identity.baseUrl; + effectiveToken = identity.token; + effectiveUserId = identity.userId; + } else if (isDirectStream) { + effectiveServerId = ''; + effectiveServerUrl = ''; + effectiveToken = ''; + effectiveUserId = ''; + } else { + final session = readActiveSession(); + effectiveServerId = session.serverId; + effectiveServerUrl = session.serverUrl; + effectiveToken = session.token; + effectiveUserId = session.userId; + } + final ctx = AuthedRequestContext( + baseUrl: effectiveServerUrl, + token: effectiveToken, + userId: effectiveUserId, + ); + final traktConnectedFuture = isDirectStream + ? Future.value(false) + : _readTraktConnected(); + + final PlaybackRequestResult result; + if (isDirectStream) { + final item = + request.preloadedItem ?? + EmbyRawItem(Id: request.itemId, Name: request.itemId, Type: 'Video'); + result = PlaybackRequestResult( + streamUrl: directStreamUrl!, + directStream: true, + item: item, + startPositionSeconds: request.playFromStart + ? 0 + : (request.startPositionTicks ?? 0) / kTicksPerSecond, + durationSeconds: item.RunTimeTicks == null + ? null + : item.RunTimeTicks! / kTicksPerSecond, + playMethod: 'DirectStream', + ); + } else { + result = await resolver.prepare( + ctx: ctx, + payload: PlaybackRequestPayload( + itemId: request.itemId, + mediaSourceId: request.mediaSourceId, + startPositionTicks: request.startPositionTicks, + playFromStart: request.playFromStart, + ), + preloadedItem: request.preloadedItem, + preloadedPlaybackInfo: preloadedPlaybackInfo, + ); + } + externalCancel?.throwIfCancelled(); + + final providerIds = providerIdsOfItem(result.item); + final parentIndexNumber = (result.item.extra['ParentIndexNumber'] as num?) + ?.toInt(); + final memoryKey = playbackMemoryKey( + serverId: effectiveServerId, + userId: effectiveUserId, + itemId: result.item.Id, + seriesId: result.item.SeriesId, + ); + final rememberedAudioFuture = + !isDirectStream && request.audioSettings.rememberAudioTrack + ? _loadRememberedAudio(memoryKey) + : Future.value(); + final rememberedSubtitleFuture = isDirectStream + ? Future.value() + : _loadRememberedSubtitle(memoryKey); + final directStreamHeaders = Map.unmodifiable( + request.directStreamHeaders, + ); + final streamHeaders = isDirectStream + ? directStreamHeaders + : EmbyRequestHeaders.media( + deviceId: deviceId, + deviceName: deviceName, + token: effectiveToken, + userId: effectiveUserId, + ); + final engineHeaders = isDirectStream + ? directStreamHeaders + : EmbyRequestHeaders.directMedia(); + final externalSubtitleLoader = ExternalSubtitleLoader(); + if (!isDirectStream) { + unawaited( + _prefetchInitialSubtitle( + request: request, + prepareResult: result, + rememberedSubtitleFuture: rememberedSubtitleFuture, + loader: externalSubtitleLoader, + streamHeaders: streamHeaders, + ), + ); + } + final seriesIdentityFuture = isDirectStream + ? Future< + ({Map providerIds, int? productionYear})? + >.value() + : _loadSeriesIdentity( + ctx: ctx, + serverId: effectiveServerId, + item: result.item, + providerIds: providerIds, + traktConnectedFuture: traktConnectedFuture, + externalCancel: externalCancel, + ); + + final probeResult = isDirectStream + ? null + : await metadataResolver.resolve( + source: result.mediaSource, + streamUrl: result.streamUrl, + item: result.item, + timeout: const Duration(seconds: 5), + ); + externalCancel?.throwIfCancelled(); + final capability = MediaCapabilityAnalyzer.analyze( + result.mediaSource, + probeResult: probeResult, + ); + final isDolbyVision = + capability.rangeCategory == VideoRangeCategory.dolbyVision; + final isHdr = switch (capability.rangeCategory) { + VideoRangeCategory.hdr10 || VideoRangeCategory.hlg => true, + _ => false, + }; + + final engineOverride = readEngineOverride(); + final engineKind = + forcedEngineKind ?? + resolvePlayerEngineKind( + engineOverride, + isDolbyVision: isDolbyVision, + videoCodec: capability.codec, + ); + AppLogger.debug( + _tag, + 'route override=${engineOverride.name} ' + 'range=${capability.rangeCategory.name} isDV=$isDolbyVision isHDR=$isHdr ' + 'forced=${forcedEngineKind?.name ?? ''} -> ${engineKind.name}', + ); + onEngineResolved?.call(engineKind, isDolbyVision); + if (priorDisposeFuture != null) { + await priorDisposeFuture; + externalCancel?.throwIfCancelled(); + } + + final engine = await engineHolder.acquire( + engineKind, + () => createEngine(engineKind), + ); + externalCancel?.throwIfCancelled(); + if (engine is FvpPlayerEngine) { + engine + ..setDolbyVision(isDolbyVision) + ..configureHardwareDecoding(request.playerSettings.hwdecMode); + } + final initialVolume = request.audioSettings.volumeBoost + ? request.playerSettings.volume + : request.playerSettings.volume.clamp(0.0, 100.0); + await engine.setVolume(initialVolume); + externalCancel?.throwIfCancelled(); + + final isTranscode = result.playMethod == 'Transcode'; + final startSeconds = result.startPositionSeconds; + final startDuration = (!isTranscode && startSeconds > 0) + ? Duration(milliseconds: (startSeconds * 1000).round()) + : Duration.zero; + externalCancel?.throwIfCancelled(); + AppLogger.debug( + _tag, + 'open engine=${engineKind.name} method=${result.playMethod} ' + 'start=${startDuration.inSeconds}s itemId=${request.itemId}', + ); + final engineOpenFuture = engine.open( + result.streamUrl, + start: startDuration, + play: false, + headers: engineHeaders, + cancel: externalCancel, + ); + final awaitEngineOpenInBuild = !engine.usesPlatformSurface; + + final reporter = EmbyPlaybackReporter( + gateway: gateway, + context: ctx, + enabled: !isDirectStream, + ); + final speedMonitor = NetworkSpeedMonitor( + totalRxBytes: engine.totalRxBytes, + ); + + final seriesIdentity = await seriesIdentityFuture; + externalCancel?.throwIfCancelled(); + final seriesProviderIds = + seriesIdentity?.providerIds ?? const {}; + final seriesProductionYear = seriesIdentity?.productionYear; + + final context = PlaybackContext( + itemId: result.item.Id, + itemName: result.item.Name, + itemType: result.item.Type, + itemIndexNumber: result.item.IndexNumber, + seriesName: result.item.SeriesName, + seriesId: result.item.SeriesId, + seasonId: result.item.SeasonId, + mediaSourceId: result.mediaSource?.Id, + playSessionId: result.playSessionId, + runTimeTicks: result.item.RunTimeTicks, + playMethod: result.playMethod, + isDolbyVision: isDolbyVision, + embySubtitles: result.subtitles, + embyAudioTracks: result.audioTracks, + allMediaSources: mediaSourcesOfItem(result.item), + providerIds: providerIds, + seriesProviderIds: seriesProviderIds, + parentIndexNumber: parentIndexNumber, + danmakuMatchContext: _buildDanmakuMatchContext(result.item), + serverId: effectiveServerId, + userId: effectiveUserId, + baseUrl: effectiveServerUrl, + token: effectiveToken, + logoUrl: _buildLogoUrl( + result.item, + baseUrl: effectiveServerUrl, + token: effectiveToken, + ), + ); + + final rememberedAudioTrack = await rememberedAudioFuture; + final rememberedPrimarySubtitle = await rememberedSubtitleFuture; + final traktConnected = await traktConnectedFuture; + + final traktReporterFactory = isDirectStream + ? null + : await _buildTraktReporterFactory( + item: result.item, + providerIds: providerIds, + seriesProviderIds: seriesProviderIds, + seriesProductionYear: seriesProductionYear, + parentIndexNumber: parentIndexNumber, + traktConnected: traktConnected, + ); + externalCancel?.throwIfCancelled(); + + if (awaitEngineOpenInBuild) { + try { + await _runWithTimeout(engineOpenFuture, _engineOpenTimeout); + externalCancel?.throwIfCancelled(); + } catch (_) { + try { + await engine.stop(); + } catch (e) { + AppLogger.warn(_tag, 'engine.stop after open failure failed', e); + } + rethrow; + } + } + + return PlaybackSession( + engine: engine, + reporter: reporter, + speedMonitor: speedMonitor, + feeder: feeder, + context: context, + request: request, + prepareResult: result, + streamHeaders: streamHeaders, + pendingEngineOpenFuture: awaitEngineOpenInBuild ? null : engineOpenFuture, + playerSettingsNotifier: playerSettingsNotifier, + traktReporterFactory: traktReporterFactory, + rememberedAudioTrack: rememberedAudioTrack, + rememberedPrimarySubtitle: rememberedPrimarySubtitle, + externalSubtitleLoader: externalSubtitleLoader, + ); + } + + Future _prefetchInitialSubtitle({ + required PlaybackRequest request, + required PlaybackRequestResult prepareResult, + required Future rememberedSubtitleFuture, + required ExternalSubtitleLoader loader, + required Map streamHeaders, + }) async { + try { + final rememberedSubtitle = await rememberedSubtitleFuture; + final selection = resolveInitialSubtitleSelection( + subtitles: prepareResult.subtitles, + preferredSubtitleStreamIndex: request.preferredSubtitleStreamIndex, + defaultSubtitleStreamIndex: prepareResult.defaultSubtitleIndex, + rememberedSubtitle: rememberedSubtitle, + ); + final selectedTrack = selection.track; + final url = selectedTrack?.deliveryUrl; + final canPrefetch = + selection.type == InitialSubtitleSelectionType.track && + selectedTrack != null && + url != null && + url.isNotEmpty && + PlaybackResolver.isTextSubtitleCodec(selectedTrack.codec); + if (!canPrefetch) return; + + AppLogger.debug( + _tag, + 'prefetch initial subtitle index=${selectedTrack.index} ' + 'external=${selectedTrack.isExternal}', + ); + await loader.load( + url, + headers: streamHeaders, + receiveTimeout: selectedTrack.isExternal + ? null + : embeddedSubtitleFetchTimeout, + ); + } catch (error) { + + AppLogger.debug(_tag, 'initial subtitle prefetch failed', error); + } + } + + Future _runWithTimeout(Future future, Duration timeout) { + return future.timeout( + timeout, + onTimeout: () { + throw TimeoutException('engine.open exceeded $timeout', timeout); + }, + ); + } + + + static PlayerEngine createEngine(PlayerEngineKind kind) { + switch (kind) { + case PlayerEngineKind.fvp: + return FvpPlayerEngine(); + case PlayerEngineKind.mpv: + return MediaKitPlayerEngine(); + case PlayerEngineKind.media3: + return Media3PlayerEngine(); + } + } + + + Future + _buildTraktReporterFactory({ + required EmbyRawItem item, + required Map providerIds, + required Map seriesProviderIds, + required int? seriesProductionYear, + required int? parentIndexNumber, + required bool traktConnected, + }) async { + final tGateway = traktGateway; + final tQueue = traktQueue; + if (tGateway == null || tQueue == null) return null; + + final target = TraktMediaMatcher.match( + itemType: item.Type, + itemName: item.Name, + productionYear: item.ProductionYear, + providerIds: providerIds, + seriesProviderIds: seriesProviderIds, + seriesName: item.SeriesName, + seriesProductionYear: seriesProductionYear, + parentIndexNumber: parentIndexNumber, + itemIndexNumber: item.IndexNumber, + ); + if (target == null) { + AppLogger.debug(_tag, 'trakt: no match for ${item.Type} "${item.Name}"'); + return null; + } + + if (!traktConnected) { + AppLogger.debug(_tag, 'trakt: not connected, scrobble disabled'); + return null; + } + + final TraktScrobbleTarget matched = target; + return (readProgress) => TraktScrobbleReporter( + gateway: tGateway, + queue: tQueue, + target: matched, + readProgress: readProgress, + appVersion: traktAppVersion, + onSuccess: traktOnSuccess, + onError: traktOnError, + ); + } + + Future _readTraktConnected() { + final tGateway = traktGateway; + if (tGateway == null || traktQueue == null) { + return Future.value(false); + } + return _traktConnectedCache ??= tGateway.isConnected().catchError(( + Object e, + ) { + AppLogger.debug(_tag, 'trakt: isConnected failed', e); + return false; + }); + } + + Future _loadRememberedAudio(String key) async { + try { + return await audioSettingsNotifier.getLastAudioTrack(key); + } catch (e) { + AppLogger.warn(_tag, 'load remembered audio failed', e); + return null; + } + } + + Future _loadRememberedSubtitle(String key) async { + try { + return await subtitleSettingsNotifier.getLastSubtitleTrack(key); + } catch (e) { + AppLogger.warn(_tag, 'load remembered subtitle failed', e); + return null; + } + } + + Future<({Map providerIds, int? productionYear})?> + _loadSeriesIdentity({ + required AuthedRequestContext ctx, + required String serverId, + required EmbyRawItem item, + required Map providerIds, + required Future traktConnectedFuture, + CancellationToken? externalCancel, + }) async { + if (item.Type != 'Episode' || !(item.SeriesId?.isNotEmpty ?? false)) { + return null; + } + final traktConnected = await traktConnectedFuture; + externalCancel?.throwIfCancelled(); + if (!traktConnected) return null; + + try { + final loadItem = loadItemDetail; + final Future future = loadItem != null + ? loadItem(serverId, item.SeriesId!).then((v) => v) + : gateway + .getItemDetail(ctx, item.SeriesId!) + .then((v) => v); + final seriesItem = await future.timeout( + _seriesIdentitySoftTimeout, + onTimeout: () { + AppLogger.debug( + _tag, + 'fetch series identity timed out after ' + '${_seriesIdentitySoftTimeout.inMilliseconds}ms', + ); + return null; + }, + ); + externalCancel?.throwIfCancelled(); + if (seriesItem == null) return null; + return ( + providerIds: providerIdsOfItem(seriesItem), + productionYear: seriesItem.ProductionYear, + ); + } on CancelledException { + rethrow; + } catch (e) { + AppLogger.warn(_tag, 'fetch series identity failed', e); + return null; + } + } + + static DanmakuMatchContext _buildDanmakuMatchContext(EmbyRawItem item) { + return DanmakuMatchContext( + itemId: item.Id, + name: item.Name, + type: item.Type, + seriesName: item.SeriesName, + season: (item.extra['ParentIndexNumber'] as num?)?.toInt(), + episode: item.IndexNumber, + durationSec: ((item.RunTimeTicks ?? 0) / kTicksPerSecond).round(), + ); + } + + static String? _buildLogoUrl( + EmbyRawItem item, { + required String baseUrl, + required String token, + }) { + final isEpisode = item.Type == 'Episode'; + final logoItemId = isEpisode && (item.SeriesId?.isNotEmpty ?? false) + ? item.SeriesId! + : item.Id; + final logoTag = isEpisode ? null : item.ImageTags?['Logo']; + return EmbyImageUrl.logo( + baseUrl: baseUrl, + token: token, + itemId: logoItemId, + tag: logoTag, + ); + } +} diff --git a/lib/features/player/session/playback_target.dart b/lib/features/player/session/playback_target.dart new file mode 100644 index 0000000..e64ebc0 --- /dev/null +++ b/lib/features/player/session/playback_target.dart @@ -0,0 +1,47 @@ +import 'package:flutter/foundation.dart'; + +import '../../../core/contracts/library.dart'; + + +@immutable +class PlaybackServerIdentity { + const PlaybackServerIdentity({ + required this.serverId, + required this.baseUrl, + required this.token, + required this.userId, + required this.serverName, + }); + + + factory PlaybackServerIdentity.fromCard(CrossServerSourceCard card) => + PlaybackServerIdentity( + serverId: card.serverId, + baseUrl: card.serverUrl, + token: card.token, + userId: card.userId, + serverName: card.serverName, + ); + + final String serverId; + final String baseUrl; + final String token; + final String userId; + final String serverName; + + @override + bool operator ==(Object other) => + other is PlaybackServerIdentity && + other.serverId == serverId && + other.baseUrl == baseUrl && + other.token == token && + other.userId == userId && + other.serverName == serverName; + + @override + int get hashCode => Object.hash(serverId, baseUrl, token, userId, serverName); + + @override + String toString() => + 'PlaybackServerIdentity(serverId=$serverId, serverName=$serverName)'; +} diff --git a/lib/features/player/session/player_engine_holder.dart b/lib/features/player/session/player_engine_holder.dart new file mode 100644 index 0000000..b7eeb0f --- /dev/null +++ b/lib/features/player/session/player_engine_holder.dart @@ -0,0 +1,43 @@ +import '../../../core/infra/player_engine/player_engine.dart'; +import 'player_engine_resolution.dart'; + + +class PlayerEngineHolder { + PlayerEngine? _engine; + PlayerEngineKind? _kind; + + + PlayerEngine? get current => _engine; + + + Future acquire( + PlayerEngineKind kind, + PlayerEngine Function() create, + ) async { + final existing = _engine; + if (existing != null && _kind == kind) { + return existing; + } + + _engine = null; + _kind = null; + if (existing != null) { + await existing.dispose(); + } + + final next = create(); + _engine = next; + _kind = kind; + return next; + } + + + Future dispose() async { + final engine = _engine; + _engine = null; + _kind = null; + if (engine != null) { + await engine.dispose(); + } + } +} diff --git a/lib/features/player/session/player_engine_resolution.dart b/lib/features/player/session/player_engine_resolution.dart new file mode 100644 index 0000000..fb5392d --- /dev/null +++ b/lib/features/player/session/player_engine_resolution.dart @@ -0,0 +1,59 @@ +import 'dart:io' show Platform; + +import '../../../providers/player_engine_override_provider.dart'; + + +enum PlayerEngineKind { fvp, mpv, media3 } + +PlayerEngineKind? playerEngineKindFromDisplayName(String displayName) { + return switch (displayName) { + 'fvp' => PlayerEngineKind.fvp, + 'mpv' => PlayerEngineKind.mpv, + 'media3' => PlayerEngineKind.media3, + _ => null, + }; +} + + +PlayerEngineKind? nextAutoFallbackEngine({ + required PlayerEngineOverride override, + required PlayerEngineKind? failedEngineKind, + required bool isDolbyVision, +}) { + if (override != PlayerEngineOverride.auto) return null; + return switch (failedEngineKind) { + PlayerEngineKind.media3 => + isDolbyVision ? PlayerEngineKind.fvp : PlayerEngineKind.mpv, + PlayerEngineKind.mpv => PlayerEngineKind.fvp, + PlayerEngineKind.fvp || null => null, + }; +} + + +PlayerEngineKind resolvePlayerEngineKind( + PlayerEngineOverride override, { + required bool isDolbyVision, + String? videoCodec, + bool? isAndroid, +}) { + final onAndroid = isAndroid ?? Platform.isAndroid; + final codec = videoCodec?.toLowerCase(); + final isProRes = codec == 'prores'; + switch (override) { + case PlayerEngineOverride.fvp: + return PlayerEngineKind.fvp; + case PlayerEngineOverride.mpv: + return PlayerEngineKind.mpv; + case PlayerEngineOverride.media3: + if (!onAndroid) return PlayerEngineKind.mpv; + if (isProRes) return PlayerEngineKind.fvp; + return PlayerEngineKind.media3; + case PlayerEngineOverride.auto: + if (onAndroid) { + if (isProRes) return PlayerEngineKind.fvp; + return PlayerEngineKind.media3; + } + if (isDolbyVision || isProRes) return PlayerEngineKind.fvp; + return PlayerEngineKind.mpv; + } +} diff --git a/lib/features/player/session/relink_scheduler.dart b/lib/features/player/session/relink_scheduler.dart new file mode 100644 index 0000000..30239fe --- /dev/null +++ b/lib/features/player/session/relink_scheduler.dart @@ -0,0 +1,77 @@ +import 'dart:async'; + + +class RelinkScheduler { + RelinkScheduler({ + this.maxAttempts = 3, + this.backoffs = const [ + Duration.zero, + Duration(seconds: 2), + Duration(seconds: 5), + ], + }) : assert(maxAttempts > 0), + assert(backoffs.isNotEmpty); + + final int maxAttempts; + final List backoffs; + + int _attempts = 0; + bool _pending = false; + Timer? _backoffTimer; + Timer? _sustainTimer; + + + bool get pending => _pending; + + + int get attempts => _attempts; + + + bool schedule({ + required void Function(Duration delay) onRelink, + required void Function() onGiveUp, + }) { + if (_pending) return false; + + + _sustainTimer?.cancel(); + + if (_attempts >= maxAttempts) { + onGiveUp(); + return false; + } + final backoffIndex = _attempts.clamp(0, backoffs.length - 1); + final delay = backoffs[backoffIndex]; + _attempts++; + + _pending = true; + _backoffTimer?.cancel(); + _backoffTimer = Timer(delay, () { + _pending = false; + onRelink(delay); + }); + return true; + } + + + void armSustain({Duration sustain = const Duration(seconds: 30)}) { + _sustainTimer?.cancel(); + _sustainTimer = Timer(sustain, () => _attempts = 0); + } + + + void cancelPending() { + _backoffTimer?.cancel(); + _pending = false; + } + + + void reset() => _attempts = 0; + + + void dispose() { + _backoffTimer?.cancel(); + _sustainTimer?.cancel(); + _pending = false; + } +} diff --git a/lib/features/player/session/stall_detector.dart b/lib/features/player/session/stall_detector.dart new file mode 100644 index 0000000..3b04b78 --- /dev/null +++ b/lib/features/player/session/stall_detector.dart @@ -0,0 +1,77 @@ + + +class StallDetector { + StallDetector({this.threshold = const Duration(seconds: 15)}) + : assert(threshold > Duration.zero); + + + final Duration threshold; + + Duration? _lastPos; + Duration? _lastBuffer; + int? _lastBytes; + Duration _lastAdvanceAt = Duration.zero; + bool _fired = false; + + + bool update({ + required Duration position, + required Duration bufferedPosition, + required int? bytesDownloaded, + required bool isActivelyPlaying, + required Duration now, + }) { + if (!isActivelyPlaying) { + _lastPos = position; + _lastBuffer = bufferedPosition; + _lastBytes = bytesDownloaded; + _lastAdvanceAt = now; + return false; + } + if (_lastPos == null) { + _lastPos = position; + _lastBuffer = bufferedPosition; + _lastBytes = bytesDownloaded; + _lastAdvanceAt = now; + return false; + } + + final positionAdvanced = position != _lastPos; + final bytesBecameAvailable = bytesDownloaded != null && _lastBytes == null; + final hasComparableByteSamples = + bytesDownloaded != null && _lastBytes != null; + final bytesAdvanced = + hasComparableByteSamples && bytesDownloaded != _lastBytes; + final bufferAdvanced = bufferedPosition != _lastBuffer; + final hasBufferedMedia = + bufferedPosition - position > const Duration(seconds: 2); + final transportAdvanced = bytesBecameAvailable || + (hasComparableByteSamples + ? bytesAdvanced + : bufferAdvanced || hasBufferedMedia); + final progressed = positionAdvanced || transportAdvanced; + + _lastPos = position; + _lastBuffer = bufferedPosition; + _lastBytes = bytesDownloaded; + if (progressed) { + _lastAdvanceAt = now; + _fired = false; + return false; + } + if (_fired) return false; + if (now - _lastAdvanceAt >= threshold) { + _fired = true; + return true; + } + return false; + } + + void reset() { + _lastPos = null; + _lastBuffer = null; + _lastBytes = null; + _lastAdvanceAt = Duration.zero; + _fired = false; + } +} diff --git a/lib/features/player/widgets/android/android_bottom_transport_controls.dart b/lib/features/player/widgets/android/android_bottom_transport_controls.dart new file mode 100644 index 0000000..8dd7a47 --- /dev/null +++ b/lib/features/player/widgets/android/android_bottom_transport_controls.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; + +import '../../../../core/infra/player_engine/player_engine.dart'; +import '../player_play_pause_button.dart'; + +class AndroidBottomTransportControls extends StatelessWidget { + const AndroidBottomTransportControls({ + super.key, + required this.player, + required this.hasPrev, + required this.hasNext, + required this.onPrevItem, + required this.onNextItem, + }); + + final PlayerEngine player; + final bool hasPrev; + final bool hasNext; + final VoidCallback onPrevItem; + final VoidCallback onNextItem; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + tooltip: '上一集', + constraints: const BoxConstraints(minWidth: 40, minHeight: 40), + padding: EdgeInsets.zero, + icon: Icon( + Icons.skip_previous, + color: hasPrev ? Colors.white : Colors.white38, + size: 28, + ), + onPressed: hasPrev ? onPrevItem : null, + ), + PlayerPlayPauseButton( + player: player, + iconSize: 30, + constraints: const BoxConstraints(minWidth: 40, minHeight: 40), + ), + IconButton( + tooltip: '下一集', + constraints: const BoxConstraints(minWidth: 40, minHeight: 40), + padding: EdgeInsets.zero, + icon: Icon( + Icons.skip_next, + color: hasNext ? Colors.white : Colors.white38, + size: 28, + ), + onPressed: hasNext ? onNextItem : null, + ), + ], + ); + } +} diff --git a/lib/features/player/widgets/android/android_gesture_feedback.dart b/lib/features/player/widgets/android/android_gesture_feedback.dart new file mode 100644 index 0000000..3e31d01 --- /dev/null +++ b/lib/features/player/widgets/android/android_gesture_feedback.dart @@ -0,0 +1,254 @@ +import 'package:flutter/material.dart'; + +import '../../../../shared/utils/format_utils.dart'; +import '../player_hold_speed_prompt.dart'; + +enum GestureFeedbackKind { + none, + brightness, + volume, + seek, + doubleTapSeek, + longPressSpeed, +} + +class GestureFeedbackData { + final GestureFeedbackKind kind; + final double value; + final Duration seekTarget; + final Duration totalDuration; + final bool seekIsForward; + final int doubleTapSeconds; + final bool doubleTapIsLeft; + final double speedRate; + + const GestureFeedbackData({ + this.kind = GestureFeedbackKind.none, + this.value = 0, + this.seekTarget = Duration.zero, + this.totalDuration = Duration.zero, + this.seekIsForward = true, + this.doubleTapSeconds = 0, + this.doubleTapIsLeft = true, + this.speedRate = 1.0, + }); + + const GestureFeedbackData.none() : this(); +} + +class AndroidGestureFeedback extends StatelessWidget { + final GestureFeedbackData data; + const AndroidGestureFeedback({super.key, required this.data}); + + @override + Widget build(BuildContext context) { + if (data.kind == GestureFeedbackKind.none) { + return const IgnorePointer(child: SizedBox.shrink()); + } + + return IgnorePointer( + child: LayoutBuilder( + builder: (context, constraints) { + final screenWidth = constraints.maxWidth; + final screenHeight = constraints.maxHeight; + return Stack(children: [_buildContent(screenWidth, screenHeight)]); + }, + ), + ); + } + + Widget _buildContent(double screenWidth, double screenHeight) { + switch (data.kind) { + case GestureFeedbackKind.brightness: + case GestureFeedbackKind.volume: + return _buildTopIndicator(); + case GestureFeedbackKind.seek: + return _buildSeekPreview(); + case GestureFeedbackKind.longPressSpeed: + return _buildLongPressSpeed(); + case GestureFeedbackKind.doubleTapSeek: + return _buildDoubleTapSeek(screenWidth, screenHeight); + case GestureFeedbackKind.none: + return const SizedBox.shrink(); + } + } + + Widget _buildTopIndicator() { + final isBrightness = data.kind == GestureFeedbackKind.brightness; + final icon = isBrightness + ? Icons.brightness_6 + : (data.value == 0 + ? Icons.volume_off + : (data.value < 0.5 ? Icons.volume_down : Icons.volume_up)); + final clamped = data.value.clamp(0.0, 1.0); + + return Positioned( + top: 64, + left: 0, + right: 0, + child: Center( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: const Color(0xE8181818), + borderRadius: BorderRadius.circular(999), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: Colors.white, size: 20), + const SizedBox(width: 10), + _buildHorizontalProgress(clamped), + const SizedBox(width: 10), + SizedBox( + width: 36, + child: Text( + '${(clamped * 100).round()}%', + textAlign: TextAlign.right, + style: const TextStyle( + color: Colors.white, + fontSize: 13, + fontWeight: FontWeight.w500, + fontFeatures: [FontFeature.tabularFigures()], + ), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildHorizontalProgress(double value) { + return SizedBox( + width: 120, + height: 4, + child: LinearProgressIndicator( + value: value, + color: Colors.white, + backgroundColor: Colors.white24, + borderRadius: BorderRadius.circular(2), + ), + ); + } + + Widget _buildSeekPreview() { + final isForward = data.seekIsForward; + return Center( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), + decoration: BoxDecoration( + color: const Color(0xE8181818), + borderRadius: BorderRadius.circular(999), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + isForward ? Icons.fast_forward : Icons.fast_rewind, + color: Colors.white, + size: 20, + ), + const SizedBox(width: 8), + Text( + formatDurationClock( + data.seekTarget < Duration.zero + ? Duration.zero + : data.seekTarget, + ), + style: const TextStyle( + color: Colors.white, + fontSize: 15, + fontWeight: FontWeight.w500, + fontFeatures: [FontFeature.tabularFigures()], + ), + ), + const Text( + ' / ', + style: TextStyle(color: Colors.white54, fontSize: 15), + ), + Text( + formatDurationClock(data.totalDuration), + style: const TextStyle( + color: Colors.white54, + fontSize: 15, + fontFeatures: [FontFeature.tabularFigures()], + ), + ), + ], + ), + ), + ); + } + + Widget _buildLongPressSpeed() { + return Positioned( + top: 64, + left: 0, + right: 0, + child: Center( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: holdSpeedPromptBackgroundColor, + borderRadius: BorderRadius.circular(999), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.fast_forward, color: Colors.white, size: 18), + const SizedBox(width: 6), + Text( + '${data.speedRate}x', + style: const TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildDoubleTapSeek(double screenWidth, double screenHeight) { + return Positioned( + top: 0, + bottom: 0, + left: data.doubleTapIsLeft ? screenWidth * 0.15 : null, + right: !data.doubleTapIsLeft ? screenWidth * 0.15 : null, + child: Center( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: const Color(0xE8181818), + borderRadius: BorderRadius.circular(999), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + data.doubleTapIsLeft ? Icons.fast_rewind : Icons.fast_forward, + color: Colors.white, + size: 20, + ), + const SizedBox(width: 6), + Text( + '${data.doubleTapSeconds}s', + style: const TextStyle( + color: Colors.white, + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ), + ); + } + +} diff --git a/lib/features/player/widgets/android/android_gesture_layer.dart b/lib/features/player/widgets/android/android_gesture_layer.dart new file mode 100644 index 0000000..ea3ceb8 --- /dev/null +++ b/lib/features/player/widgets/android/android_gesture_layer.dart @@ -0,0 +1,168 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../../../../core/infra/player_engine/player_engine.dart'; + +enum _DragMode { none, brightness, volume, seek } + +class AndroidGestureLayer extends StatefulWidget { + const AndroidGestureLayer({ + super.key, + required this.enabled, + required this.player, + required this.onToggleControls, + required this.onDoubleTapCenter, + required this.onDoubleTapLeft, + required this.onDoubleTapRight, + required this.onBrightnessChange, + required this.onVolumeChange, + required this.onVerticalDragEnd, + required this.onHorizontalSeekUpdate, + required this.onHorizontalSeekStart, + required this.onHorizontalSeekEnd, + required this.onLongPressStart, + required this.onLongPressEnd, + }); + + final bool enabled; + final PlayerEngine player; + final VoidCallback onToggleControls; + final VoidCallback onDoubleTapCenter; + final VoidCallback onDoubleTapLeft; + final VoidCallback onDoubleTapRight; + final ValueChanged onBrightnessChange; + final ValueChanged onVolumeChange; + final VoidCallback onVerticalDragEnd; + final ValueChanged onHorizontalSeekUpdate; + final VoidCallback onHorizontalSeekStart; + final VoidCallback onHorizontalSeekEnd; + final ValueChanged onLongPressStart; + final VoidCallback onLongPressEnd; + + @override + State createState() => _AndroidGestureLayerState(); +} + +class _AndroidGestureLayerState extends State { + _DragMode _dragMode = _DragMode.none; + Offset _panStartPos = Offset.zero; + double _cumulativeDx = 0; + double _cumulativeDy = 0; + + Timer? _doubleTapTimer; + Offset _lastTapDownPos = Offset.zero; + + void _handleTapDown(TapDownDetails details) { + _lastTapDownPos = details.localPosition; + } + + void _handleTapUp(TapUpDetails details) { + final pos = _lastTapDownPos; + + if (_doubleTapTimer != null) { + _doubleTapTimer!.cancel(); + _doubleTapTimer = null; + widget.onToggleControls(); + _executeDoubleTap(pos); + } else { + widget.onToggleControls(); + _doubleTapTimer = Timer(const Duration(milliseconds: 300), () { + _doubleTapTimer = null; + }); + } + } + + void _executeDoubleTap(Offset pos) { + final width = context.size?.width ?? 0; + final third = width / 3; + if (pos.dx < third) { + widget.onDoubleTapLeft(); + } else if (pos.dx > third * 2) { + widget.onDoubleTapRight(); + } else { + widget.onDoubleTapCenter(); + } + } + + void _handlePanStart(DragStartDetails details) { + _panStartPos = details.localPosition; + _dragMode = _DragMode.none; + _cumulativeDx = 0; + _cumulativeDy = 0; + } + + void _handlePanUpdate(DragUpdateDetails details) { + _cumulativeDx += details.delta.dx; + _cumulativeDy += details.delta.dy; + + if (_dragMode == _DragMode.none) { + final absDx = _cumulativeDx.abs(); + final absDy = _cumulativeDy.abs(); + if (absDx < 20 && absDy < 20) return; + + if (absDx > absDy * 1.5) { + _dragMode = _DragMode.seek; + widget.onHorizontalSeekStart(); + } else if (absDy > absDx * 1.5) { + final screenWidth = context.size?.width ?? 1; + _dragMode = _panStartPos.dx < screenWidth / 2 + ? _DragMode.brightness + : _DragMode.volume; + } else { + return; + } + } + + if (_dragMode == _DragMode.seek) { + final screenWidth = context.size?.width ?? 1; + final totalDuration = widget.player.state.duration; + final seekDelta = totalDuration * (_cumulativeDx / screenWidth) * 0.5; + widget.onHorizontalSeekUpdate(seekDelta); + } else if (_dragMode == _DragMode.brightness) { + final screenHeight = context.size?.height ?? 1; + final delta = -details.delta.dy / (screenHeight * 0.7); + widget.onBrightnessChange(delta); + } else if (_dragMode == _DragMode.volume) { + final screenHeight = context.size?.height ?? 1; + final delta = -details.delta.dy / (screenHeight * 1.5); + widget.onVolumeChange(delta); + } + } + + void _handlePanEnd(DragEndDetails details) { + if (_dragMode == _DragMode.seek) { + widget.onHorizontalSeekEnd(); + } else if (_dragMode == _DragMode.brightness || + _dragMode == _DragMode.volume) { + widget.onVerticalDragEnd(); + } + _dragMode = _DragMode.none; + } + + @override + void dispose() { + _doubleTapTimer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (!widget.enabled) return const SizedBox.expand(); + return GestureDetector( + behavior: HitTestBehavior.translucent, + onTapDown: _handleTapDown, + onTapUp: _handleTapUp, + onPanStart: _handlePanStart, + onPanUpdate: _handlePanUpdate, + onPanEnd: _handlePanEnd, + onLongPressStart: (details) { + final screenWidth = context.size?.width ?? 1; + final isLeftSide = details.localPosition.dx < screenWidth / 2; + widget.onLongPressStart(isLeftSide); + }, + onLongPressEnd: (_) => widget.onLongPressEnd(), + child: const SizedBox.expand(), + ); + } +} diff --git a/lib/features/player/widgets/android/android_lock_overlay.dart b/lib/features/player/widgets/android/android_lock_overlay.dart new file mode 100644 index 0000000..7547875 --- /dev/null +++ b/lib/features/player/widgets/android/android_lock_overlay.dart @@ -0,0 +1,100 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +class AndroidLockButton extends StatelessWidget { + const AndroidLockButton({super.key, required this.onLock}); + + final VoidCallback onLock; + + @override + Widget build(BuildContext context) { + return Material( + color: const Color(0xB3000000), + shape: const CircleBorder(), + child: InkWell( + customBorder: const CircleBorder(), + onTap: onLock, + child: const Padding( + padding: EdgeInsets.all(8), + child: Icon(Icons.lock_open, color: Colors.white, size: 22), + ), + ), + ); + } +} + +class AndroidLockedOverlay extends StatefulWidget { + const AndroidLockedOverlay({super.key, required this.onUnlock}); + + final VoidCallback onUnlock; + + @override + State createState() => _AndroidLockedOverlayState(); +} + +class _AndroidLockedOverlayState extends State { + bool _iconVisible = true; + Timer? _hideTimer; + + @override + void initState() { + super.initState(); + _startTimer(); + } + + void _startTimer() { + _hideTimer?.cancel(); + _hideTimer = Timer(const Duration(seconds: 3), () { + if (mounted) setState(() => _iconVisible = false); + }); + } + + void _onTapBackground() { + if (!_iconVisible) { + setState(() => _iconVisible = true); + _startTimer(); + } + } + + @override + void dispose() { + _hideTimer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _onTapBackground, + child: Stack( + children: [ + Positioned( + left: 56, + top: 0, + bottom: 0, + child: Center( + child: AnimatedOpacity( + opacity: _iconVisible ? 1.0 : 0.0, + duration: const Duration(milliseconds: 160), + child: Material( + color: const Color(0xB3000000), + shape: const CircleBorder(), + child: InkWell( + customBorder: const CircleBorder(), + onTap: _iconVisible ? widget.onUnlock : null, + child: const Padding( + padding: EdgeInsets.all(8), + child: Icon(Icons.lock, color: Colors.white, size: 28), + ), + ), + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/player/widgets/android/android_speed_strip.dart b/lib/features/player/widgets/android/android_speed_strip.dart new file mode 100644 index 0000000..d8b3811 --- /dev/null +++ b/lib/features/player/widgets/android/android_speed_strip.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; + +import '../speed_button.dart'; + +class AndroidSpeedStrip extends StatelessWidget { + const AndroidSpeedStrip({ + super.key, + required this.currentRate, + required this.onIncrement, + required this.onDecrement, + required this.onTapRate, + }); + + final double currentRate; + final VoidCallback onIncrement; + final VoidCallback onDecrement; + final VoidCallback onTapRate; + + @override + Widget build(BuildContext context) { + const buttonConstraints = BoxConstraints(minWidth: 36, minHeight: 36); + + return Container( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4), + decoration: BoxDecoration( + color: const Color(0xB3000000), + borderRadius: BorderRadius.circular(24), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + onPressed: onIncrement, + icon: const Icon(Icons.add, size: 20, color: Colors.white), + constraints: buttonConstraints, + padding: EdgeInsets.zero, + ), + GestureDetector( + onTap: onTapRate, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Text( + SpeedButton.formatRate(currentRate), + style: const TextStyle( + color: Colors.white, + fontSize: 13, + fontFeatures: [FontFeature.tabularFigures()], + ), + ), + ), + ), + IconButton( + onPressed: onDecrement, + icon: const Icon(Icons.remove, size: 20, color: Colors.white), + constraints: buttonConstraints, + padding: EdgeInsets.zero, + ), + ], + ), + ); + } +} diff --git a/lib/features/player/widgets/buffering_overlay.dart b/lib/features/player/widgets/buffering_overlay.dart new file mode 100644 index 0000000..b69d4d0 --- /dev/null +++ b/lib/features/player/widgets/buffering_overlay.dart @@ -0,0 +1,167 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../../../core/infra/player_engine/player_engine.dart'; +import '../../../shared/utils/format_utils.dart'; +import '../../../shared/widgets/app_loading_ring.dart'; +import '../services/network_speed_monitor.dart'; + +class BufferingOverlay extends StatefulWidget { + final PlayerEngine player; + final NetworkSpeedMonitor? speedMonitor; + final ValueChanged? onBufferingChanged; + + + final bool suppress; + + const BufferingOverlay({ + super.key, + required this.player, + this.speedMonitor, + this.onBufferingChanged, + this.suppress = false, + }); + + @override + State createState() => _BufferingOverlayState(); +} + +class _BufferingOverlayState extends State { + bool _isBuffering = false; + Timer? _debounceTimer; + + Duration _position = Duration.zero; + Duration _buffer = Duration.zero; + bool _playing = false; + + StreamSubscription? _bufferSub; + StreamSubscription? _positionSub; + StreamSubscription? _playingSub; + + @override + void initState() { + super.initState(); + _readState(widget.player); + _subscribe(widget.player); + } + + @override + void didUpdateWidget(covariant BufferingOverlay oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.player != widget.player) { + _cancelSubscriptions(); + _setBuffering(false); + _readState(widget.player); + _subscribe(widget.player); + } + } + + @override + void dispose() { + _debounceTimer?.cancel(); + _cancelSubscriptions(); + super.dispose(); + } + + void _readState(PlayerEngine player) { + _position = player.state.position; + _buffer = player.state.buffer; + _playing = player.state.playing; + } + + void _subscribe(PlayerEngine player) { + _positionSub = player.stream.position.listen((p) { + _position = p; + _evaluate(); + }); + _bufferSub = player.stream.buffer.listen((b) { + _buffer = b; + _evaluate(); + }); + _playingSub = player.stream.playing.listen((playing) { + _playing = playing; + _evaluate(); + }); + } + + void _cancelSubscriptions() { + unawaited(_positionSub?.cancel()); + unawaited(_bufferSub?.cancel()); + unawaited(_playingSub?.cancel()); + _positionSub = null; + _bufferSub = null; + _playingSub = null; + } + + void _setBuffering(bool value) { + if (_isBuffering == value) return; + setState(() => _isBuffering = value); + widget.onBufferingChanged?.call(value); + } + + void _evaluate() { + final shouldBuffer = + _playing && _buffer <= _position && _position > Duration.zero; + + if (shouldBuffer) { + if (_isBuffering || _debounceTimer != null) return; + _debounceTimer = Timer(const Duration(milliseconds: 200), () { + _debounceTimer = null; + if (!mounted) return; + if (_playing && _buffer <= _position && _position > Duration.zero) { + _setBuffering(true); + } + }); + return; + } + + _debounceTimer?.cancel(); + _debounceTimer = null; + _setBuffering(false); + } + + @override + Widget build(BuildContext context) { + final monitor = widget.speedMonitor; + return IgnorePointer( + child: Opacity( + opacity: widget.suppress ? 0.0 : 1.0, + child: AnimatedOpacity( + opacity: _isBuffering ? 1.0 : 0.0, + duration: const Duration(milliseconds: 100), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const AppLoadingRing(size: 40, color: Colors.white), + if (monitor != null) ...[ + const SizedBox(height: 12), + ValueListenableBuilder( + valueListenable: monitor.available, + builder: (_, available, _) { + if (!available) return const SizedBox.shrink(); + return ValueListenableBuilder( + valueListenable: monitor.speed, + builder: (_, speed, _) { + return Text( + formatDownloadSpeed(speed), + style: const TextStyle( + color: Colors.white70, + fontSize: 13, + fontFeatures: [FontFeature.tabularFigures()], + ), + ); + }, + ); + }, + ), + ], + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/player/widgets/danmaku/danmaku_match_tile.dart b/lib/features/player/widgets/danmaku/danmaku_match_tile.dart new file mode 100644 index 0000000..f99d805 --- /dev/null +++ b/lib/features/player/widgets/danmaku/danmaku_match_tile.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart'; + +import '../../../../core/contracts/danmaku.dart'; +import 'danmaku_tokens.dart'; + + +class DanmakuMatchTile extends StatelessWidget { + const DanmakuMatchTile({ + super.key, + required this.candidate, + required this.selected, + required this.onTap, + this.trailing, + }); + + final DanmakuMatchCandidate candidate; + final bool selected; + final VoidCallback onTap; + final Widget? trailing; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: Container( + margin: const EdgeInsets.only(bottom: 4), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + decoration: BoxDecoration( + color: selected ? DanmakuTokens.selectedFill : Colors.transparent, + borderRadius: BorderRadius.circular(DanmakuTokens.radius), + border: selected + ? Border.all(color: DanmakuTokens.accent.withValues(alpha: 0.5)) + : null, + ), + child: Row( + children: [ + Icon( + selected ? Icons.radio_button_checked : Icons.radio_button_off, + size: 15, + color: selected ? DanmakuTokens.accent : DanmakuTokens.textHint, + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + candidate.animeTitle, + style: TextStyle( + color: selected + ? DanmakuTokens.textPrimary + : DanmakuTokens.textSecondary, + fontSize: 12, + fontWeight: selected + ? FontWeight.w600 + : FontWeight.normal, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (candidate.episodeTitle.isNotEmpty) + Text( + candidate.episodeTitle, + style: const TextStyle( + color: DanmakuTokens.textHint, + fontSize: 11, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + Text( + candidate.source.displayName, + style: const TextStyle( + color: DanmakuTokens.textFaint, + fontSize: 11, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + if (trailing != null) ...[const SizedBox(width: 8), trailing!], + ], + ), + ), + ); + } +} diff --git a/lib/features/player/widgets/danmaku/danmaku_tokens.dart b/lib/features/player/widgets/danmaku/danmaku_tokens.dart new file mode 100644 index 0000000..530823d --- /dev/null +++ b/lib/features/player/widgets/danmaku/danmaku_tokens.dart @@ -0,0 +1,19 @@ +import 'package:flutter/material.dart'; + + +class DanmakuTokens { + DanmakuTokens._(); + + + static const Color accent = Colors.lightBlueAccent; + + + static const Color selectedFill = Colors.white12; + + static const Color textPrimary = Colors.white; + static const Color textSecondary = Colors.white70; + static const Color textHint = Colors.white38; + static const Color textFaint = Colors.white30; + + static const double radius = 6.0; +} diff --git a/lib/features/player/widgets/danmaku_search_dialog.dart b/lib/features/player/widgets/danmaku_search_dialog.dart new file mode 100644 index 0000000..3354190 --- /dev/null +++ b/lib/features/player/widgets/danmaku_search_dialog.dart @@ -0,0 +1,461 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:forui/forui.dart'; + +import '../../../core/contracts/danmaku.dart'; +import '../../../core/media/danmaku_episode_parser.dart'; +import '../../../shared/utils/format_utils.dart'; +import '../../../shared/widgets/app_loading_ring.dart'; +import '../services/danmaku/danmaku_session_notifier.dart'; + +part 'danmaku_search_dialog_view.dart'; + +const _kBg = Color(0xE8181818); +const _kDivider = Color(0x33FFFFFF); +const _kAccent = Color(0xFF4FA8FF); + +enum _Phase { + idle, + searching, + results, + empty, + error, + episodesLoading, + episodes, + applying, +} + +class DanmakuSearchDialog extends ConsumerStatefulWidget { + final String currentItemId; + final bool Function() isItemStillCurrent; + final String? initialQuery; + final List initialResults; + final int initialSelectedIndex; + final int? targetEpisode; + + const DanmakuSearchDialog({ + super.key, + required this.currentItemId, + required this.isItemStillCurrent, + this.initialQuery, + this.initialResults = const [], + this.initialSelectedIndex = -1, + this.targetEpisode, + }); + + @override + ConsumerState createState() => + _DanmakuSearchDialogState(); +} + +class _DanmakuSearchDialogState extends ConsumerState { + DanmakuSessionNotifier get _notifier => + ref.read(danmakuSessionProvider.notifier); + + final TextEditingController _queryCtrl = TextEditingController(); + final FocusNode _focusNode = FocusNode(); + final ScrollController _episodesScrollCtrl = ScrollController(); + final Map> _episodeCache = {}; + + static const double _kEpisodeItemExtent = 68.0; + + _Phase _phase = _Phase.idle; + List _results = const []; + DanmakuMatchCandidate? _activeMatch; + List _episodes = const []; + String? _messageText; + bool _messageIsError = false; + int? _flashEpisodeId; + DanmakuSource? _flashSource; + int _searchSeq = 0; + int _episodeSeq = 0; + int _applySeq = 0; + Offset _transitionOffset = Offset.zero; + + bool get _busy => + _phase == _Phase.searching || + _phase == _Phase.episodesLoading || + _phase == _Phase.applying; + + String _episodeCacheKey(DanmakuMatchCandidate match) => + '${match.source.id}:${match.animeId}'; + + @override + void initState() { + super.initState(); + final q = widget.initialQuery; + if (q != null && q.isNotEmpty) { + _queryCtrl.text = q; + _queryCtrl.selection = TextSelection.collapsed(offset: q.length); + } + if (widget.initialResults.isNotEmpty) { + _results = widget.initialResults; + _phase = _Phase.results; + final idx = widget.initialSelectedIndex; + if (idx >= 0 && idx < widget.initialResults.length) { + _flashEpisodeId = widget.initialResults[idx].episodeId; + _flashSource = widget.initialResults[idx].source; + } + } + } + + @override + void dispose() { + _queryCtrl.dispose(); + _focusNode.dispose(); + _episodesScrollCtrl.dispose(); + super.dispose(); + } + + int? _findTargetEpisodeIndex(List episodes) { + final target = widget.targetEpisode; + if (target == null) return null; + for (var i = 0; i < episodes.length; i++) { + final n = extractEpisodeNumberFromTitle(episodes[i].episodeTitle); + if (n == target) return i; + } + return null; + } + + void _scheduleEpisodeScroll(int targetIdx) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + if (!_episodesScrollCtrl.hasClients) return; + final position = _episodesScrollCtrl.position; + final viewport = position.viewportDimension; + final desired = + targetIdx * _kEpisodeItemExtent - + (viewport - _kEpisodeItemExtent) / 2; + final clamped = desired.clamp(0.0, position.maxScrollExtent); + _episodesScrollCtrl.jumpTo(clamped); + }); + } + + void _dismiss() { + Navigator.of(context, rootNavigator: true).maybePop(); + } + + bool _ensureItemStillCurrent() { + if (widget.isItemStillCurrent()) return true; + _dismiss(); + return false; + } + + Future _submit() async { + if (!_ensureItemStillCurrent()) return; + final query = _queryCtrl.text.trim(); + if (query.isEmpty) { + setState(() { + _phase = _Phase.idle; + _messageText = '请输入番剧名'; + _messageIsError = true; + _flashEpisodeId = null; + }); + return; + } + + final seq = ++_searchSeq; + _episodeSeq++; + _applySeq++; + _transitionOffset = Offset.zero; + + setState(() { + _phase = _Phase.searching; + _activeMatch = null; + _episodes = const []; + _messageText = null; + _messageIsError = false; + _flashEpisodeId = null; + }); + + try { + final matches = await _notifier.manualSearch(query); + if (!mounted || seq != _searchSeq) return; + if (matches == null) { + _dismiss(); + return; + } + setState(() { + _results = matches; + _phase = matches.isEmpty ? _Phase.empty : _Phase.results; + _messageText = null; + _messageIsError = false; + }); + } catch (_) { + if (!mounted || seq != _searchSeq) return; + setState(() { + _phase = _Phase.error; + _messageText = '搜索失败,请检查弹幕服务地址'; + _messageIsError = true; + }); + } + } + + Future _openAnime(DanmakuMatchCandidate match) async { + if (_busy) return; + if (!_ensureItemStillCurrent()) return; + if (match.animeId <= 0) { + await _applyDirect(match); + return; + } + + final cacheKey = _episodeCacheKey(match); + final cached = _episodeCache[cacheKey]; + if (cached != null) { + if (cached.isEmpty) { + _transitionOffset = const Offset(-0.08, 0); + setState(() { + _phase = _Phase.results; + _activeMatch = null; + _episodes = const []; + _messageText = '该番剧未返回分集列表,可直接使用搜索结果"直接加载"'; + _messageIsError = false; + _flashEpisodeId = null; + }); + return; + } + if (cached.length == 1) { + _activeMatch = match; + _episodes = cached; + await _applyEpisode(match, cached.single); + return; + } + _transitionOffset = const Offset(0.08, 0); + final targetIdx = _findTargetEpisodeIndex(cached); + setState(() { + _activeMatch = match; + _episodes = cached; + _phase = _Phase.episodes; + _messageText = null; + _messageIsError = false; + _flashEpisodeId = targetIdx != null + ? cached[targetIdx].episodeId + : null; + }); + if (targetIdx != null) _scheduleEpisodeScroll(targetIdx); + return; + } + + final seq = ++_episodeSeq; + _applySeq++; + _transitionOffset = const Offset(0.08, 0); + + setState(() { + _activeMatch = match; + _episodes = const []; + _phase = _Phase.episodesLoading; + _messageText = null; + _messageIsError = false; + _flashEpisodeId = null; + }); + + try { + final episodes = await _notifier.listEpisodesForAnime( + match.source, + match.animeId, + ); + if (!mounted || seq != _episodeSeq) return; + if (episodes == null) { + _dismiss(); + return; + } + + _episodeCache[cacheKey] = episodes; + + if (episodes.isEmpty) { + _transitionOffset = const Offset(-0.08, 0); + setState(() { + _phase = _Phase.results; + _activeMatch = null; + _episodes = const []; + _messageText = '该番剧未返回分集列表,可直接使用搜索结果"直接加载"'; + _messageIsError = false; + }); + return; + } + + if (episodes.length == 1) { + _activeMatch = match; + _episodes = episodes; + await _applyEpisode(match, episodes.single); + return; + } + + _transitionOffset = const Offset(0.08, 0); + final targetIdx = _findTargetEpisodeIndex(episodes); + setState(() { + _episodes = episodes; + _phase = _Phase.episodes; + _messageText = null; + _messageIsError = false; + _flashEpisodeId = targetIdx != null + ? episodes[targetIdx].episodeId + : null; + }); + if (targetIdx != null) _scheduleEpisodeScroll(targetIdx); + } catch (_) { + if (!mounted || seq != _episodeSeq) return; + _transitionOffset = const Offset(-0.08, 0); + setState(() { + _phase = _Phase.results; + _activeMatch = null; + _episodes = const []; + _messageText = '加载分集失败,请重试'; + _messageIsError = true; + _flashEpisodeId = null; + }); + } + } + + void _backToResults() { + _episodeSeq++; + _applySeq++; + _transitionOffset = const Offset(-0.08, 0); + setState(() { + _phase = _Phase.results; + _activeMatch = null; + _episodes = const []; + _flashEpisodeId = null; + }); + } + + Future _applyEpisode( + DanmakuMatchCandidate match, + DanmakuBangumiEpisode episode, + ) async { + if (!_ensureItemStillCurrent()) return; + final seq = ++_applySeq; + _searchSeq++; + _episodeSeq++; + _transitionOffset = Offset.zero; + + setState(() { + _flashEpisodeId = episode.episodeId; + _phase = _Phase.applying; + _messageText = null; + _messageIsError = false; + }); + + await Future.delayed(const Duration(milliseconds: 100)); + if (!mounted || seq != _applySeq) return; + if (!_ensureItemStillCurrent()) return; + + try { + await _notifier.applyManualSelection( + source: match.source, + episodeId: episode.episodeId, + animeId: match.animeId, + animeTitle: match.animeTitle, + episodeTitle: episode.episodeTitle, + ); + if (!mounted || seq != _applySeq) return; + Navigator.of(context, rootNavigator: true).pop(); + } catch (_) { + if (!mounted || seq != _applySeq) return; + setState(() { + _phase = _episodes.isEmpty ? _Phase.results : _Phase.episodes; + _messageText = '加载弹幕失败,请重试'; + _messageIsError = true; + _flashEpisodeId = null; + }); + } + } + + Future _applyDirect(DanmakuMatchCandidate match) async { + if (!_ensureItemStillCurrent()) return; + final seq = ++_applySeq; + _searchSeq++; + _episodeSeq++; + _transitionOffset = Offset.zero; + + setState(() { + _flashEpisodeId = match.episodeId; + _flashSource = match.source; + _phase = _Phase.applying; + _messageText = null; + _messageIsError = false; + }); + + await Future.delayed(const Duration(milliseconds: 100)); + if (!mounted || seq != _applySeq) return; + if (!_ensureItemStillCurrent()) return; + + try { + await _notifier.applyManualSelection( + source: match.source, + episodeId: match.episodeId, + animeId: match.animeId, + animeTitle: match.animeTitle, + episodeTitle: match.episodeTitle, + ); + if (!mounted || seq != _applySeq) return; + Navigator.of(context, rootNavigator: true).pop(); + } catch (_) { + if (!mounted || seq != _applySeq) return; + setState(() { + _phase = _Phase.results; + _messageText = '加载弹幕失败,请重试'; + _messageIsError = true; + _flashEpisodeId = null; + _flashSource = null; + }); + } + } + + @override + Widget build(BuildContext context) { + return Shortcuts( + shortcuts: const { + SingleActivator(LogicalKeyboardKey.escape): DismissIntent(), + }, + child: Actions( + actions: >{ + DismissIntent: CallbackAction( + onInvoke: (_) { + _dismiss(); + return null; + }, + ), + }, + child: Container( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: _kBg, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: _kDivider), + boxShadow: const [ + BoxShadow( + color: Colors.black54, + blurRadius: 20, + offset: Offset(0, 6), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildHeader(), + const FDivider(style: .delta(color: _kDivider, padding: .value(EdgeInsets.zero))), + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), + child: _buildSearchField(), + ), + const FDivider(style: .delta(color: _kDivider, padding: .value(EdgeInsets.zero))), + Expanded( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + transitionBuilder: _slideFade, + child: _buildBody(), + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/player/widgets/danmaku_search_dialog_view.dart b/lib/features/player/widgets/danmaku_search_dialog_view.dart new file mode 100644 index 0000000..db912e3 --- /dev/null +++ b/lib/features/player/widgets/danmaku_search_dialog_view.dart @@ -0,0 +1,592 @@ +part of 'danmaku_search_dialog.dart'; + +extension _DanmakuSearchDialogView on _DanmakuSearchDialogState { + Widget _slideFade(Widget child, Animation animation) { + final curved = CurvedAnimation( + parent: animation, + curve: Curves.easeOutCubic, + reverseCurve: Curves.easeInCubic, + ); + return FadeTransition( + opacity: curved, + child: SlideTransition( + position: Tween( + begin: _transitionOffset, + end: Offset.zero, + ).animate(curved), + child: child, + ), + ); + } + + Widget _buildHeader() { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 8, 10), + child: Row( + children: [ + const Icon(Icons.search, color: Colors.white, size: 20), + const SizedBox(width: 8), + const Text( + '搜索弹幕源', + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + const Spacer(), + IconButton( + iconSize: 18, + visualDensity: VisualDensity.compact, + onPressed: _dismiss, + icon: const Icon(Icons.close, color: Colors.white70), + tooltip: '关闭', + ), + ], + ), + ); + } + + Widget _buildSearchField() { + return SizedBox( + height: 44, + child: TextField( + controller: _queryCtrl, + focusNode: _focusNode, + autofocus: true, + enabled: !_busy, + style: const TextStyle(color: Colors.white, fontSize: 13), + cursorColor: Colors.lightBlueAccent, + textInputAction: TextInputAction.search, + decoration: InputDecoration( + filled: true, + fillColor: Colors.white.withValues(alpha: 0.08), + hintText: '输入番剧名后按 Enter 搜索', + hintStyle: const TextStyle(color: Colors.white38, fontSize: 13), + prefixIcon: const Icon(Icons.search, color: Colors.white54, size: 18), + suffixIcon: IconButton( + onPressed: _busy ? null : _submit, + icon: const Icon(Icons.arrow_forward, size: 18), + color: Colors.white70, + tooltip: '搜索', + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 0, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: const BorderSide(color: _kDivider), + ), + disabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: const BorderSide(color: _kDivider), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: const BorderSide(color: _kAccent), + ), + ), + onSubmitted: (_) => _submit(), + ), + ); + } + + Widget _buildBody() { + switch (_phase) { + case _Phase.idle: + return _buildStateBody( + key: const ValueKey('idle'), + icon: Icons.search, + title: '输入番剧名,按 Enter 搜索', + subtitle: '可手动选择具体集数', + showMessage: true, + ); + case _Phase.searching: + return _buildLoadingBody( + key: const ValueKey('searching'), + text: '正在搜索…', + ); + case _Phase.results: + return _buildResultsBody( + key: const ValueKey('results'), + disabled: false, + ); + case _Phase.empty: + return _buildStateBody( + key: const ValueKey('empty'), + icon: Icons.search_off, + title: '未找到匹配的弹幕源', + subtitle: '换个剧名试试', + ); + case _Phase.error: + return _buildStateBody( + key: const ValueKey('error'), + icon: Icons.error_outline, + title: _messageText ?? '搜索失败', + subtitle: '请检查弹幕服务地址', + ); + case _Phase.episodesLoading: + return _buildEpisodesLoadingBody(); + case _Phase.episodes: + return _buildEpisodesBody( + key: const ValueKey('episodes'), + disabled: false, + ); + case _Phase.applying: + return _buildApplyingBody(); + } + } + + Widget _buildStateBody({ + required Key key, + required IconData icon, + required String title, + String? subtitle, + bool showMessage = false, + }) { + return Center( + key: key, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 280), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: Colors.white24, size: 32), + const SizedBox(height: 12), + Text( + title, + textAlign: TextAlign.center, + style: const TextStyle( + color: Colors.white70, + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + if (subtitle != null) ...[ + const SizedBox(height: 6), + Text( + subtitle, + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.white54, fontSize: 12), + ), + ], + if (showMessage && _messageText != null) ...[ + const SizedBox(height: 16), + _buildMessageBanner(), + ], + ], + ), + ), + ); + } + + Widget _buildLoadingBody({required Key key, required String text}) { + return Center( + key: key, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const AppLoadingRing(size: 22, color: Colors.white), + const SizedBox(height: 12), + Text( + text, + style: const TextStyle(color: Colors.white54, fontSize: 12), + ), + ], + ), + ); + } + + Widget _buildResultsBody({required Key key, required bool disabled}) { + return Column( + key: key, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '搜索结果 ${_results.length} 条', + style: const TextStyle( + color: Colors.white70, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + if (_messageText != null) ...[ + const SizedBox(height: 12), + _buildMessageBanner(), + ], + const SizedBox(height: 12), + Expanded( + child: ListView.separated( + padding: EdgeInsets.zero, + itemCount: _results.length, + separatorBuilder: (_, _) => const SizedBox(height: 8), + itemBuilder: (_, i) => _buildResultTile(_results[i], disabled), + ), + ), + ], + ); + } + + Widget _buildEpisodesLoadingBody() { + return Column( + key: const ValueKey('episodes-loading'), + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildEpisodesHeader(disabled: false), + const SizedBox(height: 16), + const Expanded( + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + AppLoadingRing(size: 22, color: Colors.white), + SizedBox(height: 12), + Text( + '正在加载分集…', + style: TextStyle(color: Colors.white54, fontSize: 12), + ), + ], + ), + ), + ), + ], + ); + } + + Widget _buildEpisodesBody({ + required Key key, + required bool disabled, + bool useScrollController = true, + }) { + return Column( + key: key, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildEpisodesHeader(disabled: disabled), + if (_messageText != null) ...[ + const SizedBox(height: 12), + _buildMessageBanner(), + ], + const SizedBox(height: 12), + Expanded( + child: ListView.separated( + controller: useScrollController ? _episodesScrollCtrl : null, + padding: EdgeInsets.zero, + itemCount: _episodes.length, + separatorBuilder: (_, _) => const SizedBox(height: 8), + itemBuilder: (_, i) => _buildEpisodeTile(_episodes[i], i, disabled), + ), + ), + ], + ); + } + + Widget _buildApplyingBody() { + final base = _activeMatch != null && _episodes.isNotEmpty + ? _buildEpisodesBody( + key: const ValueKey('applying-episodes'), + disabled: true, + useScrollController: false, + ) + : _buildResultsBody( + key: const ValueKey('applying-results'), + disabled: true, + ); + + return Stack( + key: const ValueKey('applying'), + fit: StackFit.expand, + children: [ + base, + Positioned.fill( + child: ColoredBox(color: Colors.black.withValues(alpha: 0.18)), + ), + const Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + AppLoadingRing(size: 22, color: Colors.white), + SizedBox(height: 12), + Text( + '正在加载弹幕…', + style: TextStyle(color: Colors.white70, fontSize: 12), + ), + ], + ), + ), + ], + ); + } + + Widget _buildEpisodesHeader({required bool disabled}) { + return Row( + children: [ + IconButton( + onPressed: disabled ? null : _backToResults, + iconSize: 18, + visualDensity: VisualDensity.compact, + icon: const Icon(Icons.arrow_back, color: Colors.white70), + tooltip: '返回', + ), + const SizedBox(width: 4), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _activeMatch?.animeTitle ?? '选择分集', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + const Text( + '选择要加载的集数', + style: TextStyle(color: Colors.white54, fontSize: 12), + ), + ], + ), + ), + ], + ); + } + + Widget _buildMessageBanner() { + final color = _messageIsError ? Colors.redAccent : _kAccent; + final icon = _messageIsError ? Icons.error_outline : Icons.info_outline; + + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: color.withValues(alpha: 0.35)), + ), + child: Row( + children: [ + Icon(icon, size: 16, color: color), + const SizedBox(width: 8), + Expanded( + child: Text( + _messageText ?? '', + style: const TextStyle(color: Colors.white70, fontSize: 12), + ), + ), + ], + ), + ); + } + + Widget _buildResultTile(DanmakuMatchCandidate match, bool disabled) { + final highlighted = + _flashEpisodeId == match.episodeId && + _flashSource?.id == match.source.id; + final canDrill = match.animeId > 0; + final subtitle = match.episodeTitle.isNotEmpty + ? match.episodeTitle + : canDrill + ? '进入分集列表后选择具体集数' + : '直接加载当前搜索结果'; + final showTargetHint = widget.targetEpisode != null && canDrill; + + return InkWell( + onTap: disabled + ? null + : () { + if (canDrill) { + _openAnime(match); + } else { + _applyDirect(match); + } + }, + borderRadius: BorderRadius.circular(10), + child: AnimatedContainer( + duration: const Duration(milliseconds: 100), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + decoration: BoxDecoration( + color: highlighted + ? Colors.white12 + : Colors.white.withValues(alpha: 0.04), + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: highlighted + ? Colors.lightBlueAccent.withValues(alpha: 0.7) + : Colors.white.withValues(alpha: 0.08), + ), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + match.animeTitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Text( + subtitle, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white54, + fontSize: 12, + height: 1.35, + ), + ), + const SizedBox(height: 4), + Text( + match.source.displayName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Colors.white38, fontSize: 11), + ), + if (showTargetHint) ...[ + const SizedBox(height: 4), + Row( + children: [ + const Icon( + Icons.adjust, + size: 12, + color: Colors.white38, + ), + const SizedBox(width: 4), + Expanded( + child: Text( + '目标:${formatEpisodeNumber(widget.targetEpisode)}(点击选择)', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white38, + fontSize: 11, + ), + ), + ), + ], + ), + ], + ], + ), + ), + const SizedBox(width: 12), + _buildResultAffordance(canDrill: canDrill), + ], + ), + ), + ); + } + + Widget _buildResultAffordance({required bool canDrill}) { + if (canDrill) { + return const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text('选择集数', style: TextStyle(color: Colors.white54, fontSize: 11)), + SizedBox(width: 2), + Icon(Icons.chevron_right, size: 16, color: Colors.white54), + ], + ); + } + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: _kAccent.withValues(alpha: 0.16), + borderRadius: BorderRadius.circular(999), + border: Border.all(color: _kAccent.withValues(alpha: 0.4)), + ), + child: const Text( + '直接加载', + style: TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ); + } + + Widget _buildEpisodeTile( + DanmakuBangumiEpisode episode, + int index, + bool disabled, + ) { + final highlighted = _flashEpisodeId == episode.episodeId; + final epNum = episode.episodeNumber.trim(); + final episodeLabel = epNum.isEmpty + ? formatEpisodeNumber(index + 1)! + : '第$epNum集'; + + return InkWell( + onTap: disabled || _activeMatch == null + ? null + : () => _applyEpisode(_activeMatch!, episode), + borderRadius: BorderRadius.circular(10), + child: AnimatedContainer( + duration: const Duration(milliseconds: 100), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + decoration: BoxDecoration( + color: highlighted + ? Colors.white12 + : Colors.white.withValues(alpha: 0.04), + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: highlighted + ? Colors.lightBlueAccent.withValues(alpha: 0.7) + : Colors.white.withValues(alpha: 0.08), + ), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + episodeLabel, + style: const TextStyle( + color: Colors.white, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + if (episode.episodeTitle.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + episode.episodeTitle, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white54, + fontSize: 12, + height: 1.35, + ), + ), + ], + ], + ), + ), + const SizedBox(width: 12), + const Text( + '点击加载', + style: TextStyle(color: Colors.white54, fontSize: 11), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/player/widgets/debounced_seek_bar.dart b/lib/features/player/widgets/debounced_seek_bar.dart new file mode 100644 index 0000000..a869c20 --- /dev/null +++ b/lib/features/player/widgets/debounced_seek_bar.dart @@ -0,0 +1,409 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../core/contracts/danmaku.dart'; +import '../../../core/infra/player_engine/player_engine.dart'; +import '../../../core/media/danmaku_heatmap.dart'; +import '../../../providers/danmaku_settings_provider.dart'; +import '../../../shared/utils/format_utils.dart'; +import '../services/danmaku/danmaku_session_notifier.dart'; + + +const double _kSeekBarHeight = 28.0; + + +class DebouncedSeekBar extends ConsumerStatefulWidget { + final PlayerEngine player; + + + final void Function(Duration?)? onDragChanged; + + const DebouncedSeekBar({super.key, required this.player, this.onDragChanged}); + + @override + ConsumerState createState() => _DebouncedSeekBarState(); +} + +class _DebouncedSeekBarState extends ConsumerState { + StreamSubscription? _posSub; + StreamSubscription? _durSub; + StreamSubscription? _bufSub; + + Duration _position = Duration.zero; + Duration _duration = Duration.zero; + Duration _buffer = Duration.zero; + + bool _dragging = false; + double _dragFraction = 0.0; + bool _hovering = false; + double? _hoverFraction; + + List? _cachedComments; + double? _cachedDurationSec; + DanmakuHeatmapData? _cachedHeatmap; + + PlayerEngine get _player => widget.player; + + @override + void initState() { + super.initState(); + _position = _player.state.position; + _duration = _player.state.duration; + _buffer = _player.state.buffer; + _subscribeStreams(); + } + + @override + void didUpdateWidget(covariant DebouncedSeekBar oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.player != widget.player) { + unawaited(_posSub?.cancel()); + unawaited(_durSub?.cancel()); + unawaited(_bufSub?.cancel()); + _position = _player.state.position; + _duration = _player.state.duration; + _buffer = _player.state.buffer; + _subscribeStreams(); + } + } + + void _subscribeStreams() { + _posSub = _player.stream.position.listen((p) { + if (!_dragging && mounted) setState(() => _position = p); + }); + _durSub = _player.stream.duration.listen((d) { + if (mounted) setState(() => _duration = d); + }); + _bufSub = _player.stream.buffer.listen((b) { + if (mounted) setState(() => _buffer = b); + }); + } + + @override + void dispose() { + _posSub?.cancel(); + _durSub?.cancel(); + _bufSub?.cancel(); + super.dispose(); + } + + double _fraction(Duration d) { + final total = _duration.inMilliseconds; + if (total <= 0) return 0.0; + return (d.inMilliseconds / total).clamp(0.0, 1.0); + } + + void _onDown(PointerDownEvent e, double width) { + final fraction = (e.localPosition.dx / width).clamp(0.0, 1.0); + setState(() { + _dragging = true; + _dragFraction = fraction; + }); + widget.onDragChanged?.call(_duration * fraction); + } + + void _onMove(PointerMoveEvent e, double width) { + if (!_dragging) return; + final fraction = (e.localPosition.dx / width).clamp(0.0, 1.0); + setState(() { + _dragFraction = fraction; + }); + widget.onDragChanged?.call(_duration * fraction); + } + + void _onUp(PointerUpEvent e, double width) { + if (!_dragging) return; + final fraction = (e.localPosition.dx / width).clamp(0.0, 1.0); + final target = _duration * fraction; + unawaited(_player.seek(target)); + setState(() { + _dragging = false; + _position = target; + _hovering = false; + _hoverFraction = null; + }); + widget.onDragChanged?.call(null); + } + + void _onCancel(PointerCancelEvent e) { + if (!_dragging) return; + setState(() { + _dragging = false; + _hovering = false; + _hoverFraction = null; + }); + widget.onDragChanged?.call(null); + } + + + List? _watchDanmakuComments() { + final state = ref.watch(danmakuSessionProvider).value; + if (state is DanmakuSessionMatched && + state.commentStatus == DanmakuCommentStatus.ready) { + return state.comments; + } + return null; + } + + + DanmakuHeatmapData? _resolveHeatmap( + List? comments, + double durationSec, + bool enabled, + ) { + if (!enabled || comments == null) { + _cachedComments = null; + _cachedDurationSec = null; + _cachedHeatmap = null; + return null; + } + if (identical(comments, _cachedComments) && + _cachedDurationSec == durationSec) { + return _cachedHeatmap; + } + final h = computeHeatmap(comments: comments, durationSec: durationSec); + _cachedComments = comments; + _cachedDurationSec = durationSec; + _cachedHeatmap = h; + return h; + } + + @override + Widget build(BuildContext context) { + final posFrac = _dragging ? _dragFraction : _fraction(_position); + final bufFrac = _fraction(_buffer); + final active = _hovering || _dragging; + + final showBubble = _dragging || (_hovering && _hoverFraction != null); + final bubbleFrac = _dragging ? _dragFraction : (_hoverFraction ?? 0.0); + final bubbleTime = _duration * bubbleFrac; + + final heatmapEnabled = ref.watch( + danmakuSettingsProvider.select( + (v) => v.value?.panelSettings.heatmapEnabled ?? true, + ), + ); + final comments = heatmapEnabled ? _watchDanmakuComments() : null; + final durationSec = _duration.inMilliseconds / 1000.0; + final heatmap = _resolveHeatmap(comments, durationSec, heatmapEnabled); + final heatmapVisible = heatmap?.visible == true; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: LayoutBuilder( + builder: (context, constraints) { + final w = constraints.maxWidth; + const bubbleWidth = 60.0; + const bubbleHalfWidth = bubbleWidth / 2; + final bubbleLeft = showBubble + ? (w * bubbleFrac - bubbleHalfWidth).clamp(0.0, w - bubbleWidth) + : 0.0; + + return MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => setState(() => _hovering = true), + onHover: (event) { + final frac = (event.localPosition.dx / w).clamp(0.0, 1.0); + setState(() => _hoverFraction = frac); + }, + onExit: (_) { + if (!_dragging) { + setState(() { + _hovering = false; + _hoverFraction = null; + }); + } + }, + child: Stack( + clipBehavior: Clip.none, + children: [ + Listener( + behavior: HitTestBehavior.translucent, + onPointerDown: (e) => _onDown(e, w), + onPointerMove: (e) => _onMove(e, w), + onPointerUp: (e) => _onUp(e, w), + onPointerCancel: _onCancel, + child: SizedBox( + height: _kSeekBarHeight, + child: CustomPaint( + size: Size(w, _kSeekBarHeight), + painter: _SeekBarPainter( + position: posFrac, + buffer: bufFrac, + active: active, + heatmap: heatmapVisible ? heatmap : null, + ), + ), + ), + ), + if (showBubble) + Positioned( + bottom: _kSeekBarHeight + 6, + left: bubbleLeft, + child: IgnorePointer( + child: Container( + width: bubbleWidth, + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 3, + ), + decoration: BoxDecoration( + color: const Color(0xCC000000), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + formatDurationClock(bubbleTime), + textAlign: TextAlign.center, + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontFeatures: [FontFeature.tabularFigures()], + ), + ), + ), + ), + ), + ], + ), + ); + }, + ), + ); + } +} + +class _SeekBarPainter extends CustomPainter { + final double position; + final double buffer; + final bool active; + final DanmakuHeatmapData? heatmap; + + const _SeekBarPainter({ + required this.position, + required this.buffer, + required this.active, + this.heatmap, + }); + + + static const double _heatmapBaselineY = 11.0; + static const double _heatmapMaxHeight = 10.0; + static const double _heatmapPixelsPerBucket = 4.0; + + + static const double _heatmapGamma = 0.6; + + + static const Color _heatmapLowColor = Color(0xCC82B1FF); + static const Color _heatmapHighColor = Color(0xFF2962FF); + + @override + void paint(Canvas canvas, Size size) { + _paintHeatmap(canvas, size); + _paintSeekBar(canvas, size); + } + + void _paintHeatmap(Canvas canvas, Size size) { + final h = heatmap; + if (h == null || h.buckets.isEmpty || size.width <= 0) return; + + final targetCount = (size.width / _heatmapPixelsPerBucket).floor().clamp( + 1, + h.buckets.length, + ); + final samples = downsampleHeatmap(h.buckets, targetCount); + if (samples.isEmpty) return; + + final points = []; + var peak = 0.0; + for (var i = 0; i < samples.length; i++) { + final shaped = math + .pow(samples[i].clamp(0.0, 1.0), _heatmapGamma) + .toDouble(); + if (shaped > peak) peak = shaped; + final cx = samples.length == 1 + ? size.width / 2 + : i / (samples.length - 1) * size.width; + points.add(Offset(cx, _heatmapBaselineY - _heatmapMaxHeight * shaped)); + } + + final linePath = Path()..moveTo(points.first.dx, points.first.dy); + for (var i = 1; i < points.length; i++) { + linePath.lineTo(points[i].dx, points[i].dy); + } + + final color = Color.lerp(_heatmapLowColor, _heatmapHighColor, peak)!; + + final fillPath = Path.from(linePath) + ..lineTo(points.last.dx, _heatmapBaselineY) + ..lineTo(points.first.dx, _heatmapBaselineY) + ..close(); + canvas.drawPath( + fillPath, + Paint() + ..style = PaintingStyle.fill + ..color = color.withValues(alpha: color.a * 0.18), + ); + + canvas.drawPath( + linePath, + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 1.4 + ..strokeJoin = StrokeJoin.round + ..strokeCap = StrokeCap.round + ..color = color, + ); + } + + void _paintSeekBar(Canvas canvas, Size size) { + final trackH = active ? 5.0 : 3.0; + final cy = size.height / 2; + final top = cy - trackH / 2; + final bottom = cy + trackH / 2; + final r = Radius.circular(trackH / 2); + + canvas.drawRRect( + RRect.fromRectAndRadius(Rect.fromLTRB(0, top, size.width, bottom), r), + Paint()..color = const Color(0x3DFFFFFF), + ); + + if (buffer > 0) { + canvas.drawRRect( + RRect.fromRectAndRadius( + Rect.fromLTRB(0, top, size.width * buffer, bottom), + r, + ), + Paint()..color = const Color(0x3DFFFFFF), + ); + } + + if (position > 0) { + canvas.drawRRect( + RRect.fromRectAndRadius( + Rect.fromLTRB(0, top, size.width * position, bottom), + r, + ), + Paint()..color = const Color(0xFFFF0000), + ); + } + + if (active) { + canvas.drawCircle( + Offset(size.width * position, cy), + 6.4, + Paint()..color = const Color(0xFFFF0000), + ); + } + } + + @override + bool shouldRepaint(_SeekBarPainter old) => + position != old.position || + buffer != old.buffer || + active != old.active || + !identical(heatmap, old.heatmap); +} diff --git a/lib/features/player/widgets/fit_button.dart b/lib/features/player/widgets/fit_button.dart new file mode 100644 index 0000000..f6b5a49 --- /dev/null +++ b/lib/features/player/widgets/fit_button.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; + +import 'panel_toggle_affordance.dart'; + +class FitButton extends StatelessWidget { + final BoxFit currentFit; + final bool isPanelOpen; + final VoidCallback onTogglePanel; + + const FitButton({ + super.key, + required this.currentFit, + required this.isPanelOpen, + required this.onTogglePanel, + }); + + static const options = [ + (fit: BoxFit.contain, label: '适应屏幕', subtitle: 'Contain'), + (fit: BoxFit.fill, label: '拉伸', subtitle: 'Stretch'), + (fit: BoxFit.cover, label: '裁剪', subtitle: 'Crop'), + ]; + + @override + Widget build(BuildContext context) { + return PanelToggleAffordance( + isOpen: isPanelOpen, + onTap: onTogglePanel, + tooltip: '画面比例', + child: const Icon(Icons.aspect_ratio, color: Colors.white, size: 28), + ); + } +} diff --git a/lib/features/player/widgets/network_speed_indicator.dart b/lib/features/player/widgets/network_speed_indicator.dart new file mode 100644 index 0000000..07f25e2 --- /dev/null +++ b/lib/features/player/widgets/network_speed_indicator.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; + +import '../../../shared/utils/format_utils.dart'; +import '../services/network_speed_monitor.dart'; + +class NetworkSpeedIndicator extends StatelessWidget { + const NetworkSpeedIndicator({super.key, required this.monitor}); + + final NetworkSpeedMonitor monitor; + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: monitor.available, + builder: (context, available, _) { + return ValueListenableBuilder( + valueListenable: monitor.speed, + builder: (context, speed, _) { + final speedText = available ? formatDownloadSpeed(speed) : '--'; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.black54, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + speedText, + style: const TextStyle( + color: Colors.white70, + fontSize: 12, + fontFeatures: [FontFeature.tabularFigures()], + ), + ), + ); + }, + ); + }, + ); + } +} diff --git a/lib/features/player/widgets/panel_toggle_affordance.dart b/lib/features/player/widgets/panel_toggle_affordance.dart new file mode 100644 index 0000000..f972c6d --- /dev/null +++ b/lib/features/player/widgets/panel_toggle_affordance.dart @@ -0,0 +1,103 @@ +import 'package:flutter/material.dart'; + + +class PanelToggleAffordance extends StatefulWidget { + static const double _radius = 8; + static const Color _activeFill = Color(0x2EFFFFFF); + static const Duration _openDuration = Duration(milliseconds: 220); + static const Duration _closeDuration = Duration(milliseconds: 130); + + final bool isOpen; + final VoidCallback onTap; + final String? tooltip; + final EdgeInsets padding; + final Widget child; + + const PanelToggleAffordance({ + super.key, + required this.isOpen, + required this.onTap, + required this.child, + this.tooltip, + this.padding = const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + }); + + @override + State createState() => _PanelToggleAffordanceState(); +} + +class _PanelToggleAffordanceState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _ctrl; + late final Animation _t; + + @override + void initState() { + super.initState(); + _ctrl = AnimationController( + vsync: this, + duration: PanelToggleAffordance._openDuration, + reverseDuration: PanelToggleAffordance._closeDuration, + value: widget.isOpen ? 1.0 : 0.0, + ); + _t = CurvedAnimation( + parent: _ctrl, + curve: Curves.easeInOutCubic, + reverseCurve: Curves.easeInCubic, + ); + } + + @override + void didUpdateWidget(covariant PanelToggleAffordance oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.isOpen != widget.isOpen) { + if (widget.isOpen) { + _ctrl.forward(); + } else { + _ctrl.reverse(); + } + } + } + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final inkWell = Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(PanelToggleAffordance._radius), + onTap: widget.onTap, + child: AnimatedBuilder( + animation: _t, + builder: (context, child) { + final fill = Color.lerp( + Colors.transparent, + PanelToggleAffordance._activeFill, + _t.value, + )!; + return Container( + padding: widget.padding, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular( + PanelToggleAffordance._radius, + ), + color: fill, + ), + child: child, + ); + }, + child: widget.child, + ), + ), + ); + + final t = widget.tooltip; + if (t == null) return inkWell; + return Tooltip(message: t, child: inkWell); + } +} diff --git a/lib/features/player/widgets/panels/danmaku_panel_body.dart b/lib/features/player/widgets/panels/danmaku_panel_body.dart new file mode 100644 index 0000000..55e2a7a --- /dev/null +++ b/lib/features/player/widgets/panels/danmaku_panel_body.dart @@ -0,0 +1,199 @@ +import 'package:canvas_danmaku/canvas_danmaku.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:forui/forui.dart'; + +import '../../../../core/contracts/danmaku.dart'; +import '../../../../providers/danmaku_settings_provider.dart'; +import '../../../../shared/widgets/app_loading_ring.dart'; +import '../danmaku/danmaku_match_tile.dart'; +import '../player_drawer_panel.dart'; +import 'panel_setting_rows.dart'; + +part 'danmaku_panel_body_view.dart'; + + +extension DanmakuPanelSettingsCanvas on DanmakuPanelSettings { + DanmakuOption toDanmakuOption({double? screenHeight}) { + final double effectiveArea; + if (maxRows > 0 && screenHeight != null && screenHeight > 0) { + final lineHeightPx = _measureDanmakuLineHeight( + fontSize: fontSize, + lineHeight: lineHeight, + fontFamily: fontFamily, + ); + final computed = (maxRows + 0.5) * lineHeightPx / screenHeight; + effectiveArea = computed.clamp(0.05, 1.0); + } else { + effectiveArea = area; + } + return DanmakuOption( + + + opacity: 1.0, + area: effectiveArea, + fontSize: fontSize, + fontWeight: bold ? 7 : 4, + fontFamily: fontFamily, + duration: 10.0 / speed.clamp(0.1, 5.0), + lineHeight: lineHeight, + hideTop: fixedToScroll ? true : !showTop, + hideScroll: !showScroll, + hideBottom: fixedToScroll ? true : !showBottom, + strokeWidth: strokeWidth, + safeArea: true, + massiveMode: false, + ); + } +} + + +double _measureDanmakuLineHeight({ + required double fontSize, + required double lineHeight, + String? fontFamily, +}) { + final painter = TextPainter( + text: TextSpan( + text: '弹幕', + style: TextStyle( + fontSize: fontSize, + height: lineHeight, + fontFamily: fontFamily, + ), + ), + textDirection: TextDirection.ltr, + )..layout(); + return painter.height; +} + +enum _DanmakuView { main, settings, filter, search } + +class DanmakuPanelBody extends StatefulWidget { + final DanmakuPanelSettings settings; + final ValueChanged onChanged; + + final DanmakuSessionState sessionState; + final bool danmakuGlobalEnabled; + final bool danmakuSourceConfigured; + + final List matches; + final int selectedMatchIndex; + final ValueChanged onMatchSelected; + final VoidCallback onOpenSearch; + final bool isLoading; + final int commentCount; + + final List blockedKeywords; + final ValueChanged onAddKeyword; + final ValueChanged onRemoveKeyword; + + final VoidCallback? onRetry; + final VoidCallback? onGoToSettings; + final VoidCallback? onToggleDanmakuEnabled; + + const DanmakuPanelBody({ + super.key, + required this.settings, + required this.onChanged, + required this.sessionState, + this.danmakuGlobalEnabled = true, + this.danmakuSourceConfigured = true, + this.matches = const [], + this.selectedMatchIndex = -1, + required this.onMatchSelected, + required this.onOpenSearch, + this.isLoading = false, + this.commentCount = 0, + this.blockedKeywords = const [], + required this.onAddKeyword, + required this.onRemoveKeyword, + this.onRetry, + this.onGoToSettings, + this.onToggleDanmakuEnabled, + }); + + @override + State createState() => _DanmakuPanelBodyState(); +} + +class _DanmakuPanelBodyState extends State { + _DanmakuView _view = _DanmakuView.main; + bool _navForward = true; + + DanmakuPanelSettings get s => widget.settings; + + void _emit(DanmakuPanelSettings next) => widget.onChanged(next); + + void _navigateTo(_DanmakuView view) { + if (view == _view) return; + setState(() { + _navForward = view != _DanmakuView.main; + _view = view; + }); + } + + void _back() => _navigateTo(_DanmakuView.main); + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + transitionBuilder: (child, animation) { + final isEntering = + (child.key as ValueKey<_DanmakuView>).value == _view; + final enterOffset = _navForward + ? const Offset(0.2, 0.0) + : const Offset(-0.2, 0.0); + final exitOffset = _navForward + ? const Offset(-0.15, 0.0) + : const Offset(0.15, 0.0); + final slide = isEntering + ? Tween( + begin: enterOffset, + end: Offset.zero, + ).animate(animation) + : Tween( + begin: Offset.zero, + end: exitOffset, + ).animate(animation); + return ClipRect( + child: SlideTransition( + position: slide, + child: FadeTransition(opacity: animation, child: child), + ), + ); + }, + child: KeyedSubtree( + key: ValueKey<_DanmakuView>(_view), + child: _buildView(), + ), + ), + ); + } + + Widget _buildView() { + if (_view == _DanmakuView.main) { + final emptyState = _buildEmptyState(); + if (emptyState != null) return emptyState; + } + return switch (_view) { + _DanmakuView.main => _buildMainMenu(), + _DanmakuView.settings => _buildSettingsDrawer(), + _DanmakuView.filter => _buildFilterDrawer(), + _DanmakuView.search => _buildSearchDrawer(), + }; + } + + final TextEditingController _keywordController = TextEditingController(); + + @override + void dispose() { + _keywordController.dispose(); + super.dispose(); + } +} diff --git a/lib/features/player/widgets/panels/danmaku_panel_body_view.dart b/lib/features/player/widgets/panels/danmaku_panel_body_view.dart new file mode 100644 index 0000000..a47e633 --- /dev/null +++ b/lib/features/player/widgets/panels/danmaku_panel_body_view.dart @@ -0,0 +1,745 @@ +part of 'danmaku_panel_body.dart'; + +extension _DanmakuPanelBodyView on _DanmakuPanelBodyState { + Widget? _buildEmptyState() { + final state = widget.sessionState; + if (state is DanmakuSessionIdle) { + if (!widget.danmakuSourceConfigured) { + return _emptyStatePage( + icon: Icons.subtitles, + title: '弹幕未配置', + description: '请先在设置中配置弹幕数据源', + actions: [ + if (widget.onGoToSettings != null) + _emptyStateCta('前往设置', widget.onGoToSettings!), + ], + ); + } + if (widget.danmakuGlobalEnabled) { + return _emptyStatePage( + icon: Icons.subtitles, + title: '正在搜索弹幕…', + showSpinner: true, + ); + } + return _emptyStatePage( + icon: Icons.subtitles, + title: '弹幕已关闭', + description: '开启后将自动匹配弹幕', + actions: [ + if (widget.onToggleDanmakuEnabled != null) + _emptyStateCta('开启弹幕', widget.onToggleDanmakuEnabled!), + ], + ); + } + if (state is DanmakuSessionMatching || + (state is DanmakuSessionMatched && + state.commentStatus == DanmakuCommentStatus.loading)) { + return _emptyStatePage( + icon: Icons.subtitles, + title: '正在搜索弹幕…', + showSpinner: true, + ); + } + if (state is DanmakuSessionEmpty) { + return _emptyStatePage( + icon: Icons.search_off, + title: '未找到匹配弹幕', + description: '可尝试手动搜索匹配', + actions: [_emptyStateCta('搜索弹幕', widget.onOpenSearch)], + ); + } + if (state is DanmakuSessionFailed) { + return _emptyStatePage( + icon: Icons.error_outline, + title: '加载失败', + description: '弹幕匹配请求出错', + actions: [ + if (widget.onRetry != null) _emptyStateCta('重试', widget.onRetry!), + _emptyStateCta('搜索弹幕', widget.onOpenSearch), + ], + ); + } + return null; + } + + Widget _emptyStatePage({ + required IconData icon, + required String title, + String? description, + List actions = const [], + bool showSpinner = false, + }) { + return SingleChildScrollView( + child: Container( + padding: const EdgeInsets.symmetric(vertical: 28, horizontal: 24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (showSpinner) + Stack( + alignment: Alignment.center, + children: [ + Icon(icon, color: const Color(0x61FFFFFF), size: 40), + const AppLoadingRing(size: 52, color: Color(0x99FFFFFF)), + ], + ) + else + Icon(icon, color: const Color(0x61FFFFFF), size: 40), + const SizedBox(height: 12), + Text( + title, + style: const TextStyle( + color: Colors.white70, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + if (description != null) ...[ + const SizedBox(height: 4), + Text( + description, + style: const TextStyle(color: Colors.white38, fontSize: 12), + textAlign: TextAlign.center, + ), + ], + if (actions.isNotEmpty) ...[ + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + for (var i = 0; i < actions.length; i++) ...[ + if (i > 0) const SizedBox(width: 8), + actions[i], + ], + ], + ), + ], + ], + ), + ), + ); + } + + Widget _emptyStateCta(String label, VoidCallback onTap) { + return TextButton( + onPressed: onTap, + style: TextButton.styleFrom( + foregroundColor: Colors.white, + backgroundColor: Colors.white12, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + ), + child: Text(label, style: const TextStyle(fontSize: 12)), + ); + } + + Widget _buildMainMenu() { + final hasMatch = + widget.selectedMatchIndex >= 0 && + widget.selectedMatchIndex < widget.matches.length; + final matchTitle = hasMatch + ? widget.matches[widget.selectedMatchIndex].animeTitle + : null; + + return SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sectionHeader('弹幕数据'), + _menuItem( + icon: Icons.search, + label: '搜索弹幕', + hasCheck: hasMatch, + subtitle: matchTitle, + onTap: widget.onOpenSearch, + ), + const FDivider( + style: .delta( + color: Color(0x1AFFFFFF), + padding: .value(EdgeInsetsDirectional.only(start: 16)), + ), + ), + _sectionHeader('操作'), + _menuItem( + icon: Icons.tune, + label: '显示设置', + onTap: () => _navigateTo(_DanmakuView.settings), + ), + _menuItem( + icon: Icons.filter_alt_outlined, + label: '过滤关键词', + badge: widget.blockedKeywords.isNotEmpty + ? '${widget.blockedKeywords.length}' + : null, + onTap: () => _navigateTo(_DanmakuView.filter), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: PanelToggleRow( + label: '高能进度条', + value: s.heatmapEnabled, + onChanged: (v) => _emit(s.copyWith(heatmapEnabled: v)), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: PanelToggleRow( + label: '弹幕', + value: s.enabled, + onChanged: (v) => _emit(s.copyWith(enabled: v)), + ), + ), + const SizedBox(height: 4), + ], + ), + ); + } + + Widget _sectionHeader(String title) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 10, 16, 4), + child: Text( + title, + style: const TextStyle( + color: Colors.white38, + fontSize: 11, + fontWeight: FontWeight.w500, + letterSpacing: 0.4, + ), + ), + ); + } + + Widget _menuItem({ + required IconData icon, + required String label, + String? subtitle, + String? badge, + bool hasCheck = false, + bool showArrow = true, + required VoidCallback onTap, + }) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(6), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Row( + children: [ + if (hasCheck) + const Icon(Icons.check, color: Colors.white70, size: 16) + else + Icon(icon, color: Colors.white54, size: 16), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + label, + style: const TextStyle(color: Colors.white, fontSize: 13), + ), + if (subtitle != null) ...[ + const SizedBox(height: 1), + Text( + subtitle, + style: const TextStyle( + color: Colors.white38, + fontSize: 11, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ), + if (badge != null) ...[ + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.white12, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + badge, + style: const TextStyle(color: Colors.white54, fontSize: 11), + ), + ), + const SizedBox(width: 4), + ], + if (showArrow) + const Icon(Icons.chevron_right, color: Colors.white24, size: 16), + ], + ), + ), + ); + } + + Widget _buildSettingsDrawer() { + return PlayerDrawerPanel( + title: '显示设置', + onBack: _back, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + PanelToggleRow( + label: '固定弹幕转滚动', + value: s.fixedToScroll, + onChanged: (v) => _emit(s.copyWith(fixedToScroll: v)), + ), + PanelStepperRow( + label: '弹幕行数', + display: s.maxRows == 0 ? '自动' : '${s.maxRows} 行', + onDecrement: s.maxRows > 0 + ? () => _emit(s.copyWith(maxRows: s.maxRows - 1)) + : null, + onIncrement: s.maxRows < 10 + ? () => _emit(s.copyWith(maxRows: s.maxRows + 1)) + : null, + ), + const FDivider( + style: .delta( + color: Color(0x1AFFFFFF), + padding: .value(EdgeInsets.zero), + ), + ), + const SizedBox(height: 6), + PanelSliderRow( + label: '延迟显示', + value: s.offset, + min: -10.0, + max: 10.0, + step: 0.5, + display: + '${s.offset >= 0 ? '+' : ''}${s.offset.toStringAsFixed(1)}s', + onChanged: (v) => _emit(s.copyWith(offset: _round(v, -10.0, 10.0))), + ), + PanelSliderRow( + label: '行间距', + value: s.lineHeight, + min: 1.0, + max: 3.0, + step: 0.1, + display: s.lineHeight.toStringAsFixed(1), + onChanged: (v) => + _emit(s.copyWith(lineHeight: _round(v, 1.0, 3.0))), + ), + PanelSliderRow( + label: '基础速度', + value: s.speed, + min: 0.25, + max: 2.0, + step: 0.25, + display: '${s.speed.toStringAsFixed(2)}x', + onChanged: (v) => _emit(s.copyWith(speed: v.clamp(0.25, 2.0))), + ), + PanelSliderRow( + label: '字号大小', + value: s.fontSize, + min: 12, + max: 40, + step: 2, + display: s.fontSize.round().toString(), + onChanged: (v) => _emit(s.copyWith(fontSize: _round(v, 12, 40))), + ), + PanelToggleRow( + label: '粗体', + value: s.bold, + onChanged: (v) => _emit(s.copyWith(bold: v)), + ), + const FDivider( + style: .delta( + color: Color(0x1AFFFFFF), + padding: .value(EdgeInsets.zero), + ), + ), + const SizedBox(height: 6), + PanelSliderRow( + label: '不透明度', + value: s.opacity, + min: 0.1, + max: 1.0, + step: 0.1, + display: '${(s.opacity * 100).round()}%', + onChanged: (v) => _emit(s.copyWith(opacity: _round(v, 0.1, 1.0))), + ), + PanelSliderRow( + label: '描边大小', + value: s.strokeWidth, + min: 0.0, + max: 3.0, + step: 0.5, + display: s.strokeWidth.toStringAsFixed(1), + onChanged: (v) => + _emit(s.copyWith(strokeWidth: _round(v, 0.0, 3.0))), + ), + const FDivider( + style: .delta( + color: Color(0x1AFFFFFF), + padding: .value(EdgeInsets.zero), + ), + ), + _fontFamilyRow(), + const SizedBox(height: 8), + _resetButton(), + const SizedBox(height: 4), + ], + ), + ); + } + + + static double _round(double v, double min, double max) => + (v.clamp(min, max) * 10).round() / 10; + + static const _fontPresets = [ + (label: '系统默认', value: null), + (label: 'PingFang SC', value: 'PingFang SC'), + (label: 'Heiti SC', value: 'Heiti SC'), + (label: 'Arial', value: 'Arial'), + ]; + + Widget _fontFamilyRow() { + final current = s.fontFamily; + final label = + _fontPresets.where((p) => p.value == current).firstOrNull?.label ?? + '系统默认'; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + children: [ + const Expanded( + child: Text( + '字体', + style: TextStyle(color: Colors.white70, fontSize: 13), + ), + ), + GestureDetector( + onTap: _showFontPicker, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + label, + style: const TextStyle( + color: Colors.lightBlueAccent, + fontSize: 13, + ), + ), + const SizedBox(width: 2), + const Icon( + Icons.unfold_more, + color: Colors.lightBlueAccent, + size: 14, + ), + ], + ), + ), + ], + ), + ); + } + + void _showFontPicker() { + showModalBottomSheet( + context: context, + backgroundColor: const Color(0xFF252525), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(14)), + ), + builder: (_) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Padding( + padding: EdgeInsets.fromLTRB(20, 16, 20, 8), + child: Text( + '选择字体', + style: TextStyle( + color: Colors.white, + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + ), + for (final p in _fontPresets) + ListTile( + title: Text( + p.label, + style: TextStyle( + color: Colors.white, + fontFamily: p.value, + fontSize: 14, + ), + ), + trailing: s.fontFamily == p.value + ? const Icon(Icons.check, color: Colors.white70, size: 18) + : null, + onTap: () { + Navigator.pop(context); + _emit(s.copyWith(fontFamily: p.value)); + }, + ), + const SizedBox(height: 16), + ], + ), + ); + } + + Widget _resetButton() { + return SizedBox( + width: double.infinity, + child: TextButton( + onPressed: () => _emit(defaultDanmakuPanelSettings()), + style: TextButton.styleFrom( + foregroundColor: Colors.white54, + backgroundColor: Colors.white10, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + padding: const EdgeInsets.symmetric(vertical: 10), + ), + child: const Text('重置', style: TextStyle(fontSize: 13)), + ), + ); + } + + Widget _buildFilterDrawer() { + return PlayerDrawerPanel( + title: '弹幕过滤', + onBack: _back, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _keywordInput(), + const SizedBox(height: 8), + const FDivider( + style: .delta( + color: Color(0x1AFFFFFF), + padding: .value(EdgeInsets.zero), + ), + ), + const SizedBox(height: 4), + _keywordList(), + const SizedBox(height: 4), + ], + ), + ); + } + + Widget _keywordInput() { + return Container( + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.white12), + ), + child: Row( + children: [ + const Padding( + padding: EdgeInsets.only(left: 10), + child: Icon( + Icons.filter_alt_outlined, + color: Colors.white38, + size: 16, + ), + ), + Expanded( + child: TextField( + controller: _keywordController, + style: const TextStyle(color: Colors.white, fontSize: 13), + decoration: const InputDecoration( + hintText: '输入要过滤的关键词', + hintStyle: TextStyle(color: Colors.white38, fontSize: 13), + border: InputBorder.none, + contentPadding: EdgeInsets.symmetric( + horizontal: 10, + vertical: 10, + ), + isDense: true, + ), + onSubmitted: _addKeyword, + inputFormatters: [LengthLimitingTextInputFormatter(30)], + ), + ), + IconButton( + icon: const Icon(Icons.add_circle_outline, size: 18), + color: Colors.white54, + padding: const EdgeInsets.only(right: 8), + constraints: const BoxConstraints(minWidth: 32, minHeight: 32), + onPressed: () => _addKeyword(_keywordController.text), + ), + ], + ), + ); + } + + void _addKeyword(String raw) { + final kw = raw.trim(); + if (kw.isEmpty) return; + if (widget.blockedKeywords.contains(kw)) { + _keywordController.clear(); + return; + } + widget.onAddKeyword(kw); + _keywordController.clear(); + } + + Widget _keywordList() { + final keywords = widget.blockedKeywords; + if (keywords.isEmpty) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 12), + child: Center( + child: Text( + '暂无屏蔽关键词', + style: TextStyle(color: Colors.white38, fontSize: 12), + ), + ), + ); + } + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final kw in keywords) + Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Row( + children: [ + const Icon( + Icons.label_outline, + color: Colors.white38, + size: 14, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + kw, + style: const TextStyle(color: Colors.white70, fontSize: 13), + ), + ), + IconButton( + icon: const Icon(Icons.close, size: 14), + color: Colors.white38, + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: 28, + minHeight: 28, + ), + onPressed: () => widget.onRemoveKeyword(kw), + ), + ], + ), + ), + ], + ); + } + + Widget _buildSearchDrawer() { + return PlayerDrawerPanel( + title: '搜索弹幕', + onBack: _back, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _matchesContent(), + const SizedBox(height: 8), + _manualSearchTrigger(), + const SizedBox(height: 4), + ], + ), + ); + } + + Widget _matchesContent() { + if (widget.isLoading) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + AppLoadingRing(size: 14, color: Colors.white54), + SizedBox(width: 8), + Text( + '正在匹配弹幕源…', + style: TextStyle(color: Colors.white54, fontSize: 12), + ), + ], + ), + ); + } + + final matches = widget.matches; + if (matches.isEmpty) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 12), + child: Row( + children: [ + Icon(Icons.info_outline, color: Colors.white24, size: 14), + SizedBox(width: 6), + Expanded( + child: Text( + '未匹配到弹幕源,可手动搜索', + style: TextStyle(color: Colors.white38, fontSize: 12), + ), + ), + ], + ), + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Text( + '弹幕源 (${matches.length} 条)', + style: const TextStyle(color: Colors.white38, fontSize: 11), + ), + ), + ...List.generate(matches.length, (i) { + final m = matches[i]; + final selected = i == widget.selectedMatchIndex; + return DanmakuMatchTile( + candidate: m, + selected: selected, + onTap: () => widget.onMatchSelected(i), + ); + }), + ], + ); + } + + Widget _manualSearchTrigger() { + return SizedBox( + width: double.infinity, + height: 40, + child: OutlinedButton.icon( + onPressed: widget.onOpenSearch, + style: OutlinedButton.styleFrom( + alignment: Alignment.centerLeft, + foregroundColor: Colors.white, + side: const BorderSide(color: Color(0x664FA8FF)), + backgroundColor: const Color(0x10FFFFFF), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + padding: const EdgeInsets.symmetric(horizontal: 12), + ), + icon: const Icon(Icons.manage_search, size: 16), + label: const Row( + children: [ + Expanded( + child: Text( + '搜索并手动选择分集', + style: TextStyle(fontSize: 12, fontWeight: FontWeight.w500), + ), + ), + Icon(Icons.chevron_right, size: 14, color: Colors.white38), + ], + ), + ), + ); + } +} diff --git a/lib/features/player/widgets/panels/episode_panel_body.dart b/lib/features/player/widgets/panels/episode_panel_body.dart new file mode 100644 index 0000000..9bf2fc2 --- /dev/null +++ b/lib/features/player/widgets/panels/episode_panel_body.dart @@ -0,0 +1,460 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../core/contracts/library.dart'; +import '../../../../providers/di_providers.dart'; +import '../../../../providers/tmdb_settings_provider.dart'; +import '../../../../shared/utils/emby_ticks.dart'; +import '../../../../shared/utils/format_utils.dart'; +import '../../../../shared/utils/tmdb_episode_helpers.dart'; +import '../../../../shared/utils/tmdb_key_utils.dart'; +import '../../../../shared/widgets/app_inline_alert.dart'; +import '../../../../shared/widgets/app_loading_ring.dart'; +import '../../../../shared/widgets/episode_thumb.dart'; +import '../../../../shared/widgets/season_selector.dart'; +import '../player_panel_shell.dart'; + +const Color _kActiveColor = Color(0xFF4FA8FF); + +class EpisodePanelBody extends ConsumerStatefulWidget { + final String seriesId; + final String? initialSeasonId; + final String currentEpisodeId; + final ValueChanged onEpisodeSelected; + + + final String serverId; + final String baseUrl; + final String token; + + const EpisodePanelBody({ + super.key, + required this.seriesId, + required this.currentEpisodeId, + required this.onEpisodeSelected, + required this.serverId, + required this.baseUrl, + required this.token, + this.initialSeasonId, + }); + + @override + ConsumerState createState() => EpisodePanelBodyState(); +} + + +class EpisodePanelBodyState extends ConsumerState { + String? _selectedSeasonId; + bool _loadStarted = false; + + @override + void initState() { + super.initState(); + } + + + void ensureLoaded() { + if (_loadStarted) return; + _loadStarted = true; + if (mounted) setState(() {}); + } + + @override + Widget build(BuildContext context) { + if (!_loadStarted) { + return const SizedBox.shrink(); + } + + final serverId = widget.serverId; + if (serverId.isEmpty) return const SizedBox.shrink(); + + final seasonsAsync = ref.watch( + seriesSeasonsDataProvider(( + serverId: serverId, + seriesId: widget.seriesId, + )), + ); + + return seasonsAsync.when( + loading: _loading, + error: (_, _) => _error(), + data: (seasons) { + if (seasons.isEmpty) return _empty('暂无可用季'); + final effectiveSeasonId = _resolveSeasonId(seasons); + final effectiveSeason = seasons.firstWhere( + (s) => s.Id == effectiveSeasonId, + orElse: () => seasons.first, + ); + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (seasons.length > 1) ...[ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + child: Align( + alignment: Alignment.centerLeft, + child: SeasonSelector( + seasons: seasons, + selectedId: effectiveSeasonId, + onSelect: (seasonId) { + setState(() => _selectedSeasonId = seasonId); + }, + ), + ), + ), + const Divider(color: kPanelDivider, height: 1), + ], + Flexible( + child: _EpisodeList( + seriesId: widget.seriesId, + seasonId: effectiveSeasonId, + seasonNumber: seasonNumberFrom(effectiveSeason), + currentEpisodeId: widget.currentEpisodeId, + serverId: widget.serverId, + baseUrl: widget.baseUrl, + token: widget.token, + onSelected: widget.onEpisodeSelected, + ), + ), + ], + ); + }, + ); + } + + String _resolveSeasonId(List seasons) { + final selected = _selectedSeasonId; + if (selected != null && seasons.any((s) => s.Id == selected)) { + return selected; + } + final initial = widget.initialSeasonId; + if (initial != null && seasons.any((s) => s.Id == initial)) { + return initial; + } + return seasons.first.Id; + } + + Widget _loading() => const Padding( + padding: EdgeInsets.symmetric(vertical: 32), + child: Center(child: AppLoadingRing(size: 22, color: Colors.white)), + ); + + Widget _empty(String text) => Padding( + padding: const EdgeInsets.symmetric(vertical: 32), + child: Center( + child: Text( + text, + style: const TextStyle(color: Colors.white54, fontSize: 13), + ), + ), + ); + + Widget _error() => const Padding( + padding: EdgeInsets.symmetric(vertical: 32), + child: AppInlineAlert(message: '选集加载失败'), + ); +} + +class _EpisodeList extends ConsumerStatefulWidget { + final String seriesId; + final String seasonId; + final int? seasonNumber; + final String? currentEpisodeId; + final String serverId; + final String baseUrl; + final String token; + final ValueChanged onSelected; + + const _EpisodeList({ + required this.seriesId, + required this.seasonId, + required this.seasonNumber, + required this.currentEpisodeId, + required this.serverId, + required this.baseUrl, + required this.token, + required this.onSelected, + }); + + @override + ConsumerState<_EpisodeList> createState() => _EpisodeListState(); +} + +class _EpisodeListState extends ConsumerState<_EpisodeList> { + final ScrollController _scrollController = ScrollController(); + bool _hasScrolledToActive = false; + + static const _itemHeight = 76.0; + + @override + void didUpdateWidget(covariant _EpisodeList oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.seasonId != widget.seasonId || + oldWidget.currentEpisodeId != widget.currentEpisodeId) { + _hasScrolledToActive = false; + } + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + void _scrollToActive(List episodes) { + if (_hasScrolledToActive || widget.currentEpisodeId == null) return; + final idx = episodes.indexWhere((e) => e.Id == widget.currentEpisodeId); + if (idx < 0) return; + _hasScrolledToActive = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !_scrollController.hasClients) return; + final viewport = _scrollController.position.viewportDimension; + final target = idx * _itemHeight - (viewport - _itemHeight) / 2; + final clamped = target.clamp( + 0.0, + _scrollController.position.maxScrollExtent, + ); + _scrollController.jumpTo(clamped); + }); + } + + @override + Widget build(BuildContext context) { + final serverId = widget.serverId; + final episodesAsync = ref.watch( + seriesEpisodesDataProvider(( + serverId: serverId, + seriesId: widget.seriesId, + seasonId: widget.seasonId, + )), + ); + + final tmdbSettings = ref.watch(tmdbSettingsProvider).value; + final canRequest = tmdbSettings?.canRequest ?? false; + String tmdbSeriesId = ''; + if (canRequest) { + final seriesItem = ref + .watch( + mediaDetailDataProvider(( + serverId: serverId, + itemId: widget.seriesId, + )), + ) + .value + ?.base; + if (seriesItem != null) tmdbSeriesId = tmdbIdFromItem(seriesItem); + } + final canUseTmdbSeason = + canRequest && tmdbSeriesId.isNotEmpty && widget.seasonNumber != null; + final tmdbSeason = canUseTmdbSeason + ? ref + .watch( + tmdbSeasonDetailProvider(( + tmdbId: tmdbSeriesId, + seasonNumber: widget.seasonNumber!, + )), + ) + .value + : null; + final tmdbStillUrls = tmdbStillUrlsByEpisode( + tmdbSeason, + tmdbSettings?.effectiveImageBaseUrl, + ); + + return episodesAsync.when( + loading: () => const Padding( + padding: EdgeInsets.symmetric(vertical: 32), + child: Center(child: AppLoadingRing(size: 22, color: Colors.white)), + ), + error: (_, _) => const Padding( + padding: EdgeInsets.symmetric(vertical: 32), + child: AppInlineAlert(message: '选集加载失败'), + ), + data: (episodes) { + if (episodes.isEmpty) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 32), + child: Center( + child: Text( + '暂无分集', + style: TextStyle(color: Colors.white54, fontSize: 13), + ), + ), + ); + } + _scrollToActive(episodes); + return ListView.separated( + controller: _scrollController, + shrinkWrap: true, + padding: const EdgeInsets.symmetric(vertical: 4), + itemCount: episodes.length, + separatorBuilder: (_, _) => const Divider( + color: kPanelDivider, + height: 1, + indent: 12, + endIndent: 12, + ), + itemBuilder: (_, i) { + final ep = episodes[i]; + final tmdbStillUrl = ep.IndexNumber == null + ? null + : tmdbStillUrls[ep.IndexNumber]; + return _EpisodeTile( + episode: ep, + seriesId: widget.seriesId, + tmdbStillUrl: tmdbStillUrl, + isActive: ep.Id == widget.currentEpisodeId, + baseUrl: widget.baseUrl, + token: widget.token, + onTap: () => widget.onSelected(ep), + ); + }, + ); + }, + ); + } +} + +class _EpisodeTile extends StatelessWidget { + final EmbyRawItem episode; + final String seriesId; + final String? tmdbStillUrl; + final bool isActive; + final String baseUrl; + final String token; + final VoidCallback onTap; + + const _EpisodeTile({ + required this.episode, + required this.seriesId, + required this.tmdbStillUrl, + required this.isActive, + required this.baseUrl, + required this.token, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final ep = formatEpisodeNumber(episode.IndexNumber) ?? ''; + final runtime = episode.RunTimeTicks != null + ? '${(episode.RunTimeTicks! / kTicksPerMinute).round()} 分钟' + : ''; + final progress = playbackProgress(episode); + + return InkWell( + onTap: isActive ? null : onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: Container( + width: 96, + height: 54, + color: Colors.white12, + child: Stack( + fit: StackFit.expand, + children: [ + EpisodeThumb( + episode: episode, + seriesId: seriesId, + baseUrl: baseUrl, + token: token, + tmdbStillUrl: tmdbStillUrl, + maxWidth: 240, + ), + if (isActive) + Container( + color: Colors.black54, + alignment: Alignment.center, + child: const Icon( + Icons.play_arrow, + color: Colors.white, + size: 28, + ), + ), + if (progress > 0) + Positioned( + left: 0, + right: 0, + bottom: 0, + child: LinearProgressIndicator( + value: progress, + minHeight: 2, + backgroundColor: Colors.white24, + valueColor: const AlwaysStoppedAnimation( + _kActiveColor, + ), + ), + ), + ], + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + if (ep.isNotEmpty) ...[ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: isActive ? _kActiveColor : Colors.white12, + borderRadius: BorderRadius.circular(3), + ), + child: Text( + ep, + style: const TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(width: 6), + ], + Expanded( + child: Text( + episode.Name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: isActive ? _kActiveColor : Colors.white, + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + const SizedBox(height: 4), + if (runtime.isNotEmpty) + Text( + runtime, + style: const TextStyle( + color: Colors.white54, + fontSize: 11, + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/player/widgets/panels/panel_selection_list.dart b/lib/features/player/widgets/panels/panel_selection_list.dart new file mode 100644 index 0000000..4e77812 --- /dev/null +++ b/lib/features/player/widgets/panels/panel_selection_list.dart @@ -0,0 +1,110 @@ +import 'package:flutter/material.dart'; + +import '../player_panel_shell.dart' show panelDivider; +import 'selection_panel_entry.dart'; + + +class PanelSelectionList extends StatelessWidget { + final List> entries; + final ValueChanged onSelected; + + const PanelSelectionList({ + super.key, + required this.entries, + required this.onSelected, + }); + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final entry in entries) + if (entry.isDivider) panelDivider() else _itemTile(entry), + ], + ), + ); + } + + Widget _itemTile(SelectionPanelEntry entry) { + return GestureDetector( + onTap: () { + if (entry.value != null) onSelected(entry.value as T); + }, + behavior: HitTestBehavior.opaque, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8), + child: Row( + children: [ + SizedBox( + width: 24, + child: entry.isActive + ? const Icon(Icons.check, size: 16, color: Color(0xFF2196F3)) + : null, + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + entry.label!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: entry.isActive + ? const Color(0xFF2196F3) + : Colors.white, + fontWeight: entry.isActive + ? FontWeight.w600 + : FontWeight.normal, + fontSize: 13, + ), + ), + if (entry.subtitle != null && entry.subtitle != entry.label) + Text( + entry.subtitle!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white38, + fontSize: 11, + ), + ), + ], + ), + ), + if (entry.badge != null) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + entry.badge!, + style: const TextStyle(fontSize: 10, color: Colors.grey), + ), + ), + ], + if (entry.isLoading) ...[ + const SizedBox(width: 8), + const SizedBox.square( + dimension: 14, + child: CircularProgressIndicator( + strokeWidth: 1.5, + color: Color(0xFF2196F3), + ), + ), + ], + ], + ), + ), + ); + } +} diff --git a/lib/features/player/widgets/panels/panel_setting_rows.dart b/lib/features/player/widgets/panels/panel_setting_rows.dart new file mode 100644 index 0000000..ba495fa --- /dev/null +++ b/lib/features/player/widgets/panels/panel_setting_rows.dart @@ -0,0 +1,210 @@ +import 'package:flutter/material.dart'; +import 'package:forui/forui.dart'; + + +const _kRowLabelStyle = TextStyle(color: Colors.white70, fontSize: 13); +const _kRowValueStyle = TextStyle( + color: Colors.white, + fontSize: 13, + fontWeight: FontWeight.w500, +); + +const _kPanelSliderTheme = SliderThemeData( + trackHeight: 3, + thumbShape: RoundSliderThumbShape(enabledThumbRadius: 7), + overlayShape: RoundSliderOverlayShape(overlayRadius: 14), + activeTrackColor: Colors.white, + inactiveTrackColor: Colors.white24, + thumbColor: Colors.white, + overlayColor: Colors.white10, +); + + +class PanelGroupHeader extends StatelessWidget { + final String title; + + const PanelGroupHeader({super.key, required this.title}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 4, left: 2), + child: Text( + title, + style: const TextStyle( + color: Colors.white54, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ); + } +} + + +class PanelSliderRow extends StatelessWidget { + final String label; + final double value; + final double min; + final double max; + + + final double step; + + + final String display; + final ValueChanged onChanged; + + const PanelSliderRow({ + super.key, + required this.label, + required this.value, + required this.min, + required this.max, + required this.step, + required this.display, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + final divisions = ((max - min) / step).round(); + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Expanded(child: Text(label, style: _kRowLabelStyle)), + Text(display, style: _kRowValueStyle), + ], + ), + SizedBox( + height: 28, + child: SliderTheme( + data: _kPanelSliderTheme, + child: Slider( + value: value.clamp(min, max), + min: min, + max: max, + divisions: divisions, + padding: EdgeInsets.zero, + onChanged: onChanged, + ), + ), + ), + ], + ), + ); + } +} + + +class PanelToggleRow extends StatelessWidget { + final String label; + final bool value; + final ValueChanged onChanged; + + const PanelToggleRow({ + super.key, + required this.label, + required this.value, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + children: [ + Expanded(child: Text(label, style: _kRowLabelStyle)), + FSwitch( + value: value, + onChange: onChanged, + style: FSwitchStyleDelta.delta( + trackColor: FVariantsValueDelta.delta([ + FVariantValueDeltaOperation.all(Colors.white12), + FVariantValueDeltaOperation.exact({ + FSwitchVariantConstraint.selected, + }, Colors.white38), + ]), + thumbColor: FVariantsValueDelta.delta([ + FVariantValueDeltaOperation.all(Colors.white38), + FVariantValueDeltaOperation.exact({ + FSwitchVariantConstraint.selected, + }, Colors.white), + ]), + ), + ), + ], + ), + ); + } +} + + +class PanelStepperRow extends StatelessWidget { + final String label; + final String display; + final VoidCallback? onDecrement; + final VoidCallback? onIncrement; + + const PanelStepperRow({ + super.key, + required this.label, + required this.display, + this.onDecrement, + this.onIncrement, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + children: [ + Expanded(child: Text(label, style: _kRowLabelStyle)), + _StepButton(icon: Icons.remove, onPressed: onDecrement), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: SizedBox( + width: 46, + child: Text( + display, + style: _kRowValueStyle, + textAlign: TextAlign.center, + ), + ), + ), + _StepButton(icon: Icons.add, onPressed: onIncrement), + ], + ), + ); + } +} + +class _StepButton extends StatelessWidget { + final IconData icon; + final VoidCallback? onPressed; + + const _StepButton({required this.icon, required this.onPressed}); + + @override + Widget build(BuildContext context) { + return SizedBox.square( + dimension: 28, + child: FButton.icon( + variant: FButtonVariant.ghost, + size: FButtonSizeVariant.xs, + onPress: onPressed, + child: Icon( + icon, + size: 14, + color: onPressed != null ? Colors.white70 : Colors.white24, + ), + ), + ); + } +} diff --git a/lib/features/player/widgets/panels/selection_panel_entry.dart b/lib/features/player/widgets/panels/selection_panel_entry.dart new file mode 100644 index 0000000..42a63d8 --- /dev/null +++ b/lib/features/player/widgets/panels/selection_panel_entry.dart @@ -0,0 +1,27 @@ +class SelectionPanelEntry { + final T? value; + final String? label; + final String? subtitle; + final String? badge; + final bool isActive; + final bool isLoading; + final bool isDivider; + + const SelectionPanelEntry.item({ + required T this.value, + required String this.label, + this.subtitle, + this.badge, + this.isActive = false, + this.isLoading = false, + }) : isDivider = false; + + const SelectionPanelEntry.divider() + : value = null, + label = null, + subtitle = null, + badge = null, + isActive = false, + isLoading = false, + isDivider = true; +} diff --git a/lib/features/player/widgets/panels/tracks_panel_body.dart b/lib/features/player/widgets/panels/tracks_panel_body.dart new file mode 100644 index 0000000..b81424a --- /dev/null +++ b/lib/features/player/widgets/panels/tracks_panel_body.dart @@ -0,0 +1,513 @@ +import 'dart:async'; +import 'dart:io' show Platform; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import 'package:forui/forui.dart'; + +import '../../../../core/contracts/playback.dart'; +import '../../../../core/infra/player_engine/player_engine.dart'; +import '../../../../providers/subtitle_settings_provider.dart'; +import '../player_drawer_panel.dart'; +import '../player_panel_shell.dart'; +import 'panel_selection_list.dart'; +import 'panel_setting_rows.dart'; +import 'selection_panel_entry.dart'; + +const _activeColor = Color(0xFF2196F3); + +String? _translateLanguage(String? code) { + if (code == null || code.isEmpty) return null; + return kLanguageNames[code.toLowerCase()]; +} + +enum _SubtitlePanelView { subtitles, style } + +class SubtitlePanelBody extends StatefulWidget { + final SubtitleStyleSettings subtitleSettings; + final ValueChanged onSubtitleSettingsChanged; + + + final List subtitles; + + + final ValueListenable activeSubtitleStreamIndex; + + + final ValueListenable subtitleLoading; + + + final void Function(int? streamIndex)? onSubtitleSelected; + + const SubtitlePanelBody({ + super.key, + required this.subtitleSettings, + required this.onSubtitleSettingsChanged, + required this.subtitles, + required this.activeSubtitleStreamIndex, + required this.subtitleLoading, + this.onSubtitleSelected, + }); + + @override + State createState() => _SubtitlePanelBodyState(); +} + +class _SubtitlePanelBodyState extends State { + _SubtitlePanelView _selectedView = _SubtitlePanelView.subtitles; + + + int? _activeStreamIndex; + bool _isSubtitleLoading = false; + + @override + void initState() { + super.initState(); + widget.activeSubtitleStreamIndex.addListener(_onActiveIndexChanged); + widget.subtitleLoading.addListener(_onLoadingChanged); + _activeStreamIndex = widget.activeSubtitleStreamIndex.value; + _isSubtitleLoading = widget.subtitleLoading.value; + } + + @override + void didUpdateWidget(covariant SubtitlePanelBody oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.activeSubtitleStreamIndex != + widget.activeSubtitleStreamIndex) { + oldWidget.activeSubtitleStreamIndex.removeListener(_onActiveIndexChanged); + widget.activeSubtitleStreamIndex.addListener(_onActiveIndexChanged); + _activeStreamIndex = widget.activeSubtitleStreamIndex.value; + } + if (oldWidget.subtitleLoading != widget.subtitleLoading) { + oldWidget.subtitleLoading.removeListener(_onLoadingChanged); + widget.subtitleLoading.addListener(_onLoadingChanged); + _isSubtitleLoading = widget.subtitleLoading.value; + } + } + + @override + void dispose() { + widget.activeSubtitleStreamIndex.removeListener(_onActiveIndexChanged); + widget.subtitleLoading.removeListener(_onLoadingChanged); + super.dispose(); + } + + void _onActiveIndexChanged() { + if (!mounted) return; + setState(() => _activeStreamIndex = widget.activeSubtitleStreamIndex.value); + } + + void _onLoadingChanged() { + if (!mounted) return; + setState(() => _isSubtitleLoading = widget.subtitleLoading.value); + } + + + void _selectSubtitle(int? streamIndex) { + if (!mounted) return; + setState(() => _activeStreamIndex = streamIndex); + widget.onSubtitleSelected?.call(streamIndex); + } + + @override + Widget build(BuildContext context) { + if (Platform.isAndroid) return _buildAndroidBody(); + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(width: 150, child: _leftMenu()), + const FDivider( + axis: Axis.vertical, + style: .delta(color: kPanelDivider, padding: .value(EdgeInsets.zero)), + ), + Expanded(child: _content()), + ], + ); + } + + Widget _buildAndroidBody() { + if (_selectedView == _SubtitlePanelView.style) { + return PlayerDrawerPanel( + title: '字幕样式', + onBack: () => + setState(() => _selectedView = _SubtitlePanelView.subtitles), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: _subtitleStyleRows(), + ), + ); + } + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + _androidStyleEntry(), + panelDivider(), + Flexible(child: _subtitleList()), + ], + ); + } + + + Widget _androidStyleEntry() { + return InkWell( + onTap: () => setState(() => _selectedView = _SubtitlePanelView.style), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Row( + children: [ + const Icon( + Icons.closed_caption_outlined, + color: Colors.white54, + size: 16, + ), + const SizedBox(width: 10), + const Expanded( + child: Text( + '字幕样式', + style: TextStyle(color: Colors.white, fontSize: 13), + ), + ), + Text( + _subtitleStyleLabel(), + style: const TextStyle(color: Colors.white38, fontSize: 11), + ), + const SizedBox(width: 4), + const Icon(Icons.chevron_right, color: Colors.white24, size: 16), + ], + ), + ), + ); + } + + Widget _content() { + switch (_selectedView) { + case _SubtitlePanelView.subtitles: + return _subtitleList(); + case _SubtitlePanelView.style: + return _subtitleSettings(); + } + } + + Widget _leftMenu() { + return SingleChildScrollView( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _menuItem( + view: _SubtitlePanelView.subtitles, + label: '字幕', + subtitle: _activeSubtitleLabel(), + icon: Icons.subtitles_outlined, + ), + _menuItem( + view: _SubtitlePanelView.style, + label: '字幕样式', + subtitle: _subtitleStyleLabel(), + icon: Icons.closed_caption_outlined, + ), + ], + ), + ); + } + + Widget _menuItem({ + required _SubtitlePanelView view, + required String label, + required String subtitle, + required IconData icon, + }) { + final selected = _selectedView == view; + return GestureDetector( + onTap: () => setState(() => _selectedView = view), + behavior: HitTestBehavior.opaque, + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: selected + ? BoxDecoration( + color: Colors.white.withOpacity(0.08), + borderRadius: BorderRadius.circular(6), + ) + : null, + child: Row( + children: [ + Icon( + icon, + size: 16, + color: selected ? _activeColor : Colors.white54, + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + label, + style: TextStyle( + color: selected ? _activeColor : Colors.white, + fontSize: 13, + fontWeight: selected + ? FontWeight.w600 + : FontWeight.normal, + ), + ), + Text( + subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: selected + ? _activeColor.withOpacity(0.7) + : Colors.white38, + fontSize: 11, + ), + ), + ], + ), + ), + Icon( + Icons.chevron_right, + size: 16, + color: selected ? _activeColor : Colors.white24, + ), + ], + ), + ), + ); + } + + Widget _subtitleList() { + final embedded = widget.subtitles + .where((track) => !track.isExternal) + .toList(growable: false); + final external = widget.subtitles + .where((track) => track.isExternal) + .toList(growable: false); + + const disableValue = -1; + final entries = >[ + SelectionPanelEntry.item( + value: disableValue, + label: '禁用', + isActive: _activeStreamIndex == null, + ), + if (embedded.isNotEmpty) const SelectionPanelEntry.divider(), + for (final track in embedded) + SelectionPanelEntry.item( + value: track.index, + label: track.title ?? track.language ?? '字幕 ${track.index}', + subtitle: _translateLanguage(track.language), + isActive: _activeStreamIndex == track.index, + isLoading: _isSubtitleLoading && _activeStreamIndex == track.index, + ), + if (external.isNotEmpty) const SelectionPanelEntry.divider(), + for (final track in external) + SelectionPanelEntry.item( + value: track.index, + label: track.title ?? track.language ?? '外挂字幕 ${track.index}', + subtitle: _translateLanguage(track.language), + badge: '外挂', + isActive: _activeStreamIndex == track.index, + isLoading: _isSubtitleLoading && _activeStreamIndex == track.index, + ), + ]; + return PanelSelectionList( + entries: entries, + onSelected: (value) => + _selectSubtitle(value == disableValue ? null : value), + ); + } + + Widget _subtitleSettings() { + return SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const PanelGroupHeader(title: '主字幕样式'), + ..._subtitleStyleRows(), + const SizedBox(height: 8), + ], + ), + ); + } + + + List _subtitleStyleRows() { + final settings = widget.subtitleSettings; + return [ + PanelSliderRow( + label: '位置', + value: settings.position.toDouble(), + min: 0, + max: 150, + step: 5, + display: settings.position.toString(), + onChanged: (v) => widget.onSubtitleSettingsChanged( + settings.copyWith(position: v.round()), + ), + ), + PanelSliderRow( + label: '大小', + value: settings.fontSize, + min: 10, + max: 120, + step: 2, + display: settings.fontSize.round().toString(), + onChanged: (v) => + widget.onSubtitleSettingsChanged(settings.copyWith(fontSize: v)), + ), + PanelSliderRow( + label: '延迟', + value: settings.delay, + min: -10.0, + max: 10.0, + step: 0.5, + display: + '${settings.delay >= 0 ? '+' : ''}${settings.delay.toStringAsFixed(1)}s', + onChanged: (v) => + widget.onSubtitleSettingsChanged(settings.copyWith(delay: v)), + ), + PanelSliderRow( + label: '背景', + value: settings.bgOpacity, + min: 0.0, + max: 1.0, + step: 0.1, + display: '${(settings.bgOpacity * 100).round()}%', + onChanged: (v) => + widget.onSubtitleSettingsChanged(settings.copyWith(bgOpacity: v)), + ), + ]; + } + + String _activeSubtitleLabel() { + final index = _activeStreamIndex; + if (index == null) return '禁用'; + final track = widget.subtitles + .where((subtitle) => subtitle.index == index) + .firstOrNull; + if (track == null) return '字幕'; + return track.title ?? track.language ?? '字幕 ${track.index}'; + } + + String _subtitleStyleLabel() { + final settings = widget.subtitleSettings; + return '${settings.fontSize.round()} / ${settings.delay >= 0 ? '+' : ''}${settings.delay.toStringAsFixed(1)}s'; + } +} + +class AudioPanelBody extends StatefulWidget { + final PlayerEngine player; + final void Function(AudioTrack track)? onAudioTrackSelected; + + const AudioPanelBody({ + super.key, + required this.player, + this.onAudioTrackSelected, + }); + + @override + State createState() => _AudioPanelBodyState(); +} + +class _AudioPanelBodyState extends State { + AudioTrack _activeAudio = const AudioTrack.auto(); + List _audioTracks = const []; + StreamSubscription? _trackSub; + StreamSubscription? _tracksSub; + + @override + void initState() { + super.initState(); + _syncFromPlayer(widget.player); + _subscribePlayer(widget.player); + } + + @override + void didUpdateWidget(covariant AudioPanelBody oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.player != widget.player) { + _cancelSubscriptions(); + _syncFromPlayer(widget.player); + _subscribePlayer(widget.player); + } + } + + @override + void dispose() { + _cancelSubscriptions(); + super.dispose(); + } + + void _syncFromPlayer(PlayerEngine player) { + _activeAudio = player.state.track.audio; + _audioTracks = player.state.tracks.embeddedAudio; + } + + void _subscribePlayer(PlayerEngine player) { + _trackSub = player.stream.track.listen((track) { + if (!mounted) return; + setState(() => _activeAudio = track.audio); + }); + _tracksSub = player.stream.tracks.listen((tracks) { + if (!mounted) return; + setState(() => _audioTracks = tracks.embeddedAudio); + }); + } + + void _cancelSubscriptions() { + unawaited(_trackSub?.cancel()); + unawaited(_tracksSub?.cancel()); + _trackSub = null; + _tracksSub = null; + } + + Future _selectAudio(AudioTrack track) async { + if (!mounted) return; + setState(() => _activeAudio = track); + await WidgetsBinding.instance.endOfFrame; + if (!mounted) return; + try { + await widget.player.setAudioTrack(track); + widget.onAudioTrackSelected?.call(track); + } catch (_) { + if (mounted) { + setState(() => _activeAudio = widget.player.state.track.audio); + } + } + } + + @override + Widget build(BuildContext context) { + return _audioList(); + } + + Widget _audioList() { + final entries = >[ + for (final t in _audioTracks) + SelectionPanelEntry.item( + value: t, + label: t.title ?? t.language ?? '音频 ${t.id}', + subtitle: _translateLanguage(t.language) ?? t.language, + isActive: _activeAudio.id == t.id, + ), + if (_audioTracks.isNotEmpty) const SelectionPanelEntry.divider(), + SelectionPanelEntry.item( + value: const AudioTrack.no(), + label: '关闭音频', + isActive: _activeAudio.id == 'no', + ), + ]; + return PanelSelectionList( + entries: entries, + onSelected: (track) => unawaited(_selectAudio(track)), + ); + } +} diff --git a/lib/features/player/widgets/player_clock_text.dart b/lib/features/player/widgets/player_clock_text.dart new file mode 100644 index 0000000..3f74431 --- /dev/null +++ b/lib/features/player/widgets/player_clock_text.dart @@ -0,0 +1,72 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + + +class PlayerClockText extends StatefulWidget { + const PlayerClockText({super.key, this.style}); + + final TextStyle? style; + + @override + State createState() => _PlayerClockTextState(); +} + +class _PlayerClockTextState extends State { + Timer? _timer; + late String _text; + + @override + void initState() { + super.initState(); + _text = formatClock(DateTime.now()); + _scheduleNextTick(); + } + + void _scheduleNextTick() { + final now = DateTime.now(); + final next = DateTime( + now.year, + now.month, + now.day, + now.hour, + now.minute, + ).add(const Duration(minutes: 1)); + _timer = Timer(next.difference(now), _tick); + } + + void _tick() { + if (!mounted) return; + setState(() => _text = formatClock(DateTime.now())); + _scheduleNextTick(); + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Text( + _text, + style: + widget.style ?? + const TextStyle( + color: Colors.white70, + fontSize: 12, + height: 1.0, + letterSpacing: 0.5, + fontFeatures: [FontFeature.tabularFigures()], + ), + ); + } +} + + +String formatClock(DateTime t) { + final h = t.hour.toString().padLeft(2, '0'); + final m = t.minute.toString().padLeft(2, '0'); + return '$h:$m'; +} diff --git a/lib/features/player/widgets/player_controls_chrome.dart b/lib/features/player/widgets/player_controls_chrome.dart new file mode 100644 index 0000000..115aa7d --- /dev/null +++ b/lib/features/player/widgets/player_controls_chrome.dart @@ -0,0 +1,161 @@ +import 'package:flutter/material.dart'; +import 'package:window_manager/window_manager.dart'; + +import '../../../shared/theme/app_theme.dart'; +import '../../../shared/widgets/top_drag_area.dart'; + +class ControlsChrome extends StatelessWidget { + final bool visible; + final List topBar; + final double topBarLeftInset; + final double topBarTopInset; + final Widget bottomBar; + final Widget? centerBar; + final VoidCallback? onControlsAreaTap; + final ValueChanged? onControlsHoverChanged; + final Widget? leftBar; + final Widget? rightBar; + + + final bool isWindowed; + + + final bool isPip; + + const ControlsChrome({ + super.key, + required this.visible, + required this.topBar, + this.topBarLeftInset = 8, + this.topBarTopInset = 8, + required this.bottomBar, + this.centerBar, + this.leftBar, + this.rightBar, + required this.onControlsAreaTap, + this.onControlsHoverChanged, + this.isWindowed = false, + this.isPip = false, + }); + + Widget _withControlsAreaTap(Widget child) { + final onTap = onControlsAreaTap; + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap ?? () {}, + child: child, + ); + } + + + Widget _withTopDragHandle(Widget child) { + if (isPip) { + return DragToMoveArea(child: child); + } + return TopDragArea(enabled: isWindowed, child: child); + } + + @override + Widget build(BuildContext context) { + final tokens = context.appTokens; + return IgnorePointer( + ignoring: !visible, + child: AnimatedOpacity( + opacity: visible ? 1 : 0, + duration: const Duration(milliseconds: 160), + child: Stack( + fit: StackFit.expand, + children: [ + Positioned( + left: 0, + top: 0, + right: 0, + child: MouseRegion( + onEnter: (_) => onControlsHoverChanged?.call(true), + onExit: (_) => onControlsHoverChanged?.call(false), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () {}, + child: DecoratedBox( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.black87, Colors.transparent], + ), + ), + child: _withTopDragHandle( + Padding( + padding: EdgeInsets.fromLTRB( + topBarLeftInset, + topBarTopInset, + tokens.chromeInset, + isPip ? 12 : 28, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: topBar, + ), + ), + ), + ), + ), + ), + ), + Positioned( + left: 0, + right: 0, + bottom: 0, + child: MouseRegion( + onEnter: (_) => onControlsHoverChanged?.call(true), + onExit: (_) => onControlsHoverChanged?.call(false), + child: _withControlsAreaTap( + Container( + padding: EdgeInsets.fromLTRB( + 0, + isPip ? 8 : 20, + 0, + isPip ? 6 : 8, + ), + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + colors: [Colors.black87, Colors.transparent], + ), + ), + child: bottomBar, + ), + ), + ), + ), + if (leftBar != null) + Positioned( + left: 28, + top: 0, + bottom: 0, + child: Center(child: leftBar!), + ), + if (rightBar != null) + Positioned( + right: 16, + top: 0, + bottom: 0, + child: Center(child: rightBar!), + ), + if (centerBar != null) + Positioned.fill( + child: Center( + child: MouseRegion( + onEnter: (_) => onControlsHoverChanged?.call(true), + onExit: (_) => onControlsHoverChanged?.call(false), + child: centerBar!, + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/player/widgets/player_debug_hud.dart b/lib/features/player/widgets/player_debug_hud.dart new file mode 100644 index 0000000..20abde0 --- /dev/null +++ b/lib/features/player/widgets/player_debug_hud.dart @@ -0,0 +1,130 @@ +import 'package:flutter/material.dart'; + +import '../../../core/infra/player_engine/player_engine.dart'; +import '../services/frame_jank_monitor.dart'; + + +const int _kLowBufferLeadMs = 1000; + + +class PlayerDebugHud extends StatelessWidget { + const PlayerDebugHud({ + super.key, + required this.engine, + required this.frameMonitor, + }); + + final PlayerEngine engine; + final FrameJankMonitor frameMonitor; + + @override + Widget build(BuildContext context) { + return Align( + alignment: Alignment.topLeft, + child: Padding( + padding: const EdgeInsets.only(top: 56, left: 12), + child: IgnorePointer( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.62), + borderRadius: BorderRadius.circular(8), + ), + child: DefaultTextStyle( + style: const TextStyle( + color: Colors.white, + fontSize: 12, + height: 1.5, + fontFamily: 'monospace', + fontFamilyFallback: ['Consolas', 'Menlo', 'monospace'], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'DEBUG', + style: TextStyle( + color: Colors.lightGreenAccent, + fontSize: 11, + fontWeight: FontWeight.bold, + letterSpacing: 1.5, + ), + ), + Text('engine: ${engine.displayName}'), + ValueListenableBuilder( + valueListenable: engine.debugStats, + builder: (context, stats, _) => _StatsBlock(stats: stats), + ), + ValueListenableBuilder( + valueListenable: frameMonitor.diag, + builder: (context, diag, _) => _FrameBlock(diag: diag), + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +class _StatsBlock extends StatelessWidget { + const _StatsBlock({required this.stats}); + + final PlaybackStats? stats; + + @override + Widget build(BuildContext context) { + final s = stats; + if (s == null) { + return const Text('engine: (no stats yet)'); + } + final low = s.bufferLeadMs < _kLowBufferLeadMs; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text('decoder: ${s.decoder} (hwdec=${s.hwdec})'), + Text('isDV: ${s.isDolbyVision} rate: ${s.rate}x'), + Text('bitrate: ${s.bitrateMbps.toStringAsFixed(1)} Mbps'), + Text( + 'bufferLead: ${s.bufferLeadMs} ms', + style: TextStyle( + color: low ? Colors.orangeAccent : Colors.white, + fontWeight: low ? FontWeight.bold : FontWeight.normal, + ), + ), + ], + ); + } +} + +class _FrameBlock extends StatelessWidget { + const _FrameBlock({required this.diag}); + + final FrameDiag diag; + + @override + Widget build(BuildContext context) { + final rasterHot = diag.lastRasterMs >= 32; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 4), + Text('fps: ${diag.fps.toStringAsFixed(0)}'), + Text( + 'raster: ${diag.lastRasterMs.toStringAsFixed(1)} ms', + style: TextStyle( + color: rasterHot ? Colors.orangeAccent : Colors.white, + fontWeight: rasterHot ? FontWeight.bold : FontWeight.normal, + ), + ), + Text('build: ${diag.lastBuildMs.toStringAsFixed(1)} ms'), + Text('jank frames: ${diag.jankCount}'), + ], + ); + } +} diff --git a/lib/features/player/widgets/player_drawer_panel.dart b/lib/features/player/widgets/player_drawer_panel.dart new file mode 100644 index 0000000..79fe3dd --- /dev/null +++ b/lib/features/player/widgets/player_drawer_panel.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; + +import 'player_panel_shell.dart'; + +class PlayerDrawerPanel extends StatelessWidget { + final String title; + final VoidCallback onBack; + final Widget child; + + const PlayerDrawerPanel({ + super.key, + required this.title, + required this.onBack, + required this.child, + }); + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _header(), + panelDivider(), + Flexible( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: child, + ), + ), + ], + ), + ); + } + + Widget _header() { + return Padding( + padding: const EdgeInsets.fromLTRB(4, 4, 12, 4), + child: Row( + children: [ + IconButton( + icon: const Icon( + Icons.arrow_back_ios_new, + color: Colors.white70, + size: 15, + ), + onPressed: onBack, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 36, minHeight: 36), + tooltip: '返回', + ), + Text( + title, + style: const TextStyle( + color: Colors.white, + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/player/widgets/player_hold_speed_prompt.dart b/lib/features/player/widgets/player_hold_speed_prompt.dart new file mode 100644 index 0000000..b76e103 --- /dev/null +++ b/lib/features/player/widgets/player_hold_speed_prompt.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; + +import 'speed_button.dart'; + +const holdSpeedPromptBackgroundColor = Color(0x45181818); + +class HoldSpeedPrompt extends StatelessWidget { + final bool visible; + final bool isLeft; + final double rate; + + const HoldSpeedPrompt({ + super.key, + required this.visible, + required this.isLeft, + required this.rate, + }); + + @override + Widget build(BuildContext context) { + final label = isLeft + ? '${SpeedButton.formatRate(rate)} 慢放中' + : '${SpeedButton.formatRate(rate)} 快进中'; + return Positioned( + top: 0, + left: 0, + right: 0, + child: SafeArea( + child: IgnorePointer( + child: AnimatedOpacity( + opacity: visible ? 1 : 0, + duration: const Duration(milliseconds: 160), + child: Padding( + padding: const EdgeInsets.only(top: 64), + child: Center( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + decoration: BoxDecoration( + color: holdSpeedPromptBackgroundColor, + borderRadius: BorderRadius.circular(999), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + isLeft ? Icons.fast_rewind : Icons.fast_forward, + color: Colors.white, + size: 18, + ), + const SizedBox(width: 6), + Text( + label, + style: const TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w500, + fontFeatures: [FontFeature.tabularFigures()], + ), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/player/widgets/player_panel_shell.dart b/lib/features/player/widgets/player_panel_shell.dart new file mode 100644 index 0000000..c16c3ef --- /dev/null +++ b/lib/features/player/widgets/player_panel_shell.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; +import 'package:forui/forui.dart'; + +const kPanelBg = Color(0xE8181818); +const kPanelDivider = Color(0x33FFFFFF); + +Widget panelDivider() => const FDivider( + style: .delta(color: kPanelDivider, padding: .value(EdgeInsets.zero)), +); + +class PlayerPanelShell extends StatelessWidget { + final IconData icon; + final String title; + final double width; + final VoidCallback onClose; + final Widget child; + + const PlayerPanelShell({ + super.key, + required this.icon, + required this.title, + required this.width, + required this.onClose, + required this.child, + }); + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: Container( + width: width, + decoration: BoxDecoration( + color: kPanelBg, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _header(), + panelDivider(), + Flexible(child: child), + ], + ), + ), + ); + } + + Widget _header() { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 8, 10), + child: Row( + children: [ + Icon(icon, color: Colors.white70, size: 18), + const SizedBox(width: 8), + Text( + title, + style: const TextStyle( + color: Colors.white, + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + const Spacer(), + IconButton( + icon: const Icon(Icons.close, color: Colors.white54, size: 18), + onPressed: onClose, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 32, minHeight: 32), + ), + ], + ), + ); + } +} diff --git a/lib/features/player/widgets/player_play_pause_button.dart b/lib/features/player/widgets/player_play_pause_button.dart new file mode 100644 index 0000000..1094e1b --- /dev/null +++ b/lib/features/player/widgets/player_play_pause_button.dart @@ -0,0 +1,78 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../../../core/infra/player_engine/player_engine.dart'; + +class PlayerPlayPauseButton extends StatefulWidget { + final PlayerEngine player; + + + final double? iconSize; + + + final BoxConstraints? constraints; + + const PlayerPlayPauseButton({ + super.key, + required this.player, + this.iconSize, + this.constraints, + }); + + @override + State createState() => _PlayerPlayPauseButtonState(); +} + +class _PlayerPlayPauseButtonState extends State { + late bool _playing; + StreamSubscription? _sub; + + @override + void initState() { + super.initState(); + _playing = widget.player.state.playing; + _sub = widget.player.stream.playing.listen((playing) { + if (mounted) setState(() => _playing = playing); + }); + } + + @override + void didUpdateWidget(covariant PlayerPlayPauseButton oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.player != widget.player) { + unawaited(_sub?.cancel()); + _playing = widget.player.state.playing; + _sub = widget.player.stream.playing.listen((playing) { + if (mounted) setState(() => _playing = playing); + }); + } + } + + @override + void dispose() { + unawaited(_sub?.cancel()); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return IconButton( + tooltip: _playing ? '暂停' : '播放', + constraints: widget.constraints, + padding: widget.constraints != null ? EdgeInsets.zero : null, + onPressed: () { + if (_playing) { + unawaited(widget.player.pause()); + } else { + unawaited(widget.player.play()); + } + }, + icon: Icon( + _playing ? Icons.pause : Icons.play_arrow, + color: Colors.white, + size: widget.iconSize ?? 30, + ), + ); + } +} diff --git a/lib/features/player/widgets/player_position_indicator.dart b/lib/features/player/widgets/player_position_indicator.dart new file mode 100644 index 0000000..6fa4665 --- /dev/null +++ b/lib/features/player/widgets/player_position_indicator.dart @@ -0,0 +1,99 @@ +import 'dart:async'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; + +import '../../../core/infra/player_engine/player_engine.dart'; +import '../../../shared/utils/format_utils.dart'; + +class PlayerPositionIndicator extends StatefulWidget { + final PlayerEngine player; + + + final ValueNotifier? seekPreview; + + const PlayerPositionIndicator({ + super.key, + required this.player, + this.seekPreview, + }); + + @override + State createState() => + _PlayerPositionIndicatorState(); +} + +class _PlayerPositionIndicatorState extends State { + late Duration _position; + late Duration _duration; + StreamSubscription? _posSub; + StreamSubscription? _durSub; + + @override + void initState() { + super.initState(); + _position = widget.player.state.position; + _duration = widget.player.state.duration; + _posSub = widget.player.stream.position.listen((position) { + if (mounted) setState(() => _position = position); + }); + _durSub = widget.player.stream.duration.listen((duration) { + if (mounted) setState(() => _duration = duration); + }); + } + + @override + void didUpdateWidget(covariant PlayerPositionIndicator oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.player != widget.player) { + unawaited(_posSub?.cancel()); + unawaited(_durSub?.cancel()); + _position = widget.player.state.position; + _duration = widget.player.state.duration; + _posSub = widget.player.stream.position.listen((position) { + if (mounted) setState(() => _position = position); + }); + _durSub = widget.player.stream.duration.listen((duration) { + if (mounted) setState(() => _duration = duration); + }); + } + } + + @override + void dispose() { + unawaited(_posSub?.cancel()); + unawaited(_durSub?.cancel()); + super.dispose(); + } + + Widget _buildText(Duration position, Duration duration) { + return SizedBox( + width: 126, + child: Text( + '${formatDurationClock(position)} / ${formatDurationClock(duration)}', + textAlign: TextAlign.center, + style: const TextStyle( + color: Colors.white70, + fontSize: 13, + fontFeatures: [ui.FontFeature.tabularFigures()], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final seekPreview = widget.seekPreview; + if (seekPreview != null) { + return ValueListenableBuilder( + valueListenable: seekPreview, + builder: (context, preview, _) { + final displayPos = preview ?? _position; + return _buildText(displayPos, _duration); + }, + ); + } + return _buildText(_position, _duration); + } + +} diff --git a/lib/features/player/widgets/player_status_scaffold.dart b/lib/features/player/widgets/player_status_scaffold.dart new file mode 100644 index 0000000..5619c61 --- /dev/null +++ b/lib/features/player/widgets/player_status_scaffold.dart @@ -0,0 +1,249 @@ +import 'dart:ui' show ImageFilter; + +import 'package:flutter/material.dart'; +import 'package:forui/forui.dart'; + +import '../../../shared/widgets/app_loading_ring.dart'; +import '../../../shared/widgets/smart_image.dart'; +import '../../../shared/widgets/top_drag_area.dart'; +import '../../../shared/widgets/window_controls.dart'; + + +class PlayerLoadingBackdrop extends StatelessWidget { + const PlayerLoadingBackdrop({super.key, this.backdropUrl}); + + final String? backdropUrl; + + @override + Widget build(BuildContext context) { + final url = backdropUrl; + if (url == null || url.isEmpty) { + return const Positioned.fill(child: ColoredBox(color: Colors.black)); + } + + return Positioned.fill( + child: Stack( + fit: StackFit.expand, + children: [ + const ColoredBox(color: Colors.black), + RepaintBoundary( + child: Transform.scale( + scale: 1.08, + child: ImageFiltered( + imageFilter: ImageFilter.blur(sigmaX: 24, sigmaY: 24), + child: SmartImage( + url: url, + fit: BoxFit.cover, + borderRadius: 0, + fallbackIcon: null, + memCacheWidth: 480, + placeholder: const ColoredBox(color: Colors.black), + ), + ), + ), + ), + const ColoredBox(color: Colors.black54), + ], + ), + ); + } +} + + +class PlayerLoadingIndicator extends StatelessWidget { + const PlayerLoadingIndicator({super.key}); + + @override + Widget build(BuildContext context) { + return const Center(child: AppLoadingRing(size: 44, color: Colors.white)); + } +} + + +class PlayerStatusTopBar extends StatelessWidget { + const PlayerStatusTopBar({ + super.key, + required this.title, + required this.onBack, + required this.topInset, + required this.leftInset, + required this.showWindowControls, + this.enableWindowDrag = false, + }); + + + final String title; + final VoidCallback onBack; + final double topInset; + final double leftInset; + final bool showWindowControls; + + + final bool enableWindowDrag; + + @override + Widget build(BuildContext context) { + final trimmed = title.trim(); + final shown = trimmed.isEmpty ? '播放器' : trimmed; + + final Widget bar = SafeArea( + bottom: false, + child: Row( + children: [ + IconButton( + onPressed: onBack, + icon: const Icon(Icons.arrow_back, color: Colors.white), + tooltip: '返回', + ), + const SizedBox(width: 4), + Expanded( + child: Text( + shown, + style: const TextStyle(color: Colors.white, fontSize: 15), + overflow: TextOverflow.ellipsis, + ), + ), + if (showWindowControls) const WindowControls.forDarkSurface(), + ], + ), + ); + + return Positioned( + top: topInset, + left: leftInset, + right: 12, + child: enableWindowDrag ? TopDragArea(child: bar) : bar, + ); + } +} + + +class PlayerErrorCard extends StatelessWidget { + const PlayerErrorCard({ + super.key, + required this.message, + required this.onBack, + required this.onRetry, + }); + + final String message; + final VoidCallback onBack; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 480), + child: Material( + color: const Color(0xFF1C1F26), + borderRadius: BorderRadius.circular(16), + elevation: 8, + shadowColor: Colors.black, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(24, 20, 12, 0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: const Color( + 0xFFFFB020, + ).withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon( + Icons.error_outline, + color: Color(0xFFFFC46B), + size: 22, + ), + ), + const SizedBox(width: 14), + const Expanded( + child: Text( + '无法播放此媒体', + style: TextStyle( + color: Colors.white, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), + ), + IconButton( + onPressed: onBack, + icon: const Icon(Icons.close, color: Colors.white70), + tooltip: '关闭播放器', + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(24, 12, 24, 0), + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 140), + child: SingleChildScrollView( + child: SelectableText( + message, + style: const TextStyle( + color: Colors.white70, + fontSize: 13, + height: 1.45, + ), + ), + ), + ), + ), + const FDivider( + style: .delta( + color: Colors.white12, + padding: .value(EdgeInsets.only(top: 20)), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton.icon( + onPressed: onBack, + icon: const Icon(Icons.arrow_back, size: 18), + label: const Text('返回详情'), + style: TextButton.styleFrom( + foregroundColor: Colors.white70, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(6), + ), + ), + ), + const SizedBox(width: 10), + FilledButton.icon( + onPressed: onRetry, + icon: const Icon(Icons.refresh, size: 18), + label: const Text('重试'), + style: FilledButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: Colors.black, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(6), + ), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/player/widgets/player_video_widget.dart b/lib/features/player/widgets/player_video_widget.dart new file mode 100644 index 0000000..3da8f05 --- /dev/null +++ b/lib/features/player/widgets/player_video_widget.dart @@ -0,0 +1,702 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:window_manager/window_manager.dart'; + +import '../../../core/infra/player_engine/player_engine.dart'; +import '../gestures/gesture_action.dart'; +import '../gestures/gesture_bindings.dart'; +import 'player_controls_chrome.dart'; +import 'player_hold_speed_prompt.dart'; + +class PlayerVideoWidget extends StatefulWidget { + final PlayerEngine player; + final ValueListenable fit; + final List topBar; + final Widget bottomBar; + final Widget? centerBar; + final List overlays; + final VoidCallback? onControlsAreaTap; + final bool isFullscreen; + final Future Function() onToggleFullscreen; + final bool isPip; + final Future Function()? onTogglePip; + final double topBarLeftInset; + final double topBarTopInset; + final bool lockControls; + final bool keyboardEnabled; + final int seekForwardSeconds; + final int seekBackwardSeconds; + final bool volumeBoost; + + final GestureBindings gestureBindings; + + final double currentRate; + final ValueChanged? onSetRate; + final VoidCallback? onPrevItem; + final VoidCallback? onNextItem; + final VoidCallback? onExit; + + + final void Function(Offset globalPosition)? onSecondaryContextMenu; + + + final Widget Function(VoidCallback toggleControls, VoidCallback showControls)? + mobileGestureBuilder; + + + final Widget? gestureFeedback; + + + final bool suppressControlsChrome; + + + final Widget? leftBar; + + + final Widget? rightBar; + + const PlayerVideoWidget({ + super.key, + required this.player, + required this.fit, + required this.topBar, + required this.bottomBar, + this.centerBar, + this.overlays = const [], + this.onControlsAreaTap, + required this.isFullscreen, + required this.onToggleFullscreen, + this.isPip = false, + this.onTogglePip, + this.topBarLeftInset = 8, + this.topBarTopInset = 8, + this.lockControls = false, + this.keyboardEnabled = true, + this.seekForwardSeconds = 15, + this.seekBackwardSeconds = 15, + this.volumeBoost = false, + this.gestureBindings = GestureBindings.defaults, + this.currentRate = 1.0, + this.onSetRate, + this.onPrevItem, + this.onNextItem, + this.onExit, + this.onSecondaryContextMenu, + this.mobileGestureBuilder, + this.gestureFeedback, + this.suppressControlsChrome = false, + this.leftBar, + this.rightBar, + }); + + @override + State createState() => _PlayerVideoWidgetState(); +} + +class _PlayerVideoWidgetState extends State { + static const double _volumeStep = 5; + static const double _pipResizeEdgeSize = 10; + static const List _pipResizeEdges = [ + ResizeEdge.topLeft, + ResizeEdge.top, + ResizeEdge.topRight, + ResizeEdge.left, + ResizeEdge.right, + ResizeEdge.bottomLeft, + ResizeEdge.bottom, + ResizeEdge.bottomRight, + ]; + + bool _controlsVisible = true; + bool _cursorVisible = true; + bool _hoveringControls = false; + Timer? _controlsHideTimer; + Timer? _cursorHideTimer; + StreamSubscription? _playingSub; + Offset? _lastMousePosition; + + bool _holdingLeft = false; + bool _holdingRight = false; + double? _savedRateBeforeHold; + + bool _holdSpeedPromptVisible = false; + bool _holdSpeedPromptIsLeft = false; + double _holdSpeedPromptRate = 1.0; + + Timer? _pendingSeekLeft; + Timer? _pendingSeekRight; + static const _arrowHoldThreshold = Duration(milliseconds: 200); + + + static const _topHoverTrigger = 120.0; + static const _bottomHoverTrigger = 180.0; + + Timer? _secondaryTapTimer; + Offset? _secondaryTapDownPos; + + @override + void initState() { + super.initState(); + _playingSub = widget.player.stream.playing.listen((playing) { + if (!mounted) return; + if (playing) { + _scheduleHide(); + _scheduleHideCursor(); + } else { + _showControls(lock: true); + _showCursor(lock: true); + } + }); + if (widget.player.state.playing) { + _scheduleHide(); + _scheduleHideCursor(); + } + } + + @override + void didUpdateWidget(covariant PlayerVideoWidget oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.player != widget.player) { + unawaited(_playingSub?.cancel()); + _playingSub = widget.player.stream.playing.listen((playing) { + if (!mounted) return; + if (playing) { + _scheduleHide(); + _scheduleHideCursor(); + } else { + _showControls(lock: true); + _showCursor(lock: true); + } + }); + } + if (oldWidget.lockControls != widget.lockControls) { + if (widget.lockControls) { + _controlsHideTimer?.cancel(); + _cursorHideTimer?.cancel(); + _showControls(lock: true); + _showCursor(lock: true); + } else if (widget.player.state.playing) { + _scheduleHide(); + _scheduleHideCursor(); + } + } + if (oldWidget.keyboardEnabled && !widget.keyboardEnabled) { + _cancelPendingArrowSeeks(); + _restoreHoldRateIfNeeded(); + } + } + + @override + void dispose() { + _controlsHideTimer?.cancel(); + _cursorHideTimer?.cancel(); + _secondaryTapTimer?.cancel(); + _cancelPendingArrowSeeks(); + _restoreHoldRateIfNeeded(); + unawaited(_playingSub?.cancel()); + super.dispose(); + } + + void _cancelPendingArrowSeeks() { + _pendingSeekLeft?.cancel(); + _pendingSeekLeft = null; + _pendingSeekRight?.cancel(); + _pendingSeekRight = null; + } + + void _showControls({bool lock = false}) { + _controlsHideTimer?.cancel(); + if (!_controlsVisible && mounted) { + setState(() => _controlsVisible = true); + } + if (!lock && widget.player.state.playing) { + _scheduleHide(); + } + } + + void _scheduleHide() { + _controlsHideTimer?.cancel(); + if (_hoveringControls || widget.lockControls) return; + _controlsHideTimer = Timer(const Duration(seconds: 3), () { + if (mounted && + widget.player.state.playing && + !_hoveringControls && + !widget.lockControls) { + setState(() => _controlsVisible = false); + } + }); + } + + + void _showCursor({bool lock = false}) { + _cursorHideTimer?.cancel(); + if (!_cursorVisible && mounted) { + setState(() => _cursorVisible = true); + } + if (!lock && widget.player.state.playing) { + _scheduleHideCursor(); + } + } + + void _scheduleHideCursor() { + _cursorHideTimer?.cancel(); + if (_hoveringControls || widget.lockControls) return; + _cursorHideTimer = Timer(const Duration(seconds: 3), () { + if (mounted && + widget.player.state.playing && + !_hoveringControls && + !widget.lockControls) { + setState(() => _cursorVisible = false); + } + }); + } + + bool _inEdgeZone(double localY, double viewHeight) => + localY <= _topHoverTrigger || localY >= viewHeight - _bottomHoverTrigger; + + Future _togglePlayPause() async { + if (widget.player.state.playing) { + await widget.player.pause(); + } else { + await widget.player.play(); + } + } + + KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { + if (!widget.keyboardEnabled) return KeyEventResult.ignored; + final key = event.logicalKey; + + if (key == LogicalKeyboardKey.arrowLeft || + key == LogicalKeyboardKey.arrowRight) { + return _handleArrowKey(event, key == LogicalKeyboardKey.arrowLeft); + } + + final isRepeat = event is KeyRepeatEvent; + if (event is! KeyDownEvent && !isRepeat) return KeyEventResult.ignored; + if (key == LogicalKeyboardKey.space) { + if (isRepeat) return KeyEventResult.handled; + unawaited(_togglePlayPause()); + _showControls(); + return KeyEventResult.handled; + } + if (key == LogicalKeyboardKey.arrowUp) { + if (Platform.isAndroid) return KeyEventResult.ignored; + final maxVol = widget.volumeBoost ? 150.0 : 100.0; + final next = (widget.player.state.volume + _volumeStep).clamp( + 0.0, + maxVol, + ); + unawaited(widget.player.setVolume(next)); + _showControls(); + return KeyEventResult.handled; + } + if (key == LogicalKeyboardKey.arrowDown) { + if (Platform.isAndroid) return KeyEventResult.ignored; + final maxVol = widget.volumeBoost ? 150.0 : 100.0; + final next = (widget.player.state.volume - _volumeStep).clamp( + 0.0, + maxVol, + ); + unawaited(widget.player.setVolume(next)); + _showControls(); + return KeyEventResult.handled; + } + if (key == LogicalKeyboardKey.keyF || + (key == LogicalKeyboardKey.escape && widget.isFullscreen)) { + if (isRepeat) return KeyEventResult.handled; + unawaited(widget.onToggleFullscreen()); + _showControls(); + return KeyEventResult.handled; + } + if (key == LogicalKeyboardKey.keyP) { + if (isRepeat) return KeyEventResult.handled; + final togglePip = widget.onTogglePip; + if (togglePip != null) { + unawaited(togglePip()); + _showControls(); + return KeyEventResult.handled; + } + } + if (key == LogicalKeyboardKey.escape && widget.isPip) { + if (isRepeat) return KeyEventResult.handled; + final togglePip = widget.onTogglePip; + if (togglePip != null) { + unawaited(togglePip()); + _showControls(); + return KeyEventResult.handled; + } + } + return KeyEventResult.ignored; + } + + KeyEventResult _handleArrowKey(KeyEvent event, bool isLeft) { + final seekSeconds = isLeft + ? -widget.seekBackwardSeconds + : widget.seekForwardSeconds; + + if (event is KeyDownEvent) { + if (isLeft) { + _pendingSeekLeft?.cancel(); + _pendingSeekLeft = Timer(_arrowHoldThreshold, () { + _pendingSeekLeft = null; + if (!mounted) return; + _enterHoldSpeed(isLeft: true); + }); + } else { + _pendingSeekRight?.cancel(); + _pendingSeekRight = Timer(_arrowHoldThreshold, () { + _pendingSeekRight = null; + if (!mounted) return; + _enterHoldSpeed(isLeft: false); + }); + } + return KeyEventResult.handled; + } + if (event is KeyRepeatEvent) { + return KeyEventResult.handled; + } + if (event is KeyUpEvent) { + final pending = isLeft ? _pendingSeekLeft : _pendingSeekRight; + if (pending != null) { + pending.cancel(); + if (isLeft) { + _pendingSeekLeft = null; + } else { + _pendingSeekRight = null; + } + _seekBy(seekSeconds); + _showControls(); + } + final wasHolding = isLeft ? _holdingLeft : _holdingRight; + if (wasHolding) { + if (isLeft) { + _holdingLeft = false; + } else { + _holdingRight = false; + } + if (!_holdingLeft && !_holdingRight) { + _hideHoldSpeedPrompt(); + final restore = _savedRateBeforeHold ?? widget.currentRate; + _savedRateBeforeHold = null; + widget.onSetRate?.call(restore); + } + } + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + + void _enterHoldSpeed({required bool isLeft}) { + final holding = isLeft ? _holdingLeft : _holdingRight; + if (holding) return; + + final holdSpeed = isLeft + ? widget.gestureBindings.keyboardHoldLeftSpeed + : widget.gestureBindings.keyboardHoldRightSpeed; + _savedRateBeforeHold ??= widget.currentRate; + widget.onSetRate?.call(holdSpeed); + if (isLeft) { + _holdingLeft = true; + } else { + _holdingRight = true; + } + _showHoldSpeedPrompt(isLeft: isLeft, rate: holdSpeed); + } + + void _showHoldSpeedPrompt({required bool isLeft, required double rate}) { + if (!mounted) return; + setState(() { + _holdSpeedPromptVisible = true; + _holdSpeedPromptIsLeft = isLeft; + _holdSpeedPromptRate = rate; + }); + } + + void _hideHoldSpeedPrompt() { + if (!_holdSpeedPromptVisible) return; + if (!mounted) { + _holdSpeedPromptVisible = false; + return; + } + setState(() => _holdSpeedPromptVisible = false); + } + + void _restoreHoldRateIfNeeded() { + if (!_holdingLeft && !_holdingRight) return; + _holdingLeft = false; + _holdingRight = false; + _hideHoldSpeedPrompt(); + final restore = _savedRateBeforeHold; + _savedRateBeforeHold = null; + if (restore != null) widget.onSetRate?.call(restore); + } + + void _seekBy(int seconds) { + final target = widget.player.state.position + Duration(seconds: seconds); + unawaited( + widget.player.seek(target < Duration.zero ? Duration.zero : target), + ); + } + + void _toggleControlsVisibility() { + if (_controlsVisible) { + setState(() => _controlsVisible = false); + _controlsHideTimer?.cancel(); + } else { + _showControls(); + _showCursor(); + } + } + + void _handleSecondaryTap() { + final contextMenu = widget.onSecondaryContextMenu; + if (contextMenu != null) { + contextMenu(_secondaryTapDownPos ?? Offset.zero); + return; + } + if (widget.gestureBindings.mouseRightDoubleClickAction == + GestureAction.none) { + _dispatchGesture(GestureKind.rightClick); + return; + } + final pending = _secondaryTapTimer; + if (pending != null && pending.isActive) { + pending.cancel(); + _secondaryTapTimer = null; + _dispatchGesture(GestureKind.rightDoubleClick); + return; + } + _secondaryTapTimer = Timer(kDoubleTapTimeout, () { + _secondaryTapTimer = null; + if (!mounted) return; + _dispatchGesture(GestureKind.rightClick); + }); + } + + void _dispatchGesture(GestureKind kind) { + final action = widget.gestureBindings.actionFor(kind); + switch (action) { + case GestureAction.none: + return; + case GestureAction.playPause: + unawaited(_togglePlayPause()); + break; + case GestureAction.toggleControls: + _toggleControlsVisibility(); + return; + case GestureAction.fullscreen: + unawaited(widget.onToggleFullscreen()); + break; + case GestureAction.seekBackward: + _seekBy(-widget.seekBackwardSeconds); + break; + case GestureAction.seekForward: + _seekBy(widget.seekForwardSeconds); + break; + case GestureAction.previousItem: + widget.onPrevItem?.call(); + break; + case GestureAction.nextItem: + widget.onNextItem?.call(); + break; + case GestureAction.exit: + widget.onExit?.call(); + break; + } + _showControls(); + _showCursor(); + } + + Widget _withPipResizeAffordance(Widget child) { + if (!widget.isPip) return child; + return DragToResizeArea( + resizeEdgeSize: _pipResizeEdgeSize, + resizeEdgeColor: Colors.transparent, + enableResizeEdges: _pipResizeEdges, + child: child, + ); + } + + @override + Widget build(BuildContext context) { + final isWindowed = !widget.isFullscreen && !widget.isPip; + + return Focus( + autofocus: true, + onKeyEvent: _handleKeyEvent, + child: Stack( + fit: StackFit.expand, + children: [ + Positioned.fill( + child: LayoutBuilder( + builder: (context, constraints) { + final viewHeight = constraints.maxHeight; + + if (widget.mobileGestureBuilder != null) { + return _withPipResizeAffordance( + Stack( + fit: StackFit.expand, + children: [ + PlayerVideoSurface( + player: widget.player, + fit: widget.fit, + ), + widget.mobileGestureBuilder!( + _toggleControlsVisibility, + _showControls, + ), + ...widget.overlays, + if (widget.gestureFeedback != null) + widget.gestureFeedback!, + HoldSpeedPrompt( + visible: _holdSpeedPromptVisible, + isLeft: _holdSpeedPromptIsLeft, + rate: _holdSpeedPromptRate, + ), + ControlsChrome( + visible: + _controlsVisible && + !widget.suppressControlsChrome, + topBar: widget.topBar, + topBarLeftInset: widget.topBarLeftInset, + topBarTopInset: widget.topBarTopInset, + bottomBar: widget.bottomBar, + centerBar: widget.centerBar, + leftBar: widget.leftBar, + rightBar: widget.rightBar, + onControlsAreaTap: widget.onControlsAreaTap, + isWindowed: isWindowed, + isPip: widget.isPip, + onControlsHoverChanged: (hovering) { + _hoveringControls = hovering; + if (!hovering && widget.player.state.playing) { + _scheduleHide(); + } + }, + ), + ], + ), + ); + } else { + return MouseRegion( + cursor: _cursorVisible + ? SystemMouseCursors.basic + : SystemMouseCursors.none, + onEnter: (event) { + _lastMousePosition = event.position; + _showCursor(); + if (widget.isPip || + _inEdgeZone(event.localPosition.dy, viewHeight)) { + _showControls(); + } + }, + onExit: (_) { + _lastMousePosition = null; + }, + onHover: (event) { + final pos = event.position; + final last = _lastMousePosition; + _lastMousePosition = pos; + if (last != null && + _cursorVisible && + _controlsVisible && + (pos - last).distanceSquared < 9) { + return; + } + _showCursor(); + if (widget.isPip || + _inEdgeZone(event.localPosition.dy, viewHeight)) { + _showControls(); + } + }, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: widget.lockControls + ? null + : () => _dispatchGesture(GestureKind.leftClick), + onDoubleTap: + widget.lockControls || + widget + .gestureBindings + .mouseLeftDoubleClickAction == + GestureAction.none + ? null + : () => _dispatchGesture(GestureKind.leftDoubleClick), + onSecondaryTap: widget.lockControls + ? null + : _handleSecondaryTap, + onSecondaryTapDown: widget.lockControls + ? null + : (d) => _secondaryTapDownPos = d.globalPosition, + child: _withPipResizeAffordance( + Stack( + fit: StackFit.expand, + children: [ + PlayerVideoSurface( + player: widget.player, + fit: widget.fit, + ), + ...widget.overlays, + HoldSpeedPrompt( + visible: _holdSpeedPromptVisible, + isLeft: _holdSpeedPromptIsLeft, + rate: _holdSpeedPromptRate, + ), + ControlsChrome( + visible: + _controlsVisible && + !widget.suppressControlsChrome, + topBar: widget.topBar, + topBarLeftInset: widget.topBarLeftInset, + topBarTopInset: widget.topBarTopInset, + bottomBar: widget.bottomBar, + centerBar: widget.centerBar, + leftBar: widget.leftBar, + rightBar: widget.rightBar, + onControlsAreaTap: widget.onControlsAreaTap, + isWindowed: isWindowed, + isPip: widget.isPip, + onControlsHoverChanged: (hovering) { + _hoveringControls = hovering; + if (!hovering && widget.player.state.playing) { + _scheduleHide(); + } + }, + ), + ], + ), + ), + ), + ); + } + }, + ), + ), + ], + ), + ); + } +} + +class PlayerVideoSurface extends StatelessWidget { + final PlayerEngine player; + final ValueListenable fit; + + const PlayerVideoSurface({ + super.key, + required this.player, + required this.fit, + }); + + @override + Widget build(BuildContext context) { + final child = player.buildVideoView(context, fit); + if (player.usesPlatformSurface) return child; + return ColoredBox(color: Colors.black, child: child); + } +} diff --git a/lib/features/player/widgets/player_volume_button.dart b/lib/features/player/widgets/player_volume_button.dart new file mode 100644 index 0000000..65f8107 --- /dev/null +++ b/lib/features/player/widgets/player_volume_button.dart @@ -0,0 +1,103 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../../../core/infra/player_engine/player_engine.dart'; + +class PlayerVolumeButton extends StatefulWidget { + final PlayerEngine player; + final bool volumeBoost; + + const PlayerVolumeButton({ + super.key, + required this.player, + this.volumeBoost = false, + }); + + @override + State createState() => _PlayerVolumeButtonState(); +} + +class _PlayerVolumeButtonState extends State { + late double _volume; + StreamSubscription? _sub; + + @override + void initState() { + super.initState(); + _volume = widget.player.state.volume; + _sub = widget.player.stream.volume.listen((volume) { + if (mounted) setState(() => _volume = volume); + }); + } + + @override + void didUpdateWidget(covariant PlayerVolumeButton oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.player != widget.player) { + unawaited(_sub?.cancel()); + _volume = widget.player.state.volume; + _sub = widget.player.stream.volume.listen((volume) { + if (mounted) setState(() => _volume = volume); + }); + } + } + + @override + void dispose() { + unawaited(_sub?.cancel()); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final muted = _volume <= 0; + final max = widget.volumeBoost ? 150.0 : 100.0; + return SizedBox( + width: 122, + child: Row( + children: [ + IconButton( + tooltip: muted ? '取消静音' : '静音', + onPressed: () => unawaited(widget.player.toggleMute()), + icon: Icon( + muted + ? Icons.volume_off + : (_volume < 45 ? Icons.volume_down : Icons.volume_up), + color: Colors.white, + size: 24, + ), + ), + Expanded( + child: SliderTheme( + data: const SliderThemeData( + trackHeight: 3, + thumbShape: RoundSliderThumbShape(enabledThumbRadius: 6), + overlayShape: RoundSliderOverlayShape(overlayRadius: 12), + activeTrackColor: Colors.white, + inactiveTrackColor: Colors.white24, + thumbColor: Colors.white, + overlayColor: Colors.white10, + showValueIndicator: ShowValueIndicator.onlyForContinuous, + valueIndicatorShape: PaddleSliderValueIndicatorShape(), + valueIndicatorColor: Color(0xCC1E1E1E), + valueIndicatorTextStyle: TextStyle( + color: Colors.white, + fontSize: 12, + fontFeatures: [FontFeature.tabularFigures()], + ), + ), + child: Slider( + value: _volume.clamp(0.0, max), + min: 0, + max: max, + label: '${_volume.round()}', + onChanged: (value) => unawaited(widget.player.setVolume(value)), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/player/widgets/popover/panel_category_meta.dart b/lib/features/player/widgets/popover/panel_category_meta.dart new file mode 100644 index 0000000..46be3ee --- /dev/null +++ b/lib/features/player/widgets/popover/panel_category_meta.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; + +import 'player_panel_controller.dart'; + + +String panelCategoryLabel(PlayerPanelCategory c) { + switch (c) { + case PlayerPanelCategory.subtitle: + return '字幕'; + case PlayerPanelCategory.audio: + return '音轨'; + case PlayerPanelCategory.speed: + return '播放速度'; + case PlayerPanelCategory.fit: + return '画面比例'; + case PlayerPanelCategory.episode: + return '选集'; + case PlayerPanelCategory.danmaku: + return '弹幕'; + } +} + + +IconData panelCategoryIcon(PlayerPanelCategory c) { + switch (c) { + case PlayerPanelCategory.subtitle: + return Icons.subtitles_outlined; + case PlayerPanelCategory.audio: + return Icons.audiotrack; + case PlayerPanelCategory.speed: + return Icons.speed; + case PlayerPanelCategory.fit: + return Icons.aspect_ratio; + case PlayerPanelCategory.episode: + return Icons.format_list_numbered; + case PlayerPanelCategory.danmaku: + return Icons.forum_outlined; + } +} diff --git a/lib/features/player/widgets/popover/player_panel_controller.dart b/lib/features/player/widgets/popover/player_panel_controller.dart new file mode 100644 index 0000000..aaff3f3 --- /dev/null +++ b/lib/features/player/widgets/popover/player_panel_controller.dart @@ -0,0 +1,20 @@ +import 'package:flutter/foundation.dart'; + + +enum PlayerPanelCategory { subtitle, audio, speed, fit, episode, danmaku } + + +class PlayerPanelController extends ValueNotifier { + PlayerPanelController() : super(null); + + bool get isOpen => value != null; + PlayerPanelCategory? get category => value; + + void open(PlayerPanelCategory category) => value = category; + + + void toggle(PlayerPanelCategory category) => + value = (value == category) ? null : category; + + void close() => value = null; +} diff --git a/lib/features/player/widgets/popover/player_panel_popover.dart b/lib/features/player/widgets/popover/player_panel_popover.dart new file mode 100644 index 0000000..3d4849e --- /dev/null +++ b/lib/features/player/widgets/popover/player_panel_popover.dart @@ -0,0 +1,166 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../player_panel_shell.dart'; +import 'panel_category_meta.dart'; +import 'player_panel_controller.dart'; + + +class PlayerPanelPopover extends StatefulWidget { + + final PlayerPanelController controller; + + + final Map anchorLinks; + + + final Set downwardCategories; + + + final Widget Function(PlayerPanelCategory) bodyBuilder; + + + final double width; + + const PlayerPanelPopover({ + super.key, + required this.controller, + required this.anchorLinks, + this.downwardCategories = const {}, + required this.bodyBuilder, + this.width = 340, + }); + + @override + State createState() => _PlayerPanelPopoverState(); +} + +class _PlayerPanelPopoverState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _anim; + late final Animation _t; + late final Animation _scale; + + + PlayerPanelCategory? _visibleCategory; + + + bool _showing = false; + + @override + void initState() { + super.initState(); + _anim = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 180), + reverseDuration: const Duration(milliseconds: 130), + value: widget.controller.value != null ? 1.0 : 0.0, + ); + _t = CurvedAnimation( + parent: _anim, + curve: Curves.easeOutCubic, + reverseCurve: Curves.easeInCubic, + ); + _scale = Tween(begin: 0.96, end: 1.0).animate(_t); + _visibleCategory = widget.controller.value; + _showing = widget.controller.value != null; + _anim.addStatusListener(_onStatus); + widget.controller.addListener(_onController); + } + + void _onController() { + final c = widget.controller.value; + if (c != null) { + setState(() { + _visibleCategory = c; + _showing = true; + }); + _anim.forward(); + } else { + _anim.reverse(); + } + } + + void _onStatus(AnimationStatus status) { + if (status == AnimationStatus.dismissed && mounted && _showing) { + setState(() => _showing = false); + } + } + + @override + void dispose() { + widget.controller.removeListener(_onController); + _anim.removeStatusListener(_onStatus); + _anim.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final category = _visibleCategory; + if (!_showing || category == null) return const SizedBox.shrink(); + final link = widget.anchorLinks[category]; + if (link == null) return const SizedBox.shrink(); + final downward = widget.downwardCategories.contains(category); + final maxH = (MediaQuery.of(context).size.height * 0.55).clamp(0.0, 360.0); + return Stack( + children: [ + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: widget.controller.close, + ), + ), + Positioned( + left: 0, + top: 0, + child: CompositedTransformFollower( + link: link, + targetAnchor: downward ? Alignment.bottomRight : Alignment.topRight, + followerAnchor: downward + ? Alignment.topRight + : Alignment.bottomRight, + offset: Offset(0, downward ? 8 : -8), + showWhenUnlinked: false, + child: FadeTransition( + opacity: _t, + child: ScaleTransition( + scale: _scale, + alignment: downward + ? Alignment.topRight + : Alignment.bottomRight, + child: _bubble(category, maxH), + ), + ), + ), + ), + ], + ); + } + + Widget _bubble(PlayerPanelCategory category, double maxHeight) { + return CallbackShortcuts( + bindings: { + const SingleActivator(LogicalKeyboardKey.escape): + widget.controller.close, + }, + child: Focus( + autofocus: true, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () {}, + child: ConstrainedBox( + constraints: BoxConstraints(maxHeight: maxHeight), + child: PlayerPanelShell( + icon: panelCategoryIcon(category), + title: panelCategoryLabel(category), + width: widget.width, + onClose: widget.controller.close, + child: widget.bodyBuilder(category), + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/player/widgets/quality_badge.dart b/lib/features/player/widgets/quality_badge.dart new file mode 100644 index 0000000..560f4c2 --- /dev/null +++ b/lib/features/player/widgets/quality_badge.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; + +import '../../../core/contracts/library.dart'; + +class QualityBadge extends StatelessWidget { + const QualityBadge({super.key, required this.quality}); + + final MediaQualityInfo? quality; + + @override + Widget build(BuildContext context) { + final label = _buildLabel(); + if (label == null) return const SizedBox.shrink(); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.black54, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + label, + style: const TextStyle(color: Colors.white70, fontSize: 12), + ), + ); + } + + String? _buildLabel() { + final q = quality; + if (q == null) return null; + final res = q.resolutionLabel; + if (res.isEmpty) return null; + final range = q.dynamicRangeLabel; + if (range == 'SDR' || range.isEmpty) return res; + final shortRange = range == 'Dolby Vision' ? 'DV' : range; + return '$res $shortRange'; + } +} diff --git a/lib/features/player/widgets/speed_button.dart b/lib/features/player/widgets/speed_button.dart new file mode 100644 index 0000000..d1907f8 --- /dev/null +++ b/lib/features/player/widgets/speed_button.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; + +import 'panel_toggle_affordance.dart'; + +class SpeedButton extends StatelessWidget { + final double currentRate; + final bool isPanelOpen; + final VoidCallback onTogglePanel; + + const SpeedButton({ + super.key, + required this.currentRate, + required this.isPanelOpen, + required this.onTogglePanel, + }); + + static const speeds = [0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0]; + + static String formatRate(double rate) { + return rate == rate.roundToDouble() ? '${rate.toInt()}x' : '${rate}x'; + } + + @override + Widget build(BuildContext context) { + return PanelToggleAffordance( + isOpen: isPanelOpen, + onTap: onTogglePanel, + tooltip: '播放速度', + child: Text( + formatRate(currentRate), + style: const TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w500, + fontFeatures: [FontFeature.tabularFigures()], + ), + ), + ); + } +} diff --git a/lib/features/player/widgets/styled_subtitle_overlay.dart b/lib/features/player/widgets/styled_subtitle_overlay.dart new file mode 100644 index 0000000..d75d887 --- /dev/null +++ b/lib/features/player/widgets/styled_subtitle_overlay.dart @@ -0,0 +1,195 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import '../../../core/contracts/playback.dart'; +import '../../../core/infra/player_engine/player_engine.dart'; +import '../../../providers/subtitle_settings_provider.dart'; + + +class StyledSubtitleOverlay extends StatefulWidget { + final PlayerEngine player; + final SubtitleStyleSettings settings; + + + final ValueListenable>? externalLines; + + + final ValueListenable? externalActive; + + const StyledSubtitleOverlay({ + super.key, + required this.player, + required this.settings, + this.externalLines, + this.externalActive, + }); + + @override + State createState() => _StyledSubtitleOverlayState(); +} + +class _StyledSubtitleOverlayState extends State { + static const _mpvReferenceHeight = 720.0; + static const _baseEdgeInset = 24.0; + + List _subtitle = const []; + StreamSubscription>? _subscription; + + @override + void initState() { + super.initState(); + _bindPlayer(widget.player); + widget.externalLines?.addListener(_onExternalChanged); + widget.externalActive?.addListener(_onExternalChanged); + } + + @override + void didUpdateWidget(covariant StyledSubtitleOverlay oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.player != widget.player) { + unawaited(_subscription?.cancel()); + _bindPlayer(widget.player); + } + if (oldWidget.externalLines != widget.externalLines) { + oldWidget.externalLines?.removeListener(_onExternalChanged); + widget.externalLines?.addListener(_onExternalChanged); + } + if (oldWidget.externalActive != widget.externalActive) { + oldWidget.externalActive?.removeListener(_onExternalChanged); + widget.externalActive?.addListener(_onExternalChanged); + } + } + + @override + void dispose() { + unawaited(_subscription?.cancel()); + widget.externalLines?.removeListener(_onExternalChanged); + widget.externalActive?.removeListener(_onExternalChanged); + super.dispose(); + } + + void _bindPlayer(PlayerEngine player) { + _subtitle = player.state.subtitle; + _subscription = player.stream.subtitle.listen((value) { + if (mounted) setState(() => _subtitle = value); + }); + } + + void _onExternalChanged() { + if (mounted) setState(() {}); + } + + bool get _externalIsActive => widget.externalActive?.value != null; + + List get _activeLines => + _externalIsActive ? (widget.externalLines?.value ?? const []) : _subtitle; + + @override + Widget build(BuildContext context) { + final primaryText = _joinLines(_activeLines); + if (primaryText.isEmpty) { + return const SizedBox.shrink(); + } + + return IgnorePointer( + child: LayoutBuilder( + builder: (context, constraints) { + final width = constraints.maxWidth; + final height = constraints.maxHeight; + if (!width.isFinite || + !height.isFinite || + width <= 0 || + height <= 0) { + return const SizedBox.shrink(); + } + + final fontSize = _scaleFontSize(widget.settings.fontSize, height); + + final position = widget.settings.position.clamp(0, 150).toDouble(); + final verticalProgress = math.min(position, 100.0) / 100.0; + final extraLowering = math.max(0.0, position - 100.0) / 50.0; + final bottomInset = + _baseEdgeInset * (1.0 - extraLowering.clamp(0.0, 1.0)); + + final lineCount = math.max(1, primaryText.split('\n').length); + final estimatedBlockHeight = math.min( + height * 0.5, + lineCount * fontSize * 1.32 + 12.0, + ); + + final verticalRange = math.max( + 0.0, + height - _baseEdgeInset - bottomInset - estimatedBlockHeight, + ); + final top = _baseEdgeInset + verticalRange * verticalProgress; + final horizontalInset = math.min(_baseEdgeInset, width * 0.06); + + return Stack( + fit: StackFit.expand, + children: [ + Positioned( + left: horizontalInset, + right: horizontalInset, + top: top, + child: _buildLine( + text: primaryText, + fontSize: fontSize, + bgOpacity: widget.settings.bgOpacity.clamp(0.0, 1.0), + ), + ), + ], + ); + }, + ), + ); + } + + String _joinLines(List lines) => lines + .map((line) => line.trim()) + .where((line) => line.isNotEmpty) + .join('\n'); + + double _scaleFontSize(double settingsFontSize, double height) => + (settingsFontSize * height / _mpvReferenceHeight).clamp(8.0, 200.0); + + Widget _buildLine({ + required String text, + required double fontSize, + required double bgOpacity, + }) { + final child = Text( + text, + textAlign: TextAlign.center, + softWrap: true, + textScaler: TextScaler.noScaling, + style: TextStyle( + color: Colors.white, + fontSize: fontSize, + height: 1.32, + letterSpacing: 0.0, + fontWeight: FontWeight.w600, + shadows: const [ + Shadow(color: Colors.black, blurRadius: 2, offset: Offset(0, 1)), + Shadow(color: Colors.black87, blurRadius: 4), + ], + ), + ); + + if (bgOpacity > 0) { + return DecoratedBox( + decoration: BoxDecoration( + color: Colors.black.withOpacity(bgOpacity), + borderRadius: BorderRadius.circular(4), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + child: child, + ), + ); + } + return child; + } +} diff --git a/lib/features/player/widgets/track_selector_button.dart b/lib/features/player/widgets/track_selector_button.dart new file mode 100644 index 0000000..05cb523 --- /dev/null +++ b/lib/features/player/widgets/track_selector_button.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; + +import '../../../core/contracts/playback.dart'; +import '../../../core/infra/player_engine/player_engine.dart'; +import 'panel_toggle_affordance.dart'; + +class SubtitleTracksButton extends StatelessWidget { + final PlayerEngine player; + final List embySubtitles; + final bool isPanelOpen; + final VoidCallback onTogglePanel; + + const SubtitleTracksButton({ + super.key, + required this.player, + required this.embySubtitles, + required this.isPanelOpen, + required this.onTogglePanel, + }); + + @override + Widget build(BuildContext context) { + return StreamBuilder( + key: ObjectKey(player), + stream: player.stream.tracks, + initialData: player.state.tracks, + builder: (_, snapshot) { + final hasSubtitles = + (snapshot.data?.embeddedSubtitles.isNotEmpty ?? false) || + embySubtitles.isNotEmpty; + if (!hasSubtitles) return const SizedBox.shrink(); + return PanelToggleAffordance( + isOpen: isPanelOpen, + onTap: onTogglePanel, + tooltip: '字幕', + child: const Icon( + Icons.subtitles_outlined, + color: Color(0xFFFFFFFF), + size: 28, + ), + ); + }, + ); + } +} + +class AudioTracksButton extends StatelessWidget { + final PlayerEngine player; + final bool isPanelOpen; + final VoidCallback onTogglePanel; + + const AudioTracksButton({ + super.key, + required this.player, + required this.isPanelOpen, + required this.onTogglePanel, + }); + + @override + Widget build(BuildContext context) { + return StreamBuilder( + key: ObjectKey(player), + stream: player.stream.tracks, + initialData: player.state.tracks, + builder: (_, snapshot) { + if ((snapshot.data?.embeddedAudio.length ?? 0) <= 1) { + return const SizedBox.shrink(); + } + return PanelToggleAffordance( + isOpen: isPanelOpen, + onTap: onTogglePanel, + tooltip: '音轨', + child: const Icon( + Icons.multitrack_audio, + color: Color(0xFFFFFFFF), + size: 28, + ), + ); + }, + ); + } +} diff --git a/lib/features/player/widgets/version_panel.dart b/lib/features/player/widgets/version_panel.dart new file mode 100644 index 0000000..e5ec218 --- /dev/null +++ b/lib/features/player/widgets/version_panel.dart @@ -0,0 +1,225 @@ +import 'dart:io' show Platform; + +import 'package:flutter/material.dart'; +import 'package:forui/forui.dart' as forui; + +import '../../../shared/widgets/all_versions_sheet.dart'; +import '../../../shared/widgets/hover_scrollable_row.dart'; +import '../../../shared/widgets/version_filter.dart'; +import '../../../shared/widgets/version_grouping.dart'; +import '../../../shared/widgets/version_source_card.dart'; +import 'player_panel_shell.dart'; + + +class VersionPanel extends StatefulWidget { + final List entries; + final VoidCallback onClose; + + const VersionPanel({super.key, required this.entries, required this.onClose}); + + @override + State createState() => _VersionPanelState(); +} + +class _VersionPanelState extends State { + final ScrollController _scrollController = ScrollController(); + String? _selectedBucket; + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final entries = widget.entries; + final allGroups = groupVersionEntries(entries); + + final bucketsPresent = { + for (final e in entries) versionBucketOf(e.resolutionLabel), + }; + final buckets = kVersionBucketOrder + .where(bucketsPresent.contains) + .toList(growable: false); + + final activeEntry = entries + .where((entry) => entry.isCurrent || entry.selected) + .firstOrNull; + final defaultBucket = activeEntry == null + ? (buckets.isNotEmpty ? buckets.first : null) + : versionBucketOf(activeEntry.resolutionLabel); + final selected = + _selectedBucket != null && bucketsPresent.contains(_selectedBucket) + ? _selectedBucket! + : defaultBucket; + if (selected != _selectedBucket) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + setState(() => _selectedBucket = selected); + }); + } + + final filteredGroups = selected == null + ? allGroups + : allGroups + .where( + (group) => + versionBucketOf(group.representative.resolutionLabel) == + selected, + ) + .toList(growable: false); + final inlineLimit = inlineVersionLimit(); + final inlineGroups = filteredGroups + .take(inlineLimit) + .toList(growable: true); + final activeGroupIndex = filteredGroups.indexWhere( + (group) => group.hasActiveMember, + ); + if (activeGroupIndex >= inlineLimit && inlineGroups.isNotEmpty) { + inlineGroups[inlineGroups.length - 1] = filteredGroups[activeGroupIndex]; + } + final hasMergedGroup = allGroups.any((group) => group.members.length > 1); + final showAllVersionsButton = + filteredGroups.length > inlineLimit || hasMergedGroup; + final itemCount = inlineGroups.length; + + return Material( + color: Colors.transparent, + child: Container( + decoration: BoxDecoration( + color: kPanelBg, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _header( + buckets, + selected, + allGroups: allGroups, + sourceCount: entries.length, + showAllVersionsButton: showAllVersionsButton, + ), + panelDivider(), + Padding( + padding: EdgeInsets.only( + top: Platform.isAndroid ? 8 : 12, + bottom: Platform.isAndroid ? 10 : 14, + ), + child: SizedBox( + height: Platform.isAndroid ? 56 : 120, + child: HoverScrollableRow( + controller: _scrollController, + builder: (_, controller) => ListView.builder( + controller: controller, + scrollDirection: Axis.horizontal, + padding: EdgeInsets.symmetric( + horizontal: Platform.isAndroid ? 10 : 16, + ), + itemCount: itemCount, + itemBuilder: (_, i) => Padding( + padding: EdgeInsets.only( + right: i < itemCount - 1 + ? (Platform.isAndroid ? 6 : 12) + : 0, + ), + child: VersionSourceCard( + data: inlineGroups[i].displayData, + compact: Platform.isAndroid, + onMergedServersTap: inlineGroups[i].members.length > 1 + ? () => showAllVersionsSheet( + context: context, + groups: [inlineGroups[i]], + title: '选择服务器', + ) + : null, + ), + ), + ), + ), + ), + ), + ], + ), + ), + ); + } + + Widget _header( + List buckets, + String? selected, { + required List allGroups, + required int sourceCount, + required bool showAllVersionsButton, + }) { + final isAndroid = Platform.isAndroid; + return Padding( + padding: isAndroid + ? const EdgeInsets.fromLTRB(16, 10, 8, 8) + : const EdgeInsets.fromLTRB(16, 12, 8, 10), + child: Row( + children: [ + const Icon( + Icons.video_file_outlined, + color: Colors.white70, + size: 18, + ), + const SizedBox(width: 8), + Text( + '切换版本', + style: TextStyle( + color: Colors.white, + fontSize: isAndroid ? 14 : 15, + fontWeight: FontWeight.w600, + ), + ), + if (showAllVersionsButton) ...[ + const SizedBox(width: 10), + forui.FButton( + variant: forui.FButtonVariant.secondary, + size: forui.FButtonSizeVariant.xs, + mainAxisSize: MainAxisSize.min, + onPress: () => + showAllVersionsSheet(context: context, groups: allGroups), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text('全部 $sourceCount 个版本'), + const SizedBox(width: 2), + const Icon(Icons.chevron_right_rounded, size: 16), + ], + ), + ), + ], + const SizedBox(width: 16), + Expanded( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + for (var i = 0; i < buckets.length; i++) ...[ + if (i > 0) const SizedBox(width: 8), + VersionFilterChip( + label: buckets[i], + selected: selected == buckets[i], + showDot: true, + onTap: () => setState(() => _selectedBucket = buckets[i]), + ), + ], + ], + ), + ), + ), + const SizedBox(width: 8), + IconButton( + icon: const Icon(Icons.close, color: Colors.white54, size: 18), + onPressed: widget.onClose, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 32, minHeight: 32), + ), + ], + ), + ); + } +} diff --git a/lib/features/profile/profile_page.dart b/lib/features/profile/profile_page.dart new file mode 100644 index 0000000..446d3ae --- /dev/null +++ b/lib/features/profile/profile_page.dart @@ -0,0 +1,401 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../providers/session_provider.dart'; +import '../../providers/sm_account_provider.dart'; +import '../../providers/update_provider.dart'; +import '../../shared/app_info.dart'; +import '../../shared/theme/app_theme.dart'; +import '../../shared/widgets/app_card.dart'; +import '../../shared/widgets/app_loading_ring.dart'; +import '../../shared/widgets/app_inline_alert.dart'; +import '../../shared/widgets/app_sliver_header.dart'; +import '../../shared/widgets/page_background.dart'; +import '../../shared/widgets/server_avatar.dart'; +import '../modules/modules_home_page.dart'; +import '../settings/settings_page.dart'; + +class ProfilePage extends ConsumerWidget { + const ProfilePage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final activeSession = ref.watch(activeSessionProvider); + final account = ref.watch(smAccountProvider).value; + final appVersion = + ref.watch(appVersionProvider).value ?? kSmPlayerVersionFallback; + final updateState = ref.watch(updateProvider); + + return Scaffold( + backgroundColor: Colors.transparent, + extendBodyBehindAppBar: true, + body: Stack( + children: [ + const Positioned.fill(child: PageBackground()), + CustomScrollView( + physics: const AlwaysScrollableScrollPhysics( + parent: BouncingScrollPhysics(), + ), + slivers: [ + const AppSliverHeader(title: '我的'), + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 32), + sliver: SliverList( + delegate: SliverChildListDelegate.fixed([ + _SearchEntry(onTap: () => context.push('/global-search')), + const SizedBox(height: 12), + _ServerAccountCard( + serverName: activeSession?.serverName ?? '未连接服务器', + userName: activeSession?.userName ?? '添加或登录 Emby 服务器', + iconUrl: '', + active: activeSession != null, + onTap: () => context.go('/servers'), + ), + const SizedBox(height: 12), + _SmAccountCard( + account: account, + onTap: () => context.push('/account-center'), + ), + const SizedBox(height: 18), + const _SectionTitle('快捷入口'), + _MenuCard( + children: [ + _ProfileMenuItem( + icon: Icons.history_rounded, + title: '观看历史', + subtitle: '跨服务器继续观看', + onTap: () => context.go('/history'), + ), + _ProfileMenuItem( + icon: Icons.favorite_outline_rounded, + title: '我的收藏', + subtitle: '查看所有服务器收藏', + onTap: () => context.go('/favorites-all'), + ), + _ProfileMenuItem( + icon: Icons.new_releases_outlined, + title: '最近添加', + subtitle: '按服务器聚合最新内容', + onTap: () => context.go('/recent'), + ), + ], + ), + const SizedBox(height: 18), + const _SectionTitle('应用'), + _MenuCard( + children: [ + _ProfileMenuItem( + icon: Icons.extension_rounded, + title: '模块中心', + subtitle: '导入、配置与浏览 ForwardWidgets 模块', + onTap: () => showModuleCenterSheet(context), + ), + _ProfileMenuItem( + icon: Icons.settings_outlined, + title: '设置', + subtitle: '播放、账号与外观', + onTap: () => showSettingsDialog(context), + ), + _ProfileMenuItem( + icon: Icons.update_outlined, + title: '检查更新', + subtitle: _updateSubtitle(updateState, appVersion), + trailing: updateState.status == UpdateStatus.checking + ? const AppLoadingRing(size: 18) + : null, + onTap: + updateState.status == UpdateStatus.checking || + updateState.status == + UpdateStatus.downloading || + updateState.status == + UpdateStatus.readyToInstall + ? null + : () => ref + .read(updateProvider.notifier) + .manualCheck(), + ), + ], + ), + if (updateState.status == UpdateStatus.error) ...[ + const SizedBox(height: 12), + AppInlineAlert( + message: updateState.error ?? '检查更新失败', + tone: AppAlertTone.error, + ), + ], + ]), + ), + ), + ], + ), + ], + ), + ); + } + + String _updateSubtitle(UpdateState state, String appVersion) { + return switch (state.status) { + UpdateStatus.checking => '当前版本 $appVersion,正在检查', + UpdateStatus.upToDate => '当前版本 $appVersion,已是最新版本', + UpdateStatus.available => '发现新版本 ${state.info?.version ?? ''}', + UpdateStatus.downloading => '正在下载更新', + UpdateStatus.readyToInstall => '正在打开安装程序', + UpdateStatus.error => state.error ?? '检查失败', + UpdateStatus.idle => '当前版本 $appVersion', + }; + } +} + +class _SearchEntry extends StatelessWidget { + final VoidCallback onTap; + + const _SearchEntry({required this.onTap}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return AppCard( + onTap: onTap, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + child: Row( + children: [ + Icon(Icons.search_rounded, color: theme.colorScheme.primary), + const SizedBox(width: 12), + Expanded( + child: Text( + '搜索电影、剧集、演员', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurface.withValues(alpha: 0.62), + ), + ), + ), + const Icon(Icons.chevron_right_rounded, size: 20), + ], + ), + ); + } +} + +class _ServerAccountCard extends StatelessWidget { + final String serverName; + final String userName; + final String iconUrl; + final bool active; + final VoidCallback onTap; + + const _ServerAccountCard({ + required this.serverName, + required this.userName, + required this.iconUrl, + required this.active, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return AppCard( + onTap: onTap, + padding: const EdgeInsets.all(16), + child: Row( + children: [ + ServerAvatar( + name: serverName, + iconUrl: iconUrl, + size: 52, + isActive: active, + showActiveBorder: true, + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + serverName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 3), + Text( + userName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurface.withValues(alpha: 0.58), + ), + ), + ], + ), + ), + const Icon(Icons.chevron_right_rounded), + ], + ), + ); + } +} + +class _SmAccountCard extends StatelessWidget { + final SmAccountState? account; + final VoidCallback onTap; + + const _SmAccountCard({required this.account, required this.onTap}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final status = account?.status ?? SmAuthStatus.unauthenticated; + final loggedIn = status == SmAuthStatus.authenticated; + final title = loggedIn ? 'player 账号' : '登录 player 账号'; + final subtitle = loggedIn + ? (account?.email.isNotEmpty == true ? account!.email : '已登录') + : '用于 TMDB、Trakt 等扩展服务'; + + return AppCard( + onTap: onTap, + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Container( + width: 42, + height: 42, + decoration: BoxDecoration( + color: theme.colorScheme.primary.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(14), + ), + child: Icon( + loggedIn ? Icons.verified_user_outlined : Icons.person_add_alt_1, + color: theme.colorScheme.primary, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: theme.textTheme.titleSmall), + const SizedBox(height: 3), + Text( + subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurface.withValues(alpha: 0.58), + ), + ), + ], + ), + ), + const Icon(Icons.chevron_right_rounded), + ], + ), + ); + } +} + +class _SectionTitle extends StatelessWidget { + final String text; + + const _SectionTitle(this.text); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(4, 0, 4, 8), + child: Text( + text, + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: context.appTokens.mutedForeground, + fontWeight: FontWeight.w600, + ), + ), + ); + } +} + +class _MenuCard extends StatelessWidget { + final List children; + + const _MenuCard({required this.children}); + + @override + Widget build(BuildContext context) { + return AppCard( + padding: EdgeInsets.zero, + child: Column( + children: [ + for (var i = 0; i < children.length; i++) ...[ + if (i > 0) + Divider( + height: 1, + indent: 56, + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.08), + ), + children[i], + ], + ], + ), + ); + } +} + +class _ProfileMenuItem extends StatelessWidget { + final IconData icon; + final String title; + final String? subtitle; + final Widget? trailing; + final VoidCallback? onTap; + + const _ProfileMenuItem({ + required this.icon, + required this.title, + this.subtitle, + this.trailing, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13), + child: Row( + children: [ + Icon(icon, size: 22, color: theme.colorScheme.primary), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: theme.textTheme.bodyMedium), + if (subtitle != null) ...[ + const SizedBox(height: 2), + Text( + subtitle!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurface.withValues( + alpha: 0.54, + ), + ), + ), + ], + ], + ), + ), + trailing ?? const Icon(Icons.chevron_right_rounded, size: 20), + ], + ), + ), + ); + } +} diff --git a/lib/features/search/global_search_page.dart b/lib/features/search/global_search_page.dart new file mode 100644 index 0000000..d2343c4 --- /dev/null +++ b/lib/features/search/global_search_page.dart @@ -0,0 +1,470 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:forui/forui.dart'; +import 'package:go_router/go_router.dart'; + +import '../../core/contracts/library.dart'; +import '../../providers/di_providers.dart'; +import '../../providers/session_provider.dart'; +import '../../shared/layout/adaptive_card_grid.dart'; +import '../../shared/mappers/media_image_url.dart'; +import '../../shared/theme/app_theme.dart'; +import '../../shared/utils/media_nav.dart'; +import '../../shared/constants/layout_constants.dart'; +import '../../shared/widgets/app_loading_ring.dart'; +import '../../shared/widgets/app_card.dart'; +import '../../shared/widgets/app_skeleton.dart'; +import '../../shared/widgets/app_sliver_header.dart'; +import '../../shared/widgets/empty_state.dart'; +import '../../shared/widgets/media_poster_card.dart'; +import '../../shared/constants/breakpoints.dart'; +import '../../shared/widgets/page_background.dart'; +import '../../shared/widgets/section_header.dart'; +import '../../shared/widgets/sidebar.dart'; +import '../../shared/widgets/warm_gradient_background.dart'; + + +const _kBranchRootLocations = { + 0: '/', + 1: '/servers', + 2: '/aggregated', + 3: '/discover', + 4: '/calendar', +}; + +const _kTypeFilters = { + 'Movie': '电影', + 'Series': '剧', + 'Episode': '集', + 'Video': '视频', + 'Channel': '频道', + 'BoxSet': '合集', + 'Person': '演职人员', +}; + +class GlobalSearchPage extends ConsumerStatefulWidget { + final bool currentServerOnly; + const GlobalSearchPage({super.key, this.currentServerOnly = false}); + + @override + ConsumerState createState() => _GlobalSearchPageState(); +} + +class _GlobalSearchPageState extends ConsumerState { + final _controller = TextEditingController(); + StreamSubscription? _subscription; + + List _serverResults = []; + String _activeQuery = ''; + List _history = const []; + final Set _selectedTypes = {'Movie', 'Series'}; + bool _searching = false; + bool _switching = false; + + @override + void initState() { + super.initState(); + _loadHistory(); + } + + @override + void dispose() { + _subscription?.cancel(); + _controller.dispose(); + super.dispose(); + } + + Future _loadHistory() async { + final store = ref.read(searchHistoryStoreProvider); + final list = await store.list(); + if (mounted) setState(() => _history = list); + } + + void _onValueChanged(TextEditingValue value) { + if (value.text.trim().isEmpty && _activeQuery.isNotEmpty) { + _subscription?.cancel(); + setState(() { + _activeQuery = ''; + _serverResults = []; + _searching = false; + }); + } + } + + void _toggleType(String type) { + setState(() { + if (_selectedTypes.contains(type)) { + if (_selectedTypes.length > 1) _selectedTypes.remove(type); + } else { + _selectedTypes.add(type); + } + }); + if (_activeQuery.isNotEmpty) { + _search(_activeQuery); + } + } + + String get _typesParam => _selectedTypes.join(','); + + Future _search(String keyword) async { + final k = keyword.trim(); + + _subscription?.cancel(); + _subscription = null; + + if (k.isEmpty) { + setState(() { + _activeQuery = ''; + _serverResults = []; + _searching = false; + }); + return; + } + + setState(() { + _activeQuery = k; + _searching = true; + _serverResults = []; + }); + + try { + final uc = await ref.read(globalKeywordSearchUseCaseProvider.future); + String? serverId; + if (widget.currentServerOnly) { + serverId = ref.read(sessionProvider).value?.activeServerId; + } + final stream = uc.executeStream( + GlobalSearchReq( + keyword: k, + includeItemTypes: _typesParam, + serverId: serverId, + ), + ); + + _subscription = stream.listen( + (result) { + if (mounted) { + setState(() => _serverResults = [..._serverResults, result]); + } + }, + onDone: () async { + if (!mounted) return; + setState(() => _searching = false); + if (_serverResults.isNotEmpty) { + await ref.read(searchHistoryStoreProvider).add(k); + await _loadHistory(); + } + }, + onError: (_) { + if (mounted) setState(() => _searching = false); + }, + ); + } catch (_) { + if (mounted) setState(() => _searching = false); + } + } + + Future _onItemTap( + BuildContext context, + GlobalSearchServerResult serverResult, + EmbyRawItem item, + ) async { + if (_switching) return; + setState(() => _switching = true); + try { + await goMediaDetailOnServer( + context, + ref, + serverId: serverResult.serverId, + item: item, + ); + } finally { + if (mounted) setState(() => _switching = false); + } + } + + @override + Widget build(BuildContext context) { + final sessions = ref.watch(sessionProvider).value; + final activeServerId = sessions?.activeServerId; + + final isCompact = MediaQuery.sizeOf(context).width < Breakpoints.compact; + final canPop = Navigator.of(context).canPop(); + + final searchScaffold = Scaffold( + backgroundColor: Colors.transparent, + extendBodyBehindAppBar: true, + body: CustomScrollView( + slivers: [ + AppSliverHeader( + title: '搜索', + + automaticallyImplyLeading: canPop || isCompact, + ), + SliverToBoxAdapter(child: _buildSearchBar()), + SliverToBoxAdapter(child: _buildTypeFilters()), + ..._buildBodySlivers(context, activeServerId), + ], + ), + ); + + + final body = isCompact + ? searchScaffold + : Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Sidebar( + selectedBranchIndex: -1, + onNavigateBranch: (index, {initialLocation = false}) { + final root = _kBranchRootLocations[index]; + if (root != null) context.go(root); + }, + location: '/global-search', + ), + Expanded(child: searchScaffold), + ], + ); + + return Stack( + fit: StackFit.expand, + children: [ + Positioned.fill( + child: isCompact + ? const WarmGradientBackground() + : const PageBackground(), + ), + body, + if (_switching) + const Positioned.fill( + child: ColoredBox( + color: Color(0x44000000), + child: Center(child: AppLoadingRing()), + ), + ), + ], + ); + } + + Widget _buildSearchBar() { + final isCompact = MediaQuery.sizeOf(context).width < Breakpoints.compact; + final horizontalPadding = isCompact ? 16.0 : 24.0; + return Padding( + padding: EdgeInsets.fromLTRB(horizontalPadding, 12, horizontalPadding, 4), + child: FTextField( + control: FTextFieldControl.managed( + controller: _controller, + onChange: _onValueChanged, + ), + hint: widget.currentServerOnly + ? '搜索当前服务器媒体...' + : '在所有服务器中搜索...', + autofocus: true, + prefixBuilder: (ctx, style, variants) => + FTextField.prefixIconBuilder( + ctx, style, variants, const Icon(Icons.search)), + clearable: (v) => v.text.isNotEmpty, + onSubmit: _search, + ), + ); + } + + Widget _buildTypeFilters() { + final isCompact = MediaQuery.sizeOf(context).width < Breakpoints.compact; + final horizontalPadding = isCompact ? 16.0 : 24.0; + return Padding( + padding: EdgeInsets.fromLTRB(horizontalPadding, 6, horizontalPadding, 4), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + for (final entry in _kTypeFilters.entries) + Padding( + padding: const EdgeInsets.only(right: 8), + child: FButton( + variant: _selectedTypes.contains(entry.key) + ? FButtonVariant.primary + : FButtonVariant.outline, + size: FButtonSizeVariant.sm, + onPress: () => _toggleType(entry.key), + child: Text(entry.value), + ), + ), + ], + ), + ), + ); + } + + List _buildBodySlivers(BuildContext context, String? activeServerId) { + if (_activeQuery.isEmpty) { + return _buildHistorySlivers(); + } + if (_serverResults.isEmpty && !_searching) { + return const [ + SliverFillRemaining( + hasScrollBody: false, + child: EmptyState(icon: Icons.search_off, title: '没有匹配的结果'), + ), + ]; + } + if (_serverResults.isEmpty && _searching) { + return [ + SliverToBoxAdapter( + child: SkeletonGrid( + itemCount: 8, + itemHeight: 240, + minCardWidth: kPosterCardMinWidth, + padding: const EdgeInsets.fromLTRB(24, 8, 24, 0), + ), + ), + ]; + } + final slivers = []; + for (final sr in _serverResults) { + slivers.addAll( + _buildServerSlivers(context, sr, sr.serverId == activeServerId), + ); + } + slivers.add(const SliverToBoxAdapter(child: SizedBox(height: 24))); + return slivers; + } + + List _buildServerSlivers( + BuildContext context, + GlobalSearchServerResult serverResult, + bool isActive, + ) { + final tokens = context.appTokens; + final headers = buildEmbyImageHeaders( + ref, + token: serverResult.token, + userId: serverResult.userId, + ); + + return [ + if (!widget.currentServerOnly) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 12, 24, 0), + child: SectionHeader( + label: serverResult.serverName, + count: serverResult.items.length, + ), + ), + ), + SliverPadding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 8), + sliver: SliverGrid( + gridDelegate: mediaGridDelegate, + delegate: SliverChildBuilderDelegate((_, j) { + final item = serverResult.items[j]; + return AppCard( + filled: false, + radius: tokens.radiusCard, + padding: EdgeInsets.zero, + child: MediaPosterCard( + item: item, + width: 132, + imageUrl: EmbyImageUrl.primary( + baseUrl: serverResult.serverUrl, + token: serverResult.token, + item: item, + maxHeight: 300, + ), + imageHeaders: headers, + onTap: () => _onItemTap(context, serverResult, item), + ), + ); + }, childCount: serverResult.items.length), + ), + ), + ]; + } + + List _buildHistorySlivers() { + if (_history.isEmpty) { + return [ + SliverFillRemaining( + hasScrollBody: false, + child: EmptyState( + icon: Icons.search, + title: widget.currentServerOnly ? '搜索当前服务器中的内容' : '搜索所有服务器中的内容', + ), + ), + ]; + } + final isCompact = MediaQuery.sizeOf(context).width < Breakpoints.compact; + final horizontalPadding = isCompact ? 16.0 : 24.0; + final content = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SectionHeader( + label: '搜索历史', + trailing: FButton( + variant: FButtonVariant.ghost, + size: FButtonSizeVariant.sm, + onPress: () async { + await ref.read(searchHistoryStoreProvider).clear(); + _loadHistory(); + }, + prefix: const Icon(Icons.delete_outline, size: 16), + child: const Text('清空'), + ), + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: _history + .map( + (kw) => FButton( + variant: FButtonVariant.outline, + size: FButtonSizeVariant.sm, + mainAxisSize: MainAxisSize.min, + onPress: () { + _controller.text = kw; + _controller.selection = TextSelection.collapsed( + offset: kw.length, + ); + _search(kw); + }, + suffix: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () async { + await ref.read(searchHistoryStoreProvider).remove(kw); + _loadHistory(); + }, + child: const Icon(Icons.close, size: 14), + ), + child: Text(kw), + ), + ) + .toList(), + ), + ], + ); + return [ + SliverPadding( + padding: EdgeInsets.fromLTRB( + horizontalPadding, 16, horizontalPadding, 24), + sliver: SliverList( + delegate: SliverChildListDelegate([ + if (isCompact) + content + else + AppCard( + filled: true, + enableHover: false, + padding: const EdgeInsets.all(16), + child: content, + ), + ]), + ), + ), + ]; + } +} + diff --git a/lib/features/servers/servers_page.dart b/lib/features/servers/servers_page.dart new file mode 100644 index 0000000..7355db6 --- /dev/null +++ b/lib/features/servers/servers_page.dart @@ -0,0 +1,605 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:forui/forui.dart'; +import 'package:go_router/go_router.dart'; +import 'package:reorderable_grid/reorderable_grid.dart'; + +import '../../core/contracts/auth.dart'; +import '../../core/contracts/server.dart'; +import '../../providers/di_providers.dart'; +import '../../providers/item_counts_provider.dart' + show serverItemCountsProvider; +import '../../providers/server_connectivity_provider.dart'; +import '../../providers/server_provider.dart'; +import '../../providers/session_provider.dart'; +import '../../shared/constants/breakpoints.dart'; +import '../../shared/layout/adaptive_card_grid.dart'; +import '../../shared/theme/app_theme.dart'; +import '../../shared/widgets/add_server_modal.dart'; +import '../../shared/widgets/app_card.dart'; +import '../../shared/widgets/auto_dismiss_menu.dart'; +import '../../shared/widgets/app_error_state.dart'; +import '../../shared/widgets/app_skeleton.dart'; +import '../../shared/widgets/app_sliver_header.dart'; +import '../../shared/widgets/empty_state.dart'; +import '../../shared/widgets/metadata_chip.dart'; +import '../../shared/widgets/page_background.dart'; +import '../../shared/widgets/server_action_menu.dart'; +import '../../shared/widgets/server_avatar.dart'; +import '../../shared/widgets/status_dot.dart'; +import '../../shared/utils/user_error_formatter.dart'; +import '../modules/modules_home_page.dart'; + +class ServersPage extends ConsumerWidget { + const ServersPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final servers = ref.watch(serverListProvider); + final sessions = ref.watch(sessionProvider); + final enabledScriptWidgets = + (ref.watch(installedScriptWidgetsProvider).value ?? const []) + .where((installedWidget) => installedWidget.enabled) + .toList(growable: false); + final isCompact = MediaQuery.sizeOf(context).width < Breakpoints.compact; + + final headerActions = [ + if (isCompact) ...[ + IconButton( + icon: const Icon(Icons.refresh_outlined, size: 20), + onPressed: () => Future.wait([ + ref.read(serverListProvider.notifier).refresh(), + ref.read(sessionProvider.notifier).refresh(), + ]), + tooltip: '刷新', + ), + IconButton( + icon: const Icon(Icons.add, size: 20), + onPressed: () => showAddServerModal(context), + tooltip: '添加服务器', + ), + ] else ...[ + FButton( + variant: FButtonVariant.outline, + onPress: () => showAddServerModal(context), + prefix: const Icon(Icons.add, size: 16), + child: const Text('添加服务器'), + ), + ], + ]; + + Widget withBackground(List slivers) { + return Stack( + children: [ + const Positioned.fill(child: PageBackground()), + RefreshIndicator( + onRefresh: () => Future.wait([ + ref.read(serverListProvider.notifier).refresh(), + ref.read(sessionProvider.notifier).refresh(), + ]), + child: CustomScrollView( + physics: const AlwaysScrollableScrollPhysics( + parent: BouncingScrollPhysics(), + ), + slivers: slivers, + ), + ), + ], + ); + } + + List buildModuleSlivers() { + if (enabledScriptWidgets.isEmpty) return const []; + + final horizontalPadding = isCompact ? 16.0 : 24.0; + return [ + SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.fromLTRB(horizontalPadding, 16, 12, 8), + child: Row( + children: [ + Expanded( + child: Text( + '模块', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + TextButton.icon( + onPressed: () => showModuleCenterSheet(context), + icon: const Icon(Icons.settings_outlined, size: 16), + label: const Text('模块中心'), + ), + ], + ), + ), + ), + SliverPadding( + padding: EdgeInsets.fromLTRB( + horizontalPadding, + 0, + horizontalPadding, + isCompact ? 100 : 24, + ), + sliver: SliverGrid( + gridDelegate: isCompact + ? const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + childAspectRatio: 1.05, + crossAxisSpacing: 12, + mainAxisSpacing: 12, + ) + : const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: kRichCardMinWidth, + childAspectRatio: 1.55, + crossAxisSpacing: kCardGap, + mainAxisSpacing: kCardGap, + ), + delegate: SliverChildBuilderDelegate((context, moduleIndex) { + final installedWidget = enabledScriptWidgets[moduleIndex]; + final manifest = installedWidget.manifest; + return _ModuleCard( + title: manifest.title, + description: manifest.description, + onTap: () => + context.go('/modules/${Uri.encodeComponent(manifest.id)}'), + ); + }, childCount: enabledScriptWidgets.length), + ), + ), + ]; + } + + return Scaffold( + backgroundColor: Colors.transparent, + extendBodyBehindAppBar: true, + body: servers.when( + data: (serverList) { + if (serverList.isEmpty) { + return withBackground([ + AppSliverHeader(title: '服务器', actions: headerActions), + SliverFillRemaining( + hasScrollBody: false, + child: EmptyState( + icon: Icons.cloud_outlined, + title: '尚未添加 Emby 服务器', + message: '点击下方按钮添加第一个服务器,开始构建你的沉浸式影库。', + action: FButton( + variant: FButtonVariant.primary, + onPress: () => showAddServerModal(context), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.add, size: 16), + SizedBox(width: 6), + Text('添加服务器'), + ], + ), + ), + ), + ), + ...buildModuleSlivers(), + ]); + } + + final sessionList = sessions.value?.sessions ?? []; + final sessionMap = {for (final s in sessionList) s.serverId: s}; + + return withBackground([ + AppSliverHeader( + title: isCompact ? '媒体服务器' : '服务器', + actions: headerActions, + ), + SliverPadding( + padding: EdgeInsets.fromLTRB( + isCompact ? 16 : 24, + 8, + isCompact ? 16 : 24, + enabledScriptWidgets.isEmpty ? (isCompact ? 100 : 24) : 8, + ), + sliver: SliverReorderableGrid( + gridDelegate: isCompact + ? const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + childAspectRatio: 1.05, + crossAxisSpacing: 12, + mainAxisSpacing: 12, + ) + : const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: kRichCardMinWidth, + childAspectRatio: 1.55, + crossAxisSpacing: kCardGap, + mainAxisSpacing: kCardGap, + ), + itemCount: serverList.length, + onReorder: (oldIndex, newIndex) => ref + .read(serverListProvider.notifier) + .reorder(oldIndex, newIndex), + itemBuilder: (context, serverIndex) { + final server = serverList[serverIndex]; + final session = sessionMap[server.id]; + final actions = buildServerActionCallbacks( + context: context, + ref: ref, + server: server, + session: session, + ); + return _ServerCardReorderListener( + key: ValueKey(server.id), + index: serverIndex, + child: _ServerCard( + server: server, + session: session, + onTap: () async { + if (session != null) { + await ref + .read(sessionProvider.notifier) + .switchSession(server.id); + if (!context.mounted) return; + context.go('/'); + } + }, + onRefresh: actions.onRefresh!, + onChangeIcon: actions.onChangeIcon!, + onEdit: actions.onEdit!, + onTogglePause: actions.onTogglePause!, + onDelete: actions.onDelete!, + onChangePassword: actions.onChangePassword, + ), + ); + }, + ), + ), + ...buildModuleSlivers(), + ]); + }, + loading: () => withBackground([ + AppSliverHeader(title: '服务器', actions: headerActions), + const SliverToBoxAdapter( + child: SkeletonGrid( + itemCount: 6, + itemHeight: 194, + minCardWidth: kRichCardMinWidth, + padding: EdgeInsets.fromLTRB(24, 8, 24, 0), + ), + ), + ]), + error: (e, _) => withBackground([ + AppSliverHeader(title: '服务器', actions: headerActions), + SliverFillRemaining( + hasScrollBody: false, + child: AppErrorState( + message: formatUserError(e, fallback: '服务器加载失败'), + onRetry: () => ref.invalidate(serverListProvider), + ), + ), + ]), + ), + ); + } +} + +class _ModuleCard extends StatelessWidget { + final String title; + final String description; + final VoidCallback onTap; + + const _ModuleCard({ + required this.title, + required this.description, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final normalizedDescription = description.trim(); + + return AppCard( + onTap: onTap, + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.extension_rounded, + size: 28, + color: theme.colorScheme.primary, + ), + const Spacer(), + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + if (normalizedDescription.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + normalizedDescription, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ], + ), + ); + } +} + +class _ServerCardReorderListener extends StatelessWidget { + final int index; + final Widget child; + + const _ServerCardReorderListener({ + super.key, + required this.index, + required this.child, + }); + + @override + Widget build(BuildContext context) { + const desktop = { + TargetPlatform.linux, + TargetPlatform.macOS, + TargetPlatform.windows, + }; + + if (desktop.contains(Theme.of(context).platform)) { + return ReorderableGridDragStartListener(index: index, child: child); + } + + return ReorderableGridDelayedDragStartListener(index: index, child: child); + } +} + +class _ServerCard extends ConsumerStatefulWidget { + final EmbyServer server; + final AuthedSession? session; + final VoidCallback onTap; + final VoidCallback onRefresh; + final VoidCallback onChangeIcon; + final VoidCallback onEdit; + final VoidCallback onDelete; + final VoidCallback onTogglePause; + final VoidCallback? onChangePassword; + + const _ServerCard({ + required this.server, + required this.session, + required this.onTap, + required this.onRefresh, + required this.onChangeIcon, + required this.onEdit, + required this.onDelete, + required this.onTogglePause, + this.onChangePassword, + }); + + @override + ConsumerState<_ServerCard> createState() => _ServerCardState(); +} + +class _ServerCardState extends ConsumerState<_ServerCard> { + String _relativeTime() { + final dateStr = widget.server.lastConnectedAt ?? widget.server.updatedAt; + final dt = DateTime.tryParse(dateStr); + if (dt == null) return '未观看'; + final diff = DateTime.now().difference(dt); + if (diff.inDays >= 1) return '${diff.inDays}天前'; + if (diff.inHours >= 1) return '${diff.inHours}小时前'; + if (diff.inMinutes >= 1) return '${diff.inMinutes}分钟前'; + return '刚刚'; + } + + ConnectivityStatus _mapStatus(AsyncValue conn) => + conn.when( + data: (c) => c.reachable + ? ConnectivityStatus.online + : ConnectivityStatus.offline, + loading: () => ConnectivityStatus.unknown, + error: (_, _) => ConnectivityStatus.offline, + ); + + List _cardMenuItems() => buildServerActionMenuItems( + onRefresh: widget.onRefresh, + onChangeIcon: widget.onChangeIcon, + onEdit: widget.onEdit, + onChangePassword: widget.session == null ? null : widget.onChangePassword, + paused: widget.server.paused, + onTogglePause: widget.onTogglePause, + onDelete: widget.onDelete, + ); + + @override + Widget build(BuildContext context) { + final isPaused = widget.server.paused; + final counts = widget.session != null && !isPaused + ? ref.watch(serverItemCountsProvider(widget.server.id)).value + : null; + final connectivity = isPaused + ? null + : ref.watch(serverConnectivityProvider(widget.server.baseUrl)); + final theme = Theme.of(context); + final tokens = context.appTokens; + final hasSession = widget.session != null; + final seedColor = ServerAvatar.seedColorFor(widget.server.name); + + final Widget latencyWidget = isPaused + ? MetadataChip( + icon: Icons.pause_circle_outline, + label: '已暂停', + foreground: theme.colorScheme.onSurfaceVariant, + ) + : connectivity!.when( + data: (c) { + if (c.reachable && c.latencyMs != null) { + final Color color; + if (c.latencyMs! < 100) { + color = tokens.statusOnline; + } else if (c.latencyMs! < 300) { + color = tokens.statusWarn; + } else { + color = tokens.statusOffline; + } + return MetadataChip( + label: '${c.latencyMs}ms', + foreground: color, + ); + } + return MetadataChip( + label: '不可达', + foreground: tokens.statusOffline, + ); + }, + loading: () => MetadataChip( + label: '检测中...', + foreground: theme.colorScheme.onSurfaceVariant, + ), + error: (_, _) => + MetadataChip(label: '检测失败', foreground: tokens.statusOffline), + ); + + return Opacity( + opacity: isPaused ? 0.5 : 1.0, + child: AppCard( + onTap: hasSession && !isPaused ? widget.onTap : null, + contextMenuItems: _cardMenuItems(), + enableLongPressMenu: false, + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [seedColor.withValues(alpha: 0.06), Colors.transparent], + ), + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ServerAvatar( + name: widget.server.name, + iconUrl: widget.server.iconUrl, + size: 34, + ), + const SizedBox(width: 8), + Expanded( + child: SizedBox( + height: 34, + child: Row( + children: [ + Flexible( + child: Text( + widget.server.name, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + fontSize: 13, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (hasSession && !isPaused) ...[ + const SizedBox(width: 4), + StatusDot( + status: _mapStatus(connectivity!), + size: 7, + ), + ], + ], + ), + ), + ), + const SizedBox(width: 4), + SizedBox( + width: 24, + height: 24, + child: FPopoverMenu( + menuBuilder: autoDismissMenuBuilder, + menuAnchor: Alignment.topRight, + childAnchor: Alignment.bottomRight, + menu: [FItemGroup(children: _cardMenuItems())], + builder: (context, controller, child) => + GestureDetector( + onTap: controller.toggle, + child: child, + ), + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: Icon( + Icons.more_vert, + size: 16, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ), + ], + ), + + const Spacer(), + + Row( + children: [ + MetadataChip( + icon: Icons.photo_library_outlined, + label: counts?.movieCount != null + ? '${counts!.movieCount}' + : '—', + ), + const SizedBox(width: 8), + MetadataChip( + icon: Icons.play_circle_outline, + label: counts?.episodeCount != null + ? '${counts!.episodeCount}' + : '—', + ), + ], + ), + + const SizedBox(height: 6), + + Row( + children: [ + latencyWidget, + if (!isPaused) ...[ + Text( + ' · ', + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontSize: 11, + ), + ), + Flexible( + child: Text( + _relativeTime(), + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontSize: 11, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ], + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/settings/settings_full_page.dart b/lib/features/settings/settings_full_page.dart new file mode 100644 index 0000000..e28034a --- /dev/null +++ b/lib/features/settings/settings_full_page.dart @@ -0,0 +1,430 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../providers/appearance_provider.dart'; +import '../../providers/sm_account_provider.dart'; +import '../../shared/app_info.dart'; +import '../../shared/theme/app_theme.dart'; +import '../../shared/widgets/app_card.dart'; +import '../../shared/widgets/app_sliver_header.dart'; +import '../../shared/widgets/page_background.dart'; +import 'settings_page.dart'; +import 'settings_tab_key.dart'; + +class SettingsFullPage extends ConsumerWidget { + const SettingsFullPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final account = ref.watch(smAccountProvider).value; + final appVersion = + ref.watch(appVersionProvider).value ?? kSmPlayerVersionFallback; + final appearance = ref.watch(appearanceProvider).value; + + return Scaffold( + backgroundColor: Colors.transparent, + extendBodyBehindAppBar: true, + body: Stack( + children: [ + const Positioned.fill(child: PageBackground()), + CustomScrollView( + physics: const AlwaysScrollableScrollPhysics( + parent: BouncingScrollPhysics(), + ), + slivers: [ + AppSliverHeader( + title: '设置', + actions: [ + Padding( + padding: const EdgeInsets.only(right: 16), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), + decoration: BoxDecoration( + border: Border.all( + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.15), + ), + borderRadius: BorderRadius.circular(14), + ), + child: Text( + appVersion, + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ), + ], + ), + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 100), + sliver: SliverList( + delegate: SliverChildListDelegate.fixed([ + _SmAccountBanner( + account: account, + onTap: () => context.push('/account-center'), + ), + const SizedBox(height: 24), + const _SectionTitle('常规设置'), + _SettingsGroupCard( + children: [ + _SettingsItem( + icon: Icons.palette_outlined, + color: const Color(0xFF8B5CF6), + title: '主题', + trailing: _ThemeModeSelector( + currentMode: appearance?.themeMode, + onChanged: (mode) => ref + .read(appearanceProvider.notifier) + .setThemeMode(mode), + ), + ), + _SettingsItem( + icon: Icons.settings_outlined, + color: const Color(0xFF6B7280), + title: '通用', + onTap: () => showSettingsSectionSheet( + context, + initialTab: kSettingsTabGeneral, + ), + ), + ], + ), + const SizedBox(height: 24), + const _SectionTitle('播放设置'), + _SettingsGroupCard( + children: [ + _SettingsItem( + icon: Icons.play_circle_outline_rounded, + color: const Color(0xFF22C55E), + title: '播放', + onTap: () => showSettingsSectionSheet( + context, + initialTab: kSettingsTabPlayback, + ), + ), + _SettingsItem( + icon: Icons.headphones_outlined, + color: const Color(0xFFA855F7), + title: '音频', + onTap: () => showSettingsSectionSheet( + context, + initialTab: kSettingsTabAudio, + ), + ), + _SettingsItem( + icon: Icons.chat_bubble_outline_rounded, + color: const Color(0xFFEF4444), + title: '弹幕', + onTap: () => showSettingsSectionSheet( + context, + initialTab: kSettingsTabDanmaku, + ), + ), + ], + ), + const SizedBox(height: 24), + const _SectionTitle('扩展服务'), + _SettingsGroupCard( + children: [ + _SettingsItem( + icon: Icons.extension_rounded, + color: const Color(0xFF6366F1), + title: '模块中心', + onTap: () => showSettingsSectionSheet( + context, + initialTab: kSettingsTabModules, + ), + ), + _SettingsItem( + icon: Icons.movie_filter_outlined, + color: const Color(0xFF0EA5E9), + title: 'TMDB', + onTap: () => showSettingsSectionSheet( + context, + initialTab: kSettingsTabTmdb, + ), + ), + _SettingsItem( + icon: Icons.link_outlined, + color: const Color(0xFFEF4444), + title: 'Trakt', + onTap: () => showSettingsSectionSheet( + context, + initialTab: kSettingsTabTrakt, + ), + ), + _SettingsItem( + icon: Icons.palette_outlined, + color: const Color(0xFFF59E0B), + title: '图标库', + onTap: () => showSettingsSectionSheet( + context, + initialTab: kSettingsTabIconLibrary, + ), + ), + ], + ), + ]), + ), + ), + ], + ), + ], + ), + ); + } +} + +class _SmAccountBanner extends StatelessWidget { + final SmAccountState? account; + final VoidCallback onTap; + + const _SmAccountBanner({required this.account, required this.onTap}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final loggedIn = account?.status == SmAuthStatus.authenticated; + final email = account?.email ?? ''; + final isVip = account?.isVip ?? false; + + final title = loggedIn + ? (email.isNotEmpty ? email : 'Player 账号') + : '登录 Player 账号'; + final subtitle = loggedIn ? (isVip ? 'VIP 会员' : '已登录') : '登录以使用云同步等扩展服务'; + + return AppCard( + onTap: onTap, + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: loggedIn + ? (isVip + ? const Color(0xFFFAAD14).withValues(alpha: 0.15) + : theme.colorScheme.primary.withValues(alpha: 0.12)) + : theme.colorScheme.onSurface.withValues(alpha: 0.08), + shape: BoxShape.circle, + ), + child: Icon( + loggedIn + ? (isVip + ? Icons.workspace_premium_rounded + : Icons.person_rounded) + : Icons.person_outline_rounded, + size: 24, + color: loggedIn + ? (isVip + ? const Color(0xFFFAAD14) + : theme.colorScheme.primary) + : theme.colorScheme.onSurface.withValues(alpha: 0.4), + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + fontSize: 16, + ), + ), + const SizedBox(height: 3), + Text( + subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurface.withValues(alpha: 0.58), + ), + ), + ], + ), + ), + if (isVip) + Container( + margin: const EdgeInsets.only(right: 8), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: const Color(0xFFFAAD14), + borderRadius: BorderRadius.circular(4), + ), + child: const Text( + 'VIP', + style: TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.w700, + ), + ), + ), + const Icon(Icons.chevron_right_rounded), + ], + ), + ); + } +} + +class _SectionTitle extends StatelessWidget { + final String text; + + const _SectionTitle(this.text); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(4, 0, 4, 8), + child: Text( + text, + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: context.appTokens.mutedForeground, + fontWeight: FontWeight.w600, + ), + ), + ); + } +} + +class _SettingsGroupCard extends StatelessWidget { + final List children; + + const _SettingsGroupCard({required this.children}); + + @override + Widget build(BuildContext context) { + return AppCard( + padding: EdgeInsets.zero, + child: Column( + children: [ + for (var i = 0; i < children.length; i++) ...[ + if (i > 0) + Divider( + height: 1, + indent: 56, + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.08), + ), + children[i], + ], + ], + ), + ); + } +} + +class _SettingsItem extends StatelessWidget { + final IconData icon; + final Color color; + final String title; + final Widget? trailing; + final VoidCallback? onTap; + + const _SettingsItem({ + required this.icon, + required this.color, + required this.title, + this.trailing, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13), + child: Row( + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + child: Icon(icon, size: 18, color: Colors.white), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [Text(title, style: theme.textTheme.bodyMedium)], + ), + ), + if (trailing != null) + trailing! + else if (onTap != null) + const Icon(Icons.chevron_right_rounded, size: 20), + ], + ), + ), + ); + } +} + +class _ThemeModeSelector extends StatelessWidget { + final ThemeMode? currentMode; + final ValueChanged onChanged; + + const _ThemeModeSelector({ + required this.currentMode, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final mode = currentMode ?? ThemeMode.system; + + Widget chip(ThemeMode m, IconData icon) { + final selected = mode == m; + return GestureDetector( + onTap: () => onChanged(m), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: selected + ? theme.colorScheme.primary.withValues(alpha: 0.15) + : Colors.transparent, + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + icon, + size: 18, + color: selected + ? theme.colorScheme.primary + : theme.colorScheme.onSurface.withValues(alpha: 0.4), + ), + ), + ); + } + + return Container( + decoration: BoxDecoration( + color: theme.colorScheme.onSurface.withValues(alpha: 0.06), + borderRadius: BorderRadius.circular(10), + ), + padding: const EdgeInsets.all(2), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + chip(ThemeMode.light, Icons.light_mode_outlined), + chip(ThemeMode.system, Icons.settings_outlined), + chip(ThemeMode.dark, Icons.dark_mode_outlined), + ], + ), + ); + } +} diff --git a/lib/features/settings/settings_page.dart b/lib/features/settings/settings_page.dart new file mode 100644 index 0000000..53d3b8d --- /dev/null +++ b/lib/features/settings/settings_page.dart @@ -0,0 +1,251 @@ +import 'package:flutter/material.dart'; +import 'package:forui/forui.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../shared/widgets/adaptive_modal.dart'; +import '../../shared/widgets/app_inline_alert.dart'; +import '../../shared/widgets/app_snack_bar.dart'; +import 'settings_tab_key.dart'; +import 'tabs/audio_settings_tab.dart'; +import 'tabs/danmaku_settings_tab.dart'; +import 'tabs/general_settings_tab.dart'; +import 'tabs/icon_library_settings_tab.dart'; +import 'tabs/modules_settings_tab.dart'; +import 'tabs/playback_settings_tab.dart'; +import 'tabs/tmdb_settings_tab.dart'; +import 'tabs/trakt_settings_tab.dart'; + +const _kSettingsTabMinWidth = 96.0; + +void showSettingsDialog(BuildContext context, {String? initialTab}) { + showAdaptiveModal( + context: context, + maxWidth: 780, + maxHeight: 620, + compactHeightFactor: 0.92, + builder: (_) => SettingsPage(initialTab: initialTab), + ); +} + + +Future showSettingsSectionSheet( + BuildContext context, { + required String initialTab, +}) { + return showAdaptiveModal( + context: context, + maxWidth: 620, + compactHeightFactor: 0.9, + builder: (sheetContext) => _SettingsSectionSheet(tabKey: initialTab), + ); +} + +class SettingsPage extends ConsumerStatefulWidget { + final String? initialTab; + + const SettingsPage({super.key, this.initialTab}); + + @override + ConsumerState createState() => _SettingsPageState(); +} + +class _SettingsPageState extends ConsumerState { + int _tabIndex = 0; + + @override + void initState() { + super.initState(); + _tabIndex = settingsTabIndexFromKey(widget.initialTab); + } + + @override + void didUpdateWidget(covariant SettingsPage oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.initialTab != oldWidget.initialTab) { + final nextTabIndex = settingsTabIndexFromKey(widget.initialTab); + if (nextTabIndex != _tabIndex) { + setState(() { + _tabIndex = nextTabIndex; + }); + } + } + } + + void _showFeedback(String message, {AppAlertTone tone = AppAlertTone.info}) { + showAppToast(context, message, tone: tone); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final settingsBg = isDark + ? const Color(0xFF1C1C1E) + : const Color(0xFFF9FAFB); + + return Container( + color: settingsBg, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(32, 20, 32, 12), + child: Center( + child: Text( + '设置', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 0), + child: LayoutBuilder( + builder: (context, constraints) { + final scrollable = + constraints.maxWidth < + settingsTabData.length * _kSettingsTabMinWidth; + return FTabs( + scrollable: scrollable, + expands: true, + control: FTabControl.lifted( + index: _tabIndex, + onChange: (index) { + if (index == _tabIndex) return; + setState(() => _tabIndex = index); + }, + ), + children: [ + FTabEntry( + label: _tabLabel(0, scrollable: scrollable), + child: GeneralSettingsTab(onFeedback: _showFeedback), + ), + FTabEntry( + label: _tabLabel(1, scrollable: scrollable), + child: const PlaybackSettingsTab(), + ), + FTabEntry( + label: _tabLabel(2, scrollable: scrollable), + child: const AudioSettingsTab(), + ), + FTabEntry( + label: _tabLabel(3, scrollable: scrollable), + child: const DanmakuSettingsTab(), + ), + FTabEntry( + label: _tabLabel(4, scrollable: scrollable), + child: const ModulesSettingsTab(), + ), + FTabEntry( + label: _tabLabel(5, scrollable: scrollable), + child: TmdbSettingsTab(onFeedback: _showFeedback), + ), + FTabEntry( + label: _tabLabel(6, scrollable: scrollable), + child: TraktSettingsTab(onFeedback: _showFeedback), + ), + FTabEntry( + label: _tabLabel(7, scrollable: scrollable), + child: const IconLibrarySettingsTab(), + ), + ], + ); + }, + ), + ), + ), + ], + ), + ); + } + + Widget _tabLabel(int index, {required bool scrollable}) { + final tab = settingsTabData[index]; + final label = Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(tab.icon, size: 16), + const SizedBox(width: 6), + Text(tab.label), + ], + ); + return scrollable + ? SizedBox(width: _kSettingsTabMinWidth, child: label) + : label; + } +} + +class _SettingsSectionSheet extends ConsumerWidget { + final String tabKey; + + const _SettingsSectionSheet({required this.tabKey}); + + void _showFeedback( + BuildContext context, + String message, { + AppAlertTone tone = AppAlertTone.info, + }) { + showAppToast(context, message, tone: tone); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + void feedback(String msg, {AppAlertTone tone = AppAlertTone.info}) => + _showFeedback(context, msg, tone: tone); + + final (title, body) = _resolveSection(context, feedback); + + return Material( + color: theme.colorScheme.surface, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 8, 4), + child: Row( + children: [ + Expanded( + child: Text( + title, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close_rounded), + tooltip: '关闭', + ), + ], + ), + ), + const Divider(height: 1), + Flexible(child: body), + ], + ), + ); + } + + (String, Widget) _resolveSection( + BuildContext context, + void Function(String message, {AppAlertTone tone}) feedback, + ) { + return switch (tabKey) { + kSettingsTabGeneral => ('通用', GeneralSettingsTab(onFeedback: feedback)), + kSettingsTabPlayback || kSettingsTabGestures => ( + '播放', + const PlaybackSettingsTab(), + ), + kSettingsTabAudio => ('音频', const AudioSettingsTab()), + kSettingsTabDanmaku => ('弹幕', const DanmakuSettingsTab()), + kSettingsTabModules => ('模块', const ModulesSettingsTab()), + kSettingsTabTmdb => ('TMDB', TmdbSettingsTab(onFeedback: feedback)), + kSettingsTabTrakt => ('Trakt', TraktSettingsTab(onFeedback: feedback)), + kSettingsTabIconLibrary => ('图标库', const IconLibrarySettingsTab()), + _ => ('设置', GeneralSettingsTab(onFeedback: feedback)), + }; + } +} diff --git a/lib/features/settings/settings_tab_key.dart b/lib/features/settings/settings_tab_key.dart new file mode 100644 index 0000000..f0e8c79 --- /dev/null +++ b/lib/features/settings/settings_tab_key.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; + +const kSettingsTabGeneral = 'general'; +const kSettingsTabPlayback = 'playback'; +const kSettingsTabGestures = 'gestures'; +const kSettingsTabAudio = 'audio'; +const kSettingsTabDanmaku = 'danmaku'; +const kSettingsTabModules = 'modules'; +const kSettingsTabTmdb = 'tmdb'; +const kSettingsTabTrakt = 'trakt'; +const kSettingsTabIconLibrary = 'icon-library'; + +typedef SettingsTabData = ({String label, IconData icon}); + +const settingsTabData = [ + (label: '通用', icon: Icons.tune_outlined), + (label: '播放', icon: Icons.play_circle_outline_rounded), + (label: '音频', icon: Icons.headphones_outlined), + (label: '弹幕', icon: Icons.subtitles_outlined), + (label: '模块', icon: Icons.extension_rounded), + (label: 'TMDB', icon: Icons.movie_filter_outlined), + (label: 'Trakt', icon: Icons.link_outlined), + (label: '图标库', icon: Icons.palette_outlined), +]; + +int settingsTabIndexFromKey(String? key) => switch (key) { + kSettingsTabGeneral => 0, + kSettingsTabPlayback => 1, + kSettingsTabGestures => 1, + kSettingsTabAudio => 2, + kSettingsTabDanmaku => 3, + kSettingsTabModules => 4, + kSettingsTabTmdb => 5, + kSettingsTabTrakt => 6, + kSettingsTabIconLibrary => 7, + _ => 0, +}; + +String? settingsTabKeyFromIndex(int index) => switch (index) { + 0 => kSettingsTabGeneral, + 1 => kSettingsTabPlayback, + 2 => kSettingsTabAudio, + 3 => kSettingsTabDanmaku, + 4 => kSettingsTabModules, + 5 => kSettingsTabTmdb, + 6 => kSettingsTabTrakt, + 7 => kSettingsTabIconLibrary, + _ => null, +}; diff --git a/lib/features/settings/tabs/audio_settings_tab.dart b/lib/features/settings/tabs/audio_settings_tab.dart new file mode 100644 index 0000000..e7646a4 --- /dev/null +++ b/lib/features/settings/tabs/audio_settings_tab.dart @@ -0,0 +1,74 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:forui/forui.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../providers/audio_settings_provider.dart'; +import '../../../shared/widgets/setting_select.dart'; +import '../utils/settings_labels.dart'; +import '../widgets/settings_card.dart'; + +class AudioSettingsTab extends ConsumerWidget { + const AudioSettingsTab({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final audioSettings = ref.watch(audioSettingsProvider); + + return SettingsTabScroll( + children: [ + audioSettings.when( + data: (data) => Column( + children: [ + SettingsCard( + children: [ + SettingRow( + title: '记住所选音轨', + trailing: FSwitch( + value: data.rememberAudioTrack, + onChange: (v) => ref + .read(audioSettingsProvider.notifier) + .setRememberAudioTrack(v), + ), + ), + SettingRow( + title: '音频首选语言', + trailing: SettingSelect( + value: data.preferredAudioLanguage, + options: const ['', 'chi', 'eng', 'jpn'], + labelOf: audioLangLabel, + onChanged: (v) { + if (v != null) { + ref + .read(audioSettingsProvider.notifier) + .setPreferredAudioLanguage(v); + } + }, + ), + ), + ], + ), + if (!Platform.isAndroid) + SettingsCard( + children: [ + SettingRow( + title: '音频增强', + trailing: FSwitch( + value: data.volumeBoost, + onChange: (v) => ref + .read(audioSettingsProvider.notifier) + .setVolumeBoost(v), + ), + ), + ], + ), + ], + ), + loading: () => const SizedBox.shrink(), + error: (e, _) => settingsLoadError(e), + ), + ], + ); + } +} diff --git a/lib/features/settings/tabs/danmaku_settings_tab.dart b/lib/features/settings/tabs/danmaku_settings_tab.dart new file mode 100644 index 0000000..83afdd4 --- /dev/null +++ b/lib/features/settings/tabs/danmaku_settings_tab.dart @@ -0,0 +1,216 @@ +import 'package:flutter/material.dart'; +import 'package:forui/forui.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../core/contracts/danmaku.dart'; +import '../../../providers/danmaku_settings_provider.dart'; +import '../../../shared/widgets/setting_select.dart'; +import '../utils/settings_labels.dart'; +import '../widgets/blocked_keywords_dialog.dart'; +import '../widgets/danmaku_sources_dialog.dart'; +import '../widgets/settings_card.dart'; +import '../widgets/settings_stepper_row.dart'; + +class DanmakuSettingsTab extends ConsumerWidget { + const DanmakuSettingsTab({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final danmakuSettings = ref.watch(danmakuSettingsProvider); + + return SettingsTabScroll( + children: [ + danmakuSettings.when( + data: (data) => Column( + children: [ + SettingsCard( + children: [ + SettingRow( + title: '全局启用弹幕', + trailing: FSwitch( + value: data.enabled, + onChange: (v) => ref + .read(danmakuSettingsProvider.notifier) + .setEnabled(v), + ), + ), + ], + ), + if (data.enabled) ...[ + _buildDanmakuDisplayCard(context, ref, data.panelSettings), + SettingsCard( + children: [ + SettingRow( + title: '简繁转换', + subtitle: '仅对兼容原版 dandanplay 的源生效', + trailing: SettingSelect( + value: data.chineseConvert, + options: ChineseConvertMode.values, + labelOf: chineseConvertLabel, + onChanged: (v) { + if (v != null) { + ref + .read(danmakuSettingsProvider.notifier) + .setChineseConvert(v); + } + }, + ), + ), + SettingRow( + title: '弹幕源', + subtitle: danmakuSourcesSummary(data.sources), + trailing: const Icon(Icons.chevron_right, size: 18), + onTap: () => + showDanmakuSourcesDialog(context, data.sources), + ), + SettingRow( + title: '合并重复弹幕', + trailing: FSwitch( + value: data.mergeDuplicates, + onChange: (v) => ref + .read(danmakuSettingsProvider.notifier) + .setMergeDuplicates(v), + ), + ), + SettingRow( + title: '屏蔽关键词', + subtitle: data.blockedKeywords.isEmpty + ? '未设置' + : '${data.blockedKeywords.length} 个', + trailing: const Icon(Icons.chevron_right, size: 18), + onTap: () => showBlockedKeywordsDialog( + context, + ref, + data.blockedKeywords, + ), + ), + ], + ), + ], + ], + ), + loading: () => const SizedBox.shrink(), + error: (e, _) => settingsLoadError(e), + ), + ], + ); + } + + Widget _buildDanmakuDisplayCard( + BuildContext context, + WidgetRef ref, + DanmakuPanelSettings p, + ) { + void emit(DanmakuPanelSettings next) => + ref.read(danmakuSettingsProvider.notifier).updatePanelSettings(next); + + double inc(double v, double step, double min, double max) => + double.parse((v + step).clamp(min, max).toStringAsFixed(2)); + double dec(double v, double step, double min, double max) => + double.parse((v - step).clamp(min, max).toStringAsFixed(2)); + + String opacityStr(double v) => + (v * 10).round() == 10 ? '1.0' : '0.${(v * 10).round()}'; + + return Column( + children: [ + SettingsCard( + children: [ + StepperRow( + title: '字号大小', + display: p.fontSize.round().toString(), + onDecrement: p.fontSize > 12 + ? () => emit(p.copyWith(fontSize: dec(p.fontSize, 2, 12, 40))) + : null, + onIncrement: p.fontSize < 40 + ? () => emit(p.copyWith(fontSize: inc(p.fontSize, 2, 12, 40))) + : null, + ), + StepperRow( + title: '基础速度', + display: '${p.speed.toStringAsFixed(2)}x', + onDecrement: p.speed > 0.25 + ? () => emit(p.copyWith(speed: dec(p.speed, 0.25, 0.25, 2.0))) + : null, + onIncrement: p.speed < 2.0 + ? () => emit(p.copyWith(speed: inc(p.speed, 0.25, 0.25, 2.0))) + : null, + ), + StepperRow( + title: '不透明度', + display: opacityStr(p.opacity), + onDecrement: p.opacity > 0.1 + ? () => + emit(p.copyWith(opacity: dec(p.opacity, 0.1, 0.1, 1.0))) + : null, + onIncrement: p.opacity < 1.0 + ? () => + emit(p.copyWith(opacity: inc(p.opacity, 0.1, 0.1, 1.0))) + : null, + ), + StepperRow( + title: '描边大小', + display: p.strokeWidth.toStringAsFixed(1), + onDecrement: p.strokeWidth > 0.0 + ? () => emit( + p.copyWith( + strokeWidth: dec(p.strokeWidth, 0.5, 0.0, 3.0), + ), + ) + : null, + onIncrement: p.strokeWidth < 3.0 + ? () => emit( + p.copyWith( + strokeWidth: inc(p.strokeWidth, 0.5, 0.0, 3.0), + ), + ) + : null, + ), + StepperRow( + title: '行间距', + display: p.lineHeight.toStringAsFixed(1), + onDecrement: p.lineHeight > 1.0 + ? () => emit( + p.copyWith(lineHeight: dec(p.lineHeight, 0.1, 1.0, 3.0)), + ) + : null, + onIncrement: p.lineHeight < 3.0 + ? () => emit( + p.copyWith(lineHeight: inc(p.lineHeight, 0.1, 1.0, 3.0)), + ) + : null, + ), + StepperRow( + title: '弹幕行数', + display: p.maxRows == 0 ? '自动' : '${p.maxRows} 行', + onDecrement: p.maxRows > 0 + ? () => emit(p.copyWith(maxRows: p.maxRows - 1)) + : null, + onIncrement: p.maxRows < 10 + ? () => emit(p.copyWith(maxRows: p.maxRows + 1)) + : null, + ), + ], + ), + SettingsCard( + children: [ + SettingRow( + title: '粗体', + trailing: FSwitch( + value: p.bold, + onChange: (v) => emit(p.copyWith(bold: v)), + ), + ), + SettingRow( + title: '固定弹幕转滚动', + trailing: FSwitch( + value: p.fixedToScroll, + onChange: (v) => emit(p.copyWith(fixedToScroll: v)), + ), + ), + ], + ), + ], + ); + } +} diff --git a/lib/features/settings/tabs/general_settings_tab.dart b/lib/features/settings/tabs/general_settings_tab.dart new file mode 100644 index 0000000..a7ae7ec --- /dev/null +++ b/lib/features/settings/tabs/general_settings_tab.dart @@ -0,0 +1,478 @@ +import 'dart:io'; + +import 'package:file_selector/file_selector.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:forui/forui.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../../../shared/utils/format_utils.dart'; + +import '../../../providers/appearance_provider.dart'; +import '../../../providers/danmaku_settings_provider.dart'; +import '../../../providers/di_providers.dart'; +import '../../../providers/server_provider.dart'; +import '../../../providers/session_provider.dart'; +import '../../../providers/sm_account_provider.dart'; +import '../../../providers/update_provider.dart'; +import '../../../shared/app_info.dart'; +import '../../../shared/theme/app_theme.dart'; +import '../../../shared/utils/user_error_formatter.dart'; +import '../../../shared/widgets/app_inline_alert.dart'; +import '../../../shared/widgets/app_loading_ring.dart'; +import '../../../shared/widgets/setting_select.dart'; +import '../../account/account_center_page.dart'; +import '../utils/settings_labels.dart'; +import '../widgets/settings_card.dart'; + +class GeneralSettingsTab extends ConsumerStatefulWidget { + final void Function(String message, {AppAlertTone tone}) onFeedback; + + const GeneralSettingsTab({super.key, required this.onFeedback}); + + @override + ConsumerState createState() => _GeneralSettingsTabState(); +} + +class _GeneralSettingsTabState extends ConsumerState { + bool _installTriggered = false; + + @override + Widget build(BuildContext context) { + final appearance = ref.watch(appearanceProvider); + final updateState = ref.watch(updateProvider); + final appVersion = + ref.watch(appVersionProvider).value ?? kSmPlayerVersionFallback; + + if (updateState.status != UpdateStatus.readyToInstall) { + _installTriggered = false; + } else if (!updateState.silent && !_installTriggered) { + _installTriggered = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + ref.read(updateProvider.notifier).install(); + }); + } + + final account = ref.watch(smAccountProvider).value; + + return SettingsTabScroll( + children: [ + SettingsCard( + children: [ + SettingRow( + title: account?.loggedIn == true + ? (account!.email.isNotEmpty ? account.email : 'Player 账号') + : '账号', + subtitle: account?.loggedIn == true ? null : '登录以使用云同步等服务', + trailing: const Icon(Icons.chevron_right, size: 18), + onTap: _openAccountSheet, + ), + ], + ), + appearance.when( + data: (data) => SettingsCard( + children: [ + SettingRow( + title: '主题模式', + trailing: SettingSelect( + value: data.themeMode, + options: ThemeMode.values, + labelOf: themeModeLabel, + onChanged: (v) { + if (v != null) { + ref.read(appearanceProvider.notifier).setThemeMode(v); + } + }, + ), + ), + SettingRow( + title: '顶栏背景', + trailing: SettingSelect( + value: data.headerMode, + options: HeaderMode.values, + labelOf: headerModeLabel, + onChanged: (v) { + if (v != null) { + ref.read(appearanceProvider.notifier).setHeaderMode(v); + } + }, + ), + ), + SettingRow( + title: '启动页面', + subtitle: '下次打开软件时进入的页面', + trailing: SettingSelect( + value: data.startupPage, + options: StartupPage.values, + labelOf: startupPageLabel, + onChanged: (v) { + if (v != null) { + ref.read(appearanceProvider.notifier).setStartupPage(v); + } + }, + ), + ), + ], + ), + loading: () => const SizedBox.shrink(), + error: (e, _) => settingsLoadError(e), + ), + SettingsCard( + children: [ + SettingRow( + title: '检查更新', + subtitle: _updateStatusText(updateState, appVersion), + trailing: FilledButton.tonalIcon( + onPressed: _canManualCheck(updateState) + ? _manualCheckUpdate + : null, + icon: + updateState.status == UpdateStatus.checking && + !updateState.silent + ? const AppLoadingRing(size: 14) + : const Icon(Icons.system_update_alt_rounded, size: 16), + label: const Text('检查更新'), + ), + ), + if (_shouldShowInlineUpdate(updateState)) + _InlineUpdatePanel( + state: updateState, + appVersion: appVersion, + onCheckAgain: _manualCheckUpdate, + onDownload: () => + ref.read(updateProvider.notifier).startDownload(), + onCancel: () => ref.read(updateProvider.notifier).cancel(), + onOpenDownloadPage: _openDownloadPage, + ), + ], + ), + SettingsCard( + children: [ + SettingRow( + title: '导出到文件', + trailing: const Icon(Icons.upload_file, size: 18), + onTap: () => _exportBackup(context), + ), + SettingRow( + title: '从文件导入', + trailing: const Icon(Icons.download, size: 18), + onTap: () => _importBackup(context), + ), + ], + ), + ], + ); + } + + bool _canManualCheck(UpdateState state) { + return state.status != UpdateStatus.checking && + state.status != UpdateStatus.downloading && + state.status != UpdateStatus.readyToInstall; + } + + bool _shouldShowInlineUpdate(UpdateState state) { + if (state.silent) return false; + return state.status != UpdateStatus.idle; + } + + String _updateStatusText(UpdateState state, String appVersion) { + if (!state.silent) { + switch (state.status) { + case UpdateStatus.checking: + return '当前版本 $appVersion,正在检查'; + case UpdateStatus.upToDate: + return '当前版本 $appVersion,已是最新版本'; + case UpdateStatus.error: + return '当前版本 $appVersion,${state.error ?? '检查失败'}'; + case UpdateStatus.available: + return '当前版本 $appVersion,发现新版本 ${state.info?.version ?? ''}'; + case UpdateStatus.downloading: + return '当前版本 $appVersion,正在下载更新'; + case UpdateStatus.readyToInstall: + return '当前版本 $appVersion,正在打开安装程序'; + case UpdateStatus.idle: + break; + } + } + return '当前版本 $appVersion'; + } + + Future _manualCheckUpdate() async { + await ref.read(updateProvider.notifier).manualCheck(); + } + + Future _openDownloadPage(Uri uri) async { + if (!uri.hasScheme) return; + await launchUrl(uri, mode: LaunchMode.externalApplication); + } + + Future _openAccountSheet() async { + final message = await showAccountCenterSheet(context); + if (!mounted || message == null || message.isEmpty) return; + widget.onFeedback(message, tone: AppAlertTone.success); + } + + Future _exportBackup(BuildContext context) async { + try { + final location = await getSaveLocation( + suggestedName: _backupFileName(DateTime.now()), + acceptedTypeGroups: const [ + XTypeGroup(label: 'JSON', extensions: ['json']), + ], + ); + if (location == null) return; + + final usecase = await ref.read(exportBackupUseCaseProvider.future); + final result = await usecase.execute(); + await File(location.path).writeAsString(result.json, flush: true); + + if (!context.mounted) return; + widget.onFeedback( + '已导出 ${result.serverCount} 个服务器、${result.sessionCount} 个登录态、' + '${result.danmakuSourceCount} 个弹幕源、' + '${result.scriptWidgetCount} 个模块、${result.scriptWidgetSubscriptionCount} 个订阅。' + '备份文件含登录凭据与模块用户参数,请妥善保管。', + tone: AppAlertTone.success, + ); + } catch (e) { + if (!context.mounted) return; + widget.onFeedback('导出失败:${formatUserError(e)}', tone: AppAlertTone.error); + } + } + + Future _importBackup(BuildContext context) async { + try { + final file = await openFile( + acceptedTypeGroups: const [ + XTypeGroup(label: 'JSON', extensions: ['json']), + ], + ); + if (file == null) return; + + final raw = await File(file.path).readAsString(); + final usecase = await ref.read(importBackupUseCaseProvider.future); + final result = await usecase.execute(raw); + + await ref.read(serverListProvider.notifier).refresh(); + await ref.read(sessionProvider.notifier).refresh(); + ref.invalidate(danmakuSettingsProvider); + ref.invalidate(installedScriptWidgetsProvider); + ref.invalidate(scriptWidgetSubscriptionsProvider); + + if (!context.mounted) return; + widget.onFeedback( + '已导入 ${result.serverCount} 个服务器、${result.sessionCount} 个登录态、' + '${result.danmakuSourceCount} 个弹幕源、' + '${result.scriptWidgetCount} 个模块、${result.scriptWidgetSubscriptionCount} 个订阅', + tone: AppAlertTone.success, + ); + } catch (e) { + if (!context.mounted) return; + widget.onFeedback('导入失败:${formatUserError(e)}', tone: AppAlertTone.error); + } + } + + static String _backupFileName(DateTime now) { + String two(int v) => v.toString().padLeft(2, '0'); + return 'smplayer-backup-${now.year}${two(now.month)}${two(now.day)}' + '-${two(now.hour)}${two(now.minute)}.json'; + } +} + +class _InlineUpdatePanel extends StatelessWidget { + final UpdateState state; + final String appVersion; + final VoidCallback onCheckAgain; + final VoidCallback onDownload; + final VoidCallback onCancel; + final ValueChanged onOpenDownloadPage; + + const _InlineUpdatePanel({ + required this.state, + required this.appVersion, + required this.onCheckAgain, + required this.onDownload, + required this.onCancel, + required this.onOpenDownloadPage, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final tokens = context.appTokens; + final info = state.info; + final isChecking = state.status == UpdateStatus.checking; + final isUpToDate = state.status == UpdateStatus.upToDate; + final isError = state.status == UpdateStatus.error; + final isDownloading = state.status == UpdateStatus.downloading; + final isReady = state.status == UpdateStatus.readyToInstall; + + return Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 16), + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 180), + child: Column( + key: ValueKey(state.status), + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (isChecking) + _StatusLine( + icon: Icons.sync_rounded, + iconColor: tokens.mutedForeground, + text: '正在检查更新...', + ) + else if (isUpToDate) + _StatusLine( + icon: Icons.check_circle_outline_rounded, + iconColor: Colors.green, + text: '当前版本 $appVersion 已是最新版本。', + ) + else if (isError) + _ErrorBlock( + message: state.error ?? '检查更新失败,请稍后重试', + onRetry: onCheckAgain, + ) + else if (info != null) ...[ + Text( + '发现新版本 ${info.version}', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Text( + '当前版本 $appVersion → 最新版本 ${info.version}', + style: theme.textTheme.bodySmall?.copyWith( + color: tokens.mutedForeground, + ), + ), + const SizedBox(height: 12), + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 150), + child: SingleChildScrollView( + child: Text( + info.releaseNotes.trim().isEmpty + ? '暂无更新说明' + : info.releaseNotes.trim(), + style: theme.textTheme.bodySmall?.copyWith(height: 1.45), + ), + ), + ), + if (isDownloading) ...[ + const SizedBox(height: 16), + FDeterminateProgress( + value: downloadProgressValue( + received: state.received, + total: state.total, + ) ?? 0, + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '${formatDownloadBytes(state.received)} / ${formatDownloadBytes(state.total)}', + style: theme.textTheme.bodySmall?.copyWith( + color: tokens.mutedForeground, + ), + ), + Text( + formatDownloadSpeed(state.speedBytesPerSec), + style: theme.textTheme.bodySmall?.copyWith( + color: tokens.mutedForeground, + ), + ), + ], + ), + ], + if (isReady) ...[ + const SizedBox(height: 16), + _StatusLine( + icon: Icons.install_desktop_rounded, + iconColor: tokens.mutedForeground, + text: '安装程序已下载,正在打开。', + ), + ], + const SizedBox(height: 14), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + if (isDownloading) + OutlinedButton( + onPressed: onCancel, + child: const Text('取消下载'), + ) + else if (info.noAssetForPlatform) + FilledButton.tonalIcon( + onPressed: () => onOpenDownloadPage(info.htmlUrl), + icon: const Icon(Icons.open_in_new_rounded, size: 16), + label: const Text('前往下载页'), + ) + else + FilledButton.tonalIcon( + onPressed: isReady ? null : onDownload, + icon: const Icon(Icons.download_rounded, size: 16), + label: const Text('下载并安装'), + ), + TextButton( + onPressed: isDownloading || isReady ? null : onCheckAgain, + child: const Text('重新检查'), + ), + ], + ), + ], + ], + ), + ), + ); + } +} + +class _StatusLine extends StatelessWidget { + final IconData icon; + final Color iconColor; + final String text; + + const _StatusLine({ + required this.icon, + required this.iconColor, + required this.text, + }); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Icon(icon, size: 18, color: iconColor), + const SizedBox(width: 8), + Expanded(child: Text(text)), + ], + ); + } +} + +class _ErrorBlock extends StatelessWidget { + final String message; + final VoidCallback onRetry; + + const _ErrorBlock({required this.message, required this.onRetry}); + + @override + Widget build(BuildContext context) { + final color = Theme.of(context).colorScheme.error; + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.error_outline_rounded, size: 18, color: color), + const SizedBox(width: 8), + Expanded( + child: Text(message, style: TextStyle(color: color)), + ), + const SizedBox(width: 8), + TextButton(onPressed: onRetry, child: const Text('重试')), + ], + ); + } +} diff --git a/lib/features/settings/tabs/icon_library_settings_tab.dart b/lib/features/settings/tabs/icon_library_settings_tab.dart new file mode 100644 index 0000000..7d43d6f --- /dev/null +++ b/lib/features/settings/tabs/icon_library_settings_tab.dart @@ -0,0 +1,283 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../providers/icon_library_provider.dart'; +import '../../../shared/constants/breakpoints.dart'; +import '../../../shared/theme/app_theme.dart'; +import '../../../shared/utils/user_error_formatter.dart'; +import '../../../shared/widgets/adaptive_modal.dart'; +import '../../../shared/widgets/app_dialog.dart'; +import '../../../shared/widgets/app_loading_ring.dart'; +import '../widgets/settings_card.dart'; + +class IconLibrarySettingsTab extends ConsumerWidget { + const IconLibrarySettingsTab({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return const SettingsTabScroll( + children: [ + SettingsCard( + children: [ + Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: IconLibrarySection(), + ), + ], + ), + ], + ); + } +} + +class IconLibrarySection extends ConsumerWidget { + const IconLibrarySection({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final tokens = context.appTokens; + final state = ref.watch(iconLibraryProvider); + + return state.when( + data: (data) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '配置图标库 JSON URL,添加/编辑服务器时可从中选择图标', + style: theme.textTheme.bodySmall?.copyWith( + color: tokens.mutedForeground, + ), + ), + const SizedBox(height: 12), + if (data.entries.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Text( + '尚未配置图标库', + style: theme.textTheme.bodySmall?.copyWith( + color: tokens.mutedForeground, + ), + ), + ) + else + ...data.entries.map((entry) => IconLibraryRow(entry: entry)), + const SizedBox(height: 8), + Row( + children: [ + FilledButton.tonalIcon( + onPressed: () => _showAddDialog(context, ref), + icon: const Icon(Icons.add, size: 16), + label: const Text('添加图标库'), + ), + const SizedBox(width: 8), + if (data.entries.isNotEmpty) + TextButton.icon( + onPressed: () => + ref.read(iconLibraryProvider.notifier).refreshAll(), + icon: const Icon(Icons.refresh, size: 16), + label: const Text('刷新全部'), + ), + const Spacer(), + Text( + '共 ${data.totalIcons} 个图标', + style: theme.textTheme.bodySmall?.copyWith( + color: tokens.mutedForeground, + ), + ), + ], + ), + ], + ), + loading: () => const SizedBox.shrink(), + error: (e, _) => settingsLoadError(e), + ); + } + + void _showAddDialog(BuildContext context, WidgetRef ref) { + final controller = TextEditingController(); + + void submit(BuildContext ctx) { + final url = controller.text.trim(); + if (url.isEmpty) return; + ref.read(iconLibraryProvider.notifier).addUrl(url); + Navigator.pop(ctx); + } + + showAdaptiveModal( + context: context, + maxWidth: 420, + compactMaxHeightFactor: 0.9, + builder: (ctx) { + final theme = Theme.of(ctx); + final isCompact = Breakpoints.isCompact(MediaQuery.sizeOf(ctx).width); + return SingleChildScrollView( + padding: EdgeInsets.fromLTRB(24, isCompact ? 16 : 24, 24, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('添加图标库', style: theme.textTheme.titleLarge), + const SizedBox(height: 8), + Text( + '输入图标库 JSON URL', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 16), + TextField( + controller: controller, + autofocus: true, + keyboardType: TextInputType.url, + textInputAction: TextInputAction.done, + autocorrect: false, + enableSuggestions: false, + onSubmitted: (_) => submit(ctx), + decoration: const InputDecoration( + hintText: 'https://example.com/icons.json', + isDense: true, + ), + ), + const SizedBox(height: 20), + if (isCompact) ...[ + SizedBox( + height: 48, + child: FilledButton( + onPressed: () => submit(ctx), + child: const Text('添加'), + ), + ), + const SizedBox(height: 8), + SizedBox( + height: 44, + child: OutlinedButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('取消'), + ), + ), + ] else + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('取消'), + ), + const SizedBox(width: 8), + FilledButton( + onPressed: () => submit(ctx), + child: const Text('添加'), + ), + ], + ), + ], + ), + ); + }, + ).whenComplete(controller.dispose); + } +} + +class IconLibraryRow extends ConsumerWidget { + final IconLibraryEntryState entry; + + const IconLibraryRow({super.key, required this.entry}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final tokens = context.appTokens; + final notifier = ref.read(iconLibraryProvider.notifier); + + final lib = entry.library; + final title = lib?.name.isNotEmpty == true ? lib!.name : entry.url; + + final (statusText, statusColor) = switch (entry.status) { + IconLibraryStatus.loading => ('加载中…', tokens.mutedForeground), + IconLibraryStatus.loaded => ( + '${entry.iconCount} 个图标', + tokens.mutedForeground, + ), + IconLibraryStatus.error => ( + '加载失败:${formatUserError(entry.error, fallback: "未知错误")}', + theme.colorScheme.error, + ), + IconLibraryStatus.idle => ('未加载', tokens.mutedForeground), + }; + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium, + ), + const SizedBox(height: 2), + if (lib?.name.isNotEmpty == true) + Padding( + padding: const EdgeInsets.only(bottom: 2), + child: Text( + entry.url, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.labelSmall?.copyWith( + color: tokens.mutedForeground, + ), + ), + ), + Text( + statusText, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: statusColor, + ), + ), + ], + ), + ), + if (entry.status == IconLibraryStatus.loading) + const Padding( + padding: EdgeInsets.symmetric(horizontal: 6), + child: AppLoadingRing(size: 14), + ) + else + IconButton( + tooltip: '刷新', + icon: const Icon(Icons.refresh, size: 18), + onPressed: () => notifier.refresh(entry.url), + ), + IconButton( + tooltip: '移除', + icon: const Icon(Icons.delete_outline, size: 18), + onPressed: () => _confirmRemove(context, notifier, entry.url), + ), + ], + ), + ); + } + + Future _confirmRemove( + BuildContext context, + IconLibrarySettingsNotifier notifier, + String url, + ) async { + final confirmed = await showAppConfirmDialog( + context, + title: '移除图标库', + body: Text('确定移除「$url」?已选用此库图标的服务器将回退为首字母显示。'), + confirmLabel: '移除', + destructive: true, + ); + if (confirmed) await notifier.removeUrl(url); + } +} diff --git a/lib/features/settings/tabs/modules_settings_tab.dart b/lib/features/settings/tabs/modules_settings_tab.dart new file mode 100644 index 0000000..282429f --- /dev/null +++ b/lib/features/settings/tabs/modules_settings_tab.dart @@ -0,0 +1,13 @@ +import 'package:flutter/material.dart'; + +import '../../modules/modules_home_page.dart'; + + +class ModulesSettingsTab extends StatelessWidget { + const ModulesSettingsTab({super.key}); + + @override + Widget build(BuildContext context) { + return const ModuleCenterContent(embedded: true); + } +} diff --git a/lib/features/settings/tabs/playback_settings_tab.dart b/lib/features/settings/tabs/playback_settings_tab.dart new file mode 100644 index 0000000..72d8602 --- /dev/null +++ b/lib/features/settings/tabs/playback_settings_tab.dart @@ -0,0 +1,194 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../providers/player_engine_override_provider.dart'; +import '../../../providers/player_settings_provider.dart'; +import '../../../providers/version_priority_provider.dart'; +import '../../player/gestures/gesture_action.dart' as g; +import '../widgets/player_engine_row.dart'; +import '../widgets/settings_card.dart'; +import '../widgets/settings_rows.dart'; +import '../widgets/version_priority_section.dart'; + +class PlaybackSettingsTab extends ConsumerWidget { + const PlaybackSettingsTab({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final playerSettings = ref.watch(playerSettingsProvider); + final versionPriority = ref.watch(versionPriorityProvider); + final engineOverride = ref.watch(playerEngineOverrideProvider); + final theme = Theme.of(context); + + return SettingsTabScroll( + children: [ + engineOverride.when( + data: (current) => SettingsCard( + children: [ + PlayerEngineRow( + value: current, + onChanged: (v) => ref + .read(playerEngineOverrideProvider.notifier) + .setOverride(v), + ), + ], + ), + loading: () => const SizedBox.shrink(), + error: (e, _) => settingsLoadError(e), + ), + playerSettings.when( + data: (data) => Column( + children: [ + SettingsCard( + children: [ + SeekDurationRow( + title: '快退时间', + seconds: data.seekBackwardSeconds, + onChanged: (v) => ref + .read(playerSettingsProvider.notifier) + .setSeekBackwardSeconds(v), + ), + SeekDurationRow( + title: '快进时间', + seconds: data.seekForwardSeconds, + onChanged: (v) => ref + .read(playerSettingsProvider.notifier) + .setSeekForwardSeconds(v), + ), + ], + ), + if (Platform.isAndroid) + SettingsCard( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 10, 16, 4), + child: Text( + '触控', + style: theme.textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + GestureSpeedRow( + title: '长按左侧倍速', + value: data.keyboardHoldLeftSpeed, + onChanged: (v) => ref + .read(playerSettingsProvider.notifier) + .setKeyboardHoldLeftSpeed(v), + ), + GestureSpeedRow( + title: '长按右侧倍速', + value: data.keyboardHoldRightSpeed, + onChanged: (v) => ref + .read(playerSettingsProvider.notifier) + .setKeyboardHoldRightSpeed(v), + ), + ], + ) + else ...[ + SettingsCard( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 10, 16, 4), + child: Text( + '键盘', + style: theme.textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + GestureSpeedRow( + title: '长按左方向键倍速', + value: data.keyboardHoldLeftSpeed, + onChanged: (v) => ref + .read(playerSettingsProvider.notifier) + .setKeyboardHoldLeftSpeed(v), + ), + GestureSpeedRow( + title: '长按右方向键倍速', + value: data.keyboardHoldRightSpeed, + onChanged: (v) => ref + .read(playerSettingsProvider.notifier) + .setKeyboardHoldRightSpeed(v), + ), + ], + ), + SettingsCard( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 10, 16, 4), + child: Text( + '鼠标', + style: theme.textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + GestureActionRow( + title: '左键单击', + kind: g.GestureKind.leftClick, + value: data.mouseLeftClickAction, + onChanged: (v) => ref + .read(playerSettingsProvider.notifier) + .setMouseLeftClickAction(v), + ), + GestureActionRow( + title: '左键双击', + kind: g.GestureKind.leftDoubleClick, + value: data.mouseLeftDoubleClickAction, + onChanged: (v) => ref + .read(playerSettingsProvider.notifier) + .setMouseLeftDoubleClickAction(v), + ), + GestureActionRow( + title: '右键单击', + kind: g.GestureKind.rightClick, + value: data.mouseRightClickAction, + onChanged: (v) => ref + .read(playerSettingsProvider.notifier) + .setMouseRightClickAction(v), + ), + GestureActionRow( + title: '右键双击', + kind: g.GestureKind.rightDoubleClick, + value: data.mouseRightDoubleClickAction, + onChanged: (v) => ref + .read(playerSettingsProvider.notifier) + .setMouseRightDoubleClickAction(v), + ), + ], + ), + ], + ], + ), + loading: () => const SizedBox.shrink(), + error: (e, _) => settingsLoadError(e), + ), + versionPriority.when( + data: (data) => SettingsCard( + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + child: VersionPrioritySection( + activePriorities: data.activePriorities, + onToggle: (p) => + ref.read(versionPriorityProvider.notifier).toggle(p), + onReorder: (list) => ref + .read(versionPriorityProvider.notifier) + .setPriorities(list), + ), + ), + ], + ), + loading: () => const SizedBox.shrink(), + error: (e, _) => settingsLoadError(e), + ), + ], + ); + } +} diff --git a/lib/features/settings/tabs/tmdb_settings_tab.dart b/lib/features/settings/tabs/tmdb_settings_tab.dart new file mode 100644 index 0000000..96e17e1 --- /dev/null +++ b/lib/features/settings/tabs/tmdb_settings_tab.dart @@ -0,0 +1,383 @@ +import 'package:flutter/material.dart'; +import 'package:forui/forui.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/domain/ports/tmdb_gateway.dart'; +import '../../../providers/di_providers.dart'; +import '../../../providers/sm_account_provider.dart'; +import '../../../providers/tmdb_settings_provider.dart'; +import '../../../shared/widgets/app_inline_alert.dart'; +import '../../../shared/widgets/app_loading_ring.dart'; +import '../../../shared/widgets/setting_select.dart'; +import '../utils/settings_labels.dart'; +import '../widgets/settings_card.dart'; +import '../widgets/settings_text_field_row.dart'; + +class TmdbSettingsTab extends ConsumerWidget { + final void Function(String message, {AppAlertTone tone}) onFeedback; + + const TmdbSettingsTab({super.key, required this.onFeedback}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final tmdb = ref.watch(tmdbSettingsProvider); + + return SettingsTabScroll( + children: [ + tmdb.when( + data: (data) => + TmdbSettingsSection(state: data, onFeedback: onFeedback), + loading: () => const Padding( + padding: EdgeInsets.symmetric(vertical: 32), + child: Center(child: AppLoadingRing()), + ), + error: (e, _) => SettingsCard(children: [settingsLoadError(e)]), + ), + ], + ); + } +} + +class TmdbSettingsSection extends ConsumerStatefulWidget { + final TmdbSettingsState state; + final void Function(String message, {AppAlertTone tone}) onFeedback; + + const TmdbSettingsSection({ + super.key, + required this.state, + required this.onFeedback, + }); + + @override + ConsumerState createState() => + TmdbSettingsSectionState(); +} + +class TmdbSettingsSectionState extends ConsumerState { + late final TextEditingController _apiKeyCtrl; + late final TextEditingController _accessTokenCtrl; + final _apiKeyFocus = FocusNode(); + final _accessTokenFocus = FocusNode(); + bool _testingConnection = false; + + @override + void initState() { + super.initState(); + _apiKeyCtrl = TextEditingController(text: widget.state.apiKey); + _accessTokenCtrl = TextEditingController(text: widget.state.accessToken); + } + + @override + void didUpdateWidget(covariant TmdbSettingsSection oldWidget) { + super.didUpdateWidget(oldWidget); + if (!_apiKeyFocus.hasFocus && _apiKeyCtrl.text != widget.state.apiKey) { + _apiKeyCtrl.text = widget.state.apiKey; + } + if (!_accessTokenFocus.hasFocus && + _accessTokenCtrl.text != widget.state.accessToken) { + _accessTokenCtrl.text = widget.state.accessToken; + } + } + + @override + void dispose() { + _apiKeyCtrl.dispose(); + _accessTokenCtrl.dispose(); + _apiKeyFocus.dispose(); + _accessTokenFocus.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final s = widget.state; + final account = ref.watch(smAccountProvider).value; + final accountStatus = account?.status ?? SmAuthStatus.unauthenticated; + final canRequest = s.canRequestWithAccount( + dataPlaneReady: account?.canUseDataPlane ?? false, + ); + final String statusSubtitle; + if (!s.enabled) { + statusSubtitle = '关闭时不请求 TMDB 数据'; + } else if (s.accessMode == TmdbAccessMode.smAccount) { + statusSubtitle = switch (accountStatus) { + SmAuthStatus.authenticated => '已启用(内置代理),使用账号 ${account!.email}', + SmAuthStatus.restoring => '已启用(内置代理),正在恢复登录…', + SmAuthStatus.expired => '已启用(内置代理),登录已过期,请重新登录', + SmAuthStatus.degraded => '已启用(内置代理),账号凭据不完整,请重新登录', + SmAuthStatus.unauthenticated => '已启用(内置代理),需登录 sm-server 账号', + }; + } else { + statusSubtitle = canRequest ? '已启用,媒体详情和发现页会请求 TMDB 数据' : '已启用,请补全认证信息'; + } + + return Column( + children: [ + SettingsCard( + children: [ + SettingRow( + title: '启用 TMDB', + subtitle: statusSubtitle, + trailing: FSwitch( + value: s.enabled, + onChange: (v) => + ref.read(tmdbSettingsProvider.notifier).setEnabled(v), + ), + ), + if (s.enabled) + SettingRow( + title: '接入方式', + subtitle: s.accessMode == TmdbAccessMode.smAccount + ? null + : '数据接口:${TmdbSettingsState.officialApiBaseUrl}', + trailing: SettingSelect( + value: s.accessMode, + options: TmdbAccessMode.values, + labelOf: tmdbAccessModeLabel, + onChanged: (v) { + if (v != null) { + ref.read(tmdbSettingsProvider.notifier).setAccessMode(v); + } + }, + ), + ), + ], + ), + + if (s.enabled && s.accessMode == TmdbAccessMode.smAccount) ...[ + _SmAccountTmdbSection( + accountState: account, + language: s.language, + onFeedback: widget.onFeedback, + onTestConnection: _testConnectionSmAccount, + testingConnection: _testingConnection, + ), + _LanguageCard(language: s.language), + ], + + if (s.enabled && s.accessMode == TmdbAccessMode.official) ...[ + SettingsCard( + children: [ + SettingRow( + title: '认证方式', + trailing: SettingSelect( + value: s.authMode, + options: TmdbAuthMode.values, + labelOf: tmdbAuthModeLabel, + onChanged: (v) { + if (v != null) { + ref.read(tmdbSettingsProvider.notifier).setAuthMode(v); + } + }, + ), + ), + if (s.authMode == TmdbAuthMode.apiKey) + SettingsTextFieldRow( + title: 'API Key', + hintText: '粘贴 TMDB API Key', + controller: _apiKeyCtrl, + focusNode: _apiKeyFocus, + onChanged: (v) => + ref.read(tmdbSettingsProvider.notifier).setApiKey(v), + ) + else + SettingsTextFieldRow( + title: 'Bearer Token', + hintText: '粘贴 TMDB Read Access Token', + controller: _accessTokenCtrl, + focusNode: _accessTokenFocus, + obscureText: true, + onChanged: (v) => + ref.read(tmdbSettingsProvider.notifier).setAccessToken(v), + ), + _LanguageRow(language: s.language), + ], + ), + SettingsCard( + children: [ + SettingRow( + title: '配置状态', + subtitle: s.isConfigured ? '认证信息已填写' : '请填写当前认证方式对应的密钥', + trailing: Icon( + s.canRequest ? Icons.check_circle : Icons.info_outline, + size: 18, + color: s.canRequest + ? const Color(0xFF52C41A) + : const Color(0xFFFAAD14), + ), + ), + SettingRow( + title: '测试连接', + subtitle: '请求 TMDB /configuration 验证认证和网络', + trailing: FilledButton.tonalIcon( + onPressed: s.isConfigured && !_testingConnection + ? _testConnectionOfficial + : null, + icon: _testingConnection + ? const AppLoadingRing(size: 14) + : const Icon(Icons.network_check_rounded, size: 16), + label: const Text('测试'), + ), + ), + ], + ), + ], + ], + ); + } + + Future _testConnectionOfficial() async { + final s = widget.state; + final accessToken = _accessTokenCtrl.text.trim(); + final apiKey = _apiKeyCtrl.text.trim(); + final hasCredential = switch (s.authMode) { + TmdbAuthMode.bearer => accessToken.isNotEmpty, + TmdbAuthMode.apiKey => apiKey.isNotEmpty, + }; + if (!hasCredential) { + widget.onFeedback('请先填写当前认证方式对应的密钥', tone: AppAlertTone.warning); + return; + } + + setState(() => _testingConnection = true); + final ok = await ref + .read(tmdbGatewayProvider) + .testConnection( + TmdbRequestConfig( + apiBaseUrl: TmdbSettingsState.officialApiBaseUrl, + imageBaseUrl: TmdbSettingsState.officialImageBaseUrl, + accessToken: s.authMode == TmdbAuthMode.bearer ? accessToken : '', + apiKey: s.authMode == TmdbAuthMode.apiKey ? apiKey : '', + ), + language: s.language, + ); + if (!mounted) return; + setState(() => _testingConnection = false); + widget.onFeedback( + ok ? 'TMDB 连接测试成功' : 'TMDB 连接测试失败,请检查密钥或网络', + tone: ok ? AppAlertTone.success : AppAlertTone.error, + ); + } + + Future _testConnectionSmAccount() async { + setState(() => _testingConnection = true); + final token = await ref + .read(smAccountProvider.notifier) + .getAccessToken(force: true); + if (!mounted) return; + if (token == null) { + setState(() => _testingConnection = false); + widget.onFeedback('无法获取访问凭据,请重新登录账号', tone: AppAlertTone.error); + return; + } + final account = ref.read(smAccountProvider).value; + final accountState = account ?? const SmAccountState(); + final ok = await ref + .read(tmdbGatewayProvider) + .testConnection( + TmdbRequestConfig( + apiBaseUrl: accountState.tmdbApiBaseUrl, + imageBaseUrl: TmdbSettingsState.officialImageBaseUrl, + accessToken: token, + apiKey: '', + ), + language: widget.state.language, + ); + if (!mounted) return; + setState(() => _testingConnection = false); + widget.onFeedback( + ok ? 'TMDB 内置代理测试成功' : 'TMDB 内置代理测试失败,请检查账号或网络', + tone: ok ? AppAlertTone.success : AppAlertTone.error, + ); + } +} + + +class _SmAccountTmdbSection extends ConsumerWidget { + final SmAccountState? accountState; + final String language; + final void Function(String message, {AppAlertTone tone}) onFeedback; + final VoidCallback onTestConnection; + final bool testingConnection; + + const _SmAccountTmdbSection({ + required this.accountState, + required this.language, + required this.onFeedback, + required this.onTestConnection, + required this.testingConnection, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final dataPlaneReady = accountState?.canUseDataPlane == true; + + return SettingsCard( + children: [ + SettingRow( + title: '测试连接', + subtitle: '验证当前账号凭据与 TMDB 代理可达性', + trailing: FilledButton.tonalIcon( + onPressed: dataPlaneReady && !testingConnection + ? onTestConnection + : null, + icon: testingConnection + ? const AppLoadingRing(size: 14) + : const Icon(Icons.network_check_rounded, size: 16), + label: const Text('测试'), + ), + ), + ], + ); + } +} + + +class _LanguageRow extends ConsumerWidget { + final String language; + const _LanguageRow({required this.language}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return SettingRow( + title: '语言', + trailing: _LanguageSelect(language: language), + ); + } +} + + +class _LanguageCard extends ConsumerWidget { + final String language; + const _LanguageCard({required this.language}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return SettingsCard( + children: [ + SettingRow( + title: '语言', + trailing: _LanguageSelect(language: language), + ), + ], + ); + } +} + +class _LanguageSelect extends ConsumerWidget { + final String language; + const _LanguageSelect({required this.language}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return SettingSelect( + value: language, + options: const ['zh-CN', 'zh-TW', 'en-US', 'ja-JP', 'ko-KR'], + labelOf: tmdbLanguageLabel, + onChanged: (v) { + if (v != null) { + ref.read(tmdbSettingsProvider.notifier).setLanguage(v); + } + }, + ); + } +} diff --git a/lib/features/settings/tabs/trakt_settings_tab.dart b/lib/features/settings/tabs/trakt_settings_tab.dart new file mode 100644 index 0000000..464fa8b --- /dev/null +++ b/lib/features/settings/tabs/trakt_settings_tab.dart @@ -0,0 +1,126 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../providers/trakt_scrobble_status_provider.dart'; +import '../../../providers/trakt_settings_provider.dart'; +import '../../../shared/widgets/app_inline_alert.dart'; +import '../../../shared/widgets/app_loading_ring.dart'; +import '../widgets/settings_card.dart'; + +class TraktSettingsTab extends ConsumerWidget { + final void Function(String message, {AppAlertTone tone}) onFeedback; + + const TraktSettingsTab({super.key, required this.onFeedback}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final trakt = ref.watch(traktSettingsProvider); + + return SettingsTabScroll( + children: [ + trakt.when( + data: (data) => TraktSection(state: data, onFeedback: onFeedback), + loading: () => const Padding( + padding: EdgeInsets.symmetric(vertical: 32), + child: Center(child: AppLoadingRing()), + ), + error: (e, _) => SettingsCard(children: [settingsLoadError(e)]), + ), + ], + ); + } +} + +class TraktSection extends ConsumerStatefulWidget { + final TraktState state; + final void Function(String message, {AppAlertTone tone}) onFeedback; + + const TraktSection({ + super.key, + required this.state, + required this.onFeedback, + }); + + @override + ConsumerState createState() => TraktSectionState(); +} + +class TraktSectionState extends ConsumerState { + bool _busy = false; + + @override + Widget build(BuildContext context) { + final s = widget.state; + + if (s.isConnected) { + return SettingsCard( + children: [ + SettingRow( + title: '已连接', + subtitle: s.username.isEmpty ? 'Trakt 账号' : '@${s.username}', + trailing: const Icon( + Icons.check_circle, + size: 18, + color: Color(0xFF52C41A), + ), + ), + SettingRow( + title: '断开连接', + subtitle: '清除本地保存的 Trakt 令牌', + trailing: _busy + ? const AppLoadingRing(size: 18) + : const Icon(Icons.logout, size: 18), + onTap: _busy ? null : _disconnect, + ), + ], + ); + } + + return SettingsCard( + children: [ + SettingRow( + title: '连接 Trakt', + subtitle: '通过浏览器授权你的 Trakt 账号', + trailing: _busy + ? const AppLoadingRing(size: 18) + : const Icon(Icons.open_in_new, size: 18), + onTap: _busy ? null : _connect, + ), + ], + ); + } + + Future _connect() async { + setState(() => _busy = true); + String? message; + var tone = AppAlertTone.success; + try { + await ref.read(traktSettingsProvider.notifier).connect(); + message = '已连接 Trakt'; + unawaited(ref.read(traktScrobbleStatusProvider.notifier).retryPending()); + } on TraktConnectException catch (e) { + message = e.message; + tone = AppAlertTone.error; + } catch (_) { + message = '连接失败'; + tone = AppAlertTone.error; + } + if (!mounted) return; + setState(() => _busy = false); + _toast(message, tone: tone); + } + + Future _disconnect() async { + setState(() => _busy = true); + await ref.read(traktSettingsProvider.notifier).disconnect(); + if (!mounted) return; + setState(() => _busy = false); + _toast('已断开 Trakt', tone: AppAlertTone.success); + } + + void _toast(String message, {AppAlertTone tone = AppAlertTone.info}) { + widget.onFeedback(message, tone: tone); + } +} diff --git a/lib/features/settings/utils/settings_labels.dart b/lib/features/settings/utils/settings_labels.dart new file mode 100644 index 0000000..83169a1 --- /dev/null +++ b/lib/features/settings/utils/settings_labels.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; + +import '../../../providers/danmaku_settings_provider.dart'; +import '../../../providers/appearance_provider.dart'; +import '../../../providers/tmdb_settings_provider.dart'; + +String formatMenuSpeed(double v) { + return '${v.toStringAsFixed(2)}X'; +} + +String themeModeLabel(ThemeMode mode) => switch (mode) { + ThemeMode.system => '跟随系统', + ThemeMode.light => '亮色', + ThemeMode.dark => '暗色', +}; + +String headerModeLabel(HeaderMode mode) => switch (mode) { + HeaderMode.solid => '纯色', + HeaderMode.frosted => '磨砂', +}; + +String startupPageLabel(StartupPage page) => switch (page) { + StartupPage.home => '首页', + StartupPage.servers => '服务器', + StartupPage.aggregated => '聚合视界', + StartupPage.discover => '发现', + StartupPage.settings => '设置', +}; + +String audioLangLabel(String lang) => switch (lang) { + 'chi' => '中文', + 'eng' => '英语', + 'jpn' => '日语', + _ => '默认', +}; + +String chineseConvertLabel(ChineseConvertMode mode) => switch (mode) { + ChineseConvertMode.none => '不转换', + ChineseConvertMode.toSimplified => '转简体', + ChineseConvertMode.toTraditional => '转繁体', +}; + +String tmdbAccessModeLabel(TmdbAccessMode mode) => switch (mode) { + TmdbAccessMode.official => '官方 TMDB', + TmdbAccessMode.smAccount => '内置代理(sm-server)', +}; + +String tmdbAuthModeLabel(TmdbAuthMode mode) => switch (mode) { + TmdbAuthMode.apiKey => 'API Key', + TmdbAuthMode.bearer => 'Bearer Token', +}; + +String tmdbLanguageLabel(String lang) => switch (lang) { + 'zh-CN' => '简体中文', + 'zh-TW' => '繁体中文', + 'en-US' => '英语', + 'ja-JP' => '日语', + 'ko-KR' => '韩语', + _ => lang, +}; diff --git a/lib/features/settings/widgets/blocked_keywords_dialog.dart b/lib/features/settings/widgets/blocked_keywords_dialog.dart new file mode 100644 index 0000000..1899c10 --- /dev/null +++ b/lib/features/settings/widgets/blocked_keywords_dialog.dart @@ -0,0 +1,165 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:forui/forui.dart'; + +import '../../../providers/danmaku_settings_provider.dart'; +import '../../../shared/constants/breakpoints.dart'; +import '../../../shared/widgets/adaptive_modal.dart'; + +void showBlockedKeywordsDialog( + BuildContext context, + WidgetRef ref, + List keywords, +) { + showAdaptiveModal( + context: context, + maxWidth: 420, + compactMaxHeightFactor: 0.9, + builder: (ctx) => _BlockedKeywordsDialog( + keywords: keywords, + onAdd: (kw) => + ref.read(danmakuSettingsProvider.notifier).addBlockedKeyword(kw), + onRemove: (kw) => + ref.read(danmakuSettingsProvider.notifier).removeBlockedKeyword(kw), + ), + ); +} + +class _BlockedKeywordsDialog extends StatefulWidget { + final List keywords; + final ValueChanged onAdd; + final ValueChanged onRemove; + + const _BlockedKeywordsDialog({ + required this.keywords, + required this.onAdd, + required this.onRemove, + }); + + @override + State<_BlockedKeywordsDialog> createState() => _BlockedKeywordsDialogState(); +} + +class _BlockedKeywordsDialogState extends State<_BlockedKeywordsDialog> { + late final List _keywords; + final _controller = TextEditingController(); + + @override + void initState() { + super.initState(); + _keywords = List.of(widget.keywords); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _add() { + final text = _controller.text.trim(); + if (text.isEmpty || _keywords.contains(text)) return; + setState(() => _keywords.add(text)); + widget.onAdd(text); + _controller.clear(); + } + + void _remove(String kw) { + setState(() => _keywords.remove(kw)); + widget.onRemove(kw); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isCompact = Breakpoints.isCompact(MediaQuery.sizeOf(context).width); + final chipsMaxHeight = + MediaQuery.sizeOf(context).height * (isCompact ? 0.38 : 0.34); + + return SingleChildScrollView( + padding: EdgeInsets.fromLTRB(24, isCompact ? 16 : 24, 24, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('屏蔽关键词', style: theme.textTheme.titleLarge), + const SizedBox(height: 8), + Text( + '匹配到这些关键词的弹幕将被隐藏', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: TextField( + controller: _controller, + decoration: const InputDecoration( + hintText: '输入关键词后回车添加', + isDense: true, + ), + textInputAction: TextInputAction.done, + autocorrect: false, + enableSuggestions: false, + onSubmitted: (_) => _add(), + ), + ), + const SizedBox(width: 8), + IconButton.filledTonal( + tooltip: '添加', + onPressed: _add, + icon: const Icon(Icons.add, size: 18), + ), + ], + ), + const SizedBox(height: 12), + if (_keywords.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 24), + child: Text( + '暂无屏蔽关键词', + textAlign: TextAlign.center, + style: TextStyle(color: theme.colorScheme.onSurfaceVariant), + ), + ) + else + ConstrainedBox( + constraints: BoxConstraints(maxHeight: chipsMaxHeight), + child: SingleChildScrollView( + child: Wrap( + spacing: 8, + runSpacing: 8, + children: _keywords + .map( + (kw) => Chip( + label: Text(kw), + onDeleted: () => _remove(kw), + deleteIcon: const Icon(Icons.close, size: 14), + ), + ) + .toList(), + ), + ), + ), + const SizedBox(height: 20), + if (isCompact) + FButton( + onPress: () => Navigator.pop(context), + child: const Text('完成'), + ) + else + Align( + alignment: Alignment.centerRight, + child: FButton( + variant: FButtonVariant.ghost, + onPress: () => Navigator.pop(context), + child: const Text('完成'), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/settings/widgets/danmaku_sources_dialog.dart b/lib/features/settings/widgets/danmaku_sources_dialog.dart new file mode 100644 index 0000000..a46874f --- /dev/null +++ b/lib/features/settings/widgets/danmaku_sources_dialog.dart @@ -0,0 +1,788 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:forui/forui.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../core/contracts/danmaku.dart'; +import '../../../providers/danmaku_settings_provider.dart'; +import '../../../shared/constants/breakpoints.dart'; +import '../../../shared/theme/app_theme_scope.dart'; +import '../../../shared/theme/app_theme.dart'; + +String danmakuSourcesSummary(List sources) { + if (sources.isEmpty) return '未配置'; + final enabled = sources.where((s) => s.enabled).length; + if (enabled == 0) return '已配置 ${sources.length} 个,全部停用'; + final first = sources.firstWhere( + (s) => s.enabled, + orElse: () => sources.first, + ); + return '$enabled/${sources.length} 个启用,优先:${first.displayName}'; +} + +void showDanmakuSourcesDialog( + BuildContext context, + List sources, +) { + final isCompact = MediaQuery.sizeOf(context).width < Breakpoints.compact; + if (isCompact) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => _DanmakuSourcesSheet(initialSources: sources), + ); + } else { + showFSheet( + context: context, + side: FLayout.rtl, + mainAxisMaxRatio: 0.42, + draggable: false, + builder: (_) => AppThemeScope( + child: _DanmakuSourcesDesktopSheet(initialSources: sources), + ), + ); + } +} + +mixin _DanmakuSourcesLogic + on ConsumerState { + late List sources; + + void initSources(List initial) { + sources = List.of(initial); + } + + Future persist(List next) async { + final normalized = await ref + .read(danmakuSettingsProvider.notifier) + .setSources(next); + if (!mounted) return; + setState(() => sources = normalized); + } + + Future showEditDialog(DanmakuSource? source) async { + final nameCtrl = TextEditingController(text: source?.name ?? ''); + final urlCtrl = TextEditingController(text: source?.url ?? ''); + try { + final saved = await showFDialog( + context: context, + builder: (ctx, _, animation) => AppThemeScope( + child: FDialog.adaptive( + animation: animation, + title: Text(source == null ? '添加弹幕源' : '编辑弹幕源'), + body: SizedBox( + width: 420, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: nameCtrl, + decoration: const InputDecoration( + labelText: '名称', + hintText: '例如:主源、备用源', + isDense: true, + ), + ), + const SizedBox(height: 12), + TextField( + controller: urlCtrl, + autofocus: source == null, + decoration: const InputDecoration( + labelText: '服务地址', + hintText: 'http://localhost:8080', + isDense: true, + ), + ), + ], + ), + ), + actions: [ + FButton( + onPress: () { + final url = urlCtrl.text.trim(); + if (url.isEmpty) return; + final id = source?.id.isNotEmpty == true + ? source!.id + : 'src_${url.hashCode.toUnsigned(32).toRadixString(16)}'; + Navigator.pop( + ctx, + DanmakuSource( + id: id, + name: nameCtrl.text.trim(), + url: url, + enabled: source?.enabled ?? true, + ), + ); + }, + child: const Text('保存'), + ), + FButton( + onPress: () => Navigator.pop(ctx), + variant: FButtonVariant.outline, + child: const Text('取消'), + ), + ], + ), + ), + ); + if (saved == null) return; + + final existing = sources.indexWhere((s) => s.id == saved.id); + final next = [...sources]; + if (existing >= 0) { + next[existing] = saved; + } else { + next.add(saved); + } + await persist(next); + } finally { + nameCtrl.dispose(); + urlCtrl.dispose(); + } + } + + Future removeSource(DanmakuSource source) async { + await persist(sources.where((s) => s.id != source.id).toList()); + } + + Future toggleSource(DanmakuSource source, bool enabled) async { + await persist( + sources + .map((s) => s.id == source.id ? s.copyWith(enabled: enabled) : s) + .toList(), + ); + } + + Future reorderSource(int oldIndex, int newIndex) async { + final next = [...sources]; + if (newIndex > oldIndex) newIndex -= 1; + final item = next.removeAt(oldIndex); + next.insert(newIndex, item); + await persist(next); + } +} + +class _DanmakuSourcesSheet extends ConsumerStatefulWidget { + final List initialSources; + + const _DanmakuSourcesSheet({required this.initialSources}); + + @override + ConsumerState<_DanmakuSourcesSheet> createState() => + _DanmakuSourcesSheetState(); +} + +class _DanmakuSourcesSheetState extends ConsumerState<_DanmakuSourcesSheet> + with _DanmakuSourcesLogic<_DanmakuSourcesSheet> { + bool _editing = false; + + @override + void initState() { + super.initState(); + initSources(widget.initialSources); + } + + @override + Future persist(List next) async { + await super.persist(next); + if (mounted && sources.isEmpty && _editing) { + setState(() => _editing = false); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final tokens = context.appTokens; + + return Container( + constraints: BoxConstraints( + maxHeight: MediaQuery.sizeOf(context).height * 0.7, + ), + decoration: BoxDecoration( + color: theme.colorScheme.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 20, 8, 0), + child: Row( + children: [ + Text( + '弹幕源', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const Spacer(), + if (sources.isNotEmpty) + TextButton( + onPressed: () => setState(() => _editing = !_editing), + child: Text(_editing ? '完成' : '编辑'), + ), + ], + ), + ), + + Padding( + padding: const EdgeInsets.fromLTRB(20, 4, 20, 12), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + '按顺序自动匹配;启用的源会聚合显示候选,选中后从对应源加载弹幕。', + style: TextStyle( + fontSize: 13, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ), + + if (sources.isNotEmpty) + Flexible( + child: _editing + ? ReorderableListView.builder( + shrinkWrap: true, + buildDefaultDragHandles: false, + itemCount: sources.length, + onReorder: (oldIdx, newIdx) => + unawaited(reorderSource(oldIdx, newIdx)), + itemBuilder: (context, index) { + final source = sources[index]; + return _DanmakuEditRow( + key: ValueKey(source.id), + source: source, + index: index, + onDelete: () => unawaited(removeSource(source)), + onEdit: () => unawaited(showEditDialog(source)), + ); + }, + ) + : ListView.builder( + shrinkWrap: true, + itemCount: sources.length, + itemBuilder: (context, index) { + final source = sources[index]; + final hasName = source.name.trim().isNotEmpty; + return InkWell( + onTap: () => unawaited(showEditDialog(source)), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 10, + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + hasName + ? source.name.trim() + : source.url, + style: theme.textTheme.bodyMedium + ?.copyWith( + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (hasName) + Text( + source.url, + style: theme.textTheme.bodySmall + ?.copyWith( + color: tokens.mutedForeground, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + FSwitch( + value: source.enabled, + onChange: (v) => + unawaited(toggleSource(source, v)), + ), + ], + ), + ), + ); + }, + ), + ), + + Padding( + padding: EdgeInsets.fromLTRB( + 8, + 4, + 8, + MediaQuery.paddingOf(context).bottom + 16, + ), + child: SizedBox( + width: double.infinity, + child: TextButton.icon( + onPressed: () => unawaited(showEditDialog(null)), + icon: const Icon(Icons.add, size: 16), + label: const Text('添加弹幕源'), + ), + ), + ), + ], + ), + ); + } +} + +class _DanmakuEditRow extends StatefulWidget { + final DanmakuSource source; + final int index; + final VoidCallback onDelete; + final VoidCallback onEdit; + + const _DanmakuEditRow({ + required super.key, + required this.source, + required this.index, + required this.onDelete, + required this.onEdit, + }); + + @override + State<_DanmakuEditRow> createState() => _DanmakuEditRowState(); +} + +class _DanmakuEditRowState extends State<_DanmakuEditRow> + with SingleTickerProviderStateMixin { + static const _deleteWidth = 72.0; + + late final AnimationController _controller; + late final Animation _slideAnim; + bool _expanded = false; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 250), + ); + _slideAnim = Tween( + begin: Offset.zero, + end: const Offset(-0.2, 0), + ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeInOut)); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _toggle() { + setState(() => _expanded = !_expanded); + if (_expanded) { + _controller.forward(); + } else { + _controller.reverse(); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final tokens = context.appTokens; + final source = widget.source; + final hasName = source.name.trim().isNotEmpty; + + return ClipRect( + child: SizedBox( + height: hasName ? 64 : 52, + child: Stack( + children: [ + Positioned( + right: 0, + top: 0, + bottom: 0, + width: _deleteWidth, + child: Material( + color: Colors.red, + child: InkWell( + onTap: widget.onDelete, + child: const Center( + child: Text( + '删除', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + fontSize: 14, + ), + ), + ), + ), + ), + ), + + SlideTransition( + position: _slideAnim, + child: Container( + color: theme.colorScheme.surface, + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + children: [ + GestureDetector( + onTap: _toggle, + behavior: HitTestBehavior.opaque, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: AnimatedRotation( + turns: _expanded ? 0.0 : -0.25, + duration: const Duration(milliseconds: 250), + child: const Icon( + Icons.remove_circle, + color: Colors.red, + size: 22, + ), + ), + ), + ), + + Expanded( + child: GestureDetector( + onTap: widget.onEdit, + behavior: HitTestBehavior.opaque, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + hasName ? source.name.trim() : source.url, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (hasName) + Text( + source.url, + style: theme.textTheme.bodySmall?.copyWith( + color: tokens.mutedForeground, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ), + + ReorderableDragStartListener( + index: widget.index, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Icon( + Icons.drag_handle, + size: 18, + color: tokens.mutedForeground, + ), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } +} + +class _DanmakuSourcesDesktopSheet extends ConsumerStatefulWidget { + final List initialSources; + + const _DanmakuSourcesDesktopSheet({required this.initialSources}); + + @override + ConsumerState<_DanmakuSourcesDesktopSheet> createState() => + _DanmakuSourcesDesktopSheetState(); +} + +class _DanmakuSourcesDesktopSheetState + extends ConsumerState<_DanmakuSourcesDesktopSheet> + with _DanmakuSourcesLogic<_DanmakuSourcesDesktopSheet> { + bool _editing = false; + DanmakuSource? _editingSource; + final _nameCtrl = TextEditingController(); + final _urlCtrl = TextEditingController(); + + @override + void initState() { + super.initState(); + initSources(widget.initialSources); + } + + @override + void dispose() { + _nameCtrl.dispose(); + _urlCtrl.dispose(); + super.dispose(); + } + + void _startEdit(DanmakuSource? source) { + _nameCtrl.text = source?.name ?? ''; + _urlCtrl.text = source?.url ?? ''; + setState(() { + _editing = true; + _editingSource = source; + }); + } + + void _cancelEdit() { + setState(() { + _editing = false; + _editingSource = null; + }); + } + + Future _saveEdit() async { + final url = _urlCtrl.text.trim(); + if (url.isEmpty) return; + final source = _editingSource; + final id = source?.id.isNotEmpty == true + ? source!.id + : 'src_${url.hashCode.toUnsigned(32).toRadixString(16)}'; + final saved = DanmakuSource( + id: id, + name: _nameCtrl.text.trim(), + url: url, + enabled: source?.enabled ?? true, + ); + final existing = sources.indexWhere((s) => s.id == saved.id); + final next = [...sources]; + if (existing >= 0) { + next[existing] = saved; + } else { + next.add(saved); + } + await persist(next); + if (!mounted) return; + setState(() { + _editing = false; + _editingSource = null; + }); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Material( + color: theme.colorScheme.surface, + child: SafeArea( + left: false, + right: false, + child: Column( + children: [ + _buildHeader(theme), + const Divider(height: 1), + Expanded(child: _editing ? _buildEditView() : _buildListView(theme)), + ], + ), + ), + ); + } + + Widget _buildHeader(ThemeData theme) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 8, 12), + child: Row( + children: [ + if (_editing) + IconButton( + tooltip: '返回', + icon: const Icon(Icons.arrow_back, size: 20), + onPressed: _cancelEdit, + ) + else + const SizedBox(width: 8), + Expanded( + child: Text( + _editing + ? (_editingSource == null ? '添加弹幕源' : '编辑弹幕源') + : '弹幕源', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + IconButton( + tooltip: '关闭', + icon: const Icon(Icons.close, size: 20), + onPressed: () => Navigator.pop(context), + ), + ], + ), + ); + } + + Widget _buildListView(ThemeData theme) { + return Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 12), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + '按顺序自动匹配;启用的源会聚合显示候选,选中后从对应源加载弹幕。', + style: TextStyle( + fontSize: 13, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ), + Expanded( + child: sources.isEmpty + ? Center( + child: Text( + '尚未配置弹幕源', + style: TextStyle( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ) + : ReorderableListView.builder( + buildDefaultDragHandles: false, + padding: const EdgeInsets.symmetric(horizontal: 8), + itemCount: sources.length, + onReorder: (oldIndex, newIndex) => + unawaited(reorderSource(oldIndex, newIndex)), + itemBuilder: (context, index) { + final source = sources[index]; + return ListTile( + key: ValueKey(source.id), + leading: ReorderableDragStartListener( + index: index, + child: const Icon(Icons.drag_handle, size: 18), + ), + title: Text( + source.displayName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: source.name.trim().isEmpty + ? null + : Text( + source.url, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + FSwitch( + value: source.enabled, + onChange: (v) => unawaited(toggleSource(source, v)), + ), + IconButton( + tooltip: '编辑', + icon: const Icon(Icons.edit_outlined, size: 18), + onPressed: () => _startEdit(source), + ), + IconButton( + tooltip: '移除', + icon: const Icon(Icons.delete_outline, size: 18), + onPressed: () => unawaited(removeSource(source)), + ), + ], + ), + ); + }, + ), + ), + const Divider(height: 1), + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), + child: Row( + children: [ + Expanded( + child: FButton( + onPress: () => _startEdit(null), + prefix: const Icon(Icons.add, size: 16), + child: const Text('添加'), + ), + ), + ], + ), + ), + ], + ); + } + + Widget _buildEditView() { + return Column( + children: [ + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(20, 20, 20, 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: _nameCtrl, + decoration: const InputDecoration( + labelText: '名称', + hintText: '例如:主源、备用源', + isDense: true, + ), + ), + const SizedBox(height: 16), + TextField( + controller: _urlCtrl, + autofocus: _editingSource == null, + decoration: const InputDecoration( + labelText: '服务地址', + hintText: 'http://localhost:8080', + isDense: true, + ), + ), + ], + ), + ), + ), + const Divider(height: 1), + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), + child: Row( + children: [ + Expanded( + child: FButton( + onPress: _cancelEdit, + variant: FButtonVariant.outline, + child: const Text('取消'), + ), + ), + const SizedBox(width: 12), + Expanded( + child: FButton( + onPress: () => unawaited(_saveEdit()), + child: const Text('保存'), + ), + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/features/settings/widgets/player_engine_row.dart b/lib/features/settings/widgets/player_engine_row.dart new file mode 100644 index 0000000..6527c3f --- /dev/null +++ b/lib/features/settings/widgets/player_engine_row.dart @@ -0,0 +1,72 @@ +import 'dart:io' show Platform; + +import 'package:flutter/material.dart'; +import '../../../providers/player_engine_override_provider.dart'; +import '../../../shared/widgets/setting_select.dart'; + + +class PlayerEngineRow extends StatelessWidget { + final PlayerEngineOverride value; + final ValueChanged onChanged; + + + final bool? isAndroid; + + const PlayerEngineRow({ + super.key, + required this.value, + required this.onChanged, + this.isAndroid, + }); + + + static List visibleOptions({required bool isAndroid}) { + return PlayerEngineOverride.values + .where((v) => v != PlayerEngineOverride.media3 || isAndroid) + .toList(); + } + + + static PlayerEngineOverride coerceValue( + PlayerEngineOverride value, { + required bool isAndroid, + }) { + if (value == PlayerEngineOverride.media3 && !isAndroid) { + return PlayerEngineOverride.auto; + } + return value; + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final onAndroid = isAndroid ?? Platform.isAndroid; + + final options = visibleOptions(isAndroid: onAndroid); + final coercedValue = coerceValue(value, isAndroid: onAndroid); + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), + child: Row( + children: [ + Expanded( + child: Text( + '播放引擎', + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + ), + SettingSelect( + value: coercedValue, + options: options, + labelOf: (v) => v.label, + onChanged: (v) { + if (v != null) onChanged(v); + }, + ), + ], + ), + ); + } +} diff --git a/lib/features/settings/widgets/settings_card.dart b/lib/features/settings/widgets/settings_card.dart new file mode 100644 index 0000000..600510a --- /dev/null +++ b/lib/features/settings/widgets/settings_card.dart @@ -0,0 +1,138 @@ +import 'package:flutter/material.dart'; + +import '../../../shared/theme/app_theme.dart'; +import '../../../shared/utils/user_error_formatter.dart'; +import '../../../shared/widgets/app_inline_alert.dart'; + + +Widget settingsLoadError(Object error) { + return AppInlineAlert(message: '加载失败:${formatUserError(error)}'); +} + +class SettingsTabScroll extends StatelessWidget { + final List children; + const SettingsTabScroll({super.key, required this.children}); + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.fromLTRB(0, 12, 0, 32), + children: [ + Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 720), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: children, + ), + ), + ), + ), + ], + ); + } +} + +class SettingsCard extends StatelessWidget { + final List children; + + const SettingsCard({super.key, required this.children}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final dividerColor = isDark + ? Colors.white.withValues(alpha: 0.08) + : Colors.black.withValues(alpha: 0.06); + + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Container( + decoration: BoxDecoration( + color: isDark ? const Color(0xFF2C2C2E) : Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: dividerColor, width: 0.5), + ), + clipBehavior: Clip.antiAlias, + child: Column( + children: [ + for (int i = 0; i < children.length; i++) ...[ + children[i], + if (i < children.length - 1) + Divider( + height: 1, + thickness: 0.5, + indent: 16, + endIndent: 16, + color: dividerColor, + ), + ], + ], + ), + ), + ); + } +} + +class SettingRow extends StatelessWidget { + final String title; + final String? subtitle; + final Widget? trailing; + final VoidCallback? onTap; + + const SettingRow({ + super.key, + required this.title, + this.subtitle, + this.trailing, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final tokens = context.appTokens; + + Widget content = Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + if (subtitle != null) ...[ + const SizedBox(height: 2), + Text( + subtitle!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: tokens.mutedForeground, + ), + ), + ], + ], + ), + ), + if (trailing != null) trailing!, + ], + ), + ); + + if (onTap != null) { + content = InkWell(onTap: onTap, child: content); + } + + return content; + } +} diff --git a/lib/features/settings/widgets/settings_rows.dart b/lib/features/settings/widgets/settings_rows.dart new file mode 100644 index 0000000..3314e13 --- /dev/null +++ b/lib/features/settings/widgets/settings_rows.dart @@ -0,0 +1,148 @@ +import 'package:flutter/material.dart'; +import '../../../core/contracts/player_gestures.dart'; +import '../../../shared/widgets/setting_select.dart'; +import '../../player/gestures/gesture_action.dart' as g; +import '../utils/settings_labels.dart'; + +class SeekDurationRow extends StatelessWidget { + final String title; + final int seconds; + final ValueChanged onChanged; + + static const _options = [5, 10, 15, 20, 30, 45, 60, 90, 120]; + + const SeekDurationRow({ + super.key, + required this.title, + required this.seconds, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), + child: Row( + children: [ + Expanded( + child: Text( + title, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + ), + SettingSelect( + value: seconds, + options: _options, + labelOf: (value) => '$value 秒', + onChanged: (v) { + if (v != null) onChanged(v); + }, + ), + ], + ), + ); + } +} + +class GestureSpeedRow extends StatelessWidget { + final String title; + final double value; + final ValueChanged onChanged; + + static const _options = [ + 0.5, + 0.75, + 1.0, + 1.25, + 1.5, + 1.75, + 2.0, + 2.5, + 3.0, + 4.0, + 8.0, + ]; + + const GestureSpeedRow({ + super.key, + required this.title, + required this.value, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), + child: Row( + children: [ + Expanded( + child: Text( + title, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + ), + SettingSelect( + value: value, + options: _options, + labelOf: formatMenuSpeed, + onChanged: (v) { + if (v != null) onChanged(v); + }, + ), + ], + ), + ); + } +} + +class GestureActionRow extends StatelessWidget { + final String title; + final g.GestureKind kind; + final GestureAction value; + final ValueChanged onChanged; + + const GestureActionRow({ + super.key, + required this.title, + required this.kind, + required this.value, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final options = g.allowedActionsFor(kind); + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), + child: Row( + children: [ + Expanded( + child: Text( + title, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + ), + SettingSelect( + value: value, + options: options, + labelOf: g.gestureActionLabel, + onChanged: (v) { + if (v != null) onChanged(v); + }, + ), + ], + ), + ); + } +} diff --git a/lib/features/settings/widgets/settings_stepper_row.dart b/lib/features/settings/widgets/settings_stepper_row.dart new file mode 100644 index 0000000..6d14265 --- /dev/null +++ b/lib/features/settings/widgets/settings_stepper_row.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; +import 'package:forui/forui.dart'; + +import '../../../shared/theme/app_theme.dart'; + +class StepperRow extends StatelessWidget { + final String title; + final String display; + final VoidCallback? onDecrement; + final VoidCallback? onIncrement; + + const StepperRow({ + super.key, + required this.title, + required this.display, + this.onDecrement, + this.onIncrement, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final tokens = context.appTokens; + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 16), + child: Row( + children: [ + Expanded( + child: Text( + title, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + ), + _stepBtn(Icons.remove, onDecrement, theme), + SizedBox( + width: 52, + child: Text( + display, + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + color: tokens.mutedForeground, + ), + ), + ), + _stepBtn(Icons.add, onIncrement, theme), + ], + ), + ); + } + + Widget _stepBtn(IconData icon, VoidCallback? onPressed, ThemeData theme) { + return SizedBox.square( + dimension: 28, + child: FButton.icon( + variant: FButtonVariant.ghost, + size: FButtonSizeVariant.xs, + onPress: onPressed, + child: Icon( + icon, + size: 14, + color: onPressed != null + ? theme.colorScheme.onSurface + : theme.colorScheme.onSurface.withValues(alpha: 0.25), + ), + ), + ); + } +} diff --git a/lib/features/settings/widgets/settings_text_field_row.dart b/lib/features/settings/widgets/settings_text_field_row.dart new file mode 100644 index 0000000..230b869 --- /dev/null +++ b/lib/features/settings/widgets/settings_text_field_row.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; + +class SettingsTextFieldRow extends StatelessWidget { + final String title; + final String hintText; + final TextEditingController controller; + final FocusNode focusNode; + final ValueChanged onChanged; + final bool obscureText; + + const SettingsTextFieldRow({ + super.key, + required this.title, + required this.hintText, + required this.controller, + required this.focusNode, + required this.onChanged, + this.obscureText = false, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Row( + children: [ + Expanded( + flex: 1, + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + flex: 2, + child: Align( + alignment: Alignment.centerRight, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 360), + child: TextField( + controller: controller, + focusNode: focusNode, + obscureText: obscureText, + onChanged: onChanged, + decoration: InputDecoration( + hintText: hintText, + isDense: true, + suffixIcon: controller.text.isEmpty + ? null + : IconButton( + tooltip: '清空', + icon: const Icon(Icons.close, size: 16), + onPressed: () { + controller.clear(); + onChanged(''); + }, + ), + ), + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/settings/widgets/version_priority_section.dart b/lib/features/settings/widgets/version_priority_section.dart new file mode 100644 index 0000000..220d966 --- /dev/null +++ b/lib/features/settings/widgets/version_priority_section.dart @@ -0,0 +1,157 @@ +import 'package:flutter/material.dart'; +import 'package:forui/forui.dart'; + +import '../../../providers/version_priority_provider.dart'; +import '../../../shared/theme/app_theme.dart'; +import '../../../shared/theme/app_theme_scope.dart'; + +class VersionPrioritySection extends StatelessWidget { + final List activePriorities; + final ValueChanged onToggle; + final ValueChanged> onReorder; + + const VersionPrioritySection({ + super.key, + required this.activePriorities, + required this.onToggle, + required this.onReorder, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final tokens = context.appTokens; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '选择后按顺序排列详情页的视频版本', + style: theme.textTheme.bodySmall?.copyWith( + color: tokens.mutedForeground, + ), + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: VersionPriority.values.map((p) { + final isActive = activePriorities.contains(p); + return FilterChip( + label: Text(p.label), + selected: isActive, + onSelected: (_) => onToggle(p), + ); + }).toList(), + ), + if (activePriorities.isNotEmpty) ...[ + const SizedBox(height: 12), + Text( + activePriorities + .map((p) => p.label.replaceAll('优先', '')) + .join(' > '), + style: theme.textTheme.bodySmall?.copyWith( + color: tokens.mutedForeground, + ), + ), + ], + if (activePriorities.length > 1) ...[ + const SizedBox(height: 8), + GestureDetector( + onTap: () => _showReorderDialog(context), + child: Text( + '调整顺序', + style: TextStyle(fontSize: 12, color: theme.colorScheme.primary), + ), + ), + ], + ], + ); + } + + void _showReorderDialog(BuildContext context) { + showFDialog( + context: context, + builder: (_, _, animation) => AppThemeScope( + child: FDialog.raw( + animation: animation, + builder: (context, _) => PriorityOrderDialog( + priorities: activePriorities, + onChanged: onReorder, + ), + ), + ), + ); + } +} + +class PriorityOrderDialog extends StatefulWidget { + final List priorities; + final ValueChanged> onChanged; + + const PriorityOrderDialog({ + super.key, + required this.priorities, + required this.onChanged, + }); + + @override + State createState() => PriorityOrderDialogState(); +} + +class PriorityOrderDialogState extends State { + late final List _items; + + @override + void initState() { + super.initState(); + _items = List.of(widget.priorities); + } + + @override + Widget build(BuildContext context) { + return FDialog( + title: const Text('调整优先级顺序'), + body: SizedBox( + width: 320, + height: (_items.length * 56.0).clamp(0, 400), + child: ReorderableListView.builder( + shrinkWrap: true, + buildDefaultDragHandles: false, + itemCount: _items.length, + onReorder: (oldIndex, newIndex) { + setState(() { + if (newIndex > oldIndex) newIndex -= 1; + final item = _items.removeAt(oldIndex); + _items.insert(newIndex, item); + }); + widget.onChanged(List.of(_items)); + }, + itemBuilder: (context, index) { + return ListTile( + key: ValueKey(_items[index].name), + leading: Text( + '${index + 1}', + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + title: Text( + _items[index].label, + style: const TextStyle(fontSize: 14), + ), + trailing: ReorderableDragStartListener( + index: index, + child: const Icon(Icons.drag_handle, size: 18), + ), + ); + }, + ), + ), + actions: [ + FButton(onPress: () => Navigator.pop(context), child: const Text('完成')), + ], + ); + } +} diff --git a/lib/features/update/update_dialog.dart b/lib/features/update/update_dialog.dart new file mode 100644 index 0000000..5524aa1 --- /dev/null +++ b/lib/features/update/update_dialog.dart @@ -0,0 +1,182 @@ +import 'package:flutter/material.dart'; +import 'package:forui/forui.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../../providers/update_provider.dart'; +import '../../shared/app_info.dart'; +import '../../shared/utils/format_utils.dart'; + +class UpdateDialog extends ConsumerStatefulWidget { + const UpdateDialog({super.key}); + + @override + ConsumerState createState() => _UpdateDialogState(); +} + +class _UpdateDialogState extends ConsumerState { + bool _installTriggered = false; + + @override + Widget build(BuildContext context) { + final state = ref.watch(updateProvider); + final info = state.info; + final appVersion = + ref.watch(appVersionProvider).value ?? kSmPlayerVersionFallback; + + if (state.status == UpdateStatus.readyToInstall && !_installTriggered) { + _installTriggered = true; + WidgetsBinding.instance.addPostFrameCallback((_) async { + final navigator = Navigator.of(context); + await ref.read(updateProvider.notifier).install(); + if (mounted && navigator.canPop()) { + navigator.pop(); + } + }); + } + + if (info == null) { + return FDialog.adaptive( + title: const Text('检查更新'), + body: _ErrorContent(message: state.error ?? '没有可用的更新信息'), + actions: [ + FButton( + onPress: () => ref.read(updateProvider.notifier).manualCheck(), + child: const Text('重试'), + ), + FButton( + onPress: () => Navigator.of(context).pop(), + variant: FButtonVariant.outline, + child: const Text('关闭'), + ), + ], + ); + } + + final isDownloading = state.status == UpdateStatus.downloading; + final isReady = state.status == UpdateStatus.readyToInstall; + final hasError = state.status == UpdateStatus.error; + final noAsset = info.noAssetForPlatform; + + return FDialog.adaptive( + title: Text('发现新版本 ${info.version}'), + body: SizedBox( + width: 460, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('当前版本 $appVersion → 最新版本 ${info.version}'), + const SizedBox(height: 12), + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 220), + child: SingleChildScrollView( + child: Text( + info.releaseNotes.trim().isEmpty + ? '暂无更新说明' + : info.releaseNotes.trim(), + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ), + if (isDownloading) ...[ + const SizedBox(height: 16), + FDeterminateProgress( + value: downloadProgressValue( + received: state.received, + total: state.total, + ) ?? 0, + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '${formatDownloadBytes(state.received)} / ${formatDownloadBytes(state.total)}', + style: Theme.of(context).textTheme.bodySmall, + ), + Text( + formatDownloadSpeed(state.speedBytesPerSec), + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ], + if (isReady) ...[ + const SizedBox(height: 16), + const Text('安装程序已下载,正在打开。'), + ], + if (hasError) ...[ + const SizedBox(height: 16), + _ErrorContent(message: state.error ?? '更新失败'), + ], + ], + ), + ), + actions: [ + if (isDownloading) + FButton( + onPress: () => ref.read(updateProvider.notifier).cancel(), + variant: FButtonVariant.outline, + child: const Text('取消'), + ) + else ...[ + FButton( + onPress: () => Navigator.of(context).pop(), + variant: FButtonVariant.outline, + child: const Text('稍后'), + ), + if (hasError) + FButton( + onPress: () => ref.read(updateProvider.notifier).startDownload(), + child: const Text('重试'), + ) + else if (noAsset) + FButton( + onPress: () => _openDownloadPage(info.htmlUrl), + child: const Text('前往下载页'), + ) + else + FButton( + onPress: isReady + ? null + : () => ref.read(updateProvider.notifier).startDownload(), + child: const Text('下载并安装'), + ), + ], + ], + ); + } + + Future _openDownloadPage(Uri uri) async { + if (!uri.hasScheme) return; + await launchUrl(uri, mode: LaunchMode.externalApplication); + } +} + +class _ErrorContent extends StatelessWidget { + final String message; + + const _ErrorContent({required this.message}); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.error_outline, + size: 18, + color: Theme.of(context).colorScheme.error, + ), + const SizedBox(width: 8), + Flexible( + child: Text( + message, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ), + ], + ); + } +} diff --git a/lib/features/update/update_service.dart b/lib/features/update/update_service.dart new file mode 100644 index 0000000..929b7b5 --- /dev/null +++ b/lib/features/update/update_service.dart @@ -0,0 +1,208 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:crypto/crypto.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter/services.dart'; +import 'package:path_provider/path_provider.dart'; + +import '../../core/contracts/app_update.dart'; +import '../../core/infra/github_releases/github_releases_client.dart'; +import '../../shared/app_info.dart'; +import '../../shared/utils/app_logger.dart'; +import '../../shared/utils/semver.dart'; +import '../../shared/utils/window_bounds.dart'; + +class UpdateVerificationException implements Exception { + UpdateVerificationException(this.message); + final String message; + @override + String toString() => 'UpdateVerificationException: $message'; +} + +class UpdateService { + static const _tag = 'Update'; + + final GithubReleasesClient _client; + final Dio _dio; + final FutureOr Function() _readCurrentVersion; + + UpdateService({ + GithubReleasesClient? client, + Dio? dio, + FutureOr Function()? readCurrentVersion, + }) : _readCurrentVersion = readCurrentVersion ?? getSmPlayerVersion, + _client = client ?? GithubReleasesClient(), + _dio = + dio ?? + Dio( + BaseOptions( + connectTimeout: const Duration(seconds: 8), + receiveTimeout: const Duration(seconds: 15), + followRedirects: true, + ), + ); + + + static String? get currentPlatformKey { + if (Platform.isWindows) return 'windows-x64'; + if (Platform.isMacOS) return 'macos'; + if (Platform.isAndroid) return 'android-arm64'; + return null; + } + + Future check() async { + final platformKey = currentPlatformKey; + if (platformKey == null) { + return const UpdateCheckResult(hasUpdate: false); + } + + final latest = await _client.fetchLatestRelease(platformKey: platformKey); + final currentVersion = await _readCurrentVersion(); + + final platformEntry = latest.platformAssets[platformKey]; + final selectedAsset = _selectPlatformAsset(latest); + + if (platformEntry == null && selectedAsset == null) { + return const UpdateCheckResult(hasUpdate: false); + } + + final effectiveRemoteVersion = (platformEntry?.version?.isNotEmpty ?? false) + ? platformEntry!.version! + : latest.version; + + if (!isNewerVersion(currentVersion, effectiveRemoteVersion)) { + return const UpdateCheckResult(hasUpdate: false); + } + + return UpdateCheckResult( + hasUpdate: true, + info: latest.copyWith( + version: effectiveRemoteVersion, + selectedAsset: selectedAsset, + noAssetForPlatform: selectedAsset == null, + ), + ); + } + + static Future computeSha256(File file) async { + final bytes = await file.readAsBytes(); + return sha256.convert(bytes).toString(); + } + + Future download( + AppUpdateAsset asset, { + required CancelToken cancelToken, + void Function(int received, int total)? onReceiveProgress, + }) async { + final directory = Directory( + '${(await getTemporaryDirectory()).path}${Platform.pathSeparator}' + 'smplayer-update', + ); + if (await directory.exists()) { + try { + await directory.delete(recursive: true); + } catch (e) { + AppLogger.warn(_tag, 'clean update temp directory failed', e); + } + } + await directory.create(recursive: true); + + final file = File( + '${directory.path}${Platform.pathSeparator}${_safeFileName(asset.name)}', + ); + await _dio.download( + asset.downloadUrl, + file.path, + cancelToken: cancelToken, + onReceiveProgress: onReceiveProgress, + ); + final expected = asset.sha256; + if (expected == null || expected.isEmpty) { + throw UpdateVerificationException('更新包缺少校验信息,请前往发布页手动下载'); + } + final actual = await computeSha256(file); + if (actual.toLowerCase() != expected.toLowerCase()) { + AppLogger.warn(_tag, 'sha256 mismatch expected=$expected actual=$actual'); + try { + await file.delete(); + } catch (_) {} + throw UpdateVerificationException('更新包校验失败,请前往发布页重试'); + } + return file; + } + + static const _apkInstallerChannel = MethodChannel('smplayer/apk_installer'); + + Future launchInstaller(String path) async { + if (Platform.isWindows) { + await Process.start(path, const [], mode: ProcessStartMode.detached); + await saveWindowBounds(); + await Future.delayed(const Duration(milliseconds: 500)); + exit(0); + } + + if (Platform.isMacOS) { + await Process.start('open', [path], mode: ProcessStartMode.detached); + return; + } + + if (Platform.isAndroid) { + await _apkInstallerChannel.invokeMethod('installApk', { + 'path': path, + }); + return; + } + + throw UnsupportedError('unsupported platform'); + } + + AppUpdateAsset? _selectPlatformAsset(AppUpdateInfo info) { + final platformKey = currentPlatformKey; + final platformAsset = platformKey == null + ? null + : info.platformAssets[platformKey]; + if (platformAsset != null) return platformAsset; + return _selectFromAssets(info.assets); + } + + AppUpdateAsset? _selectFromAssets(List assets) { + if (Platform.isWindows) { + final setupPattern = RegExp( + r'^smplayer-setup-.*-x64\.exe$', + caseSensitive: false, + ); + for (final asset in assets) { + if (setupPattern.hasMatch(asset.name)) return asset; + } + for (final asset in assets) { + if (asset.name.toLowerCase().endsWith('.exe')) return asset; + } + } + + if (Platform.isMacOS) { + for (final asset in assets) { + if (asset.name.toLowerCase().endsWith('.dmg')) return asset; + } + } + + if (Platform.isAndroid) { + final arm64Pattern = RegExp(r'arm64|arm64-v8a', caseSensitive: false); + for (final asset in assets) { + final lowerName = asset.name.toLowerCase(); + if (lowerName.endsWith('.apk') && arm64Pattern.hasMatch(lowerName)) { + return asset; + } + } + for (final asset in assets) { + if (asset.name.toLowerCase().endsWith('.apk')) return asset; + } + } + + return null; + } + + String _safeFileName(String name) { + return name.replaceAll(RegExp(r'[\\/:*?"<>|]'), '_'); + } +} diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..d598b57 --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,114 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:media_kit/media_kit.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:window_manager/window_manager.dart'; + +import 'app.dart'; +import 'core/infra/deep_link/deep_link_service.dart'; +import 'features/player/services/pip_manager.dart'; +import 'providers/appearance_provider.dart'; +import 'shared/utils/window_bounds.dart'; + +const _kMinW = 960.0; +const _kMinH = 640.0; + +Future main(List args) async { + WidgetsFlutterBinding.ensureInitialized(); + MediaKit.ensureInitialized(); + _installErrorNoiseFilters(); + + if (Platform.isAndroid) { + SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + SystemChrome.setSystemUIOverlayStyle( + const SystemUiOverlayStyle( + statusBarColor: Colors.transparent, + systemNavigationBarColor: Colors.transparent, + systemNavigationBarDividerColor: Colors.transparent, + ), + ); + } + + if (Platform.isWindows || Platform.isMacOS) { + await windowManager.ensureInitialized(); + final prefs = await SharedPreferences.getInstance(); + final savedW = prefs.getDouble(kWinWidthKey); + final savedH = prefs.getDouble(kWinHeightKey); + final savedX = prefs.getDouble(kWinXKey); + final savedY = prefs.getDouble(kWinYKey); + final hasSize = savedW != null && savedH != null; + final size = hasSize + ? Size( + savedW.clamp(_kMinW, double.infinity), + savedH.clamp(_kMinH, double.infinity), + ) + : const Size(1280, 720); + final opts = WindowOptions( + size: size, + minimumSize: const Size(_kMinW, _kMinH), + center: !hasSize, + title: 'smPlayer', + titleBarStyle: TitleBarStyle.hidden, + ); + await windowManager.waitUntilReadyToShow(opts, () async { + if (hasSize && savedX != null && savedY != null) { + await windowManager.setPosition(Offset(savedX, savedY)); + } + if (Platform.isMacOS || Platform.isWindows) { + await windowManager.setTitleBarStyle( + TitleBarStyle.hidden, + windowButtonVisibility: false, + ); + } + await windowManager.show(); + await windowManager.focus(); + }); + windowManager.addListener(_MainWindowListener()); + } + + await DeepLinkService.instance.init(); + + var initialLocation = startupPagePath(StartupPage.home); + try { + final prefs = await SharedPreferences.getInstance(); + initialLocation = startupPagePath( + parseStartupPage(prefs.getString(kStartupPageKey)), + ); + } catch (_) {} + + runApp( + ProviderScope( + retry: (_, _) => null, + overrides: [initialLocationProvider.overrideWithValue(initialLocation)], + child: const SmPlayerApp(), + ), + ); +} + +void _installErrorNoiseFilters() { + final original = FlutterError.onError; + FlutterError.onError = (details) { + final msg = details.exceptionAsString(); + if (msg.contains('KeyDownEvent is dispatched, but the state shows')) { + return; + } + if (details.exception is HttpException && + (details.library ?? '').contains('image resource')) { + return; + } + original?.call(details); + }; +} + +class _MainWindowListener extends WindowListener { + @override + Future onWindowClose() async { + if (!PipManager.isActive) { + await saveWindowBounds(); + } + exit(0); + } +} diff --git a/lib/providers/appearance_provider.dart b/lib/providers/appearance_provider.dart new file mode 100644 index 0000000..93a1cfd --- /dev/null +++ b/lib/providers/appearance_provider.dart @@ -0,0 +1,129 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'shared/settings_persistence.dart'; + +enum HeaderMode { solid, frosted } + + +enum StartupPage { home, servers, aggregated, discover, settings } + + +const String kStartupPageKey = 'smplayer.startup_page'; + + +StartupPage parseStartupPage(String? s) => switch (s) { + 'servers' => StartupPage.servers, + 'aggregated' => StartupPage.aggregated, + 'discover' => StartupPage.discover, + 'settings' => StartupPage.settings, + _ => StartupPage.home, +}; + + +String startupPageToStr(StartupPage p) => switch (p) { + StartupPage.home => 'home', + StartupPage.servers => 'servers', + StartupPage.aggregated => 'aggregated', + StartupPage.discover => 'discover', + StartupPage.settings => 'settings', +}; + + +String startupPagePath(StartupPage p) => switch (p) { + StartupPage.home => '/', + StartupPage.servers => '/servers', + StartupPage.aggregated => '/aggregated', + StartupPage.discover => '/discover', + StartupPage.settings => '/settings', +}; + +class AppearanceState { + final ThemeMode themeMode; + final HeaderMode headerMode; + final StartupPage startupPage; + + const AppearanceState({ + this.themeMode = ThemeMode.system, + this.headerMode = HeaderMode.frosted, + this.startupPage = StartupPage.home, + }); + + AppearanceState copyWith({ + ThemeMode? themeMode, + HeaderMode? headerMode, + StartupPage? startupPage, + }) => AppearanceState( + themeMode: themeMode ?? this.themeMode, + headerMode: headerMode ?? this.headerMode, + startupPage: startupPage ?? this.startupPage, + ); +} + +class AppearanceNotifier extends AsyncNotifier + with SettingsPersistence { + static const _keyMode = 'smplayer.theme_mode'; + static const _keyHeader = 'smplayer.header_mode'; + + @override + Future build() async { + final prefs = await getPrefs(); + final raw = prefs.getString(_keyMode) ?? 'system'; + final rawHeader = prefs.getString(_keyHeader) ?? 'frosted'; + return AppearanceState( + themeMode: _parse(raw), + headerMode: _parseHeader(rawHeader), + startupPage: parseStartupPage(prefs.getString(kStartupPageKey)), + ); + } + + static ThemeMode _parse(String s) => switch (s) { + 'light' => ThemeMode.light, + 'dark' => ThemeMode.dark, + _ => ThemeMode.system, + }; + + static String _toStr(ThemeMode m) => switch (m) { + ThemeMode.light => 'light', + ThemeMode.dark => 'dark', + ThemeMode.system => 'system', + }; + + static HeaderMode _parseHeader(String s) => switch (s) { + 'solid' => HeaderMode.solid, + 'frosted' => HeaderMode.frosted, + _ => HeaderMode.frosted, + }; + + static String _headerToStr(HeaderMode m) => switch (m) { + HeaderMode.solid => 'solid', + HeaderMode.frosted => 'frosted', + }; + + Future setThemeMode(ThemeMode mode) async { + final current = state.value ?? const AppearanceState(); + state = AsyncValue.data(current.copyWith(themeMode: mode)); + await persistField(_keyMode, _toStr(mode)); + } + + Future setHeaderMode(HeaderMode mode) async { + final current = state.value ?? const AppearanceState(); + state = AsyncValue.data(current.copyWith(headerMode: mode)); + await persistField(_keyHeader, _headerToStr(mode)); + } + + + Future setStartupPage(StartupPage page) async { + final current = state.value ?? const AppearanceState(); + state = AsyncValue.data(current.copyWith(startupPage: page)); + await persistField(kStartupPageKey, startupPageToStr(page)); + } +} + + +final initialLocationProvider = Provider((_) => '/'); + +final appearanceProvider = + AsyncNotifierProvider( + AppearanceNotifier.new, + ); diff --git a/lib/providers/audio_settings_provider.dart b/lib/providers/audio_settings_provider.dart new file mode 100644 index 0000000..2c5ab38 --- /dev/null +++ b/lib/providers/audio_settings_provider.dart @@ -0,0 +1,121 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'shared/settings_persistence.dart'; + +class RememberedAudioTrack { + final int streamIndex; + final String? language; + final String? codec; + final int? channels; + final String? displayTitle; + final DateTime updatedAt; + + const RememberedAudioTrack({ + required this.streamIndex, + this.language, + this.codec, + this.channels, + this.displayTitle, + required this.updatedAt, + }); + + Map toJson() => { + 'streamIndex': streamIndex, + if (language != null) 'language': language, + if (codec != null) 'codec': codec, + if (channels != null) 'channels': channels, + if (displayTitle != null) 'displayTitle': displayTitle, + 'updatedAt': updatedAt.toIso8601String(), + }; + + factory RememberedAudioTrack.fromJson(Map json) { + return RememberedAudioTrack( + streamIndex: json['streamIndex'] as int, + language: json['language'] as String?, + codec: json['codec'] as String?, + channels: json['channels'] as int?, + displayTitle: json['displayTitle'] as String?, + updatedAt: + DateTime.tryParse(json['updatedAt'] as String? ?? '') ?? + DateTime.now(), + ); + } +} + +class AudioSettingsState { + final bool rememberAudioTrack; + final String preferredAudioLanguage; + final bool volumeBoost; + + const AudioSettingsState({ + this.rememberAudioTrack = false, + this.preferredAudioLanguage = '', + this.volumeBoost = false, + }); + + AudioSettingsState copyWith({ + bool? rememberAudioTrack, + String? preferredAudioLanguage, + bool? volumeBoost, + }) => AudioSettingsState( + rememberAudioTrack: rememberAudioTrack ?? this.rememberAudioTrack, + preferredAudioLanguage: + preferredAudioLanguage ?? this.preferredAudioLanguage, + volumeBoost: volumeBoost ?? this.volumeBoost, + ); +} + +class AudioSettingsNotifier extends AsyncNotifier + with SettingsPersistence { + static const _keyRememberTrack = 'smplayer.audio_remember_track'; + static const _keyPreferredLang = 'smplayer.audio_preferred_language'; + static const _keyVolumeBoost = 'smplayer.audio_volume_boost'; + static const _keyLastAudioTracks = 'smplayer.audio_last_tracks'; + static const _maxRememberedTracks = 200; + + @override + Future build() async { + final prefs = await getPrefs(); + return AudioSettingsState( + rememberAudioTrack: prefs.getBool(_keyRememberTrack) ?? false, + preferredAudioLanguage: prefs.getString(_keyPreferredLang) ?? '', + volumeBoost: prefs.getBool(_keyVolumeBoost) ?? false, + ); + } + + Future setRememberAudioTrack(bool value) async { + final current = state.value ?? const AudioSettingsState(); + state = AsyncValue.data(current.copyWith(rememberAudioTrack: value)); + await persistField(_keyRememberTrack, value); + } + + Future setPreferredAudioLanguage(String language) async { + final current = state.value ?? const AudioSettingsState(); + state = AsyncValue.data(current.copyWith(preferredAudioLanguage: language)); + await persistField(_keyPreferredLang, language); + } + + Future setVolumeBoost(bool value) async { + final current = state.value ?? const AudioSettingsState(); + state = AsyncValue.data(current.copyWith(volumeBoost: value)); + await persistField(_keyVolumeBoost, value); + } + + Future saveLastAudioTrack(String key, RememberedAudioTrack track) => + putRemembered( + _keyLastAudioTracks, + key, + track.toJson(), + _maxRememberedTracks, + ); + + Future getLastAudioTrack(String key) async { + final entry = await readRemembered(_keyLastAudioTracks, key); + return entry == null ? null : RememberedAudioTrack.fromJson(entry); + } +} + +final audioSettingsProvider = + AsyncNotifierProvider( + AudioSettingsNotifier.new, + ); diff --git a/lib/providers/danmaku_settings_provider.dart b/lib/providers/danmaku_settings_provider.dart new file mode 100644 index 0000000..f03264b --- /dev/null +++ b/lib/providers/danmaku_settings_provider.dart @@ -0,0 +1,317 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../core/contracts/danmaku.dart'; +import 'shared/settings_persistence.dart'; + +enum ChineseConvertMode { none, toSimplified, toTraditional } + +DanmakuPanelSettings defaultDanmakuPanelSettings({bool? isAndroid}) { + final android = isAndroid ?? Platform.isAndroid; + return DanmakuPanelSettings( + opacity: android ? 0.9 : 1.0, + fontSize: android ? 18 : 20, + strokeWidth: android ? 1.2 : 1.5, + lineHeight: android ? 1.4 : 1.6, + maxRows: android ? 3 : 0, + ); +} + +class DanmakuSettingsState { + final bool enabled; + final ChineseConvertMode chineseConvert; + final List sources; + final List blockedKeywords; + final bool mergeDuplicates; + final DanmakuPanelSettings panelSettings; + + const DanmakuSettingsState({ + this.enabled = false, + this.chineseConvert = ChineseConvertMode.none, + this.sources = const [], + this.blockedKeywords = const [], + this.mergeDuplicates = false, + this.panelSettings = const DanmakuPanelSettings(), + }); + + List get enabledSources => + sources.where((s) => s.enabled && s.url.trim().isNotEmpty).toList(); + + String get sourceUrl => + enabledSources.isNotEmpty ? enabledSources.first.url : ''; + + DanmakuSettingsState copyWith({ + bool? enabled, + ChineseConvertMode? chineseConvert, + List? sources, + List? blockedKeywords, + bool? mergeDuplicates, + DanmakuPanelSettings? panelSettings, + }) => DanmakuSettingsState( + enabled: enabled ?? this.enabled, + chineseConvert: chineseConvert ?? this.chineseConvert, + sources: sources ?? this.sources, + blockedKeywords: blockedKeywords ?? this.blockedKeywords, + mergeDuplicates: mergeDuplicates ?? this.mergeDuplicates, + panelSettings: panelSettings ?? this.panelSettings, + ); +} + +class DanmakuSettingsNotifier extends AsyncNotifier + with SettingsPersistence { + static const _keyEnabled = 'smplayer.danmaku_enabled'; + static const _keyChineseConvert = 'smplayer.danmaku_chinese_convert'; + static const _keySourceUrl = 'smplayer.danmaku_source_url'; + static const _keySources = 'smplayer.danmaku_sources'; + static const _keyBlockedKeywords = 'smplayer.danmaku_blocked_keywords'; + static const _keyMergeDuplicates = 'smplayer.danmaku_merge_duplicates'; + + static const _keyPanelEnabled = 'smplayer.danmaku_panel_enabled'; + static const _keyPanelOpacity = 'smplayer.danmaku_panel_opacity'; + static const _keyPanelArea = 'smplayer.danmaku_panel_area'; + static const _keyPanelFontSize = 'smplayer.danmaku_panel_font_size'; + static const _keyPanelSpeed = 'smplayer.danmaku_panel_speed'; + static const _keyPanelOffset = 'smplayer.danmaku_panel_offset'; + static const _keyPanelShowTop = 'smplayer.danmaku_panel_show_top'; + static const _keyPanelShowScroll = 'smplayer.danmaku_panel_show_scroll'; + static const _keyPanelShowBottom = 'smplayer.danmaku_panel_show_bottom'; + static const _keyPanelColored = 'smplayer.danmaku_panel_colored'; + static const _keyPanelStrokeLegacy = 'smplayer.danmaku_panel_stroke'; + static const _keyPanelStrokeWidth = 'smplayer.danmaku_panel_stroke_width'; + static const _keyPanelBold = 'smplayer.danmaku_panel_bold'; + static const _keyPanelFontFamily = 'smplayer.danmaku_panel_font_family'; + static const _keyPanelLineHeight = 'smplayer.danmaku_panel_line_height'; + static const _keyPanelFixedToScroll = + 'smplayer.danmaku_panel_fixed_to_scroll'; + static const _keyPanelMaxRows = 'smplayer.danmaku_panel_max_rows'; + static const _keyPanelHeatmapEnabled = + 'smplayer.danmaku_panel_heatmap_enabled'; + + @override + Future build() async { + final prefs = await getPrefs(); + return DanmakuSettingsState( + enabled: prefs.getBool(_keyEnabled) ?? false, + chineseConvert: _parseConvert(prefs.getString(_keyChineseConvert)), + sources: _parseSources( + prefs.getStringList(_keySources), + legacyUrl: prefs.getString(_keySourceUrl), + ), + blockedKeywords: prefs.getStringList(_keyBlockedKeywords) ?? [], + mergeDuplicates: prefs.getBool(_keyMergeDuplicates) ?? false, + panelSettings: _loadPanelSettings(prefs), + ); + } + + DanmakuPanelSettings _loadPanelSettings(SharedPreferences prefs) { + final defaults = defaultDanmakuPanelSettings(); + return DanmakuPanelSettings( + enabled: prefs.getBool(_keyPanelEnabled) ?? defaults.enabled, + opacity: prefs.getDouble(_keyPanelOpacity) ?? defaults.opacity, + area: prefs.getDouble(_keyPanelArea) ?? defaults.area, + fontSize: prefs.getDouble(_keyPanelFontSize) ?? defaults.fontSize, + speed: prefs.getDouble(_keyPanelSpeed) ?? defaults.speed, + offset: prefs.getDouble(_keyPanelOffset) ?? defaults.offset, + showTop: prefs.getBool(_keyPanelShowTop) ?? defaults.showTop, + showScroll: prefs.getBool(_keyPanelShowScroll) ?? defaults.showScroll, + showBottom: prefs.getBool(_keyPanelShowBottom) ?? defaults.showBottom, + colored: prefs.getBool(_keyPanelColored) ?? defaults.colored, + strokeWidth: + prefs.getDouble(_keyPanelStrokeWidth) ?? + ((prefs.getBool(_keyPanelStrokeLegacy) ?? true) + ? defaults.strokeWidth + : 0.0), + bold: prefs.getBool(_keyPanelBold) ?? defaults.bold, + fontFamily: prefs.getString(_keyPanelFontFamily) ?? defaults.fontFamily, + lineHeight: prefs.getDouble(_keyPanelLineHeight) ?? defaults.lineHeight, + fixedToScroll: + prefs.getBool(_keyPanelFixedToScroll) ?? defaults.fixedToScroll, + maxRows: prefs.getInt(_keyPanelMaxRows) ?? defaults.maxRows, + heatmapEnabled: + prefs.getBool(_keyPanelHeatmapEnabled) ?? defaults.heatmapEnabled, + ); + } + + static ChineseConvertMode _parseConvert(String? s) => switch (s) { + 'simplified' => ChineseConvertMode.toSimplified, + 'traditional' => ChineseConvertMode.toTraditional, + _ => ChineseConvertMode.none, + }; + + static String _convertToStr(ChineseConvertMode m) => switch (m) { + ChineseConvertMode.toSimplified => 'simplified', + ChineseConvertMode.toTraditional => 'traditional', + ChineseConvertMode.none => 'none', + }; + + static List _parseSources( + List? raw, { + required String? legacyUrl, + }) { + final parsed = []; + for (final item in raw ?? const []) { + try { + final data = jsonDecode(item); + if (data is Map) { + final source = DanmakuSource.fromJson(data); + if (source.url.isNotEmpty) parsed.add(source); + } + } catch (_) { + final url = item.trim(); + if (url.isNotEmpty) { + parsed.add( + DanmakuSource(id: _sourceIdFromUrl(url), name: '', url: url), + ); + } + } + } + if (parsed.isEmpty) { + final url = legacyUrl?.trim() ?? ''; + if (url.isNotEmpty) { + parsed.add( + DanmakuSource(id: _sourceIdFromUrl(url), name: '默认源', url: url), + ); + } else { + parsed.add( + DanmakuSource( + id: _sourceIdFromUrl('https://api.dandanplay.net'), + name: '弹弹play', + url: 'https://api.dandanplay.net', + ), + ); + } + } + return _normalizeSources(parsed); + } + + static List _normalizeSources(List sources) { + final seen = {}; + final result = []; + for (final source in sources) { + final url = source.url.trim(); + if (url.isEmpty || seen.contains(url)) continue; + seen.add(url); + result.add( + source.copyWith( + id: source.id.trim().isEmpty ? _sourceIdFromUrl(url) : source.id, + url: url, + ), + ); + } + return result; + } + + static String _sourceIdFromUrl(String url) => + 'src_${url.hashCode.toUnsigned(32).toRadixString(16)}'; + + Future setEnabled(bool v) async { + final current = state.value ?? const DanmakuSettingsState(); + state = AsyncValue.data(current.copyWith(enabled: v)); + await persistField(_keyEnabled, v); + } + + Future setChineseConvert(ChineseConvertMode mode) async { + final current = state.value ?? const DanmakuSettingsState(); + state = AsyncValue.data(current.copyWith(chineseConvert: mode)); + await persistField(_keyChineseConvert, _convertToStr(mode)); + } + + + Future> setSources(List sources) async { + final current = state.value ?? const DanmakuSettingsState(); + final normalized = _normalizeSources(sources); + state = AsyncValue.data(current.copyWith(sources: normalized)); + final enabled = normalized.where((s) => s.enabled).toList(); + final enabledUrl = enabled.isEmpty ? null : enabled.first.url; + await persistFields({ + _keySources: normalized.map((s) => jsonEncode(s.toJson())).toList(), + _keySourceUrl: enabledUrl, + }); + return normalized; + } + + Future addSource({required String name, required String url}) async { + final current = state.value ?? const DanmakuSettingsState(); + final trimmed = url.trim(); + if (trimmed.isEmpty) return; + final source = DanmakuSource( + id: _sourceIdFromUrl(trimmed), + name: name.trim(), + url: trimmed, + ); + final existing = current.sources.indexWhere((s) => s.url == trimmed); + final next = [...current.sources]; + if (existing >= 0) { + next[existing] = source.copyWith(id: next[existing].id); + } else { + next.add(source); + } + await setSources(next); + } + + Future removeSource(String id) async { + final current = state.value ?? const DanmakuSettingsState(); + await setSources(current.sources.where((s) => s.id != id).toList()); + } + + Future addBlockedKeyword(String keyword) async { + final current = state.value ?? const DanmakuSettingsState(); + if (current.blockedKeywords.contains(keyword)) return; + final updated = [...current.blockedKeywords, keyword]; + state = AsyncValue.data(current.copyWith(blockedKeywords: updated)); + await persistField(_keyBlockedKeywords, updated); + } + + Future removeBlockedKeyword(String keyword) async { + final current = state.value ?? const DanmakuSettingsState(); + final updated = current.blockedKeywords.where((k) => k != keyword).toList(); + state = AsyncValue.data(current.copyWith(blockedKeywords: updated)); + await persistField(_keyBlockedKeywords, updated); + } + + Future setMergeDuplicates(bool v) async { + final current = state.value ?? const DanmakuSettingsState(); + state = AsyncValue.data(current.copyWith(mergeDuplicates: v)); + await persistField(_keyMergeDuplicates, v); + } + + Future updatePanelSettings(DanmakuPanelSettings panel) async { + setPanelSettings(panel); + await persistPanelSettings(panel); + } + + void setPanelSettings(DanmakuPanelSettings panel) { + final current = state.value ?? const DanmakuSettingsState(); + state = AsyncValue.data(current.copyWith(panelSettings: panel)); + } + + Future persistPanelSettings(DanmakuPanelSettings panel) async { + await persistFields({ + _keyPanelEnabled: panel.enabled, + _keyPanelOpacity: panel.opacity, + _keyPanelArea: panel.area, + _keyPanelFontSize: panel.fontSize, + _keyPanelSpeed: panel.speed, + _keyPanelOffset: panel.offset, + _keyPanelShowTop: panel.showTop, + _keyPanelShowScroll: panel.showScroll, + _keyPanelShowBottom: panel.showBottom, + _keyPanelColored: panel.colored, + _keyPanelStrokeWidth: panel.strokeWidth, + _keyPanelBold: panel.bold, + _keyPanelFontFamily: panel.fontFamily, + _keyPanelLineHeight: panel.lineHeight, + _keyPanelFixedToScroll: panel.fixedToScroll, + _keyPanelMaxRows: panel.maxRows, + _keyPanelHeatmapEnabled: panel.heatmapEnabled, + }); + } +} + +final danmakuSettingsProvider = + AsyncNotifierProvider( + DanmakuSettingsNotifier.new, + ); diff --git a/lib/providers/detail_palette_provider.dart b/lib/providers/detail_palette_provider.dart new file mode 100644 index 0000000..941bfbc --- /dev/null +++ b/lib/providers/detail_palette_provider.dart @@ -0,0 +1,75 @@ +import 'dart:math' as math; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +@immutable +class DetailPaletteRequest { + DetailPaletteRequest({required this.url, Map? headers}) + : headers = headers == null + ? null + : Map.unmodifiable(headers); + + final String url; + final Map? headers; + + @override + bool operator ==(Object other) { + return identical(this, other) || + other is DetailPaletteRequest && + other.url == url && + mapEquals(other.headers, headers); + } + + @override + int get hashCode { + final sortedHeaderEntries = headers?.entries.toList(growable: false) + ?..sort((left, right) => left.key.compareTo(right.key)); + final headersHash = sortedHeaderEntries == null + ? null + : Object.hashAll( + sortedHeaderEntries.map( + (entry) => Object.hash(entry.key, entry.value), + ), + ); + return Object.hash(url, headersHash); + } +} + + +final detailPaletteProvider = + FutureProvider.family((ref, request) async { + try { + final sourceProvider = CachedNetworkImageProvider( + request.url, + headers: request.headers, + ); + final resizedProvider = ResizeImage.resizeIfNeeded( + 100, + null, + sourceProvider, + ); + final colorScheme = await ColorScheme.fromImageProvider( + provider: resizedProvider, + brightness: Brightness.dark, + ).timeout(const Duration(seconds: 6)); + + final sourceColor = HSLColor.fromColor(colorScheme.primary); + return sourceColor + .withSaturation(math.min(sourceColor.saturation, 0.3)) + .withLightness(0.11) + .toColor(); + } catch (_) { + return null; + } + }); + +Color createDetailNavigationColor(Color backgroundColor) { + final backgroundHsl = HSLColor.fromColor(backgroundColor); + return backgroundHsl + .withSaturation(math.min(backgroundHsl.saturation, 0.26)) + .withLightness(0.07) + .toColor(); +} diff --git a/lib/providers/di/di_core_stores.dart b/lib/providers/di/di_core_stores.dart new file mode 100644 index 0000000..658a2c8 --- /dev/null +++ b/lib/providers/di/di_core_stores.dart @@ -0,0 +1,76 @@ +part of '../di_providers.dart'; + + +final appDataDirProvider = FutureProvider((ref) async { + final base = await getApplicationSupportDirectory(); + final dir = Directory('${base.path}/smplayer'); + if (!await dir.exists()) { + await dir.create(recursive: true); + } + return dir; +}); + + +final deviceIdProvider = FutureProvider((ref) async { + final store = DeviceIdStore(); + return store.getOrCreate(); +}); + + +final serverRepositoryProvider = FutureProvider((ref) async { + final dir = await ref.watch(appDataDirProvider.future); + return JsonServerRepository(file: File('${dir.path}/servers.json')); +}); + + +final sessionRepositoryProvider = FutureProvider(( + ref, +) async { + final dir = await ref.watch(appDataDirProvider.future); + return JsonSessionRepository(file: File('${dir.path}/sessions.json')); +}); + + +final searchHistoryStoreProvider = Provider( + (ref) => SearchHistoryStore(), +); + + +final danmakuSourcesStoreProvider = Provider( + (ref) => PrefsDanmakuSourcesStore(), +); + + +final homePageCacheStoreProvider = FutureProvider(( + ref, +) async { + final dir = await ref.watch(appDataDirProvider.future); + return HomePageCacheStore(directory: dir); +}); + + +final discoverPageCacheStoreProvider = FutureProvider(( + ref, +) async { + final dir = await ref.watch(appDataDirProvider.future); + return DiscoverPageCacheStore(directory: dir); +}); + + +final embyGatewayProvider = FutureProvider((ref) async { + final deviceId = await ref.read(deviceIdProvider.future); + return EmbyApiClient(deviceId: deviceId, deviceName: kEmbyDeviceName); +}); + + +final tmdbGatewayProvider = Provider((ref) => TmdbApiClient()); + + +final danmakuGatewayProvider = Provider( + (ref) => DispatchingDanmakuGateway( + httpGateway: DanmakuApiClient(), + scriptWidgetGateway: ScriptWidgetDanmakuGateway( + loadService: () => ref.read(scriptWidgetServiceProvider.future), + ), + ), +); diff --git a/lib/providers/di/di_detail_data.dart b/lib/providers/di/di_detail_data.dart new file mode 100644 index 0000000..74b367c --- /dev/null +++ b/lib/providers/di/di_detail_data.dart @@ -0,0 +1,271 @@ +part of '../di_providers.dart'; + +typedef SeriesSeasonsKey = ({String serverId, String seriesId}); +typedef EpisodesKey = ({String serverId, String seriesId, String seasonId}); +typedef CrossServerSearchKey = ({ + String activeServerId, + String anchorType, + String providerIdQuery, + String seriesProviderIdQuery, + int parentIndexNumber, + int indexNumber, + String itemName, + String excludedServerId, +}); + +typedef MediaDetailKey = ({String serverId, String itemId}); + + +final mediaDetailDataProvider = + FutureProvider.family((ref, key) async { + final uc = await ref.read(mediaDetailUseCaseProvider.future); + final sw = Stopwatch()..start(); + final res = await uc + .execute(MediaDetailReq(itemId: key.itemId)) + .timeout(const Duration(seconds: 30)); + final rawSources = res.base.extra['MediaSources']; + final srcCount = rawSources is List ? rawSources.length : 0; + AppLogger.debug( + 'DetailTiming', + 'base.fetch type=${res.base.Type} sources=$srcCount ms=${sw.elapsedMilliseconds}', + ); + return res; + }); + +final similarItemsDataProvider = + FutureProvider.family, String>((ref, itemId) async { + final uc = await ref.read(similarItemsUseCaseProvider.future); + final res = await uc.execute(SimilarItemsReq(itemId: itemId)); + return res.items; + }); + +final additionalPartsDataProvider = + FutureProvider.family, String>((ref, itemId) async { + final uc = await ref.read(additionalPartsUseCaseProvider.future); + final res = await uc.execute(AdditionalPartsReq(itemId: itemId)); + return res.items; + }); + +final specialFeaturesDataProvider = FutureProvider.autoDispose + .family, String>((ref, itemId) async { + final uc = await ref.read(specialFeaturesUseCaseProvider.future); + return uc.execute(itemId); + }); + +final crossServerSearchDataProvider = StreamProvider.autoDispose + .family, CrossServerSearchKey>(( + ref, + key, + ) async* { + final uc = await ref.read(crossServerSearchUseCaseProvider.future); + final cancelToken = CancellationToken(); + ref.onDispose(() => cancelToken.cancel('provider disposed')); + + final req = CrossServerSearchReq( + anchorType: key.anchorType, + itemName: key.itemName, + providerIds: _decodeProviderIdQuery(key.providerIdQuery), + seriesProviderIds: key.seriesProviderIdQuery.isEmpty + ? null + : _decodeProviderIdQuery(key.seriesProviderIdQuery), + parentIndexNumber: key.parentIndexNumber < 0 + ? null + : key.parentIndexNumber, + indexNumber: key.indexNumber < 0 ? null : key.indexNumber, + excludedServerId: key.excludedServerId.isEmpty + ? null + : key.excludedServerId, + ); + + yield const []; + yield* uc.executeStream(req, cancelToken: cancelToken); + }); + + +final tmdbLibraryAvailabilityProvider = StreamProvider.autoDispose + .family< + List, + ({String mediaType, String tmdbId, String imdbId}) + >((ref, key) async* { + if (key.tmdbId.isEmpty) { + yield const []; + return; + } + final uc = await ref.read(tmdbAvailabilityUseCaseProvider.future); + final cancelToken = CancellationToken(); + ref.onDispose(() => cancelToken.cancel('provider disposed')); + yield* uc.executeStream( + TmdbAvailabilityReq( + tmdbId: key.tmdbId, + mediaType: key.mediaType, + imdbId: key.imdbId.isEmpty ? null : key.imdbId, + ), + cancelToken: cancelToken, + ); + }); + + +typedef ServerScopedItemKey = ({String serverId, String itemId}); + + +final tmdbServerScopedDetailProvider = FutureProvider.autoDispose + .family((ref, key) async { + final uc = await ref.read(mediaDetailUseCaseProvider.future); + return uc.executeForServer(serverId: key.serverId, itemId: key.itemId); + }); + + +typedef ServerScopedAuth = ({ + String baseUrl, + String token, + String userId, + Map imageHeaders, +}); + +final tmdbServerScopedAuthProvider = FutureProvider.autoDispose + .family((ref, serverId) async { + final sessionRepo = await ref.read(sessionRepositoryProvider.future); + final serverRepo = await ref.read(serverRepositoryProvider.future); + final session = await sessionRepo.getByServerId(serverId); + if (session == null) return null; + final server = await serverRepo.findById(serverId); + if (server == null) return null; + final deviceId = await ref.read(deviceIdProvider.future); + return ( + baseUrl: server.baseUrl, + token: session.token, + userId: session.userId, + imageHeaders: EmbyRequestHeaders.image( + deviceId: deviceId, + deviceName: kEmbyDeviceName, + token: session.token, + userId: session.userId, + ), + ); + }); + + +final tmdbServerScopedAdditionalPartsProvider = FutureProvider.autoDispose + .family, ServerScopedItemKey>((ref, key) async { + final uc = await ref.read(additionalPartsUseCaseProvider.future); + final res = await uc.executeForServer( + serverId: key.serverId, + itemId: key.itemId, + ); + return res.items; + }); + +typedef SeriesPrewarmKey = ({ + String activeServerId, + String seriesProviderIdQuery, +}); + + +final seriesPrewarmProvider = FutureProvider.autoDispose + .family((ref, key) async { + if (key.seriesProviderIdQuery.isEmpty) return; + final keepAlive = ref.keepAlive(); + final uc = await ref.read(crossServerSearchUseCaseProvider.future); + final cancelToken = CancellationToken(); + var disposed = false; + ref.onDispose(() { + disposed = true; + cancelToken.cancel('prewarm disposed'); + }); + try { + await uc.prewarmSeriesIdentity( + _decodeProviderIdQuery(key.seriesProviderIdQuery), + cancelToken: cancelToken, + ); + } finally { + if (!disposed) keepAlive.close(); + } + }); + +Map _decodeProviderIdQuery(String q) { + final out = {}; + if (q.isEmpty) return out; + for (final part in q.split(',')) { + final dotIdx = part.indexOf('.'); + if (dotIdx > 0) { + out[part.substring(0, dotIdx)] = part.substring(dotIdx + 1); + } + } + return out; +} + +final seriesSeasonsDataProvider = + FutureProvider.family, SeriesSeasonsKey>(( + ref, + key, + ) async { + final uc = await ref.read(seriesSeasonsUseCaseProvider.future); + final res = await uc.executeForServer( + serverId: key.serverId, + input: SeriesSeasonsReq(seriesId: key.seriesId), + ); + return res.items; + }); + +final seriesEpisodesDataProvider = + FutureProvider.family, EpisodesKey>((ref, key) async { + final uc = await ref.read(seriesEpisodesUseCaseProvider.future); + final res = await uc.executeForServer( + serverId: key.serverId, + input: MediaEpisodesReq(seriesId: key.seriesId, seasonId: key.seasonId), + ); + return res.items; + }); + + +typedef SeriesResumeTargetKey = ({String serverId, String seriesId}); + + +final seriesResumeTargetProvider = FutureProvider.autoDispose + .family((ref, key) async { + try { + final sessionRepo = await ref.read(sessionRepositoryProvider.future); + final serverRepo = await ref.read(serverRepositoryProvider.future); + final ctx = await resolveAuthedContextForServer( + sessionRepository: sessionRepo, + serverRepository: serverRepo, + serverId: key.serverId, + ); + if (ctx == null) return null; + final gw = await ref.read(embyGatewayProvider.future); + return resolveSeriesResumeTarget(gw, ctx, key.seriesId); + } catch (e) { + AppLogger.warn('SeriesResumeTarget', 'resolve failed', e); + return null; + } + }); + + +final tmdbTvRecentHitProvider = FutureProvider.autoDispose + .family< + TmdbLibraryHit?, + ({String mediaType, String tmdbId, String imdbId}) + >((ref, key) async { + final hits = await ref.watch(tmdbLibraryAvailabilityProvider(key).future); + final seriesHits = hits + .where((h) => h.itemType == 'Series') + .toList(growable: false); + if (seriesHits.length < 2) return null; + final uc = await ref.read(mediaDetailUseCaseProvider.future); + DateTime? bestDate; + TmdbLibraryHit? best; + for (final h in seriesHits) { + try { + final res = await uc.executeForServer( + serverId: h.serverId, + itemId: h.itemId, + ); + final lp = parseEmbyDate(res.base.UserData?.extra['LastPlayedDate']); + if (lp != null && (bestDate == null || lp.isAfter(bestDate))) { + bestDate = lp; + best = h; + } + } catch (_) {} + } + return best; + }); diff --git a/lib/providers/di/di_emby_usecases.dart b/lib/providers/di/di_emby_usecases.dart new file mode 100644 index 0000000..1f341d9 --- /dev/null +++ b/lib/providers/di/di_emby_usecases.dart @@ -0,0 +1,261 @@ +part of '../di_providers.dart'; + + +final playbackResolverProvider = FutureProvider((ref) async { + final gateway = await ref.read(embyGatewayProvider.future); + final deviceId = await ref.read(deviceIdProvider.future); + return PlaybackResolver( + gateway: gateway, + deviceId: deviceId, + deviceName: kEmbyDeviceName, + ); +}); + +typedef _EmbyDeps = ({ + SessionRepository session, + ServerRepository server, + EmbyGateway gw, +}); + +Future<_EmbyDeps> _embyDeps(Ref ref) async => ( + session: await ref.watch(sessionRepositoryProvider.future), + server: await ref.watch(serverRepositoryProvider.future), + gw: await ref.watch(embyGatewayProvider.future), +); + + +FutureProvider _authedUseCaseProvider( + T Function({ + required SessionRepository sessionRepository, + required ServerRepository serverRepository, + required EmbyGateway embyGateway, + }) + ctor, +) { + return FutureProvider((ref) async { + final d = await _embyDeps(ref); + return ctor( + sessionRepository: d.session, + serverRepository: d.server, + embyGateway: d.gw, + ); + }); +} + +final listServersUseCaseProvider = FutureProvider(( + ref, +) async { + final repo = await ref.watch(serverRepositoryProvider.future); + return ListServersUseCase(serverRepository: repo); +}); + +final saveServerUseCaseProvider = FutureProvider(( + ref, +) async { + final repo = await ref.watch(serverRepositoryProvider.future); + return SaveServerUseCase(serverRepository: repo); +}); + +final reorderServersUseCaseProvider = FutureProvider(( + ref, +) async { + final repo = await ref.watch(serverRepositoryProvider.future); + return ReorderServersUseCase(serverRepository: repo); +}); + +final probeServerUseCaseProvider = FutureProvider(( + ref, +) async { + final gw = await ref.watch(embyGatewayProvider.future); + return ProbeServerUseCase(embyGateway: gw); +}); + +final exportBackupUseCaseProvider = FutureProvider(( + ref, +) async { + return ExportBackupUseCase( + serverRepository: await ref.watch(serverRepositoryProvider.future), + sessionRepository: await ref.watch(sessionRepositoryProvider.future), + danmakuSourcesStore: ref.read(danmakuSourcesStoreProvider), + scriptWidgetRepository: await ref.watch( + scriptWidgetRepositoryProvider.future, + ), + ); +}); + +final importBackupUseCaseProvider = FutureProvider(( + ref, +) async { + return ImportBackupUseCase( + serverRepository: await ref.watch(serverRepositoryProvider.future), + sessionRepository: await ref.watch(sessionRepositoryProvider.future), + danmakuSourcesStore: ref.read(danmakuSourcesStoreProvider), + scriptWidgetRepository: await ref.watch( + scriptWidgetRepositoryProvider.future, + ), + ); +}); + + +final serverSyncGatewayProvider = Provider( + (ref) => ServerSyncClient(), +); + + +final uploadServerSyncUseCaseProvider = FutureProvider( + (ref) async { + return UploadServerSyncUseCase( + export: await ref.watch(exportBackupUseCaseProvider.future), + gateway: ref.read(serverSyncGatewayProvider), + ); + }, +); + + +final pullServerSyncUseCaseProvider = FutureProvider(( + ref, +) async { + return PullServerSyncUseCase( + serverRepository: await ref.watch(serverRepositoryProvider.future), + sessionRepository: await ref.watch(sessionRepositoryProvider.future), + danmakuSourcesStore: ref.read(danmakuSourcesStoreProvider), + scriptWidgetRepository: await ref.watch( + scriptWidgetRepositoryProvider.future, + ), + gateway: ref.read(serverSyncGatewayProvider), + ); +}); + + +final repairSessionsUseCaseProvider = FutureProvider(( + ref, +) async { + final d = await _embyDeps(ref); + return RepairSessionsUseCase( + serverRepository: d.server, + sessionRepository: d.session, + embyGateway: d.gw, + ); +}); + +final deleteServerUseCaseProvider = FutureProvider(( + ref, +) async { + final sRepo = await ref.watch(serverRepositoryProvider.future); + final ssRepo = await ref.watch(sessionRepositoryProvider.future); + return DeleteServerUseCase( + serverRepository: sRepo, + sessionRepository: ssRepo, + ); +}); + +final togglePauseServerUseCaseProvider = + FutureProvider((ref) async { + final sRepo = await ref.watch(serverRepositoryProvider.future); + return TogglePauseServerUseCase(serverRepository: sRepo); + }); + +final authByNameUseCaseProvider = FutureProvider(( + ref, +) async { + final d = await _embyDeps(ref); + return AuthByNameUseCase( + serverRepository: d.server, + sessionRepository: d.session, + embyGateway: d.gw, + ); +}); + +final logoutUseCaseProvider = FutureProvider((ref) async { + return LogoutUseCase( + sessionRepository: await ref.watch(sessionRepositoryProvider.future), + ); +}); + +final listSessionsUseCaseProvider = FutureProvider(( + ref, +) async { + return ListSessionsUseCase( + sessionRepository: await ref.watch(sessionRepositoryProvider.future), + serverRepository: await ref.watch(serverRepositoryProvider.future), + ); +}); + +final switchSessionUseCaseProvider = FutureProvider(( + ref, +) async { + return SwitchSessionUseCase( + sessionRepository: await ref.watch(sessionRepositoryProvider.future), + ); +}); + +final changePasswordUseCaseProvider = FutureProvider(( + ref, +) async { + final d = await _embyDeps(ref); + return ChangePasswordUseCase( + sessionRepository: d.session, + serverRepository: d.server, + embyGateway: d.gw, + ); +}); + +final libraryViewsUseCaseProvider = _authedUseCaseProvider( + GetLibraryViewsUseCase.new, +); +final libraryResumeUseCaseProvider = _authedUseCaseProvider( + GetLibraryResumeUseCase.new, +); +final libraryLatestUseCaseProvider = _authedUseCaseProvider( + GetLibraryLatestUseCase.new, +); +final libraryItemsUseCaseProvider = _authedUseCaseProvider( + GetLibraryItemsUseCase.new, +); +final homeBannerUseCaseProvider = _authedUseCaseProvider( + GetHomeBannerUseCase.new, +); +final mediaDetailUseCaseProvider = _authedUseCaseProvider( + GetMediaDetailUseCase.new, +); +final setFavoriteUseCaseProvider = _authedUseCaseProvider( + SetFavoriteUseCase.new, +); +final setPlayedUseCaseProvider = _authedUseCaseProvider(SetPlayedUseCase.new); +final hideFromResumeUseCaseProvider = _authedUseCaseProvider( + HideFromResumeUseCase.new, +); +final seriesEpisodesUseCaseProvider = _authedUseCaseProvider( + GetSeriesEpisodesUseCase.new, +); +final seriesSeasonsUseCaseProvider = _authedUseCaseProvider( + GetSeriesSeasonsUseCase.new, +); +final similarItemsUseCaseProvider = _authedUseCaseProvider( + GetSimilarItemsUseCase.new, +); +final additionalPartsUseCaseProvider = _authedUseCaseProvider( + GetAdditionalPartsUseCase.new, +); +final specialFeaturesUseCaseProvider = _authedUseCaseProvider( + GetSpecialFeaturesUseCase.new, +); +final crossServerSearchUseCaseProvider = _authedUseCaseProvider( + CrossServerSearchUseCase.new, +); +final tmdbAvailabilityUseCaseProvider = _authedUseCaseProvider( + TmdbAvailabilityUseCase.new, +); + +final globalKeywordSearchUseCaseProvider = _authedUseCaseProvider( + GlobalKeywordSearchUseCase.new, +); +final aggregatedResumeUseCaseProvider = _authedUseCaseProvider( + GetAggregatedResumeUseCase.new, +); +final aggregatedFavoritesUseCaseProvider = _authedUseCaseProvider( + GetAggregatedFavoritesUseCase.new, +); +final aggregatedRecentUseCaseProvider = _authedUseCaseProvider( + GetAggregatedRecentUseCase.new, +); diff --git a/lib/providers/di/di_script_widget.dart b/lib/providers/di/di_script_widget.dart new file mode 100644 index 0000000..7cf1ccb --- /dev/null +++ b/lib/providers/di/di_script_widget.dart @@ -0,0 +1,84 @@ +part of '../di_providers.dart'; + +final scriptWidgetRuntimeProvider = Provider((ref) { + final runtime = QuickJsWidgetRuntime(); + ref.onDispose(() { + unawaited(runtime.dispose()); + }); + return runtime; +}); + +final scriptWidgetRemoteGatewayProvider = Provider( + (ref) => ScriptWidgetRemoteClient(), +); + +final scriptWidgetRepositoryProvider = FutureProvider(( + ref, +) async { + final appDataDirectory = await ref.watch(appDataDirProvider.future); + return JsonScriptWidgetRepository( + file: File('${appDataDirectory.path}/script_widgets.json'), + ); +}); + +final scriptWidgetServiceProvider = FutureProvider(( + ref, +) async { + final repository = await ref.watch(scriptWidgetRepositoryProvider.future); + return ScriptWidgetService( + repository: repository, + remoteGateway: ref.watch(scriptWidgetRemoteGatewayProvider), + runtime: ref.watch(scriptWidgetRuntimeProvider), + danmakuSourcesStore: ref.watch(danmakuSourcesStoreProvider), + onDanmakuSourcesChanged: () async { + ref.invalidate(danmakuSettingsProvider); + }, + ); +}); + +final installedScriptWidgetsProvider = + FutureProvider>((ref) async { + final service = await ref.watch(scriptWidgetServiceProvider.future); + return service.listWidgets(); + }); + +final scriptWidgetSubscriptionsProvider = + FutureProvider>((ref) async { + final service = await ref.watch(scriptWidgetServiceProvider.future); + return service.listSubscriptions(); + }); + +final installedScriptWidgetProvider = + FutureProvider.family((ref, widgetId) async { + final service = await ref.watch(scriptWidgetServiceProvider.future); + return service.requireWidget(widgetId); + }); + +typedef ScriptWidgetModulePreviewKey = ({String widgetId, String moduleKey}); + +final scriptWidgetModulePreviewProvider = + FutureProvider.family< + List, + ScriptWidgetModulePreviewKey + >((ref, previewKey) async { + final service = await ref.watch(scriptWidgetServiceProvider.future); + final installedWidget = await service.requireWidget(previewKey.widgetId); + final module = installedWidget.manifest.modules + .where( + (candidate) => + (candidate.id.isEmpty + ? candidate.functionName + : candidate.id) == + previewKey.moduleKey, + ) + .firstOrNull; + if (module == null) { + throw StateError('未找到模块功能:${previewKey.moduleKey}'); + } + + return service.loadModuleSections( + previewKey.widgetId, + module, + const {}, + ); + }); diff --git a/lib/providers/di/di_tmdb.dart b/lib/providers/di/di_tmdb.dart new file mode 100644 index 0000000..22364af --- /dev/null +++ b/lib/providers/di/di_tmdb.dart @@ -0,0 +1,362 @@ +part of '../di_providers.dart'; + +typedef TmdbMediaKey = ({String tmdbId, String mediaType}); +typedef TmdbSeasonKey = ({String tmdbId, int seasonNumber}); + + +typedef _TmdbReady = ({TmdbRequestConfig config, String language}); + + +final _tmdbReadyProvider = FutureProvider<_TmdbReady?>((ref) async { + final settings = await ref.watch(tmdbSettingsProvider.future); + if (!settings.canRequest) return null; + + if (settings.accessMode == TmdbAccessMode.smAccount) { + final account = await ref.watch(smAccountProvider.future); + if (!account.canUseDataPlane) return null; + final notifier = ref.read(smAccountProvider.notifier); + final token = await notifier.getAccessToken(); + if (token == null) return null; + return ( + config: TmdbRequestConfig( + apiBaseUrl: account.tmdbApiBaseUrl, + imageBaseUrl: TmdbSettingsState.officialImageBaseUrl, + accessToken: token, + apiKey: '', + onUnauthorized: () => notifier.getAccessToken(force: true), + ), + language: settings.language, + ); + } + + return ( + config: TmdbRequestConfig( + apiBaseUrl: settings.effectiveApiBaseUrl, + imageBaseUrl: settings.effectiveImageBaseUrl, + accessToken: settings.effectiveAccessToken, + apiKey: settings.effectiveApiKey, + ), + language: settings.language, + ); +}); + + +final _tmdbCacheScopeProvider = FutureProvider(( + ref, +) async { + final settings = await ref.watch(tmdbSettingsProvider.future); + if (!settings.canRequest) return null; + + if (settings.accessMode == TmdbAccessMode.smAccount) { + final account = await ref.watch(smAccountProvider.future); + if (!account.hasActiveSession) return null; + return DiscoverPageCacheScope( + apiBaseUrl: account.tmdbApiBaseUrl, + imageBaseUrl: TmdbSettingsState.officialImageBaseUrl, + language: settings.language, + ); + } + + return DiscoverPageCacheScope( + apiBaseUrl: settings.effectiveApiBaseUrl, + imageBaseUrl: settings.effectiveImageBaseUrl, + language: settings.language, + ); +}); + + +final tmdbEnrichedDetailProvider = + FutureProvider.family((ref, key) async { + final ready = await ref.watch(_tmdbReadyProvider.future); + if (ready == null || key.tmdbId.isEmpty) return null; + return ref + .read(tmdbGatewayProvider) + .getEnrichedDetail( + ready.config, + key.tmdbId, + key.mediaType, + language: ready.language, + ); + }); + + +final tmdbImagesProvider = Provider.family(( + ref, + key, +) { + return ref.watch(tmdbEnrichedDetailProvider(key)).value?.images; +}); + + +final tmdbMediaDetailProvider = Provider.family( + (ref, key) { + return ref.watch(tmdbEnrichedDetailProvider(key)).value?.detail; + }, +); + + +final tmdbSeasonDetailProvider = + FutureProvider.family((ref, key) async { + final ready = await ref.watch(_tmdbReadyProvider.future); + if (ready == null || key.tmdbId.isEmpty) return null; + return ref + .read(tmdbGatewayProvider) + .getSeasonDetail( + ready.config, + key.tmdbId, + key.seasonNumber, + language: ready.language, + ); + }); + +enum _TmdbRecommendationRowKind { + trendingDay, + trendingWeek, + popularMovies, + popularTv, + airingTodayTv, + topRatedMovies, +} + +final _tmdbRecommendationRowProvider = + AsyncNotifierProvider.family< + _TmdbRecommendationRowNotifier, + TmdbRecommendationsRes?, + _TmdbRecommendationRowKind + >(_TmdbRecommendationRowNotifier.new); + +final tmdbTrendingDayProvider = _tmdbRecommendationRowProvider( + _TmdbRecommendationRowKind.trendingDay, +); + +final tmdbTrendingWeekProvider = _tmdbRecommendationRowProvider( + _TmdbRecommendationRowKind.trendingWeek, +); + +final tmdbPopularMoviesProvider = _tmdbRecommendationRowProvider( + _TmdbRecommendationRowKind.popularMovies, +); + +final tmdbPopularTvProvider = _tmdbRecommendationRowProvider( + _TmdbRecommendationRowKind.popularTv, +); + +final tmdbAiringTodayTvProvider = _tmdbRecommendationRowProvider( + _TmdbRecommendationRowKind.airingTodayTv, +); + +final tmdbTopRatedMoviesProvider = _tmdbRecommendationRowProvider( + _TmdbRecommendationRowKind.topRatedMovies, +); + + +typedef TmdbDiscoverKey = ({ + String mediaType, + String sortBy, + String? withGenres, + String? voteCountGte, +}); + +final tmdbDiscoverProvider = + AsyncNotifierProvider.family< + _TmdbDiscoverRowNotifier, + TmdbRecommendationsRes?, + TmdbDiscoverKey + >(_TmdbDiscoverRowNotifier.new); + +class _TmdbRecommendationRowNotifier + extends AsyncNotifier { + final _TmdbRecommendationRowKind arg; + + _TmdbRecommendationRowNotifier(this.arg); + + @override + Future build() async { + final rowKey = _rowKeyForKind(arg); + + final scope = await ref.watch(_tmdbCacheScopeProvider.future); + if (scope == null) return null; + + final cached = await _readDiscoverRowCacheByScope(ref, scope, rowKey); + if (cached != null) { + unawaited(_refreshFromNetwork(arg, rowKey)); + return cached; + } + + final ready = await ref.watch(_tmdbReadyProvider.future); + if (ready == null) return null; + return _fetchAndCache(arg, ready, rowKey); + } + + Future _refreshFromNetwork( + _TmdbRecommendationRowKind kind, + String rowKey, + ) async { + try { + final ready = await ref.read(_tmdbReadyProvider.future); + if (ready == null) return; + final fresh = await _fetchAndCache(kind, ready, rowKey); + if (fresh != null) { + state = AsyncValue.data(fresh); + } + } catch (e) { + AppLogger.warn('TMDB', 'refresh discover row $rowKey failed', e); + } + } + + Future _fetchAndCache( + _TmdbRecommendationRowKind kind, + _TmdbReady ready, + String rowKey, + ) async { + final fresh = await _fetchRow(kind, ready); + if (fresh != null) { + await _writeDiscoverRowCache(ref, ready, rowKey, fresh); + } + return fresh; + } + + Future _fetchRow( + _TmdbRecommendationRowKind kind, + _TmdbReady ready, + ) { + final gateway = ref.read(tmdbGatewayProvider); + return switch (kind) { + _TmdbRecommendationRowKind.trendingDay => gateway.getTrending( + ready.config, + language: ready.language, + mediaType: 'all', + timeWindow: 'day', + ), + _TmdbRecommendationRowKind.trendingWeek => gateway.getTrending( + ready.config, + language: ready.language, + mediaType: 'all', + timeWindow: 'week', + ), + _TmdbRecommendationRowKind.popularMovies => gateway.getPopularMovies( + ready.config, + language: ready.language, + ), + _TmdbRecommendationRowKind.popularTv => gateway.getPopularTv( + ready.config, + language: ready.language, + ), + _TmdbRecommendationRowKind.airingTodayTv => gateway.getAiringTodayTv( + ready.config, + language: ready.language, + ), + _TmdbRecommendationRowKind.topRatedMovies => gateway.getTopRatedMovies( + ready.config, + language: ready.language, + ), + }; + } +} + +class _TmdbDiscoverRowNotifier extends AsyncNotifier { + final TmdbDiscoverKey arg; + + _TmdbDiscoverRowNotifier(this.arg); + + @override + Future build() async { + final rowKey = _rowKeyForDiscover(arg); + + final scope = await ref.watch(_tmdbCacheScopeProvider.future); + if (scope == null) return null; + + final cached = await _readDiscoverRowCacheByScope(ref, scope, rowKey); + if (cached != null) { + unawaited(_refreshFromNetwork(arg, rowKey)); + return cached; + } + + final ready = await ref.watch(_tmdbReadyProvider.future); + if (ready == null) return null; + return _fetchAndCache(arg, ready, rowKey); + } + + Future _refreshFromNetwork(TmdbDiscoverKey key, String rowKey) async { + try { + final ready = await ref.read(_tmdbReadyProvider.future); + if (ready == null) return; + final fresh = await _fetchAndCache(key, ready, rowKey); + if (fresh != null) { + state = AsyncValue.data(fresh); + } + } catch (e) { + AppLogger.warn('TMDB', 'refresh discover row $rowKey failed', e); + } + } + + Future _fetchAndCache( + TmdbDiscoverKey key, + _TmdbReady ready, + String rowKey, + ) async { + final fresh = await ref + .read(tmdbGatewayProvider) + .getDiscover( + ready.config, + key.mediaType, + language: ready.language, + sortBy: key.sortBy, + withGenres: key.withGenres, + voteCountGte: key.voteCountGte, + ); + if (fresh != null) { + await _writeDiscoverRowCache(ref, ready, rowKey, fresh); + } + return fresh; + } +} + +DiscoverPageCacheScope _cacheScopeForReady(_TmdbReady ready) { + return DiscoverPageCacheScope( + apiBaseUrl: ready.config.apiBaseUrl, + imageBaseUrl: ready.config.imageBaseUrl, + language: ready.language, + ); +} + +Future _readDiscoverRowCacheByScope( + Ref ref, + DiscoverPageCacheScope scope, + String rowKey, +) async { + final store = await ref.read(discoverPageCacheStoreProvider.future); + final entry = await store.readRow(scope, rowKey); + return entry?.data; +} + +Future _writeDiscoverRowCache( + Ref ref, + _TmdbReady ready, + String rowKey, + TmdbRecommendationsRes data, +) async { + final store = await ref.read(discoverPageCacheStoreProvider.future); + await store.writeRow(_cacheScopeForReady(ready), rowKey, data); +} + +String _rowKeyForKind(_TmdbRecommendationRowKind kind) { + return switch (kind) { + _TmdbRecommendationRowKind.trendingDay => 'trending:day', + _TmdbRecommendationRowKind.trendingWeek => 'trending:week', + _TmdbRecommendationRowKind.popularMovies => 'popular:movie', + _TmdbRecommendationRowKind.popularTv => 'popular:tv', + _TmdbRecommendationRowKind.airingTodayTv => 'airing_today:tv', + _TmdbRecommendationRowKind.topRatedMovies => 'top_rated:movie', + }; +} + +String _rowKeyForDiscover(TmdbDiscoverKey key) { + return [ + 'discover', + key.mediaType, + key.sortBy, + key.withGenres ?? '', + key.voteCountGte ?? '', + ].join(':'); +} diff --git a/lib/providers/di/di_trakt.dart b/lib/providers/di/di_trakt.dart new file mode 100644 index 0000000..7b532b1 --- /dev/null +++ b/lib/providers/di/di_trakt.dart @@ -0,0 +1,121 @@ +part of '../di_providers.dart'; + + +final traktGatewayProvider = Provider((ref) => TraktApiClient()); + + +final traktTokenStoreProvider = Provider( + (ref) => TraktTokenStore(), +); + + +final traktAuthProvider = Provider((ref) { + return TraktAuth( + store: ref.read(traktTokenStoreProvider), + gateway: ref.read(traktGatewayProvider), + ); +}); + + +final authedTraktGatewayProvider = Provider((ref) { + return AuthedTraktClient(auth: ref.read(traktAuthProvider)); +}); + + +final traktSyncQueueProvider = FutureProvider(( + ref, +) async { + final dir = await ref.watch(appDataDirProvider.future); + return TraktPendingSyncQueue( + store: JsonTraktSyncJobStore( + file: File('${dir.path}/trakt_pending_sync_queue.json'), + ), + sender: ref.read(authedTraktGatewayProvider), + policy: TraktRetryPolicy(), + ); +}); + + +final traktSyncServiceProvider = FutureProvider((ref) async { + final queue = await ref.read(traktSyncQueueProvider.future); + return TraktSyncService( + traktGateway: ref.read(authedTraktGatewayProvider), + queue: queue, + ); +}); + + +final traktContinueWatchingProvider = + FutureProvider>((ref) async { + final authed = ref.read(authedTraktGatewayProvider); + if (!await authed.isConnected()) return const []; + + final ready = await ref.watch(_tmdbReadyProvider.future); + if (ready == null) return const []; + + final List> raw; + try { + raw = await authed.fetchPlayback(); + } catch (e) { + AppLogger.warn('Trakt', 'fetchPlayback failed', e); + return const []; + } + + final entries = raw + .map(TraktPlaybackEntry.fromJson) + .whereType() + .toList(); + entries.sort((a, b) { + final pa = a.pausedAt; + final pb = b.pausedAt; + if (pa == null && pb == null) return 0; + if (pa == null) return 1; + if (pb == null) return -1; + return pb.compareTo(pa); + }); + + final base = []; + final seenKeys = {}; + for (final e in entries) { + final item = TraktContinueWatchingItem.fromEntry(e); + if (item == null) continue; + if (!seenKeys.add('${item.mediaType}:${item.tmdbId}')) continue; + base.add(item); + if (base.length >= 20) break; + } + if (base.isEmpty) return const []; + + const enrichConcurrency = 5; + final gateway = ref.read(tmdbGatewayProvider); + final enriched = []; + for (var i = 0; i < base.length; i += enrichConcurrency) { + final chunk = base.skip(i).take(enrichConcurrency); + final results = await Future.wait( + chunk.map((it) async { + try { + final detail = await gateway.getMediaDetail( + ready.config, + it.tmdbId.toString(), + it.mediaType, + language: ready.language, + ); + final localized = detail?.title; + final titled = (localized != null && localized.isNotEmpty) + ? it.withTitle(localized) + : it; + final url = TmdbImageUrl.backdrop( + ready.config.imageBaseUrl, + detail?.backdropPath, + size: 'w780', + ); + return titled.withBackdrop(url); + } catch (_) { + return it; + } + }), + ); + enriched.addAll(results); + } + + return enriched; + }); diff --git a/lib/providers/di_providers.dart b/lib/providers/di_providers.dart new file mode 100644 index 0000000..561f8a7 --- /dev/null +++ b/lib/providers/di_providers.dart @@ -0,0 +1,72 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:path_provider/path_provider.dart'; + +import '../core/contracts/cancellation.dart'; +import '../core/contracts/library.dart'; +import '../core/contracts/script_widget.dart'; +import '../core/contracts/tmdb.dart'; +import '../core/contracts/trakt/trakt_playback_entry.dart'; +import '../core/domain/ports/authed_trakt_gateway.dart'; +import '../core/domain/ports/danmaku_gateway.dart'; +import '../core/domain/ports/danmaku_sources_store.dart'; +import '../core/domain/ports/emby_gateway.dart'; +import '../core/domain/ports/server_repository.dart'; +import '../core/domain/ports/server_sync_gateway.dart'; +import '../core/domain/ports/session_repository.dart'; +import '../core/domain/ports/script_widget_remote_gateway.dart'; +import '../core/domain/ports/script_widget_repository.dart'; +import '../core/domain/ports/script_widget_runtime.dart'; +import '../core/domain/ports/tmdb_gateway.dart'; +import '../core/domain/ports/trakt_gateway.dart'; +import '../core/domain/usecases/auth_usecases.dart'; +import '../core/domain/usecases/backup_usecases.dart'; +import '../core/domain/usecases/server_sync_usecases.dart'; +import '../core/domain/usecases/library_usecases.dart'; +import '../core/domain/usecases/media_usecases.dart'; +import '../core/domain/usecases/series_resume_target.dart'; +import '../core/domain/usecases/server_usecases.dart'; +import '../core/domain/usecases/script_widget/script_widget_service.dart'; +import '../core/domain/usecases/usecase_helpers.dart'; +import '../core/infra/danmaku_api/dispatching_danmaku_gateway.dart'; +import '../core/infra/danmaku_api/danmaku_client.dart'; +import '../core/infra/danmaku_api/script_widget_danmaku_gateway.dart'; +import '../core/infra/emby_api/emby_api_client.dart'; +import '../core/infra/emby_api/emby_headers.dart'; +import '../core/infra/emby_api/playback_resolver.dart'; +import '../core/infra/storage/device_id_store.dart'; +import '../core/infra/storage/discover_page_cache_store.dart'; +import '../core/infra/storage/home_page_cache_store.dart'; +import '../core/infra/server_sync_api/server_sync_client.dart'; +import '../core/infra/storage/json_server_repository.dart'; +import '../core/infra/storage/json_session_repository.dart'; +import '../core/infra/storage/json_script_widget_repository.dart'; +import '../core/infra/storage/prefs_danmaku_sources_store.dart'; +import '../core/infra/storage/search_history_store.dart'; +import '../core/infra/script_widget/quickjs_widget_runtime.dart'; +import '../core/infra/script_widget/script_widget_remote_client.dart'; +import '../core/infra/tmdb_api/tmdb_client.dart'; +import '../core/infra/trakt_api/authed_trakt_client.dart'; +import '../core/infra/trakt_api/trakt_auth.dart'; +import '../core/infra/trakt_api/trakt_client.dart'; +import '../core/infra/trakt_api/trakt_pending_sync_queue.dart'; +import '../core/infra/trakt_api/trakt_retry_policy.dart'; +import '../core/infra/trakt_api/trakt_sync_service.dart'; +import '../core/infra/trakt_api/trakt_token_store.dart'; +import '../features/discover/trakt_continue_watching_item.dart'; +import '../shared/mappers/tmdb_image_url.dart'; +import '../shared/utils/app_logger.dart'; +import '../shared/utils/platform_device_name.dart'; +import '../shared/utils/time_bucket.dart'; +import 'danmaku_settings_provider.dart'; +import 'sm_account_provider.dart'; +import 'tmdb_settings_provider.dart'; + +part 'di/di_core_stores.dart'; +part 'di/di_detail_data.dart'; +part 'di/di_emby_usecases.dart'; +part 'di/di_script_widget.dart'; +part 'di/di_tmdb.dart'; +part 'di/di_trakt.dart'; diff --git a/lib/providers/emby_headers_provider.dart b/lib/providers/emby_headers_provider.dart new file mode 100644 index 0000000..9a105b0 --- /dev/null +++ b/lib/providers/emby_headers_provider.dart @@ -0,0 +1,20 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../core/infra/emby_api/emby_headers.dart'; +import '../shared/utils/platform_device_name.dart'; +import 'di_providers.dart'; +import 'session_provider.dart'; + +final embyImageHeadersProvider = FutureProvider?>(( + ref, +) async { + final session = ref.watch(activeSessionProvider); + if (session == null) return null; + final deviceId = await ref.watch(deviceIdProvider.future); + return EmbyRequestHeaders.image( + deviceId: deviceId, + deviceName: kEmbyDeviceName, + token: session.token, + userId: session.userId, + ); +}); diff --git a/lib/providers/home_banner_provider.dart b/lib/providers/home_banner_provider.dart new file mode 100644 index 0000000..0f0d420 --- /dev/null +++ b/lib/providers/home_banner_provider.dart @@ -0,0 +1,95 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../core/contracts/library.dart'; +import '../core/infra/storage/home_page_cache_store.dart'; +import 'di_providers.dart'; +import 'session_provider.dart'; + +class HomeBannerNotifier extends AsyncNotifier> { + @override + Future> build() async { + final session = ref.watch(activeSessionProvider); + if (session == null) return const []; + + final store = await ref.read(homePageCacheStoreProvider.future); + final cache = await store.readBanner(session.serverId, session.userId); + + if (cache != null) { + unawaited(_refreshBannerFromNetwork(session.serverId, session.userId)); + return cache.bannerItems; + } + + return _fetchAndCacheBanner(session.serverId, session.userId); + } + + Future> _fetchAndCacheBanner( + String serverId, + String userId, + ) async { + final store = await ref.read(homePageCacheStoreProvider.future); + final uc = await ref.read(homeBannerUseCaseProvider.future); + final items = await uc.execute(); + unawaited( + store.writeBanner( + serverId, + userId, + HomeBannerCacheData( + bannerItems: items, + savedAt: DateTime.now().toUtc(), + ), + ), + ); + return items; + } + + Future _refreshBannerFromNetwork(String serverId, String userId) async { + try { + final uc = await ref.read(homeBannerUseCaseProvider.future); + final items = await uc.execute(); + + final s = ref.read(activeSessionProvider); + if (s == null || s.serverId != serverId || s.userId != userId) return; + + state = AsyncValue.data(items); + + final store = await ref.read(homePageCacheStoreProvider.future); + unawaited( + store.writeBanner( + serverId, + userId, + HomeBannerCacheData( + bannerItems: items, + savedAt: DateTime.now().toUtc(), + ), + ), + ); + } catch (_) {} + } + + Future refresh() async { + state = const AsyncValue.loading(); + state = await AsyncValue.guard(() async { + final session = ref.read(activeSessionProvider); + if (session == null) return const []; + return _fetchAndCacheBanner(session.serverId, session.userId); + }); + } + + + Future softRefresh() async { + final session = ref.read(activeSessionProvider); + if (session == null) return; + if (state.value == null) { + await refresh(); + return; + } + await _refreshBannerFromNetwork(session.serverId, session.userId); + } +} + +final homeBannerProvider = + AsyncNotifierProvider>( + HomeBannerNotifier.new, + ); diff --git a/lib/providers/home_page_mode_provider.dart b/lib/providers/home_page_mode_provider.dart new file mode 100644 index 0000000..a9e739b --- /dev/null +++ b/lib/providers/home_page_mode_provider.dart @@ -0,0 +1,7 @@ +import 'package:flutter_riverpod/legacy.dart'; + +enum HomePageMode { server, recommend } + +final homePageModeProvider = StateProvider( + (_) => HomePageMode.server, +); diff --git a/lib/providers/icon_library_provider.dart b/lib/providers/icon_library_provider.dart new file mode 100644 index 0000000..22dd1c9 --- /dev/null +++ b/lib/providers/icon_library_provider.dart @@ -0,0 +1,224 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../core/contracts/icon_library.dart'; +import '../core/infra/icon_library_api/icon_library_client.dart'; +import 'shared/settings_persistence.dart'; + + +enum IconLibraryStatus { idle, loading, loaded, error } + +class IconLibraryEntryState { + final String url; + final IconLibrary? library; + final IconLibraryStatus status; + final String? error; + + const IconLibraryEntryState({ + required this.url, + this.library, + this.status = IconLibraryStatus.idle, + this.error, + }); + + IconLibraryEntryState copyWith({ + IconLibrary? library, + IconLibraryStatus? status, + String? error, + bool clearError = false, + }) => IconLibraryEntryState( + url: url, + library: library ?? this.library, + status: status ?? this.status, + error: clearError ? null : (error ?? this.error), + ); + + int get iconCount => library?.icons.length ?? 0; +} + +class IconLibrarySettingsState { + + final List entries; + + const IconLibrarySettingsState({this.entries = const []}); + + IconLibrarySettingsState copyWith({List? entries}) => + IconLibrarySettingsState(entries: entries ?? this.entries); + + int get totalIcons => entries.fold(0, (acc, e) => acc + e.iconCount); +} + +class IconLibrarySettingsNotifier + extends AsyncNotifier + with SettingsPersistence { + static const _keyUrls = 'smplayer.icon_library_urls'; + static const _keyCache = 'smplayer.icon_library_cache'; + static const _keyInitialized = 'smplayer.icon_library_initialized'; + + + static const defaultUrls = [ + 'https://emby-icon.vercel.app/TFEL-Emby.json', + 'https://raw.githubusercontent.com/xiyuliu509/Player-Icon/refs/heads/master/invisible.json', + 'https://raw.githubusercontent.com/ginibond/ginibond/main/Icons/Forward/tubiao.json', + ]; + + late final IconLibraryClient _client; + + @override + Future build() async { + _client = IconLibraryClient(); + + final prefs = await getPrefs(); + final initialized = prefs.getBool(_keyInitialized) ?? false; + List urls = prefs.getStringList(_keyUrls) ?? const []; + + if (!initialized && urls.isEmpty) { + urls = List.of(defaultUrls); + await persistFields({_keyUrls: urls, _keyInitialized: true}); + } else if (!initialized) { + await persistField(_keyInitialized, true); + } + + final cacheRaw = prefs.getString(_keyCache); + final cache = {}; + if (cacheRaw != null && cacheRaw.isNotEmpty) { + try { + final decoded = jsonDecode(cacheRaw); + if (decoded is Map) { + for (final e in decoded.entries) { + final value = e.value; + if (value is Map) { + cache[e.key] = IconLibrary.parse(e.key, value); + } + } + } + } catch (err) { + debugPrint('[IconLibrary] failed to decode cache: $err'); + } + } + + final entries = urls + .map( + (u) => IconLibraryEntryState( + url: u, + library: cache[u], + status: cache[u] != null + ? IconLibraryStatus.loaded + : IconLibraryStatus.idle, + ), + ) + .toList(); + + final initialState = IconLibrarySettingsState(entries: entries); + + Future.microtask(() async { + for (final e in entries) { + if (e.library == null) { + await refresh(e.url); + } + } + }); + + return initialState; + } + + + Future addUrl(String url) async { + final trimmed = url.trim(); + if (trimmed.isEmpty) return; + + final current = state.value ?? const IconLibrarySettingsState(); + if (current.entries.any((e) => e.url == trimmed)) return; + + final next = [ + ...current.entries, + IconLibraryEntryState(url: trimmed, status: IconLibraryStatus.loading), + ]; + state = AsyncValue.data(current.copyWith(entries: next)); + await _persistUrls(next); + await refresh(trimmed); + } + + + Future removeUrl(String url) async { + final current = state.value ?? const IconLibrarySettingsState(); + final next = current.entries.where((e) => e.url != url).toList(); + if (next.length == current.entries.length) return; + state = AsyncValue.data(current.copyWith(entries: next)); + await _persistUrls(next); + await _persistCache(next); + } + + + Future refresh(String url) async { + final current = state.value; + if (current == null) return; + final idx = current.entries.indexWhere((e) => e.url == url); + if (idx < 0) return; + + var entries = List.of(current.entries); + entries[idx] = entries[idx].copyWith( + status: IconLibraryStatus.loading, + clearError: true, + ); + state = AsyncValue.data(current.copyWith(entries: entries)); + + try { + final lib = await _client.fetch(url); + final after = state.value; + if (after == null) return; + final i = after.entries.indexWhere((e) => e.url == url); + if (i < 0) return; + entries = List.of(after.entries); + entries[i] = entries[i].copyWith( + library: lib, + status: IconLibraryStatus.loaded, + clearError: true, + ); + state = AsyncValue.data(after.copyWith(entries: entries)); + await _persistCache(entries); + } catch (err) { + debugPrint('[IconLibrary] refresh failed for $url: $err'); + final after = state.value; + if (after == null) return; + final i = after.entries.indexWhere((e) => e.url == url); + if (i < 0) return; + entries = List.of(after.entries); + entries[i] = entries[i].copyWith( + status: IconLibraryStatus.error, + error: err.toString(), + ); + state = AsyncValue.data(after.copyWith(entries: entries)); + } + } + + + Future refreshAll() async { + final current = state.value; + if (current == null) return; + for (final e in current.entries) { + await refresh(e.url); + } + } + + Future _persistUrls(List entries) async { + await persistField(_keyUrls, entries.map((e) => e.url).toList()); + } + + Future _persistCache(List entries) async { + final map = {}; + for (final e in entries) { + final lib = e.library; + if (lib != null) map[e.url] = lib.toJson(); + } + await persistField(_keyCache, jsonEncode(map)); + } +} + +final iconLibraryProvider = + AsyncNotifierProvider< + IconLibrarySettingsNotifier, + IconLibrarySettingsState + >(IconLibrarySettingsNotifier.new); diff --git a/lib/providers/item_counts_provider.dart b/lib/providers/item_counts_provider.dart new file mode 100644 index 0000000..c3fb61d --- /dev/null +++ b/lib/providers/item_counts_provider.dart @@ -0,0 +1,26 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../core/contracts/library.dart'; +import '../core/domain/ports/emby_gateway.dart'; +import 'di_providers.dart'; +import 'session_provider.dart'; + + +final serverItemCountsProvider = FutureProvider.family(( + ref, + serverId, +) async { + final sessions = ref.watch(sessionProvider); + final session = sessions.value?.sessions + .where((s) => s.serverId == serverId) + .firstOrNull; + if (session == null) return null; + + final gateway = await ref.read(embyGatewayProvider.future); + final ctx = AuthedRequestContext( + baseUrl: session.serverUrl, + token: session.token, + userId: session.userId, + ); + return gateway.getItemCounts(ctx); +}); diff --git a/lib/providers/last_played_version_store.dart b/lib/providers/last_played_version_store.dart new file mode 100644 index 0000000..51fe392 --- /dev/null +++ b/lib/providers/last_played_version_store.dart @@ -0,0 +1,36 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'shared/settings_persistence.dart'; + + +class LastPlayedVersionStore with SettingsPersistence { + static const _key = 'smplayer.last_played_version'; + static const _maxRemembered = 500; + + String _entryKey(String serverId, String itemId) => '$serverId:$itemId'; + + Future save({ + required String serverId, + required String itemId, + required String mediaSourceId, + }) async { + if (serverId.isEmpty || itemId.isEmpty || mediaSourceId.isEmpty) return; + await putRemembered(_key, _entryKey(serverId, itemId), { + 'mediaSourceId': mediaSourceId, + 'updatedAt': DateTime.now().toIso8601String(), + }, _maxRemembered); + } + + Future get({ + required String serverId, + required String itemId, + }) async { + if (serverId.isEmpty || itemId.isEmpty) return null; + final entry = await readRemembered(_key, _entryKey(serverId, itemId)); + return entry?['mediaSourceId'] as String?; + } +} + +final lastPlayedVersionStoreProvider = Provider( + (ref) => LastPlayedVersionStore(), +); diff --git a/lib/providers/library_overview_provider.dart b/lib/providers/library_overview_provider.dart new file mode 100644 index 0000000..643e036 --- /dev/null +++ b/lib/providers/library_overview_provider.dart @@ -0,0 +1,569 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../core/contracts/cancellation.dart'; +import '../core/contracts/library.dart'; +import '../core/infra/storage/home_page_cache_store.dart'; +import '../shared/utils/app_logger.dart'; +import 'di_providers.dart'; +import 'playback_active_provider.dart'; +import 'session_provider.dart'; + +class LibraryOverviewState { + final List libraries; + final List resumeItems; + final Map> latestByLibrary; + final Map totalCountByLibrary; + final Set loadingLibraryIds; + final bool resumeLoading; + final Object? resumeError; + final Map latestErrorByLibrary; + + const LibraryOverviewState({ + this.libraries = const [], + this.resumeItems = const [], + this.latestByLibrary = const {}, + this.totalCountByLibrary = const {}, + this.loadingLibraryIds = const {}, + this.resumeLoading = false, + this.resumeError, + this.latestErrorByLibrary = const {}, + }); + + LibraryOverviewState copyWith({ + List? libraries, + List? resumeItems, + Map>? latestByLibrary, + Map? totalCountByLibrary, + Set? loadingLibraryIds, + bool? resumeLoading, + Object? resumeError, + bool clearResumeError = false, + Map? latestErrorByLibrary, + }) => LibraryOverviewState( + libraries: libraries ?? this.libraries, + resumeItems: resumeItems ?? this.resumeItems, + latestByLibrary: latestByLibrary ?? this.latestByLibrary, + totalCountByLibrary: totalCountByLibrary ?? this.totalCountByLibrary, + loadingLibraryIds: loadingLibraryIds ?? this.loadingLibraryIds, + resumeLoading: resumeLoading ?? this.resumeLoading, + resumeError: clearResumeError ? null : (resumeError ?? this.resumeError), + latestErrorByLibrary: latestErrorByLibrary ?? this.latestErrorByLibrary, + ); +} + +class LibraryOverviewNotifier extends AsyncNotifier { + CancellationToken? _latestBatchToken; + int _latestRunId = 0; + + @override + Future build() async { + final session = ref.watch(activeSessionProvider); + if (session == null) return const LibraryOverviewState(); + + final store = await ref.read(homePageCacheStoreProvider.future); + final cache = await store.readOverview(session.serverId, session.userId); + + if (cache != null) { + unawaited(_refreshFromNetwork(session.serverId, session.userId)); + return LibraryOverviewState( + libraries: cache.libraries, + resumeItems: cache.resumeItems, + latestByLibrary: cache.latestByLibrary, + loadingLibraryIds: const {}, + resumeLoading: false, + ); + } + + return _loadPrimaryAndCache(session.serverId, session.userId); + } + + Future _loadPrimaryAndCache( + String serverId, + String userId, + ) async { + final store = await ref.read(homePageCacheStoreProvider.future); + final viewsUc = await ref.read(libraryViewsUseCaseProvider.future); + + final sw = Stopwatch()..start(); + final views = await viewsUc.execute(); + AppLogger.debug('prefetch', 'overview.views', { + 'count': views.Items.length, + 'ms': sw.elapsedMilliseconds, + }); + + unawaited( + store.updateOverviewFields(serverId, userId, libraries: views.Items), + ); + + final libraryIds = views.Items.map((lib) => lib.Id).toSet(); + final initialState = LibraryOverviewState( + libraries: views.Items, + resumeItems: const [], + loadingLibraryIds: libraryIds, + resumeLoading: true, + ); + + unawaited(_loadResumeAndCache(serverId, userId, store)); + unawaited( + _loadLatestProgressivelyAndCache(views.Items, serverId, userId, store), + ); + + return initialState; + } + + Future _loadResumeAndCache( + String serverId, + String userId, + HomePageCacheStore store, + ) async { + final sw = Stopwatch()..start(); + try { + final resumeUc = await ref.read(libraryResumeUseCaseProvider.future); + final resume = await resumeUc.execute(); + final current = state.value; + if (current == null) return; + AppLogger.debug('prefetch', 'overview.resume.ok', { + 'count': resume.Items.length, + 'ms': sw.elapsedMilliseconds, + }); + state = AsyncValue.data( + current.copyWith( + resumeItems: resume.Items, + resumeLoading: false, + clearResumeError: true, + ), + ); + unawaited( + store.updateOverviewFields(serverId, userId, resumeItems: resume.Items), + ); + } catch (e) { + final current = state.value; + if (current == null) return; + AppLogger.debug('prefetch', 'overview.resume.err', { + 'error': e.toString(), + 'ms': sw.elapsedMilliseconds, + }); + state = AsyncValue.data( + current.copyWith(resumeLoading: false, resumeError: e), + ); + } + } + + static const _batchSize = 4; + + Future _loadLatestProgressivelyAndCache( + List libraries, + String serverId, + String userId, + HomePageCacheStore store, + ) async { + await _runLatestBatches( + libraries, + serverId, + userId, + store, + mode: _LatestUpdateMode.progressive, + ); + } + + Future _refreshFromNetwork(String serverId, String userId) async { + final store = await ref.read(homePageCacheStoreProvider.future); + + try { + final viewsUc = await ref.read(libraryViewsUseCaseProvider.future); + final sw = Stopwatch()..start(); + final views = await viewsUc.execute(); + AppLogger.debug('prefetch', 'overview.views', { + 'count': views.Items.length, + 'ms': sw.elapsedMilliseconds, + }); + if (!_sessionMatches(serverId, userId)) return; + final current = state.value; + if (current != null) { + state = AsyncValue.data(current.copyWith(libraries: views.Items)); + } + unawaited( + store.updateOverviewFields(serverId, userId, libraries: views.Items), + ); + + unawaited(_refreshResumeFromNetwork(serverId, userId, store)); + + unawaited( + _refreshLatestFromNetwork(views.Items, serverId, userId, store), + ); + } catch (_) {} + } + + Future _refreshResumeFromNetwork( + String serverId, + String userId, + HomePageCacheStore store, + ) async { + final sw = Stopwatch()..start(); + try { + final resumeUc = await ref.read(libraryResumeUseCaseProvider.future); + final resume = await resumeUc.execute(); + AppLogger.debug('prefetch', 'overview.resume.ok', { + 'count': resume.Items.length, + 'ms': sw.elapsedMilliseconds, + }); + if (!_sessionMatches(serverId, userId)) return; + final current = state.value; + if (current == null) return; + state = AsyncValue.data( + current.copyWith( + resumeItems: resume.Items, + resumeLoading: false, + clearResumeError: true, + ), + ); + unawaited( + store.updateOverviewFields(serverId, userId, resumeItems: resume.Items), + ); + } catch (_) {} + } + + Future _refreshLatestFromNetwork( + List libraries, + String serverId, + String userId, + HomePageCacheStore store, + ) async { + await _runLatestBatches( + libraries, + serverId, + userId, + store, + mode: _LatestUpdateMode.refresh, + ); + } + + Future _runLatestBatches( + List libraries, + String serverId, + String userId, + HomePageCacheStore store, { + required _LatestUpdateMode mode, + }) async { + final runId = ++_latestRunId; + _latestBatchToken?.cancel('latest_replaced'); + _latestBatchToken = null; + final latestUc = await ref.read(libraryLatestUseCaseProvider.future); + + for (var i = 0; i < libraries.length;) { + if (runId != _latestRunId || !_sessionMatches(serverId, userId)) return; + + await _waitWhileDetailActive(); + if (runId != _latestRunId || !_sessionMatches(serverId, userId)) return; + + final batch = libraries.sublist( + i, + (i + _batchSize).clamp(0, libraries.length), + ); + final token = CancellationToken(); + _latestBatchToken = token; + final sub = ref.listen(detailPageActiveProvider, (_, active) { + if (active) token.cancel('detail_active'); + }); + + try { + if (ref.read(detailPageActiveProvider)) { + token.cancel('detail_active'); + } + token.throwIfCancelled(); + final results = await _fetchLatestBatch(batch, latestUc.execute, token); + token.throwIfCancelled(); + + if (runId != _latestRunId || !_sessionMatches(serverId, userId)) { + return; + } + _applyLatestResults(results, serverId, userId, store, mode: mode); + i += _batchSize; + } on CancelledException catch (e) { + if (runId != _latestRunId) return; + AppLogger.debug('prefetch', 'overview.latest.cancel', { + 'index': i, + 'reason': e.reason ?? token.reason ?? 'cancelled', + }); + } finally { + sub.close(); + if (identical(_latestBatchToken, token)) { + _latestBatchToken = null; + } + } + } + } + + Future> _fetchLatestBatch( + List batch, + Future Function( + LibraryLatestReq input, { + CancellationToken? cancelToken, + }) + execute, + CancellationToken cancelToken, + ) async { + final results = <_LatestResult>[]; + final futures = batch.map((lib) async { + cancelToken.throwIfCancelled(); + final sw = Stopwatch()..start(); + List items = const []; + int totalCount = 0; + Object? err; + try { + final res = await execute( + LibraryLatestReq(parentId: lib.Id, limit: 16), + cancelToken: cancelToken, + ); + cancelToken.throwIfCancelled(); + items = res.items; + totalCount = res.totalCount; + } on CancelledException { + rethrow; + } catch (e) { + err = e; + } + AppLogger.debug('prefetch', + err == null ? 'overview.latest.ok' : 'overview.latest.err', + { + 'lib': lib.Name, + 'count': items.length, + if (err != null) 'error': err.toString(), + 'ms': sw.elapsedMilliseconds, + }, + ); + results.add( + _LatestResult( + libraryId: lib.Id, + items: items, + totalCount: totalCount, + error: err, + ), + ); + }).toList(); + await Future.wait(futures); + return results; + } + + void _applyLatestResults( + List<_LatestResult> results, + String serverId, + String userId, + HomePageCacheStore store, { + required _LatestUpdateMode mode, + }) { + final current = state.value; + if (current == null) return; + + final newMap = Map>.from(current.latestByLibrary); + final newCounts = Map.from(current.totalCountByLibrary); + var hasSuccess = false; + + if (mode == _LatestUpdateMode.refresh) { + final newLoading = Set.from(current.loadingLibraryIds); + for (final r in results) { + if (r.error != null) continue; + newMap[r.libraryId] = r.items; + if (r.totalCount > 0) newCounts[r.libraryId] = r.totalCount; + newLoading.remove(r.libraryId); + hasSuccess = true; + } + state = AsyncValue.data( + current.copyWith( + latestByLibrary: newMap, + totalCountByLibrary: newCounts, + loadingLibraryIds: newLoading, + ), + ); + if (hasSuccess) { + unawaited( + store.updateOverviewFields( + serverId, + userId, + latestByLibrary: Map>.from(newMap), + ), + ); + } + return; + } + + final newLoading = Set.from(current.loadingLibraryIds); + final newErrors = Map.from(current.latestErrorByLibrary); + + for (final r in results) { + newMap[r.libraryId] = r.items; + if (r.totalCount > 0) newCounts[r.libraryId] = r.totalCount; + newLoading.remove(r.libraryId); + if (r.error != null) { + newErrors[r.libraryId] = r.error; + } else { + newErrors.remove(r.libraryId); + hasSuccess = true; + } + } + + state = AsyncValue.data( + current.copyWith( + latestByLibrary: newMap, + totalCountByLibrary: newCounts, + loadingLibraryIds: newLoading, + latestErrorByLibrary: newErrors, + ), + ); + + if (hasSuccess) { + unawaited( + store.updateOverviewFields( + serverId, + userId, + latestByLibrary: Map>.from(newMap), + ), + ); + } + } + + Future _waitWhileDetailActive() async { + if (!ref.read(detailPageActiveProvider)) return; + final completer = Completer(); + final sub = ref.listen(detailPageActiveProvider, (_, active) { + if (!active && !completer.isCompleted) { + completer.complete(); + } + }); + await completer.future; + sub.close(); + } + + bool _sessionMatches(String serverId, String userId) { + final s = ref.read(activeSessionProvider); + return s != null && s.serverId == serverId && s.userId == userId; + } + + Future refresh() async { + state = const AsyncValue.loading(); + state = await AsyncValue.guard(() async { + final session = ref.read(activeSessionProvider); + if (session == null) return const LibraryOverviewState(); + return _loadPrimaryAndCache(session.serverId, session.userId); + }); + } + + + Future softRefresh() async { + final session = ref.read(activeSessionProvider); + if (session == null) return; + if (state.value == null) { + await refresh(); + return; + } + await _refreshFromNetwork(session.serverId, session.userId); + } + + Future retryResume() async { + final session = ref.read(activeSessionProvider); + if (session == null) return; + final store = await ref.read(homePageCacheStoreProvider.future); + final current = state.value; + if (current == null) return; + state = AsyncValue.data( + current.copyWith(resumeLoading: true, clearResumeError: true), + ); + await _loadResumeAndCache(session.serverId, session.userId, store); + } + + Future retryLatest(String libraryId) async { + final session = ref.read(activeSessionProvider); + if (session == null) return; + final store = await ref.read(homePageCacheStoreProvider.future); + final current = state.value; + if (current == null) return; + + final newLoading = Set.from(current.loadingLibraryIds) + ..add(libraryId); + final clearedErrors = Map.from( + current.latestErrorByLibrary, + )..remove(libraryId); + state = AsyncValue.data( + current.copyWith( + loadingLibraryIds: newLoading, + latestErrorByLibrary: clearedErrors, + ), + ); + + final uc = await ref.read(libraryLatestUseCaseProvider.future); + final sw = Stopwatch()..start(); + try { + final res = await uc.execute( + LibraryLatestReq(parentId: libraryId, limit: 16), + ); + AppLogger.debug('prefetch', 'overview.latest.retry.ok', { + 'lib': libraryId, + 'count': res.items.length, + 'ms': sw.elapsedMilliseconds, + }); + final cur = state.value; + if (cur == null) return; + final newMap = Map>.from(cur.latestByLibrary); + newMap[libraryId] = res.items; + final newCounts = Map.from(cur.totalCountByLibrary); + if (res.totalCount > 0) newCounts[libraryId] = res.totalCount; + final stillLoading = Set.from(cur.loadingLibraryIds) + ..remove(libraryId); + state = AsyncValue.data( + cur.copyWith( + latestByLibrary: newMap, + totalCountByLibrary: newCounts, + loadingLibraryIds: stillLoading, + ), + ); + unawaited( + store.updateOverviewFields( + session.serverId, + session.userId, + latestByLibrary: Map>.from(newMap), + ), + ); + } catch (e) { + AppLogger.debug('prefetch', 'overview.latest.retry.err', { + 'lib': libraryId, + 'error': e.toString(), + 'ms': sw.elapsedMilliseconds, + }); + final cur = state.value; + if (cur == null) return; + final stillLoading = Set.from(cur.loadingLibraryIds) + ..remove(libraryId); + final errs = Map.from(cur.latestErrorByLibrary); + errs[libraryId] = e; + state = AsyncValue.data( + cur.copyWith( + loadingLibraryIds: stillLoading, + latestErrorByLibrary: errs, + ), + ); + } + } +} + +final libraryOverviewProvider = + AsyncNotifierProvider( + LibraryOverviewNotifier.new, + ); + +enum _LatestUpdateMode { progressive, refresh } + +class _LatestResult { + final String libraryId; + final List items; + final int totalCount; + final Object? error; + + const _LatestResult({ + required this.libraryId, + required this.items, + required this.totalCount, + this.error, + }); +} diff --git a/lib/providers/person_items_provider.dart b/lib/providers/person_items_provider.dart new file mode 100644 index 0000000..488d3b7 --- /dev/null +++ b/lib/providers/person_items_provider.dart @@ -0,0 +1,94 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../core/contracts/library.dart'; +import '../core/domain/ports/emby_gateway.dart'; +import 'di_providers.dart'; +import 'session_provider.dart'; + + +final personItemsProvider = FutureProvider.family, String>(( + ref, + embyPersonId, +) async { + if (embyPersonId.isEmpty) return const []; + + final session = ref.watch(activeSessionProvider); + if (session == null) return const []; + + final gateway = await ref.read(embyGatewayProvider.future); + final ctx = AuthedRequestContext( + baseUrl: session.serverUrl, + token: session.token, + userId: session.userId, + ); + + return gateway.getLibraryItems( + ctx: ctx, + personIds: [embyPersonId], + includeItemTypes: 'Movie,Series', + recursive: true, + sortBy: 'ProductionYear,SortName', + sortOrder: SortOrder.descending, + limit: 200, + fields: + 'BasicSyncInfo,PrimaryImageAspectRatio,CommunityRating,ProductionYear,UserData', + ); +}); + + +final embyPersonIdByNameProvider = FutureProvider.family(( + ref, + name, +) async { + final trimmed = name.trim(); + if (trimmed.isEmpty) return null; + + final session = ref.watch(activeSessionProvider); + if (session == null) return null; + + final gateway = await ref.read(embyGatewayProvider.future); + final ctx = AuthedRequestContext( + baseUrl: session.serverUrl, + token: session.token, + userId: session.userId, + ); + + final items = await gateway.getLibraryItems( + ctx: ctx, + includeItemTypes: 'Person', + searchTerm: trimmed, + recursive: true, + limit: 5, + fields: 'BasicSyncInfo', + ); + if (items.isEmpty) return null; + + final lower = trimmed.toLowerCase(); + final exact = items.where((e) => e.Name.toLowerCase() == lower); + final match = exact.isNotEmpty ? exact.first : items.first; + return match.Id; +}); + + +final personItemDetailProvider = FutureProvider.family(( + ref, + embyPersonId, +) async { + if (embyPersonId.isEmpty) return null; + + final session = ref.watch(activeSessionProvider); + if (session == null) return null; + + final gateway = await ref.read(embyGatewayProvider.future); + final ctx = AuthedRequestContext( + baseUrl: session.serverUrl, + token: session.token, + userId: session.userId, + ); + + try { + return await gateway.getItemDetail(ctx, embyPersonId); + } catch (_) { + return null; + } +}); diff --git a/lib/providers/playback_active_provider.dart b/lib/providers/playback_active_provider.dart new file mode 100644 index 0000000..daf3fc8 --- /dev/null +++ b/lib/providers/playback_active_provider.dart @@ -0,0 +1,35 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_riverpod/legacy.dart'; + + +final playbackActiveProvider = StateProvider((ref) => false); + + +class DetailPageActivity extends Notifier { + var _disposed = false; + + @override + int build() { + ref.onDispose(() => _disposed = true); + return 0; + } + + void enter() { + if (_disposed) return; + state = state + 1; + } + + void leave() { + if (_disposed || state <= 0) return; + state = state - 1; + } +} + +final detailPageActivityProvider = NotifierProvider( + DetailPageActivity.new, +); + + +final detailPageActiveProvider = Provider( + (ref) => ref.watch(detailPageActivityProvider) > 0, +); diff --git a/lib/providers/playback_info_prefetch_provider.dart b/lib/providers/playback_info_prefetch_provider.dart new file mode 100644 index 0000000..563ccc1 --- /dev/null +++ b/lib/providers/playback_info_prefetch_provider.dart @@ -0,0 +1,104 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../core/domain/usecases/usecase_helpers.dart'; +import '../core/infra/emby_api/playback_resolver.dart'; +import '../shared/utils/app_logger.dart'; +import 'di_providers.dart'; + +const _tag = 'PlaybackInfoPrefetch'; + +typedef PlaybackInfoPrefetchKey = ({ + String serverId, + String itemId, + String? mediaSourceId, + int startTimeTicks, +}); + +typedef PlaybackInfoPrefetchPayload = ({ + String serverId, + String itemId, + String? mediaSourceId, + int startTimeTicks, + Map info, +}); + +String describePlaybackInfoPrefetchKey(PlaybackInfoPrefetchKey key) { + return 'serverId=${key.serverId} itemId=${key.itemId} ' + 'mediaSourceId=${key.mediaSourceId ?? '-'} ' + 'startTimeTicks=${key.startTimeTicks}'; +} + +final playbackInfoPrefetchProvider = FutureProvider.autoDispose + .family(( + ref, + key, + ) async { + final keepAlive = ref.keepAlive(); + Timer? cacheTimer; + var disposed = false; + ref.onDispose(() { + disposed = true; + cacheTimer?.cancel(); + }); + try { + AppLogger.debug( + _tag, + 'fetch start ${describePlaybackInfoPrefetchKey(key)}', + ); + final gateway = await ref.read(embyGatewayProvider.future); + final sessionRepo = await ref.read(sessionRepositoryProvider.future); + final serverRepo = await ref.read(serverRepositoryProvider.future); + final ctx = await resolveAuthedContextForServer( + sessionRepository: sessionRepo, + serverRepository: serverRepo, + serverId: key.serverId, + ); + if (ctx == null) { + AppLogger.debug( + _tag, + 'fetch skipped missing auth ${describePlaybackInfoPrefetchKey(key)}', + ); + return null; + } + final info = await gateway.fetchPlaybackInfo( + ctx: ctx, + itemId: key.itemId, + mediaSourceId: key.mediaSourceId, + startTimeTicks: key.startTimeTicks, + isPlayback: true, + deviceProfile: PlaybackResolver.deviceProfile(), + ); + if (info.isEmpty) { + AppLogger.debug( + _tag, + 'fetch empty ${describePlaybackInfoPrefetchKey(key)}', + ); + return null; + } + AppLogger.debug( + _tag, + 'fetch success ${describePlaybackInfoPrefetchKey(key)} ' + 'payloadKeys=${info.length}', + ); + return ( + serverId: key.serverId, + itemId: key.itemId, + mediaSourceId: key.mediaSourceId, + startTimeTicks: key.startTimeTicks, + info: info, + ); + } catch (e) { + AppLogger.debug( + _tag, + 'fetch failed ${describePlaybackInfoPrefetchKey(key)}', + e, + ); + return null; + } finally { + if (!disposed) { + cacheTimer = Timer(const Duration(minutes: 2), keepAlive.close); + } + } + }); diff --git a/lib/providers/player_engine_override_provider.dart b/lib/providers/player_engine_override_provider.dart new file mode 100644 index 0000000..dbbe55d --- /dev/null +++ b/lib/providers/player_engine_override_provider.dart @@ -0,0 +1,41 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'shared/settings_persistence.dart'; + + +enum PlayerEngineOverride { + auto('自动'), + fvp('fvp (libmdk)'), + mpv('mpv (media_kit)'), + media3('Media3 (ExoPlayer+FFmpeg)'); + + final String label; + const PlayerEngineOverride(this.label); +} + +class PlayerEngineOverrideNotifier extends AsyncNotifier + with SettingsPersistence { + static const _key = 'smplayer.player_engine_override'; + + @override + Future build() async { + final prefs = await getPrefs(); + final raw = prefs.getString(_key); + if (raw == null) return PlayerEngineOverride.auto; + try { + return PlayerEngineOverride.values.byName(raw); + } catch (_) { + return PlayerEngineOverride.auto; + } + } + + Future setOverride(PlayerEngineOverride value) async { + state = AsyncValue.data(value); + await persistField(_key, value.name); + } +} + +final playerEngineOverrideProvider = + AsyncNotifierProvider( + PlayerEngineOverrideNotifier.new, + ); diff --git a/lib/providers/player_settings_provider.dart b/lib/providers/player_settings_provider.dart new file mode 100644 index 0000000..13ba617 --- /dev/null +++ b/lib/providers/player_settings_provider.dart @@ -0,0 +1,241 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../core/contracts/player_gestures.dart'; +import 'shared/settings_persistence.dart'; + +class PlayerSettingsState { + final String hwdecMode; + final double volume; + final int seekForwardSeconds; + final int seekBackwardSeconds; + + final double keyboardHoldLeftSpeed; + final double keyboardHoldRightSpeed; + + final GestureAction mouseLeftClickAction; + final GestureAction mouseLeftDoubleClickAction; + final GestureAction mouseRightClickAction; + final GestureAction mouseRightDoubleClickAction; + final bool autoPip; + final bool pipShowEpisodeActions; + final double? androidBrightness; + + const PlayerSettingsState({ + this.hwdecMode = 'auto-copy', + this.volume = 100.0, + this.seekForwardSeconds = 15, + this.seekBackwardSeconds = 15, + this.keyboardHoldLeftSpeed = 0.5, + this.keyboardHoldRightSpeed = 3.0, + this.mouseLeftClickAction = GestureAction.playPause, + this.mouseLeftDoubleClickAction = GestureAction.fullscreen, + this.mouseRightClickAction = GestureAction.toggleControls, + this.mouseRightDoubleClickAction = GestureAction.none, + this.autoPip = true, + this.pipShowEpisodeActions = true, + this.androidBrightness, + }); + + PlayerSettingsState copyWith({ + String? hwdecMode, + double? volume, + int? seekForwardSeconds, + int? seekBackwardSeconds, + double? keyboardHoldLeftSpeed, + double? keyboardHoldRightSpeed, + GestureAction? mouseLeftClickAction, + GestureAction? mouseLeftDoubleClickAction, + GestureAction? mouseRightClickAction, + GestureAction? mouseRightDoubleClickAction, + bool? autoPip, + bool? pipShowEpisodeActions, + double? androidBrightness, + }) => PlayerSettingsState( + hwdecMode: hwdecMode ?? this.hwdecMode, + volume: volume ?? this.volume, + seekForwardSeconds: seekForwardSeconds ?? this.seekForwardSeconds, + seekBackwardSeconds: seekBackwardSeconds ?? this.seekBackwardSeconds, + keyboardHoldLeftSpeed: keyboardHoldLeftSpeed ?? this.keyboardHoldLeftSpeed, + keyboardHoldRightSpeed: + keyboardHoldRightSpeed ?? this.keyboardHoldRightSpeed, + mouseLeftClickAction: mouseLeftClickAction ?? this.mouseLeftClickAction, + mouseLeftDoubleClickAction: + mouseLeftDoubleClickAction ?? this.mouseLeftDoubleClickAction, + mouseRightClickAction: mouseRightClickAction ?? this.mouseRightClickAction, + mouseRightDoubleClickAction: + mouseRightDoubleClickAction ?? this.mouseRightDoubleClickAction, + autoPip: autoPip ?? this.autoPip, + pipShowEpisodeActions: pipShowEpisodeActions ?? this.pipShowEpisodeActions, + androidBrightness: androidBrightness ?? this.androidBrightness, + ); +} + +class PlayerSettingsNotifier extends AsyncNotifier + with SettingsPersistence { + static const _keyHwdec = 'smplayer.player_hwdec'; + static const _legacyKeyEnginePreference = 'smplayer.player_engine_preference'; + static const _keyVolume = 'smplayer.player_volume'; + static const _keySeekForward = 'smplayer.player_seek_forward'; + static const _keySeekBackward = 'smplayer.player_seek_backward'; + static const _keyKbdHoldLeft = 'smplayer.gesture_kbd_hold_left_speed'; + static const _keyKbdHoldRight = 'smplayer.gesture_kbd_hold_right_speed'; + static const _keyMouseLeftClick = 'smplayer.gesture_mouse_left_click'; + static const _keyMouseLeftDblClick = 'smplayer.gesture_mouse_left_dbl_click'; + static const _keyMouseRightClick = 'smplayer.gesture_mouse_right_click'; + static const _keyMouseRightDblClick = + 'smplayer.gesture_mouse_right_dbl_click'; + static const _keyAutoPip = 'smplayer.player_auto_pip'; + static const _keyPipEpisodeActions = 'smplayer.player_pip_episode_actions'; + static const _keyAndroidBrightness = 'smplayer.player_android_brightness'; + + static const _speedAllowed = [ + 0.5, + 0.75, + 1.0, + 1.25, + 1.5, + 1.75, + 2.0, + 2.5, + 3.0, + 4.0, + 8.0, + ]; + + static double _normalizeSpeed(double v, double fallback) { + for (final s in _speedAllowed) { + if ((s - v).abs() < 0.001) return s; + } + return fallback; + } + + @override + Future build() async { + final prefs = await getPrefs(); + if (prefs.containsKey(_keyHwdec)) { + await prefs.remove(_keyHwdec); + } + const hwdec = 'auto-copy'; + final volume = prefs.getDouble(_keyVolume) ?? 100.0; + final seekFwd = prefs.getInt(_keySeekForward) ?? 15; + final seekBwd = prefs.getInt(_keySeekBackward) ?? 15; + if (prefs.containsKey(_legacyKeyEnginePreference)) { + await prefs.remove(_legacyKeyEnginePreference); + } + final holdLeft = _normalizeSpeed( + prefs.getDouble(_keyKbdHoldLeft) ?? 0.5, + 0.5, + ); + final holdRight = _normalizeSpeed( + prefs.getDouble(_keyKbdHoldRight) ?? 3.0, + 3.0, + ); + final autoPip = prefs.getBool(_keyAutoPip) ?? true; + final pipEpisodeActions = prefs.getBool(_keyPipEpisodeActions) ?? true; + final androidBrightness = prefs + .getDouble(_keyAndroidBrightness) + ?.clamp(0.0, 1.0); + return PlayerSettingsState( + hwdecMode: hwdec, + volume: volume.clamp(0.0, 200.0), + seekForwardSeconds: seekFwd.clamp(5, 120), + seekBackwardSeconds: seekBwd.clamp(5, 120), + keyboardHoldLeftSpeed: holdLeft, + keyboardHoldRightSpeed: holdRight, + mouseLeftClickAction: gestureActionFromName( + prefs.getString(_keyMouseLeftClick), + GestureAction.playPause, + ), + mouseLeftDoubleClickAction: gestureActionFromName( + prefs.getString(_keyMouseLeftDblClick), + GestureAction.fullscreen, + ), + mouseRightClickAction: gestureActionFromName( + prefs.getString(_keyMouseRightClick), + GestureAction.toggleControls, + ), + mouseRightDoubleClickAction: gestureActionFromName( + prefs.getString(_keyMouseRightDblClick), + GestureAction.none, + ), + autoPip: autoPip, + pipShowEpisodeActions: pipEpisodeActions, + androidBrightness: androidBrightness, + ); + } + + Future setVolume(double volume) async { + final clamped = volume.clamp(0.0, 200.0); + final current = state.value ?? const PlayerSettingsState(); + state = AsyncValue.data(current.copyWith(volume: clamped)); + await persistField(_keyVolume, clamped); + } + + Future setSeekForwardSeconds(int seconds) async { + final clamped = seconds.clamp(5, 120); + final current = state.value ?? const PlayerSettingsState(); + state = AsyncValue.data(current.copyWith(seekForwardSeconds: clamped)); + await persistField(_keySeekForward, clamped); + } + + Future setSeekBackwardSeconds(int seconds) async { + final clamped = seconds.clamp(5, 120); + final current = state.value ?? const PlayerSettingsState(); + state = AsyncValue.data(current.copyWith(seekBackwardSeconds: clamped)); + await persistField(_keySeekBackward, clamped); + } + + Future setKeyboardHoldLeftSpeed(double v) async { + final normalized = _normalizeSpeed(v, 0.5); + final current = state.value ?? const PlayerSettingsState(); + state = AsyncValue.data( + current.copyWith(keyboardHoldLeftSpeed: normalized), + ); + await persistField(_keyKbdHoldLeft, normalized); + } + + Future setKeyboardHoldRightSpeed(double v) async { + final normalized = _normalizeSpeed(v, 3.0); + final current = state.value ?? const PlayerSettingsState(); + state = AsyncValue.data( + current.copyWith(keyboardHoldRightSpeed: normalized), + ); + await persistField(_keyKbdHoldRight, normalized); + } + + Future setMouseLeftClickAction(GestureAction a) async { + final current = state.value ?? const PlayerSettingsState(); + state = AsyncValue.data(current.copyWith(mouseLeftClickAction: a)); + await persistField(_keyMouseLeftClick, a.name); + } + + Future setMouseLeftDoubleClickAction(GestureAction a) async { + final current = state.value ?? const PlayerSettingsState(); + state = AsyncValue.data(current.copyWith(mouseLeftDoubleClickAction: a)); + await persistField(_keyMouseLeftDblClick, a.name); + } + + Future setMouseRightClickAction(GestureAction a) async { + final current = state.value ?? const PlayerSettingsState(); + state = AsyncValue.data(current.copyWith(mouseRightClickAction: a)); + await persistField(_keyMouseRightClick, a.name); + } + + Future setMouseRightDoubleClickAction(GestureAction a) async { + final current = state.value ?? const PlayerSettingsState(); + state = AsyncValue.data(current.copyWith(mouseRightDoubleClickAction: a)); + await persistField(_keyMouseRightDblClick, a.name); + } + + Future setAndroidBrightness(double brightness) async { + final clamped = brightness.clamp(0.0, 1.0); + final current = state.value ?? const PlayerSettingsState(); + state = AsyncValue.data(current.copyWith(androidBrightness: clamped)); + await persistField(_keyAndroidBrightness, clamped); + } +} + +final playerSettingsProvider = + AsyncNotifierProvider( + PlayerSettingsNotifier.new, + ); diff --git a/lib/providers/server_connectivity_provider.dart b/lib/providers/server_connectivity_provider.dart new file mode 100644 index 0000000..1c7d8bd --- /dev/null +++ b/lib/providers/server_connectivity_provider.dart @@ -0,0 +1,29 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../core/contracts/server.dart'; +import 'di_providers.dart'; + +class ServerConnectivity { + final bool reachable; + final int? latencyMs; + + const ServerConnectivity({required this.reachable, this.latencyMs}); +} + + +final serverConnectivityProvider = + FutureProvider.family((ref, baseUrl) async { + final uc = await ref.read(probeServerUseCaseProvider.future); + final sw = Stopwatch()..start(); + try { + await uc.execute(EmbyServerProbeReq(baseUrl: baseUrl)); + sw.stop(); + return ServerConnectivity( + reachable: true, + latencyMs: sw.elapsedMilliseconds, + ); + } catch (_) { + sw.stop(); + return const ServerConnectivity(reachable: false); + } + }); diff --git a/lib/providers/server_provider.dart b/lib/providers/server_provider.dart new file mode 100644 index 0000000..623f488 --- /dev/null +++ b/lib/providers/server_provider.dart @@ -0,0 +1,62 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../core/contracts/server.dart'; +import 'di_providers.dart'; + +class ServerListNotifier extends AsyncNotifier> { + @override + Future> build() async { + final usecase = await ref.watch(listServersUseCaseProvider.future); + return usecase.execute(); + } + + Future refresh() async { + state = const AsyncValue.loading(); + state = await AsyncValue.guard(() async { + final usecase = await ref.read(listServersUseCaseProvider.future); + return usecase.execute(); + }); + } + + Future save(EmbyServerSaveReq req) async { + final usecase = await ref.read(saveServerUseCaseProvider.future); + final server = await usecase.execute(req); + await refresh(); + return server; + } + + Future delete(String id) async { + final usecase = await ref.read(deleteServerUseCaseProvider.future); + await usecase.execute(id); + await refresh(); + } + + Future togglePause(String id) async { + final usecase = await ref.read(togglePauseServerUseCaseProvider.future); + final updated = await usecase.execute(id); + await refresh(); + return updated; + } + + Future reorder(int oldIndex, int newIndex) async { + final currentServers = state.value; + if (currentServers == null || oldIndex == newIndex) return; + + final reorderedServers = List.of(currentServers); + reorderedServers.insert(newIndex, reorderedServers.removeAt(oldIndex)); + + state = AsyncValue.data(reorderedServers); + try { + final usecase = await ref.read(reorderServersUseCaseProvider.future); + await usecase.execute(reorderedServers); + } catch (_) { + await refresh(); + rethrow; + } + } +} + +final serverListProvider = + AsyncNotifierProvider>( + ServerListNotifier.new, + ); diff --git a/lib/providers/server_sync_provider.dart b/lib/providers/server_sync_provider.dart new file mode 100644 index 0000000..b3e408d --- /dev/null +++ b/lib/providers/server_sync_provider.dart @@ -0,0 +1,190 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../core/domain/errors.dart'; +import '../shared/utils/app_logger.dart'; +import 'danmaku_settings_provider.dart'; +import 'di_providers.dart'; +import 'server_provider.dart'; +import 'session_provider.dart'; +import 'sm_account_provider.dart'; + + +enum ServerSyncStatus { success, info, failure, needsLogin, needsVip } + + +class ServerSyncOutcome { + final ServerSyncStatus status; + final String message; + + const ServerSyncOutcome(this.status, this.message); + + bool get isError => + status == ServerSyncStatus.failure || + status == ServerSyncStatus.needsLogin || + status == ServerSyncStatus.needsVip; +} + + +class ServerSyncState { + final bool busy; + final DateTime? lastSyncAt; + final String? lastSyncError; + const ServerSyncState({ + this.busy = false, + this.lastSyncAt, + this.lastSyncError, + }); +} + + +class ServerSyncNotifier extends Notifier { + static const _tag = 'ServerSync'; + + @override + ServerSyncState build() => const ServerSyncState(); + + + Future upload() async { + if (state.busy) { + return const ServerSyncOutcome(ServerSyncStatus.info, '正在同步,请稍候'); + } + final access = await _resolveAccess(); + if (access.outcome != null) return access.outcome!; + final creds = access.creds!; + + state = const ServerSyncState(busy: true); + try { + final uc = await ref.read(uploadServerSyncUseCaseProvider.future); + final r = await uc.execute( + baseUrl: creds.baseUrl, + accessToken: creds.accessToken, + ); + final outcome = ServerSyncOutcome( + ServerSyncStatus.success, + '已上传 ${r.serverCount} 个服务器、${r.sessionCount} 个登录态、' + '${r.danmakuSourceCount} 个弹幕源、' + '${r.scriptWidgetCount} 个模块、${r.scriptWidgetSubscriptionCount} 个订阅到云端', + ); + state = ServerSyncState(lastSyncAt: DateTime.now()); + return outcome; + } on DomainError catch (e) { + state = ServerSyncState(lastSyncError: '上传失败:${e.message}'); + return ServerSyncOutcome(ServerSyncStatus.failure, '上传失败:${e.message}'); + } catch (e) { + AppLogger.warn(_tag, 'upload failed', e); + state = const ServerSyncState(lastSyncError: '上传失败,请稍后重试'); + return const ServerSyncOutcome(ServerSyncStatus.failure, '上传失败,请稍后重试'); + } + } + + + Future pull() async { + if (state.busy) { + return const ServerSyncOutcome(ServerSyncStatus.info, '正在同步,请稍候'); + } + final access = await _resolveAccess(); + if (access.outcome != null) return access.outcome!; + final creds = access.creds!; + + state = const ServerSyncState(busy: true); + try { + final uc = await ref.read(pullServerSyncUseCaseProvider.future); + final r = await uc.execute( + baseUrl: creds.baseUrl, + accessToken: creds.accessToken, + ); + if (r.empty) { + return const ServerSyncOutcome(ServerSyncStatus.info, '云端还没有备份'); + } + + await ref.read(serverListProvider.notifier).refresh(); + await ref.read(sessionProvider.notifier).refresh(); + ref.invalidate(danmakuSettingsProvider); + ref.invalidate(installedScriptWidgetsProvider); + ref.invalidate(scriptWidgetSubscriptionsProvider); + + unawaited(_repairInBackground()); + + final outcome = ServerSyncOutcome( + ServerSyncStatus.success, + '已从云端恢复 ${r.serverCount} 个服务器、${r.sessionCount} 个登录态、' + '${r.danmakuSourceCount} 个弹幕源、' + '${r.scriptWidgetCount} 个模块、${r.scriptWidgetSubscriptionCount} 个订阅', + ); + state = ServerSyncState(lastSyncAt: DateTime.now()); + return outcome; + } on DomainError catch (e) { + state = ServerSyncState(lastSyncError: '拉取失败:${e.message}'); + return ServerSyncOutcome(ServerSyncStatus.failure, '拉取失败:${e.message}'); + } catch (e) { + AppLogger.warn(_tag, 'pull failed', e); + state = const ServerSyncState(lastSyncError: '拉取失败,请稍后重试'); + return const ServerSyncOutcome(ServerSyncStatus.failure, '拉取失败,请稍后重试'); + } + } + + Future _repairInBackground() async { + try { + final uc = await ref.read(repairSessionsUseCaseProvider.future); + final repaired = await uc.execute(); + if (repaired > 0) { + await ref.read(sessionProvider.notifier).refresh(); + AppLogger.info(_tag, 'repaired $repaired session(s) after pull'); + } + } catch (e) { + AppLogger.warn(_tag, 'background repair failed', e); + } + } + + + Future<({_SyncCredentials? creds, ServerSyncOutcome? outcome})> + _resolveAccess() async { + const needsLogin = ( + creds: null, + outcome: ServerSyncOutcome(ServerSyncStatus.needsLogin, '请先登录 sm 账号后再同步'), + ); + + var account = await ref.read(smAccountProvider.future); + if (!account.canUseDataPlane) return needsLogin; + + var token = await ref.read(smAccountProvider.notifier).getAccessToken(); + if (token == null || token.isEmpty) return needsLogin; + account = await ref.read(smAccountProvider.future); + + if (!account.isVip) { + token = await ref + .read(smAccountProvider.notifier) + .getAccessToken(force: true); + if (token == null || token.isEmpty) return needsLogin; + account = await ref.read(smAccountProvider.future); + } + + if (!account.isVip) { + return ( + creds: null, + outcome: const ServerSyncOutcome( + ServerSyncStatus.needsVip, + '服务器云同步为 VIP 功能,请开通 VIP 后使用', + ), + ); + } + + return ( + creds: _SyncCredentials(baseUrl: account.baseUrl, accessToken: token), + outcome: null, + ); + } +} + +class _SyncCredentials { + final String baseUrl; + final String accessToken; + const _SyncCredentials({required this.baseUrl, required this.accessToken}); +} + +final serverSyncProvider = + NotifierProvider( + ServerSyncNotifier.new, + ); diff --git a/lib/providers/session_provider.dart b/lib/providers/session_provider.dart new file mode 100644 index 0000000..29c3c58 --- /dev/null +++ b/lib/providers/session_provider.dart @@ -0,0 +1,64 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../core/contracts/auth.dart'; +import 'di_providers.dart'; +import 'server_provider.dart'; + +class SessionNotifier extends AsyncNotifier { + @override + Future build() async { + final usecase = await ref.watch(listSessionsUseCaseProvider.future); + return usecase.execute(); + } + + Future refresh() async { + state = await AsyncValue.guard(() async { + final usecase = await ref.read(listSessionsUseCaseProvider.future); + return usecase.execute(); + }); + } + + Future login({ + required String serverId, + required String username, + required String password, + }) async { + final usecase = await ref.read(authByNameUseCaseProvider.future); + await usecase.execute( + AuthByNameReq(serverId: serverId, username: username, password: password), + ); + await refresh(); + ref.invalidate(serverListProvider); + } + + Future logout({String? serverId}) async { + final usecase = await ref.read(logoutUseCaseProvider.future); + await usecase.execute(serverId); + await refresh(); + } + + Future switchSession(String serverId) async { + final usecase = await ref.read(switchSessionUseCaseProvider.future); + await usecase.execute(serverId); + await refresh(); + } +} + +final sessionProvider = AsyncNotifierProvider( + SessionNotifier.new, +); + + +final activeSessionProvider = Provider((ref) { + final state = ref.watch(sessionProvider); + return state.maybeWhen( + data: (data) { + if (data.activeServerId == null) return null; + for (final s in data.sessions) { + if (s.isActive) return s; + } + return null; + }, + orElse: () => null, + ); +}); diff --git a/lib/providers/shared/settings_persistence.dart b/lib/providers/shared/settings_persistence.dart new file mode 100644 index 0000000..8414f3d --- /dev/null +++ b/lib/providers/shared/settings_persistence.dart @@ -0,0 +1,87 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../../shared/utils/lru_eviction.dart'; + +mixin SettingsPersistence { + SharedPreferences? _prefs; + + Future getPrefs() async { + return _prefs ??= await SharedPreferences.getInstance(); + } + + Future persistField(String key, dynamic value) async { + try { + final p = await getPrefs(); + if (value == null) return p.remove(key); + if (value is bool) return p.setBool(key, value); + if (value is int) return p.setInt(key, value); + if (value is double) return p.setDouble(key, value); + if (value is String) return p.setString(key, value); + if (value is List) return p.setStringList(key, value); + return false; + } catch (e) { + debugPrint('[SettingsPersistence] Failed to persist $key: $e'); + return false; + } + } + + Future persistFields(Map fields) async { + try { + final p = await getPrefs(); + final futures = >[]; + for (final entry in fields.entries) { + final key = entry.key; + final value = entry.value; + if (value == null) { + futures.add(p.remove(key)); + } else if (value is bool) { + futures.add(p.setBool(key, value)); + } else if (value is int) { + futures.add(p.setInt(key, value)); + } else if (value is double) { + futures.add(p.setDouble(key, value)); + } else if (value is String) { + futures.add(p.setString(key, value)); + } else if (value is List) { + futures.add(p.setStringList(key, value)); + } + } + await Future.wait(futures); + return true; + } catch (e) { + debugPrint('[SettingsPersistence] Failed to persist fields: $e'); + return false; + } + } + + + Future putRemembered( + String prefsKey, + String entryKey, + Map entryJson, + int maxEntries, + ) async { + final p = await getPrefs(); + final raw = p.getString(prefsKey); + final map = raw != null + ? (jsonDecode(raw) as Map) + : {}; + map[entryKey] = entryJson; + evictOldestEntries(map, maxEntries); + await p.setString(prefsKey, jsonEncode(map)); + } + + + Future?> readRemembered( + String prefsKey, + String entryKey, + ) async { + final raw = (await getPrefs()).getString(prefsKey); + if (raw == null) return null; + final entry = (jsonDecode(raw) as Map)[entryKey]; + return entry is Map ? entry : null; + } +} diff --git a/lib/providers/shell_backdrop_provider.dart b/lib/providers/shell_backdrop_provider.dart new file mode 100644 index 0000000..d6e0d26 --- /dev/null +++ b/lib/providers/shell_backdrop_provider.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +@immutable +class ShellBackdropState { + final String primaryImageUrl; + final List fallbackImageUrls; + final Color accentColor; + + const ShellBackdropState({ + required this.primaryImageUrl, + this.fallbackImageUrls = const [], + required this.accentColor, + }); +} + +class ShellBackdropNotifier extends Notifier { + @override + ShellBackdropState? build() => null; + + void show(ShellBackdropState backdrop) { + state = backdrop; + } + + void clear() { + state = null; + } +} + +final shellBackdropProvider = + NotifierProvider( + ShellBackdropNotifier.new, + ); + + +class ShellLocationNotifier extends Notifier { + @override + String build() => '/'; + + void set(String loc) { + if (state != loc) state = loc; + } +} + +final shellLocationProvider = NotifierProvider( + ShellLocationNotifier.new, +); diff --git a/lib/providers/sm_account_provider.dart b/lib/providers/sm_account_provider.dart new file mode 100644 index 0000000..cd5d81e --- /dev/null +++ b/lib/providers/sm_account_provider.dart @@ -0,0 +1,432 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../core/domain/ports/sm_account_gateway.dart'; +import '../core/infra/sm_account_api/sm_account_client.dart'; +import 'shared/settings_persistence.dart'; +import 'tmdb_settings_provider.dart'; + + +final smAccountGatewayProvider = Provider( + (ref) => SmAccountClient(), +); + + +enum SmAuthStatus { + + unauthenticated, + + + restoring, + + + authenticated, + + + expired, + + + degraded, +} + + +class SmAccountState { + + final SmAuthStatus status; + + + final String email; + + + final String baseUrl; + + + final String dataRefreshToken; + + + final bool isVip; + + + final int? vipExpiresAt; + + + final bool vipPermanent; + + const SmAccountState({ + this.status = SmAuthStatus.unauthenticated, + this.email = '', + this.baseUrl = defaultBaseUrl, + this.dataRefreshToken = '', + this.isVip = false, + this.vipExpiresAt, + this.vipPermanent = false, + }); + + + static const defaultBaseUrl = 'https://your-server.example'; + + + String get tmdbApiBaseUrl => '${baseUrl.replaceAll(RegExp(r'/+$'), '')}/3'; + + + bool get loggedIn => status == SmAuthStatus.authenticated; + + + bool get canUseDataPlane => status == SmAuthStatus.authenticated; + + + bool get hasActiveSession => + status == SmAuthStatus.authenticated || status == SmAuthStatus.restoring; + + + bool get needsReauth => + status == SmAuthStatus.expired || status == SmAuthStatus.degraded; + + + bool get hasVip => status == SmAuthStatus.authenticated && isVip; + + SmAccountState copyWith({ + SmAuthStatus? status, + String? email, + String? baseUrl, + String? dataRefreshToken, + bool? isVip, + int? vipExpiresAt, + bool? vipPermanent, + bool clearVipExpiresAt = false, + }) => SmAccountState( + status: status ?? this.status, + email: email ?? this.email, + baseUrl: baseUrl ?? this.baseUrl, + dataRefreshToken: dataRefreshToken ?? this.dataRefreshToken, + isVip: isVip ?? this.isVip, + vipExpiresAt: clearVipExpiresAt + ? null + : (vipExpiresAt ?? this.vipExpiresAt), + vipPermanent: vipPermanent ?? this.vipPermanent, + ); +} + +class SmAccountNotifier extends AsyncNotifier + with SettingsPersistence { + static const _keyBaseUrl = 'smplayer.sm_account_base_url'; + static const _keyEmail = 'smplayer.sm_account_email'; + + + static const _keyCookie = 'smplayer.sm_account_cookie'; + static const _keyRefreshToken = 'smplayer.sm_account_refresh_token'; + static const _keyVip = 'smplayer.sm_account_vip'; + static const _keyVipExpiresAt = 'smplayer.sm_account_vip_expires_at'; + static const _keyVipPermanent = 'smplayer.sm_account_vip_permanent'; + + + String? _accessToken; + DateTime? _accessTokenExpiry; + Future? _refreshInFlight; + + @override + Future build() async { + final prefs = await getPrefs(); + final refreshToken = prefs.getString(_keyRefreshToken)?.trim() ?? ''; + final legacyCookie = prefs.getString(_keyCookie)?.trim() ?? ''; + final rawBaseUrl = prefs.getString(_keyBaseUrl)?.trim(); + final baseUrl = (rawBaseUrl == null || rawBaseUrl.isEmpty) + ? SmAccountState.defaultBaseUrl + : rawBaseUrl; + final email = prefs.getString(_keyEmail)?.trim() ?? ''; + final isVip = prefs.getBool(_keyVip) ?? false; + final vipExpiresAt = prefs.getInt(_keyVipExpiresAt); + final vipPermanent = prefs.getBool(_keyVipPermanent) ?? false; + + if (legacyCookie.isNotEmpty) { + await persistField(_keyCookie, null); + } + + if (refreshToken.isNotEmpty) { + Future.microtask(_restoreSession); + return SmAccountState( + status: SmAuthStatus.restoring, + email: email, + baseUrl: baseUrl, + dataRefreshToken: refreshToken, + isVip: isVip, + vipExpiresAt: vipExpiresAt, + vipPermanent: vipPermanent, + ); + } + + if (legacyCookie.isNotEmpty) { + return SmAccountState( + status: SmAuthStatus.degraded, + email: email, + baseUrl: baseUrl, + ); + } + + return SmAccountState( + status: SmAuthStatus.unauthenticated, + baseUrl: baseUrl, + ); + } + + + Future _restoreSession() async { + final current = state.value; + if (current == null || + current.status != SmAuthStatus.restoring || + current.dataRefreshToken.isEmpty) { + return; + } + final token = await getAccessToken(force: true); + if (token != null) { + return; + } + final after = state.value; + if (after != null && + after.status == SmAuthStatus.restoring && + after.dataRefreshToken.isNotEmpty) { + _setStatus(SmAuthStatus.authenticated); + } + } + + void _setStatus(SmAuthStatus status) { + final current = state.value; + if (current == null || current.status == status) return; + state = AsyncValue.data(current.copyWith(status: status)); + } + + + Future getAccessToken({bool force = false}) async { + final current = state.value; + if (current == null || current.dataRefreshToken.isEmpty) return null; + + if (!force && + _accessToken != null && + _accessTokenExpiry != null && + _accessTokenExpiry!.isAfter( + DateTime.now().add(const Duration(seconds: 60)), + )) { + return _accessToken; + } + + final inFlight = _refreshInFlight; + if (inFlight != null) return inFlight; + + final refresh = _refreshAccessToken(current); + _refreshInFlight = refresh; + try { + return await refresh; + } finally { + if (identical(_refreshInFlight, refresh)) { + _refreshInFlight = null; + } + } + } + + Future _refreshAccessToken(SmAccountState current) async { + final result = await ref + .read(smAccountGatewayProvider) + .refreshAccessToken( + baseUrl: current.baseUrl, + refreshToken: current.dataRefreshToken, + ); + + if (!result.ok) { + _accessToken = null; + _accessTokenExpiry = null; + if (result.invalidRefreshToken) { + await _markExpired(); + } + return null; + } + + _accessToken = result.accessToken; + _accessTokenExpiry = DateTime.now().add( + Duration(seconds: result.expiresIn > 0 ? result.expiresIn : 900), + ); + + final latest = state.value ?? current; + final rotatedRefreshToken = result.refreshToken.trim(); + final nextRefreshToken = + (rotatedRefreshToken.isNotEmpty && + rotatedRefreshToken != latest.dataRefreshToken) + ? rotatedRefreshToken + : latest.dataRefreshToken; + if (nextRefreshToken != latest.dataRefreshToken) { + await persistField(_keyRefreshToken, nextRefreshToken); + } + if (result.hasVipInfo) { + await persistFields({ + _keyVip: result.isVip, + _keyVipExpiresAt: result.vipExpiresAt, + _keyVipPermanent: result.vipPermanent, + }); + } + state = AsyncValue.data( + latest.copyWith( + status: SmAuthStatus.authenticated, + dataRefreshToken: nextRefreshToken, + isVip: result.hasVipInfo ? result.isVip : null, + vipExpiresAt: result.hasVipInfo ? result.vipExpiresAt : null, + clearVipExpiresAt: result.hasVipInfo && result.vipExpiresAt == null, + vipPermanent: result.hasVipInfo ? result.vipPermanent : null, + ), + ); + return _accessToken; + } + + + Future login(String email, String password) async { + final current = state.value ?? const SmAccountState(); + final baseUrl = current.baseUrl.trim().isEmpty + ? SmAccountState.defaultBaseUrl + : current.baseUrl.trim(); + + final result = await ref + .read(smAccountGatewayProvider) + .login(baseUrl: baseUrl, email: email.trim(), password: password); + if (!result.ok) return result; + + await _applySession(baseUrl, result); + return result; + } + + + Future registerStart(String email) async { + final baseUrl = _effectiveBaseUrl(); + final trimmedEmail = email.trim(); + final result = await ref + .read(smAccountGatewayProvider) + .registerStart(baseUrl: baseUrl, email: trimmedEmail); + if (result.ok) { + await persistField(_keyBaseUrl, baseUrl); + } + return result; + } + + + Future registerVerify( + String email, + String password, + String code, + ) async { + final baseUrl = _effectiveBaseUrl(); + final result = await ref + .read(smAccountGatewayProvider) + .registerVerify( + baseUrl: baseUrl, + email: email.trim(), + password: password, + code: code.trim(), + ); + if (!result.ok) return result; + + await _applySession(baseUrl, result); + return result; + } + + + Future resendRegisterCode(String email) async { + return ref + .read(smAccountGatewayProvider) + .resendCode(baseUrl: _effectiveBaseUrl(), email: email.trim()); + } + + + Future logout() async { + final current = state.value ?? const SmAccountState(); + _accessToken = null; + _accessTokenExpiry = null; + _refreshInFlight = null; + if (current.dataRefreshToken.isNotEmpty) { + await ref + .read(smAccountGatewayProvider) + .logout( + baseUrl: current.baseUrl, + refreshToken: current.dataRefreshToken, + ); + } + await persistFields({ + _keyEmail: null, + _keyCookie: null, + _keyRefreshToken: null, + _keyVip: null, + _keyVipExpiresAt: null, + _keyVipPermanent: null, + }); + state = AsyncValue.data( + SmAccountState( + status: SmAuthStatus.unauthenticated, + baseUrl: current.baseUrl, + ), + ); + } + + + Future _applySession(String baseUrl, SmLoginResult result) async { + _accessToken = result.accessToken.isNotEmpty ? result.accessToken : null; + _accessTokenExpiry = result.expiresIn > 0 + ? DateTime.now().add(Duration(seconds: result.expiresIn)) + : null; + _refreshInFlight = null; + + await persistFields({ + _keyBaseUrl: baseUrl, + _keyEmail: result.email, + _keyRefreshToken: result.refreshToken, + _keyVip: result.isVip, + _keyVipExpiresAt: result.vipExpiresAt, + _keyVipPermanent: result.vipPermanent, + _keyCookie: null, + }); + final current = state.value ?? const SmAccountState(); + state = AsyncValue.data( + current.copyWith( + status: SmAuthStatus.authenticated, + email: result.email, + baseUrl: baseUrl, + dataRefreshToken: result.refreshToken, + isVip: result.isVip, + vipExpiresAt: result.vipExpiresAt, + clearVipExpiresAt: result.vipExpiresAt == null, + vipPermanent: result.vipPermanent, + ), + ); + if (result.isVip) { + await ref.read(tmdbSettingsProvider.notifier).enableSmAccountProxy(); + } + } + + + Future _markExpired() async { + _accessToken = null; + _accessTokenExpiry = null; + _refreshInFlight = null; + await persistFields({ + _keyRefreshToken: null, + _keyVip: null, + _keyVipExpiresAt: null, + _keyVipPermanent: null, + }); + final current = state.value ?? const SmAccountState(); + state = AsyncValue.data( + current.copyWith( + status: SmAuthStatus.expired, + dataRefreshToken: '', + isVip: false, + clearVipExpiresAt: true, + vipPermanent: false, + ), + ); + } + + String _effectiveBaseUrl() { + final current = state.value ?? const SmAccountState(); + final v = current.baseUrl.trim(); + return v.isEmpty ? SmAccountState.defaultBaseUrl : v; + } +} + +final smAccountProvider = + AsyncNotifierProvider( + SmAccountNotifier.new, + ); diff --git a/lib/providers/subtitle_settings_provider.dart b/lib/providers/subtitle_settings_provider.dart new file mode 100644 index 0000000..ebb4bd4 --- /dev/null +++ b/lib/providers/subtitle_settings_provider.dart @@ -0,0 +1,187 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'shared/settings_persistence.dart'; + + +const kLanguageNames = { + 'chi': '中文', + 'zho': '中文', + 'eng': '英语', + 'jpn': '日语', + 'kor': '韩语', + 'fre': '法语', + 'fra': '法语', + 'ger': '德语', + 'deu': '德语', + 'spa': '西班牙语', + 'ita': '意大利语', + 'por': '葡萄牙语', + 'rus': '俄语', + 'ara': '阿拉伯语', + 'hin': '印地语', + 'tha': '泰语', + 'vie': '越南语', + 'pol': '波兰语', + 'tur': '土耳其语', + 'dut': '荷兰语', + 'nld': '荷兰语', + 'dan': '丹麦语', + 'fin': '芬兰语', + 'swe': '瑞典语', + 'nor': '挪威语', + 'nob': '书面挪威语', + 'cat': '加泰罗尼亚语', + 'cze': '捷克语', + 'ces': '捷克语', + 'hun': '匈牙利语', + 'gre': '希腊语', + 'ell': '希腊语', + 'heb': '希伯来语', + 'ind': '印度尼西亚语', + 'may': '马来语', + 'msa': '马来语', + 'hrv': '克罗地亚语', + 'baq': '巴斯克语', + 'eus': '巴斯克语', + 'glg': '加利西亚语', + 'fil': '菲律宾语', + 'rum': '罗马尼亚语', + 'ron': '罗马尼亚语', + 'bul': '保加利亚语', + 'ukr': '乌克兰语', + 'srp': '塞尔维亚语', + 'slk': '斯洛伐克语', + 'slv': '斯洛文尼亚语', + 'lit': '立陶宛语', + 'lav': '拉脱维亚语', + 'est': '爱沙尼亚语', +}; + + +class RememberedSubtitleTrack { + final int streamIndex; + final String? language; + final String? title; + final DateTime updatedAt; + + const RememberedSubtitleTrack({ + required this.streamIndex, + this.language, + this.title, + required this.updatedAt, + }); + + bool get isNone => streamIndex == -1; + + Map toJson() => { + 'streamIndex': streamIndex, + if (language != null) 'language': language, + if (title != null) 'title': title, + 'updatedAt': updatedAt.toIso8601String(), + }; + + factory RememberedSubtitleTrack.fromJson(Map json) { + return RememberedSubtitleTrack( + streamIndex: (json['streamIndex'] as num?)?.toInt() ?? -1, + language: json['language'] as String?, + title: json['title'] as String?, + updatedAt: + DateTime.tryParse(json['updatedAt'] as String? ?? '') ?? + DateTime.now(), + ); + } +} + +class SubtitleStyleSettings { + final int position; + final double fontSize; + final double delay; + final double bgOpacity; + + const SubtitleStyleSettings({ + this.position = 100, + this.fontSize = 55, + this.delay = 0.0, + this.bgOpacity = 0.0, + }); + + SubtitleStyleSettings copyWith({ + int? position, + double? fontSize, + double? delay, + double? bgOpacity, + }) => SubtitleStyleSettings( + position: position ?? this.position, + fontSize: fontSize ?? this.fontSize, + delay: delay ?? this.delay, + bgOpacity: bgOpacity ?? this.bgOpacity, + ); +} + +class SubtitleSettingsNotifier extends AsyncNotifier + with SettingsPersistence { + static const _keyPosition = 'smplayer.subtitle_position'; + static const _keyFontSize = 'smplayer.subtitle_font_size'; + static const _keyDelay = 'smplayer.subtitle_delay'; + static const _keyBgOpacity = 'smplayer.subtitle_bg_opacity'; + + static const _keyLastTracks = 'smplayer.subtitle_last_tracks_primary'; + static const _maxRememberedTracks = 200; + + @override + Future build() async { + final prefs = await getPrefs(); + for (final stale in const [ + 'smplayer.subtitle_default_policy', + 'smplayer.subtitle_preferred_language', + 'smplayer.subtitle_secondary_position', + 'smplayer.subtitle_secondary_font_size', + 'smplayer.subtitle_secondary_delay', + 'smplayer.subtitle_secondary_bg_opacity', + 'smplayer.subtitle_secondary_follow_primary', + 'smplayer.subtitle_last_tracks_secondary', + ]) { + await prefs.remove(stale); + } + return SubtitleStyleSettings( + position: prefs.getInt(_keyPosition) ?? 100, + fontSize: prefs.getDouble(_keyFontSize) ?? 55, + delay: prefs.getDouble(_keyDelay) ?? 0.0, + bgOpacity: prefs.getDouble(_keyBgOpacity) ?? 0.0, + ); + } + + Future setSettings(SubtitleStyleSettings settings) async { + state = AsyncValue.data(settings); + await persistFields({ + _keyPosition: settings.position, + _keyFontSize: settings.fontSize, + _keyDelay: settings.delay, + _keyBgOpacity: settings.bgOpacity, + }); + } + + + Future saveLastSubtitleTrack( + String key, + RememberedSubtitleTrack? track, + ) async { + if (track == null) return; + await putRemembered( + _keyLastTracks, + key, + track.toJson(), + _maxRememberedTracks, + ); + } + + Future getLastSubtitleTrack(String key) async { + final entry = await readRemembered(_keyLastTracks, key); + return entry == null ? null : RememberedSubtitleTrack.fromJson(entry); + } +} + +final subtitleSettingsProvider = + AsyncNotifierProvider( + SubtitleSettingsNotifier.new, + ); diff --git a/lib/providers/tmdb_prefetch_provider.dart b/lib/providers/tmdb_prefetch_provider.dart new file mode 100644 index 0000000..abb5e06 --- /dev/null +++ b/lib/providers/tmdb_prefetch_provider.dart @@ -0,0 +1,244 @@ +import 'dart:async'; +import 'dart:collection'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../core/contracts/library.dart'; +import '../shared/utils/app_logger.dart'; +import '../shared/utils/tmdb_key_utils.dart'; +import 'di_providers.dart'; +import 'library_overview_provider.dart'; +import 'playback_active_provider.dart' + show playbackActiveProvider, detailPageActiveProvider; +import 'session_provider.dart'; +import 'tmdb_settings_provider.dart'; + +final tmdbPrefetchProvider = NotifierProvider( + TmdbPrefetchNotifier.new, +); + +class TmdbPrefetchNotifier extends Notifier { + static const _requestDelay = Duration(milliseconds: 200); + static const _retryDelay = Duration(seconds: 2); + static const _visibleItemCount = 6; + static const _maxRetries = 2; + static const _initialDelay = Duration(milliseconds: 1500); + + final Queue _queue = Queue(); + final Set _enqueuedKeys = {}; + final Map _failureCount = {}; + bool _disposeHookRegistered = false; + bool _disposed = false; + bool _isProcessing = false; + bool _initialDelayElapsed = false; + bool _initialDelayTimerStarted = false; + bool _lastPlaybackActive = false; + bool _lastDetailActive = false; + String? _lastSessionServerId; + List? _lastResumeRef; + Map>? _lastLatestRef; + + @override + void build() { + _registerDisposeHook(); + + final session = ref.watch(activeSessionProvider); + final serverId = session?.serverId; + if (_lastSessionServerId != serverId) { + _lastSessionServerId = serverId; + _resetQueue(); + } + + final playbackActive = ref.watch(playbackActiveProvider); + final detailActive = ref.watch(detailPageActiveProvider); + final resumedFromPlayback = _lastPlaybackActive && !playbackActive; + _lastPlaybackActive = playbackActive; + final resumedFromDetail = _lastDetailActive && !detailActive; + _lastDetailActive = detailActive; + + final settings = ref.watch(tmdbSettingsProvider).value; + if (!(settings?.canRequest ?? false)) return; + + final overview = ref.watch(libraryOverviewProvider).value; + if (overview == null) return; + + final resumeSame = identical(_lastResumeRef, overview.resumeItems); + final latestSame = identical(_lastLatestRef, overview.latestByLibrary); + if (resumeSame && latestSame) { + if (resumedFromPlayback || resumedFromDetail) { + AppLogger.debug('prefetch', 'tmdb.build.resume_after_pause'); + _scheduleProcessing(); + } else { + AppLogger.debug('prefetch', 'tmdb.build.skip'); + } + return; + } + _lastResumeRef = overview.resumeItems; + _lastLatestRef = overview.latestByLibrary; + + final visibleItems = [ + ...overview.resumeItems.take(_visibleItemCount), + ]; + for (final items in overview.latestByLibrary.values) { + visibleItems.addAll(items.take(_visibleItemCount)); + } + _enqueueItems(visibleItems, priority: true); + + AppLogger.debug('prefetch', 'tmdb.build.enqueued', { + 'queue': _queue.length, + 'tracked': _enqueuedKeys.length, + }); + _scheduleProcessing(); + } + + void prefetchItem(EmbyRawItem item, {bool priority = false}) { + final key = tmdbMediaKeyFrom(item); + if (key == null) return; + _enqueueKey(key, priority: priority); + _scheduleProcessing(); + } + + void _registerDisposeHook() { + if (_disposeHookRegistered) return; + _disposeHookRegistered = true; + ref.onDispose(() { + _disposed = true; + _resetQueue(); + }); + } + + void _resetQueue() { + _queue.clear(); + _enqueuedKeys.clear(); + _failureCount.clear(); + _lastResumeRef = null; + _lastLatestRef = null; + } + + void _enqueueItems(Iterable items, {bool priority = false}) { + final keys = []; + for (final item in items) { + final key = tmdbMediaKeyFrom(item); + if (key != null) keys.add(key); + } + + if (priority) { + for (final key in keys.reversed) { + _enqueueKey(key, priority: true); + } + } else { + for (final key in keys) { + _enqueueKey(key); + } + } + } + + void _enqueueKey(TmdbMediaKey key, {bool priority = false}) { + if (_enqueuedKeys.contains(key)) { + if (priority && _queue.remove(key)) { + _queue.addFirst(key); + } + return; + } + + _enqueuedKeys.add(key); + if (priority) { + _queue.addFirst(key); + } else { + _queue.add(key); + } + } + + bool get _shouldPause => + ref.read(playbackActiveProvider) || ref.read(detailPageActiveProvider); + + void _scheduleProcessing() { + if (_disposed || _isProcessing || _queue.isEmpty) return; + if (_shouldPause) return; + if (!_initialDelayElapsed) { + _startInitialDelay(); + return; + } + unawaited(_processQueue()); + } + + void _startInitialDelay() { + if (_initialDelayTimerStarted) return; + _initialDelayTimerStarted = true; + Future.delayed(_initialDelay, () { + _initialDelayElapsed = true; + if (_disposed) return; + _scheduleProcessing(); + }); + } + + Future _processQueue() async { + if (_isProcessing) return; + _isProcessing = true; + try { + while (!_disposed && _queue.isNotEmpty) { + if (!_canRequestTmdb()) break; + if (_shouldPause) { + AppLogger.debug('prefetch', 'tmdb.queue.suspended', { + 'queue': _queue.length, + }); + break; + } + + final key = _queue.removeFirst(); + _enqueuedKeys.remove(key); + + bool requeued = false; + try { + final detail = await ref.read(tmdbEnrichedDetailProvider(key).future); + if (_disposed) break; + if (detail == null) { + ref.invalidate(tmdbEnrichedDetailProvider(key)); + _failureCount.remove(key); + AppLogger.debug('prefetch', 'tmdb.fetch.empty', {'key': key}); + } else { + _failureCount.remove(key); + AppLogger.debug('prefetch', 'tmdb.fetch.ok', {'key': key}); + } + } catch (e) { + if (_disposed) break; + ref.invalidate(tmdbEnrichedDetailProvider(key)); + final attempts = (_failureCount[key] ?? 0) + 1; + if (attempts <= _maxRetries) { + _failureCount[key] = attempts; + _enqueuedKeys.add(key); + _queue.addLast(key); + requeued = true; + AppLogger.debug('prefetch', 'tmdb.fetch.retry', { + 'key': key, + 'attempts': attempts, + 'error': e.toString(), + }); + } else { + _failureCount.remove(key); + AppLogger.debug('prefetch', 'tmdb.fetch.giveup', { + 'key': key, + 'error': e.toString(), + }); + } + } + + if (!_disposed && _queue.isNotEmpty) { + await Future.delayed(requeued ? _retryDelay : _requestDelay); + } + } + } finally { + _isProcessing = false; + if (!_disposed && + _queue.isNotEmpty && + _canRequestTmdb() && + !_shouldPause) { + _scheduleProcessing(); + } + } + } + + bool _canRequestTmdb() { + return ref.read(tmdbSettingsProvider).value?.canRequest ?? false; + } +} diff --git a/lib/providers/tmdb_settings_provider.dart b/lib/providers/tmdb_settings_provider.dart new file mode 100644 index 0000000..4e9de16 --- /dev/null +++ b/lib/providers/tmdb_settings_provider.dart @@ -0,0 +1,188 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'shared/settings_persistence.dart'; + +enum TmdbAuthMode { bearer, apiKey } + + +enum TmdbAccessMode { + + official, + + + smAccount, +} + +class TmdbSettingsState { + + static const officialApiBaseUrl = 'https://api.themoviedb.org/3'; + static const officialImageBaseUrl = 'https://image.tmdb.org/t/p'; + + + static const smAccountApiBaseUrl = 'https://your-server.example'; + + final bool enabled; + final TmdbAccessMode accessMode; + final TmdbAuthMode authMode; + final String accessToken; + final String apiKey; + final String language; + + const TmdbSettingsState({ + this.enabled = false, + this.accessMode = TmdbAccessMode.official, + this.authMode = TmdbAuthMode.apiKey, + this.accessToken = '', + this.apiKey = '', + this.language = 'zh-CN', + }); + + bool get isConfigured => switch (accessMode) { + TmdbAccessMode.smAccount => true, + TmdbAccessMode.official => switch (authMode) { + TmdbAuthMode.bearer => accessToken.isNotEmpty, + TmdbAuthMode.apiKey => apiKey.isNotEmpty, + }, + }; + + bool get canRequest => enabled && isConfigured; + + + bool canRequestWithAccount({required bool dataPlaneReady}) => + switch (accessMode) { + TmdbAccessMode.official => canRequest, + TmdbAccessMode.smAccount => enabled && dataPlaneReady, + }; + + String get effectiveApiBaseUrl => switch (accessMode) { + TmdbAccessMode.smAccount => smAccountApiBaseUrl, + TmdbAccessMode.official => officialApiBaseUrl, + }; + + + String get effectiveImageBaseUrl => officialImageBaseUrl; + + String get effectiveAccessToken => + accessMode == TmdbAccessMode.official && authMode == TmdbAuthMode.bearer + ? accessToken + : ''; + + String get effectiveApiKey => + accessMode == TmdbAccessMode.official && authMode == TmdbAuthMode.apiKey + ? apiKey + : ''; + + TmdbSettingsState copyWith({ + bool? enabled, + TmdbAccessMode? accessMode, + TmdbAuthMode? authMode, + String? accessToken, + String? apiKey, + String? language, + }) => TmdbSettingsState( + enabled: enabled ?? this.enabled, + accessMode: accessMode ?? this.accessMode, + authMode: authMode ?? this.authMode, + accessToken: accessToken ?? this.accessToken, + apiKey: apiKey ?? this.apiKey, + language: language ?? this.language, + ); +} + +class TmdbSettingsNotifier extends AsyncNotifier + with SettingsPersistence { + static const _keyEnabled = 'smplayer.tmdb_enabled'; + static const _keyAccessMode = 'smplayer.tmdb_access_mode'; + static const _keyAuthMode = 'smplayer.tmdb_auth_mode'; + static const _keyAccessToken = 'smplayer.tmdb_access_token'; + static const _keyApiKey = 'smplayer.tmdb_api_key'; + static const _keyLanguage = 'smplayer.tmdb_language'; + + @override + Future build() async { + final prefs = await getPrefs(); + final accessToken = prefs.getString(_keyAccessToken)?.trim() ?? ''; + final apiKey = prefs.getString(_keyApiKey)?.trim() ?? ''; + return TmdbSettingsState( + enabled: prefs.getBool(_keyEnabled) ?? false, + accessMode: _parseAccessMode(prefs.getString(_keyAccessMode)), + authMode: _parseAuthMode( + prefs.getString(_keyAuthMode), + hasLegacyAccessToken: accessToken.isNotEmpty, + ), + accessToken: accessToken, + apiKey: apiKey, + language: prefs.getString(_keyLanguage) ?? 'zh-CN', + ); + } + + Future setEnabled(bool v) async { + final current = state.value ?? const TmdbSettingsState(); + state = AsyncValue.data(current.copyWith(enabled: v)); + await persistField(_keyEnabled, v); + } + + Future setAccessMode(TmdbAccessMode mode) async { + final current = state.value ?? const TmdbSettingsState(); + state = AsyncValue.data(current.copyWith(accessMode: mode)); + await persistField(_keyAccessMode, mode.name); + } + + Future enableSmAccountProxy() async { + final current = state.value ?? await future; + state = AsyncValue.data( + current.copyWith(enabled: true, accessMode: TmdbAccessMode.smAccount), + ); + await persistFields({ + _keyEnabled: true, + _keyAccessMode: TmdbAccessMode.smAccount.name, + }); + } + + Future setAuthMode(TmdbAuthMode mode) async { + final current = state.value ?? const TmdbSettingsState(); + state = AsyncValue.data(current.copyWith(authMode: mode)); + await persistField(_keyAuthMode, mode.name); + } + + Future setAccessToken(String token) async { + final trimmed = token.trim(); + final current = state.value ?? const TmdbSettingsState(); + state = AsyncValue.data(current.copyWith(accessToken: trimmed)); + await persistField(_keyAccessToken, trimmed); + } + + Future setApiKey(String key) async { + final trimmed = key.trim(); + final current = state.value ?? const TmdbSettingsState(); + state = AsyncValue.data(current.copyWith(apiKey: trimmed)); + await persistField(_keyApiKey, trimmed); + } + + Future setLanguage(String lang) async { + final current = state.value ?? const TmdbSettingsState(); + state = AsyncValue.data(current.copyWith(language: lang)); + await persistField(_keyLanguage, lang); + } +} + +final tmdbSettingsProvider = + AsyncNotifierProvider( + TmdbSettingsNotifier.new, + ); + +TmdbAccessMode _parseAccessMode(String? value) => TmdbAccessMode.values + .firstWhere((m) => m.name == value, orElse: () => TmdbAccessMode.official); + +TmdbAuthMode _parseAuthMode( + String? value, { + bool hasLegacyAccessToken = false, +}) { + if (value == null && hasLegacyAccessToken) { + return TmdbAuthMode.bearer; + } + return TmdbAuthMode.values.firstWhere( + (mode) => mode.name == value, + orElse: () => TmdbAuthMode.apiKey, + ); +} diff --git a/lib/providers/trakt_calendar_provider.dart b/lib/providers/trakt_calendar_provider.dart new file mode 100644 index 0000000..cf6c468 --- /dev/null +++ b/lib/providers/trakt_calendar_provider.dart @@ -0,0 +1,216 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_riverpod/legacy.dart'; + +import '../core/contracts/trakt/trakt_calendar_entry.dart'; +import '../core/domain/ports/tmdb_gateway.dart'; +import '../shared/mappers/tmdb_image_url.dart'; +import '../shared/utils/app_logger.dart'; +import 'di_providers.dart'; +import 'sm_account_provider.dart'; +import 'tmdb_settings_provider.dart'; + +const _tag = 'TraktCalendar'; + +enum TraktCalendarFilter { all, shows, movies } + +final traktCalendarFilterProvider = StateProvider( + (ref) => TraktCalendarFilter.all, +); + + +final traktCalendarProvider = FutureProvider>(( + ref, +) async { + final authed = ref.read(authedTraktGatewayProvider); + if (!await authed.isConnected()) return const []; + + final filter = ref.watch(traktCalendarFilterProvider); + + final entries = []; + + if (filter != TraktCalendarFilter.movies) { + try { + final raw = await authed.fetchCalendarShows(); + entries.addAll( + raw + .map(TraktCalendarEntry.fromShowJson) + .whereType(), + ); + } catch (e) { + AppLogger.warn(_tag, 'fetchCalendarShows failed', e); + } + } + + if (filter != TraktCalendarFilter.shows) { + try { + final raw = await authed.fetchCalendarMovies(); + entries.addAll( + raw + .map(TraktCalendarEntry.fromMovieJson) + .whereType(), + ); + } catch (e) { + AppLogger.warn(_tag, 'fetchCalendarMovies failed', e); + } + } + + entries.sort((a, b) => a.firstAired.compareTo(b.firstAired)); + if (entries.isEmpty) return const []; + + final tmdbConfig = await _resolveTmdbConfig(ref); + if (tmdbConfig == null) return entries; + + final gateway = ref.read(tmdbGatewayProvider); + final cache = {}; + const enrichConcurrency = 5; + final enriched = []; + + for (var i = 0; i < entries.length; i += enrichConcurrency) { + final chunk = entries.skip(i).take(enrichConcurrency); + final results = await Future.wait( + chunk.map((entry) async { + final id = entry.tmdbId; + if (id == null || id <= 0) return entry; + + final cacheKey = '${entry.mediaType}:$id'; + try { + final cached = cache[cacheKey] ??= await _fetchPoster( + gateway, + tmdbConfig.config, + tmdbConfig.language, + id.toString(), + entry.mediaType, + ); + var result = entry; + if (cached.posterUrl != null) { + result = result.withPoster(cached.posterUrl); + } + if (cached.title != null && cached.title!.isNotEmpty) { + result = result.withTitle(cached.title!); + } + return result; + } catch (_) { + return entry; + } + }), + ); + enriched.addAll(results); + } + + return enriched; +}); + + +Future<_TmdbReadyConfig?> _resolveTmdbConfig(Ref ref) async { + final settings = await ref.watch(tmdbSettingsProvider.future); + if (!settings.canRequest) return null; + + if (settings.accessMode == TmdbAccessMode.smAccount) { + final account = await ref.watch(smAccountProvider.future); + if (!account.canUseDataPlane) return null; + final notifier = ref.read(smAccountProvider.notifier); + final token = await notifier.getAccessToken(); + if (token == null) return null; + return _TmdbReadyConfig( + config: TmdbRequestConfig( + apiBaseUrl: account.tmdbApiBaseUrl, + imageBaseUrl: TmdbSettingsState.officialImageBaseUrl, + accessToken: token, + apiKey: '', + onUnauthorized: () => notifier.getAccessToken(force: true), + ), + language: settings.language, + ); + } + + return _TmdbReadyConfig( + config: TmdbRequestConfig( + apiBaseUrl: settings.effectiveApiBaseUrl, + imageBaseUrl: settings.effectiveImageBaseUrl, + accessToken: settings.effectiveAccessToken, + apiKey: settings.effectiveApiKey, + ), + language: settings.language, + ); +} + +Future<_TmdbPosterResult> _fetchPoster( + TmdbGateway gateway, + TmdbRequestConfig config, + String language, + String tmdbId, + String mediaType, +) async { + final detail = await gateway.getMediaDetail( + config, + tmdbId, + mediaType, + language: language, + ); + final posterUrl = TmdbImageUrl.poster( + config.imageBaseUrl, + detail?.posterPath, + size: 'w342', + ); + return _TmdbPosterResult(posterUrl: posterUrl, title: detail?.title); +} + +class _TmdbReadyConfig { + final TmdbRequestConfig config; + final String language; + const _TmdbReadyConfig({required this.config, required this.language}); +} + +class _TmdbPosterResult { + final String? posterUrl; + final String? title; + const _TmdbPosterResult({this.posterUrl, this.title}); +} + + +List groupByDate(List entries) { + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final groups = >{}; + final groupOrder = {}; + + for (final e in entries) { + final local = e.firstAired.toLocal(); + final date = DateTime(local.year, local.month, local.day); + final label = _dateLabel(date, today); + (groups[label] ??= []).add(e); + groupOrder.putIfAbsent(label, () => date); + } + + final sorted = groups.entries.toList() + ..sort((a, b) => groupOrder[a.key]!.compareTo(groupOrder[b.key]!)); + + return [ + for (final e in sorted) CalendarDateGroup(label: e.key, entries: e.value), + ]; +} + +String _dateLabel(DateTime date, DateTime today) { + final diff = date.difference(today).inDays; + if (diff == 0) return '今天'; + if (diff == 1) return '明天'; + final weekday = _weekdayName(date.weekday); + return '$weekday ${date.month}/${date.day}'; +} + +String _weekdayName(int weekday) => switch (weekday) { + 1 => '周一', + 2 => '周二', + 3 => '周三', + 4 => '周四', + 5 => '周五', + 6 => '周六', + _ => '周日', +}; + +class CalendarDateGroup { + final String label; + final List entries; + + const CalendarDateGroup({required this.label, required this.entries}); +} diff --git a/lib/providers/trakt_scrobble_status_provider.dart b/lib/providers/trakt_scrobble_status_provider.dart new file mode 100644 index 0000000..2e18768 --- /dev/null +++ b/lib/providers/trakt_scrobble_status_provider.dart @@ -0,0 +1,82 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../shared/utils/app_logger.dart'; +import 'di_providers.dart'; + + +class TraktScrobbleStatus { + + final DateTime? lastSuccessAt; + + + final String? lastError; + + + final int pendingCount; + + const TraktScrobbleStatus({ + this.lastSuccessAt, + this.lastError, + this.pendingCount = 0, + }); + + TraktScrobbleStatus copyWith({ + DateTime? lastSuccessAt, + String? lastError, + bool clearError = false, + int? pendingCount, + }) { + return TraktScrobbleStatus( + lastSuccessAt: lastSuccessAt ?? this.lastSuccessAt, + lastError: clearError ? null : (lastError ?? this.lastError), + pendingCount: pendingCount ?? this.pendingCount, + ); + } +} + + +class TraktScrobbleStatusNotifier extends Notifier { + static const _tag = 'TraktStatus'; + + @override + TraktScrobbleStatus build() => const TraktScrobbleStatus(); + + void recordSuccess() { + state = state.copyWith(lastSuccessAt: DateTime.now(), clearError: true); + _refreshPending(); + } + + void recordError(String error) { + state = state.copyWith(lastError: error); + _refreshPending(); + } + + + Future refreshPending() => _refreshPending(); + + + Future retryPending() async { + try { + final queue = await ref.read(traktSyncQueueProvider.future); + await queue.drain(); + } catch (e) { + AppLogger.warn(_tag, 'retryPending failed', e); + } + await _refreshPending(); + } + + Future _refreshPending() async { + try { + final queue = await ref.read(traktSyncQueueProvider.future); + final count = await queue.pendingCount(); + state = state.copyWith(pendingCount: count); + } catch (e) { + AppLogger.debug(_tag, 'refreshPending failed', e); + } + } +} + +final traktScrobbleStatusProvider = + NotifierProvider( + TraktScrobbleStatusNotifier.new, + ); diff --git a/lib/providers/trakt_settings_provider.dart b/lib/providers/trakt_settings_provider.dart new file mode 100644 index 0000000..a323492 --- /dev/null +++ b/lib/providers/trakt_settings_provider.dart @@ -0,0 +1,198 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../core/contracts/trakt/trakt_token.dart'; +import '../core/infra/deep_link/deep_link_service.dart'; +import '../core/infra/trakt_api/trakt_credentials.dart'; +import '../core/infra/trakt_api/trakt_token_store.dart'; +import '../shared/utils/app_logger.dart'; +import 'di_providers.dart'; +import 'shared/settings_persistence.dart'; + +const _tag = 'Trakt'; + + +const _connectTimeout = Duration(minutes: 5); + + +class TraktState { + final bool isConnected; + final String username; + final String accessToken; + final String refreshToken; + final int expiresIn; + final int createdAt; + + const TraktState({ + this.isConnected = false, + this.username = '', + this.accessToken = '', + this.refreshToken = '', + this.expiresIn = 0, + this.createdAt = 0, + }); + + TraktState copyWith({ + bool? isConnected, + String? username, + String? accessToken, + String? refreshToken, + int? expiresIn, + int? createdAt, + }) => TraktState( + isConnected: isConnected ?? this.isConnected, + username: username ?? this.username, + accessToken: accessToken ?? this.accessToken, + refreshToken: refreshToken ?? this.refreshToken, + expiresIn: expiresIn ?? this.expiresIn, + createdAt: createdAt ?? this.createdAt, + ); +} + + +class TraktConnectException implements Exception { + final String message; + const TraktConnectException(this.message); + + @override + String toString() => 'TraktConnectException: $message'; +} + +class TraktSettingsNotifier extends AsyncNotifier + with SettingsPersistence { + static const _keyAccessToken = TraktTokenStore.keyAccessToken; + static const _keyRefreshToken = TraktTokenStore.keyRefreshToken; + static const _keyExpiresIn = TraktTokenStore.keyExpiresIn; + static const _keyCreatedAt = TraktTokenStore.keyCreatedAt; + static const _keyUsername = 'smplayer.trakt_username'; + + @override + Future build() async { + ref.read(traktAuthProvider).onCleared = () { + final s = state.value; + if (s != null && s.isConnected) { + state = const AsyncValue.data(TraktState()); + } + }; + + final prefs = await getPrefs(); + final accessToken = prefs.getString(_keyAccessToken)?.trim() ?? ''; + return TraktState( + isConnected: accessToken.isNotEmpty, + username: prefs.getString(_keyUsername) ?? '', + accessToken: accessToken, + refreshToken: prefs.getString(_keyRefreshToken)?.trim() ?? '', + expiresIn: prefs.getInt(_keyExpiresIn) ?? 0, + createdAt: prefs.getInt(_keyCreatedAt) ?? 0, + ); + } + + + Future connect() async { + final gateway = ref.read(traktGatewayProvider); + + final csrfState = _generateState(); + + final redirectFuture = DeepLinkService.instance.uriStream + .firstWhere((uri) => uri.queryParameters['state'] == csrfState) + .timeout(_connectTimeout); + + final authorizeUrl = TraktCredentials.authorizeUrl(csrfState); + final launched = await launchUrl( + authorizeUrl, + mode: LaunchMode.externalApplication, + ); + if (!launched) { + throw const TraktConnectException('无法打开浏览器'); + } + + final Uri redirect; + try { + redirect = await redirectFuture; + } on TimeoutException { + throw const TraktConnectException('连接超时,请重试'); + } + + final error = redirect.queryParameters['error']; + if (error != null && error.isNotEmpty) { + AppLogger.warn(_tag, 'authorization denied: $error'); + throw const TraktConnectException('已取消授权'); + } + + final returnedState = redirect.queryParameters['state']; + if (returnedState != csrfState) { + AppLogger.warn(_tag, 'state mismatch'); + throw const TraktConnectException('授权校验失败'); + } + + final code = redirect.queryParameters['code']; + if (code == null || code.isEmpty) { + throw const TraktConnectException('未收到授权码'); + } + + final TraktToken token; + try { + token = await gateway.exchangeCode(code); + } catch (e) { + AppLogger.warn(_tag, 'exchangeCode failed', e); + throw const TraktConnectException('令牌交换失败'); + } + + await _persistToken(token); + + var username = ''; + try { + final user = await gateway.getMe(token.accessToken); + username = user.username; + } catch (e) { + AppLogger.warn(_tag, 'getMe failed (non-fatal)', e); + } + await persistField(_keyUsername, username); + + state = AsyncValue.data( + TraktState( + isConnected: true, + username: username, + accessToken: token.accessToken, + refreshToken: token.refreshToken, + expiresIn: token.expiresIn, + createdAt: token.createdAt, + ), + ); + } + + + Future disconnect() async { + await ref.read(traktAuthProvider).clear(); + await persistField(_keyUsername, null); + state = const AsyncValue.data(TraktState()); + } + + + Future ensureValidToken() { + return ref.read(traktAuthProvider).validAccessToken(); + } + + Future _persistToken(TraktToken token) async { + await persistFields({ + _keyAccessToken: token.accessToken, + _keyRefreshToken: token.refreshToken, + _keyExpiresIn: token.expiresIn, + _keyCreatedAt: token.createdAt, + }); + } + + static String _generateState() { + final rnd = Random.secure(); + final bytes = List.generate(16, (_) => rnd.nextInt(256)); + return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + } +} + +final traktSettingsProvider = + AsyncNotifierProvider( + TraktSettingsNotifier.new, + ); diff --git a/lib/providers/update_provider.dart b/lib/providers/update_provider.dart new file mode 100644 index 0000000..3201d3c --- /dev/null +++ b/lib/providers/update_provider.dart @@ -0,0 +1,293 @@ +import 'dart:async'; + +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../core/contracts/app_update.dart'; +import '../features/update/update_service.dart'; +import '../shared/utils/app_logger.dart'; +import 'shared/settings_persistence.dart'; + +enum UpdateStatus { + idle, + checking, + upToDate, + available, + downloading, + readyToInstall, + error, +} + +class UpdateState { + final UpdateStatus status; + final AppUpdateInfo? info; + final int received; + final int total; + + + final double speedBytesPerSec; + final String? error; + final bool silent; + final String? installerPath; + + const UpdateState({ + this.status = UpdateStatus.idle, + this.info, + this.received = 0, + this.total = 0, + this.speedBytesPerSec = 0, + this.error, + this.silent = false, + this.installerPath, + }); + + UpdateState copyWith({ + UpdateStatus? status, + AppUpdateInfo? info, + int? received, + int? total, + double? speedBytesPerSec, + String? error, + bool? silent, + String? installerPath, + bool clearInfo = false, + bool clearError = false, + bool clearInstallerPath = false, + }) { + return UpdateState( + status: status ?? this.status, + info: clearInfo ? null : info ?? this.info, + received: received ?? this.received, + total: total ?? this.total, + speedBytesPerSec: speedBytesPerSec ?? this.speedBytesPerSec, + error: clearError ? null : error ?? this.error, + silent: silent ?? this.silent, + installerPath: clearInstallerPath + ? null + : installerPath ?? this.installerPath, + ); + } +} + +final updateProvider = NotifierProvider( + UpdateNotifier.new, +); + +class UpdateNotifier extends Notifier with SettingsPersistence { + static const _tag = 'Update'; + static const _lastCheckKey = 'smplayer.update.last_check'; + static const _silentThrottle = Duration(hours: 12); + + final UpdateService _service; + CancelToken? _cancelToken; + + DateTime? _lastSpeedSampleAt; + int _lastSpeedSampleBytes = 0; + + UpdateNotifier({UpdateService? service}) + : _service = service ?? UpdateService(); + + @override + UpdateState build() => const UpdateState(); + + Future silentCheck() async { + final prefs = await getPrefs(); + final lastCheck = prefs.getInt(_lastCheckKey); + final now = DateTime.now().millisecondsSinceEpoch; + if (lastCheck != null && now - lastCheck < _silentThrottle.inMilliseconds) { + AppLogger.debug(_tag, 'silent check throttled'); + return; + } + + try { + await prefs.setInt(_lastCheckKey, now); + state = state.copyWith( + status: UpdateStatus.checking, + silent: true, + received: 0, + total: 0, + clearError: true, + clearInstallerPath: true, + ); + final result = await _service.check(); + if (result.hasUpdate && result.info != null) { + state = state.copyWith( + status: UpdateStatus.available, + info: result.info, + silent: true, + ); + } else { + state = state.copyWith( + status: UpdateStatus.idle, + silent: true, + clearInfo: true, + ); + } + } catch (e) { + AppLogger.warn(_tag, 'silent check failed', e); + state = state.copyWith( + status: UpdateStatus.idle, + silent: true, + clearError: true, + ); + } + } + + Future manualCheck() async { + try { + state = state.copyWith( + status: UpdateStatus.checking, + silent: false, + received: 0, + total: 0, + clearError: true, + clearInstallerPath: true, + ); + final result = await _service.check(); + await persistField(_lastCheckKey, DateTime.now().millisecondsSinceEpoch); + if (result.hasUpdate && result.info != null) { + state = state.copyWith( + status: UpdateStatus.available, + info: result.info, + silent: false, + ); + } else { + state = state.copyWith( + status: UpdateStatus.upToDate, + silent: false, + clearInfo: true, + ); + } + } catch (e, stack) { + AppLogger.error(_tag, 'manual check failed', e, stack); + state = state.copyWith( + status: UpdateStatus.error, + error: '检查更新失败,请稍后重试', + silent: false, + ); + } + } + + Future startDownload() async { + final info = state.info; + final asset = info?.selectedAsset; + if (info == null || asset == null) return; + + _cancelToken?.cancel('restart download'); + final cancelToken = CancelToken(); + _cancelToken = cancelToken; + + _lastSpeedSampleAt = null; + _lastSpeedSampleBytes = 0; + + try { + state = state.copyWith( + status: UpdateStatus.downloading, + received: 0, + total: asset.size, + speedBytesPerSec: 0, + clearError: true, + clearInstallerPath: true, + ); + final file = await _service.download( + asset, + cancelToken: cancelToken, + onReceiveProgress: (received, total) { + if (cancelToken.isCancelled) return; + state = state.copyWith( + status: UpdateStatus.downloading, + received: received, + total: total > 0 ? total : asset.size, + speedBytesPerSec: _sampleSpeed(received), + ); + }, + ); + if (cancelToken.isCancelled) return; + state = state.copyWith( + status: UpdateStatus.readyToInstall, + received: state.total, + speedBytesPerSec: 0, + installerPath: file.path, + clearError: true, + ); + } on UpdateVerificationException catch (e, stack) { + AppLogger.error(_tag, 'update verification failed', e, stack); + state = state.copyWith( + status: UpdateStatus.error, + speedBytesPerSec: 0, + error: e.message, + ); + return; + } on DioException catch (e, stack) { + if (CancelToken.isCancel(e)) { + state = state.copyWith( + status: UpdateStatus.available, + received: 0, + total: asset.size, + speedBytesPerSec: 0, + clearError: true, + ); + return; + } + AppLogger.error(_tag, 'download failed', e, stack); + state = state.copyWith( + status: UpdateStatus.error, + speedBytesPerSec: 0, + error: '下载失败,请稍后重试', + ); + } catch (e, stack) { + AppLogger.error(_tag, 'download failed', e, stack); + state = state.copyWith( + status: UpdateStatus.error, + speedBytesPerSec: 0, + error: '下载失败,请稍后重试', + ); + } finally { + if (identical(_cancelToken, cancelToken)) { + _cancelToken = null; + } + } + } + + + double? _sampleSpeed(int received) { + final now = DateTime.now(); + final lastAt = _lastSpeedSampleAt; + if (lastAt == null) { + _lastSpeedSampleAt = now; + _lastSpeedSampleBytes = received; + return null; + } + final elapsedMs = now.difference(lastAt).inMilliseconds; + if (elapsedMs < 500) return null; + final delta = received - _lastSpeedSampleBytes; + _lastSpeedSampleAt = now; + _lastSpeedSampleBytes = received; + return delta > 0 ? delta * 1000.0 / elapsedMs : 0.0; + } + + void cancel() { + _cancelToken?.cancel('cancelled by user'); + _cancelToken = null; + final info = state.info; + state = state.copyWith( + status: info == null ? UpdateStatus.idle : UpdateStatus.available, + received: 0, + total: info?.selectedAsset?.size ?? 0, + speedBytesPerSec: 0, + clearError: true, + ); + } + + Future install() async { + final path = state.installerPath; + if (path == null || path.isEmpty) return; + + try { + await _service.launchInstaller(path); + } catch (e, stack) { + AppLogger.error(_tag, 'launch installer failed', e, stack); + state = state.copyWith(status: UpdateStatus.error, error: '安装程序启动失败'); + } + } +} diff --git a/lib/providers/version_priority_provider.dart b/lib/providers/version_priority_provider.dart new file mode 100644 index 0000000..2c8ed8c --- /dev/null +++ b/lib/providers/version_priority_provider.dart @@ -0,0 +1,117 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../core/contracts/library.dart'; +import '../core/media/media_range_detector.dart'; +import 'shared/settings_persistence.dart'; + +enum VersionPriority { + dolbyVision('杜比视界优先'), + hdr('HDR 优先'), + sdr('SDR 优先'), + highBitrate('高码率优先'), + lowBitrate('低码率优先'), + highFramerate('高帧率优先'), + largeFile('大文件优先'); + + final String label; + const VersionPriority(this.label); +} + +class VersionPriorityState { + final List activePriorities; + const VersionPriorityState({this.activePriorities = const []}); +} + +class VersionPriorityNotifier extends AsyncNotifier + with SettingsPersistence { + static const _key = 'smplayer.version_priorities'; + + @override + Future build() async { + final prefs = await getPrefs(); + final raw = prefs.getStringList(_key); + if (raw == null) return const VersionPriorityState(); + final priorities = raw + .map((name) { + try { + return VersionPriority.values.byName(name); + } catch (_) { + return null; + } + }) + .whereType() + .toList(); + return VersionPriorityState(activePriorities: priorities); + } + + Future _save(List list) async { + state = AsyncValue.data(VersionPriorityState(activePriorities: list)); + await persistField(_key, list.map((p) => p.name).toList()); + } + + Future toggle(VersionPriority p) async { + final current = List.from( + state.value?.activePriorities ?? [], + ); + current.contains(p) ? current.remove(p) : current.add(p); + await _save(current); + } + + Future setPriorities(List list) => _save(list); +} + +final versionPriorityProvider = + AsyncNotifierProvider( + VersionPriorityNotifier.new, + ); + +List sortMediaSources( + List sources, + List priorities, +) { + if (priorities.isEmpty || sources.length <= 1) return sources; + final sorted = List.from(sources); + sorted.sort((a, b) => compareMediaSourcesByPriority(a, b, priorities)); + return sorted; +} + + +int compareMediaSourcesByPriority( + EmbyRawMediaSource a, + EmbyRawMediaSource b, + List priorities, +) { + for (final p in priorities) { + final cmp = _compareByRule(a, b, p); + if (cmp != 0) return cmp; + } + return 0; +} + +int _compareByRule( + EmbyRawMediaSource a, + EmbyRawMediaSource b, + VersionPriority rule, +) { + switch (rule) { + case VersionPriority.dolbyVision: + return _boolDesc(isDolbyVision(a), isDolbyVision(b)); + case VersionPriority.hdr: + return _boolDesc(isHdr(a), isHdr(b)); + case VersionPriority.sdr: + return _boolDesc(!isHdr(a), !isHdr(b)); + case VersionPriority.highBitrate: + return (b.Bitrate ?? 0).compareTo(a.Bitrate ?? 0); + case VersionPriority.lowBitrate: + return (a.Bitrate ?? 0).compareTo(b.Bitrate ?? 0); + case VersionPriority.highFramerate: + return getFrameRate(b).compareTo(getFrameRate(a)); + case VersionPriority.largeFile: + return (b.Size ?? 0).compareTo(a.Size ?? 0); + } +} + +int _boolDesc(bool a, bool b) { + if (a == b) return 0; + return a ? -1 : 1; +} diff --git a/lib/router/app_router.dart b/lib/router/app_router.dart new file mode 100644 index 0000000..250b157 --- /dev/null +++ b/lib/router/app_router.dart @@ -0,0 +1,380 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../features/account/account_auth_page.dart'; +import '../features/account/account_center_page.dart'; +import '../features/aggregated/aggregated_view_page.dart'; +import '../features/calendar/trakt_calendar_page.dart'; +import '../features/detail/detail_entry.dart'; +import '../features/detail/emby_detail_view.dart'; +import '../features/detail/tmdb_detail_view.dart'; +import '../features/discover/discover_page.dart'; +import '../features/home/home_page.dart'; +import '../features/library/library_list_page.dart'; +import '../features/modules/module_all_page.dart'; +import '../features/modules/module_browse_page.dart'; +import '../features/modules/module_detail_page.dart'; +import '../features/person_detail/person_detail_page.dart'; +import '../features/player/player_launcher.dart'; +import '../features/player/player_page.dart'; +import '../features/profile/profile_page.dart'; +import '../features/search/global_search_page.dart'; +import '../features/servers/servers_page.dart'; +import '../features/settings/settings_full_page.dart'; +import '../shared/constants/breakpoints.dart'; +import '../core/contracts/library.dart'; +import '../core/contracts/script_widget.dart'; +import '../core/contracts/tmdb.dart'; +import '../providers/appearance_provider.dart'; +import '../shared/theme/app_theme.dart'; +import '../shared/widgets/app_error_state.dart'; +import '../shared/widgets/app_shell.dart'; +import '../shared/widgets/window_chrome_overlay.dart'; +import 'page_transitions.dart'; + +final rootNavigatorKey = GlobalKey(debugLabel: 'root'); +final _homeBranchKey = GlobalKey(debugLabel: 'home'); +final _serversBranchKey = GlobalKey(debugLabel: 'servers'); +final _aggregatedBranchKey = GlobalKey( + debugLabel: 'aggregated', +); +final _discoverBranchKey = GlobalKey(debugLabel: 'discover'); +final _calendarBranchKey = GlobalKey(debugLabel: 'calendar'); +final _settingsBranchKey = GlobalKey(debugLabel: 'settings'); +final _modulesBranchKey = GlobalKey(debugLabel: 'modules'); +final appRouterProvider = Provider((ref) { + return GoRouter( + navigatorKey: rootNavigatorKey, + initialLocation: ref.watch(initialLocationProvider), + routes: [ + StatefulShellRoute.indexedStack( + builder: (ctx, state, navigationShell) { + final parentTheme = Theme.of(ctx); + final materialTheme = parentTheme.brightness == Brightness.dark + ? AppTheme.dark() + : AppTheme.light(); + return Theme( + data: materialTheme.copyWith(platform: parentTheme.platform), + child: AppShell( + navigationShell: navigationShell, + location: state.matchedLocation, + ), + ); + }, + branches: [ + StatefulShellBranch( + navigatorKey: _homeBranchKey, + routes: [ + GoRoute( + path: '/', + pageBuilder: (_, _) => const CustomTransitionPage( + child: HomePage(), + transitionsBuilder: PageTransitions.fade, + transitionDuration: PageTransitions.shellFade, + reverseTransitionDuration: PageTransitions.shellFade, + ), + ), + GoRoute( + path: '/library/:libraryId', + pageBuilder: (_, state) => CustomTransitionPage( + child: LibraryListPage( + libraryId: state.pathParameters['libraryId']!, + title: state.uri.queryParameters['title'], + isCollection: + state.uri.queryParameters['collection'] == '1', + ), + transitionsBuilder: PageTransitions.fade, + transitionDuration: PageTransitions.shellFade, + reverseTransitionDuration: PageTransitions.shellFade, + ), + ), + ], + ), + StatefulShellBranch( + navigatorKey: _serversBranchKey, + routes: [ + GoRoute( + path: '/servers', + pageBuilder: (_, _) => const CustomTransitionPage( + child: ServersPage(), + transitionsBuilder: PageTransitions.fade, + transitionDuration: PageTransitions.shellFade, + reverseTransitionDuration: PageTransitions.shellFade, + ), + ), + ], + ), + StatefulShellBranch( + navigatorKey: _aggregatedBranchKey, + routes: [ + GoRoute( + path: '/aggregated', + pageBuilder: (_, state) { + final tab = state.uri.queryParameters['tab']; + return CustomTransitionPage( + child: LayoutBuilder( + builder: (_, constraints) => AggregatedViewPage( + initialTab: tab, + isCompactMode: Breakpoints.isCompact( + constraints.maxWidth, + ), + ), + ), + transitionsBuilder: PageTransitions.fade, + transitionDuration: PageTransitions.shellFade, + reverseTransitionDuration: PageTransitions.shellFade, + ); + }, + ), + GoRoute( + path: '/history', + redirect: (_, _) => '/aggregated?tab=history', + ), + GoRoute( + path: '/favorites-all', + redirect: (_, _) => '/aggregated?tab=favorites', + ), + GoRoute( + path: '/recent', + redirect: (_, _) => '/aggregated?tab=recent', + ), + ], + ), + StatefulShellBranch( + navigatorKey: _discoverBranchKey, + routes: [ + GoRoute( + path: '/discover', + pageBuilder: (_, _) => const CustomTransitionPage( + child: DiscoverPage(), + transitionsBuilder: PageTransitions.fade, + transitionDuration: PageTransitions.shellFade, + reverseTransitionDuration: PageTransitions.shellFade, + ), + ), + ], + ), + StatefulShellBranch( + navigatorKey: _calendarBranchKey, + routes: [ + GoRoute( + path: '/calendar', + pageBuilder: (_, _) => const CustomTransitionPage( + child: TraktCalendarPage(), + transitionsBuilder: PageTransitions.fade, + transitionDuration: PageTransitions.shellFade, + reverseTransitionDuration: PageTransitions.shellFade, + ), + ), + ], + ), + StatefulShellBranch( + navigatorKey: _settingsBranchKey, + routes: [ + GoRoute( + path: '/settings', + pageBuilder: (_, _) => CustomTransitionPage( + child: LayoutBuilder( + builder: (_, constraints) => + Breakpoints.isCompact(constraints.maxWidth) + ? const SettingsFullPage() + : const ProfilePage(), + ), + transitionsBuilder: PageTransitions.fade, + transitionDuration: PageTransitions.shellFade, + reverseTransitionDuration: PageTransitions.shellFade, + ), + ), + GoRoute(path: '/profile', redirect: (_, _) => '/settings'), + ], + ), + + + StatefulShellBranch( + navigatorKey: _modulesBranchKey, + routes: [ + GoRoute(path: '/modules', redirect: (_, _) => '/'), + GoRoute( + path: '/modules/:widgetId', + pageBuilder: (_, state) => CustomTransitionPage( + key: state.pageKey, + child: ModuleBrowsePage( + widgetId: state.pathParameters['widgetId'] ?? '', + ), + transitionsBuilder: PageTransitions.fade, + transitionDuration: PageTransitions.shellFade, + reverseTransitionDuration: PageTransitions.shellFade, + ), + ), + GoRoute( + path: '/modules/:widgetId/module/:moduleKey', + pageBuilder: (_, state) => CustomTransitionPage( + key: state.pageKey, + child: ModuleAllPage( + widgetId: state.pathParameters['widgetId'] ?? '', + moduleKey: state.pathParameters['moduleKey'] ?? '', + initialParams: Map.from( + state.uri.queryParameters, + ), + ), + transitionsBuilder: PageTransitions.fade, + transitionDuration: PageTransitions.shellFade, + reverseTransitionDuration: PageTransitions.shellFade, + ), + ), + ], + ), + ], + ), + GoRoute( + path: '/global-search', + parentNavigatorKey: rootNavigatorKey, + pageBuilder: (_, state) { + final currentOnly = + state.uri.queryParameters['currentServer'] == 'true'; + return CustomTransitionPage( + child: WindowChromeHost( + child: GlobalSearchPage(currentServerOnly: currentOnly), + ), + transitionsBuilder: PageTransitions.fade, + transitionDuration: PageTransitions.shellFade, + reverseTransitionDuration: PageTransitions.shellFade, + ); + }, + ), + GoRoute( + path: '/media/:itemId', + parentNavigatorKey: rootNavigatorKey, + pageBuilder: (_, state) => CustomTransitionPage( + key: state.pageKey, + child: EmbyDetailView( + itemId: state.pathParameters['itemId']!, + initialSeriesId: state.uri.queryParameters['seriesId'], + initialSeasonId: state.uri.queryParameters['seasonId'], + seedItem: state.extra is EmbyRawItem + ? state.extra as EmbyRawItem + : null, + ), + transitionsBuilder: PageTransitions.slideRight, + transitionDuration: PageTransitions.detailForward, + reverseTransitionDuration: PageTransitions.detailReverse, + ), + ), + GoRoute( + path: '/tmdb/:mediaType/:id', + parentNavigatorKey: rootNavigatorKey, + pageBuilder: (_, state) { + final extra = state.extra; + return CustomTransitionPage( + key: state.pageKey, + child: TmdbDetailView( + entry: TmdbDetailEntry( + mediaType: state.pathParameters['mediaType']!, + tmdbId: state.pathParameters['id']!, + seed: extra is TmdbRecommendation ? extra : null, + ), + ), + transitionsBuilder: PageTransitions.slideRight, + transitionDuration: PageTransitions.detailForward, + reverseTransitionDuration: PageTransitions.detailReverse, + ); + }, + ), + GoRoute( + path: '/person/:embyPersonId', + parentNavigatorKey: rootNavigatorKey, + pageBuilder: (_, state) { + final embyPersonId = state.pathParameters['embyPersonId'] ?? ''; + return CustomTransitionPage( + child: WindowChromeHost( + child: PersonDetailPage(embyPersonId: embyPersonId), + ), + transitionsBuilder: PageTransitions.slideRight, + transitionDuration: PageTransitions.detailForward, + reverseTransitionDuration: PageTransitions.detailReverse, + ); + }, + ), + GoRoute( + path: '/account/auth', + parentNavigatorKey: rootNavigatorKey, + pageBuilder: (_, _) => const CustomTransitionPage( + child: WindowChromeHost(child: AccountAuthPage()), + transitionsBuilder: PageTransitions.slideRight, + transitionDuration: PageTransitions.detailForward, + reverseTransitionDuration: PageTransitions.detailReverse, + ), + ), + GoRoute( + path: '/account-center', + parentNavigatorKey: rootNavigatorKey, + pageBuilder: (_, _) => const CustomTransitionPage( + child: WindowChromeHost(child: AccountCenterPage()), + transitionsBuilder: PageTransitions.slideRight, + transitionDuration: PageTransitions.detailForward, + reverseTransitionDuration: PageTransitions.detailReverse, + ), + ), + GoRoute( + path: '/modules/:widgetId/detail', + parentNavigatorKey: rootNavigatorKey, + pageBuilder: (_, state) { + final seedItem = state.extra; + return CustomTransitionPage( + key: state.pageKey, + child: seedItem is ScriptVideoItem + ? ModuleDetailPage( + widgetId: state.pathParameters['widgetId'] ?? '', + seedItem: seedItem, + ) + : const Scaffold( + body: AppErrorState( + title: '缺少媒体信息', + message: '请从模块内容列表重新打开该条目。', + icon: Icons.link_off_rounded, + ), + ), + transitionsBuilder: PageTransitions.slideRight, + transitionDuration: PageTransitions.detailForward, + reverseTransitionDuration: PageTransitions.detailReverse, + ); + }, + ), + GoRoute( + path: '/player/:itemId', + parentNavigatorKey: rootNavigatorKey, + pageBuilder: (_, state) { + final q = state.uri.queryParameters; + return CustomTransitionPage( + transitionsBuilder: PageTransitions.player, + transitionDuration: PageTransitions.playerForward, + reverseTransitionDuration: PageTransitions.playerReverse, + child: PlayerPage( + itemId: state.pathParameters['itemId']!, + serverId: q['serverId'], + mediaSourceId: q['mediaSourceId'], + backdropUrl: q['backdropUrl'], + preferredSubtitleStreamIndex: int.tryParse( + q['subtitleIndex'] ?? '', + ), + preferredAudioStreamIndex: int.tryParse(q['audioIndex'] ?? ''), + startPositionTicks: int.tryParse(q['startPositionTicks'] ?? ''), + fromStart: q['fromStart'] == '1', + directStream: state.extra is DirectStreamLaunchData + ? state.extra as DirectStreamLaunchData + : null, + ), + ); + }, + ), + ], + errorBuilder: (_, state) => Scaffold( + body: AppErrorState( + title: '页面不存在', + message: state.matchedLocation, + icon: Icons.search_off_outlined, + ), + ), + ); +}); diff --git a/lib/router/page_transitions.dart b/lib/router/page_transitions.dart new file mode 100644 index 0000000..f9e52e5 --- /dev/null +++ b/lib/router/page_transitions.dart @@ -0,0 +1,83 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +class PageTransitions { + PageTransitions._(); + + static const Duration detailForward = Duration(milliseconds: 280); + static const Duration detailReverse = Duration(milliseconds: 220); + static const Duration shellFade = Duration(milliseconds: 200); + + static Duration get playerForward => + defaultTargetPlatform == TargetPlatform.android + ? Duration.zero + : detailForward; + + static Duration get playerReverse => + defaultTargetPlatform == TargetPlatform.android + ? Duration.zero + : detailReverse; + + + static Widget fade( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + Widget child, + ) => FadeTransition(opacity: animation, child: child); + + + static Widget slideRight( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + Widget child, + ) { + final offset = Tween(begin: const Offset(0.05, 0), end: Offset.zero) + .animate( + CurvedAnimation( + parent: animation, + curve: Curves.easeOut, + reverseCurve: Curves.easeIn, + ), + ); + return SlideTransition( + position: offset, + child: FadeTransition(opacity: animation, child: child), + ); + } + + + static Widget slideRightFull( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + Widget child, + ) { + final offset = Tween(begin: const Offset(1, 0), end: Offset.zero) + .animate( + CurvedAnimation( + parent: animation, + curve: Curves.easeOutCubic, + reverseCurve: Curves.easeInCubic, + ), + ); + return SlideTransition( + position: offset, + + + child: ClipRect(child: RepaintBoundary(child: child)), + ); + } + + + static Widget player( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + Widget child, + ) { + if (defaultTargetPlatform == TargetPlatform.android) return child; + return slideRightFull(context, animation, secondaryAnimation, child); + } +} diff --git a/lib/shared/app_info.dart b/lib/shared/app_info.dart new file mode 100644 index 0000000..b196a39 --- /dev/null +++ b/lib/shared/app_info.dart @@ -0,0 +1,21 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:package_info_plus/package_info_plus.dart'; + +const String kSmPlayerVersionDefine = String.fromEnvironment('SMPLAYER_VERSION'); +const String kSmPlayerVersionFallback = '0.2.7'; + +final appVersionProvider = FutureProvider( + (ref) => getSmPlayerVersion(), +); + +Future getSmPlayerVersion() async { + final configuredVersion = kSmPlayerVersionDefine.trim(); + if (configuredVersion.isNotEmpty) return configuredVersion; + + try { + final info = await PackageInfo.fromPlatform(); + final version = info.version.trim(); + if (version.isNotEmpty) return version; + } catch (_) {} + return kSmPlayerVersionFallback; +} diff --git a/lib/shared/constants/breakpoints.dart b/lib/shared/constants/breakpoints.dart new file mode 100644 index 0000000..4e066d6 --- /dev/null +++ b/lib/shared/constants/breakpoints.dart @@ -0,0 +1,10 @@ + +abstract final class Breakpoints { + + static const double compact = 600; + + + static const double detail = 720; + + static bool isCompact(double width) => width < compact; +} diff --git a/lib/shared/constants/layout_constants.dart b/lib/shared/constants/layout_constants.dart new file mode 100644 index 0000000..e5bcf80 --- /dev/null +++ b/lib/shared/constants/layout_constants.dart @@ -0,0 +1,11 @@ +import 'dart:io' show Platform; + +import 'package:flutter/material.dart'; + +SliverGridDelegateWithMaxCrossAxisExtent get mediaGridDelegate => + SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: Platform.isAndroid ? 130 : 180, + childAspectRatio: 0.55, + crossAxisSpacing: 12, + mainAxisSpacing: 16, + ); diff --git a/lib/shared/layout/adaptive_card_grid.dart b/lib/shared/layout/adaptive_card_grid.dart new file mode 100644 index 0000000..ec1ddcd --- /dev/null +++ b/lib/shared/layout/adaptive_card_grid.dart @@ -0,0 +1,21 @@ +import 'dart:io' show Platform; + + +const double kRichCardMinWidth = 300; + + +final double kPosterCardMinWidth = Platform.isAndroid ? 130 : 180; + + +const double kCardGap = 12; + + +int cardColumnsFor( + double width, { + double minCardWidth = kRichCardMinWidth, + int maxCols = 4, +}) { + if (width <= 0) return 1; + final cols = (width / minCardWidth).floor(); + return cols.clamp(1, maxCols); +} diff --git a/lib/shared/mappers/media_image_url.dart b/lib/shared/mappers/media_image_url.dart new file mode 100644 index 0000000..3f20c53 --- /dev/null +++ b/lib/shared/mappers/media_image_url.dart @@ -0,0 +1,318 @@ +import '../../core/contracts/library.dart'; +import '../utils/url_utils.dart'; + +class EmbyImageUrl { + static final RegExp _typedUlidPattern = RegExp( + r'^[a-z][0-9A-HJKMNP-TV-Z]{26}$', + ); + + + static String? primary({ + required String baseUrl, + required String token, + required EmbyRawItem item, + int? maxWidth, + int? maxHeight, + int? quality, + String format = 'webp', + }) { + final tag = item.PrimaryImageTag ?? item.ImageTags?['Primary']; + if (tag == null) return null; + return _build( + baseUrl: baseUrl, + itemId: item.Id, + type: 'Primary', + tag: tag, + token: token, + maxWidth: maxWidth, + maxHeight: maxHeight, + quality: quality, + format: format, + ); + } + + + static String? library({ + required String baseUrl, + required String token, + required EmbyRawLibrary lib, + int? maxHeight, + int? quality, + }) { + final tag = lib.ImageTags?['Primary']; + if (tag == null) return null; + return _build( + baseUrl: baseUrl, + itemId: lib.Id, + type: 'Primary', + tag: tag, + token: token, + maxHeight: maxHeight, + quality: quality, + ); + } + + + static String? backdrop({ + required String baseUrl, + required String token, + required EmbyRawItem item, + int? maxWidth, + int? quality, + }) { + final tags = item.BackdropImageTags ?? const []; + if (tags.isEmpty) return null; + return _build( + baseUrl: baseUrl, + itemId: item.Id, + type: 'Backdrop', + tag: tags.first, + token: token, + maxWidth: maxWidth, + quality: quality, + ); + } + + static String detailBanner({ + required String baseUrl, + required String token, + required EmbyRawItem item, + }) { + final artworkItemId = (item.SeriesId?.isNotEmpty ?? false) + ? item.SeriesId! + : item.Id; + return backdrop( + baseUrl: baseUrl, + token: token, + item: item, + maxWidth: 1600, + quality: 90, + ) ?? + fanart(baseUrl: baseUrl, token: token, itemId: artworkItemId); + } + + + static String fanart({ + required String baseUrl, + required String token, + required String itemId, + }) { + return _buildDirectImage( + baseUrl: baseUrl, + itemId: itemId, + imageKind: 'fanart', + extension: 'jpg', + token: token, + ); + } + + static String? logo({ + required String baseUrl, + required String token, + required String itemId, + String? tag, + }) { + if (tag != null) { + return _build( + baseUrl: baseUrl, + itemId: itemId, + type: 'Logo', + tag: tag, + token: token, + ); + } + final base = stripTrailingSlash(baseUrl); + return '$base/Items/$itemId/Images/Logo?api_key=$token'; + } + + + static String? thumb({ + required String baseUrl, + required String token, + required EmbyRawItem item, + int? maxWidth, + int? quality, + }) { + final tag = item.ImageTags?['Thumb']; + if (tag != null) { + return _build( + baseUrl: baseUrl, + itemId: item.Id, + type: 'Thumb', + tag: tag, + token: token, + maxWidth: maxWidth, + quality: quality, + ); + } + return primary( + baseUrl: baseUrl, + token: token, + item: item, + quality: quality, + ); + } + + + static String primaryById({ + required String baseUrl, + required String token, + required String itemId, + int? maxHeight, + int? quality, + }) { + final base = stripTrailingSlash(baseUrl); + final params = { + 'api_key': token, + if (maxHeight != null) 'maxHeight': '$maxHeight', + 'quality': '${quality ?? 90}', + 'format': 'webp', + }; + final query = params.entries.map((e) => '${e.key}=${e.value}').join('&'); + return '$base/Items/$itemId/Images/Primary?$query'; + } + + + static String? season({ + required String baseUrl, + required String token, + required EmbyRawSeason season, + int? maxHeight, + int? quality, + }) { + final tag = season.PrimaryImageTag ?? season.ImageTags?['Primary']; + if (tag == null) return null; + return _build( + baseUrl: baseUrl, + itemId: season.Id, + type: 'Primary', + tag: tag, + token: token, + maxHeight: maxHeight, + quality: quality, + ); + } + + + static String? landscapeCard({ + required String baseUrl, + required String token, + required EmbyRawItem item, + int? maxWidth, + int? quality, + }) { + String? pick(String? itemId, String type, String? tag) => + (itemId == null || tag == null) + ? null + : _build( + baseUrl: baseUrl, + itemId: itemId, + type: type, + tag: tag, + token: token, + maxWidth: maxWidth, + quality: quality, + ); + String? self(String type, String? tag) => pick(item.Id, type, tag); + + final primary = item.PrimaryImageTag ?? item.ImageTags?['Primary']; + final thumb = item.ImageTags?['Thumb']; + final backdropTags = item.BackdropImageTags ?? const []; + final backdrop = backdropTags.isEmpty ? null : backdropTags.first; + + if (item.Type == 'Movie') { + return self('Backdrop', backdrop) ?? + self('Thumb', thumb) ?? + self('Primary', primary) ?? + _build( + baseUrl: baseUrl, + itemId: item.Id, + type: 'Primary', + tag: null, + token: token, + maxWidth: maxWidth, + quality: quality, + ); + } + + final extra = item.extra; + final parentBackdropTags = extra['ParentBackdropImageTags']; + return self('Primary', primary) ?? + self('Thumb', thumb) ?? + self('Backdrop', backdrop) ?? + pick( + extra['ParentThumbItemId'] as String?, + 'Thumb', + extra['ParentThumbImageTag'] as String?, + ) ?? + pick( + extra['ParentBackdropItemId'] as String?, + 'Backdrop', + parentBackdropTags is List && parentBackdropTags.isNotEmpty + ? parentBackdropTags.first as String + : null, + ) ?? + pick( + extra['SeriesId'] as String?, + 'Primary', + extra['SeriesPrimaryImageTag'] as String?, + ); + } + + + static int targetPixelWidth( + double logicalWidth, + double devicePixelRatio, { + double maxScale = 3.0, + }) { + final scale = devicePixelRatio.clamp(1.0, maxScale); + return (logicalWidth * scale).ceil(); + } + + + static String _build({ + required String baseUrl, + required String itemId, + required String type, + required String? tag, + required String token, + int? maxWidth, + int? maxHeight, + int? quality, + String format = 'webp', + }) { + final base = stripTrailingSlash(baseUrl); + final params = { + if (tag != null) 'tag': tag, + 'api_key': token, + if (maxWidth != null) 'maxWidth': '$maxWidth', + if (maxHeight != null) 'maxHeight': '$maxHeight', + 'quality': '${quality ?? 90}', + 'format': format, + }; + final query = params.entries.map((e) => '${e.key}=${e.value}').join('&'); + return '$base/Items/$itemId/Images/$type?$query'; + } + + static String _buildDirectImage({ + required String baseUrl, + required String itemId, + required String imageKind, + required String extension, + required String token, + }) { + final uri = Uri.parse(baseUrl); + final origin = + '${uri.scheme}://${uri.host}${uri.hasPort ? ':${uri.port}' : ''}'; + final id = Uri.encodeComponent(_directImageId(itemId)); + final key = Uri.encodeQueryComponent(token); + return '$origin/img/i/$imageKind/$id.$extension?api_key=$key'; + } + + static String _directImageId(String itemId) { + if (_typedUlidPattern.hasMatch(itemId)) { + return itemId.substring(1); + } + return itemId; + } +} diff --git a/lib/shared/mappers/tmdb_image_selector.dart b/lib/shared/mappers/tmdb_image_selector.dart new file mode 100644 index 0000000..9e11c51 --- /dev/null +++ b/lib/shared/mappers/tmdb_image_selector.dart @@ -0,0 +1,105 @@ +import '../../core/contracts/tmdb.dart'; +import 'tmdb_image_url.dart'; + +class TmdbImageSelector { + static TmdbImage? bestImage( + List images, { + bool preferNeutral = false, + }) { + if (images.isEmpty) return null; + if (preferNeutral) { + final neutrals = images + .where((i) => i.filePath.isNotEmpty && i.isLanguageNeutral) + .toList(); + if (neutrals.isNotEmpty) return _pickBest(neutrals); + } + return _pickBest(images); + } + + static TmdbImage? _pickBest(List images) { + TmdbImage? best; + for (final image in images) { + if (image.filePath.isEmpty) continue; + if (best == null || _isBetter(image, best)) best = image; + } + return best; + } + + static String? backdropUrl( + TmdbImageSet? imageSet, + String? imageBaseUrl, { + String size = 'original', + }) { + if (imageBaseUrl == null || imageBaseUrl.isEmpty) return null; + final image = bestImage( + imageSet?.backdrops ?? const [], + preferNeutral: true, + ); + return TmdbImageUrl.backdrop(imageBaseUrl, image?.filePath, size: size); + } + + static String? posterUrl( + TmdbImageSet? imageSet, + String? imageBaseUrl, { + String size = 'w500', + }) { + if (imageBaseUrl == null || imageBaseUrl.isEmpty) return null; + final image = bestImage(imageSet?.posters ?? const []); + return TmdbImageUrl.poster(imageBaseUrl, image?.filePath, size: size); + } + + + static String? logoUrl( + TmdbImageSet? imageSet, + String? imageBaseUrl, { + String? language, + String size = 'w500', + }) { + if (imageBaseUrl == null || imageBaseUrl.isEmpty) return null; + final logos = imageSet?.logos ?? const []; + if (logos.isEmpty) return null; + + final lang = _iso639Of(language); + final image = + (lang != null ? _pickBest(_withLang(logos, lang)) : null) ?? + _pickBest(_withLang(logos, 'en')) ?? + _pickBest(logos.where((i) => i.isLanguageNeutral).toList()) ?? + _pickBest(logos); + return TmdbImageUrl.logo(imageBaseUrl, image?.filePath, size: size); + } + + static List _withLang(List images, String iso) => + images.where((i) => i.iso639 == iso).toList(); + + + static String? _iso639Of(String? language) { + if (language == null || language.isEmpty) return null; + final dash = language.indexOf('-'); + final code = dash >= 0 ? language.substring(0, dash) : language; + return code.isEmpty ? null : code.toLowerCase(); + } + + static List buildFallbackUrls({ + required String? primaryUrl, + required Iterable candidates, + }) { + final urls = []; + for (final url in candidates) { + if (url == null || url.isEmpty || url == primaryUrl) continue; + if (!urls.contains(url)) urls.add(url); + } + return urls; + } + + static bool _isBetter(TmdbImage candidate, TmdbImage current) { + final voteCompare = (candidate.voteAverage ?? 0).compareTo( + current.voteAverage ?? 0, + ); + if (voteCompare != 0) return voteCompare > 0; + final pixelsCompare = (candidate.width * candidate.height).compareTo( + current.width * current.height, + ); + if (pixelsCompare != 0) return pixelsCompare > 0; + return candidate.filePath.compareTo(current.filePath) < 0; + } +} diff --git a/lib/shared/mappers/tmdb_image_url.dart b/lib/shared/mappers/tmdb_image_url.dart new file mode 100644 index 0000000..57b1c84 --- /dev/null +++ b/lib/shared/mappers/tmdb_image_url.dart @@ -0,0 +1,50 @@ +class TmdbImageUrl { + static String? profile( + String imageBaseUrl, + String? path, { + String size = 'w185', + }) { + return _build(imageBaseUrl, path, size); + } + + static String? poster( + String imageBaseUrl, + String? path, { + String size = 'w500', + }) { + return _build(imageBaseUrl, path, size); + } + + static String? backdrop( + String imageBaseUrl, + String? path, { + String size = 'original', + }) { + return _build(imageBaseUrl, path, size); + } + + static String? still( + String imageBaseUrl, + String? path, { + String size = 'w300', + }) { + return _build(imageBaseUrl, path, size); + } + + static String? logo( + String imageBaseUrl, + String? path, { + String size = 'w500', + }) { + return _build(imageBaseUrl, path, size); + } + + static String? _build(String imageBaseUrl, String? path, String size) { + if (path == null || path.isEmpty) return null; + final base = imageBaseUrl.endsWith('/') + ? imageBaseUrl.substring(0, imageBaseUrl.length - 1) + : imageBaseUrl; + final normalizedPath = path.startsWith('/') ? path : '/$path'; + return '$base/$size$normalizedPath'; + } +} diff --git a/lib/shared/theme/app_scroll_behavior.dart b/lib/shared/theme/app_scroll_behavior.dart new file mode 100644 index 0000000..f1caa12 --- /dev/null +++ b/lib/shared/theme/app_scroll_behavior.dart @@ -0,0 +1,22 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter/widgets.dart' as widgets; + +class AppScrollBehavior extends material.MaterialScrollBehavior { + const AppScrollBehavior(); + + @override + widgets.Widget buildScrollbar( + widgets.BuildContext context, + widgets.Widget child, + widgets.ScrollableDetails details, + ) { + switch (details.direction) { + case widgets.AxisDirection.up: + case widgets.AxisDirection.down: + return child; + case widgets.AxisDirection.left: + case widgets.AxisDirection.right: + return super.buildScrollbar(context, child, details); + } + } +} diff --git a/lib/shared/theme/app_theme.dart b/lib/shared/theme/app_theme.dart new file mode 100644 index 0000000..ae4f67d --- /dev/null +++ b/lib/shared/theme/app_theme.dart @@ -0,0 +1,299 @@ +import 'package:flutter/material.dart'; + +class AppTokens { + final double siderWidthExpanded; + final double siderWidthCollapsed; + final Duration siderTransition; + final Color progressBarColor; + final Color ratingColor; + final Color shellBg; + final Color mutedForeground; + final Color pageGradientTop; + final Color pageGradientBottom; + final Color statusOnline; + final Color statusWarn; + final Color statusOffline; + final double panelRadius; + final double posterRadius; + final double titleBarHeight; + final Color separatorColor; + final double chromeInset; + final double radiusCard; + final double cardFillAlpha; + final double cardFillHoverAlpha; + final double radiusCardCompact; + final double bottomNavRadius; + final List warmGradientColors; + + const AppTokens({ + required this.siderWidthExpanded, + required this.siderWidthCollapsed, + required this.siderTransition, + required this.progressBarColor, + required this.ratingColor, + required this.shellBg, + required this.mutedForeground, + required this.pageGradientTop, + required this.pageGradientBottom, + required this.statusOnline, + required this.statusWarn, + required this.statusOffline, + required this.panelRadius, + required this.posterRadius, + required this.titleBarHeight, + required this.separatorColor, + required this.chromeInset, + required this.radiusCard, + required this.cardFillAlpha, + required this.cardFillHoverAlpha, + required this.radiusCardCompact, + required this.bottomNavRadius, + required this.warmGradientColors, + }); +} + +class AppTheme { + static const _seedColor = Color(0xFF7C99C9); + + + static const _fontFamilyFallback = ['Noto Sans SC']; + + static const lightTokens = AppTokens( + siderWidthExpanded: 220, + siderWidthCollapsed: 64, + siderTransition: Duration(milliseconds: 240), + progressBarColor: Color(0xFFFFFFFF), + ratingColor: Color(0xFFF6C453), + shellBg: Color(0xFFFFFFFF), + mutedForeground: Color(0xFF8C8C8C), + pageGradientTop: Color(0xFFF3F5FA), + pageGradientBottom: Color(0xFFFFFFFF), + statusOnline: Color(0xFF22C55E), + statusWarn: Color(0xFFFB9E0B), + statusOffline: Color(0xFFEF4444), + panelRadius: 0, + posterRadius: 4, + titleBarHeight: 46, + separatorColor: Color(0x18000000), + chromeInset: 12, + radiusCard: 8, + cardFillAlpha: 0.025, + cardFillHoverAlpha: 0.05, + radiusCardCompact: 16, + bottomNavRadius: 24, + warmGradientColors: [ + Color(0xFFFFF8F0), + Color(0xFFFFE8D6), + Color(0xFFFFD6C0), + ], + ); + + static const darkTokens = AppTokens( + siderWidthExpanded: 220, + siderWidthCollapsed: 64, + siderTransition: Duration(milliseconds: 240), + progressBarColor: Color(0xFFFFFFFF), + ratingColor: Color(0xFFF6C453), + shellBg: Color(0xFF000000), + mutedForeground: Color(0xFFA3A3A3), + pageGradientTop: Color(0xFF06080C), + pageGradientBottom: Color(0xFF000000), + statusOnline: Color(0xFF22C55E), + statusWarn: Color(0xFFFB9E0B), + statusOffline: Color(0xFFEF4444), + panelRadius: 0, + posterRadius: 4, + titleBarHeight: 46, + separatorColor: Color(0x1AFFFFFF), + chromeInset: 12, + radiusCard: 8, + cardFillAlpha: 0.025, + cardFillHoverAlpha: 0.05, + radiusCardCompact: 16, + bottomNavRadius: 24, + warmGradientColors: [ + Color(0xFF2A1810), + Color(0xFF1A1208), + Color(0xFF0A0A0A), + ], + ); + + static ThemeData light() { + final scheme = + ColorScheme.fromSeed( + seedColor: _seedColor, + brightness: Brightness.light, + ).copyWith( + surface: const Color(0xFFFFFFFF), + surfaceContainerHighest: const Color(0xFFF5F5F5), + outlineVariant: const Color(0xFFE8E8E8), + ); + return _build(scheme, lightTokens); + } + + static ThemeData dark() { + final scheme = + ColorScheme.fromSeed( + seedColor: _seedColor, + brightness: Brightness.dark, + ).copyWith( + surface: const Color(0xFF000000), + surfaceContainerHighest: const Color(0xFF1A1A1A), + outlineVariant: const Color(0xFF2A2A2A), + ); + return _build(scheme, darkTokens); + } + + static ThemeData _build(ColorScheme scheme, AppTokens tokens) { + final isLight = scheme.brightness == Brightness.light; + final onSurface = isLight + ? const Color(0xFF0A0A0A) + : const Color(0xFFF5F5F5); + final secondaryText = isLight + ? const Color(0xFF6B6B6B) + : const Color(0xFF8C8C8C); + + return ThemeData( + colorScheme: scheme, + useMaterial3: true, + fontFamilyFallback: _fontFamilyFallback, + scaffoldBackgroundColor: tokens.shellBg, + canvasColor: tokens.shellBg, + cardColor: tokens.shellBg, + dividerColor: tokens.separatorColor, + visualDensity: VisualDensity.adaptivePlatformDensity, + textTheme: TextTheme( + titleLarge: TextStyle( + fontSize: 36, + height: 1.05, + fontWeight: FontWeight.w800, + letterSpacing: -0.5, + color: onSurface, + ), + titleMedium: TextStyle( + fontSize: 20, + height: 1.15, + fontWeight: FontWeight.w700, + letterSpacing: -0.2, + color: onSurface, + ), + titleSmall: TextStyle( + fontSize: 14, + height: 1.2, + fontWeight: FontWeight.w600, + letterSpacing: 0.1, + color: onSurface, + ), + bodyLarge: TextStyle( + fontSize: 15, + height: 1.55, + fontWeight: FontWeight.w400, + letterSpacing: 0.1, + color: onSurface, + ), + bodyMedium: TextStyle( + fontSize: 13.5, + height: 1.45, + fontWeight: FontWeight.w400, + letterSpacing: 0.1, + color: onSurface, + ), + bodySmall: TextStyle( + fontSize: 12, + height: 1.35, + fontWeight: FontWeight.w400, + letterSpacing: 0.15, + color: secondaryText, + ), + labelLarge: TextStyle( + fontSize: 13, + height: 1.2, + fontWeight: FontWeight.w600, + letterSpacing: 0.1, + color: onSurface, + ), + labelSmall: TextStyle( + fontSize: 11, + height: 1.2, + fontWeight: FontWeight.w500, + letterSpacing: 0.6, + color: secondaryText, + ), + ), + cardTheme: CardThemeData( + elevation: 0, + color: tokens.shellBg, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(tokens.posterRadius), + ), + clipBehavior: Clip.antiAlias, + margin: EdgeInsets.zero, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), + ), + ), + textButtonTheme: TextButtonThemeData( + style: TextButton.styleFrom( + foregroundColor: onSurface, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)), + ), + ), + inputDecorationTheme: InputDecorationTheme( + filled: false, + hintStyle: TextStyle(color: secondaryText.withValues(alpha: 0.6)), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(4), + borderSide: BorderSide(color: tokens.separatorColor), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(4), + borderSide: BorderSide(color: tokens.separatorColor), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(4), + borderSide: BorderSide(color: scheme.primary.withValues(alpha: 0.6)), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 12, + ), + ), + dividerTheme: DividerThemeData( + color: tokens.separatorColor, + thickness: 1, + space: 1, + ), + switchTheme: SwitchThemeData( + thumbColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) { + return isLight ? Colors.white : const Color(0xFF1C1C1E); + } + return isLight ? const Color(0xFFBDBDBD) : const Color(0xFF6B6B6B); + }), + trackColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) { + return isLight ? const Color(0xFF1C1C1E) : Colors.white; + } + return isLight ? const Color(0xFFDEDEDE) : const Color(0xFF3A3A3A); + }), + trackOutlineColor: WidgetStateProperty.all(Colors.transparent), + ), + ); + } +} + + +extension AppTokensContext on BuildContext { + AppTokens get appTokens => Theme.of(this).brightness == Brightness.dark + ? AppTheme.darkTokens + : AppTheme.lightTokens; +} diff --git a/lib/shared/theme/app_theme_scope.dart b/lib/shared/theme/app_theme_scope.dart new file mode 100644 index 0000000..f4b7068 --- /dev/null +++ b/lib/shared/theme/app_theme_scope.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../providers/appearance_provider.dart'; +import 'app_theme.dart'; + + +class AppThemeScope extends ConsumerWidget { + final Widget child; + + const AppThemeScope({super.key, required this.child}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final mode = ref.watch( + appearanceProvider.select((a) => a.value?.themeMode ?? ThemeMode.system), + ); + final brightness = switch (mode) { + ThemeMode.light => Brightness.light, + ThemeMode.dark => Brightness.dark, + ThemeMode.system => MediaQuery.platformBrightnessOf(context), + }; + return Theme( + data: brightness == Brightness.dark ? AppTheme.dark() : AppTheme.light(), + child: Material(type: MaterialType.transparency, child: child), + ); + } +} diff --git a/lib/shared/utils/app_logger.dart b/lib/shared/utils/app_logger.dart new file mode 100644 index 0000000..e5bd127 --- /dev/null +++ b/lib/shared/utils/app_logger.dart @@ -0,0 +1,38 @@ +import 'package:flutter/foundation.dart'; + +abstract final class AppLogger { + static void debug(String tag, String message, [Object? error]) { + if (!kDebugMode) return; + _emit('D', tag, message, error); + } + + static void info(String tag, String message) { + if (!kDebugMode) return; + _emit('I', tag, message); + } + + static void warn(String tag, String message, [Object? error]) { + if (!kDebugMode) return; + _emit('W', tag, message, error); + } + + + static void error( + String tag, + String message, + Object error, [ + StackTrace? stack, + ]) { + _emit('E', tag, message, error); + if (stack != null && kDebugMode) { + debugPrint( + '[$tag] stack: ${stack.toString().split('\n').take(8).join('\n')}', + ); + } + } + + static void _emit(String level, String tag, String message, [Object? error]) { + final suffix = error != null ? ' err=$error' : ''; + debugPrint('[$level/$tag] $message$suffix'); + } +} diff --git a/lib/shared/utils/async_lock.dart b/lib/shared/utils/async_lock.dart new file mode 100644 index 0000000..c8998fe --- /dev/null +++ b/lib/shared/utils/async_lock.dart @@ -0,0 +1,20 @@ +import 'dart:async'; + + +class AsyncLock { + Future? _lastOp; + + Future run(Future Function() action) async { + final previous = _lastOp; + final completer = Completer(); + _lastOp = completer.future; + if (previous != null) { + await previous; + } + try { + return await action(); + } finally { + completer.complete(); + } + } +} diff --git a/lib/shared/utils/color_utils.dart b/lib/shared/utils/color_utils.dart new file mode 100644 index 0000000..5c7140b --- /dev/null +++ b/lib/shared/utils/color_utils.dart @@ -0,0 +1,10 @@ +import 'package:flutter/painting.dart'; + + +Color accentColorForSeed(String seed, {required bool isDark}) { + final hash = seed.runes.fold(0, (value, rune) => value * 37 + rune); + final hue = (hash % 360).toDouble(); + final saturation = isDark ? 0.48 : 0.40; + final lightness = isDark ? 0.42 : 0.70; + return HSLColor.fromAHSL(1, hue, saturation, lightness).toColor(); +} diff --git a/lib/shared/utils/cross_server_search_key.dart b/lib/shared/utils/cross_server_search_key.dart new file mode 100644 index 0000000..0a33198 --- /dev/null +++ b/lib/shared/utils/cross_server_search_key.dart @@ -0,0 +1,39 @@ +import '../../core/contracts/library.dart'; + + +CrossServerSearchReq? buildEpisodeCrossServerReq({ + required EmbyRawItem episode, + EmbyRawItem? seriesItem, + int? seasonNumberOverride, +}) { + final seasonNumber = + seasonNumberOverride ?? + (episode.extra['ParentIndexNumber'] as num?)?.toInt(); + final episodeNumber = episode.IndexNumber; + if (seasonNumber == null || episodeNumber == null) return null; + + final providerIds = providerIdsOfItem(episode); + final seriesProviderIds = seriesItem != null + ? providerIdsOfItem(seriesItem) + : const {}; + if (providerIds.isEmpty && seriesProviderIds.isEmpty) return null; + + return CrossServerSearchReq( + anchorType: 'Episode', + itemName: episode.Name, + providerIds: providerIds, + seriesProviderIds: seriesProviderIds, + parentIndexNumber: seasonNumber, + indexNumber: episodeNumber, + ); +} + + +String encodeProviderIdsForKey(Map ids) { + if (ids.isEmpty) return ''; + final entries = ids.entries.toList()..sort((a, b) => a.key.compareTo(b.key)); + return entries + .where((e) => e.value.isNotEmpty) + .map((e) => '${e.key}.${e.value}') + .join(','); +} diff --git a/lib/shared/utils/emby_ticks.dart b/lib/shared/utils/emby_ticks.dart new file mode 100644 index 0000000..8206c36 --- /dev/null +++ b/lib/shared/utils/emby_ticks.dart @@ -0,0 +1,28 @@ +import '../../core/contracts/library.dart'; + + +const int kTicksPerSecond = 10000000; +const int kTicksPerMinute = 600000000; + +int ticksFromDuration(Duration duration) => duration.inMicroseconds * 10; + +Duration durationFromTicks(int ticks) => Duration(microseconds: ticks ~/ 10); + + +double playbackProgress(EmbyRawItem item) { + final position = item.UserData?.PlaybackPositionTicks; + final total = item.RunTimeTicks; + if (position == null || total == null || total <= 0) return 0; + final value = position / total; + if (value.isNaN || value.isInfinite) return 0; + return value.clamp(0.0, 1.0); +} + + +String? formatRemainingLabel(EmbyRawItem item) { + final position = item.UserData?.PlaybackPositionTicks; + final total = item.RunTimeTicks; + if (position == null || total == null || total <= 0) return null; + final minutes = ((total - position) / kTicksPerMinute).ceil(); + return minutes > 0 ? '剩余 $minutes 分钟' : null; +} diff --git a/lib/shared/utils/format_utils.dart b/lib/shared/utils/format_utils.dart new file mode 100644 index 0000000..efbbb1e --- /dev/null +++ b/lib/shared/utils/format_utils.dart @@ -0,0 +1,69 @@ +import 'emby_ticks.dart'; + + +String? formatRuntimeMinutes(int? minutes) { + if (minutes == null || minutes <= 0) return null; + final h = minutes ~/ 60; + final m = minutes % 60; + if (h > 0) return m > 0 ? '$h小时$m分钟' : '$h小时'; + return '$m分钟'; +} + +String formatFileSize(int bytes) { + if (bytes >= 1073741824) { + return '${(bytes / 1073741824).toStringAsFixed(2)}G'; + } + if (bytes >= 1048576) { + return '${(bytes / 1048576).toStringAsFixed(0)}M'; + } + return '${(bytes / 1024).toStringAsFixed(0)}K'; +} + + +String? formatEpisodeNumber(int? index) { + if (index == null) return null; + return '第$index集'; +} + + +String? formatSeasonNumber(int? season) { + if (season == null) return null; + return '第$season季'; +} + + +double? downloadProgressValue({required int received, required int total}) { + if (total <= 0) return null; + final value = received / total; + return value.clamp(0, 1).toDouble(); +} + + +String formatDownloadBytes(int bytes) { + if (bytes <= 0) return '-- MB'; + return '${(bytes / 1024 / 1024).toStringAsFixed(1)} MB'; +} + + +String formatDownloadSpeed(double bytesPerSec) { + if (bytesPerSec <= 0) return '0 B/s'; + if (bytesPerSec >= 1024 * 1024) { + return '${(bytesPerSec / (1024 * 1024)).toStringAsFixed(1)} MB/s'; + } + if (bytesPerSec >= 1024) { + return '${(bytesPerSec / 1024).toStringAsFixed(0)} KB/s'; + } + return '${bytesPerSec.toStringAsFixed(0)} B/s'; +} + + +String formatDurationClock(Duration duration) { + final h = duration.inHours; + final m = duration.inMinutes.remainder(60).toString().padLeft(2, '0'); + final s = duration.inSeconds.remainder(60).toString().padLeft(2, '0'); + return h > 0 ? '$h:$m:$s' : '$m:$s'; +} + + +String formatTicksAsClock(int ticks) => + formatDurationClock(ticks <= 0 ? Duration.zero : durationFromTicks(ticks)); diff --git a/lib/shared/utils/lru_eviction.dart b/lib/shared/utils/lru_eviction.dart new file mode 100644 index 0000000..9a07ec3 --- /dev/null +++ b/lib/shared/utils/lru_eviction.dart @@ -0,0 +1,23 @@ + + +void evictOldestEntries(Map map, int maxSize) { + if (map.length <= maxSize) return; + final entries = map.entries.toList() + ..sort((a, b) { + final aTime = + DateTime.tryParse( + (a.value as Map)['updatedAt'] as String? ?? '', + ) ?? + DateTime(2000); + final bTime = + DateTime.tryParse( + (b.value as Map)['updatedAt'] as String? ?? '', + ) ?? + DateTime(2000); + return aTime.compareTo(bTime); + }); + final removeCount = map.length - maxSize; + for (var i = 0; i < removeCount; i++) { + map.remove(entries[i].key); + } +} diff --git a/lib/shared/utils/media_nav.dart b/lib/shared/utils/media_nav.dart new file mode 100644 index 0000000..fffaf33 --- /dev/null +++ b/lib/shared/utils/media_nav.dart @@ -0,0 +1,131 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../core/contracts/library.dart'; +import '../../core/contracts/tmdb.dart'; +import '../../core/infra/emby_api/emby_headers.dart'; +import '../../features/player/player_launcher.dart'; +import '../../providers/di_providers.dart'; +import '../../providers/session_provider.dart'; +import '../../providers/tmdb_prefetch_provider.dart'; +import 'platform_device_name.dart'; + + +const _kContainerTypes = { + 'BoxSet', + 'CollectionFolder', + 'Folder', + 'Playlist', + 'UserView', +}; + +void goMediaDetail(BuildContext context, EmbyRawItem item, {WidgetRef? ref}) { + if (_kContainerTypes.contains(item.Type)) { + context.push( + Uri( + path: '/library/${item.Id}', + queryParameters: { + 'collection': '1', + if (item.Name.isNotEmpty) 'title': item.Name, + }, + ).toString(), + ); + return; + } + if (ref != null) { + ref.read(tmdbPrefetchProvider.notifier).prefetchItem(item, priority: true); + } + final isEpisode = item.Type == 'Episode'; + final seriesId = isEpisode && (item.SeriesId?.isNotEmpty ?? false) + ? item.SeriesId + : null; + final seasonId = isEpisode && (item.SeasonId?.isNotEmpty ?? false) + ? item.SeasonId + : null; + final query = { + if (seriesId != null) 'seriesId': seriesId, + if (seasonId != null) 'seasonId': seasonId, + }; + context.push( + Uri( + path: '/media/${item.Id}', + queryParameters: query.isNotEmpty ? query : null, + ).toString(), + extra: item, + ); +} + + +Future playFromTmdbOnServer( + BuildContext context, + WidgetRef ref, { + required String serverId, + required String itemId, + String? mediaSourceId, + int preferredSubtitleStreamIndex = -1, + int? preferredAudioStreamIndex, + bool fromStart = false, + int? startPositionTicks, + String? backdropUrl, +}) async { + final active = ref.read(activeSessionProvider); + if (active?.serverId != serverId) { + await ref.read(sessionProvider.notifier).switchSession(serverId); + } + if (!context.mounted) return null; + return PlayerLauncher.launch( + context: context, + itemId: itemId, + serverId: serverId, + mediaSourceId: mediaSourceId, + preferredSubtitleStreamIndex: preferredSubtitleStreamIndex, + preferredAudioStreamIndex: preferredAudioStreamIndex, + startPositionTicks: startPositionTicks, + fromStart: fromStart, + backdropUrl: backdropUrl, + ref: ref, + ); +} + + +void goTmdbDetail( + BuildContext context, { + required int tmdbId, + required String mediaType, + TmdbRecommendation? seed, +}) { + if (mediaType != 'movie' && mediaType != 'tv') return; + context.push('/tmdb/$mediaType/$tmdbId', extra: seed); +} + + +Future goMediaDetailOnServer( + BuildContext context, + WidgetRef ref, { + required String serverId, + required EmbyRawItem item, +}) async { + final activeId = ref.read(sessionProvider).value?.activeServerId; + if (serverId != activeId) { + await ref.read(sessionProvider.notifier).switchSession(serverId); + } + if (context.mounted) { + goMediaDetail(context, item); + } +} + + +Map buildEmbyImageHeaders( + WidgetRef ref, { + required String token, + required String userId, +}) { + final deviceId = ref.read(deviceIdProvider).value ?? ''; + return EmbyRequestHeaders.image( + deviceId: deviceId, + deviceName: kEmbyDeviceName, + token: token, + userId: userId, + ); +} diff --git a/lib/shared/utils/platform_device_name.dart b/lib/shared/utils/platform_device_name.dart new file mode 100644 index 0000000..d28d3a6 --- /dev/null +++ b/lib/shared/utils/platform_device_name.dart @@ -0,0 +1,3 @@ + + +const String kEmbyDeviceName = 'Mac'; diff --git a/lib/shared/utils/semver.dart b/lib/shared/utils/semver.dart new file mode 100644 index 0000000..7e4f6f8 --- /dev/null +++ b/lib/shared/utils/semver.dart @@ -0,0 +1,91 @@ +class ParsedVersion { + final int major; + final int minor; + final int patch; + final String? prerelease; + final String? build; + + const ParsedVersion({ + required this.major, + required this.minor, + required this.patch, + this.prerelease, + this.build, + }); + + bool get hasPrerelease => prerelease != null && prerelease!.isNotEmpty; + + bool sameCore(ParsedVersion other) { + return major == other.major && minor == other.minor && patch == other.patch; + } +} + +final RegExp _versionPattern = RegExp( + r'^[vV]?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+([0-9A-Za-z.-]+))?$', +); + +ParsedVersion? parseVersion(String tag) { + final match = _versionPattern.firstMatch(tag.trim()); + if (match == null) return null; + + return ParsedVersion( + major: int.parse(match.group(1)!), + minor: int.parse(match.group(2)!), + patch: int.parse(match.group(3)!), + prerelease: match.group(4), + build: match.group(5), + ); +} + +bool isNewerVersion(String localTag, String remoteTag) { + final local = parseVersion(localTag); + final remote = parseVersion(remoteTag); + if (local == null || remote == null) return false; + + if (remote.hasPrerelease) { + if (!local.hasPrerelease || !local.sameCore(remote)) return false; + return _comparePrerelease(remote.prerelease!, local.prerelease!) > 0; + } + + final coreCompare = _compareCore(remote, local); + if (coreCompare != 0) return coreCompare > 0; + return local.hasPrerelease; +} + +int _compareCore(ParsedVersion a, ParsedVersion b) { + final major = a.major.compareTo(b.major); + if (major != 0) return major; + final minor = a.minor.compareTo(b.minor); + if (minor != 0) return minor; + return a.patch.compareTo(b.patch); +} + +int _comparePrerelease(String a, String b) { + final left = a.split('.'); + final right = b.split('.'); + final length = left.length > right.length ? left.length : right.length; + + for (var i = 0; i < length; i++) { + if (i >= left.length) return -1; + if (i >= right.length) return 1; + + final leftPart = left[i]; + final rightPart = right[i]; + final leftNumber = int.tryParse(leftPart); + final rightNumber = int.tryParse(rightPart); + + if (leftNumber != null && rightNumber != null) { + final numberCompare = leftNumber.compareTo(rightNumber); + if (numberCompare != 0) return numberCompare; + continue; + } + + if (leftNumber != null) return -1; + if (rightNumber != null) return 1; + + final textCompare = leftPart.compareTo(rightPart); + if (textCompare != 0) return textCompare; + } + + return 0; +} diff --git a/lib/shared/utils/time_bucket.dart b/lib/shared/utils/time_bucket.dart new file mode 100644 index 0000000..93c247c --- /dev/null +++ b/lib/shared/utils/time_bucket.dart @@ -0,0 +1,97 @@ + + +library; + +enum TimeBucketScheme { watched, added } + + +class TimeBucket { + final String key; + final String label; + final int order; + + const TimeBucket({ + required this.key, + required this.label, + required this.order, + }); +} + +const _bucketToday = TimeBucket(key: 'today', label: '今天', order: 0); +const _bucketYesterday = TimeBucket(key: 'yesterday', label: '昨天', order: 1); +const _bucketThisWeekWatched = TimeBucket( + key: 'this_week', + label: '本周', + order: 2, +); +const _bucketEarlierWatched = TimeBucket(key: 'earlier', label: '更早', order: 3); + +const _bucketThisWeekAdded = TimeBucket( + key: 'this_week', + label: '本周', + order: 1, +); +const _bucketThisMonthAdded = TimeBucket( + key: 'this_month', + label: '本月', + order: 2, +); +const _bucketEarlierAdded = TimeBucket(key: 'earlier', label: '更早', order: 3); + + +DateTime? parseEmbyDate(Object? raw) { + if (raw is DateTime) return raw.toLocal(); + if (raw is! String || raw.isEmpty) return null; + final parsed = DateTime.tryParse(raw); + return parsed?.toLocal(); +} + +DateTime _dateOnly(DateTime d) => DateTime(d.year, d.month, d.day); + + +DateTime _startOfWeek(DateTime now) { + final today = _dateOnly(now); + return today.subtract(Duration(days: today.weekday - DateTime.monday)); +} + + +TimeBucket bucketFor(DateTime? when, DateTime now, TimeBucketScheme scheme) { + final earlier = scheme == TimeBucketScheme.watched + ? _bucketEarlierWatched + : _bucketEarlierAdded; + if (when == null) return earlier; + + final whenDay = _dateOnly(when); + final todayDay = _dateOnly(now); + final dayDiff = todayDay.difference(whenDay).inDays; + + switch (scheme) { + case TimeBucketScheme.watched: + if (dayDiff <= 0) return _bucketToday; + if (dayDiff == 1) return _bucketYesterday; + if (!whenDay.isBefore(_startOfWeek(now))) return _bucketThisWeekWatched; + return _bucketEarlierWatched; + case TimeBucketScheme.added: + if (dayDiff <= 0) return _bucketToday; + if (!whenDay.isBefore(_startOfWeek(now))) return _bucketThisWeekAdded; + if (whenDay.year == todayDay.year && whenDay.month == todayDay.month) { + return _bucketThisMonthAdded; + } + return _bucketEarlierAdded; + } +} + + +String? relativeTimeLabel(DateTime? when, DateTime now) { + if (when == null) return null; + final diff = now.difference(when); + if (diff.isNegative) return '刚刚'; + if (diff.inMinutes < 1) return '刚刚'; + if (diff.inMinutes < 60) return '${diff.inMinutes} 分钟前'; + if (diff.inHours < 24) return '${diff.inHours} 小时前'; + if (diff.inDays < 7) return '${diff.inDays} 天前'; + if (diff.inDays < 35) return '${(diff.inDays / 7).floor()} 周前'; + final m = when.month.toString().padLeft(2, '0'); + final d = when.day.toString().padLeft(2, '0'); + return '${when.year}-$m-$d'; +} diff --git a/lib/shared/utils/tmdb_cta_model.dart b/lib/shared/utils/tmdb_cta_model.dart new file mode 100644 index 0000000..2a7c38b --- /dev/null +++ b/lib/shared/utils/tmdb_cta_model.dart @@ -0,0 +1,12 @@ +import '../../core/contracts/library.dart'; + + +String? buildTmdbCtaLabel({ + required String mediaType, + required TmdbLibraryHit? primaryHit, + required int hitCount, +}) { + if (primaryHit == null) return null; + final hasProgress = (primaryHit.playbackPositionTicks ?? 0) > 0; + return hasProgress ? '继续播放' : '播放'; +} diff --git a/lib/shared/utils/tmdb_episode_helpers.dart b/lib/shared/utils/tmdb_episode_helpers.dart new file mode 100644 index 0000000..00c20cf --- /dev/null +++ b/lib/shared/utils/tmdb_episode_helpers.dart @@ -0,0 +1,36 @@ +import '../../core/contracts/library.dart'; +import '../../core/contracts/tmdb.dart'; +import '../mappers/tmdb_image_url.dart'; + + +int? seasonNumberFrom(EmbyRawSeason season) { + final indexNumber = season.IndexNumber; + if (indexNumber != null) return indexNumber; + final name = season.Name.trim().toLowerCase(); + if (name == 'specials' || name == '特别篇' || name == 'sp') return 0; + final match = RegExp(r'\d+').firstMatch(season.Name); + if (match == null) return null; + return int.tryParse(match.group(0)!); +} + + +Map tmdbStillUrlsByEpisode( + TmdbSeasonDetail? season, + String? imageBaseUrl, +) { + if (season == null || imageBaseUrl == null || imageBaseUrl.isEmpty) { + return const {}; + } + final urls = {}; + for (final episode in season.episodes) { + final url = TmdbImageUrl.still( + imageBaseUrl, + episode.stillPath, + size: 'w500', + ); + if (url != null && url.isNotEmpty) { + urls[episode.episodeNumber] = url; + } + } + return urls; +} diff --git a/lib/shared/utils/tmdb_key_utils.dart b/lib/shared/utils/tmdb_key_utils.dart new file mode 100644 index 0000000..33720eb --- /dev/null +++ b/lib/shared/utils/tmdb_key_utils.dart @@ -0,0 +1,28 @@ +import '../../core/contracts/library.dart'; +import '../../providers/di_providers.dart'; + +String tmdbIdFromItem(EmbyRawItem item) { + final rawProviderIds = item.extra['ProviderIds']; + if (rawProviderIds is! Map) return ''; + + for (final entry in rawProviderIds.entries) { + final key = entry.key?.toString().trim().toLowerCase() ?? ''; + final value = entry.value?.toString().trim() ?? ''; + if (key == 'tmdb' && value.isNotEmpty) return value; + } + return ''; +} + +TmdbMediaKey? tmdbMediaKeyFrom(EmbyRawItem item) { + final mediaType = switch (item.Type) { + 'Movie' => 'movie', + 'Series' || 'Episode' || 'Season' => 'tv', + _ => null, + }; + if (mediaType == null) return null; + + final tmdbId = tmdbIdFromItem(item); + if (tmdbId.isEmpty) return null; + + return (tmdbId: tmdbId, mediaType: mediaType); +} diff --git a/lib/shared/utils/tmdb_primary_hit.dart b/lib/shared/utils/tmdb_primary_hit.dart new file mode 100644 index 0000000..50bab83 --- /dev/null +++ b/lib/shared/utils/tmdb_primary_hit.dart @@ -0,0 +1,27 @@ +import '../../core/contracts/library.dart'; + + +TmdbLibraryHit? selectPrimaryTmdbHit( + List hits, { + String? activeServerId, +}) { + if (hits.isEmpty) return null; + + int score(TmdbLibraryHit h) { + var s = 0; + if ((h.playbackPositionTicks ?? 0) > 0) s += 2; + if (activeServerId != null && h.serverId == activeServerId) s += 1; + return s; + } + + var best = hits.first; + var bestScore = score(best); + for (final h in hits.skip(1)) { + final s = score(h); + if (s > bestScore) { + best = h; + bestScore = s; + } + } + return best; +} diff --git a/lib/shared/utils/url_utils.dart b/lib/shared/utils/url_utils.dart new file mode 100644 index 0000000..ec47b90 --- /dev/null +++ b/lib/shared/utils/url_utils.dart @@ -0,0 +1 @@ +String stripTrailingSlash(String s) => s.replaceFirst(RegExp(r'/+$'), ''); diff --git a/lib/shared/utils/user_error_formatter.dart b/lib/shared/utils/user_error_formatter.dart new file mode 100644 index 0000000..a0c740e --- /dev/null +++ b/lib/shared/utils/user_error_formatter.dart @@ -0,0 +1,13 @@ +import '../../core/domain/errors.dart'; + +String formatUserError(Object? error, {String fallback = '操作失败'}) { + if (error == null) return fallback; + if (error is DomainError) return error.message; + + final raw = error.toString().trim(); + if (raw.isEmpty) return fallback; + + return raw + .replaceFirst(RegExp(r'^(Exception|StateError|TimeoutException):\s*'), '') + .trim(); +} diff --git a/lib/shared/utils/window_bounds.dart b/lib/shared/utils/window_bounds.dart new file mode 100644 index 0000000..40a0578 --- /dev/null +++ b/lib/shared/utils/window_bounds.dart @@ -0,0 +1,21 @@ +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:window_manager/window_manager.dart'; + + +const String kWinWidthKey = 'window_width'; +const String kWinHeightKey = 'window_height'; +const String kWinXKey = 'window_x'; +const String kWinYKey = 'window_y'; + + +Future saveWindowBounds() async { + try { + final size = await windowManager.getSize(); + final position = await windowManager.getPosition(); + final prefs = await SharedPreferences.getInstance(); + await prefs.setDouble(kWinWidthKey, size.width); + await prefs.setDouble(kWinHeightKey, size.height); + await prefs.setDouble(kWinXKey, position.dx); + await prefs.setDouble(kWinYKey, position.dy); + } catch (_) {} +} diff --git a/lib/shared/widgets/adaptive_modal.dart b/lib/shared/widgets/adaptive_modal.dart new file mode 100644 index 0000000..cdd3f02 --- /dev/null +++ b/lib/shared/widgets/adaptive_modal.dart @@ -0,0 +1,116 @@ +import 'package:flutter/material.dart'; +import 'package:forui/forui.dart'; + +import '../constants/breakpoints.dart'; +import '../theme/app_theme_scope.dart'; + + +Future showAdaptiveModal({ + required BuildContext context, + required WidgetBuilder builder, + double maxWidth = 520, + double? maxHeight, + double compactMaxHeightFactor = 0.92, + double? compactHeightFactor, + bool barrierDismissible = true, + bool useRootNavigator = false, +}) { + final isCompact = Breakpoints.isCompact(MediaQuery.sizeOf(context).width); + if (isCompact) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + useRootNavigator: useRootNavigator, + isDismissible: barrierDismissible, + showDragHandle: true, + backgroundColor: Colors.transparent, + barrierColor: Colors.black54, + builder: (sheetContext) => AppThemeScope( + child: _AdaptiveBottomSheet( + maxHeightFactor: compactMaxHeightFactor, + heightFactor: compactHeightFactor, + child: builder(sheetContext), + ), + ), + ); + } + + const insetPadding = EdgeInsets.symmetric(horizontal: 40, vertical: 32); + return showFDialog( + context: context, + useRootNavigator: useRootNavigator, + barrierDismissible: barrierDismissible, + builder: (dialogContext, _, animation) { + final screenHeight = MediaQuery.sizeOf(dialogContext).height; + return AppThemeScope( + child: FDialog.raw( + animation: animation, + clipBehavior: Clip.antiAlias, + constraints: BoxConstraints( + maxWidth: maxWidth, + maxHeight: maxHeight ?? screenHeight - insetPadding.vertical, + ), + builder: (_, _) => builder(dialogContext), + ), + ); + }, + ); +} + +class _AdaptiveBottomSheet extends StatelessWidget { + final Widget child; + final double maxHeightFactor; + final double? heightFactor; + + const _AdaptiveBottomSheet({ + required this.child, + required this.maxHeightFactor, + required this.heightFactor, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final size = MediaQuery.sizeOf(context); + final viewInsets = MediaQuery.viewInsetsOf(context); + final fixedHeight = heightFactor == null + ? null + : size.height * heightFactor!; + final maxHeight = size.height * maxHeightFactor; + + Widget sheet = Material( + color: theme.colorScheme.surface, + clipBehavior: Clip.antiAlias, + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + child: SafeArea( + top: false, + child: Column( + mainAxisSize: fixedHeight == null + ? MainAxisSize.min + : MainAxisSize.max, + children: [ + if (fixedHeight == null) + Flexible(fit: FlexFit.loose, child: child) + else + Expanded(child: child), + ], + ), + ), + ); + + sheet = ConstrainedBox( + constraints: BoxConstraints(maxHeight: maxHeight), + child: fixedHeight == null + ? sheet + : SizedBox(height: fixedHeight, child: sheet), + ); + + return AnimatedPadding( + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + padding: EdgeInsets.only(bottom: viewInsets.bottom), + child: Align(alignment: Alignment.bottomCenter, child: sheet), + ); + } +} diff --git a/lib/shared/widgets/add_server_modal.dart b/lib/shared/widgets/add_server_modal.dart new file mode 100644 index 0000000..fbb9f8f --- /dev/null +++ b/lib/shared/widgets/add_server_modal.dart @@ -0,0 +1,157 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/contracts/server.dart'; +import '../../providers/di_providers.dart'; +import '../../providers/server_provider.dart'; +import '../../providers/session_provider.dart'; +import 'adaptive_modal.dart'; +import 'connection_form.dart'; + + +class EditingServer { + final String id; + final String baseUrl; + final String? userName; + final String? password; + final String? displayName; + final String iconUrl; + + const EditingServer({ + required this.id, + required this.baseUrl, + this.userName, + this.password, + this.displayName, + this.iconUrl = '', + }); + + factory EditingServer.fromServer(EmbyServer s) => EditingServer( + id: s.id, + baseUrl: s.baseUrl, + displayName: s.name, + iconUrl: s.iconUrl, + ); +} + + +class _AddServerDialogContent extends ConsumerStatefulWidget { + final EditingServer? editingServer; + + const _AddServerDialogContent({this.editingServer}); + + @override + ConsumerState<_AddServerDialogContent> createState() => + _AddServerDialogContentState(); +} + +class _AddServerDialogContentState + extends ConsumerState<_AddServerDialogContent> { + bool _loading = false; + String? _error; + + bool get _isEdit => widget.editingServer != null; + + Future _handleConnect( + String url, + String username, + String password, + String? displayName, + String iconUrl, + ) async { + setState(() { + _loading = true; + _error = null; + }); + + try { + final probeUc = await ref.read(probeServerUseCaseProvider.future); + final probeResult = await probeUc.execute( + EmbyServerProbeReq(baseUrl: url), + ); + + final gw = await ref.read(embyGatewayProvider.future); + await gw.authenticateByName( + baseUrl: url, + username: username, + password: password, + ); + + final name = (displayName != null && displayName.isNotEmpty) + ? displayName + : probeResult.serverName; + + final server = await ref + .read(serverListProvider.notifier) + .save( + EmbyServerSaveReq( + id: widget.editingServer?.id, + name: name, + baseUrl: url, + iconUrl: iconUrl, + ), + ); + + if (_isEdit) { + await ref.read(sessionProvider.notifier).logout(serverId: server.id); + } + + await ref + .read(sessionProvider.notifier) + .login(serverId: server.id, username: username, password: password); + + if (!mounted) return; + Navigator.of(context).pop(server); + } catch (e) { + if (mounted) setState(() => _error = e.toString()); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + void _handleClose() { + if (_loading) return; + Navigator.of(context).pop(); + } + + @override + Widget build(BuildContext context) { + final editing = widget.editingServer; + + return PopScope( + canPop: !_loading, + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(24, 20, 24, 24), + child: ConnectionForm( + mode: _isEdit ? 'edit' : 'add', + initialUrl: editing?.baseUrl, + initialUsername: editing?.userName, + initialPassword: editing?.password, + initialDisplayName: editing?.displayName, + initialIconUrl: editing?.iconUrl ?? '', + onConnect: _handleConnect, + onCancel: _handleClose, + loading: _loading, + error: _error, + ), + ), + ); + } +} + + +Future showAddServerModal( + BuildContext context, { + EmbyServer? initial, + EditingServer? editingServer, +}) async { + final target = + editingServer ?? + (initial != null ? EditingServer.fromServer(initial) : null); + return showAdaptiveModal( + context: context, + maxWidth: 520, + compactMaxHeightFactor: 0.92, + builder: (_) => _AddServerDialogContent(editingServer: target), + ); +} diff --git a/lib/shared/widgets/all_versions_sheet.dart b/lib/shared/widgets/all_versions_sheet.dart new file mode 100644 index 0000000..5fbd72b --- /dev/null +++ b/lib/shared/widgets/all_versions_sheet.dart @@ -0,0 +1,320 @@ +import 'dart:io' show Platform; + +import 'package:flutter/material.dart'; + +import '../utils/format_utils.dart'; +import 'adaptive_modal.dart'; +import 'app_loading_ring.dart'; +import 'app_snack_bar.dart'; +import 'version_filter.dart'; +import 'version_grouping.dart'; +import 'version_source_card.dart'; + +const int kCompactInlineVersionLimit = 6; +const int kDesktopInlineVersionLimit = 8; + +int inlineVersionLimit() { + return Platform.isAndroid + ? kCompactInlineVersionLimit + : kDesktopInlineVersionLimit; +} + +Future showAllVersionsSheet({ + required BuildContext context, + required List groups, + String title = '全部版本', +}) async { + final isSingleVersionPicker = groups.length == 1; + await showAdaptiveModal( + context: context, + maxWidth: isSingleVersionPicker ? 520 : 720, + maxHeight: isSingleVersionPicker ? 300 : 640, + compactMaxHeightFactor: isSingleVersionPicker ? 0.55 : 0.92, + compactHeightFactor: isSingleVersionPicker ? null : 0.82, + builder: (modalContext) => AllVersionsSheet(groups: groups, title: title), + ); +} + + +class AllVersionsSheet extends StatelessWidget { + final List groups; + final String title; + + const AllVersionsSheet({ + super.key, + required this.groups, + this.title = '全部版本', + }); + + @override + Widget build(BuildContext context) { + final groupsByBucket = >{}; + for (final group in groups) { + final bucket = versionBucketOf(group.representative.resolutionLabel); + groupsByBucket.putIfAbsent(bucket, () => []).add(group); + } + + final concreteSourceCount = groups.fold( + 0, + (count, group) => count + group.members.length, + ); + + return Material( + color: const Color(0xFF171A20), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 8, 10), + child: Row( + children: [ + const Icon( + Icons.video_library_outlined, + color: Colors.white70, + size: 20, + ), + const SizedBox(width: 10), + Text( + title, + style: const TextStyle( + color: Colors.white, + fontSize: 17, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(width: 8), + Text( + '$concreteSourceCount 个来源', + style: TextStyle( + color: Colors.white.withValues(alpha: 0.55), + fontSize: 12, + ), + ), + const Spacer(), + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close, color: Colors.white60), + tooltip: '关闭', + ), + ], + ), + ), + Divider(height: 1, color: Colors.white.withValues(alpha: 0.08)), + Flexible( + fit: FlexFit.loose, + child: ListView( + shrinkWrap: true, + padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), + children: [ + for (final bucket in kVersionBucketOrder) + if (groupsByBucket[bucket] case final bucketGroups?) ...[ + _BucketHeader(label: bucket, count: bucketGroups.length), + const SizedBox(height: 8), + for ( + var index = 0; + index < bucketGroups.length; + index++ + ) ...[ + _VersionGroupRow(group: bucketGroups[index]), + if (index < bucketGroups.length - 1) + const SizedBox(height: 10), + ], + const SizedBox(height: 20), + ], + ], + ), + ), + ], + ), + ); + } +} + +class _BucketHeader extends StatelessWidget { + final String label; + final int count; + + const _BucketHeader({required this.label, required this.count}); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Text( + label, + style: const TextStyle( + color: Colors.white, + fontSize: 15, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(width: 8), + Text( + '$count 个版本', + style: TextStyle( + color: Colors.white.withValues(alpha: 0.5), + fontSize: 12, + ), + ), + ], + ); + } +} + +class _VersionGroupRow extends StatelessWidget { + final VersionSourceGroup group; + + const _VersionGroupRow({required this.group}); + + @override + Widget build(BuildContext context) { + final representative = group.representative; + final sizeBytes = representative.sizeBytes; + final bitrate = representative.bitrate; + final details = [ + if (sizeBytes != null && sizeBytes > 0) formatFileSize(sizeBytes), + if (bitrate != null && bitrate > 0) + '${(bitrate / 1e6).toStringAsFixed(2)}Mbps', + ]; + + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.06), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: Colors.white.withValues(alpha: 0.08)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + representative.topLabel, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, + fontSize: 15, + fontWeight: FontWeight.w700, + ), + ), + ), + if (details.isNotEmpty) + Text( + details.join(' · '), + style: TextStyle( + color: Colors.white.withValues(alpha: 0.58), + fontSize: 12, + ), + ), + ], + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final member in group.members) + _ServerSourceChip(data: member), + ], + ), + ], + ), + ); + } +} + +class _ServerSourceChip extends StatefulWidget { + final VersionSourceCardData data; + + const _ServerSourceChip({required this.data}); + + @override + State<_ServerSourceChip> createState() => _ServerSourceChipState(); +} + +class _ServerSourceChipState extends State<_ServerSourceChip> { + bool _loading = false; + + Future _selectSource() async { + final data = widget.data; + if (_loading || data.isCurrent || data.selected || data.onTap == null) { + return; + } + + setState(() => _loading = true); + try { + await data.onTap!(); + if (mounted) Navigator.of(context).pop(); + } catch (_) { + if (mounted) { + setState(() => _loading = false); + showAppSnackBar(context, '切换服务器失败', tone: AppSnackTone.error); + } + } + } + + @override + Widget build(BuildContext context) { + final data = widget.data; + final active = data.isCurrent || data.selected; + + return Tooltip( + message: active ? '${data.serverName}(当前选择)' : '切换到 ${data.serverName}', + child: InkWell( + onTap: active ? null : _selectSource, + borderRadius: BorderRadius.circular(999), + child: AnimatedContainer( + duration: const Duration(milliseconds: 160), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7), + decoration: BoxDecoration( + color: active + ? const Color(0xFF4F8DFF).withValues(alpha: 0.24) + : Colors.white.withValues(alpha: 0.07), + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: active + ? const Color(0xFF4F8DFF) + : Colors.white.withValues(alpha: 0.12), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (_loading) + const AppLoadingRing(size: 12, color: Colors.white) + else + Icon( + active ? Icons.check_circle : Icons.dns_outlined, + size: 14, + color: active ? const Color(0xFF77A7FF) : Colors.white60, + ), + const SizedBox(width: 6), + Text( + data.serverName, + style: TextStyle( + color: Colors.white.withValues(alpha: active ? 1 : 0.78), + fontSize: 12, + fontWeight: active ? FontWeight.w600 : FontWeight.w500, + ), + ), + if (data.isCurrent) ...[ + const SizedBox(width: 6), + const Text( + '当前', + style: TextStyle( + color: Color(0xFF9FC0FF), + fontSize: 10, + fontWeight: FontWeight.w600, + ), + ), + ], + ], + ), + ), + ), + ); + } +} diff --git a/lib/shared/widgets/app_bottom_nav.dart b/lib/shared/widgets/app_bottom_nav.dart new file mode 100644 index 0000000..6764651 --- /dev/null +++ b/lib/shared/widgets/app_bottom_nav.dart @@ -0,0 +1,206 @@ +import 'dart:io' show Platform; +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import '../theme/app_theme.dart'; + +class AppBottomNav extends StatelessWidget { + final StatefulNavigationShell navigationShell; + + const AppBottomNav({super.key, required this.navigationShell}); + + static const _destinations = <_BottomDestination>[ + _BottomDestination( + branchIndex: 0, + icon: Icons.video_library_outlined, + selectedIcon: Icons.video_library, + label: '媒体', + ), + _BottomDestination( + branchIndex: 2, + icon: Icons.history_outlined, + selectedIcon: Icons.history, + label: '观看', + ), + _BottomDestination( + branchIndex: 1, + icon: Icons.dns_outlined, + selectedIcon: Icons.dns, + label: '资源', + ), + _BottomDestination( + branchIndex: 5, + icon: Icons.settings_outlined, + selectedIcon: Icons.settings, + label: '设置', + ), + ]; + + @override + Widget build(BuildContext context) { + final tokens = context.appTokens; + final theme = Theme.of(context); + final currentBranch = navigationShell.currentIndex; + final bottomPadding = MediaQuery.paddingOf(context).bottom; + + return SizedBox( + height: 64 + bottomPadding + 28, + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned( + left: 0, + right: 0, + bottom: 0, + child: ClipRRect( + borderRadius: BorderRadius.vertical( + top: Radius.circular( + Platform.isAndroid ? 24 : tokens.bottomNavRadius, + ), + ), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), + child: Container( + color: tokens.shellBg.withValues( + alpha: Platform.isAndroid ? 0.88 : 0.78, + ), + padding: EdgeInsets.only(bottom: bottomPadding), + height: 64 + bottomPadding, + child: Row( + children: [ + for (var i = 0; i < _destinations.length; i++) ...[ + if (i == 2) const SizedBox(width: 64), + Expanded( + child: _NavItem( + destination: _destinations[i], + selected: + _destinations[i].branchIndex == currentBranch, + onTap: () { + final target = _destinations[i].branchIndex; + navigationShell.goBranch( + target, + initialLocation: + target == navigationShell.currentIndex, + ); + }, + theme: theme, + tokens: tokens, + ), + ), + ], + ], + ), + ), + ), + ), + ), + Positioned( + bottom: 64 + bottomPadding - 28, + left: 0, + right: 0, + child: Center( + child: _SearchFab( + onTap: () => context.push('/global-search'), + theme: theme, + ), + ), + ), + ], + ), + ); + } +} + +class _NavItem extends StatelessWidget { + final _BottomDestination destination; + final bool selected; + final VoidCallback onTap; + final ThemeData theme; + final AppTokens tokens; + + const _NavItem({ + required this.destination, + required this.selected, + required this.onTap, + required this.theme, + required this.tokens, + }); + + @override + Widget build(BuildContext context) { + final color = selected ? theme.colorScheme.primary : tokens.mutedForeground; + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + selected ? destination.selectedIcon : destination.icon, + size: 24, + color: color, + ), + const SizedBox(height: 4), + Text( + destination.label, + style: TextStyle( + fontSize: 10, + fontWeight: selected ? FontWeight.w600 : FontWeight.w400, + color: color, + ), + ), + ], + ), + ); + } +} + +class _SearchFab extends StatelessWidget { + final VoidCallback onTap; + final ThemeData theme; + + const _SearchFab({required this.onTap, required this.theme}); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + width: 56, + height: 56, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: theme.colorScheme.onSurface, + boxShadow: [ + BoxShadow( + color: theme.colorScheme.onSurface.withValues(alpha: 0.25), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: Icon( + Icons.search_rounded, + size: 26, + color: theme.colorScheme.surface, + ), + ), + ); + } +} + +class _BottomDestination { + final int branchIndex; + final IconData icon; + final IconData selectedIcon; + final String label; + + const _BottomDestination({ + required this.branchIndex, + required this.icon, + required this.selectedIcon, + required this.label, + }); +} diff --git a/lib/shared/widgets/app_card.dart b/lib/shared/widgets/app_card.dart new file mode 100644 index 0000000..7ff6cce --- /dev/null +++ b/lib/shared/widgets/app_card.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart'; +import 'package:forui/forui.dart'; + +import '../theme/app_theme.dart'; +import 'auto_dismiss_menu.dart'; + + +class AppCard extends StatefulWidget { + final Widget child; + final VoidCallback? onTap; + + + final List? contextMenuItems; + + + final bool enableLongPressMenu; + + + final bool filled; + final double? radius; + final EdgeInsetsGeometry? padding; + final bool enableHover; + + + final ValueChanged? onHoverChanged; + + const AppCard({ + super.key, + required this.child, + this.onTap, + this.contextMenuItems, + this.enableLongPressMenu = true, + this.filled = true, + this.radius, + this.padding, + this.enableHover = true, + this.onHoverChanged, + }); + + @override + State createState() => _AppCardState(); +} + +class _AppCardState extends State { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final tokens = context.appTokens; + final radius = widget.radius ?? tokens.radiusCard; + final base = theme.colorScheme.onSurface; + + final double alpha; + if (widget.filled) { + alpha = _hovered ? tokens.cardFillHoverAlpha : tokens.cardFillAlpha; + } else { + alpha = _hovered ? tokens.cardFillAlpha : 0.0; + } + + Widget result = MouseRegion( + onEnter: (_) { + if (widget.enableHover) setState(() => _hovered = true); + widget.onHoverChanged?.call(true); + }, + onExit: (_) { + if (widget.enableHover) setState(() => _hovered = false); + widget.onHoverChanged?.call(false); + }, + cursor: widget.onTap != null + ? SystemMouseCursors.click + : SystemMouseCursors.basic, + child: GestureDetector( + onTap: widget.onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 140), + padding: widget.padding, + decoration: BoxDecoration( + color: base.withValues(alpha: alpha), + borderRadius: BorderRadius.circular(radius), + ), + clipBehavior: Clip.antiAlias, + child: widget.child, + ), + ), + ); + + final items = widget.contextMenuItems; + if (items != null && items.isNotEmpty) { + result = FContextMenu( + menuBuilder: autoDismissMenuBuilder, + menu: [FItemGroup(children: items)], + longPress: widget.enableLongPressMenu ? null : false, + child: result, + ); + } + return result; + } +} diff --git a/lib/shared/widgets/app_dialog.dart b/lib/shared/widgets/app_dialog.dart new file mode 100644 index 0000000..a6b2fd7 --- /dev/null +++ b/lib/shared/widgets/app_dialog.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; +import 'package:forui/forui.dart'; + +import '../theme/app_theme_scope.dart'; + +Future showAppRawDialog( + BuildContext context, { + required WidgetBuilder builder, + BoxConstraints constraints = const BoxConstraints( + minWidth: 280, + maxWidth: 560, + ), + bool useRootNavigator = false, + bool barrierDismissible = true, +}) { + return showFDialog( + context: context, + useRootNavigator: useRootNavigator, + barrierDismissible: barrierDismissible, + builder: (dialogContext, _, animation) => AppThemeScope( + child: FDialog.raw( + animation: animation, + clipBehavior: Clip.antiAlias, + constraints: constraints, + builder: (_, _) => builder(dialogContext), + ), + ), + ); +} + +Future showAppConfirmDialog( + BuildContext context, { + required String title, + required Widget body, + required String confirmLabel, + String cancelLabel = '取消', + bool destructive = false, +}) async { + final confirmed = await showFDialog( + context: context, + builder: (ctx, _, animation) => AppThemeScope( + child: FDialog.adaptive( + animation: animation, + title: Text(title), + body: body, + actions: [ + FButton( + onPress: () => Navigator.of(ctx).pop(true), + variant: destructive + ? FButtonVariant.destructive + : FButtonVariant.primary, + child: Text(confirmLabel), + ), + FButton( + onPress: () => Navigator.of(ctx).pop(false), + variant: FButtonVariant.outline, + child: Text(cancelLabel), + ), + ], + ), + ), + ); + return confirmed == true; +} diff --git a/lib/shared/widgets/app_error_state.dart b/lib/shared/widgets/app_error_state.dart new file mode 100644 index 0000000..661468b --- /dev/null +++ b/lib/shared/widgets/app_error_state.dart @@ -0,0 +1,128 @@ +import 'package:flutter/material.dart'; + +class AppErrorState extends StatelessWidget { + final String title; + final String? message; + final IconData icon; + final VoidCallback? onRetry; + final Widget? action; + final bool compact; + final bool panel; + final Color? foregroundColor; + + const AppErrorState({ + super.key, + this.title = '加载失败', + this.message, + this.icon = Icons.error_outline, + this.onRetry, + this.action, + this.compact = false, + this.panel = false, + this.foregroundColor, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final child = ConstrainedBox( + constraints: BoxConstraints(maxWidth: compact ? 420 : 520), + child: Padding( + padding: EdgeInsets.all(compact ? 16 : 28), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: compact ? 36 : 48, + height: compact ? 36 : 48, + decoration: BoxDecoration( + color: theme.colorScheme.errorContainer.withValues(alpha: 0.72), + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + icon, + size: compact ? 20 : 26, + color: theme.colorScheme.onErrorContainer, + ), + ), + SizedBox(height: compact ? 10 : 14), + Text( + title, + textAlign: TextAlign.center, + style: + (compact + ? theme.textTheme.titleSmall + : theme.textTheme.titleMedium) + ?.copyWith( + color: foregroundColor, + fontWeight: FontWeight.w600, + ), + ), + if (message != null && message!.isNotEmpty) ...[ + const SizedBox(height: 8), + Text( + message!, + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + color: + foregroundColor?.withValues(alpha: 0.72) ?? + theme.colorScheme.onSurfaceVariant, + height: 1.4, + ), + ), + ], + if (action != null || onRetry != null) ...[ + SizedBox(height: compact ? 14 : 18), + action ?? _RetryButton(onPressed: onRetry!), + ], + ], + ), + ), + ); + + final content = panel + ? DecoratedBox( + decoration: BoxDecoration( + color: theme.colorScheme.surface.withValues(alpha: 0.86), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: theme.colorScheme.outlineVariant.withValues(alpha: 0.5), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.10), + blurRadius: 28, + offset: const Offset(0, 16), + ), + ], + ), + child: child, + ) + : child; + + return Center( + child: Padding( + padding: EdgeInsets.all(compact ? 16 : 32), + child: content, + ), + ); + } +} + +class _RetryButton extends StatelessWidget { + final VoidCallback onPressed; + + const _RetryButton({required this.onPressed}); + + @override + Widget build(BuildContext context) { + return FilledButton.icon( + onPressed: onPressed, + icon: const Icon(Icons.refresh, size: 18), + label: const Text('重试'), + style: FilledButton.styleFrom( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)), + ), + ); + } +} diff --git a/lib/shared/widgets/app_inline_alert.dart b/lib/shared/widgets/app_inline_alert.dart new file mode 100644 index 0000000..26ed818 --- /dev/null +++ b/lib/shared/widgets/app_inline_alert.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; + +enum AppAlertTone { error, warning, info, success } + +class AppInlineAlert extends StatelessWidget { + final String message; + final AppAlertTone tone; + final IconData? icon; + + const AppInlineAlert({ + super.key, + required this.message, + this.tone = AppAlertTone.error, + this.icon, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = _colors(theme.colorScheme); + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: colors.background, + borderRadius: BorderRadius.circular(6), + border: Border.all(color: colors.foreground.withValues(alpha: 0.16)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon ?? _defaultIcon, size: 16, color: colors.foreground), + const SizedBox(width: 8), + Expanded( + child: Text( + message, + style: theme.textTheme.bodySmall?.copyWith( + color: colors.foreground, + height: 1.35, + ), + ), + ), + ], + ), + ); + } + + IconData get _defaultIcon => switch (tone) { + AppAlertTone.error => Icons.error_outline, + AppAlertTone.warning => Icons.warning_amber_rounded, + AppAlertTone.info => Icons.info_outline, + AppAlertTone.success => Icons.check_circle_outline, + }; + + ({Color background, Color foreground}) _colors(ColorScheme scheme) { + final isDark = scheme.brightness == Brightness.dark; + return switch (tone) { + AppAlertTone.error => ( + background: scheme.errorContainer, + foreground: scheme.onErrorContainer, + ), + AppAlertTone.warning => + isDark + ? ( + background: const Color(0xFF3A2E12), + foreground: const Color(0xFFFFD68A), + ) + : ( + background: const Color(0xFFFFF3D6), + foreground: const Color(0xFF7A4D00), + ), + AppAlertTone.info => ( + background: scheme.secondaryContainer, + foreground: scheme.onSecondaryContainer, + ), + AppAlertTone.success => + isDark + ? ( + background: const Color(0xFF12331F), + foreground: const Color(0xFF7FE3A8), + ) + : ( + background: const Color(0xFFDDF7E8), + foreground: const Color(0xFF0B6B3A), + ), + }; + } +} diff --git a/lib/shared/widgets/app_loading_ring.dart b/lib/shared/widgets/app_loading_ring.dart new file mode 100644 index 0000000..cdd95f3 --- /dev/null +++ b/lib/shared/widgets/app_loading_ring.dart @@ -0,0 +1,114 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + + +class AppLoadingRing extends StatefulWidget { + const AppLoadingRing({super.key, this.size = 44, this.color}); + + + final double size; + + + final Color? color; + + @override + State createState() => _AppLoadingRingState(); +} + +class _AppLoadingRingState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _rotationController; + + @override + void initState() { + super.initState(); + _rotationController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1100), + )..repeat(); + } + + @override + void dispose() { + _rotationController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final ringColor = + widget.color ?? + IconTheme.of(context).color ?? + Theme.of(context).colorScheme.onSurface; + return TweenAnimationBuilder( + tween: Tween(begin: 0, end: 1), + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + builder: (context, fadeInOpacity, child) => + Opacity(opacity: fadeInOpacity, child: child), + child: RepaintBoundary( + child: SizedBox( + width: widget.size, + height: widget.size, + child: AnimatedBuilder( + animation: _rotationController, + builder: (context, _) => CustomPaint( + painter: _LoadingRingPainter( + rotationTurns: _rotationController.value, + color: ringColor, + ), + ), + ), + ), + ), + ); + } +} + +class _LoadingRingPainter extends CustomPainter { + _LoadingRingPainter({required this.rotationTurns, required this.color}); + + + final double rotationTurns; + + + final Color color; + + static const double strokeWidth = 3; + + + static const double _sweepAngle = math.pi * 5 / 3; + + @override + void paint(Canvas canvas, Size size) { + final center = Offset(size.width / 2, size.height / 2); + final radius = (math.min(size.width, size.height) - strokeWidth) / 2; + final arcRect = Rect.fromCircle(center: center, radius: radius); + final rotationAngle = rotationTurns * 2 * math.pi; + + final gradient = SweepGradient( + startAngle: 0, + endAngle: 2 * math.pi, + transform: GradientRotation(rotationAngle), + colors: [ + color.withValues(alpha: 0.0), + color.withValues(alpha: 0.25), + color, + ], + stops: const [0.0, 0.5, _sweepAngle / (2 * math.pi)], + ); + + final arcPaint = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = strokeWidth + ..strokeCap = StrokeCap.round + ..shader = gradient.createShader(arcRect); + + canvas.drawArc(arcRect, rotationAngle, _sweepAngle, false, arcPaint); + } + + @override + bool shouldRepaint(_LoadingRingPainter oldDelegate) => + oldDelegate.rotationTurns != rotationTurns || oldDelegate.color != color; +} diff --git a/lib/shared/widgets/app_shell.dart b/lib/shared/widgets/app_shell.dart new file mode 100644 index 0000000..f43bb31 --- /dev/null +++ b/lib/shared/widgets/app_shell.dart @@ -0,0 +1,187 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../providers/session_provider.dart'; +import '../../providers/shell_backdrop_provider.dart'; +import '../constants/breakpoints.dart'; +import '../theme/app_theme.dart'; +import 'app_bottom_nav.dart'; +import 'shell_backdrop_layer.dart'; +import 'sidebar.dart'; +import 'window_chrome.dart'; +import 'window_chrome_overlay.dart'; + +class AppShell extends ConsumerStatefulWidget { + final StatefulNavigationShell navigationShell; + + + final String location; + + const AppShell({ + super.key, + required this.navigationShell, + required this.location, + }); + + @override + ConsumerState createState() => _AppShellState(); +} + +class _AppShellState extends ConsumerState { + @override + void initState() { + super.initState(); + initSidebarState(ref); + } + + @override + Widget build(BuildContext context) { + final immersive = + widget.location == '/' || + widget.location == '/discover' || + widget.location.startsWith('/library/'); + + final sessionAsync = ref.watch(sessionProvider); + final unauthenticatedHome = + widget.location == '/' && + sessionAsync.hasValue && + ref.watch(activeSessionProvider) == null; + final effectiveImmersive = immersive && !unauthenticatedHome; + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + ref.read(shellLocationProvider.notifier).set(widget.location); + }); + + final themeBrightness = Theme.of(context).brightness; + final overlayStyle = effectiveImmersive + ? const SystemUiOverlayStyle( + statusBarColor: Colors.transparent, + statusBarIconBrightness: Brightness.light, + statusBarBrightness: Brightness.dark, + systemNavigationBarColor: Colors.transparent, + systemNavigationBarIconBrightness: Brightness.light, + ) + : SystemUiOverlayStyle( + statusBarColor: Colors.transparent, + statusBarIconBrightness: themeBrightness == Brightness.dark + ? Brightness.light + : Brightness.dark, + statusBarBrightness: themeBrightness, + systemNavigationBarColor: Colors.transparent, + systemNavigationBarIconBrightness: + themeBrightness == Brightness.dark + ? Brightness.light + : Brightness.dark, + ); + + return AnnotatedRegion( + value: overlayStyle, + child: Scaffold( + backgroundColor: Colors.transparent, + body: LayoutBuilder( + builder: (context, constraints) { + final isCompact = Breakpoints.isCompact(constraints.maxWidth); + return Stack( + children: [ + Positioned.fill( + child: ShellBackdropLayer(active: effectiveImmersive), + ), + if (isCompact) + _CompactBody(navigationShell: widget.navigationShell) + else + Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Sidebar( + selectedBranchIndex: + widget.navigationShell.currentIndex, + onNavigateBranch: (index, {initialLocation = false}) => + widget.navigationShell.goBranch( + index, + initialLocation: initialLocation, + ), + location: widget.location, + immersiveRoute: effectiveImmersive, + ), + Expanded( + child: RepaintBoundary(child: widget.navigationShell), + ), + ], + ), + _AppTitleBarOverlay(immersive: effectiveImmersive), + ], + ); + }, + ), + ), + ); + } +} + + +class _CompactBody extends StatelessWidget { + final StatefulNavigationShell navigationShell; + + const _CompactBody({required this.navigationShell}); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.transparent, + extendBody: true, + body: RepaintBoundary(child: navigationShell), + bottomNavigationBar: AppBottomNav(navigationShell: navigationShell), + ); + } +} + + +class _AppTitleBarOverlay extends ConsumerWidget { + + final bool immersive; + + const _AppTitleBarOverlay({required this.immersive}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + if (!WindowChrome.isDesktop) { + return const SizedBox.shrink(); + } + final tokens = context.appTokens; + + final immersiveBackdrop = + immersive && ref.watch(shellBackdropProvider) != null; + + if (!immersiveBackdrop) { + return const WindowChromeOverlay(); + } + + return Positioned( + top: 0, + left: 0, + right: 0, + height: tokens.titleBarHeight, + child: const Stack( + children: [ + Positioned.fill( + child: IgnorePointer( + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0x2E000000), Color(0x00000000)], + ), + ), + ), + ), + ), + WindowChromeOverlay(onDarkSurface: true), + ], + ), + ); + } +} diff --git a/lib/shared/widgets/app_skeleton.dart b/lib/shared/widgets/app_skeleton.dart new file mode 100644 index 0000000..95d6378 --- /dev/null +++ b/lib/shared/widgets/app_skeleton.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; + +import '../layout/adaptive_card_grid.dart'; +import '../theme/app_theme.dart'; + + +class AppSkeleton extends StatefulWidget { + final double? width; + final double? height; + final double radius; + + const AppSkeleton({super.key, this.width, this.height, this.radius = 8}); + + @override + State createState() => _AppSkeletonState(); +} + +class _AppSkeletonState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _c = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1100), + )..repeat(reverse: true); + + @override + void dispose() { + _c.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final base = Theme.of(context).colorScheme.onSurface; + return AnimatedBuilder( + animation: _c, + builder: (context, _) { + final alpha = 0.04 + 0.05 * _c.value; + return Container( + width: widget.width, + height: widget.height, + decoration: BoxDecoration( + color: base.withValues(alpha: alpha), + borderRadius: BorderRadius.circular(widget.radius), + ), + ); + }, + ); + } +} + + +class SkeletonGrid extends StatelessWidget { + final int itemCount; + final double itemHeight; + final double minCardWidth; + final EdgeInsetsGeometry padding; + + const SkeletonGrid({ + super.key, + this.itemCount = 6, + this.itemHeight = 84, + this.minCardWidth = kRichCardMinWidth, + this.padding = const EdgeInsets.symmetric(horizontal: 24), + }); + + @override + Widget build(BuildContext context) { + final tokens = context.appTokens; + return Padding( + padding: padding, + child: LayoutBuilder( + builder: (_, constraints) { + final columns = cardColumnsFor( + constraints.maxWidth, + minCardWidth: minCardWidth, + ); + final width = + (constraints.maxWidth - kCardGap * (columns - 1)) / columns; + return Wrap( + spacing: kCardGap, + runSpacing: kCardGap, + children: [ + for (var i = 0; i < itemCount; i++) + SizedBox( + width: width, + child: AppSkeleton( + height: itemHeight, + radius: tokens.radiusCard, + ), + ), + ], + ); + }, + ), + ); + } +} diff --git a/lib/shared/widgets/app_sliver_header.dart b/lib/shared/widgets/app_sliver_header.dart new file mode 100644 index 0000000..f069221 --- /dev/null +++ b/lib/shared/widgets/app_sliver_header.dart @@ -0,0 +1,90 @@ +import 'dart:ui' show ImageFilter; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../providers/appearance_provider.dart'; +import '../theme/app_theme.dart'; +import 'window_chrome.dart'; + + +class AppSliverHeader extends ConsumerWidget { + final String? title; + final Widget? titleWidget; + final Widget? leading; + final bool automaticallyImplyLeading; + final List actions; + final PreferredSizeWidget? bottom; + final double? expandedHeight; + final double? toolbarHeight; + + const AppSliverHeader({ + super.key, + this.title, + this.titleWidget, + this.leading, + this.automaticallyImplyLeading = true, + this.actions = const [], + this.bottom, + this.expandedHeight, + this.toolbarHeight, + }) : assert( + title != null || titleWidget != null, + 'AppSliverHeader requires either title or titleWidget', + ); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final mode = + ref.watch(appearanceProvider).value?.headerMode ?? HeaderMode.frosted; + final isFrosted = mode == HeaderMode.frosted; + final theme = Theme.of(context); + final tokens = context.appTokens; + final reserved = WindowChrome.reservedTrailingWidth(tokens); + final effectiveActions = [ + ...actions, + if (reserved > 0) SizedBox(width: reserved), + ]; + + return SliverAppBar( + pinned: true, + elevation: 0, + scrolledUnderElevation: 0, + toolbarHeight: toolbarHeight ?? kToolbarHeight, + leading: leading, + automaticallyImplyLeading: automaticallyImplyLeading, + title: + titleWidget ?? + (title != null + ? Text( + title!, + style: theme.textTheme.titleMedium, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ) + : null), + actions: effectiveActions, + bottom: bottom, + expandedHeight: expandedHeight, + backgroundColor: isFrosted + ? theme.colorScheme.surface.withValues(alpha: 0.7) + : theme.colorScheme.surface, + shape: isFrosted + ? Border( + bottom: BorderSide( + color: theme.colorScheme.onSurface.withValues(alpha: 0.08), + width: 1, + ), + ) + : null, + flexibleSpace: isFrosted + ? ClipRect( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 18, sigmaY: 18), + child: const SizedBox.expand(), + ), + ) + : null, + ); + } +} diff --git a/lib/shared/widgets/app_snack_bar.dart b/lib/shared/widgets/app_snack_bar.dart new file mode 100644 index 0000000..50ed263 --- /dev/null +++ b/lib/shared/widgets/app_snack_bar.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; +import 'package:forui/forui.dart'; + +import 'app_inline_alert.dart'; + +enum AppSnackTone { neutral, error, success, warning } + +void showAppSnackBar( + BuildContext context, + String message, { + AppSnackTone tone = AppSnackTone.neutral, +}) { + final messenger = ScaffoldMessenger.maybeOf(context); + if (messenger == null) return; + + messenger + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text(message), + behavior: SnackBarBehavior.floating, + backgroundColor: _backgroundColor(tone), + ), + ); +} + +void showAppToast( + BuildContext context, + String message, { + AppAlertTone tone = AppAlertTone.info, +}) { + showFToast( + context: context, + title: Text(message), + icon: Icon(_toastIcon(tone), size: 16), + variant: tone == AppAlertTone.error + ? FToastVariant.destructive + : FToastVariant.primary, + ); +} + +Color? _backgroundColor(AppSnackTone tone) { + return switch (tone) { + AppSnackTone.neutral => null, + AppSnackTone.error => const Color(0xFFB42318), + AppSnackTone.success => const Color(0xFF107C41), + AppSnackTone.warning => const Color(0xFF9A6700), + }; +} + +IconData _toastIcon(AppAlertTone tone) { + return switch (tone) { + AppAlertTone.error => Icons.error_outline, + AppAlertTone.warning => Icons.warning_amber_rounded, + AppAlertTone.info => Icons.info_outline, + AppAlertTone.success => Icons.check_circle_outline, + }; +} diff --git a/lib/shared/widgets/auto_dismiss_menu.dart b/lib/shared/widgets/auto_dismiss_menu.dart new file mode 100644 index 0000000..5186312 --- /dev/null +++ b/lib/shared/widgets/auto_dismiss_menu.dart @@ -0,0 +1,45 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; +import 'package:forui/forui.dart'; + + +List autoDismissMenuBuilder( + BuildContext context, + FPopoverController controller, + List? menu, +) { + final groups = menu ?? const []; + if (groups.isEmpty) return groups; + return [ + _AutoDismissMenuGroup(controller: controller, children: groups), + ]; +} + +class _AutoDismissMenuGroup extends StatelessWidget with FItemGroupMixin { + final FPopoverController controller; + final List children; + + const _AutoDismissMenuGroup({ + required this.controller, + required this.children, + }); + + @override + Widget build(BuildContext context) { + final parent = FInheritedItemCallbacks.maybeOf(context); + return FInheritedItemCallbacks( + onHoverEnter: parent?.onHoverEnter, + onHoverExit: parent?.onHoverExit, + onPress: () { + parent?.onPress?.call(); + unawaited(controller.hide()); + }, + onLongPress: () { + parent?.onLongPress?.call(); + unawaited(controller.hide()); + }, + child: FItemGroup.merge(children: children), + ); + } +} diff --git a/lib/shared/widgets/banner_carousel_mixin.dart b/lib/shared/widgets/banner_carousel_mixin.dart new file mode 100644 index 0000000..dce1b84 --- /dev/null +++ b/lib/shared/widgets/banner_carousel_mixin.dart @@ -0,0 +1,209 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + + +mixin BannerCarouselMixin + on State, WidgetsBindingObserver { + final PageController pageController = PageController(); + Timer? _autoPlayTimer; + Timer? _resumeTimer; + int currentIndex = 0; + bool hovering = false; + bool _manualPaused = false; + bool _appPaused = false; + bool _autoPageChangeInProgress = false; + double? lastBannerWidth; + bool _tickerVisible = true; + + static const autoInterval = Duration(seconds: 5); + static const _manualPauseDuration = Duration(seconds: 8); + static const slideDuration = Duration(milliseconds: 450); + static const slideCurve = Curves.easeInOutCubic; + + int get itemCount; + bool get isActive; + void emitActiveItem(); + void precacheAdjacentSlides(); + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + emitActiveItem(); + syncAutoPlay(); + }); + } + + @override + void dispose() { + _autoPlayTimer?.cancel(); + _resumeTimer?.cancel(); + pageController.dispose(); + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final visible = TickerMode.of(context); + if (visible == _tickerVisible) return; + _tickerVisible = visible; + if (visible) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !_tickerVisible) return; + emitActiveItem(); + precacheAdjacentSlides(); + }); + syncAutoPlay(); + } else { + stopAutoPlay(); + _resumeTimer?.cancel(); + _manualPaused = false; + } + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + final paused = + state == AppLifecycleState.paused || + state == AppLifecycleState.inactive || + state == AppLifecycleState.hidden; + if (paused == _appPaused) return; + _appPaused = paused; + paused ? stopAutoPlay() : syncAutoPlay(); + } + + bool get shouldAutoPlay => + mounted && + isActive && + _tickerVisible && + !_appPaused && + !hovering && + !_manualPaused && + itemCount > 1; + + void syncAutoPlay() { + if (!shouldAutoPlay) { + stopAutoPlay(); + return; + } + _autoPlayTimer ??= Timer.periodic( + autoInterval, + (_) => nextPage(manual: false), + ); + } + + void stopAutoPlay() { + _autoPlayTimer?.cancel(); + _autoPlayTimer = null; + } + + + void setHovering(bool value) { + hovering = value; + value ? stopAutoPlay() : syncAutoPlay(); + if (mounted) setState(() {}); + } + + void pauseForManual() { + if (!_manualPaused) setState(() => _manualPaused = true); + stopAutoPlay(); + _resumeTimer?.cancel(); + _resumeTimer = Timer(_manualPauseDuration, () { + if (!mounted) return; + setState(() => _manualPaused = false); + syncAutoPlay(); + }); + } + + void onPageChanged(int index) { + setState(() => currentIndex = index); + emitActiveItem(); + precacheAdjacentSlides(); + if (_autoPageChangeInProgress) { + _autoPageChangeInProgress = false; + return; + } + pauseForManual(); + } + + void goToPage(int index) { + if (index < 0 || index >= itemCount) return; + if (!pageController.hasClients) return; + pageController.animateToPage( + index, + duration: slideDuration, + curve: slideCurve, + ); + pauseForManual(); + } + + void nextPage({bool manual = true}) { + if (itemCount <= 1) return; + if (!pageController.hasClients) return; + final nextIndex = (currentIndex + 1) % itemCount; + if (!manual) _autoPageChangeInProgress = true; + pageController.animateToPage( + nextIndex, + duration: slideDuration, + curve: slideCurve, + ); + if (manual) pauseForManual(); + } + + void prevPage() { + if (itemCount <= 1) return; + if (!pageController.hasClients) return; + final previousIndex = (currentIndex - 1 + itemCount) % itemCount; + pageController.animateToPage( + previousIndex, + duration: slideDuration, + curve: slideCurve, + ); + pauseForManual(); + } + + int? bannerCacheWidth() { + final width = lastBannerWidth; + if (width == null || !width.isFinite || width <= 0) return null; + return (width * MediaQuery.devicePixelRatioOf(context)).ceil(); + } + + + void resetCarouselItems() { + stopAutoPlay(); + _resumeTimer?.cancel(); + _manualPaused = false; + if (itemCount > 0 && pageController.hasClients) { + pageController.jumpToPage(0); + } + currentIndex = 0; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + emitActiveItem(); + precacheAdjacentSlides(); + syncAutoPlay(); + }); + } + + + void activateCarousel() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + emitActiveItem(); + precacheAdjacentSlides(); + syncAutoPlay(); + }); + } + + + void deactivateCarousel() { + stopAutoPlay(); + _resumeTimer?.cancel(); + _manualPaused = false; + } +} diff --git a/lib/shared/widgets/banner_carousel_scaffold.dart b/lib/shared/widgets/banner_carousel_scaffold.dart new file mode 100644 index 0000000..9fe8994 --- /dev/null +++ b/lib/shared/widgets/banner_carousel_scaffold.dart @@ -0,0 +1,601 @@ +import 'dart:ui'; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; + +import '../constants/breakpoints.dart'; +import '../theme/app_theme.dart'; +import 'banner_carousel_mixin.dart'; +import 'circle_nav_icon_button.dart'; +import 'frosted_panel.dart'; +import 'smart_image.dart'; + + +class BannerSlideData { + final String? imageUrl; + final String? fallbackImageUrl; + final Map? imageHeaders; + + + final String? logoUrl; + final String title; + final String? overview; + + + final String? rating; + + + final List primaryMetaLabels; + + + final List secondaryMetaLabels; + + const BannerSlideData({ + required this.imageUrl, + required this.title, + this.fallbackImageUrl, + this.imageHeaders, + this.logoUrl, + this.overview, + this.rating, + this.primaryMetaLabels = const [], + this.secondaryMetaLabels = const [], + }); +} + + +class BannerCarouselScaffold extends StatelessWidget { + final BannerCarouselMixin controller; + final List slides; + final ValueChanged onSlideTap; + + + final bool reserveLogoArea; + + const BannerCarouselScaffold({ + super.key, + required this.controller, + required this.slides, + required this.onSlideTap, + this.reserveLogoArea = false, + }); + + @override + Widget build(BuildContext context) { + if (slides.isEmpty) return const SizedBox.shrink(); + + final compact = Breakpoints.isCompact(MediaQuery.sizeOf(context).width); + if (compact) return _buildCompact(context); + return _buildDesktop(context); + } + + + Widget _contentSwitcher({required bool compact}) { + return IgnorePointer( + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeOut, + layoutBuilder: (currentChild, previousChildren) => Stack( + fit: StackFit.expand, + children: [ + ...previousChildren, + if (currentChild != null) currentChild, + ], + ), + child: _BannerSlideContent( + key: ValueKey(controller.currentIndex), + data: slides[controller.currentIndex], + compact: compact, + reserveLogoArea: reserveLogoArea, + ), + ), + ); + } + + Widget _pageIndicator() { + return FrostedPanel( + radius: 999, + enableBlur: false, + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + color: Colors.black.withValues(alpha: 0.24), + border: Border.all(color: Colors.white.withValues(alpha: 0.12)), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (var index = 0; index < slides.length; index++) + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => controller.goToPage(index), + child: AnimatedContainer( + duration: const Duration(milliseconds: 240), + curve: Curves.easeOut, + width: controller.currentIndex == index ? 22 : 7, + height: 7, + margin: EdgeInsets.only( + right: index == slides.length - 1 ? 0 : 6, + ), + decoration: BoxDecoration( + color: controller.currentIndex == index + ? Colors.white + : const Color(0x66FFFFFF), + borderRadius: BorderRadius.circular(999), + ), + ), + ), + ], + ), + ); + } + + Widget _pageView({required bool compact}) { + return PageView.builder( + controller: controller.pageController, + physics: compact ? null : const NeverScrollableScrollPhysics(), + itemCount: slides.length, + onPageChanged: controller.onPageChanged, + itemBuilder: (context, index) { + return _BannerSlide(data: slides[index], compact: compact); + }, + ); + } + + Widget _buildCompact(BuildContext context) { + final screenHeight = MediaQuery.sizeOf(context).height; + final height = screenHeight * 0.55; + final screenWidth = MediaQuery.sizeOf(context).width; + controller.lastBannerWidth = screenWidth.isFinite && screenWidth > 0 + ? screenWidth + : null; + + return SizedBox( + height: height, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => onSlideTap(controller.currentIndex), + child: Stack( + fit: StackFit.expand, + children: [ + _pageView(compact: true), + _contentSwitcher(compact: true), + Positioned( + left: 0, + right: 0, + bottom: 20, + child: Center(child: _pageIndicator()), + ), + ], + ), + ), + ); + } + + Widget _buildDesktop(BuildContext context) { + final tokens = context.appTokens; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: LayoutBuilder( + builder: (context, constraints) { + final width = constraints.maxWidth; + controller.lastBannerWidth = width.isFinite && width > 0 + ? width + : null; + final height = (width * 7 / 16).clamp(260.0, 500.0); + return SizedBox( + height: height, + child: ClipRRect( + borderRadius: BorderRadius.circular(tokens.panelRadius + 6), + child: MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => controller.setHovering(true), + onExit: (_) => controller.setHovering(false), + child: Stack( + fit: StackFit.expand, + children: [ + _BlurredBackdropLayer( + data: slides[controller.currentIndex], + animateSwitch: slides.length > 1, + ), + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => onSlideTap(controller.currentIndex), + child: _pageView(compact: false), + ), + _contentSwitcher(compact: false), + Positioned(right: 22, bottom: 20, child: _pageIndicator()), + if (controller.hovering && slides.length > 1) ...[ + Positioned( + left: 14, + top: 0, + bottom: 0, + child: Center( + child: CircleNavIconButton( + icon: Icons.chevron_left_rounded, + onPressed: controller.prevPage, + iconSize: 28, + ), + ), + ), + Positioned( + right: 14, + top: 0, + bottom: 0, + child: Center( + child: CircleNavIconButton( + icon: Icons.chevron_right_rounded, + onPressed: controller.nextPage, + iconSize: 28, + ), + ), + ), + ], + ], + ), + ), + ), + ); + }, + ), + ); + } +} + + +class _BlurredBackdropLayer extends StatelessWidget { + final BannerSlideData data; + final bool animateSwitch; + + const _BlurredBackdropLayer({ + required this.data, + required this.animateSwitch, + }); + + @override + Widget build(BuildContext context) { + final image = _BlurredBackdropImage( + key: ValueKey(data.imageUrl ?? '__none__'), + data: data, + ); + + if (!animateSwitch) return image; + + return AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeOut, + layoutBuilder: (currentChild, previousChildren) { + return Stack( + fit: StackFit.expand, + children: [ + ...previousChildren, + if (currentChild != null) currentChild, + ], + ); + }, + child: image, + ); + } +} + +class _BlurredBackdropImage extends StatelessWidget { + final BannerSlideData data; + + const _BlurredBackdropImage({super.key, required this.data}); + + @override + Widget build(BuildContext context) { + return ClipRect( + child: Stack( + fit: StackFit.expand, + children: [ + ImageFiltered( + imageFilter: ImageFilter.blur( + sigmaX: 36, + sigmaY: 36, + tileMode: TileMode.clamp, + ), + child: SmartImage( + url: data.imageUrl, + fallbackUrls: [ + if (data.fallbackImageUrl != null) data.fallbackImageUrl!, + ], + httpHeaders: data.imageHeaders, + borderRadius: 0, + fit: BoxFit.cover, + memCacheWidth: 800, + fallbackIcon: null, + placeholder: const ColoredBox(color: Colors.black), + ), + ), + IgnorePointer( + child: ColoredBox(color: Colors.black.withValues(alpha: 0.30)), + ), + ], + ), + ); + } +} + + +class _BannerSlide extends StatelessWidget { + final BannerSlideData data; + final bool compact; + + const _BannerSlide({required this.data, this.compact = false}); + + @override + Widget build(BuildContext context) { + if (compact) { + return Stack( + fit: StackFit.expand, + children: [ + SmartImage( + url: data.imageUrl, + fallbackUrls: [ + if (data.fallbackImageUrl != null) data.fallbackImageUrl!, + ], + borderRadius: 0, + fit: BoxFit.cover, + httpHeaders: data.imageHeaders, + fallbackIcon: Icons.movie_outlined, + placeholder: const SizedBox.expand(), + ), + DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + stops: const [0.0, 0.12, 0.4, 0.75, 1.0], + colors: [ + Colors.black.withValues(alpha: 0.35), + Colors.black.withValues(alpha: 0.12), + Colors.black.withValues(alpha: 0.0), + Colors.black.withValues(alpha: 0.35), + Colors.black.withValues(alpha: 0.70), + ], + ), + ), + ), + ], + ); + } + + return Stack( + fit: StackFit.expand, + children: [ + ImageFiltered( + imageFilter: ImageFilter.blur( + sigmaX: 36, + sigmaY: 36, + tileMode: TileMode.mirror, + ), + child: SmartImage( + url: data.imageUrl, + fallbackUrls: [ + if (data.fallbackImageUrl != null) data.fallbackImageUrl!, + ], + borderRadius: 0, + fit: BoxFit.cover, + httpHeaders: data.imageHeaders, + fallbackIcon: null, + placeholder: const SizedBox.expand(), + ), + ), + DecoratedBox( + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.32), + ), + ), + SmartImage( + url: data.imageUrl, + fallbackUrls: [ + if (data.fallbackImageUrl != null) data.fallbackImageUrl!, + ], + borderRadius: 0, + fit: BoxFit.contain, + httpHeaders: data.imageHeaders, + fallbackIcon: Icons.movie_outlined, + placeholder: const SizedBox.expand(), + ), + DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + stops: const [0.0, 0.35, 0.6, 1.0], + colors: [ + Colors.black.withValues(alpha: 0.0), + Colors.black.withValues(alpha: 0.03), + Colors.black.withValues(alpha: 0.25), + Colors.black.withValues(alpha: 0.55), + ], + ), + ), + ), + ], + ); + } +} + + +class _BannerSlideContent extends StatelessWidget { + final BannerSlideData data; + final bool compact; + final bool reserveLogoArea; + + const _BannerSlideContent({ + super.key, + required this.data, + required this.compact, + required this.reserveLogoArea, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final tokens = context.appTokens; + final ratingColor = tokens.ratingColor; + + final hasMetaInfo = + data.rating != null || + data.primaryMetaLabels.isNotEmpty || + data.secondaryMetaLabels.isNotEmpty; + + final titleFontSize = compact ? 24.0 : 32.0; + final titleText = Text( + data.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleLarge?.copyWith( + color: Colors.white, + fontSize: titleFontSize, + height: 1.0, + ), + ); + + final padding = compact + ? const EdgeInsets.only(left: 24, bottom: 64, right: 24) + : const EdgeInsets.only(left: 32, bottom: 32); + + return Align( + alignment: Alignment.bottomLeft, + child: Padding( + padding: padding, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 480), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (reserveLogoArea) _buildLogoArea(titleText) else titleText, + if (hasMetaInfo) ...[ + const SizedBox(height: 10), + _MetaRow( + rating: data.rating, + ratingColor: ratingColor, + primaryLabels: data.primaryMetaLabels, + secondaryLabels: data.secondaryMetaLabels, + ), + ], + if ((data.overview ?? '').trim().isNotEmpty) ...[ + const SizedBox(height: 12), + Text( + data.overview!, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + color: Colors.white.withValues(alpha: 0.78), + height: 1.5, + ), + ), + ], + ], + ), + ), + ), + ); + } + + + Widget _buildLogoArea(Widget titleText) { + final logoHeight = compact ? 56.0 : 72.0; + final logoMaxWidth = compact ? 240.0 : 320.0; + + return ConstrainedBox( + constraints: BoxConstraints(maxWidth: logoMaxWidth), + child: SizedBox( + width: double.infinity, + height: logoHeight, + child: data.logoUrl != null + ? CachedNetworkImage( + imageUrl: data.logoUrl!, + httpHeaders: data.imageHeaders, + fit: BoxFit.contain, + alignment: Alignment.bottomLeft, + memCacheWidth: 960, + fadeInDuration: const Duration(milliseconds: 300), + placeholderFadeInDuration: Duration.zero, + fadeOutDuration: const Duration(milliseconds: 300), + placeholder: (_, _) => const SizedBox.shrink(), + errorWidget: (_, _, _) => + Align(alignment: Alignment.bottomLeft, child: titleText), + ) + : Align(alignment: Alignment.bottomLeft, child: titleText), + ), + ); + } +} + +class _MetaRow extends StatelessWidget { + final String? rating; + final Color ratingColor; + final List primaryLabels; + final List secondaryLabels; + + const _MetaRow({ + required this.rating, + required this.ratingColor, + required this.primaryLabels, + required this.secondaryLabels, + }); + + @override + Widget build(BuildContext context) { + final style = Theme.of(context).textTheme.bodySmall; + final children = []; + + void addSep() { + children.add( + Padding( + padding: const EdgeInsets.symmetric(horizontal: 6), + child: Text( + '·', + style: style?.copyWith(color: Colors.white.withValues(alpha: 0.38)), + ), + ), + ); + } + + if (rating != null) { + children.add( + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.star_rounded, size: 13, color: ratingColor), + const SizedBox(width: 3), + Text( + rating!, + style: style?.copyWith( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ); + } + + for (final label in primaryLabels) { + if (children.isNotEmpty) addSep(); + children.add( + Text( + label, + style: style?.copyWith(color: Colors.white.withValues(alpha: 0.80)), + ), + ); + } + + for (final label in secondaryLabels) { + if (children.isNotEmpty) addSep(); + children.add( + Text( + label, + style: style?.copyWith(color: Colors.white.withValues(alpha: 0.62)), + ), + ); + } + + return Row(mainAxisSize: MainAxisSize.min, children: children); + } +} diff --git a/lib/shared/widgets/card_progress_bar.dart b/lib/shared/widgets/card_progress_bar.dart new file mode 100644 index 0000000..ad8e592 --- /dev/null +++ b/lib/shared/widgets/card_progress_bar.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; + +import '../theme/app_theme.dart'; + + +class CardProgressBar extends StatelessWidget { + final double progress; + final EdgeInsets padding; + + const CardProgressBar({ + super.key, + required this.progress, + this.padding = const EdgeInsets.only(left: 8, right: 8, bottom: 6), + }); + + @override + Widget build(BuildContext context) { + final tokens = context.appTokens; + return Positioned( + left: padding.left, + right: padding.right, + bottom: padding.bottom, + child: ClipRRect( + borderRadius: BorderRadius.circular(3), + child: LinearProgressIndicator( + value: progress, + minHeight: 3, + backgroundColor: Colors.black.withValues(alpha: 0.35), + valueColor: AlwaysStoppedAnimation(tokens.progressBarColor), + ), + ), + ); + } +} diff --git a/lib/shared/widgets/cast_scroller.dart b/lib/shared/widgets/cast_scroller.dart new file mode 100644 index 0000000..30064a6 --- /dev/null +++ b/lib/shared/widgets/cast_scroller.dart @@ -0,0 +1,128 @@ +import 'package:flutter/material.dart'; + +import '../constants/breakpoints.dart'; +import 'hover_scrollable_row.dart'; +import 'smart_image.dart'; + + +class CastEntry { + final String name; + + + final String? subtitle; + final String? imageUrl; + final Map? imageHeaders; + + + final VoidCallback? onTap; + + const CastEntry({ + required this.name, + this.subtitle, + this.imageUrl, + this.imageHeaders, + this.onTap, + }); +} + + +class CastScroller extends StatelessWidget { + final List entries; + final EdgeInsets padding; + + + final int maxCount; + + const CastScroller({ + super.key, + required this.entries, + this.padding = EdgeInsets.zero, + this.maxCount = 20, + }); + + @override + Widget build(BuildContext context) { + final count = entries.length.clamp(0, maxCount); + final compact = MediaQuery.sizeOf(context).width < Breakpoints.compact; + return SizedBox( + height: compact ? 146 : 164, + child: HoverScrollableRow( + builder: (_, controller) => ListView.separated( + controller: controller, + scrollDirection: Axis.horizontal, + padding: padding, + itemCount: count, + separatorBuilder: (_, _) => SizedBox(width: compact ? 12 : 16), + itemBuilder: (_, i) => _CastCard(entry: entries[i], compact: compact), + ), + ), + ); + } +} + +class _CastCard extends StatelessWidget { + final CastEntry entry; + final bool compact; + + const _CastCard({required this.entry, this.compact = false}); + + @override + Widget build(BuildContext context) { + final avatarSize = compact ? 60.0 : 72.0; + final card = SizedBox( + width: compact ? 74 : 84, + child: Column( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(avatarSize / 2), + child: SizedBox( + width: avatarSize, + height: avatarSize, + child: SmartImage( + url: entry.imageUrl, + httpHeaders: entry.imageHeaders, + fallbackIcon: Icons.person, + borderRadius: avatarSize / 2, + ), + ), + ), + SizedBox(height: compact ? 6 : 8), + Text( + entry.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: compact ? 11 : 12, + fontWeight: FontWeight.w500, + color: Colors.white, + ), + ), + if (entry.subtitle != null && entry.subtitle!.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + entry.subtitle!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: compact ? 10 : 11, + color: Colors.white.withValues(alpha: 0.6), + ), + ), + ], + ], + ), + ); + + if (entry.onTap == null) return card; + return MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: entry.onTap, + child: card, + ), + ); + } +} diff --git a/lib/shared/widgets/circle_nav_icon_button.dart b/lib/shared/widgets/circle_nav_icon_button.dart new file mode 100644 index 0000000..77ae538 --- /dev/null +++ b/lib/shared/widgets/circle_nav_icon_button.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; + +import 'frosted_panel.dart'; + + +class CircleNavIconButton extends StatelessWidget { + final IconData icon; + + + final VoidCallback? onPressed; + final double iconSize; + + + final bool enabled; + + const CircleNavIconButton({ + super.key, + required this.icon, + required this.onPressed, + this.iconSize = 26, + this.enabled = true, + }); + + @override + Widget build(BuildContext context) { + return Opacity( + opacity: enabled ? 1.0 : 0.35, + child: FrostedPanel( + radius: 999, + enableBlur: false, + padding: const EdgeInsets.all(8), + color: Colors.black.withValues(alpha: 0.26), + border: Border.all(color: Colors.white.withValues(alpha: 0.12)), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: onPressed, + customBorder: const CircleBorder(), + child: Icon(icon, color: Colors.white, size: iconSize), + ), + ), + ), + ); + } +} diff --git a/lib/shared/widgets/connection_form.dart b/lib/shared/widgets/connection_form.dart new file mode 100644 index 0000000..8785df8 --- /dev/null +++ b/lib/shared/widgets/connection_form.dart @@ -0,0 +1,504 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:forui/forui.dart'; + +import '../constants/breakpoints.dart'; +import 'icon_picker_dialog.dart'; +import 'app_inline_alert.dart'; +import 'app_loading_ring.dart'; +import 'server_avatar.dart'; +import 'setting_select.dart'; + +class ConnectionForm extends StatefulWidget { + final String mode; + final String? initialUrl; + final String? initialUsername; + final String? initialPassword; + final String? initialDisplayName; + final String? initialIconUrl; + final void Function( + String url, + String username, + String password, + String? displayName, + String iconUrl, + ) + onConnect; + final VoidCallback? onCancel; + final bool loading; + final String? error; + + const ConnectionForm({ + super.key, + this.mode = 'add', + this.initialUrl, + this.initialUsername, + this.initialPassword, + this.initialDisplayName, + this.initialIconUrl, + required this.onConnect, + this.onCancel, + this.loading = false, + this.error, + }); + + @override + State createState() => _ConnectionFormState(); +} + +class _ConnectionFormState extends State { + final _formKey = GlobalKey(); + late String _protocol; + late final TextEditingController _host; + late final TextEditingController _port; + late final TextEditingController _displayName; + late final TextEditingController _username; + late final TextEditingController _password; + bool _obscurePassword = true; + late String _iconUrl; + + @override + void initState() { + super.initState(); + final parsed = _parseUrl(widget.initialUrl ?? ''); + _protocol = parsed['protocol'] ?? 'https'; + _host = TextEditingController(text: parsed['host'] ?? ''); + _port = TextEditingController(text: parsed['port'] ?? ''); + _displayName = TextEditingController(text: widget.initialDisplayName ?? ''); + _username = TextEditingController(text: widget.initialUsername ?? ''); + _password = TextEditingController(text: widget.initialPassword ?? ''); + _iconUrl = widget.initialIconUrl ?? ''; + } + + @override + void dispose() { + _host.dispose(); + _port.dispose(); + _displayName.dispose(); + _username.dispose(); + _password.dispose(); + super.dispose(); + } + + static Map _parseUrl(String input) { + final trimmed = input.trim(); + if (trimmed.isEmpty) return {'protocol': 'https', 'host': '', 'port': null}; + try { + final toParse = trimmed.contains('://') ? trimmed : 'https://$trimmed'; + final uri = Uri.parse(toParse); + final scheme = (uri.scheme == 'http' || uri.scheme == 'https') + ? uri.scheme + : 'https'; + return { + 'protocol': scheme, + 'host': uri.host.isEmpty ? trimmed : uri.host, + 'port': uri.hasPort ? uri.port.toString() : null, + }; + } catch (_) { + return {'protocol': 'https', 'host': trimmed, 'port': null}; + } + } + + void _syncFromUrl(String value) { + if (!value.contains('://')) return; + final parsed = _parseUrl(value); + final host = parsed['host'] ?? ''; + if (host.isEmpty || host == value.trim()) return; + + setState(() { + if (parsed['protocol'] != null) _protocol = parsed['protocol']!; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _host.text = host; + _host.selection = TextSelection.fromPosition( + TextPosition(offset: host.length), + ); + _port.text = parsed['port'] ?? ''; + }); + } + + String _buildUrl() { + final host = _host.text.trim(); + final port = _port.text.trim(); + return port.isNotEmpty ? '$_protocol://$host:$port' : '$_protocol://$host'; + } + + void _submit() { + if (!_formKey.currentState!.validate()) return; + final url = _buildUrl(); + final displayName = _displayName.text.trim(); + widget.onConnect( + url, + _username.text.trim(), + _password.text, + displayName.isEmpty ? null : displayName, + _iconUrl, + ); + } + + Future _pickIcon() async { + if (widget.loading) return; + final result = await showIconPickerDialog( + context, + currentIconUrl: _iconUrl, + ); + if (result == null) return; + if (!mounted) return; + setState(() => _iconUrl = result); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isEdit = widget.mode == 'edit'; + final isCompact = Breakpoints.isCompact(MediaQuery.sizeOf(context).width); + final protocolTextStyle = theme.textTheme.bodyLarge?.copyWith( + color: theme.colorScheme.onSurface, + fontWeight: FontWeight.w600, + ); + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(isEdit ? '编辑服务器' : '添加服务器', style: theme.textTheme.titleLarge), + const SizedBox(height: 4), + Text( + isEdit ? '修改 Emby 服务器信息并重新登录' : '输入 Emby 服务器信息以连接', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 20), + Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (isCompact) + _buildCompactEndpointFields(protocolTextStyle) + else + _buildDesktopEndpointFields(protocolTextStyle), + const SizedBox(height: 10), + _DisplayNameWithAvatar( + iconUrl: _iconUrl, + serverName: _displayName.text.trim().isNotEmpty + ? _displayName.text.trim() + : (_host.text.trim().isNotEmpty ? _host.text.trim() : 'S'), + controller: _displayName, + enabled: !widget.loading, + onPick: _pickIcon, + ), + const SizedBox(height: 10), + TextFormField( + controller: _username, + decoration: const InputDecoration(labelText: '用户名'), + enabled: !widget.loading, + autofillHints: const [AutofillHints.username], + textInputAction: TextInputAction.next, + autocorrect: false, + enableSuggestions: false, + validator: (v) => + (v == null || v.trim().isEmpty) ? '用户名不能为空' : null, + ), + const SizedBox(height: 10), + TextFormField( + controller: _password, + decoration: InputDecoration( + labelText: '密码', + suffixIcon: IconButton( + icon: Icon( + _obscurePassword + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + ), + onPressed: widget.loading + ? null + : () => setState( + () => _obscurePassword = !_obscurePassword, + ), + ), + ), + enabled: !widget.loading, + obscureText: _obscurePassword, + autofillHints: const [AutofillHints.password], + textInputAction: TextInputAction.done, + autocorrect: false, + enableSuggestions: false, + onFieldSubmitted: (_) { + if (!widget.loading) _submit(); + }, + ), + if (widget.error != null) ...[ + const SizedBox(height: 12), + AppInlineAlert(message: widget.error!), + ], + const SizedBox(height: 20), + _buildActions(isCompact), + ], + ), + ), + ], + ); + } + + Widget _buildDesktopEndpointFields(TextStyle? protocolTextStyle) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 124, + child: _ProtocolSelect( + value: _protocol, + enabled: !widget.loading, + onChanged: (v) { + if (v != null) setState(() => _protocol = v); + }, + ), + ), + const SizedBox(width: 8), + Expanded(child: _buildHostField()), + const SizedBox(width: 8), + SizedBox(width: 80, child: _buildPortField()), + ], + ); + } + + Widget _buildCompactEndpointFields(TextStyle? protocolTextStyle) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildHostField(), + const SizedBox(height: 10), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 5, + child: _ProtocolSelect( + value: _protocol, + enabled: !widget.loading, + onChanged: (v) { + if (v != null) setState(() => _protocol = v); + }, + ), + ), + const SizedBox(width: 12), + Expanded(flex: 6, child: _buildPortField()), + ], + ), + ], + ); + } + + Widget _buildHostField() { + return Focus( + onFocusChange: (hasFocus) { + if (!hasFocus) _syncFromUrl(_host.text); + }, + child: TextFormField( + controller: _host, + decoration: const InputDecoration( + labelText: '服务器地址', + hintText: 'example.com', + ), + enabled: !widget.loading, + keyboardType: TextInputType.url, + textInputAction: TextInputAction.next, + autocorrect: false, + enableSuggestions: false, + onChanged: _syncFromUrl, + validator: (v) => (v == null || v.trim().isEmpty) ? '服务器地址不能为空' : null, + ), + ); + } + + Widget _buildPortField() { + return TextFormField( + controller: _port, + decoration: const InputDecoration(labelText: '端口', hintText: '443'), + enabled: !widget.loading, + keyboardType: TextInputType.number, + textInputAction: TextInputAction.next, + autocorrect: false, + enableSuggestions: false, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + validator: (v) { + if (v == null || v.trim().isEmpty) return null; + final n = int.tryParse(v.trim()); + if (n == null || n < 1 || n > 65535) return '1-65535'; + return null; + }, + ); + } + + Widget _buildActions(bool isCompact) { + final saveButton = FButton( + variant: FButtonVariant.primary, + onPress: widget.loading ? null : _submit, + child: widget.loading + ? const AppLoadingRing(size: 16, color: Colors.white) + : const Text('保存'), + ); + + if (!isCompact) { + return Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (widget.onCancel != null) + FButton( + variant: FButtonVariant.outline, + onPress: widget.loading ? null : widget.onCancel, + child: const Text('取消'), + ), + const SizedBox(width: 8), + saveButton, + ], + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox(height: 48, child: saveButton), + if (widget.onCancel != null) ...[ + const SizedBox(height: 8), + SizedBox( + height: 44, + child: FButton( + variant: FButtonVariant.outline, + onPress: widget.loading ? null : widget.onCancel, + child: const Text('取消'), + ), + ), + ], + ], + ); + } +} + +class _ProtocolSelect extends StatelessWidget { + final String value; + final bool enabled; + final ValueChanged onChanged; + + const _ProtocolSelect({ + required this.value, + required this.enabled, + required this.onChanged, + }); + + static const _protocols = ['https', 'http']; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '协议', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 6), + SettingSelect( + value: value, + enabled: enabled, + options: _protocols, + labelOf: (value) => value.toUpperCase(), + onChanged: onChanged, + ), + ], + ); + } +} + + +class _DisplayNameWithAvatar extends StatelessWidget { + final String iconUrl; + final String serverName; + final TextEditingController controller; + final bool enabled; + final VoidCallback onPick; + + const _DisplayNameWithAvatar({ + required this.iconUrl, + required this.serverName, + required this.controller, + required this.enabled, + required this.onPick, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Tooltip( + message: '点击更换图标', + child: InkWell( + onTap: enabled ? onPick : null, + borderRadius: BorderRadius.circular(10), + child: SizedBox( + width: 62, + height: 62, + child: Stack( + children: [ + Positioned( + left: 0, + top: 0, + child: ServerAvatar( + name: serverName, + iconUrl: iconUrl, + size: 56, + ), + ), + Positioned( + right: 0, + bottom: 0, + child: Container( + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + color: theme.colorScheme.surface, + shape: BoxShape.circle, + border: Border.all( + color: theme.dividerColor, + width: 0.5, + ), + ), + child: Icon( + Icons.edit_outlined, + size: 11, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: TextFormField( + controller: controller, + decoration: const InputDecoration( + labelText: '展示名称', + hintText: '留空则使用服务器名称', + ), + enabled: enabled, + textInputAction: TextInputAction.next, + autocorrect: false, + enableSuggestions: false, + ), + ), + ], + ); + } +} diff --git a/lib/shared/widgets/empty_state.dart b/lib/shared/widgets/empty_state.dart new file mode 100644 index 0000000..7212b7a --- /dev/null +++ b/lib/shared/widgets/empty_state.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; + +class EmptyState extends StatelessWidget { + final IconData icon; + final String title; + final String? message; + final Widget? action; + final Color? foregroundColor; + + + final bool compact; + + const EmptyState({ + super.key, + this.icon = Icons.inbox_outlined, + required this.title, + this.message, + this.action, + this.foregroundColor, + this.compact = false, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final iconSize = compact ? 28.0 : 56.0; + final pad = compact ? 16.0 : 32.0; + final iconGap = compact ? 8.0 : 18.0; + final messageGap = compact ? 8.0 : 10.0; + final actionGap = compact ? 8.0 : 20.0; + final iconAlpha = compact ? 0.5 : 0.4; + final titleStyle = compact + ? theme.textTheme.bodyMedium + : theme.textTheme.titleMedium; + final messageText = message == null + ? null + : Text( + message!, + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + color: + foregroundColor?.withValues(alpha: 0.72) ?? + theme.colorScheme.onSurfaceVariant, + ), + ); + return Center( + child: Padding( + padding: EdgeInsets.all(pad), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + size: iconSize, + color: (foregroundColor ?? theme.colorScheme.onSurfaceVariant) + .withValues(alpha: iconAlpha), + ), + SizedBox(height: iconGap), + Text(title, style: titleStyle?.copyWith(color: foregroundColor)), + if (messageText != null) ...[ + SizedBox(height: messageGap), + compact + ? messageText + : ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 280), + child: messageText, + ), + ], + if (action != null) ...[SizedBox(height: actionGap), action!], + ], + ), + ), + ); + } +} diff --git a/lib/shared/widgets/episode_thumb.dart b/lib/shared/widgets/episode_thumb.dart new file mode 100644 index 0000000..f92bd9d --- /dev/null +++ b/lib/shared/widgets/episode_thumb.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; + +import '../../core/contracts/library.dart'; +import '../mappers/media_image_url.dart'; +import '../mappers/tmdb_image_selector.dart'; +import 'smart_image.dart'; + + +class EpisodeThumb extends StatelessWidget { + final EmbyRawItem episode; + final String seriesId; + final String baseUrl; + final String token; + final String? tmdbStillUrl; + final double? aspectRatio; + final double borderRadius; + final int maxWidth; + final IconData? fallbackIcon; + final BoxFit fit; + + const EpisodeThumb({ + super.key, + required this.episode, + required this.seriesId, + required this.baseUrl, + required this.token, + this.tmdbStillUrl, + this.aspectRatio, + this.borderRadius = 0, + this.maxWidth = 480, + this.fallbackIcon = Icons.movie, + this.fit = BoxFit.cover, + }); + + @override + Widget build(BuildContext context) { + final embyThumbUrl = EmbyImageUrl.thumb( + baseUrl: baseUrl, + token: token, + item: episode, + maxWidth: maxWidth, + ); + final primaryUrl = tmdbStillUrl ?? embyThumbUrl; + final fallbackUrls = TmdbImageSelector.buildFallbackUrls( + primaryUrl: primaryUrl, + candidates: [ + embyThumbUrl, + EmbyImageUrl.backdrop( + baseUrl: baseUrl, + token: token, + item: episode, + maxWidth: maxWidth, + ), + EmbyImageUrl.fanart(baseUrl: baseUrl, token: token, itemId: seriesId), + EmbyImageUrl.primaryById( + baseUrl: baseUrl, + token: token, + itemId: seriesId, + maxHeight: maxWidth, + ), + ], + ); + return SmartImage( + url: primaryUrl, + fallbackUrls: fallbackUrls, + aspectRatio: aspectRatio, + borderRadius: borderRadius, + fallbackIcon: fallbackIcon, + fit: fit, + ); + } +} diff --git a/lib/shared/widgets/error_media_row.dart b/lib/shared/widgets/error_media_row.dart new file mode 100644 index 0000000..71937f2 --- /dev/null +++ b/lib/shared/widgets/error_media_row.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; +import 'package:forui/forui.dart'; + +import 'app_error_state.dart'; +import 'media_row_metrics.dart'; +import 'media_poster_card.dart'; + +class ErrorMediaRow extends StatelessWidget { + final String title; + final String message; + final VoidCallback onRetry; + final MediaCardVariant cardVariant; + final Color? titleColor; + + const ErrorMediaRow({ + super.key, + required this.title, + required this.onRetry, + this.message = '加载失败', + this.cardVariant = MediaCardVariant.poster, + this.titleColor, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final compact = MediaRowMetrics.isCompact(context); + final rowHeight = MediaRowMetrics.rowHeight(cardVariant, compact: compact); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 12), + child: Text( + title, + style: theme.textTheme.titleMedium?.copyWith(color: titleColor), + ), + ), + SizedBox( + height: rowHeight, + child: AppErrorState( + message: message, + compact: true, + action: FButton( + variant: FButtonVariant.outline, + onPress: onRetry, + child: const Text('重试'), + ), + ), + ), + ], + ); + } +} diff --git a/lib/shared/widgets/frosted_panel.dart b/lib/shared/widgets/frosted_panel.dart new file mode 100644 index 0000000..43a518d --- /dev/null +++ b/lib/shared/widgets/frosted_panel.dart @@ -0,0 +1,67 @@ +import 'dart:ui' show ImageFilter; + +import 'package:flutter/material.dart'; + +class FrostedPanel extends StatelessWidget { + final Widget child; + final double radius; + final double blurSigma; + final bool enableBlur; + final EdgeInsetsGeometry padding; + final EdgeInsetsGeometry? margin; + final Color color; + final Gradient? gradient; + final BoxBorder? border; + final List boxShadow; + + const FrostedPanel({ + super.key, + required this.child, + this.radius = 24, + this.blurSigma = 18, + this.enableBlur = true, + this.padding = EdgeInsets.zero, + this.margin, + this.color = Colors.black26, + this.gradient, + this.border, + this.boxShadow = const [], + }); + + @override + Widget build(BuildContext context) { + final borderRadius = BorderRadius.circular(radius); + + final content = DecoratedBox( + decoration: BoxDecoration( + color: gradient == null ? color : null, + gradient: gradient, + borderRadius: borderRadius, + border: border, + ), + child: Padding(padding: padding, child: child), + ); + + return Padding( + padding: margin ?? EdgeInsets.zero, + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: borderRadius, + boxShadow: boxShadow, + ), + child: ClipRRect( + borderRadius: borderRadius, + child: enableBlur + ? BackdropFilter( + filter: ImageFilter.blur( + sigmaX: blurSigma, + sigmaY: blurSigma, + ), + child: content, + ) + : content, + ), + ), + ); + } +} diff --git a/lib/shared/widgets/hover_scrollable_row.dart b/lib/shared/widgets/hover_scrollable_row.dart new file mode 100644 index 0000000..01906b7 --- /dev/null +++ b/lib/shared/widgets/hover_scrollable_row.dart @@ -0,0 +1,191 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; + +import 'circle_nav_icon_button.dart'; + +typedef HoverScrollableRowBuilder = + Widget Function(BuildContext context, ScrollController controller); + + +class _MouseDragScrollBehavior extends MaterialScrollBehavior { + const _MouseDragScrollBehavior(); + + @override + Set get dragDevices => const { + PointerDeviceKind.touch, + PointerDeviceKind.mouse, + PointerDeviceKind.trackpad, + PointerDeviceKind.stylus, + }; +} + +class HoverScrollableRow extends StatefulWidget { + final HoverScrollableRowBuilder builder; + final ScrollController? controller; + final double scrollFraction; + final double buttonInset; + final double iconSize; + + const HoverScrollableRow({ + super.key, + required this.builder, + this.controller, + this.scrollFraction = 0.6, + this.buttonInset = 10, + this.iconSize = 26, + }); + + @override + State createState() => _HoverScrollableRowState(); +} + +class _HoverScrollableRowState extends State { + late ScrollController _controller; + bool _ownsController = false; + bool _hovered = false; + bool _canScrollLeft = false; + bool _canScrollRight = false; + + @override + void initState() { + super.initState(); + _attachController(widget.controller); + WidgetsBinding.instance.addPostFrameCallback((_) => _updateScrollState()); + } + + @override + void didUpdateWidget(covariant HoverScrollableRow oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + _detachController(); + _attachController(widget.controller); + WidgetsBinding.instance.addPostFrameCallback((_) => _updateScrollState()); + } + } + + void _attachController(ScrollController? external) { + if (external != null) { + _controller = external; + _ownsController = false; + } else { + _controller = ScrollController(); + _ownsController = true; + } + _controller.addListener(_updateScrollState); + } + + void _detachController() { + _controller.removeListener(_updateScrollState); + if (_ownsController) { + _controller.dispose(); + } + } + + @override + void dispose() { + _detachController(); + super.dispose(); + } + + void _updateScrollState() { + if (!mounted || !_controller.hasClients) return; + final maxScroll = _controller.position.maxScrollExtent; + final offset = _controller.offset; + final canLeft = offset > 1; + final canRight = maxScroll - offset > 1; + if (canLeft != _canScrollLeft || canRight != _canScrollRight) { + setState(() { + _canScrollLeft = canLeft; + _canScrollRight = canRight; + }); + } + } + + void _scrollByPage({required bool left}) { + if (!_controller.hasClients) return; + final viewport = _controller.position.viewportDimension; + final delta = viewport * widget.scrollFraction; + final target = (_controller.offset + (left ? -delta : delta)).clamp( + 0.0, + _controller.position.maxScrollExtent, + ); + _controller.animateTo( + target, + duration: const Duration(milliseconds: 320), + curve: Curves.easeOutCubic, + ); + } + + @override + Widget build(BuildContext context) { + final hasOverflow = _canScrollLeft || _canScrollRight; + final showPager = _hovered && hasOverflow; + + return MouseRegion( + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: NotificationListener( + onNotification: (_) { + WidgetsBinding.instance.addPostFrameCallback( + (_) => _updateScrollState(), + ); + return false; + }, + child: Stack( + children: [ + ScrollConfiguration( + behavior: const _MouseDragScrollBehavior(), + child: widget.builder(context, _controller), + ), + if (hasOverflow) + Positioned( + left: widget.buttonInset, + top: 0, + bottom: 0, + child: Center( + child: AnimatedOpacity( + opacity: showPager ? 1 : 0, + duration: const Duration(milliseconds: 140), + child: IgnorePointer( + ignoring: !showPager || !_canScrollLeft, + child: CircleNavIconButton( + icon: Icons.chevron_left_rounded, + iconSize: widget.iconSize, + enabled: _canScrollLeft, + onPressed: _canScrollLeft + ? () => _scrollByPage(left: true) + : null, + ), + ), + ), + ), + ), + if (hasOverflow) + Positioned( + right: widget.buttonInset, + top: 0, + bottom: 0, + child: Center( + child: AnimatedOpacity( + opacity: showPager ? 1 : 0, + duration: const Duration(milliseconds: 140), + child: IgnorePointer( + ignoring: !showPager || !_canScrollRight, + child: CircleNavIconButton( + icon: Icons.chevron_right_rounded, + iconSize: widget.iconSize, + enabled: _canScrollRight, + onPressed: _canScrollRight + ? () => _scrollByPage(left: false) + : null, + ), + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/shared/widgets/icon_picker_dialog.dart b/lib/shared/widgets/icon_picker_dialog.dart new file mode 100644 index 0000000..c1131df --- /dev/null +++ b/lib/shared/widgets/icon_picker_dialog.dart @@ -0,0 +1,338 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:forui/forui.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/contracts/icon_library.dart'; +import '../../features/settings/settings_page.dart'; +import '../../providers/icon_library_provider.dart'; +import 'adaptive_modal.dart'; +import 'app_error_state.dart'; +import 'app_loading_ring.dart'; +import 'app_skeleton.dart'; +import '../utils/user_error_formatter.dart'; + + +Future showIconPickerDialog( + BuildContext context, { + String currentIconUrl = '', +}) { + return showAdaptiveModal( + context: context, + maxWidth: 720, + maxHeight: 600, + compactHeightFactor: 0.88, + builder: (_) => _IconPickerDialog(currentIconUrl: currentIconUrl), + ); +} + +class _IconPickerDialog extends ConsumerStatefulWidget { + final String currentIconUrl; + const _IconPickerDialog({required this.currentIconUrl}); + + @override + ConsumerState<_IconPickerDialog> createState() => _IconPickerDialogState(); +} + +class _IconPickerDialogState extends ConsumerState<_IconPickerDialog> { + String _query = ''; + final _searchController = TextEditingController(); + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final state = ref.watch(iconLibraryProvider); + + return Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 12), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: Text( + '选择服务器图标', + style: theme.textTheme.titleMedium, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (widget.currentIconUrl.isNotEmpty) ...[ + const SizedBox(width: 8), + TextButton.icon( + style: TextButton.styleFrom( + minimumSize: const Size(0, 44), + padding: const EdgeInsets.symmetric(horizontal: 12), + ), + onPressed: () => Navigator.of(context).pop(''), + icon: const Icon(Icons.do_disturb_on_outlined, size: 18), + label: const Text('清除图标'), + ), + ], + IconButton( + tooltip: '关闭', + icon: const Icon(Icons.close, size: 20), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + const SizedBox(height: 8), + TextField( + controller: _searchController, + decoration: const InputDecoration( + prefixIcon: Icon(Icons.search, size: 18), + hintText: '按名称搜索图标', + isDense: true, + ), + textInputAction: TextInputAction.search, + autocorrect: false, + enableSuggestions: false, + onChanged: (v) => setState(() => _query = v.trim().toLowerCase()), + ), + const SizedBox(height: 8), + Expanded( + child: state.when( + data: (data) => _buildBody(theme, data), + loading: () => const Center(child: AppLoadingRing()), + error: (e, _) => AppErrorState( + message: formatUserError(e, fallback: '图标库加载失败'), + compact: true, + onRetry: () => + ref.read(iconLibraryProvider.notifier).refreshAll(), + ), + ), + ), + ], + ), + ); + } + + Widget _buildBody(ThemeData theme, IconLibrarySettingsState data) { + if (data.entries.isEmpty) { + return _EmptyState( + title: '尚未配置图标库', + message: '前往「设置 → 图标库」添加 JSON 来源 URL', + actionLabel: '前往设置', + onAction: () { + Navigator.of(context).pop(); + showSettingsDialog(context, initialTab: 'icon-library'); + }, + ); + } + + return FTabs( + scrollable: true, + expands: true, + contentPhysics: const NeverScrollableScrollPhysics(), + children: [ + for (final entry in data.entries) + FTabEntry( + label: _libraryLabel(entry), + child: _buildLibraryTab(entry), + ), + ], + ); + } + + Widget _libraryLabel(IconLibraryEntryState entry) { + final name = entry.library?.name; + final label = (name != null && name.isNotEmpty) + ? name + : Uri.tryParse(entry.url)?.host ?? entry.url; + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(label, maxLines: 1, overflow: TextOverflow.ellipsis), + if (entry.status == IconLibraryStatus.loading) ...[ + const SizedBox(width: 6), + const AppLoadingRing(size: 10), + ], + ], + ); + } + + Widget _buildLibraryTab(IconLibraryEntryState entry) { + final theme = Theme.of(context); + + if (entry.status == IconLibraryStatus.loading) { + return const Center(child: AppLoadingRing()); + } + + if (entry.status == IconLibraryStatus.error) { + return AppErrorState( + message: entry.error ?? '', + compact: true, + onRetry: () => + ref.read(iconLibraryProvider.notifier).refresh(entry.url), + ); + } + + final lib = entry.library; + if (lib == null) { + return const Center(child: AppLoadingRing()); + } + + final filtered = _query.isEmpty + ? lib.icons + : lib.icons + .where((i) => i.name.toLowerCase().contains(_query)) + .toList(); + + if (filtered.isEmpty) { + return Center( + child: Text( + _query.isNotEmpty ? '无匹配图标' : '图标库为空', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ); + } + + return GridView.builder( + padding: const EdgeInsets.symmetric(vertical: 4), + gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 76, + childAspectRatio: 0.85, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + ), + itemCount: filtered.length, + itemBuilder: (_, i) { + final icon = filtered[i]; + return _IconTile( + entry: icon, + selected: icon.url == widget.currentIconUrl, + onTap: () => Navigator.of(context).pop(icon.url), + ); + }, + ); + } +} + +class _IconTile extends StatelessWidget { + final IconEntry entry; + final bool selected; + final VoidCallback onTap; + + const _IconTile({ + required this.entry, + required this.selected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final accent = theme.colorScheme.primary; + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: Tooltip( + message: entry.name, + preferBelow: false, + child: Padding( + padding: const EdgeInsets.all(4), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Expanded( + child: Container( + decoration: BoxDecoration( + border: Border.all( + color: selected + ? accent + : theme.colorScheme.outlineVariant.withValues( + alpha: 0.3, + ), + width: selected ? 2 : 1, + ), + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.all(6), + child: CachedNetworkImage( + imageUrl: entry.url, + fit: BoxFit.contain, + memCacheWidth: 128, + placeholder: (_, _) => + const SizedBox.expand(child: AppSkeleton(radius: 6)), + errorWidget: (_, _, _) => Icon( + Icons.broken_image_outlined, + size: 18, + color: theme.colorScheme.onSurfaceVariant.withValues( + alpha: 0.4, + ), + ), + ), + ), + ), + const SizedBox(height: 4), + Text( + entry.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.labelSmall?.copyWith( + fontSize: 10, + color: selected ? accent : theme.colorScheme.onSurfaceVariant, + fontWeight: selected ? FontWeight.w600 : FontWeight.w400, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _EmptyState extends StatelessWidget { + final String title; + final String message; + final String actionLabel; + final VoidCallback onAction; + + const _EmptyState({ + required this.title, + required this.message, + required this.actionLabel, + required this.onAction, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.image_search_outlined, + size: 36, + color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.5), + ), + const SizedBox(height: 12), + Text(title, style: theme.textTheme.titleSmall), + const SizedBox(height: 6), + Text( + message, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + OutlinedButton(onPressed: onAction, child: Text(actionLabel)), + ], + ), + ); + } +} diff --git a/lib/shared/widgets/latest_media_row.dart b/lib/shared/widgets/latest_media_row.dart new file mode 100644 index 0000000..8fa1ee0 --- /dev/null +++ b/lib/shared/widgets/latest_media_row.dart @@ -0,0 +1,194 @@ +import 'package:flutter/material.dart'; + +import '../../core/contracts/library.dart'; +import '../theme/app_theme.dart'; +import 'hover_scrollable_row.dart'; +import 'media_row_metrics.dart'; +import 'media_poster_card.dart'; + +class LatestMediaRow extends StatefulWidget { + final String title; + final String? subtitle; + final List items; + final String? Function(EmbyRawItem) imageUrlBuilder; + final String? Function(EmbyRawItem)? preloadImageUrlBuilder; + final void Function(EmbyRawItem) onItemTap; + final VoidCallback? onSeeAll; + final int? totalCount; + final MediaCardVariant cardVariant; + final bool enableContextMenu; + final Map? favoriteMap; + final Map? playedMap; + final Map? favoriteLoadingMap; + final Map? playedLoadingMap; + final void Function(EmbyRawItem)? onAddFavorite; + final void Function(EmbyRawItem)? onMarkPlayed; + final void Function(EmbyRawItem)? onHideFromResume; + final Map? hideFromResumeLoadingMap; + final bool showUnplayedCountBadge; + final bool showHoverPlayIcon; + final String? activeItemId; + final String? fallbackImageUrl; + final Map? imageHeaders; + + + final Color? titleColor; + + const LatestMediaRow({ + super.key, + required this.title, + this.subtitle, + required this.items, + required this.imageUrlBuilder, + this.preloadImageUrlBuilder, + required this.onItemTap, + this.onSeeAll, + this.totalCount, + this.cardVariant = MediaCardVariant.poster, + this.enableContextMenu = false, + this.favoriteMap, + this.playedMap, + this.favoriteLoadingMap, + this.playedLoadingMap, + this.onAddFavorite, + this.onMarkPlayed, + this.onHideFromResume, + this.hideFromResumeLoadingMap, + this.showUnplayedCountBadge = true, + this.showHoverPlayIcon = true, + this.activeItemId, + this.fallbackImageUrl, + this.imageHeaders, + this.titleColor, + }); + + @override + State createState() => _LatestMediaRowState(); +} + +class _LatestMediaRowState extends State { + @override + Widget build(BuildContext context) { + final compact = MediaRowMetrics.isCompact(context); + final rowHeight = MediaRowMetrics.rowHeight( + widget.cardVariant, + compact: compact, + ); + final cardWidth = MediaRowMetrics.cardWidth( + widget.cardVariant, + compact: compact, + ); + final theme = Theme.of(context); + final tokens = context.appTokens; + final titleColor = widget.titleColor; + final mutedColor = titleColor != null + ? titleColor.withValues(alpha: 0.55) + : tokens.mutedForeground; + final seeAllColor = titleColor != null + ? titleColor.withValues(alpha: 0.78) + : theme.colorScheme.onSurface.withValues(alpha: 0.82); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(24, 12, 24, 10), + child: Row( + children: [ + Expanded( + child: Row( + children: [ + Flexible( + child: Text( + widget.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + color: titleColor, + ), + ), + ), + if (widget.subtitle != null) ...[ + const SizedBox(width: 8), + Text( + '· ${widget.subtitle}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: mutedColor, + ), + ), + ], + ], + ), + ), + if (widget.onSeeAll != null) + TextButton( + onPressed: widget.onSeeAll, + style: TextButton.styleFrom( + foregroundColor: seeAllColor, + padding: const EdgeInsets.symmetric(horizontal: 8), + visualDensity: VisualDensity.compact, + ), + child: Text( + widget.totalCount != null && widget.totalCount! > 0 + ? '查看全部 ${widget.totalCount}' + : '查看全部', + ), + ), + ], + ), + ), + SizedBox( + height: rowHeight, + child: HoverScrollableRow( + builder: (_, controller) => ListView.separated( + controller: controller, + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 24), + itemCount: widget.items.length, + separatorBuilder: (_, index) => const SizedBox(width: 14), + itemBuilder: (_, index) { + final item = widget.items[index]; + final isActive = widget.activeItemId == item.Id; + return MediaPosterCard( + item: item, + imageUrl: widget.imageUrlBuilder(item), + onTap: () => widget.onItemTap(item), + width: cardWidth, + variant: widget.cardVariant, + showHoverPlayIcon: widget.showHoverPlayIcon, + enableContextMenu: widget.enableContextMenu, + isFavoriteOverride: + widget.favoriteMap?[item.Id] ?? item.UserData?.IsFavorite, + isPlayedOverride: + widget.playedMap?[item.Id] ?? item.UserData?.Played, + favoriteLoading: widget.favoriteLoadingMap?[item.Id] ?? false, + playedLoading: widget.playedLoadingMap?[item.Id] ?? false, + onAddFavorite: widget.onAddFavorite != null + ? () => widget.onAddFavorite!(item) + : null, + onMarkPlayed: widget.onMarkPlayed != null + ? () => widget.onMarkPlayed!(item) + : null, + onHideFromResume: widget.onHideFromResume != null + ? () => widget.onHideFromResume!(item) + : null, + hideFromResumeLoading: + widget.hideFromResumeLoadingMap?[item.Id] ?? false, + showUnplayedCountBadge: widget.showUnplayedCountBadge, + isActive: isActive, + fallbackImageUrl: widget.fallbackImageUrl, + preloadImageUrl: widget.preloadImageUrlBuilder?.call(item), + imageHeaders: widget.imageHeaders, + textColor: widget.titleColor, + ); + }, + ), + ), + ), + ], + ); + } +} diff --git a/lib/shared/widgets/login_form.dart b/lib/shared/widgets/login_form.dart new file mode 100644 index 0000000..fbad1c9 --- /dev/null +++ b/lib/shared/widgets/login_form.dart @@ -0,0 +1,134 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:forui/forui.dart'; + +import '../../core/contracts/server.dart'; +import '../../providers/session_provider.dart'; +import 'app_loading_ring.dart'; +import 'app_inline_alert.dart'; + +class LoginForm extends ConsumerStatefulWidget { + final EmbyServer server; + final VoidCallback? onSuccess; + final VoidCallback? onBack; + + const LoginForm({ + super.key, + required this.server, + this.onSuccess, + this.onBack, + }); + + @override + ConsumerState createState() => _LoginFormState(); +} + +class _LoginFormState extends ConsumerState { + final _formKey = GlobalKey(); + final _username = TextEditingController(); + final _password = TextEditingController(); + bool _loading = false; + bool _obscure = true; + String? _error; + + @override + void dispose() { + _username.dispose(); + _password.dispose(); + super.dispose(); + } + + Future _submit() async { + if (!_formKey.currentState!.validate()) return; + setState(() { + _loading = true; + _error = null; + }); + try { + await ref + .read(sessionProvider.notifier) + .login( + serverId: widget.server.id, + username: _username.text.trim(), + password: _password.text, + ); + if (!mounted) return; + widget.onSuccess?.call(); + } catch (e) { + setState(() => _error = e.toString()); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('登录到 ${widget.server.name}', style: theme.textTheme.titleLarge), + const SizedBox(height: 4), + Text( + widget.server.baseUrl, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 20), + TextFormField( + controller: _username, + decoration: const InputDecoration(labelText: '用户名'), + autofillHints: const [AutofillHints.username], + validator: (v) => (v == null || v.trim().isEmpty) ? '请输入用户名' : null, + ), + const SizedBox(height: 12), + TextFormField( + controller: _password, + decoration: InputDecoration( + labelText: '密码', + suffixIcon: IconButton( + icon: Icon( + _obscure + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + ), + onPressed: () => setState(() => _obscure = !_obscure), + ), + ), + obscureText: _obscure, + autofillHints: const [AutofillHints.password], + onFieldSubmitted: (_) => _submit(), + ), + if (_error != null) ...[ + const SizedBox(height: 12), + AppInlineAlert(message: _error!), + ], + const SizedBox(height: 20), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (widget.onBack != null) + FButton( + variant: FButtonVariant.ghost, + onPress: _loading ? null : widget.onBack, + child: const Text('返回'), + ), + const SizedBox(width: 8), + FButton( + variant: FButtonVariant.primary, + onPress: _loading ? null : _submit, + child: _loading + ? const AppLoadingRing(size: 16, color: Colors.white) + : const Text('登录'), + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/shared/widgets/media_poster_card.dart b/lib/shared/widgets/media_poster_card.dart new file mode 100644 index 0000000..6c8fcb7 --- /dev/null +++ b/lib/shared/widgets/media_poster_card.dart @@ -0,0 +1,389 @@ +import 'dart:async'; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:forui/forui.dart'; + +import '../../core/contracts/library.dart'; +import '../theme/app_theme.dart'; +import '../utils/emby_ticks.dart'; +import '../utils/format_utils.dart'; +import 'auto_dismiss_menu.dart'; +import 'card_progress_bar.dart'; +import 'smart_image.dart'; + +enum MediaCardVariant { poster, landscape } + +class MediaPosterCard extends StatefulWidget { + final EmbyRawItem item; + final String? imageUrl; + final VoidCallback? onTap; + final double width; + final MediaCardVariant variant; + final bool showHoverPlayIcon; + final bool enableContextMenu; + final bool? isFavoriteOverride; + final bool? isPlayedOverride; + final VoidCallback? onAddFavorite; + final VoidCallback? onMarkPlayed; + final VoidCallback? onHideFromResume; + final bool favoriteLoading; + final bool playedLoading; + final bool hideFromResumeLoading; + final bool showUnplayedCountBadge; + final bool isActive; + final String? fallbackImageUrl; + final String? preloadImageUrl; + final Map? imageHeaders; + + + final Color? textColor; + + const MediaPosterCard({ + super.key, + required this.item, + required this.imageUrl, + this.onTap, + this.width = 160, + this.variant = MediaCardVariant.poster, + this.showHoverPlayIcon = true, + this.enableContextMenu = false, + this.isFavoriteOverride, + this.isPlayedOverride, + this.onAddFavorite, + this.onMarkPlayed, + this.onHideFromResume, + this.favoriteLoading = false, + this.playedLoading = false, + this.hideFromResumeLoading = false, + this.showUnplayedCountBadge = true, + this.isActive = false, + this.fallbackImageUrl, + this.preloadImageUrl, + this.imageHeaders, + this.textColor, + }); + + @override + State createState() => _MediaPosterCardState(); +} + +class _MediaPosterCardState extends State { + bool _hovered = false; + String? _preloadedImageUrl; + + bool get _isFavorite => + widget.isFavoriteOverride ?? (widget.item.UserData?.IsFavorite ?? false); + + bool get _isPlayed => + widget.isPlayedOverride ?? (widget.item.UserData?.Played ?? false); + + double get _aspectRatio => + widget.variant == MediaCardVariant.landscape ? 16 / 9 : 0.66; + + String? _subtitleText() { + final item = widget.item; + if (widget.variant == MediaCardVariant.landscape) { + if (item.Type == 'Episode' && item.IndexNumber != null) { + final season = _seasonNumber(item); + final epLabel = formatEpisodeNumber(item.IndexNumber)!; + final locator = season != null && season > 1 + ? '${formatSeasonNumber(season)} $epLabel' + : epLabel; + return [ + locator, + item.Name, + ].where((segment) => segment.isNotEmpty).join(' · '); + } + if (item.Type == 'Episode') { + final seasonLabel = _seasonLabel(item); + if (seasonLabel != null) { + return [ + seasonLabel, + item.Name, + ].where((segment) => segment.isNotEmpty).join(' · '); + } + } + if (item.ProductionYear != null) return '${item.ProductionYear}'; + return null; + } + if (item.Type == 'Episode' && item.SeriesName != null) { + final season = item.SeasonName ?? ''; + final episode = formatEpisodeNumber(item.IndexNumber) ?? ''; + return [ + item.SeriesName, + season, + episode, + ].where((segment) => segment != null && segment.isNotEmpty).join(' · '); + } + if (item.ProductionYear != null) return '${item.ProductionYear}'; + return null; + } + + + int? _seasonNumber(EmbyRawItem item) => + (item.extra['ParentIndexNumber'] as num?)?.toInt(); + + + String? _seasonLabel(EmbyRawItem item) { + final name = item.SeasonName; + if (name != null && name.isNotEmpty) return name; + final season = (item.extra['ParentIndexNumber'] as num?)?.toInt(); + if (season != null) return formatSeasonNumber(season); + return null; + } + + String get _displayTitle { + if (widget.variant == MediaCardVariant.landscape) { + return widget.item.SeriesName ?? widget.item.Name; + } + return widget.item.Name; + } + + List _contextMenuItems() => [ + if (widget.onAddFavorite != null) + FItem( + prefix: Icon( + _isFavorite ? Icons.favorite : Icons.favorite_border, + size: 16, + ), + enabled: !widget.favoriteLoading, + title: Text(_isFavorite ? '取消收藏' : '添加到收藏'), + onPress: () => widget.onAddFavorite?.call(), + ), + if (widget.onMarkPlayed != null) + FItem( + prefix: Icon( + _isPlayed ? Icons.check_circle : Icons.check_circle_outline, + size: 16, + ), + enabled: !widget.playedLoading, + title: Text(_isPlayed ? '取消已观看' : '标记为已观看'), + onPress: () => widget.onMarkPlayed?.call(), + ), + if (widget.onHideFromResume != null) + FItem( + prefix: const Icon(Icons.visibility_off_outlined, size: 16), + enabled: !widget.hideFromResumeLoading, + title: const Text('从“继续观看”中移除'), + onPress: () => widget.onHideFromResume?.call(), + ), + ]; + + void _preloadImage() { + final url = widget.preloadImageUrl; + if (url == null || url.isEmpty || _preloadedImageUrl == url) return; + _preloadedImageUrl = url; + var logicalWidth = widget.width; + if (!logicalWidth.isFinite || logicalWidth <= 0) { + logicalWidth = context.size?.width ?? 0; + } + final scaledWidth = logicalWidth * MediaQuery.devicePixelRatioOf(context); + final memCacheWidth = scaledWidth.isFinite && scaledWidth > 0 + ? scaledWidth.ceil() + : null; + unawaited( + precacheImage( + ResizeImage.resizeIfNeeded( + memCacheWidth, + null, + CachedNetworkImageProvider(url, headers: widget.imageHeaders), + ), + context, + onError: (_, _) {}, + ), + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final tokens = context.appTokens; + final progress = playbackProgress(widget.item); + final unplayed = widget.item.UserData?.UnplayedItemCount ?? 0; + final showUnplayed = widget.showUnplayedCountBadge && unplayed > 0; + final resolvedImageUrl = widget.imageUrl ?? widget.fallbackImageUrl; + final radius = tokens.posterRadius; + final remainingLabel = formatRemainingLabel(widget.item); + + final subtitleText = _subtitleText(); + + final menuItems = widget.enableContextMenu ? _contextMenuItems() : []; + final hasMenu = menuItems.isNotEmpty; + + Widget content = SizedBox( + width: widget.width, + child: MouseRegion( + onEnter: (_) { + _preloadImage(); + setState(() => _hovered = true); + }, + onExit: (_) => setState(() => _hovered = false), + child: GestureDetector( + onTap: widget.onTap == null + ? null + : () { + _preloadImage(); + widget.onTap!(); + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: AnimatedOpacity( + opacity: _hovered ? 0.85 : 1.0, + duration: const Duration(milliseconds: 160), + child: Stack( + children: [ + SmartImage( + url: resolvedImageUrl, + aspectRatio: _aspectRatio, + borderRadius: radius, + httpHeaders: widget.imageHeaders, + ), + if (widget.showHoverPlayIcon) + Positioned.fill( + child: AnimatedOpacity( + opacity: _hovered ? 1.0 : 0.0, + duration: const Duration(milliseconds: 160), + child: ClipRRect( + borderRadius: BorderRadius.circular(radius), + child: Container( + color: Colors.black.withValues(alpha: 0.3), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.play_arrow_rounded, + size: 32, + color: Colors.white, + ), + if (remainingLabel != null) ...[ + const SizedBox(height: 4), + Text( + remainingLabel, + style: const TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.w500, + ), + ), + ], + ], + ), + ), + ), + ), + ), + ), + if (progress > 0) CardProgressBar(progress: progress), + if (_isPlayed) + Positioned( + top: 6, + left: 6, + child: Icon( + Icons.check_circle, + size: 16, + color: Colors.white.withValues(alpha: 0.9), + ), + ), + if (_isFavorite) + Positioned( + top: 6, + right: showUnplayed ? 32 : 6, + child: Icon( + Icons.favorite, + size: 14, + color: Colors.white.withValues(alpha: 0.9), + ), + ), + if (showUnplayed) + Positioned( + top: 6, + right: 6, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 5, + vertical: 2, + ), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(2), + ), + child: Text( + '$unplayed', + style: const TextStyle( + color: Colors.black, + fontSize: 10, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + if (widget.isActive) + Positioned( + left: 0, + right: 0, + bottom: 0, + child: Container( + height: 2, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.only( + bottomLeft: Radius.circular(radius), + bottomRight: Radius.circular(radius), + ), + ), + ), + ), + ], + ), + ), + ), + const SizedBox(height: 10), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _displayTitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + color: widget.textColor, + ), + ), + if (subtitleText != null) ...[ + const SizedBox(height: 2), + Text( + subtitleText, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: widget.textColor != null + ? widget.textColor!.withValues(alpha: 0.55) + : tokens.mutedForeground, + ), + ), + ], + ], + ), + ], + ), + ), + ), + ); + + if (hasMenu) { + content = FContextMenu( + menuBuilder: autoDismissMenuBuilder, + menu: [FItemGroup(children: menuItems)], + child: content, + ); + } + return content; + } +} diff --git a/lib/shared/widgets/media_row_metrics.dart b/lib/shared/widgets/media_row_metrics.dart new file mode 100644 index 0000000..ae52898 --- /dev/null +++ b/lib/shared/widgets/media_row_metrics.dart @@ -0,0 +1,58 @@ +import 'dart:io' show Platform; + +import 'package:flutter/widgets.dart'; + +import '../constants/breakpoints.dart'; +import 'media_poster_card.dart'; + +abstract final class MediaRowMetrics { + static const landscapeCompactWidth = 185.0; + static const landscapeExpandedWidth = 232.0; + static const posterCompactWidth = 130.0; + static const posterExpandedWidth = 158.0; + + static const landscapeCompactHeight = 171.0; + static const landscapeExpandedHeight = 214.0; + static const posterCompactHeight = 250.0; + static const posterExpandedHeight = 304.0; + + static const _landscapeAndroidWidth = 148.0; + static const _posterAndroidWidth = 110.0; + static const _landscapeAndroidHeight = 137.0; + static const _posterAndroidHeight = 211.0; + + static final bool _isAndroid = Platform.isAndroid; + + static bool isCompact(BuildContext context) => + Breakpoints.isCompact(MediaQuery.sizeOf(context).width); + + static double cardWidth(MediaCardVariant variant, {required bool compact}) { + if (compact && _isAndroid) { + return switch (variant) { + MediaCardVariant.landscape => _landscapeAndroidWidth, + MediaCardVariant.poster => _posterAndroidWidth, + }; + } + return switch (variant) { + MediaCardVariant.landscape => + compact ? landscapeCompactWidth : landscapeExpandedWidth, + MediaCardVariant.poster => + compact ? posterCompactWidth : posterExpandedWidth, + }; + } + + static double rowHeight(MediaCardVariant variant, {required bool compact}) { + if (compact && _isAndroid) { + return switch (variant) { + MediaCardVariant.landscape => _landscapeAndroidHeight, + MediaCardVariant.poster => _posterAndroidHeight, + }; + } + return switch (variant) { + MediaCardVariant.landscape => + compact ? landscapeCompactHeight : landscapeExpandedHeight, + MediaCardVariant.poster => + compact ? posterCompactHeight : posterExpandedHeight, + }; + } +} diff --git a/lib/shared/widgets/metadata_chip.dart b/lib/shared/widgets/metadata_chip.dart new file mode 100644 index 0000000..67386ef --- /dev/null +++ b/lib/shared/widgets/metadata_chip.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; + +import '../theme/app_theme.dart'; + + +class MetadataChip extends StatelessWidget { + final IconData? icon; + final String label; + final Color? foreground; + final Color? background; + final bool dense; + + const MetadataChip({ + super.key, + required this.label, + this.icon, + this.foreground, + this.background, + this.dense = false, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final tokens = context.appTokens; + final fg = foreground ?? tokens.mutedForeground; + final bg = + background ?? theme.colorScheme.onSurface.withValues(alpha: 0.07); + return Container( + padding: EdgeInsets.symmetric(horizontal: dense ? 6 : 7, vertical: 2), + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(4), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, size: 12, color: fg), + const SizedBox(width: 3), + ], + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.labelSmall?.copyWith( + color: fg, + fontSize: 10, + letterSpacing: 0.2, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/shared/widgets/page_background.dart b/lib/shared/widgets/page_background.dart new file mode 100644 index 0000000..ca394c4 --- /dev/null +++ b/lib/shared/widgets/page_background.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; + +import '../theme/app_theme.dart'; + + +class PageBackground extends StatelessWidget { + const PageBackground({super.key}); + + @override + Widget build(BuildContext context) { + final tokens = context.appTokens; + final scheme = Theme.of(context).colorScheme; + final glow = scheme.brightness == Brightness.dark + ? Colors.transparent + : scheme.primary.withValues(alpha: 0.05); + + return RepaintBoundary( + child: Stack( + fit: StackFit.expand, + children: [ + DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [tokens.pageGradientTop, tokens.pageGradientBottom], + ), + ), + ), + DecoratedBox( + decoration: BoxDecoration( + gradient: RadialGradient( + center: const Alignment(0, -0.85), + radius: 1.2, + colors: [glow, Colors.transparent], + stops: const [0.0, 1.0], + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/shared/widgets/pill_tab_bar.dart b/lib/shared/widgets/pill_tab_bar.dart new file mode 100644 index 0000000..6390872 --- /dev/null +++ b/lib/shared/widgets/pill_tab_bar.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; + +import '../theme/app_theme.dart'; + + +class PillTabBar extends StatelessWidget { + final List tabs; + final int selectedIndex; + final ValueChanged onSelect; + + + final EdgeInsetsGeometry padding; + + const PillTabBar({ + super.key, + required this.tabs, + required this.selectedIndex, + required this.onSelect, + this.padding = EdgeInsets.zero, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final tokens = context.appTokens; + final isDark = theme.brightness == Brightness.dark; + final activeBg = theme.colorScheme.primary.withValues(alpha: 0.12); + final activeFg = theme.colorScheme.primary; + final idleBg = isDark + ? Colors.white.withValues(alpha: 0.04) + : Colors.black.withValues(alpha: 0.03); + + return Padding( + padding: padding, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: List.generate(tabs.length, (i) { + final selected = i == selectedIndex; + return Padding( + padding: const EdgeInsets.only(right: 8), + child: GestureDetector( + onTap: () => onSelect(i), + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + decoration: BoxDecoration( + color: selected ? activeBg : idleBg, + borderRadius: BorderRadius.circular(20), + ), + child: Text( + tabs[i], + style: TextStyle( + fontSize: 13, + fontWeight: selected ? FontWeight.w600 : FontWeight.w500, + color: selected ? activeFg : tokens.mutedForeground, + ), + ), + ), + ), + ); + }), + ), + ), + ); + } +} diff --git a/lib/shared/widgets/script_video_card.dart b/lib/shared/widgets/script_video_card.dart new file mode 100644 index 0000000..c9cbae8 --- /dev/null +++ b/lib/shared/widgets/script_video_card.dart @@ -0,0 +1,153 @@ +import 'package:flutter/material.dart'; + +import '../../core/contracts/script_widget.dart'; +import '../theme/app_theme.dart'; +import 'app_card.dart'; +import 'metadata_chip.dart'; +import 'smart_image.dart'; + + +class ScriptVideoCard extends StatefulWidget { + final ScriptVideoItem item; + final double width; + final VoidCallback? onTap; + final Color? textColor; + + const ScriptVideoCard({ + super.key, + required this.item, + required this.width, + this.onTap, + this.textColor, + }); + + @override + State createState() => _ScriptVideoCardState(); +} + +class _ScriptVideoCardState extends State { + bool _isHovered = false; + + String? get _imageUrl { + final imageCandidates = [ + widget.item.posterPath, + widget.item.coverUrl, + widget.item.detailPoster, + ]; + for (final imageCandidate in imageCandidates) { + if (imageCandidate.trim().isNotEmpty) return imageCandidate; + } + return null; + } + + String? get _subtitle { + final subtitleParts = [ + if (widget.item.releaseDate.trim().isNotEmpty) widget.item.releaseDate, + if (widget.item.genreTitle.trim().isNotEmpty) widget.item.genreTitle, + ]; + return subtitleParts.isEmpty ? null : subtitleParts.join(' · '); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final tokens = context.appTokens; + final rating = widget.item.rating; + final title = widget.item.title.trim().isEmpty + ? widget.item.id + : widget.item.title; + + return SizedBox( + width: widget.width, + child: AppCard( + filled: false, + enableHover: false, + radius: tokens.radiusCard, + padding: EdgeInsets.zero, + onTap: widget.onTap, + onHoverChanged: (isHovered) { + if (_isHovered == isHovered) return; + setState(() => _isHovered = isHovered); + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Stack( + fit: StackFit.expand, + children: [ + AnimatedOpacity( + opacity: _isHovered ? 0.84 : 1, + duration: const Duration(milliseconds: 160), + child: SmartImage( + url: _imageUrl, + fallbackIcon: Icons.movie_outlined, + borderRadius: tokens.posterRadius, + ), + ), + Positioned.fill( + child: AnimatedOpacity( + opacity: _isHovered && widget.onTap != null ? 1 : 0, + duration: const Duration(milliseconds: 160), + child: IgnorePointer( + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.30), + borderRadius: BorderRadius.circular( + tokens.posterRadius, + ), + ), + child: const Center( + child: Icon( + Icons.play_arrow_rounded, + size: 32, + color: Colors.white, + ), + ), + ), + ), + ), + ), + if (rating != null && rating > 0) + Positioned( + top: 7, + right: 7, + child: MetadataChip( + icon: Icons.star_rounded, + label: rating.toStringAsFixed(1), + foreground: tokens.ratingColor, + background: Colors.black.withValues(alpha: 0.68), + ), + ), + ], + ), + ), + const SizedBox(height: 8), + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.w500, + color: widget.textColor, + ), + ), + if (_subtitle case final subtitle?) ...[ + const SizedBox(height: 2), + Text( + subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: + widget.textColor?.withValues(alpha: 0.55) ?? + tokens.mutedForeground, + ), + ), + ], + ], + ), + ), + ); + } +} diff --git a/lib/shared/widgets/scroll_fade_header.dart b/lib/shared/widgets/scroll_fade_header.dart new file mode 100644 index 0000000..2402904 --- /dev/null +++ b/lib/shared/widgets/scroll_fade_header.dart @@ -0,0 +1,101 @@ +import 'dart:io'; +import 'dart:math' as math; +import 'dart:ui' show ImageFilter; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../providers/appearance_provider.dart'; +import '../theme/app_theme.dart'; +import 'window_chrome.dart'; + + +class ScrollFadeHeader extends ConsumerWidget { + final ValueNotifier scrollProgress; + + + final List actions; + + const ScrollFadeHeader({ + super.key, + required this.scrollProgress, + this.actions = const [], + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final tokens = context.appTokens; + final isDesktop = Platform.isMacOS || Platform.isWindows; + final topPadding = isDesktop + ? tokens.titleBarHeight + : math.max(MediaQuery.paddingOf(context).top, tokens.titleBarHeight); + final overlayHeight = topPadding + 12; + + final headerMode = + ref.watch(appearanceProvider).value?.headerMode ?? HeaderMode.frosted; + final isFrosted = headerMode == HeaderMode.frosted; + final theme = Theme.of(context); + + final background = ValueListenableBuilder( + valueListenable: scrollProgress, + builder: (_, raw, _) { + final p = raw.clamp(0.0, 1.0); + if (isFrosted) { + final entryProgress = (p / 0.25).clamp(0.0, 1.0); + final glassProgress = + Curves.easeOutCubic.transform(entryProgress) * 0.42; + return ClipRect( + child: BackdropFilter( + filter: ImageFilter.blur( + sigmaX: glassProgress * 10, + sigmaY: glassProgress * 10, + ), + child: DecoratedBox( + decoration: BoxDecoration( + color: theme.colorScheme.surface.withValues( + alpha: glassProgress * 0.88, + ), + border: Border( + bottom: BorderSide( + color: theme.colorScheme.onSurface.withValues( + alpha: glassProgress * 0.08, + ), + width: 1, + ), + ), + ), + ), + ), + ); + } + return ColoredBox( + color: theme.colorScheme.surface.withValues(alpha: p), + ); + }, + ); + + return SizedBox( + height: overlayHeight, + width: double.infinity, + child: Stack( + children: [ + Positioned.fill(child: IgnorePointer(child: background)), + if (actions.isNotEmpty) + Positioned( + right: 0, + top: tokens.chromeInset, + height: WindowChrome.controlsHeight, + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + ...actions, + SizedBox(width: WindowChrome.reservedTrailingWidth(tokens)), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/shared/widgets/season_selector.dart b/lib/shared/widgets/season_selector.dart new file mode 100644 index 0000000..d9301cd --- /dev/null +++ b/lib/shared/widgets/season_selector.dart @@ -0,0 +1,232 @@ +import 'package:flutter/material.dart'; +import 'package:forui/forui.dart'; + +import '../../core/contracts/library.dart'; + +const Color _defaultSeasonAccentColor = Color(0xFF4F8DFF); +const double _inlineSeasonHorizontalPadding = 14; +const double _inlineSeasonVerticalPadding = 6; +const double _inlineSeasonGap = 8; +const double _inlineWidthSafetyMargin = 2; +const TextStyle _inlineSeasonTextStyle = TextStyle(fontSize: 13); + +class SeasonSelector extends StatelessWidget { + final List seasons; + final String selectedId; + final ValueChanged onSelect; + final Color accentColor; + final Color? backgroundColor; + + const SeasonSelector({ + super.key, + required this.seasons, + required this.selectedId, + required this.onSelect, + this.accentColor = _defaultSeasonAccentColor, + this.backgroundColor, + }); + + EmbyRawSeason _resolveSelectedSeason() { + for (final season in seasons) { + if (season.Id == selectedId) return season; + } + return seasons.first; + } + + TextStyle _inlineTextStyle(bool selected) { + return _inlineSeasonTextStyle.copyWith( + fontWeight: selected ? FontWeight.w600 : FontWeight.normal, + color: selected ? Colors.white : Colors.white.withValues(alpha: 0.7), + ); + } + + double _calculateInlineWidth(BuildContext context) { + final textDirection = Directionality.of(context); + final textScaler = MediaQuery.textScalerOf(context); + var requiredWidth = 0.0; + + for (var index = 0; index < seasons.length; index++) { + final season = seasons[index]; + final textPainter = TextPainter( + text: TextSpan( + text: season.Name, + style: _inlineTextStyle(season.Id == selectedId), + ), + maxLines: 1, + textDirection: textDirection, + textScaler: textScaler, + )..layout(); + requiredWidth += textPainter.width + (_inlineSeasonHorizontalPadding * 2); + if (index > 0) requiredWidth += _inlineSeasonGap; + } + + return requiredWidth + _inlineWidthSafetyMargin; + } + + @override + Widget build(BuildContext context) { + if (seasons.isEmpty) return const SizedBox.shrink(); + + final selectedSeason = _resolveSelectedSeason(); + + return LayoutBuilder( + builder: (context, constraints) { + final inlineWidth = _calculateInlineWidth(context); + final hasEnoughInlineSpace = + !constraints.hasBoundedWidth || inlineWidth <= constraints.maxWidth; + + final selector = hasEnoughInlineSpace + ? _buildInlineOptions() + : _buildDropdownSelect(context, selectedSeason); + + return Align( + alignment: Alignment.centerLeft, + widthFactor: 1, + child: selector, + ); + }, + ); + } + + Widget _buildInlineOptions() { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (var index = 0; index < seasons.length; index++) ...[ + if (index > 0) const SizedBox(width: _inlineSeasonGap), + _buildInlineOption(seasons[index]), + ], + ], + ); + } + + Widget _buildInlineOption(EmbyRawSeason season) { + final selected = season.Id == selectedId; + + return Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(20), + onTap: selected ? null : () => onSelect(season.Id), + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + padding: const EdgeInsets.symmetric( + horizontal: _inlineSeasonHorizontalPadding, + vertical: _inlineSeasonVerticalPadding, + ), + decoration: BoxDecoration( + color: selected + ? accentColor.withValues(alpha: 0.28) + : backgroundColor ?? Colors.white.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(20), + ), + child: Text(season.Name, style: _inlineTextStyle(selected)), + ), + ), + ); + } + + Widget _buildDropdownSelect( + BuildContext context, + EmbyRawSeason selectedSeason, + ) { + final controlWidth = _calculateControlWidth(context, selectedSeason); + final menuWidth = _calculateMenuWidth(context, controlWidth); + + return SizedBox( + width: controlWidth, + child: FSelect.rich( + control: FSelectControl.lifted( + value: selectedId, + onChange: (seasonId) { + if (seasonId != null && seasonId != selectedId) { + onSelect(seasonId); + } + }, + ), + format: _seasonNameForId, + contentConstraints: FPortalConstraints( + minWidth: menuWidth, + maxWidth: menuWidth, + maxHeight: 360, + ), + children: [ + for (final season in seasons) + FSelectItem.item( + title: Text(season.Name), + subtitle: season.ChildCount == null + ? null + : Text('${season.ChildCount} 集'), + value: season.Id, + ), + ], + ), + ); + } + + double _calculateControlWidth( + BuildContext context, + EmbyRawSeason selectedSeason, + ) { + final selectedTextWidth = _measureTextWidth( + context, + selectedSeason.Name, + _inlineSeasonTextStyle.copyWith(fontWeight: FontWeight.w600), + ); + + + return (selectedTextWidth + 64).clamp(104.0, 200.0).toDouble(); + } + + double _calculateMenuWidth(BuildContext context, double controlWidth) { + var widestContent = 0.0; + for (final season in seasons) { + final seasonNameWidth = _measureTextWidth( + context, + season.Name, + const TextStyle(fontSize: 14), + ); + final episodeCountWidth = season.ChildCount == null + ? 0.0 + : _measureTextWidth( + context, + '${season.ChildCount} 集', + const TextStyle(fontSize: 12), + ); + widestContent = widestContent.clamp( + seasonNameWidth > episodeCountWidth + ? seasonNameWidth + : episodeCountWidth, + double.infinity, + ); + } + + final screenWidth = MediaQuery.sizeOf(context).width; + final maximumSafeWidth = (screenWidth - 32).clamp( + controlWidth, + double.infinity, + ); + + + return (widestContent + 72) + .clamp(controlWidth, maximumSafeWidth) + .toDouble(); + } + + double _measureTextWidth(BuildContext context, String text, TextStyle style) { + final textPainter = TextPainter( + text: TextSpan(text: text, style: style), + maxLines: 1, + textDirection: Directionality.of(context), + textScaler: MediaQuery.textScalerOf(context), + )..layout(); + return textPainter.width; + } + + String _seasonNameForId(String seasonId) { + for (final season in seasons) { + if (season.Id == seasonId) return season.Name; + } + return _resolveSelectedSeason().Name; + } +} diff --git a/lib/shared/widgets/section_header.dart b/lib/shared/widgets/section_header.dart new file mode 100644 index 0000000..33092a9 --- /dev/null +++ b/lib/shared/widgets/section_header.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; + +import '../theme/app_theme.dart'; + + +class SectionHeader extends StatelessWidget { + final String label; + final int? count; + final bool collapsible; + final bool collapsed; + final VoidCallback? onToggle; + final Widget? trailing; + + + final Color? labelColor; + + const SectionHeader({ + super.key, + required this.label, + this.count, + this.collapsible = false, + this.collapsed = false, + this.onToggle, + this.trailing, + this.labelColor, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final tokens = context.appTokens; + + final mutedColor = labelColor != null + ? labelColor!.withValues(alpha: 0.55) + : tokens.mutedForeground; + + final row = Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: Row( + children: [ + Text( + label, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + color: labelColor, + ), + ), + if (count != null) ...[ + const SizedBox(width: 8), + Text( + '$count', + style: theme.textTheme.bodySmall?.copyWith(color: mutedColor), + ), + ], + const Spacer(), + if (trailing != null) trailing!, + if (collapsible) + AnimatedRotation( + turns: collapsed ? -0.25 : 0, + duration: const Duration(milliseconds: 180), + child: Icon( + Icons.keyboard_arrow_down_rounded, + size: 20, + color: mutedColor, + ), + ), + ], + ), + ); + + if (!collapsible) return row; + return InkWell( + onTap: onToggle, + borderRadius: BorderRadius.circular(6), + child: row, + ); + } +} diff --git a/lib/shared/widgets/server_action_menu.dart b/lib/shared/widgets/server_action_menu.dart new file mode 100644 index 0000000..2774954 --- /dev/null +++ b/lib/shared/widgets/server_action_menu.dart @@ -0,0 +1,164 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:forui/forui.dart'; + +import '../../core/contracts/auth.dart'; +import '../../core/contracts/server.dart'; +import '../../providers/item_counts_provider.dart'; +import '../../providers/server_connectivity_provider.dart'; +import '../../providers/server_provider.dart'; +import '../../providers/server_sync_provider.dart'; +import '../../providers/session_provider.dart'; +import 'add_server_modal.dart'; +import 'app_dialog.dart'; +import 'icon_picker_dialog.dart'; +import 'server_change_password_dialog.dart'; + +typedef ServerActionCallbacks = ({ + VoidCallback? onRefresh, + VoidCallback? onChangeIcon, + VoidCallback? onEdit, + VoidCallback? onChangePassword, + VoidCallback? onTogglePause, + VoidCallback? onDelete, +}); + +ServerActionCallbacks buildServerActionCallbacks({ + required BuildContext context, + required WidgetRef ref, + required EmbyServer? server, + AuthedSession? session, +}) { + Future changeIcon(EmbyServer server) async { + final picked = await showIconPickerDialog( + context, + currentIconUrl: server.iconUrl, + ); + if (picked == null) return; + await ref + .read(serverListProvider.notifier) + .save( + EmbyServerSaveReq( + id: server.id, + name: server.name, + baseUrl: server.baseUrl, + iconUrl: picked, + ), + ); + } + + Future togglePause(EmbyServer server) async { + final wasPaused = server.paused; + final sessions = ref.read(sessionProvider).value; + final wasActive = sessions?.activeServerId == server.id; + await ref.read(serverListProvider.notifier).togglePause(server.id); + + if (!wasPaused && wasActive) { + final updatedServers = ref.read(serverListProvider).value ?? const []; + final pausedIds = { + for (final s in updatedServers) + if (s.paused) s.id, + }; + final nextSession = sessions?.sessions + .where( + (s) => s.serverId != server.id && !pausedIds.contains(s.serverId), + ) + .firstOrNull; + + if (nextSession != null) { + await ref + .read(sessionProvider.notifier) + .switchSession(nextSession.serverId); + } else { + await ref.read(sessionProvider.notifier).logout(serverId: server.id); + } + } + + await ref.read(serverSyncProvider.notifier).upload(); + } + + Future delete(EmbyServer server) async { + final confirmed = await showAppConfirmDialog( + context, + title: '删除服务器', + body: Text('确定删除「${server.name}」?此操作不可撤销。'), + confirmLabel: '删除', + destructive: true, + ); + if (!confirmed) return; + await ref.read(serverListProvider.notifier).delete(server.id); + await ref.read(sessionProvider.notifier).refresh(); + } + + final s = server; + return ( + onRefresh: s == null + ? null + : () { + ref.invalidate(serverConnectivityProvider(s.baseUrl)); + ref.invalidate(serverItemCountsProvider(s.id)); + }, + onChangeIcon: s == null ? null : () => changeIcon(s), + onEdit: s == null ? null : () => showAddServerModal(context, initial: s), + onChangePassword: session == null + ? null + : () => showServerChangePasswordDialog( + context: context, + serverId: session.serverId, + serverName: s?.name ?? session.serverName, + ), + onTogglePause: s == null ? null : () => togglePause(s), + onDelete: s == null ? null : () => delete(s), + ); +} + +List buildServerActionMenuItems({ + VoidCallback? onRefresh, + VoidCallback? onChangeIcon, + VoidCallback? onEdit, + VoidCallback? onChangePassword, + bool paused = false, + VoidCallback? onTogglePause, + VoidCallback? onDelete, +}) => [ + if (onRefresh != null) + FItem( + prefix: const Icon(Icons.refresh_outlined, size: 16), + title: const Text('刷新'), + onPress: onRefresh, + ), + if (onChangeIcon != null) + FItem( + prefix: const Icon(Icons.image_outlined, size: 16), + title: const Text('更换图标'), + onPress: onChangeIcon, + ), + if (onEdit != null) + FItem( + prefix: const Icon(Icons.edit_outlined, size: 16), + title: const Text('编辑'), + onPress: onEdit, + ), + if (onChangePassword != null) + FItem( + prefix: const Icon(Icons.lock_outline, size: 16), + title: const Text('修改密码'), + onPress: onChangePassword, + ), + if (onTogglePause != null) + FItem( + prefix: Icon( + paused ? Icons.play_circle_outline : Icons.pause_circle_outline, + size: 16, + ), + title: Text(paused ? '恢复使用' : '暂停使用'), + onPress: onTogglePause, + ), + if (onDelete != null) + FItem( + variant: FItemVariant.destructive, + prefix: const Icon(Icons.delete_outline, size: 16), + title: const Text('删除'), + onPress: onDelete, + ), +]; diff --git a/lib/shared/widgets/server_avatar.dart b/lib/shared/widgets/server_avatar.dart new file mode 100644 index 0000000..23b336f --- /dev/null +++ b/lib/shared/widgets/server_avatar.dart @@ -0,0 +1,107 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; + +class ServerAvatar extends StatelessWidget { + final String name; + final String iconUrl; + final double size; + final bool isActive; + final bool showActiveBorder; + + + final ServerAvatarShape shape; + + const ServerAvatar({ + super.key, + required this.name, + this.iconUrl = '', + this.size = 38, + this.isActive = false, + this.showActiveBorder = false, + this.shape = ServerAvatarShape.roundedSquare, + }); + + + static const _avatarColors = [ + Color(0xFF6366F1), + Color(0xFF3B82F6), + Color(0xFF22C55E), + Color(0xFFA855F7), + Color(0xFFF97316), + Color(0xFF14B8A6), + Color(0xFFEC4899), + Color(0xFF84CC16), + ]; + + + static Color seedColorFor(String name) => + _avatarColors[name.hashCode.abs() % _avatarColors.length]; + + @override + Widget build(BuildContext context) { + final color = seedColorFor(name); + final initial = name.isNotEmpty ? name[0].toUpperCase() : '?'; + final hasIcon = iconUrl.isNotEmpty; + + final radius = shape == ServerAvatarShape.circle ? size / 2 : size * 0.26; + + final borderRadius = BorderRadius.circular(radius); + + final borderColor = showActiveBorder && isActive + ? color.withValues(alpha: 0.55) + : color.withValues(alpha: 0.25); + + return Container( + width: size, + height: size, + decoration: BoxDecoration( + color: hasIcon ? Colors.transparent : color.withValues(alpha: 0.15), + borderRadius: borderRadius, + border: Border.all( + color: hasIcon ? Colors.transparent : borderColor, + width: showActiveBorder && isActive ? 1.5 : 1, + ), + ), + clipBehavior: Clip.antiAlias, + child: hasIcon + ? CachedNetworkImage( + imageUrl: iconUrl, + fit: BoxFit.contain, + memCacheWidth: 128, + fadeInDuration: const Duration(milliseconds: 180), + placeholder: (_, _) => Center( + child: Text( + initial, + style: TextStyle( + color: color, + fontWeight: FontWeight.w700, + fontSize: size * 0.42, + ), + ), + ), + errorWidget: (_, _, _) => Center( + child: Text( + initial, + style: TextStyle( + color: color, + fontWeight: FontWeight.w700, + fontSize: size * 0.42, + ), + ), + ), + ) + : Center( + child: Text( + initial, + style: TextStyle( + color: color, + fontWeight: FontWeight.w700, + fontSize: size * 0.42, + ), + ), + ), + ); + } +} + +enum ServerAvatarShape { roundedSquare, circle } diff --git a/lib/shared/widgets/server_change_password_dialog.dart b/lib/shared/widgets/server_change_password_dialog.dart new file mode 100644 index 0000000..772e615 --- /dev/null +++ b/lib/shared/widgets/server_change_password_dialog.dart @@ -0,0 +1,183 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:forui/forui.dart'; + +import '../../core/domain/errors.dart'; +import '../../providers/di_providers.dart'; +import '../constants/breakpoints.dart'; +import '../utils/user_error_formatter.dart'; +import 'adaptive_modal.dart'; +import 'app_inline_alert.dart'; +import 'app_loading_ring.dart'; +import 'app_snack_bar.dart'; + +Future showServerChangePasswordDialog({ + required BuildContext context, + required String serverId, + required String serverName, +}) => showAdaptiveModal( + context: context, + maxWidth: 420, + compactMaxHeightFactor: 0.9, + builder: (_) => + _ServerChangePasswordDialog(serverId: serverId, serverName: serverName), +); + +class _ServerChangePasswordDialog extends ConsumerStatefulWidget { + final String serverId; + final String serverName; + + const _ServerChangePasswordDialog({ + required this.serverId, + required this.serverName, + }); + + @override + ConsumerState<_ServerChangePasswordDialog> createState() => + _ServerChangePasswordDialogState(); +} + +class _ServerChangePasswordDialogState + extends ConsumerState<_ServerChangePasswordDialog> { + final _currentPwCtrl = TextEditingController(); + final _newPwCtrl = TextEditingController(); + final _confirmPwCtrl = TextEditingController(); + bool _loading = false; + String? _error; + + @override + void dispose() { + _currentPwCtrl.dispose(); + _newPwCtrl.dispose(); + _confirmPwCtrl.dispose(); + super.dispose(); + } + + Future _submit() async { + final currentPw = _currentPwCtrl.text; + final newPw = _newPwCtrl.text; + final confirmPw = _confirmPwCtrl.text; + + if (currentPw.isEmpty || newPw.isEmpty || confirmPw.isEmpty) { + setState(() => _error = '所有字段不能为空'); + return; + } + if (newPw != confirmPw) { + setState(() => _error = '两次输入的新密码不一致'); + return; + } + + setState(() { + _loading = true; + _error = null; + }); + + try { + final uc = await ref.read(changePasswordUseCaseProvider.future); + await uc.execute( + serverId: widget.serverId, + currentPassword: currentPw, + newPassword: newPw, + ); + if (mounted) Navigator.of(context).pop(); + if (mounted) { + showAppSnackBar(context, '密码已修改', tone: AppSnackTone.neutral); + } + } catch (e) { + if (!mounted) return; + setState(() { + _loading = false; + _error = e is DomainError ? formatUserError(e) : '修改失败,请检查当前密码是否正确'; + }); + } + } + + @override + Widget build(BuildContext context) { + final isCompact = Breakpoints.isCompact(MediaQuery.sizeOf(context).width); + final title = Text('修改密码 · ${widget.serverName}'); + final body = SingleChildScrollView( + padding: EdgeInsets.fromLTRB(24, isCompact ? 16 : 24, 24, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + title, + const SizedBox(height: 18), + TextField( + controller: _currentPwCtrl, + obscureText: true, + enabled: !_loading, + textInputAction: TextInputAction.next, + autocorrect: false, + enableSuggestions: false, + decoration: const InputDecoration(labelText: '当前密码'), + ), + const SizedBox(height: 12), + TextField( + controller: _newPwCtrl, + obscureText: true, + enabled: !_loading, + textInputAction: TextInputAction.next, + autocorrect: false, + enableSuggestions: false, + decoration: const InputDecoration(labelText: '新密码'), + ), + const SizedBox(height: 12), + TextField( + controller: _confirmPwCtrl, + obscureText: true, + enabled: !_loading, + textInputAction: TextInputAction.done, + autocorrect: false, + enableSuggestions: false, + onSubmitted: (_) => _submit(), + decoration: const InputDecoration(labelText: '确认新密码'), + ), + if (_error != null) ...[ + const SizedBox(height: 12), + AppInlineAlert(message: _error!), + ], + const SizedBox(height: 20), + if (isCompact) + FButton( + onPress: _loading ? null : _submit, + child: _loading + ? const AppLoadingRing(size: 16, color: Colors.white) + : const Text('确认修改'), + ) + else + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + FButton( + variant: FButtonVariant.ghost, + onPress: _loading + ? null + : () => Navigator.of(context).pop(), + child: const Text('取消'), + ), + const SizedBox(width: 8), + FButton( + onPress: _loading ? null : _submit, + child: _loading + ? const AppLoadingRing(size: 16, color: Colors.white) + : const Text('确认修改'), + ), + ], + ), + if (isCompact) ...[ + const SizedBox(height: 8), + FButton( + variant: FButtonVariant.outline, + onPress: _loading ? null : () => Navigator.of(context).pop(), + child: const Text('取消'), + ), + ], + ], + ), + ); + + return PopScope(canPop: !_loading, child: body); + } +} diff --git a/lib/shared/widgets/server_menu_item.dart b/lib/shared/widgets/server_menu_item.dart new file mode 100644 index 0000000..8e279c6 --- /dev/null +++ b/lib/shared/widgets/server_menu_item.dart @@ -0,0 +1,144 @@ +import 'package:flutter/material.dart'; +import 'package:forui/forui.dart'; + +import 'auto_dismiss_menu.dart'; +import 'server_action_menu.dart'; +import 'server_avatar.dart'; + +class ServerMenuItem extends StatelessWidget { + final String serverName; + final String iconUrl; + final bool isActive; + final bool collapsed; + + + final Color foregroundActive; + final Color foregroundInactive; + final VoidCallback? onTap; + final VoidCallback? onChangeIcon; + final VoidCallback? onEdit; + final VoidCallback? onChangePassword; + final VoidCallback? onTogglePause; + final VoidCallback? onDelete; + + const ServerMenuItem({ + super.key, + required this.serverName, + required this.isActive, + required this.collapsed, + required this.foregroundActive, + required this.foregroundInactive, + this.iconUrl = '', + this.onTap, + this.onChangeIcon, + this.onEdit, + this.onChangePassword, + this.onTogglePause, + this.onDelete, + }); + + List _menuItems() => buildServerActionMenuItems( + onChangeIcon: onChangeIcon, + onEdit: onEdit, + onChangePassword: onChangePassword, + onTogglePause: onTogglePause, + onDelete: onDelete, + ); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final foreground = isActive ? foregroundActive : foregroundInactive; + + final child = collapsed + ? _buildCollapsed(foreground) + : _buildExpanded(theme, foreground); + + final items = _menuItems(); + if (items.isEmpty) return GestureDetector(onTap: onTap, child: child); + + return FContextMenu( + menuBuilder: autoDismissMenuBuilder, + menu: [FItemGroup(children: items)], + child: GestureDetector( + onTap: onTap, + child: MouseRegion(cursor: SystemMouseCursors.click, child: child), + ), + ); + } + + Widget _buildCollapsed(Color foreground) { + return Tooltip( + message: serverName, + preferBelow: false, + child: Padding( + padding: const EdgeInsets.only(bottom: 2), + child: SizedBox( + width: 44, + height: 38, + child: Row( + children: [ + Container( + width: 3, + height: 20, + decoration: BoxDecoration( + color: isActive ? foregroundActive : Colors.transparent, + borderRadius: BorderRadius.circular(1.5), + ), + ), + const Spacer(), + ServerAvatar( + name: serverName, + iconUrl: iconUrl, + size: 30, + isActive: isActive, + showActiveBorder: true, + shape: ServerAvatarShape.circle, + ), + const Spacer(), + ], + ), + ), + ), + ); + } + + Widget _buildExpanded(ThemeData theme, Color foreground) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8), + child: Row( + children: [ + Container( + width: 2, + height: 16, + margin: const EdgeInsets.only(right: 10), + decoration: BoxDecoration( + color: isActive ? foregroundActive : Colors.transparent, + borderRadius: BorderRadius.circular(1), + ), + ), + ServerAvatar( + name: serverName, + iconUrl: iconUrl, + size: 28, + isActive: isActive, + showActiveBorder: false, + shape: ServerAvatarShape.roundedSquare, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + serverName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + color: foreground, + fontWeight: isActive ? FontWeight.w600 : FontWeight.w400, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/shared/widgets/setting_select.dart b/lib/shared/widgets/setting_select.dart new file mode 100644 index 0000000..e79c5b3 --- /dev/null +++ b/lib/shared/widgets/setting_select.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; +import 'package:forui/forui.dart'; + +class SettingSelect extends StatelessWidget { + final T? value; + final List options; + final String Function(T value) labelOf; + final ValueChanged? onChanged; + final bool enabled; + + const SettingSelect({ + super.key, + required this.value, + required this.options, + required this.labelOf, + required this.onChanged, + this.enabled = true, + }); + + @override + Widget build(BuildContext context) { + final screenWidth = MediaQuery.maybeSizeOf(context)?.width; + final maxWidth = screenWidth == null + ? 240.0 + : (screenWidth * 0.45).clamp(120.0, 240.0).toDouble(); + + return ConstrainedBox( + constraints: BoxConstraints(minWidth: 96, maxWidth: maxWidth), + child: FSelect.rich( + enabled: enabled && onChanged != null, + control: FSelectControl.lifted( + value: value, + onChange: onChanged ?? (_) {}, + ), + format: labelOf, + children: [ + for (final option in options) + FSelectItem.item(title: Text(labelOf(option)), value: option), + ], + ), + ); + } +} diff --git a/lib/shared/widgets/shell_backdrop_layer.dart b/lib/shared/widgets/shell_backdrop_layer.dart new file mode 100644 index 0000000..3c32847 --- /dev/null +++ b/lib/shared/widgets/shell_backdrop_layer.dart @@ -0,0 +1,132 @@ +import 'dart:ui' show ImageFilter; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../providers/emby_headers_provider.dart'; +import '../../providers/shell_backdrop_provider.dart'; + + +class ShellBackdropLayer extends ConsumerWidget { + final bool active; + + const ShellBackdropLayer({super.key, this.active = false}); + + + static const double _blurSigma = 32; + + + static const Duration _crossfade = Duration(milliseconds: 350); + + + static const LinearGradient _immersiveDarkFallback = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + stops: [0.0, 0.4, 1.0], + colors: [Color(0xFF1A1A1A), Color(0xFF0F0F0F), Color(0xFF000000)], + ); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final backdrop = active ? ref.watch(shellBackdropProvider) : null; + final imageHeaders = ref.watch(embyImageHeadersProvider).value; + final scheme = Theme.of(context).colorScheme; + + final Widget child; + if (backdrop != null) { + child = _BackdropImage( + key: ValueKey(backdrop.primaryImageUrl), + state: backdrop, + imageHeaders: imageHeaders, + ); + } else if (active) { + child = const DecoratedBox( + key: ValueKey('__immersive_dark_fallback__'), + decoration: BoxDecoration(gradient: _immersiveDarkFallback), + ); + } else { + child = ColoredBox( + key: ValueKey('__surface__${scheme.brightness.name}'), + color: scheme.surface, + ); + } + + return AnimatedSwitcher( + duration: _crossfade, + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeOut, + layoutBuilder: (currentChild, previousChildren) { + final seen = {if (currentChild != null) currentChild.key}; + final filtered = previousChildren.where((child) { + if (seen.contains(child.key)) return false; + seen.add(child.key); + return true; + }).toList(); + return Stack( + fit: StackFit.expand, + children: [...filtered, if (currentChild != null) currentChild], + ); + }, + child: child, + ); + } +} + +class _BackdropImage extends StatelessWidget { + final ShellBackdropState state; + final Map? imageHeaders; + + const _BackdropImage({ + super.key, + required this.state, + required this.imageHeaders, + }); + + @override + Widget build(BuildContext context) { + final fallbackUrl = state.fallbackImageUrls.isNotEmpty + ? state.fallbackImageUrls.first + : state.primaryImageUrl; + final surface = Theme.of(context).colorScheme.surface; + + return Stack( + fit: StackFit.expand, + children: [ + ImageFiltered( + imageFilter: ImageFilter.blur( + sigmaX: ShellBackdropLayer._blurSigma, + sigmaY: ShellBackdropLayer._blurSigma, + tileMode: TileMode.clamp, + ), + child: CachedNetworkImage( + imageUrl: state.primaryImageUrl, + httpHeaders: imageHeaders, + fit: BoxFit.cover, + memCacheWidth: 800, + fadeInDuration: const Duration(milliseconds: 200), + placeholder: (_, _) => ColoredBox(color: surface), + errorWidget: (_, _, _) => CachedNetworkImage( + imageUrl: fallbackUrl, + httpHeaders: imageHeaders, + fit: BoxFit.cover, + memCacheWidth: 800, + placeholder: (_, _) => ColoredBox(color: surface), + errorWidget: (_, _, _) => ColoredBox(color: surface), + ), + ), + ), + const DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + stops: [0.0, 0.4, 1.0], + colors: [Color(0x33000000), Color(0x59000000), Color(0xB3000000)], + ), + ), + ), + ], + ); + } +} diff --git a/lib/shared/widgets/sidebar.dart b/lib/shared/widgets/sidebar.dart new file mode 100644 index 0000000..a2797aa --- /dev/null +++ b/lib/shared/widgets/sidebar.dart @@ -0,0 +1,646 @@ +import 'dart:async'; +import 'dart:ui' show ImageFilter; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_riverpod/legacy.dart'; +import 'package:go_router/go_router.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../../providers/home_banner_provider.dart'; +import '../../providers/di_providers.dart'; +import '../../providers/library_overview_provider.dart'; +import '../../providers/server_provider.dart'; +import '../../providers/session_provider.dart'; +import '../../providers/shell_backdrop_provider.dart'; +import '../../features/settings/settings_page.dart'; +import '../../providers/tmdb_settings_provider.dart'; +import '../../providers/sm_account_provider.dart'; +import '../../providers/trakt_settings_provider.dart'; +import '../theme/app_theme.dart'; +import 'server_action_menu.dart'; +import 'server_menu_item.dart'; +import 'top_drag_area.dart'; + +const _kSidebarCollapsedKey = 'smplayer.sidebar_collapsed'; + +const double _kSidebarBlurSigma = 18; + + +class _SidebarPalette { + final Color fg; + final Color fgMuted; + final Color fgFaint; + final Color glassTint; + final Color hairline; + + const _SidebarPalette({ + required this.fg, + required this.fgMuted, + required this.fgFaint, + required this.glassTint, + required this.hairline, + }); + + static const dark = _SidebarPalette( + fg: Colors.white, + fgMuted: Color(0x99FFFFFF), + fgFaint: Color(0x66FFFFFF), + glassTint: Color(0x42000000), + hairline: Color(0x14FFFFFF), + ); + + static const light = _SidebarPalette( + fg: Color(0xFF1A1A1A), + fgMuted: Color(0x99000000), + fgFaint: Color(0x59000000), + glassTint: Color(0x8CFFFFFF), + hairline: Color(0x0F000000), + ); + + static const darkSurface = _SidebarPalette( + fg: Colors.white, + fgMuted: Color(0x99FFFFFF), + fgFaint: Color(0x66FFFFFF), + glassTint: Color(0x14FFFFFF), + hairline: Color(0x1FFFFFFF), + ); +} + +final sidebarCollapsedProvider = StateProvider((ref) => false); + +Future initSidebarState(WidgetRef ref) async { + final prefs = await SharedPreferences.getInstance(); + final collapsed = prefs.getBool(_kSidebarCollapsedKey) ?? false; + ref.read(sidebarCollapsedProvider.notifier).state = collapsed; +} + +Future _persistSidebarState(bool collapsed) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_kSidebarCollapsedKey, collapsed); +} + +class Sidebar extends ConsumerStatefulWidget { + + + final int selectedBranchIndex; + + + final void Function(int index, {bool initialLocation}) onNavigateBranch; + + + final bool immersiveRoute; + + + final String location; + + const Sidebar({ + super.key, + required this.selectedBranchIndex, + required this.onNavigateBranch, + required this.location, + this.immersiveRoute = false, + }); + + @override + ConsumerState createState() => _SidebarState(); +} + +class _SidebarState extends ConsumerState { + bool _suppressBlur = false; + Timer? _blurTimer; + + @override + void dispose() { + _blurTimer?.cancel(); + super.dispose(); + } + + void _onCollapseToggled() { + setState(() => _suppressBlur = true); + _blurTimer?.cancel(); + _blurTimer = Timer(const Duration(milliseconds: 300), () { + if (mounted) setState(() => _suppressBlur = false); + }); + } + + Widget _frostedGlass({required bool suppressBlur, required Widget child}) { + return BackdropFilter( + enabled: !suppressBlur, + filter: ImageFilter.blur( + sigmaX: _kSidebarBlurSigma, + sigmaY: _kSidebarBlurSigma, + ), + child: child, + ); + } + + @override + Widget build(BuildContext context) { + ref.listen(sidebarCollapsedProvider, (prev, next) { + if (prev != next) _onCollapseToggled(); + }); + final theme = Theme.of(context); + final tokens = context.appTokens; + final hasBackdrop = ref.watch( + shellBackdropProvider.select((s) => s != null), + ); + final immersive = widget.immersiveRoute && hasBackdrop; + final isDark = theme.brightness == Brightness.dark; + final palette = immersive + ? _SidebarPalette.dark + : (isDark ? _SidebarPalette.darkSurface : _SidebarPalette.light); + final collapsed = ref.watch(sidebarCollapsedProvider); + final sessionState = ref.watch(sessionProvider); + final servers = ref.watch(serverListProvider).value ?? const []; + final serverById = {for (final s in servers) s.id: s}; + final iconUrlByServer = {for (final s in servers) s.id: s.iconUrl}; + final currentIndex = widget.selectedBranchIndex; + final locationSegments = Uri.parse(widget.location).pathSegments; + final activeModuleId = + locationSegments.length > 1 && locationSegments.first == 'modules' + ? locationSegments[1] + : null; + final sessionByServerId = { + for (final session in sessionState.value?.sessions ?? const []) + session.serverId: session, + }; + final sessionList = [ + for (final server in servers) + if (!server.paused && sessionByServerId[server.id] != null) + sessionByServerId[server.id]!, + ]; + final tmdbConfigured = + ref.watch(tmdbSettingsProvider).value?.canRequest ?? false; + final traktConnected = + ref.watch(traktSettingsProvider).value?.isConnected ?? false; + final smAccountLoggedIn = + ref.watch(smAccountProvider).value?.loggedIn ?? false; + final enabledScriptWidgets = + (ref.watch(installedScriptWidgetsProvider).value ?? const []) + .where((installedWidget) => installedWidget.enabled) + .toList(growable: false); + final width = collapsed + ? tokens.siderWidthCollapsed + : tokens.siderWidthExpanded; + + return RepaintBoundary( + child: AnimatedContainer( + duration: tokens.siderTransition, + curve: Curves.easeOutCubic, + width: width, + child: ClipRect( + child: _frostedGlass( + suppressBlur: _suppressBlur, + child: AnimatedContainer( + duration: const Duration(milliseconds: 350), + curve: Curves.easeOut, + decoration: BoxDecoration( + color: palette.glassTint, + border: Border( + right: BorderSide(color: palette.hairline, width: 1), + ), + ), + child: OverflowBox( + alignment: Alignment.topLeft, + minWidth: width, + maxWidth: width, + child: SizedBox( + width: width, + child: Padding( + padding: EdgeInsets.fromLTRB( + collapsed ? 8 : 16, + 10, + collapsed ? 8 : 16, + 10, + ), + child: LayoutBuilder( + builder: (context, constraints) { + final effectiveCollapsed = constraints.maxWidth < 100; + const settingsNavReserveHeight = 48.0; + final reservedBottomHeight = + (sessionList.isNotEmpty || + enabledScriptWidgets.isNotEmpty + ? 16.0 + : 0.0) + + 4.0 + + settingsNavReserveHeight; + final availableHeaderHeight = + constraints.maxHeight - reservedBottomHeight; + final maxHeaderHeight = constraints.maxHeight.isFinite + ? (availableHeaderHeight < 0 + ? 0.0 + : availableHeaderHeight) + : double.infinity; + return Column( + crossAxisAlignment: effectiveCollapsed + ? CrossAxisAlignment.center + : CrossAxisAlignment.start, + children: [ + ConstrainedBox( + constraints: BoxConstraints( + maxHeight: maxHeaderHeight, + ), + child: SingleChildScrollView( + child: AnimatedSize( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOutCubic, + alignment: Alignment.topCenter, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: effectiveCollapsed + ? CrossAxisAlignment.center + : CrossAxisAlignment.start, + children: [ + _SidebarHeader( + collapsed: effectiveCollapsed, + palette: palette, + onToggle: () { + final next = !collapsed; + ref + .read( + sidebarCollapsedProvider + .notifier, + ) + .state = + next; + _persistSidebarState(next); + }, + ), + const SizedBox(height: 16), + _buildSearchEntry( + context, + collapsed: effectiveCollapsed, + palette: palette, + ), + const SizedBox(height: 12), + _navItem( + context, + icon: Icons.dns_outlined, + label: '服务器', + selected: currentIndex == 1, + collapsed: effectiveCollapsed, + palette: palette, + onTap: () => widget.onNavigateBranch(1), + ), + _navItem( + context, + icon: Icons.dashboard_outlined, + label: '聚合视界', + selected: currentIndex == 2, + collapsed: effectiveCollapsed, + palette: palette, + onTap: () => widget.onNavigateBranch(2), + ), + if (tmdbConfigured) + _navItem( + context, + icon: Icons.explore_outlined, + label: '发现', + selected: currentIndex == 3, + collapsed: effectiveCollapsed, + palette: palette, + onTap: () => + widget.onNavigateBranch(3), + ), + if (smAccountLoggedIn && traktConnected) + _navItem( + context, + icon: Icons.calendar_today_outlined, + label: '日历', + selected: currentIndex == 4, + collapsed: effectiveCollapsed, + palette: palette, + onTap: () => + widget.onNavigateBranch(4), + ), + ], + ), + ), + ), + ), + if (sessionList.isNotEmpty || + enabledScriptWidgets.isNotEmpty) ...[ + const SizedBox(height: 16), + Expanded( + child: SingleChildScrollView( + child: Column( + children: [ + ...sessionList.map( + (session) => Padding( + padding: const EdgeInsets.only( + bottom: 4, + ), + child: Builder( + builder: (context) { + final server = + serverById[session.serverId]; + final actions = + buildServerActionCallbacks( + context: context, + ref: ref, + server: server, + session: session, + ); + return ServerMenuItem( + serverName: session.serverName, + iconUrl: + iconUrlByServer[session + .serverId] ?? + '', + isActive: + session.isActive && + activeModuleId == null, + collapsed: effectiveCollapsed, + foregroundActive: palette.fg, + foregroundInactive: + palette.fgMuted, + onTap: () async { + if (!session.isActive) { + await ref + .read( + sessionProvider + .notifier, + ) + .switchSession( + session.serverId, + ); + widget.onNavigateBranch( + 0, + initialLocation: true, + ); + return; + } + if (widget.location == '/') { + await Future.wait([ + ref + .read( + libraryOverviewProvider + .notifier, + ) + .softRefresh(), + ref + .read( + homeBannerProvider + .notifier, + ) + .softRefresh(), + ]); + } else { + widget.onNavigateBranch( + 0, + initialLocation: true, + ); + } + }, + onChangeIcon: + actions.onChangeIcon, + onEdit: actions.onEdit, + onChangePassword: + actions.onChangePassword, + onTogglePause: + actions.onTogglePause, + onDelete: actions.onDelete, + ); + }, + ), + ), + ), + for (final installedWidget + in enabledScriptWidgets) + _navItem( + context, + icon: Icons.extension_rounded, + label: installedWidget.manifest.title, + selected: + activeModuleId == + installedWidget.manifest.id, + collapsed: effectiveCollapsed, + palette: palette, + + + onTap: () => context.go( + '/modules/${Uri.encodeComponent(installedWidget.manifest.id)}', + ), + ), + ], + ), + ), + ), + ] else + const Spacer(), + const SizedBox(height: 4), + _navItem( + context, + icon: Icons.settings_outlined, + label: '设置', + selected: false, + collapsed: effectiveCollapsed, + palette: palette, + onTap: () => showSettingsDialog(context), + ), + ], + ); + }, + ), + ), + ), + ), + ), + ), + ), + ), + ); + } + + Widget _buildSearchEntry( + BuildContext context, { + required bool collapsed, + required _SidebarPalette palette, + }) { + final theme = Theme.of(context); + + if (collapsed) { + return Tooltip( + message: '搜索', + preferBelow: false, + child: GestureDetector( + onTap: () => context.push('/global-search'), + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: SizedBox( + width: 44, + height: 38, + child: Center( + child: Icon( + Icons.search_outlined, + size: 20, + color: palette.fgFaint, + ), + ), + ), + ), + ), + ); + } + + return GestureDetector( + onTap: () => context.push('/global-search'), + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 6), + child: Row( + children: [ + Icon(Icons.search_outlined, size: 18, color: palette.fgMuted), + const SizedBox(width: 10), + Expanded( + child: Text( + '搜索', + style: theme.textTheme.bodySmall?.copyWith( + color: palette.fgMuted, + ), + ), + ), + Text( + '⌘K', + style: theme.textTheme.labelSmall?.copyWith( + color: palette.fgFaint, + fontSize: 10, + ), + ), + ], + ), + ), + ), + ); + } + + Widget _navItem( + BuildContext context, { + required IconData icon, + required String label, + required bool selected, + required bool collapsed, + required _SidebarPalette palette, + required VoidCallback onTap, + }) { + final theme = Theme.of(context); + final foreground = selected ? palette.fg : palette.fgMuted; + + final child = GestureDetector( + onTap: onTap, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: collapsed ? 0 : 4, + vertical: 8, + ), + child: collapsed + ? SizedBox( + width: 44, + child: Center(child: Icon(icon, size: 20, color: foreground)), + ) + : Row( + children: [ + Container( + width: 2, + height: 16, + margin: const EdgeInsets.only(right: 10), + decoration: BoxDecoration( + color: selected ? palette.fg : Colors.transparent, + borderRadius: BorderRadius.circular(1), + ), + ), + Icon(icon, size: 18, color: foreground), + const SizedBox(width: 10), + Expanded( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + color: foreground, + fontWeight: selected + ? FontWeight.w600 + : FontWeight.w400, + ), + ), + ), + ], + ), + ), + ), + ); + + if (collapsed) { + return Tooltip( + message: label, + preferBelow: false, + child: Padding(padding: const EdgeInsets.only(bottom: 2), child: child), + ); + } + + return Padding(padding: const EdgeInsets.only(bottom: 2), child: child); + } +} + +class _SidebarHeader extends StatelessWidget { + final bool collapsed; + final _SidebarPalette palette; + final VoidCallback onToggle; + + const _SidebarHeader({ + required this.collapsed, + required this.palette, + required this.onToggle, + }); + + @override + Widget build(BuildContext context) { + final tokens = context.appTokens; + + if (collapsed) { + return Column( + children: [ + TopDragArea( + child: SizedBox( + width: double.infinity, + height: tokens.titleBarHeight, + ), + ), + const SizedBox(height: 4), + GestureDetector( + onTap: onToggle, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: Icon(Icons.menu, size: 18, color: palette.fgFaint), + ), + ), + ], + ); + } + + return TopDragArea( + child: SizedBox( + height: tokens.titleBarHeight, + child: Row( + children: [ + const Spacer(), + GestureDetector( + onTap: onToggle, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: Padding( + padding: const EdgeInsets.all(4), + child: Icon(Icons.menu, size: 18, color: palette.fgFaint), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/shared/widgets/skeleton_media_row.dart b/lib/shared/widgets/skeleton_media_row.dart new file mode 100644 index 0000000..1e7a857 --- /dev/null +++ b/lib/shared/widgets/skeleton_media_row.dart @@ -0,0 +1,198 @@ +import 'package:flutter/material.dart'; + +import 'media_row_metrics.dart'; +import 'media_poster_card.dart'; + +class SkeletonMediaRow extends StatefulWidget { + final String title; + final MediaCardVariant cardVariant; + final Color? titleColor; + + const SkeletonMediaRow({ + super.key, + required this.title, + this.cardVariant = MediaCardVariant.poster, + this.titleColor, + }); + + @override + State createState() => _SkeletonMediaRowState(); +} + +class _SkeletonMediaRowState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1500), + )..repeat(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + double get _cardAspect => + widget.cardVariant == MediaCardVariant.landscape ? 16 / 9 : 2 / 3; + + bool get _showSubtitlePlaceholder => + widget.cardVariant == MediaCardVariant.landscape; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isLight = theme.brightness == Brightness.light; + final compact = MediaRowMetrics.isCompact(context); + final rowHeight = MediaRowMetrics.rowHeight( + widget.cardVariant, + compact: compact, + ); + final cardWidth = MediaRowMetrics.cardWidth( + widget.cardVariant, + compact: compact, + ); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(24, 12, 24, 10), + child: Text( + widget.title, + style: theme.textTheme.titleMedium?.copyWith( + color: widget.titleColor, + ), + ), + ), + SizedBox( + height: rowHeight, + child: ListView.separated( + scrollDirection: Axis.horizontal, + physics: const NeverScrollableScrollPhysics(), + padding: const EdgeInsets.symmetric(horizontal: 24), + itemCount: 6, + separatorBuilder: (_, _) => const SizedBox(width: 14), + itemBuilder: (_, _) => _SkeletonCard( + width: cardWidth, + aspect: _cardAspect, + showSubtitle: _showSubtitlePlaceholder, + isLight: isLight, + animation: _controller, + ), + ), + ), + ], + ); + } +} + +class _SkeletonCard extends StatelessWidget { + final double width; + final double aspect; + final bool showSubtitle; + final bool isLight; + final Animation animation; + + const _SkeletonCard({ + required this.width, + required this.aspect, + required this.showSubtitle, + required this.isLight, + required this.animation, + }); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: width, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AspectRatio( + aspectRatio: aspect, + child: AnimatedBuilder( + animation: animation, + builder: (context, child) { + final base = isLight + ? const Color(0xFFF3F4F6) + : const Color(0xFF2A2A2A); + final highlight = isLight + ? const Color(0xFFE5E7EB) + : const Color(0xFF3A3A3A); + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + gradient: LinearGradient( + begin: Alignment(-1.0 + 2.0 * animation.value, 0), + end: Alignment(-1.0 + 2.0 * animation.value + 1.0, 0), + colors: [base, highlight, base], + stops: const [0.0, 0.5, 1.0], + ), + ), + ); + }, + ), + ), + const SizedBox(height: 8), + AnimatedBuilder( + animation: animation, + builder: (context, child) { + final base = isLight + ? const Color(0xFFF3F4F6) + : const Color(0xFF2A2A2A); + final highlight = isLight + ? const Color(0xFFE5E7EB) + : const Color(0xFF3A3A3A); + return Container( + height: 12, + width: width * 0.7, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + gradient: LinearGradient( + begin: Alignment(-1.0 + 2.0 * animation.value, 0), + end: Alignment(-1.0 + 2.0 * animation.value + 1.0, 0), + colors: [base, highlight, base], + stops: const [0.0, 0.5, 1.0], + ), + ), + ); + }, + ), + if (showSubtitle) ...[ + const SizedBox(height: 6), + AnimatedBuilder( + animation: animation, + builder: (context, child) { + final base = isLight + ? const Color(0xFFF3F4F6) + : const Color(0xFF2A2A2A); + final highlight = isLight + ? const Color(0xFFE5E7EB) + : const Color(0xFF3A3A3A); + return Container( + height: 10, + width: width * 0.5, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + gradient: LinearGradient( + begin: Alignment(-1.0 + 2.0 * animation.value, 0), + end: Alignment(-1.0 + 2.0 * animation.value + 1.0, 0), + colors: [base, highlight, base], + stops: const [0.0, 0.5, 1.0], + ), + ), + ); + }, + ), + ], + ], + ), + ); + } +} diff --git a/lib/shared/widgets/smart_image.dart b/lib/shared/widgets/smart_image.dart new file mode 100644 index 0000000..7707b40 --- /dev/null +++ b/lib/shared/widgets/smart_image.dart @@ -0,0 +1,303 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../providers/emby_headers_provider.dart'; +import '../../providers/session_provider.dart'; + +class SmartImage extends ConsumerStatefulWidget { + final String? url; + final List fallbackUrls; + final BoxFit fit; + final Alignment alignment; + final double? aspectRatio; + final double borderRadius; + final IconData? fallbackIcon; + final VoidCallback? onLoad; + final VoidCallback? onError; + final Map? httpHeaders; + final Map? Function(String url)? resolveHttpHeaders; + + + final int? memCacheWidth; + + + final Widget? placeholder; + + const SmartImage({ + super.key, + required this.url, + this.fallbackUrls = const [], + this.fit = BoxFit.cover, + this.alignment = Alignment.center, + this.aspectRatio, + this.borderRadius = 8, + this.fallbackIcon = Icons.image_outlined, + this.onLoad, + this.onError, + this.httpHeaders, + this.resolveHttpHeaders, + this.memCacheWidth, + this.placeholder, + }); + + @override + ConsumerState createState() => _SmartImageState(); +} + +class _SmartImageState extends ConsumerState { + List _displayedUrls = const []; + int _failedCount = 0; + String? _pendingPrimaryUrl; + int? _lastMemCacheWidth; + + String? _embyBaseUrl; + Map? _embyHeaders; + + List _resolveAllUrls() { + final urls = []; + if (widget.url != null) urls.add(widget.url!); + urls.addAll(widget.fallbackUrls); + return urls; + } + + Map? _headersFor(String url) { + if (widget.resolveHttpHeaders != null) { + return widget.resolveHttpHeaders!(url); + } + if (widget.httpHeaders != null) return widget.httpHeaders; + final base = _embyBaseUrl; + if (base != null && url.startsWith(base)) return _embyHeaders; + return null; + } + + @override + void initState() { + super.initState(); + _displayedUrls = _resolveAllUrls(); + } + + @override + void didUpdateWidget(covariant SmartImage oldWidget) { + super.didUpdateWidget(oldWidget); + final newAll = _resolveAllUrls(); + final newPrimary = newAll.isEmpty ? null : newAll.first; + final currentPrimary = _displayedUrls.isEmpty ? null : _displayedUrls.first; + + if (newPrimary == null) { + if (_displayedUrls.isNotEmpty || _pendingPrimaryUrl != null) { + setState(() { + _displayedUrls = const []; + _failedCount = 0; + _pendingPrimaryUrl = null; + }); + } + return; + } + + if (currentPrimary == null) { + setState(() { + _displayedUrls = newAll; + _failedCount = 0; + _pendingPrimaryUrl = null; + }); + return; + } + + if (newPrimary == currentPrimary) { + _displayedUrls = newAll; + return; + } + + _preloadAndSwap(newPrimary, newAll); + } + + Future _preloadAndSwap(String newUrl, List newAll) async { + if (_pendingPrimaryUrl == newUrl) return; + _pendingPrimaryUrl = newUrl; + final provider = ResizeImage.resizeIfNeeded( + _lastMemCacheWidth, + null, + CachedNetworkImageProvider(newUrl, headers: _headersFor(newUrl)), + ); + try { + await precacheImage(provider, context); + } catch (_) { + if (_pendingPrimaryUrl == newUrl) _pendingPrimaryUrl = null; + return; + } + if (!mounted) return; + if (_pendingPrimaryUrl != newUrl) return; + setState(() { + _displayedUrls = newAll; + _failedCount = 0; + _pendingPrimaryUrl = null; + }); + } + + @override + Widget build(BuildContext context) { + if (widget.httpHeaders == null && widget.resolveHttpHeaders == null) { + _embyBaseUrl = ref.watch(activeSessionProvider)?.serverUrl; + _embyHeaders = ref.watch(embyImageHeadersProvider).value; + } else { + _embyBaseUrl = null; + _embyHeaders = null; + } + + final scheme = Theme.of(context).colorScheme; + final isLight = scheme.brightness == Brightness.light; + + Widget placeholderWidget() => + widget.placeholder ?? _ShimmerPlaceholder(isLight: isLight); + + final urls = _displayedUrls; + final effectiveUrl = _failedCount < urls.length ? urls[_failedCount] : null; + + final fallback = widget.fallbackIcon != null + ? Container( + color: scheme.surfaceContainerHighest, + child: Center( + child: Icon( + widget.fallbackIcon, + size: 32, + color: scheme.onSurfaceVariant.withValues(alpha: 0.4), + ), + ), + ) + : const SizedBox.shrink(); + + final Widget child; + if (effectiveUrl == null) { + child = KeyedSubtree( + key: const ValueKey('_smart_image_empty'), + child: fallback, + ); + } else { + final effectiveHeaders = _headersFor(effectiveUrl); + child = LayoutBuilder( + builder: (context, constraints) { + final memCacheWidth = + widget.memCacheWidth ?? + (constraints.maxWidth.isFinite + ? (constraints.maxWidth * + MediaQuery.devicePixelRatioOf(context)) + .ceil() + : null); + _lastMemCacheWidth = memCacheWidth; + + return CachedNetworkImage( + key: ValueKey(effectiveUrl), + imageUrl: effectiveUrl, + httpHeaders: effectiveHeaders, + fit: widget.fit, + alignment: widget.alignment, + memCacheWidth: memCacheWidth, + fadeInDuration: const Duration(milliseconds: 300), + fadeInCurve: Curves.easeOut, + placeholder: (_, _) => placeholderWidget(), + errorWidget: (_, e, st) { + if (_failedCount + 1 < urls.length) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) setState(() => _failedCount++); + }); + return placeholderWidget(); + } + WidgetsBinding.instance.addPostFrameCallback((_) { + widget.onError?.call(); + }); + return fallback; + }, + imageBuilder: (ctx, imageProvider) { + widget.onLoad?.call(); + return Image( + image: imageProvider, + fit: widget.fit, + alignment: widget.alignment, + ); + }, + ); + }, + ); + } + + final switcher = AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeOut, + layoutBuilder: (currentChild, previousChildren) { + return Stack( + fit: StackFit.passthrough, + children: [ + ...previousChildren, + if (currentChild != null) currentChild, + ], + ); + }, + child: child, + ); + + final clipped = ClipRRect( + borderRadius: BorderRadius.circular(widget.borderRadius), + child: switcher, + ); + + if (widget.aspectRatio != null) { + return AspectRatio(aspectRatio: widget.aspectRatio!, child: clipped); + } + return clipped; + } +} + +class _ShimmerPlaceholder extends StatefulWidget { + final bool isLight; + const _ShimmerPlaceholder({required this.isLight}); + + @override + State<_ShimmerPlaceholder> createState() => _ShimmerPlaceholderState(); +} + +class _ShimmerPlaceholderState extends State<_ShimmerPlaceholder> + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1500), + )..repeat(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _controller, + builder: (context, child) { + final base = widget.isLight + ? const Color(0xFFF3F4F6) + : const Color(0xFF2A2A2A); + final highlight = widget.isLight + ? const Color(0xFFE5E7EB) + : const Color(0xFF3A3A3A); + return Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment(-1.0 + 2.0 * _controller.value, 0), + end: Alignment(-1.0 + 2.0 * _controller.value + 1.0, 0), + colors: [base, highlight, base], + stops: const [0.0, 0.5, 1.0], + ), + ), + ); + }, + ); + } +} diff --git a/lib/shared/widgets/status_dot.dart b/lib/shared/widgets/status_dot.dart new file mode 100644 index 0000000..0fd1cac --- /dev/null +++ b/lib/shared/widgets/status_dot.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; + +import '../theme/app_theme.dart'; + +enum ConnectivityStatus { online, offline, unknown } + +Color statusColor(ConnectivityStatus status, AppTokens tokens) { + switch (status) { + case ConnectivityStatus.online: + return tokens.statusOnline; + case ConnectivityStatus.offline: + return tokens.statusOffline; + case ConnectivityStatus.unknown: + return tokens.mutedForeground; + } +} + + +class StatusDot extends StatelessWidget { + final ConnectivityStatus status; + final double size; + + const StatusDot({super.key, required this.status, this.size = 9}); + + @override + Widget build(BuildContext context) { + final tokens = context.appTokens; + final color = statusColor(status, tokens); + return AnimatedContainer( + duration: const Duration(milliseconds: 220), + width: size, + height: size, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: color.withValues(alpha: 0.5), + blurRadius: 4, + spreadRadius: 0.5, + ), + ], + ), + ); + } +} diff --git a/lib/shared/widgets/tmdb_poster_card.dart b/lib/shared/widgets/tmdb_poster_card.dart new file mode 100644 index 0000000..808ddbe --- /dev/null +++ b/lib/shared/widgets/tmdb_poster_card.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart'; + +import '../mappers/tmdb_image_url.dart'; +import '../theme/app_theme.dart'; +import 'app_card.dart'; +import 'metadata_chip.dart'; +import 'smart_image.dart'; + + +class TmdbPosterCard extends StatelessWidget { + final String title; + final String? posterPath; + final double? voteAverage; + + + final String? mediaTypeLabel; + final String imageBaseUrl; + final double width; + final VoidCallback? onTap; + + + final Color? textColor; + + const TmdbPosterCard({ + super.key, + required this.title, + required this.imageBaseUrl, + required this.width, + this.posterPath, + this.voteAverage, + this.mediaTypeLabel, + this.onTap, + this.textColor, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final tokens = context.appTokens; + final posterUrl = TmdbImageUrl.poster(imageBaseUrl, posterPath); + + final chipFg = textColor?.withValues(alpha: 0.65); + final chipBg = textColor?.withValues(alpha: 0.12); + + return SizedBox( + width: width, + child: AppCard( + filled: false, + enableHover: onTap != null, + radius: tokens.radiusCard, + padding: EdgeInsets.zero, + onTap: onTap, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: SmartImage( + url: posterUrl, + fallbackIcon: Icons.movie_outlined, + borderRadius: 8, + ), + ), + const SizedBox(height: 8), + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.w500, + color: textColor, + ), + ), + if (voteAverage != null && voteAverage! > 0) ...[ + const SizedBox(height: 2), + Row( + children: [ + MetadataChip( + icon: Icons.star_rounded, + label: voteAverage!.toStringAsFixed(1), + foreground: tokens.ratingColor, + background: chipBg, + ), + if (mediaTypeLabel != null) ...[ + const SizedBox(width: 6), + MetadataChip( + label: mediaTypeLabel!, + foreground: chipFg, + background: chipBg, + ), + ], + ], + ), + ], + ], + ), + ), + ); + } +} diff --git a/lib/shared/widgets/top_drag_area.dart b/lib/shared/widgets/top_drag_area.dart new file mode 100644 index 0000000..a2ae39f --- /dev/null +++ b/lib/shared/widgets/top_drag_area.dart @@ -0,0 +1,98 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:window_manager/window_manager.dart'; + + +class TopDragArea extends StatefulWidget { + final Widget child; + final double topResizeZone; + + + final bool enabled; + + const TopDragArea({ + super.key, + required this.child, + this.topResizeZone = 6, + this.enabled = true, + }); + + @override + State createState() => _TopDragAreaState(); +} + +class _TopDragAreaState extends State with WindowListener { + bool _isMaximized = false; + + bool get _supported => + widget.enabled && (Platform.isMacOS || Platform.isWindows); + + @override + void initState() { + super.initState(); + if (_supported) { + windowManager.addListener(this); + _syncMaximized(); + } + } + + @override + void dispose() { + if (Platform.isMacOS || Platform.isWindows) { + windowManager.removeListener(this); + } + super.dispose(); + } + + Future _syncMaximized() async { + try { + final maximized = await windowManager.isMaximized(); + if (!mounted) return; + if (maximized != _isMaximized) { + setState(() => _isMaximized = maximized); + } + } catch (_) {} + } + + @override + void onWindowMaximize() { + if (mounted) setState(() => _isMaximized = true); + } + + @override + void onWindowUnmaximize() { + if (mounted) setState(() => _isMaximized = false); + } + + @override + Widget build(BuildContext context) { + if (!_supported) return widget.child; + final showResizeZone = + Platform.isWindows && !_isMaximized && widget.topResizeZone > 0; + final deadZone = showResizeZone ? widget.topResizeZone : 0.0; + + return Stack( + children: [ + Padding( + padding: EdgeInsets.only(top: deadZone), + child: DragToMoveArea(child: widget.child), + ), + if (showResizeZone) + Positioned( + top: 0, + left: 0, + right: 0, + height: deadZone, + child: MouseRegion( + cursor: SystemMouseCursors.resizeUp, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onPanStart: (_) => windowManager.startResizing(ResizeEdge.top), + ), + ), + ), + ], + ); + } +} diff --git a/lib/shared/widgets/version_filter.dart b/lib/shared/widgets/version_filter.dart new file mode 100644 index 0000000..d78a366 --- /dev/null +++ b/lib/shared/widgets/version_filter.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; + + +const kVersionBucketOrder = ['4K', '2K', '1080P', '其他']; + + +String versionBucketOf(String? resolutionLabel) { + switch (resolutionLabel) { + case '4K': + case '2K': + case '1080P': + return resolutionLabel!; + default: + return '其他'; + } +} + + +class VersionFilterChip extends StatelessWidget { + final String label; + final bool selected; + final bool showDot; + final VoidCallback onTap; + + const VersionFilterChip({ + super.key, + required this.label, + required this.selected, + required this.onTap, + this.showDot = false, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: selected + ? Colors.white.withValues(alpha: 0.18) + : Colors.white.withValues(alpha: 0.06), + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: Colors.white.withValues(alpha: selected ? 0.5 : 0.15), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (showDot) ...[ + Container( + width: 6, + height: 6, + decoration: const BoxDecoration( + color: Color(0xFF4F8DFF), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 6), + ], + Text( + label, + style: TextStyle( + fontSize: 13, + color: Colors.white.withValues(alpha: selected ? 1 : 0.7), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/shared/widgets/version_grouping.dart b/lib/shared/widgets/version_grouping.dart new file mode 100644 index 0000000..2558063 --- /dev/null +++ b/lib/shared/widgets/version_grouping.dart @@ -0,0 +1,102 @@ +import 'version_source_card.dart'; + + +class VersionSourceGroup { + final List members; + + VersionSourceGroup(List members) + : assert(members.isNotEmpty), + members = List.unmodifiable(members); + + VersionSourceCardData get representative => members.first; + + VersionSourceCardData? get activeMember { + for (final member in members) { + if (member.isCurrent || member.selected) return member; + } + return null; + } + + bool get hasActiveMember => activeMember != null; + + bool get hasCurrentMember => members.any((member) => member.isCurrent); + + List get serverNames => + List.unmodifiable([for (final member in members) member.serverName]); + + + VersionSourceCardData get displayData { + final visibleMember = activeMember ?? representative; + final names = serverNames; + final hasCurrent = hasCurrentMember; + final hasSelected = !hasCurrent && members.any((member) => member.selected); + final serverList = names.join('、'); + + return VersionSourceCardData( + topLabel: visibleMember.topLabel, + sizeBytes: visibleMember.sizeBytes, + bitrate: visibleMember.bitrate, + serverName: visibleMember.serverName, + serverKey: visibleMember.serverKey, + isCurrent: hasCurrent, + selected: hasSelected, + onTap: visibleMember.onTap, + tooltip: names.length > 1 ? '可用服务器:$serverList' : visibleMember.tooltip, + resolutionLabel: visibleMember.resolutionLabel, + sourceKey: visibleMember.sourceKey, + mergedServerNames: names, + ); + } +} + + +String? versionGroupKeyOf({ + required int? sizeBytes, + required String resolutionLabel, +}) { + if (sizeBytes == null || sizeBytes <= 0) return null; + return '$sizeBytes|${resolutionLabel.trim().toUpperCase()}'; +} + + +List groupVersionEntries( + List entries, +) { + final groupedMembers = >>{}; + final orderedMemberLists = >[]; + + for (final entry in entries) { + final groupKey = versionGroupKeyOf( + sizeBytes: entry.sizeBytes, + resolutionLabel: entry.resolutionLabel, + ); + if (groupKey == null) { + orderedMemberLists.add([entry]); + continue; + } + + final candidateGroups = groupedMembers[groupKey] ?? const []; + List? matchingGroup; + for (final candidateGroup in candidateGroups) { + final alreadyContainsServer = candidateGroup.any( + (member) => member.serverKey == entry.serverKey, + ); + if (!alreadyContainsServer) { + matchingGroup = candidateGroup; + break; + } + } + if (matchingGroup != null && entry.serverKey.isNotEmpty) { + matchingGroup.add(entry); + continue; + } + + final newMembers = [entry]; + groupedMembers.putIfAbsent(groupKey, () => []).add(newMembers); + orderedMemberLists.add(newMembers); + } + + return [ + for (final members in orderedMemberLists) VersionSourceGroup(members), + ]; +} diff --git a/lib/shared/widgets/version_source_card.dart b/lib/shared/widgets/version_source_card.dart new file mode 100644 index 0000000..7bdaef0 --- /dev/null +++ b/lib/shared/widgets/version_source_card.dart @@ -0,0 +1,493 @@ +import 'dart:io' show Platform; + +import 'package:flutter/material.dart'; + +import '../../core/contracts/library.dart'; +import '../utils/format_utils.dart'; +import 'app_loading_ring.dart'; +import 'app_snack_bar.dart'; +import 'frosted_panel.dart'; + + +@immutable +class VersionSourceCardData { + + final String topLabel; + + + final int? sizeBytes; + + + final int? bitrate; + + + final String serverName; + + + final String serverKey; + + + final String sourceKey; + + + final List mergedServerNames; + + + final bool isCurrent; + + + final bool selected; + + + final Future Function()? onTap; + + + final String? tooltip; + + + final String resolutionLabel; + + const VersionSourceCardData({ + required this.topLabel, + required this.serverName, + this.serverKey = '', + this.sourceKey = '', + this.mergedServerNames = const [], + this.sizeBytes, + this.bitrate, + this.isCurrent = false, + this.selected = false, + this.onTap, + this.tooltip, + this.resolutionLabel = '', + }); + + + factory VersionSourceCardData.fromCrossServer( + CrossServerSourceCard card, { + bool isCurrent = false, + bool selected = false, + Future Function()? onTap, + }) { + final src = card.mediaSource; + return VersionSourceCardData( + topLabel: _topLabelFromQuality(card.quality), + serverName: card.serverName, + serverKey: card.serverId, + sourceKey: '${card.serverId}|${card.mediaSourceId}', + sizeBytes: src.Size, + bitrate: src.Bitrate, + isCurrent: isCurrent, + selected: selected, + onTap: onTap, + tooltip: '切换到 ${card.serverName}', + resolutionLabel: card.quality?.resolutionLabel ?? '', + ); + } + + + factory VersionSourceCardData.fromLocalSource( + EmbyRawMediaSource source, { + required String serverName, + String serverKey = '', + bool isCurrent = false, + Future Function()? onTap, + String? tooltip, + }) { + final quality = MediaQualityInfo.fromMediaSource(source); + return VersionSourceCardData( + topLabel: _topLabelFromQuality(quality), + serverName: serverName, + serverKey: serverKey, + sourceKey: '$serverKey|${source.Id ?? ''}', + sizeBytes: source.Size, + bitrate: source.Bitrate, + isCurrent: isCurrent, + onTap: onTap, + tooltip: tooltip, + resolutionLabel: quality.resolutionLabel, + ); + } +} + +String _abbrevRange(String label) { + if (label.isEmpty) return 'SDR'; + if (label == 'Dolby Vision') return 'Dolby'; + return label; +} + +String _topLabelFromQuality(MediaQualityInfo? quality) { + final resolutionLabel = quality?.resolutionLabel ?? ''; + final rangeLabel = _abbrevRange(quality?.dynamicRangeLabel ?? ''); + final parts = []; + if (resolutionLabel.isNotEmpty) parts.add(resolutionLabel); + parts.add(rangeLabel); + return parts.join(' '); +} + + +class VersionSourceCard extends StatefulWidget { + final VersionSourceCardData data; + final bool compact; + final VoidCallback? onMergedServersTap; + + const VersionSourceCard({ + super.key, + required this.data, + this.compact = false, + this.onMergedServersTap, + }); + + @override + State createState() => _VersionSourceCardState(); +} + +class _VersionSourceCardState extends State { + bool _loading = false; + + Future _open() async { + final data = widget.data; + if (_loading || data.isCurrent) return; + final tap = data.onTap; + if (tap == null) return; + setState(() => _loading = true); + try { + await tap(); + } catch (_) { + if (!mounted) return; + showAppSnackBar(context, '切换服务器失败', tone: AppSnackTone.error); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + @override + Widget build(BuildContext context) { + final data = widget.data; + final sizeBytes = data.sizeBytes; + final bitrate = data.bitrate; + + final hasSize = sizeBytes != null && sizeBytes > 0; + final hasBitrate = bitrate != null && bitrate > 0; + final showInfoRow = hasSize || hasBitrate; + final isCurrent = data.isCurrent; + final highlighted = isCurrent || data.selected; + final representedServerCount = data.mergedServerNames.length; + final hasMergedServers = representedServerCount > 1; + + final isAndroid = Platform.isAndroid; + final compact = widget.compact; + final cardWidth = compact ? 130.0 : (isAndroid ? 170.0 : 220.0); + final cardHeight = compact ? 52.0 : (isAndroid ? 100.0 : 120.0); + final radius = compact ? 10.0 : 16.0; + final textColor = Colors.white.withValues(alpha: 0.85); + + final card = SizedBox( + width: cardWidth, + height: cardHeight, + child: Stack( + fit: StackFit.expand, + children: [ + FrostedPanel( + radius: radius, + padding: compact + ? const EdgeInsets.symmetric(horizontal: 10, vertical: 6) + : const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + blurSigma: 15, + border: Border.all(color: Colors.white.withValues(alpha: 0.08)), + color: highlighted + ? const Color(0xFF4F8DFF).withValues(alpha: 0.18) + : Colors.white.withValues(alpha: 0.10), + child: compact + ? _buildCompactContent(data, hasMergedServers) + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Expanded( + child: Text( + data.topLabel, + style: TextStyle( + fontSize: isAndroid ? 16 : 18, + fontWeight: FontWeight.w700, + color: Colors.white, + height: 1.1, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (isCurrent) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + decoration: BoxDecoration( + color: const Color(0xFF4F8DFF), + borderRadius: BorderRadius.circular(999), + ), + child: const Text( + '当前', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ), + ], + ), + if (showInfoRow) + Row( + children: [ + if (hasSize) ...[ + Icon( + Icons.movie_outlined, + size: isAndroid ? 13 : 14, + color: textColor, + ), + SizedBox(width: isAndroid ? 3 : 4), + Flexible( + child: Text( + formatFileSize(sizeBytes), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: isAndroid ? 12 : 13, + color: textColor, + ), + ), + ), + ], + if (hasSize && hasBitrate) + SizedBox(width: isAndroid ? 6 : 12), + if (hasBitrate) ...[ + Icon( + Icons.equalizer, + size: isAndroid ? 13 : 14, + color: textColor, + ), + SizedBox(width: isAndroid ? 3 : 4), + Flexible( + child: Text( + '${(bitrate / 1e6).toStringAsFixed(2)}Mbps', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: isAndroid ? 12 : 13, + color: textColor, + ), + ), + ), + ], + ], + ), + Row( + children: [ + Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + color: Color(0xFF4F8DFF), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 6), + Expanded( + child: Text( + data.serverName, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: Colors.white, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (hasMergedServers) ...[ + const SizedBox(width: 6), + _MergedServersButton( + serverCount: representedServerCount, + onTap: widget.onMergedServersTap, + ), + ], + const SizedBox(width: 6), + Icon( + Icons.bolt, + size: 14, + color: Colors.white.withValues(alpha: 0.85), + ), + ], + ), + ], + ), + ), + if (highlighted) + Positioned.fill( + child: IgnorePointer( + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(radius), + border: Border.all( + color: const Color(0xFF4F8DFF), + width: 1.5, + ), + ), + ), + ), + ), + if (_loading) + Positioned.fill( + child: ClipRRect( + borderRadius: BorderRadius.circular(radius), + child: Container( + color: Colors.black.withValues(alpha: 0.3), + child: Center( + child: AppLoadingRing( + size: compact ? 14.0 : 18.0, + color: Colors.white, + ), + ), + ), + ), + ), + ], + ), + ); + + final tappable = GestureDetector(onTap: _open, child: card); + + final tooltip = data.tooltip; + if (tooltip == null) return tappable; + return Tooltip(message: tooltip, child: tappable); + } + + Widget _buildCompactContent( + VersionSourceCardData data, + bool hasMergedServers, + ) { + final sizeBytes = data.sizeBytes; + final secondaryColor = Colors.white.withValues(alpha: 0.65); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Row( + children: [ + Expanded( + child: Text( + data.topLabel, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: Colors.white, + height: 1.1, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (data.isCurrent) + Container( + margin: const EdgeInsets.only(left: 4), + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), + decoration: BoxDecoration( + color: const Color(0xFF4F8DFF), + borderRadius: BorderRadius.circular(999), + ), + child: const Text( + '当前', + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ), + if (hasMergedServers) ...[ + const SizedBox(width: 4), + _MergedServersButton( + serverCount: data.mergedServerNames.length, + onTap: widget.onMergedServersTap, + compact: true, + ), + ], + ], + ), + const SizedBox(height: 3), + Text( + [ + if (sizeBytes != null && sizeBytes > 0) formatFileSize(sizeBytes), + data.serverName, + ].join(' · '), + style: TextStyle(fontSize: 11, color: secondaryColor), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ); + } +} + +class _MergedServersButton extends StatelessWidget { + final int serverCount; + final VoidCallback? onTap; + final bool compact; + + const _MergedServersButton({ + required this.serverCount, + required this.onTap, + this.compact = false, + }); + + @override + Widget build(BuildContext context) { + final content = Container( + padding: EdgeInsets.symmetric( + horizontal: compact ? 4 : 6, + vertical: compact ? 1 : 2, + ), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(999), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '$serverCount服', + style: TextStyle( + fontSize: compact ? 9 : 10, + fontWeight: FontWeight.w600, + color: Colors.white70, + ), + ), + const SizedBox(width: 2), + Icon( + Icons.keyboard_arrow_down_rounded, + size: compact ? 11 : 13, + color: Colors.white70, + ), + ], + ), + ); + + final callback = onTap; + if (callback == null) return content; + return Tooltip( + message: '选择其他服务器', + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: callback, + child: content, + ), + ), + ); + } +} diff --git a/lib/shared/widgets/warm_gradient_background.dart b/lib/shared/widgets/warm_gradient_background.dart new file mode 100644 index 0000000..d80d290 --- /dev/null +++ b/lib/shared/widgets/warm_gradient_background.dart @@ -0,0 +1,22 @@ +import 'package:flutter/material.dart'; + +import '../theme/app_theme.dart'; + +class WarmGradientBackground extends StatelessWidget { + const WarmGradientBackground({super.key}); + + @override + Widget build(BuildContext context) { + final colors = context.appTokens.warmGradientColors; + return DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: colors, + ), + ), + child: const SizedBox.expand(), + ); + } +} diff --git a/lib/shared/widgets/window_chrome.dart b/lib/shared/widgets/window_chrome.dart new file mode 100644 index 0000000..722c353 --- /dev/null +++ b/lib/shared/widgets/window_chrome.dart @@ -0,0 +1,31 @@ +import 'dart:io'; + +import '../theme/app_theme.dart'; + + +class WindowChrome { + WindowChrome._(); + + + static const double buttonWidth = 36; + + + static const double controlsHeight = 30; + + + static const int buttonCount = 3; + + + static const double containerHorizontalPadding = 12; + + + static const double controlsWidth = + buttonWidth * buttonCount + containerHorizontalPadding; + + + static bool get isDesktop => Platform.isMacOS || Platform.isWindows; + + + static double reservedTrailingWidth(AppTokens tokens) => + isDesktop ? controlsWidth + tokens.chromeInset + 8 : 0; +} diff --git a/lib/shared/widgets/window_chrome_overlay.dart b/lib/shared/widgets/window_chrome_overlay.dart new file mode 100644 index 0000000..2fe8916 --- /dev/null +++ b/lib/shared/widgets/window_chrome_overlay.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + +import '../theme/app_theme.dart'; +import 'top_drag_area.dart'; +import 'window_chrome.dart'; +import 'window_controls.dart'; + + +class WindowChromeOverlay extends StatelessWidget { + + + final bool onDarkSurface; + + const WindowChromeOverlay({super.key, this.onDarkSurface = false}); + + @override + Widget build(BuildContext context) { + if (!WindowChrome.isDesktop) { + return const SizedBox.shrink(); + } + final AppTokens tokens = context.appTokens; + return Positioned( + top: 0, + left: 0, + right: 0, + height: tokens.titleBarHeight, + child: _HitTestPassThrough( + child: Stack( + children: [ + const Positioned.fill( + child: TopDragArea(child: SizedBox.expand()), + ), + Positioned( + top: tokens.chromeInset, + right: tokens.chromeInset, + child: onDarkSurface + ? const WindowControls.forDarkSurface() + : const WindowControls.forShellOverlay(), + ), + ], + ), + ), + ); + } +} + + +class WindowChromeHost extends StatelessWidget { + final Widget child; + final bool onDarkSurface; + + const WindowChromeHost({ + super.key, + required this.child, + this.onDarkSurface = false, + }); + + @override + Widget build(BuildContext context) { + if (!WindowChrome.isDesktop) { + return child; + } + return Stack( + fit: StackFit.expand, + children: [ + child, + WindowChromeOverlay(onDarkSurface: onDarkSurface), + ], + ); + } +} + + +class _HitTestPassThrough extends SingleChildRenderObjectWidget { + const _HitTestPassThrough({required super.child}); + + @override + RenderObject createRenderObject(BuildContext context) => + _RenderHitTestPassThrough(); +} + +class _RenderHitTestPassThrough extends RenderProxyBox { + @override + bool hitTest(BoxHitTestResult result, {required Offset position}) { + hitTestChildren(result, position: position); + return false; + } +} diff --git a/lib/shared/widgets/window_controls.dart b/lib/shared/widgets/window_controls.dart new file mode 100644 index 0000000..95b76ba --- /dev/null +++ b/lib/shared/widgets/window_controls.dart @@ -0,0 +1,276 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:window_manager/window_manager.dart'; + +import 'window_chrome.dart'; + +final _windowControlsBinding = _WindowControlsBinding(); + +class WindowControls extends StatefulWidget { + final Brightness? brightness; + + const WindowControls({super.key, this.brightness}); + + + const WindowControls.forShellOverlay({super.key}) : brightness = null; + + + const WindowControls.forDarkSurface({super.key}) + : brightness = Brightness.dark; + + @override + State createState() => _WindowControlsState(); +} + +class _WindowControlsState extends State { + @override + void initState() { + super.initState(); + unawaited(_windowControlsBinding.ensureInitialized()); + } + + Future _toggleMaximize() async { + if (_windowControlsBinding.isMaximized.value) { + await windowManager.unmaximize(); + return; + } + await windowManager.maximize(); + } + + @override + Widget build(BuildContext context) { + if (!Platform.isWindows && !Platform.isMacOS) { + return const SizedBox.shrink(); + } + + final resolvedBrightness = + widget.brightness ?? Theme.of(context).brightness; + final isLight = resolvedBrightness == Brightness.light; + + return ValueListenableBuilder( + valueListenable: _windowControlsBinding.isMaximized, + builder: (context, isMaximized, _) { + final backgroundColor = isLight + ? Colors.black.withValues(alpha: 0.14) + : const Color(0xFF08090c).withValues(alpha: 0.72); + final iconColor = isLight + ? Colors.black.withValues(alpha: 0.65) + : Colors.white.withValues(alpha: 0.85); + final defaultHoverColor = isLight + ? Colors.black.withValues(alpha: 0.10) + : Colors.white.withValues(alpha: 0.15); + const containerHeight = WindowChrome.controlsHeight; + const containerRadius = 20.0; + const buttonWidth = WindowChrome.buttonWidth; + const iconSize = 16.0; + + return Container( + height: containerHeight, + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(containerRadius), + boxShadow: isLight + ? null + : const [ + BoxShadow( + color: Color(0x2E000000), + blurRadius: 8, + offset: Offset(0, 2), + ), + ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _ControlButton( + icon: Icons.remove_rounded, + tooltip: '最小化', + iconColor: iconColor, + defaultHoverColor: defaultHoverColor, + width: buttonWidth, + height: containerHeight, + iconSize: iconSize, + borderRadius: containerRadius, + onTap: windowManager.minimize, + ), + _ControlButton( + icon: isMaximized + ? Icons.filter_none_rounded + : Icons.crop_square_rounded, + tooltip: isMaximized ? '还原' : '最大化', + iconColor: iconColor, + defaultHoverColor: defaultHoverColor, + width: buttonWidth, + height: containerHeight, + iconSize: iconSize, + borderRadius: containerRadius, + onTap: _toggleMaximize, + ), + _ControlButton( + icon: Icons.close_rounded, + tooltip: '关闭', + iconColor: iconColor, + defaultHoverColor: defaultHoverColor, + hoverColor: const Color(0xFFE81123), + pressedColor: const Color(0xFFC50F1F), + width: buttonWidth, + height: containerHeight, + iconSize: iconSize, + borderRadius: containerRadius, + onTap: windowManager.close, + ), + ], + ), + ); + }, + ); + } +} + +class _WindowControlsBinding with WindowListener { + final ValueNotifier isMaximized = ValueNotifier(false); + + Future ensureInitialized() async { + if (!windowManager.listeners.contains(this)) { + windowManager.addListener(this); + } + try { + isMaximized.value = await windowManager.isMaximized(); + } catch (_) {} + } + + @override + void onWindowMaximize() { + isMaximized.value = true; + } + + @override + void onWindowUnmaximize() { + isMaximized.value = false; + } + + void reset() { + if (windowManager.listeners.contains(this)) { + windowManager.removeListener(this); + } + isMaximized.value = false; + } +} + +class _ControlButton extends StatefulWidget { + final IconData icon; + final String tooltip; + final Color iconColor; + final Color defaultHoverColor; + final Color? hoverColor; + final Color? pressedColor; + final double width; + final double height; + final double iconSize; + final double borderRadius; + final VoidCallback onTap; + + const _ControlButton({ + required this.icon, + required this.tooltip, + required this.iconColor, + required this.defaultHoverColor, + this.hoverColor, + this.pressedColor, + this.width = 36, + this.height = 30, + this.iconSize = 16, + this.borderRadius = 20, + required this.onTap, + }); + + @override + State<_ControlButton> createState() => _ControlButtonState(); +} + +class _ControlButtonState extends State<_ControlButton> { + bool _hovering = false; + bool _pressing = false; + bool _focused = false; + late final FocusNode _focusNode; + + @override + void initState() { + super.initState(); + _focusNode = FocusNode(); + _focusNode.addListener(_onFocusChange); + } + + @override + void dispose() { + _focusNode.removeListener(_onFocusChange); + _focusNode.dispose(); + super.dispose(); + } + + void _onFocusChange() { + setState(() => _focused = _focusNode.hasFocus); + } + + bool get _active => _hovering || _focused; + + @override + Widget build(BuildContext context) { + final hoverBg = widget.hoverColor ?? widget.defaultHoverColor; + final pressedBg = + widget.pressedColor ?? + widget.defaultHoverColor.withValues( + alpha: widget.defaultHoverColor.a * 1.8, + ); + + final Color bg; + if (_pressing) { + bg = widget.hoverColor != null + ? (widget.pressedColor ?? hoverBg) + : pressedBg; + } else if (_active) { + bg = hoverBg; + } else { + bg = Colors.transparent; + } + + final bool useBrightIcon = + (_active || _pressing) && widget.hoverColor != null; + + return Tooltip( + message: widget.tooltip, + child: Focus( + focusNode: _focusNode, + child: MouseRegion( + onEnter: (_) => setState(() => _hovering = true), + onExit: (_) => setState(() { + _hovering = false; + _pressing = false; + }), + child: GestureDetector( + onTap: widget.onTap, + onTapDown: (_) => setState(() => _pressing = true), + onTapUp: (_) => setState(() => _pressing = false), + onTapCancel: () => setState(() => _pressing = false), + child: Container( + width: widget.width, + height: widget.height, + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(widget.borderRadius), + ), + alignment: Alignment.center, + child: Icon( + widget.icon, + size: widget.iconSize, + color: useBrightIcon ? Colors.white : widget.iconColor, + ), + ), + ), + ), + ), + ); + } +} diff --git a/macos/.gitignore b/macos/.gitignore new file mode 100644 index 0000000..746adbb --- /dev/null +++ b/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/macos/Flutter/Flutter-Debug.xcconfig b/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 0000000..4b81f9b --- /dev/null +++ b/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/Flutter-Release.xcconfig b/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 0000000..5caa9d1 --- /dev/null +++ b/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Podfile b/macos/Podfile new file mode 100644 index 0000000..c8cdecb --- /dev/null +++ b/macos/Podfile @@ -0,0 +1,55 @@ +platform :osx, '13.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\"" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_macos_podfile_setup + +def delete_rpath_if_present(binary, rpath) + return unless File.exist?(binary) + + load_commands = `"/usr/bin/otool" -l "#{binary}"` + return unless load_commands.include?("path #{rpath} ") + + unless system('/usr/bin/install_name_tool', '-delete_rpath', rpath, binary) + raise "Failed to delete #{rpath} from #{binary}" + end +end + + +target 'Runner' do + use_frameworks! + + flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) + + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_macos_build_settings(target) + end +end diff --git a/macos/Podfile.lock b/macos/Podfile.lock new file mode 100644 index 0000000..8e4c229 --- /dev/null +++ b/macos/Podfile.lock @@ -0,0 +1,118 @@ +PODS: + - app_links (6.4.1): + - FlutterMacOS + - file_selector_macos (0.0.1): + - FlutterMacOS + - fjs (3.1.0): + - Flutter + - FlutterMacOS + - FlutterMacOS (1.0.0) + - fvp (0.36.1): + - Flutter + - FlutterMacOS + - mdk (~> 0.36.0) + - mdk (0.36.0) + - media_kit_libs_macos_video (1.0.4): + - FlutterMacOS + - media_kit_video (0.0.1): + - FlutterMacOS + - package_info_plus (0.0.1): + - FlutterMacOS + - screen_brightness_macos (0.1.0): + - FlutterMacOS + - screen_retriever_macos (0.0.1): + - FlutterMacOS + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS + - sqflite_darwin (0.0.4): + - Flutter + - FlutterMacOS + - url_launcher_macos (0.0.1): + - FlutterMacOS + - video_player_avfoundation (0.0.1): + - Flutter + - FlutterMacOS + - wakelock_plus (0.0.1): + - FlutterMacOS + - window_manager (0.5.0): + - FlutterMacOS + +DEPENDENCIES: + - app_links (from `Flutter/ephemeral/.symlinks/plugins/app_links/macos`) + - file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`) + - fjs (from `Flutter/ephemeral/.symlinks/plugins/fjs/darwin`) + - FlutterMacOS (from `Flutter/ephemeral`) + - fvp (from `Flutter/ephemeral/.symlinks/plugins/fvp/darwin`) + - media_kit_libs_macos_video (from `Flutter/ephemeral/.symlinks/plugins/media_kit_libs_macos_video/macos`) + - media_kit_video (from `Flutter/ephemeral/.symlinks/plugins/media_kit_video/macos`) + - package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`) + - screen_brightness_macos (from `Flutter/ephemeral/.symlinks/plugins/screen_brightness_macos/macos`) + - screen_retriever_macos (from `Flutter/ephemeral/.symlinks/plugins/screen_retriever_macos/macos`) + - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) + - sqflite_darwin (from `Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin`) + - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) + - video_player_avfoundation (from `Flutter/ephemeral/.symlinks/plugins/video_player_avfoundation/darwin`) + - wakelock_plus (from `Flutter/ephemeral/.symlinks/plugins/wakelock_plus/macos`) + - window_manager (from `Flutter/ephemeral/.symlinks/plugins/window_manager/macos`) + +SPEC REPOS: + trunk: + - mdk + +EXTERNAL SOURCES: + app_links: + :path: Flutter/ephemeral/.symlinks/plugins/app_links/macos + file_selector_macos: + :path: Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos + fjs: + :path: Flutter/ephemeral/.symlinks/plugins/fjs/darwin + FlutterMacOS: + :path: Flutter/ephemeral + fvp: + :path: Flutter/ephemeral/.symlinks/plugins/fvp/darwin + media_kit_libs_macos_video: + :path: Flutter/ephemeral/.symlinks/plugins/media_kit_libs_macos_video/macos + media_kit_video: + :path: Flutter/ephemeral/.symlinks/plugins/media_kit_video/macos + package_info_plus: + :path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos + screen_brightness_macos: + :path: Flutter/ephemeral/.symlinks/plugins/screen_brightness_macos/macos + screen_retriever_macos: + :path: Flutter/ephemeral/.symlinks/plugins/screen_retriever_macos/macos + shared_preferences_foundation: + :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin + sqflite_darwin: + :path: Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin + url_launcher_macos: + :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos + video_player_avfoundation: + :path: Flutter/ephemeral/.symlinks/plugins/video_player_avfoundation/darwin + wakelock_plus: + :path: Flutter/ephemeral/.symlinks/plugins/wakelock_plus/macos + window_manager: + :path: Flutter/ephemeral/.symlinks/plugins/window_manager/macos + +SPEC CHECKSUMS: + app_links: 05a6ec2341985eb05e9f97dc63f5837c39895c3f + file_selector_macos: 9e9e068e90ebee155097d00e89ae91edb2374db7 + fjs: ec057ba34f3df5d1c24b4ff94f4b36658951e576 + FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 + fvp: f8b3b61cf8c696ecaa5608ae0d282ba710e65ae9 + mdk: 3bfb53e0bcc9643180eaa864360051f281e4c17b + media_kit_libs_macos_video: 85a23e549b5f480e72cae3e5634b5514bc692f65 + media_kit_video: fa6564e3799a0a28bff39442334817088b7ca758 + package_info_plus: f0052d280d17aa382b932f399edf32507174e870 + screen_brightness_macos: 09aa40f72b71c5dc51d72eba86938da580f6dab8 + screen_retriever_macos: c5508cc3c66ff0d4db650480cf0ab691e220d933 + shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb + sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 + url_launcher_macos: f87a979182d112f911de6820aefddaf56ee9fbfd + video_player_avfoundation: 3453f792138786248960ca029747fcd9f318ef52 + wakelock_plus: 917609be14d812ddd9e9528876538b2263aaa03b + window_manager: b729e31d38fb04905235df9ea896128991cad99e + +PODFILE CHECKSUM: 3cb5e292f3c7e753b091ad404a82b5f514df078e + +COCOAPODS: 1.16.2 diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..7fb0b0d --- /dev/null +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,823 @@ + +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + + + 33CC111A2044C6BA0003C045 = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 ; + buildPhases = ( + 33CC111E2044C6BF0003C045 , + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; + + + + 1187CB366D640062D4080E18 = {isa = PBXBuildFile; fileRef = 62F0C9747720D940AAF1C3E4 ; }; + 331C80D8294CF71000263BE5 = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 ; }; + 335BBD1B22A9A15E00E9071D = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D ; }; + 33CC10F12044A3C60003C045 = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 ; }; + 33CC10F32044A3C60003C045 = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 ; }; + 33CC10F62044A3C60003C045 = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 ; }; + 33CC11132044BFA00003C045 = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 ; }; + D43E836FD7AFD01D2057F4C8 = {isa = PBXBuildFile; fileRef = 28003D6792682FB2F586D97C ; }; + 78A318202AECB46A00862997 = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 ; }; + + + + 331C80D9294CF71000263BE5 = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 ; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 33CC111F2044C79F0003C045 = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 ; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; + + + + 33CC110E2044A8840003C045 = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; + + + + 0D581436401E4A81D139AC94 = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 28003D6792682FB2F586D97C = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D5294CF71000263BE5 = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 333000ED22D3DE5D00554162 = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = sm_emby.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 62F0C9747720D940AAF1C3E4 = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 63AD2E7BB5ACDFB32355643F = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + 6841C0192F9EBB83762F381B = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 92DE63F911DF04A72447FCAD = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + 9740EEB21CF90195004384FC = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + D03048310CB8B06866B4BC9F = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + F2C18CF3CF59DC238C175751 = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; + + + + 331C80D2294CF70F00263BE5 = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + D43E836FD7AFD01D2057F4C8 , + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 78A318202AECB46A00862997 , + 1187CB366D640062D4080E18 , + ); + runOnlyForDeploymentPostprocessing = 0; + }; + + + + 0F3EBF41EAF18007EBBE01A8 = { + isa = PBXGroup; + children = ( + D03048310CB8B06866B4BC9F , + F2C18CF3CF59DC238C175751 , + 0D581436401E4A81D139AC94 , + 6841C0192F9EBB83762F381B , + 63AD2E7BB5ACDFB32355643F , + 92DE63F911DF04A72447FCAD , + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; + 331C80D6294CF71000263BE5 = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 , + ); + path = RunnerTests; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D , + 9740EEB21CF90195004384FC , + 7AFA3C8E1D35360C0083082E , + 333000ED22D3DE5D00554162 , + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E , + 33CEB47122A05771004F2AC0 , + 331C80D6294CF71000263BE5 , + 33CC10EE2044A3C60003C045 , + 0F3EBF41EAF18007EBBE01A8 , + FE09A7B16CE0E4530769B6CF , + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 , + 331C80D5294CF71000263BE5 , + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 , + 33CC10F42044A3C60003C045 , + 33CC10F72044A3C60003C045 , + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 = { + isa = PBXGroup; + children = ( + 78E0A7A72DC9AD7400C4905E , + 335BBD1A22A9A15E00E9071D , + 33CEB47222A05771004F2AC0 , + 33CEB47422A05771004F2AC0 , + 33CEB47722A0578A004F2AC0 , + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 , + 33CC11122044BFA00003C045 , + 33E51913231747F40026EE4D , + 33E51914231749380026EE4D , + 33CC11242044D66E0003C045 , + 33BA886A226E78AF003329D5 , + ); + path = Runner; + sourceTree = ""; + }; + FE09A7B16CE0E4530769B6CF = { + isa = PBXGroup; + children = ( + 62F0C9747720D940AAF1C3E4 , + 28003D6792682FB2F586D97C , + ); + name = Frameworks; + sourceTree = ""; + }; + + + + 331C80D4294CF70F00263BE5 = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 ; + buildPhases = ( + FF439560F60C7A0F1CB5971D , + 331C80D1294CF70F00263BE5 , + 331C80D2294CF70F00263BE5 , + 331C80D3294CF70F00263BE5 , + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 , + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 ; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 33CC10EC2044A3C60003C045 = { + packageProductDependencies = ( + 78A3181F2AECB46A00862997 , + ); + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 ; + buildPhases = ( + 0BF1557AC8B40EC45B15FDB7 , + 33CC10E92044A3C60003C045 , + 33CC10EA2044A3C60003C045 , + 33CC10EB2044A3C60003C045 , + 33CC110E2044A8840003C045 , + 3399D490228B24CF009A79C7 , + AA5B9574138D80932D5376CC , + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 , + ); + name = Runner; + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 ; + productType = "com.apple.product-type.application"; + }; + + + + 33CC10E52044A3C60003C045 = { + packageReferences = ( + 781AD8BC2B33823900A9FFBB , + ); + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 ; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + productRefGroup = 33CC10EE2044A3C60003C045 ; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 , + 331C80D4294CF70F00263BE5 , + 33CC111A2044C6BA0003C045 , + ); + }; + + + + 331C80D3294CF70F00263BE5 = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EB2044A3C60003C045 = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 , + 33CC10F62044A3C60003C045 , + ); + runOnlyForDeploymentPostprocessing = 0; + }; + + + + 0BF1557AC8B40EC45B15FDB7 = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 3399D490228B24CF009A79C7 = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33CC111E2044C6BF0003C045 = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + }; + AA5B9574138D80932D5376CC = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + FF439560F60C7A0F1CB5971D = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + + + + 331C80D1294CF70F00263BE5 = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 , + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10E92044A3C60003C045 = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 , + 33CC10F12044A3C60003C045 , + 335BBD1B22A9A15E00E9071D , + ); + runOnlyForDeploymentPostprocessing = 0; + }; + + + + 331C80DA294CF71000263BE5 = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 ; + targetProxy = 331C80D9294CF71000263BE5 ; + }; + 33CC11202044C79F0003C045 = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 ; + targetProxy = 33CC111F2044C79F0003C045 ; + }; + + + + 33CC10F42044A3C60003C045 = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 , + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; + + + + 331C80DB294CF71000263BE5 = { + isa = XCBuildConfiguration; + baseConfigurationReference = 6841C0192F9EBB83762F381B ; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.smemby.smEmby.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/sm_emby.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/sm_emby"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 = { + isa = XCBuildConfiguration; + baseConfigurationReference = 63AD2E7BB5ACDFB32355643F ; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.smemby.smEmby.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/sm_emby.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/sm_emby"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 = { + isa = XCBuildConfiguration; + baseConfigurationReference = 92DE63F911DF04A72447FCAD ; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.smemby.smEmby.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/sm_emby.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/sm_emby"; + }; + name = Profile; + }; + 338D0CE9231458BD00FA5F75 = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E ; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D ; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC ; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E ; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D ; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D ; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; + + + + 331C80DE294CF71000263BE5 = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 , + 331C80DC294CF71000263BE5 , + 331C80DD294CF71000263BE5 , + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10E82044A3C60003C045 = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 , + 33CC10FA2044A3C60003C045 , + 338D0CE9231458BD00FA5F75 , + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 , + 33CC10FD2044A3C60003C045 , + 338D0CEA231458BD00FA5F75 , + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 , + 33CC111D2044C6BA0003C045 , + 338D0CEB231458BD00FA5F75 , + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + + + 781AD8BC2B33823900A9FFBB = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; + + + 78A3181F2AECB46A00862997 = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; + + }; + rootObject = 33CC10E52044A3C60003C045 ; +} diff --git a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..ad67d79 --- /dev/null +++ b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,117 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Runner.xcworkspace/contents.xcworkspacedata b/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..21a3cc1 --- /dev/null +++ b/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/macos/Runner/AppDelegate.swift b/macos/Runner/AppDelegate.swift new file mode 100644 index 0000000..db44369 --- /dev/null +++ b/macos/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Cocoa +import FlutterMacOS + +@main +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return false + } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } +} diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..96d3fee --- /dev/null +++ b/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "info": { + "version": 1, + "author": "xcode" + }, + "images": [ + { + "size": "16x16", + "idiom": "mac", + "filename": "app_icon_16.png", + "scale": "1x" + }, + { + "size": "16x16", + "idiom": "mac", + "filename": "app_icon_32.png", + "scale": "2x" + }, + { + "size": "32x32", + "idiom": "mac", + "filename": "app_icon_32.png", + "scale": "1x" + }, + { + "size": "32x32", + "idiom": "mac", + "filename": "app_icon_64.png", + "scale": "2x" + }, + { + "size": "128x128", + "idiom": "mac", + "filename": "app_icon_128.png", + "scale": "1x" + }, + { + "size": "128x128", + "idiom": "mac", + "filename": "app_icon_256.png", + "scale": "2x" + }, + { + "size": "256x256", + "idiom": "mac", + "filename": "app_icon_256.png", + "scale": "1x" + }, + { + "size": "256x256", + "idiom": "mac", + "filename": "app_icon_512.png", + "scale": "2x" + }, + { + "size": "512x512", + "idiom": "mac", + "filename": "app_icon_512.png", + "scale": "1x" + }, + { + "size": "512x512", + "idiom": "mac", + "filename": "app_icon_1024.png", + "scale": "2x" + } + ] +} \ No newline at end of file diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 0000000..d1662fb Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 0000000..443a3e9 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 0000000..49f4e89 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 0000000..a1bbba5 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 0000000..b4e9f9b Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 0000000..a59ed59 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 0000000..12f6b07 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/macos/Runner/Base.lproj/MainMenu.xib b/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 0000000..80e867a --- /dev/null +++ b/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Runner/Configs/AppInfo.xcconfig b/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 0000000..5ef362f --- /dev/null +++ b/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = smplayer + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.smplayer.smplayer + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2026 com.smemby. All rights reserved. diff --git a/macos/Runner/Configs/Debug.xcconfig b/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 0000000..36b0fd9 --- /dev/null +++ b/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/macos/Runner/Configs/Release.xcconfig b/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 0000000..dff4f49 --- /dev/null +++ b/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/macos/Runner/Configs/Warnings.xcconfig b/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 0000000..42bcbf4 --- /dev/null +++ b/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements new file mode 100644 index 0000000..9158f3e --- /dev/null +++ b/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,16 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.client + + com.apple.security.network.server + + com.apple.security.files.user-selected.read-write + + + diff --git a/macos/Runner/Info.plist b/macos/Runner/Info.plist new file mode 100644 index 0000000..cdd1f5d --- /dev/null +++ b/macos/Runner/Info.plist @@ -0,0 +1,43 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + CFBundleURLTypes + + + CFBundleURLName + com.smplayer.smplayer + CFBundleURLSchemes + + smplayer + + + + + diff --git a/macos/Runner/MainFlutterWindow.swift b/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 0000000..78e7d75 --- /dev/null +++ b/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,18 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + + + flutterViewController.mouseTrackingMode = .always + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements new file mode 100644 index 0000000..38da9a9 --- /dev/null +++ b/macos/Runner/Release.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.network.client + + com.apple.security.files.user-selected.read-write + + + diff --git a/macos/RunnerTests/RunnerTests.swift b/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..695a325 --- /dev/null +++ b/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Cocoa +import FlutterMacOS +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + + + } + +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..b8999b4 --- /dev/null +++ b/package.json @@ -0,0 +1,29 @@ +{ + "name": "sm-emby", + "private": true, + "description": "SM Emby - Emby 媒体服务器客户端。本文件仅作为 npm 风格的脚本入口,封装 Flutter / 打包命令,单一真相源仍在 scripts/*。", + "scripts": { + "dev": "powershell -ExecutionPolicy Bypass -File scripts/dev.ps1 run", + "dev:android": "powershell -ExecutionPolicy Bypass -File scripts/dev.ps1 android", + "dev:android:mac": "bash scripts/dev-android.sh", + "dev:macos": "bash scripts/dev-macos.sh", + "gen": "dart run build_runner build --delete-conflicting-outputs", + "watch": "dart run build_runner watch --delete-conflicting-outputs", + "build:windows": "powershell -ExecutionPolicy Bypass -File scripts/dev.ps1 build", + "build:macos": "bash scripts/build-macos.sh", + "build:apk": "powershell -ExecutionPolicy Bypass -File scripts/dev.ps1 build-apk", + "test": "flutter test", + "analyze": "flutter analyze", + "clean": "flutter clean", + "pub": "flutter pub get", + "doctor": "flutter doctor -v", + "installer": "powershell -ExecutionPolicy Bypass -File scripts/dev.ps1 installer", + "dmg": "bash scripts/build_dmg.sh", + "release:pc": "bash scripts/release-tag.sh pc", + "release:android": "bash scripts/release-tag.sh android", + "release:both": "bash scripts/release-tag.sh both", + "release:pc:win": "powershell -ExecutionPolicy Bypass -File scripts/release-tag.ps1 -Platform pc", + "release:android:win": "powershell -ExecutionPolicy Bypass -File scripts/release-tag.ps1 -Platform android", + "release:both:win": "powershell -ExecutionPolicy Bypass -File scripts/release-tag.ps1 -Platform both" + } +} diff --git a/plugins/media3_engine/README.md b/plugins/media3_engine/README.md new file mode 100644 index 0000000..1688be1 --- /dev/null +++ b/plugins/media3_engine/README.md @@ -0,0 +1,162 @@ +# media3_engine + +`smplayer` 项目本地 Flutter plugin。**仅 Android**,包名 `com.smplayer.media3_engine`, +不发布到 pub.dev。Dart 侧通过约定好的 MethodChannel / EventChannel 与 native 通讯。 + +引擎实现位于主项目: +- Dart:`lib/core/infra/player_engine/media3_player_engine.dart` +- Kotlin:`plugins/media3_engine/android/src/main/kotlin/com/smplayer/media3_engine/` + +## 版本锁定 + +| 组件 | 版本 | +|------|------| +| androidx.media | `1.10.1` | +| FFmpeg | `release/6.0` | +| Android NDK | `r27d` LTS (27.3.13750724) | +| Android `minSdk` | 24 | +| Android `compileSdk` | 36 | +| AGP / Kotlin / JDK | 8.11.1 / 2.2.20 / 17 | +| ABI | **arm64-v8a only**(包体最小化) | +| License | LGPL(不开 `--enable-gpl` / `--enable-nonfree`) | + +升 Media3 / FFmpeg 版本前,先核对 `androidx/media @ /libraries/decoder_ffmpeg/README.md` +推荐的 NDK 与 FFmpeg branch,再调整 `scripts/build_media3_ffmpeg.sh` 默认值。 + +## 通道协议 + +### MethodChannel `smplayer/media3_engine` + +| 方法 | 参数 | 返回 | +|------|------|------| +| `create` | – | `{playerId:int, textureId:int, ffmpegAvailable:bool, ffmpegVersion:string}` | +| `open` | `playerId, url, headers, startMs, play, generation` | 首次 `STATE_READY` 或 `PlaybackException` 后 complete | +| `play` / `pause` | `playerId` | – | +| `stop` | `playerId, generation` | – | +| `seek` | `playerId, positionMs` | – | +| `setRate` | `playerId, rate` (float) | – | +| `setVolume` | `playerId, volume` (0..200) | – | +| `setAudioTrack` | `playerId, id` (`audio::` / `auto` / `no`) | – | +| `setSubtitleTrack` | `playerId, id` (`text::` / `auto` / `no`) | – | +| `setSubtitleStyle` | `playerId, fontSize, bgOpacity, position` | – (首版 no-op,cue 由 Dart 渲染) | +| `getProperty` | `playerId, name` | `string`(`ffmpegAvailable` / `ffmpegVersion` / `audioRenderer` / `playbackState`) | +| `dispose` | `playerId` | – | + +### EventChannel `smplayer/media3_engine/events` + +所有事件载荷形如 `{playerId, generation, type, ...}`,Dart 侧按 `playerId` 过滤、 +按 `generation` 丢弃过期事件。 + +| `type` | payload | +|--------|---------| +| `position` | `{positionMs, bufferMs}` | +| `duration` | `{durationMs}` | +| `playing` | `{playing}` | +| `completed` | `{}` | +| `videoSize` | `{width, height}` | +| `tracks` | `{audio:[{id,index,title,language}], subtitle:[…]}` | +| `subtitle` | `{lines:[string]}` | +| `volume` | `{volume}`(保留,目前 native 不主动 emit) | +| `error` | `{code, message}` | + +Generation token 语义:Dart 端每次 `open / stop / dispose` ++ generation,native +回调先比对此值,过期事件丢弃。这保证了 cancel / reopen 时不会有迟到的 STATE_READY +污染新 session 的状态。 + +## FFmpeg 软解 codec 列表 + +`scripts/build_media3_ffmpeg.sh` 的 `ENABLED_DECODERS` 默认: + +| 解码器名(FFmpeg) | 实际格式 | +|-------------------|----------| +| `dca` | DTS / DTS-HD / DTS-HD MA | +| `ac3` | AC3 | +| `eac3` | E-AC3 (Dolby Digital Plus) | +| `truehd` | Dolby TrueHD(含 Atmos 元数据,但 Atmos 渲染依赖宿主 audio sink) | +| `opus` | Opus | +| `vorbis` | Vorbis | +| `flac` | FLAC | + +视频解码不在范围——交给平台 MediaCodec 硬解。容器解析与音频帧 parser 由 ExoPlayer +自身处理,FFmpeg 扩展只补 decoder(无需 `--enable-parser` / `--enable-demuxer`)。 + +## 编译 AAR + +> **前置硬约束**:`android/app/libs/media3-decoder-ffmpeg-1.10.1-arm64.aar` 是 +> Android 构建的硬依赖。仓库默认不提交该 AAR 二进制;缺文件时 plugin 的 +> `preBuild` 任务 `verifyMedia3FfmpegAar` 会 fail-fast 抛错并给出本节的恢复 +> 指令。CI / Clean checkout 都必须先编出 AAR 才能 `flutter build apk`。 + +### 前置 + +- macOS / Linux host(Apple Silicon 建议通过 Docker `linux-x86_64` 容器编译以规避 + Media3 README 未文档化的 darwin-arm64 行为) +- Android NDK r27d (LTS):从 https://developer.android.com/ndk/downloads 安装;Media3 1.10.1 README 实测版本是 r26b,r27 ABI 兼容、社区已验证可编 +- `perl`, `make`, `yasm`, `git`, `python3`, `shasum`(macOS 自带;Linux 需手装 yasm) +- JDK 17 + Android SDK(命令行工具足够,Android Studio 非必需) + +### 步骤 + +```sh +# 1. 设置 NDK 路径(或用 --ndk-path 参数) +export ANDROID_NDK_HOME=/path/to/android-ndk-r27d + +# 2. 跑脚本(首次约 30-60 分钟,取决于 host CPU / IO) +./scripts/build_media3_ffmpeg.sh + +# 3. 产物 +# android/app/libs/media3-decoder-ffmpeg-1.10.1-arm64.aar +# android/app/libs/CHECKSUMS.txt 自动追加 / 替换该 AAR 的 SHA-256 +# +# 4. 核对 SHA-256 → git add 上述两个文件 → flutter build apk --debug 验证加载 +``` + +### 重新编译 / 升级版本 + +```sh +# 完全清空工作区重新拉取(覆盖 androidx/media + FFmpeg 缓存) +./scripts/build_media3_ffmpeg.sh --clean + +# 升 Media3 tag +./scripts/build_media3_ffmpeg.sh --clean --media3-tag 1.11.0 + +# 升 FFmpeg branch +./scripts/build_media3_ffmpeg.sh --clean --ffmpeg-branch release/6.1 +``` + +脚本会在 `libraries/decoder_ffmpeg/src/main/jni/build_ffmpeg.sh` 上幂等地打一次 +sed patch,把上游默认的 4 ABI 限制到 arm64-v8a only。`--clean` 会清空 patch 与缓存。 + +### Docker 复现编译(推荐 Apple Silicon) + +```sh +docker run --rm -it \ + -v "$(pwd)":/work \ + -e ANDROID_NDK_HOME=/opt/android-ndk-r27d \ + -w /work \ + cimg/android:2024.05.1-ndk \ + scripts/build_media3_ffmpeg.sh +``` + +镜像版本以 r27d NDK 实际可用性为准;上面只是一个起点示意。 + +## 已知限制 + +| 限制 | 说明 / 解决路径 | +|------|----------------| +| **位图字幕**(PGS / VobSub) | onCues 检出后 emit 空文本 + `error: 'subtitle_bitmap_unsupported'`;建议改外挂 SRT/ASS 或切回 fvp | +| **音量 boost**(100 < volume ≤ 200) | ExoPlayer `volume` 上限为 1.0;本插件把 100..200 段静默截断到 1.0。Dart 端 `state.volume` 保留原值(UI 一致),但物理音量不会更大 | +| **HDR / DV** | 路由层显式排除(`resolvePlayerEngineKind` 在 DV/ProRes 下回退 fvp);media3 视频走 MediaCodec,DV 元数据未被特殊处理 | +| **iOS / 桌面** | plugin 仅声明 Android 平台,构造层 `Platform.isAndroid` 守卫;UI 层桌面隐藏 media3 选项 | +| **PiP / MediaSession / Cast** | 不实现(范围外);播放在前台 Activity 内完成 | +| **DRM / Widevine** | 不实现(范围外) | +| **FFmpeg AAR 体积** | 约 12-15 MB / arm64-v8a;release APK 增量约 ≤ 30 MB | + +## 平台守卫四层 + +1. **枚举层**:`PlayerEngineOverride.media3` / `PlayerEngineKind.media3` 在所有平台编译通过(inert) +2. **路由层**:`resolvePlayerEngineKind` 在 desktop 把 media3 降级为 mpv +3. **构造层**:`Media3PlayerEngine` 构造函数 `assert(Platform.isAndroid)`,否则抛 `UnsupportedError` +4. **UI 层**:`PlayerEngineRow.visibleOptions` 过滤 + `coerceValue` 持久化值降级 + +任一层失守,其它层仍能阻止 native crash。 diff --git a/plugins/media3_engine/android/build.gradle.kts b/plugins/media3_engine/android/build.gradle.kts new file mode 100644 index 0000000..ae44e99 --- /dev/null +++ b/plugins/media3_engine/android/build.gradle.kts @@ -0,0 +1,154 @@ +// Media3 Engine plugin — Android-only ExoPlayer + FFmpeg audio decoder bridge. +// +// 依赖: +// - Maven Central: media3-exoplayer / media3-datasource / media3-decoder 1.10.1 +// - 本地 AAR : media3-decoder-ffmpeg-1.10.1-arm64.aar +// 由 fileTree 从主 app 的 android/app/libs/ 加载。AAR 由 +// scripts/build_media3_ffmpeg.sh 自编译产出,CHECKSUMS.txt +// 记录 SHA-256。 +// +// AAR 缺失时 preBuild 任务会 fail-fast 抛出带恢复指引的错误(详见 README)。 + +import java.security.MessageDigest + +plugins { + id("com.android.library") + id("kotlin-android") +} + +group = "com.smplayer.media3_engine" +version = "0.0.1" + +android { + namespace = "com.smplayer.media3_engine" + compileSdk = 36 + + defaultConfig { + minSdk = 24 + ndk { + // 与主 app 保持一致:仅 arm64-v8a。其它 ABI 在 FFmpeg AAR 中也不存在, + // 即便 ABI filter 漏了也只是无声的浪费(不会 crash)。 + abiFilters += listOf("arm64-v8a") + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } + + buildFeatures { + buildConfig = false + } +} + +repositories { + google() + mavenCentral() +} + +// 自编译 FFmpeg audio decoder extension(arm64-v8a only,LGPL 配置)。AAR 由 +// scripts/build_media3_ffmpeg.sh 产出,放在 android/app/libs/。 +// 这里只声明 compileOnly:AGP 8 起禁止 library module 直接 implementation 本地 +// .aar("Direct local .aar file dependencies are not supported when building an +// AAR"),实际把 AAR 打入最终 APK 的责任落到主 app/build.gradle.kts 的 +// `implementation(fileTree("libs"))`。本 plugin 编译期只需要 FfmpegAudioRenderer +// 等符号的可见性。 +val media3Version = "1.10.1" +val ffmpegAarDir = file("../../../android/app/libs") +val ffmpegAarName = "media3-decoder-ffmpeg-$media3Version-arm64.aar" + +dependencies { + implementation("androidx.media3:media3-exoplayer:$media3Version") + implementation("androidx.media3:media3-datasource:$media3Version") + implementation("androidx.media3:media3-decoder:$media3Version") + implementation("androidx.media3:media3-ui:$media3Version") + compileOnly(fileTree(mapOf("dir" to ffmpegAarDir, "include" to listOf(ffmpegAarName)))) +} + +// AAR 缺失 / 篡改 fail-fast:在 preBuild 之前校验文件存在性 + SHA-256 与 +// `android/app/libs/CHECKSUMS.txt` 一致。CI/Clean checkout 友好。 +val verifyMedia3FfmpegAar by tasks.registering { + group = "verification" + description = "Verify media3 FFmpeg decoder AAR exists and matches CHECKSUMS.txt." + val checksumsFile = ffmpegAarDir.resolve("CHECKSUMS.txt") + val aarFile = ffmpegAarDir.resolve(ffmpegAarName) + inputs.file(aarFile).withPropertyName("aar").withPathSensitivity(org.gradle.api.tasks.PathSensitivity.NONE) + inputs.file(checksumsFile).withPropertyName("checksums").withPathSensitivity(org.gradle.api.tasks.PathSensitivity.NONE) + doLast { + if (!aarFile.isFile) { + throw GradleException( + """ + |Media3 FFmpeg decoder AAR is missing: + | expected: ${aarFile.absolutePath} + | + |This AAR is self-built (LGPL config, arm64-v8a, + |codecs: dca/ac3/eac3/truehd/opus/vorbis/flac). + |To produce it, run from the repo root: + | + | scripts/build_media3_ffmpeg.sh + | + |Prerequisites: NDK r26b, JDK 17, ~30-60min wall-clock. See + |plugins/media3_engine/README.md for full instructions and Docker recipe. + | + |If you only build the desktop (Windows/macOS) target, this Android + |module is not on the dependency path and you can ignore this error. + """.trimMargin() + ) + } + if (!checksumsFile.isFile) { + throw GradleException( + "Media3 AAR checksum manifest is missing: ${checksumsFile.absolutePath}\n" + + "Restore CHECKSUMS.txt from git or rerun scripts/build_media3_ffmpeg.sh." + ) + } + // CHECKSUMS.txt 行格式:` `,可有 # 开头注释 / 空行 + val expectedSha = checksumsFile.readLines() + .map { it.trim() } + .firstOrNull { it.isNotEmpty() && !it.startsWith("#") && it.endsWith(ffmpegAarName) } + ?.substringBefore(' ') + ?.trim() + ?.lowercase() + if (expectedSha.isNullOrBlank() || !expectedSha.matches(Regex("^[0-9a-f]{64}$"))) { + throw GradleException( + "Media3 AAR checksum entry missing or malformed for $ffmpegAarName in " + + "${checksumsFile.absolutePath}. Expected a line: ' $ffmpegAarName'." + ) + } + val md = MessageDigest.getInstance("SHA-256") + aarFile.inputStream().use { input -> + val buffer = ByteArray(8192) + while (true) { + val n = input.read(buffer) + if (n <= 0) break + md.update(buffer, 0, n) + } + } + val actualSha = md.digest().joinToString("") { "%02x".format(it) } + if (actualSha != expectedSha) { + throw GradleException( + """ + |Media3 FFmpeg decoder AAR checksum mismatch — refusing to build. + | file : ${aarFile.absolutePath} + | expected : $expectedSha (from CHECKSUMS.txt) + | actual : $actualSha + | + |The AAR has been replaced/corrupted since CHECKSUMS.txt was written. + |If you intentionally rebuilt it, scripts/build_media3_ffmpeg.sh will + |refresh CHECKSUMS.txt automatically; otherwise restore the file from git. + """.trimMargin() + ) + } + logger.lifecycle("[verifyMedia3FfmpegAar] OK — sha256 $actualSha") + } +} + +afterEvaluate { + tasks.named("preBuild").configure { + dependsOn(verifyMedia3FfmpegAar) + } +} diff --git a/plugins/media3_engine/android/src/main/AndroidManifest.xml b/plugins/media3_engine/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..94cbbcf --- /dev/null +++ b/plugins/media3_engine/android/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/plugins/media3_engine/android/src/main/kotlin/com/smplayer/media3_engine/Media3AspectRatioFrameLayout.kt b/plugins/media3_engine/android/src/main/kotlin/com/smplayer/media3_engine/Media3AspectRatioFrameLayout.kt new file mode 100644 index 0000000..42971e2 --- /dev/null +++ b/plugins/media3_engine/android/src/main/kotlin/com/smplayer/media3_engine/Media3AspectRatioFrameLayout.kt @@ -0,0 +1,65 @@ +package com.smplayer.media3_engine + +import android.content.Context +import android.widget.FrameLayout +import kotlin.math.abs + +internal class Media3AspectRatioFrameLayout(context: Context) : FrameLayout(context) { + private var videoAspectRatio: Float = 0f + private var resizeMode: Int = RESIZE_FIT + + fun setAspectRatio(aspectRatio: Float) { + if (videoAspectRatio == aspectRatio) return + videoAspectRatio = aspectRatio + requestLayout() + } + + fun setResizeMode(mode: Int) { + if (resizeMode == mode) return + resizeMode = mode + requestLayout() + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + if (videoAspectRatio <= 0f || resizeMode == RESIZE_FILL) return + + var width = measuredWidth + var height = measuredHeight + if (width <= 0 || height <= 0) return + + val viewAspectRatio = width.toFloat() / height + val aspectDeformation = videoAspectRatio / viewAspectRatio - 1f + if (abs(aspectDeformation) <= MAX_ASPECT_RATIO_DEFORMATION_FRACTION) return + + when (resizeMode) { + RESIZE_ZOOM -> { + if (aspectDeformation > 0f) { + width = (height * videoAspectRatio).toInt() + } else { + height = (width / videoAspectRatio).toInt() + } + } + else -> { + if (aspectDeformation > 0f) { + height = (width / videoAspectRatio).toInt() + } else { + width = (height * videoAspectRatio).toInt() + } + } + } + + super.onMeasure( + MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY), + ) + setMeasuredDimension(width, height) + } + + companion object { + const val RESIZE_FIT = 0 + const val RESIZE_FILL = 3 + const val RESIZE_ZOOM = 4 + private const val MAX_ASPECT_RATIO_DEFORMATION_FRACTION = 0.01f + } +} diff --git a/plugins/media3_engine/android/src/main/kotlin/com/smplayer/media3_engine/Media3EnginePlugin.kt b/plugins/media3_engine/android/src/main/kotlin/com/smplayer/media3_engine/Media3EnginePlugin.kt new file mode 100644 index 0000000..f989161 --- /dev/null +++ b/plugins/media3_engine/android/src/main/kotlin/com/smplayer/media3_engine/Media3EnginePlugin.kt @@ -0,0 +1,200 @@ +package com.smplayer.media3_engine + +import android.content.Context +import android.os.Handler +import android.os.Looper +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import io.flutter.plugin.platform.PlatformViewRegistry + + +class Media3EnginePlugin : FlutterPlugin, MethodChannel.MethodCallHandler, + EventChannel.StreamHandler { + + private lateinit var methodChannel: MethodChannel + private lateinit var eventChannel: EventChannel + private lateinit var applicationContext: Context + private lateinit var binaryMessenger: BinaryMessenger + private lateinit var platformViewRegistry: PlatformViewRegistry + private val mainHandler = Handler(Looper.getMainLooper()) + private val players = mutableMapOf() + private var nextPlayerId = 1 + private var eventSink: EventChannel.EventSink? = null + + override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { + applicationContext = binding.applicationContext + binaryMessenger = binding.binaryMessenger + platformViewRegistry = binding.platformViewRegistry + + methodChannel = MethodChannel(binaryMessenger, CHANNEL_METHOD) + methodChannel.setMethodCallHandler(this) + + eventChannel = EventChannel(binaryMessenger, CHANNEL_EVENTS) + eventChannel.setStreamHandler(this) + + + platformViewRegistry.registerViewFactory( + VIEW_TYPE, + Media3PlatformViewFactory { id -> players[id] }, + ) + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + + for (player in players.values) { + try { + player.dispose() + } catch (t: Throwable) { + android.util.Log.w(TAG, "dispose during detach failed", t) + } + } + players.clear() + methodChannel.setMethodCallHandler(null) + eventChannel.setStreamHandler(null) + eventSink = null + } + + + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + eventSink = events + + + for (player in players.values) { + try { + player.replayKeyState() + } catch (t: Throwable) { + android.util.Log.w(TAG, "replayKeyState failed", t) + } + } + } + + override fun onCancel(arguments: Any?) { + eventSink = null + } + + + internal fun emit(event: Map) { + val sink = eventSink ?: return + if (Looper.myLooper() == Looper.getMainLooper()) { + sink.success(event) + } else { + mainHandler.post { eventSink?.success(event) } + } + } + + + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + try { + when (call.method) { + "create" -> handleCreate(result) + else -> dispatchToPlayer(call, result) + } + } catch (t: Throwable) { + android.util.Log.e(TAG, "method ${call.method} failed", t) + result.error("media3_error", t.message, null) + } + } + + private fun handleCreate(result: MethodChannel.Result) { + val id = nextPlayerId++ + val instance = Media3PlayerInstance( + id = id, + context = applicationContext, + mainHandler = mainHandler, + emit = ::emit, + ) + players[id] = instance + val info = instance.create() + result.success(info) + } + + private fun dispatchToPlayer(call: MethodCall, result: MethodChannel.Result) { + val playerId = (call.argument("playerId"))?.toInt() + ?: run { + result.error("missing_player_id", "method ${call.method}", null) + return + } + val player = players[playerId] + ?: run { + + result.success(null) + return + } + when (call.method) { + "open" -> player.open(call, result) + "play" -> { player.play(); result.success(null) } + "pause" -> { player.pause(); result.success(null) } + "stop" -> { + val gen = (call.argument("generation"))?.toInt() ?: -1 + player.stop(gen) + result.success(null) + } + "seek" -> { + val ms = (call.argument("positionMs"))?.toLong() ?: 0L + player.seek(ms) + result.success(null) + } + "setRate" -> { + val rate = (call.argument("rate"))?.toFloat() ?: 1f + player.setRate(rate) + result.success(null) + } + "setVolume" -> { + val vol = (call.argument("volume"))?.toFloat() ?: 100f + player.setVolume(vol) + result.success(null) + } + "setResizeMode" -> { + val mode = call.argument("mode") ?: "fit" + player.setResizeMode(mapResizeMode(mode)) + result.success(null) + } + "replayKeyState" -> { + player.replayKeyState() + result.success(null) + } + "setAudioTrack" -> { + val id = call.argument("id") ?: "auto" + player.setAudioTrack(id) + result.success(null) + } + "setSubtitleTrack" -> { + val id = call.argument("id") ?: "no" + player.setSubtitleTrack(id) + result.success(null) + } + "setSubtitleStyle" -> { + + result.success(null) + } + "getProperty" -> { + val name = call.argument("name") ?: "" + result.success(player.getProperty(name)) + } + "dispose" -> { + player.dispose() + players.remove(playerId) + result.success(null) + } + else -> result.notImplemented() + } + } + + private fun mapResizeMode(mode: String): Int { + return when (mode) { + "fill" -> Media3AspectRatioFrameLayout.RESIZE_FILL + "zoom" -> Media3AspectRatioFrameLayout.RESIZE_ZOOM + else -> Media3AspectRatioFrameLayout.RESIZE_FIT + } + } + + companion object { + private const val TAG = "Media3EnginePlugin" + private const val CHANNEL_METHOD = "smplayer/media3_engine" + private const val CHANNEL_EVENTS = "smplayer/media3_engine/events" + private const val VIEW_TYPE = "smplayer/media3_surface" + } +} diff --git a/plugins/media3_engine/android/src/main/kotlin/com/smplayer/media3_engine/Media3PlatformView.kt b/plugins/media3_engine/android/src/main/kotlin/com/smplayer/media3_engine/Media3PlatformView.kt new file mode 100644 index 0000000..8e0d0d5 --- /dev/null +++ b/plugins/media3_engine/android/src/main/kotlin/com/smplayer/media3_engine/Media3PlatformView.kt @@ -0,0 +1,111 @@ +package com.smplayer.media3_engine + +import android.content.Context +import android.graphics.Color +import android.view.Gravity +import android.view.SurfaceHolder +import android.view.SurfaceView +import android.view.View +import android.widget.FrameLayout +import androidx.media3.ui.SubtitleView +import io.flutter.plugin.common.StandardMessageCodec +import io.flutter.plugin.platform.PlatformView +import io.flutter.plugin.platform.PlatformViewFactory + + +internal class Media3PlatformView( + context: Context, + private val playerId: Int, + private val getPlayer: (Int) -> Media3PlayerInstance?, +) : PlatformView, SurfaceHolder.Callback { + + private val rootView: FrameLayout = FrameLayout(context).apply { + setBackgroundColor(Color.BLACK) + } + private val aspectLayout: Media3AspectRatioFrameLayout = Media3AspectRatioFrameLayout(context) + private val surfaceView: SurfaceView = SurfaceView(context) + private val subtitleView: SubtitleView = SubtitleView(context) + + + private val shutterView: View = View(context).apply { + setBackgroundColor(Color.BLACK) + } + + init { + aspectLayout.addView( + surfaceView, + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT, + ), + ) + aspectLayout.addView( + subtitleView, + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT, + ), + ) + rootView.addView( + aspectLayout, + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT, + Gravity.CENTER, + ), + ) + rootView.addView( + shutterView, + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT, + ), + ) + surfaceView.holder.addCallback(this) + getPlayer(playerId)?.attachView(aspectLayout) + getPlayer(playerId)?.attachSubtitleView(subtitleView) + getPlayer(playerId)?.attachShutter(shutterView) + } + + override fun getView(): View = rootView + + override fun dispose() { + surfaceView.holder.removeCallback(this) + getPlayer(playerId)?.detachShutter(shutterView) + getPlayer(playerId)?.detachSubtitleView(subtitleView) + getPlayer(playerId)?.detachView(aspectLayout) + getPlayer(playerId)?.detachSurface() + } + + + override fun surfaceCreated(holder: SurfaceHolder) { + android.util.Log.i(TAG, "surfaceCreated playerId=$playerId valid=${holder.surface.isValid}") + getPlayer(playerId)?.attachSurface(holder.surface) + } + + override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) { + android.util.Log.i(TAG, "surfaceChanged playerId=$playerId ${width}x$height format=$format") + + } + + override fun surfaceDestroyed(holder: SurfaceHolder) { + android.util.Log.i(TAG, "surfaceDestroyed playerId=$playerId") + getPlayer(playerId)?.detachSurface() + } + + companion object { + private const val TAG = "Media3PlatformView" + } +} + + +internal class Media3PlatformViewFactory( + private val getPlayer: (Int) -> Media3PlayerInstance?, +) : PlatformViewFactory(StandardMessageCodec.INSTANCE) { + + override fun create(context: Context, viewId: Int, args: Any?): PlatformView { + val params = args as? Map<*, *> + val playerId = (params?.get("playerId") as? Number)?.toInt() ?: -1 + return Media3PlatformView(context, playerId, getPlayer) + } +} diff --git a/plugins/media3_engine/android/src/main/kotlin/com/smplayer/media3_engine/Media3PlayerInstance.kt b/plugins/media3_engine/android/src/main/kotlin/com/smplayer/media3_engine/Media3PlayerInstance.kt new file mode 100644 index 0000000..0056028 --- /dev/null +++ b/plugins/media3_engine/android/src/main/kotlin/com/smplayer/media3_engine/Media3PlayerInstance.kt @@ -0,0 +1,756 @@ +package com.smplayer.media3_engine + +import android.content.Context +import android.os.Handler +import android.view.Surface +import android.view.View +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player +import androidx.media3.common.TrackSelectionOverride +import androidx.media3.common.TrackSelectionParameters +import androidx.media3.common.Tracks +import androidx.media3.common.VideoSize +import androidx.media3.common.text.CueGroup +import androidx.media3.datasource.DataSource +import androidx.media3.datasource.DataSpec +import androidx.media3.datasource.DefaultDataSource +import androidx.media3.datasource.DefaultHttpDataSource +import androidx.media3.datasource.TransferListener +import androidx.media3.ui.SubtitleView +import androidx.media3.exoplayer.DefaultLoadControl +import androidx.media3.exoplayer.DefaultRenderersFactory +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.analytics.AnalyticsListener +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import kotlin.math.max +import kotlin.math.min + + +internal class Media3PlayerInstance( + val id: Int, + private val context: Context, + private val mainHandler: Handler, + private val emit: (Map) -> Unit, +) { + + private var player: ExoPlayer? = null + + + private var surface: Surface? = null + private var attachedView: Media3AspectRatioFrameLayout? = null + private var listener: Player.Listener? = null + private var positionRunnable: Runnable? = null + private var pendingOpenListener: Player.Listener? = null + private var pendingOpenResult: MethodChannel.Result? = null + private var pendingOpenGen: Int = -1 + private var pendingSurfaceTimeout: Runnable? = null + private var currentResizeMode: Int = Media3AspectRatioFrameLayout.RESIZE_FIT + private var lastAspectRatio: Float = 0f + + + private var attachedSubtitleView: SubtitleView? = null + private var lastCueGroup: CueGroup? = null + + + private var attachedShutterView: View? = null + private var firstFrameRendered: Boolean = false + + + private var generation: Int = 0 + private var disposed: Boolean = false + private var ffmpegAvailable: Boolean = false + private var ffmpegVersion: String? = null + + @Volatile + private var totalRxBytes: Long = 0L + + + private var pendingPrepareGen: Int = -1 + + + private var lastDurationMs: Long = 0L + private var lastVideoWidth: Int = 0 + private var lastVideoHeight: Int = 0 + private var lastTracksPayload: Map? = null + private var lastPlaying: Boolean = false + private var lastPositionMs: Long = 0L + + + fun create(): Map { + ffmpegAvailable = probeFfmpegAvailability() + ffmpegVersion = probeFfmpegVersion() + + val renderersFactory = DefaultRenderersFactory(context) + .setExtensionRendererMode( + DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER + ) + + + .setEnableDecoderFallback(true) + val loadControl = DefaultLoadControl.Builder() + .setBufferDurationsMs( + 15_000, + 50_000, + 500, + 2_000, + ) + .build() + val exo = ExoPlayer.Builder(context, renderersFactory) + .setLoadControl(loadControl) + .build() + exo.playWhenReady = false + + + exo.addAnalyticsListener(object : AnalyticsListener { + override fun onVideoDecoderInitialized( + eventTime: AnalyticsListener.EventTime, + decoderName: String, + initializedTimestampMs: Long, + initializationDurationMs: Long, + ) { + android.util.Log.i(TAG, "video decoder initialized: $decoderName") + } + }) + player = exo + + + return mapOf( + "playerId" to id, + "ffmpegAvailable" to ffmpegAvailable, + "ffmpegVersion" to (ffmpegVersion ?: ""), + ) + } + + + fun attachSurface(s: Surface) { + surface = s + player?.setVideoSurface(s) + + + val pendingGen = pendingPrepareGen + if (pendingGen >= 0 && pendingGen == generation) { + pendingPrepareGen = -1 + clearPendingSurfaceTimeout() + android.util.Log.i(TAG, "deferred prepare: surface arrived, executing prepare (gen=$pendingGen)") + player?.prepare() + } + } + + fun attachView(view: Media3AspectRatioFrameLayout) { + attachedView = view + view.setResizeMode(currentResizeMode) + view.setAspectRatio(lastAspectRatio) + } + + fun detachView(view: Media3AspectRatioFrameLayout) { + if (attachedView === view) attachedView = null + } + + + fun detachSurface() { + surface = null + try { + player?.clearVideoSurface() + } catch (t: Throwable) { + android.util.Log.w(TAG, "clearVideoSurface failed", t) + } + } + + fun attachSubtitleView(view: SubtitleView) { + attachedSubtitleView = view + lastCueGroup?.let { group -> + val hasBitmap = group.cues.any { it.bitmap != null } + if (hasBitmap) { + view.setCues(group.cues) + } else { + view.setCues(emptyList()) + } + } + } + + fun detachSubtitleView(view: SubtitleView) { + if (attachedSubtitleView === view) attachedSubtitleView = null + } + + + fun attachShutter(view: View) { + attachedShutterView = view + view.visibility = if (firstFrameRendered) View.GONE else View.VISIBLE + } + + + fun detachShutter(view: View) { + if (attachedShutterView === view) attachedShutterView = null + } + + + private fun attachListener(exo: ExoPlayer, openGen: Int) { + val lst = object : Player.Listener { + private fun expired(): Boolean { + if (disposed || openGen != generation) { + try { exo.removeListener(this) } catch (_: Throwable) {} + if (listener === this) listener = null + return true + } + return false + } + + override fun onPlaybackStateChanged(playbackState: Int) { + if (expired()) return + when (playbackState) { + Player.STATE_READY -> { + val dur = exo.duration + if (dur != C.TIME_UNSET) { + lastDurationMs = dur + emitTyped("duration", mapOf("durationMs" to dur), openGen) + } + } + Player.STATE_ENDED -> emitTyped("completed", emptyMap(), openGen) + Player.STATE_BUFFERING, Player.STATE_IDLE -> Unit + } + } + + override fun onIsPlayingChanged(isPlaying: Boolean) { + if (expired()) return + lastPlaying = isPlaying + emitTyped("playing", mapOf("playing" to isPlaying), openGen) + if (isPlaying) startPositionTimer(openGen) else stopPositionTimer() + } + + override fun onVideoSizeChanged(videoSize: VideoSize) { + if (expired()) return + val w = videoSize.width + val h = videoSize.height + if (w <= 0 || h <= 0) return + val pixelWidthHeightRatio = videoSize.pixelWidthHeightRatio + lastAspectRatio = if (pixelWidthHeightRatio > 0f) { + w * pixelWidthHeightRatio / h + } else { + 0f + } + attachedView?.setAspectRatio(lastAspectRatio) + lastVideoWidth = w + lastVideoHeight = h + + emitTyped("videoSize", mapOf("width" to w, "height" to h), openGen) + } + + override fun onRenderedFirstFrame() { + if (expired()) return + android.util.Log.i(TAG, "rendered first frame (gen=$openGen)") + firstFrameRendered = true + attachedShutterView?.visibility = View.GONE + } + + override fun onTracksChanged(tracks: Tracks) { + if (expired()) return + val payload = serializeTracks(tracks) + lastTracksPayload = payload + emitTyped("tracks", payload, openGen) + } + + override fun onCues(cueGroup: CueGroup) { + if (expired()) return + lastCueGroup = cueGroup + val cues = cueGroup.cues + var bitmapDetected = false + for (cue in cues) { + if (cue.bitmap != null) { + bitmapDetected = true + break + } + } + if (bitmapDetected) { + + + attachedSubtitleView?.setCues(cues) + emitTyped("subtitle", mapOf("lines" to emptyList()), openGen) + return + } + + attachedSubtitleView?.setCues(emptyList()) + val lines = ArrayList(cues.size) + for (cue in cues) { + val text = cue.text?.toString() + if (!text.isNullOrEmpty()) lines.add(text) + } + emitTyped("subtitle", mapOf("lines" to lines), openGen) + } + + override fun onPlayerError(error: PlaybackException) { + if (expired()) return + emitTyped( + "error", + mapOf( + "code" to (error.errorCodeName ?: "playback_error"), + "message" to (error.message ?: ""), + ), + openGen, + ) + } + } + + listener?.let { try { exo.removeListener(it) } catch (_: Throwable) {} } + listener = lst + exo.addListener(lst) + } + + + fun open(call: MethodCall, result: MethodChannel.Result) { + val exo = player ?: run { + result.error("not_created", "player not created", null) + return + } + val url = call.argument("url") ?: run { + result.error("missing_url", "open without url", null) + return + } + + + val scheme = try { android.net.Uri.parse(url).scheme?.lowercase() } catch (_: Throwable) { null } + if (scheme != "http" && scheme != "https" && scheme != "file") { + result.error( + "invalid_url_scheme", + "media3 only accepts http/https/file urls; got: $scheme", + null, + ) + return + } + val headersRaw = call.argument>("headers") ?: emptyMap() + val startMs = (call.argument("startMs"))?.toLong() ?: 0L + val play = call.argument("play") ?: true + + cancelPendingOpen("open_cancelled", "open superseded before completion") + + + val dartGen = (call.argument("generation"))?.toInt() ?: -1 + generation = max(generation + 1, dartGen) + val openGen = generation + + + lastDurationMs = 0L + lastVideoWidth = 0 + lastVideoHeight = 0 + lastAspectRatio = 0f + attachedView?.setAspectRatio(0f) + lastTracksPayload = null + lastPositionMs = 0L + lastPlaying = false + lastCueGroup = null + attachedSubtitleView?.setCues(emptyList()) + + + firstFrameRendered = false + attachedShutterView?.visibility = View.VISIBLE + + + val once = object : Player.Listener { + private var settled = false + + fun settle(success: Boolean, error: PlaybackException?) { + if (settled) return + if (pendingOpenListener !== this) { + settled = true + return + } + settled = true + try { exo.removeListener(this) } catch (_: Throwable) {} + pendingOpenListener = null + pendingOpenResult = null + pendingOpenGen = -1 + pendingPrepareGen = -1 + clearPendingSurfaceTimeout() + if (openGen != generation) { + result.error("open_cancelled", "open cancelled before completion", null) + return + } + if (disposed) { + result.error("open_cancelled", "player disposed before open completed", null) + return + } + if (success) { + result.success(null) + } else { + result.error( + "open_failed", + error?.message ?: "playback error", + error?.errorCodeName, + ) + } + } + + override fun onPlaybackStateChanged(playbackState: Int) { + if (playbackState == Player.STATE_READY) settle(true, null) + } + + override fun onPlayerError(error: PlaybackException) { + settle(false, error) + } + } + exo.addListener(once) + pendingOpenListener = once + pendingOpenResult = result + pendingOpenGen = openGen + + + attachListener(exo, openGen) + + + exo.stop() + exo.clearMediaItems() + + totalRxBytes = 0L + val transferListener = object : TransferListener { + override fun onTransferInitializing(source: DataSource, dataSpec: DataSpec, isNetwork: Boolean) {} + override fun onTransferStart(source: DataSource, dataSpec: DataSpec, isNetwork: Boolean) {} + override fun onBytesTransferred( + source: DataSource, + dataSpec: DataSpec, + isNetwork: Boolean, + bytesTransferred: Int, + ) { + if (isNetwork) totalRxBytes += bytesTransferred.toLong() + } + override fun onTransferEnd(source: DataSource, dataSpec: DataSpec, isNetwork: Boolean) {} + } + val httpFactory = DefaultHttpDataSource.Factory() + .setAllowCrossProtocolRedirects(true) + .setDefaultRequestProperties(sanitizeHeaders(headersRaw)) + val dataSourceFactory = DefaultDataSource.Factory(context, httpFactory) + .setTransferListener(transferListener) + val mediaSourceFactory = DefaultMediaSourceFactory(dataSourceFactory) + exo.setMediaSource(mediaSourceFactory.createMediaSource(MediaItem.fromUri(url))) + + if (startMs > 0L) exo.seekTo(startMs) + exo.playWhenReady = play + + + if (surface != null) { + pendingPrepareGen = -1 + clearPendingSurfaceTimeout() + exo.prepare() + } else { + android.util.Log.i(TAG, "open: surface not ready, deferring prepare (gen=$openGen)") + pendingPrepareGen = openGen + scheduleSurfaceTimeout(openGen) + } + + + if (exo.playerError != null) { + once.settle(false, exo.playerError) + } else if (exo.playbackState == Player.STATE_READY) { + once.settle(true, null) + } + } + + fun play() = player?.let { it.playWhenReady = true } + fun pause() = player?.let { it.playWhenReady = false } + + fun stop(generationFromDart: Int) { + + if (generationFromDart >= 0) generation = max(generation, generationFromDart) + cancelPendingOpen("open_cancelled", "open cancelled by stop") + player?.stop() + stopPositionTimer() + lastCueGroup = null + attachedSubtitleView?.setCues(emptyList()) + } + + fun seek(positionMs: Long) { + player?.seekTo(positionMs) + lastPositionMs = positionMs + emitTyped("position", mapOf("positionMs" to positionMs), generation) + } + + fun setRate(rate: Float) { + player?.let { it.setPlaybackSpeed(max(0.1f, rate)) } + } + + fun setVolume(volume0to200: Float) { + val mapped = min(1f, max(0f, volume0to200 / 100f)) + player?.volume = mapped + + } + + fun setResizeMode(mode: Int) { + currentResizeMode = mode + attachedView?.setResizeMode(mode) + } + + + fun setAudioTrack(id: String) { + applyTrackOverride(C.TRACK_TYPE_AUDIO, id) + } + + fun setSubtitleTrack(id: String) { + applyTrackOverride(C.TRACK_TYPE_TEXT, id) + } + + private fun applyTrackOverride(trackType: @C.TrackType Int, id: String) { + val exo = player ?: return + val builder = exo.trackSelectionParameters.buildUpon() + .clearOverridesOfType(trackType) + .setTrackTypeDisabled(trackType, false) + when (id) { + "auto" -> {} + "no" -> builder.setTrackTypeDisabled(trackType, true) + else -> { + + val parts = id.split(":") + if (parts.size != 3) return + val groupIdx = parts[1].toIntOrNull() ?: return + val trackIdx = parts[2].toIntOrNull() ?: return + val tracks = exo.currentTracks + val typedGroups = tracks.groups.filter { it.type == trackType } + val target = typedGroups.getOrNull(groupIdx) ?: return + if (trackIdx !in 0 until target.length) return + builder.addOverride( + TrackSelectionOverride(target.mediaTrackGroup, trackIdx) + ) + } + } + exo.trackSelectionParameters = builder.build() + } + + fun getProperty(name: String): Any { + return when (name) { + "ffmpegAvailable" -> if (ffmpegAvailable) "true" else "false" + "ffmpegVersion" -> ffmpegVersion.orEmpty() + "audioRenderer" -> currentAudioRendererName() + "playbackState" -> playbackStateName() + "totalRxBytes" -> totalRxBytes + else -> "" + } + } + + private fun currentAudioRendererName(): String { + val exo = player ?: return "" + return try { + for (i in 0 until exo.rendererCount) { + if (exo.getRendererType(i) == C.TRACK_TYPE_AUDIO) { + + val renderer = exo.javaClass.getMethod("getRenderer", Int::class.java) + .invoke(exo, i) + if (renderer != null) return renderer.javaClass.simpleName + } + } + "" + } catch (t: Throwable) { + "" + } + } + + private fun playbackStateName(): String { + val state = player?.playbackState ?: return "" + return when (state) { + Player.STATE_IDLE -> "idle" + Player.STATE_BUFFERING -> "buffering" + Player.STATE_READY -> "ready" + Player.STATE_ENDED -> "ended" + else -> "unknown" + } + } + + fun dispose() { + if (disposed) return + cancelPendingOpen("open_cancelled", "player disposed before open completed") + disposed = true + ++generation + stopPositionTimer() + try { + listener?.let { player?.removeListener(it) } + } catch (t: Throwable) { + android.util.Log.w(TAG, "removeListener failed", t) + } + listener = null + try { + player?.clearVideoSurface() + } catch (t: Throwable) { + android.util.Log.w(TAG, "clearVideoSurface failed", t) + } + try { + player?.release() + } catch (t: Throwable) { + android.util.Log.w(TAG, "player.release failed", t) + } + player = null + + surface = null + attachedView = null + attachedSubtitleView = null + } + + + fun replayKeyState() { + if (disposed) return + val gen = generation + if (lastDurationMs > 0) emitTyped("duration", mapOf("durationMs" to lastDurationMs), gen) + if (lastVideoWidth > 0 && lastVideoHeight > 0) { + emitTyped( + "videoSize", + mapOf("width" to lastVideoWidth, "height" to lastVideoHeight), + gen, + ) + } + lastTracksPayload?.let { emitTyped("tracks", it, gen) } + emitTyped("playing", mapOf("playing" to lastPlaying), gen) + emitTyped("position", mapOf("positionMs" to lastPositionMs), gen) + } + + + private fun emitTyped(type: String, payload: Map, gen: Int) { + val merged = HashMap(payload.size + 3) + merged["playerId"] = id + merged["generation"] = gen + merged["type"] = type + merged.putAll(payload) + emit(merged) + } + + + private fun sanitizeHeaders(headers: Map): Map { + val sanitized = HashMap(headers.size) + for ((k, v) in headers) { + if (k.contains('\n') || k.contains('\r')) continue + if (v.contains('\n') || v.contains('\r')) continue + sanitized[k] = v + } + return sanitized + } + + private fun startPositionTimer(openGen: Int) { + stopPositionTimer() + val r = object : Runnable { + override fun run() { + if (disposed || openGen != generation) return + val exo = player ?: return + val pos = exo.currentPosition + val buf = exo.bufferedPosition + if (pos != lastPositionMs) { + lastPositionMs = pos + emitTyped( + "position", + mapOf("positionMs" to pos, "bufferMs" to buf), + openGen, + ) + } + mainHandler.postDelayed(this, POSITION_INTERVAL_MS) + } + } + positionRunnable = r + mainHandler.postDelayed(r, POSITION_INTERVAL_MS) + } + + private fun stopPositionTimer() { + positionRunnable?.let { mainHandler.removeCallbacks(it) } + positionRunnable = null + } + + private fun scheduleSurfaceTimeout(openGen: Int) { + clearPendingSurfaceTimeout() + val r = Runnable { + if (disposed || openGen != generation || pendingPrepareGen != openGen) return@Runnable + android.util.Log.w(TAG, "deferred prepare timed out waiting for surface (gen=$openGen)") + cancelPendingOpen("surface_timeout", "surface not attached within timeout") + } + pendingSurfaceTimeout = r + mainHandler.postDelayed(r, SURFACE_ATTACH_TIMEOUT_MS) + } + + private fun clearPendingSurfaceTimeout() { + pendingSurfaceTimeout?.let { mainHandler.removeCallbacks(it) } + pendingSurfaceTimeout = null + } + + private fun cancelPendingOpen(code: String, message: String) { + clearPendingSurfaceTimeout() + pendingPrepareGen = -1 + val once = pendingOpenListener + val result = pendingOpenResult + pendingOpenListener = null + pendingOpenResult = null + pendingOpenGen = -1 + if (once != null) { + try { player?.removeListener(once) } catch (_: Throwable) {} + } + if (result != null) { + try { + result.error(code, message, null) + } catch (t: Throwable) { + android.util.Log.w(TAG, "complete pending open failed", t) + } + } + } + + + private fun serializeTracks(tracks: Tracks): Map { + val audio = ArrayList>() + val subtitle = ArrayList>() + var audioGroupIdx = 0 + var textGroupIdx = 0 + for (group in tracks.groups) { + when (group.type) { + C.TRACK_TYPE_AUDIO -> { + val gi = audioGroupIdx++ + for (ti in 0 until group.length) { + if (!group.isTrackSupported(ti)) continue + val fmt = group.getTrackFormat(ti) + audio.add( + mapOf( + "id" to "audio:$gi:$ti", + "index" to ti, + "title" to (fmt.label ?: ""), + "language" to (fmt.language ?: ""), + ) + ) + } + } + C.TRACK_TYPE_TEXT -> { + val gi = textGroupIdx++ + for (ti in 0 until group.length) { + if (!group.isTrackSupported(ti)) continue + val fmt = group.getTrackFormat(ti) + subtitle.add( + mapOf( + "id" to "text:$gi:$ti", + "index" to ti, + "title" to (fmt.label ?: ""), + "language" to (fmt.language ?: ""), + ) + ) + } + } + } + } + return mapOf("audio" to audio, "subtitle" to subtitle) + } + + private fun probeFfmpegAvailability(): Boolean { + + return try { + val cls = Class.forName("androidx.media3.decoder.ffmpeg.FfmpegLibrary") + cls.getMethod("isAvailable").invoke(null) as? Boolean ?: false + } catch (t: Throwable) { + false + } + } + + private fun probeFfmpegVersion(): String? { + return try { + val cls = Class.forName("androidx.media3.decoder.ffmpeg.FfmpegLibrary") + cls.getMethod("getVersion").invoke(null) as? String + } catch (t: Throwable) { + null + } + } + + companion object { + private const val TAG = "Media3PlayerInstance" + private const val POSITION_INTERVAL_MS = 500L + private const val SURFACE_ATTACH_TIMEOUT_MS = 10_000L + } +} diff --git a/plugins/media3_engine/lib/media3_engine.dart b/plugins/media3_engine/lib/media3_engine.dart new file mode 100644 index 0000000..e2a5b9f --- /dev/null +++ b/plugins/media3_engine/lib/media3_engine.dart @@ -0,0 +1,3 @@ + + +library; diff --git a/plugins/media3_engine/pubspec.yaml b/plugins/media3_engine/pubspec.yaml new file mode 100644 index 0000000..11f9fb8 --- /dev/null +++ b/plugins/media3_engine/pubspec.yaml @@ -0,0 +1,20 @@ +name: media3_engine +description: Android-only Flutter plugin bridging Media3 ExoPlayer + FFmpeg audio + decoder extension. 仅供主项目本地 path 依赖,不发布到 pub.dev。 +version: 0.0.1 +publish_to: 'none' + +environment: + sdk: ^3.11.5 + flutter: '>=3.41.0' + +dependencies: + flutter: + sdk: flutter + +flutter: + plugin: + platforms: + android: + package: com.smplayer.media3_engine + pluginClass: Media3EnginePlugin diff --git a/plugins/media_kit_libs_windows_video/CHANGELOG.md b/plugins/media_kit_libs_windows_video/CHANGELOG.md new file mode 100644 index 0000000..854e948 --- /dev/null +++ b/plugins/media_kit_libs_windows_video/CHANGELOG.md @@ -0,0 +1,79 @@ +## 1.0.11 + + - **FIX**(windows): do not bundle MSVCP/UCRT DLLs. + +## 1.0.10 + +- fix windows crash by @Airyzz [#900](https://github.com/media-kit/media-kit/pull/900) + +## 1.0.9 + +- [mpv-player/mpv@`652a1dd`](https://github.com/mpv-player/mpv/commit/652a1dd90711839acdccc08004056d25514ef2d8) +- [ANGLE 2.1.18844 git hash: 2693b03eba82](https://github.com/google/angle) +- MSVCRT +- UCRT +- feat: improve compile-time failure handling + +## 1.0.8 + +- [mpv-player/mpv@`c0fb9b4`](https://github.com/mpv-player/mpv/commit/c0fb9b4b83905ff29f2b4c678af431561b5b53be) +- [ANGLE 2.1.18844 git hash: 2693b03eba82](https://github.com/google/angle) +- MSVCRT +- UCRT + +## 1.0.7 + +- [mpv-player/mpv@`140ec21`](https://github.com/mpv-player/mpv/commit/140ec21c89d671d392877a7f3b91d67e7d7b9239) +- [ANGLE 2.1.18844 git hash: 2693b03eba82](https://github.com/google/angle) +- MSVCRT +- UCRT +- fix: DASH having BaseURL(s) with special characters not loading ([#353](https://github.com/media-kit/media-kit/issues/353)) +- fix: AV1 support ([#351](https://github.com/media-kit/media-kit/issues/351)) + +## 1.0.6 + +- [mpv-player/mpv@`140ec21`](https://github.com/mpv-player/mpv/commit/140ec21c89d671d392877a7f3b91d67e7d7b9239) +- [ANGLE 2.1.18844 git hash: 2693b03eba82](https://github.com/google/angle) +- MSVCRT +- UCRT + +## 1.0.5 + +- [mpv-player/mpv@`140ec21`](https://github.com/mpv-player/mpv/commit/140ec21c89d671d392877a7f3b91d67e7d7b9239) +- [ANGLE 2.1.18844 git hash: 2693b03eba82](https://github.com/google/angle) +- MSVCRT +- UCRT +- fix: HLS support +- perf: reduce bundle size + +## 1.0.4 + +- [mpv-player/mpv@`140ec21`](https://github.com/mpv-player/mpv/commit/140ec21c89d671d392877a7f3b91d67e7d7b9239) +- [ANGLE 2.1.18844 git hash: 2693b03eba82](https://github.com/google/angle) +- MSVCRT +- UCRT +- perf: reduce bundle size + +## 1.0.2 + +- [mpv-player/mpv@`7ae7fc0`](https://github.com/mpv-player/mpv/commit/7ae7fc01122c4fdfdb51600463872a6f8e78b975) +- [ANGLE 2.1.18844 git hash: 2693b03eba82](https://github.com/google/angle) +- MSVCRT +- UCRT +- feat: migrate to the plugin template +- feat: make package optional during build +- feat: add MEDIA_KIT_LIBS_AVAILABLE option + +## 1.0.1 + +- [mpv-player/mpv@`7990dd8`](https://github.com/mpv-player/mpv/commit/7990dd8f3f664e01c984a405dad7c8af320cabf1) +- [ANGLE 2.1.18844 git hash: 2693b03eba82](https://github.com/google/angle) +- MSVCRT +- UCRT + +## 1.0.0 + +- [mpv-player/mpv@`7990dd8`](https://github.com/mpv-player/mpv/commit/7990dd8f3f664e01c984a405dad7c8af320cabf1) +- [ANGLE 2.1.18844 git hash: 2693b03eba82](https://github.com/google/angle) +- MSVCRT +- UCRT diff --git a/plugins/media_kit_libs_windows_video/LICENSE b/plugins/media_kit_libs_windows_video/LICENSE new file mode 100644 index 0000000..c4c95d9 --- /dev/null +++ b/plugins/media_kit_libs_windows_video/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 & onwards Hitesh Kumar Saini + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/media_kit_libs_windows_video/README.md b/plugins/media_kit_libs_windows_video/README.md new file mode 100644 index 0000000..1f41737 --- /dev/null +++ b/plugins/media_kit_libs_windows_video/README.md @@ -0,0 +1,55 @@ +# media_kit_libs_windows_video (vendored fork) + +Fork of `media_kit_libs_windows_video 1.0.11` from pub.dev. Windows-only, wired +into the main app via `dependency_overrides` in the project `pubspec.yaml`. Not +published to pub. + +## Why fork + +Upstream 1.0.11 downloads the media-kit organisation's 2023-09-24 `mpv-dev` +archive at build time. Its bundled FFmpeg is compiled with +`--disable-decoders` plus an explicit allow-list that omits two decoders we +need: + +- `truehd` — Dolby TrueHD audio (mpv reports + `Failed to initialize a decoder for codec 'truehd'`) +- `hdmv_pgs_subtitle` (`pgssub`) — Blu-ray PGS bitmap subtitles (track can be + selected via `sid`, but nothing renders on screen) + +Both symptoms are documented in the upstream tracker +([media-kit/media-kit#1269](https://github.com/media-kit/media-kit/issues/1269), +[#1371](https://github.com/media-kit/media-kit/issues/1371)) and both are fixed +by swapping in a full libmpv build. + +## What changed vs. upstream 1.0.11 + +Only three variables in `windows/CMakeLists.txt` — the archive name, download +URL, and MD5. The download / extract / bundle logic is untouched, and the +archive layout (`libmpv-2.dll` + `include/mpv/*.h`) matches upstream. + +| | Upstream 1.0.11 | This fork | +|---|-----------------|-----------| +| Source | `media-kit/libmpv-win32-video-build` | `zhongfly/mpv-winbuild` | +| Tag / date | `2023-09-24` | `2026-07-11-e5486b96d7` | +| Archive | `mpv-dev-x86_64-20230924-git-652a1dd.7z` | `mpv-dev-x86_64-20260711-git-e5486b96d7.7z` | +| Build flavour | Stripped (allow-listed decoders only) | Full (all upstream FFmpeg decoders) | +| CPU baseline | Generic x86_64 | Generic x86_64 (not `-v3`, so no AVX2 requirement) | + +The plugin registrar (`MediaKitLibsWindowsVideoPluginCApi`), ANGLE bundle, and +package name / class name / API are identical to upstream so no other change +to `media_kit` / `media_kit_video` is required. + +## Follow-ups (not blocking) + +- `zhongfly/mpv-winbuild` is a nightly repository. Historical releases can be + pruned. To keep CI reproducible long-term, mirror the pinned archive to a + project-owned GitHub Release (same pattern as `mdk-sdk v0.37.0`) and repoint + `LIBMPV_URL`. +- Android has the same missing decoders in `media_kit_libs_android_video`. + Android currently falls back to media3/fvp so this is not urgent; if we + ever need mpv on Android too, mirror this vendor pattern there. + +## License + +Original MIT license retained in [`LICENSE`](./LICENSE). Upstream copyright: +Hitesh Kumar Saini and contributors. diff --git a/plugins/media_kit_libs_windows_video/pubspec.yaml b/plugins/media_kit_libs_windows_video/pubspec.yaml new file mode 100644 index 0000000..257e06c --- /dev/null +++ b/plugins/media_kit_libs_windows_video/pubspec.yaml @@ -0,0 +1,20 @@ +name: media_kit_libs_windows_video +description: Vendored fork of media_kit_libs_windows_video 1.0.11 that ships a + full libmpv build (adds truehd audio + hdmv_pgs_subtitle decoders). Local path + dependency only, do not publish to pub.dev. +version: 1.0.11+smplayer.1 +publish_to: 'none' + +environment: + sdk: ">=2.17.0 <4.0.0" + flutter: ">=3.0.0" + +dependencies: + flutter: + sdk: flutter + +flutter: + plugin: + platforms: + windows: + pluginClass: MediaKitLibsWindowsVideoPluginCApi diff --git a/plugins/media_kit_libs_windows_video/windows/CMakeLists.txt b/plugins/media_kit_libs_windows_video/windows/CMakeLists.txt new file mode 100644 index 0000000..a147e25 --- /dev/null +++ b/plugins/media_kit_libs_windows_video/windows/CMakeLists.txt @@ -0,0 +1,183 @@ +# This file is a part of media_kit (https://github.com/media-kit/media-kit). +# +# Copyright © 2021 & onwards, Hitesh Kumar Saini . +# All rights reserved. +# Use of this source code is governed by MIT license that can be found in the LICENSE file. + +cmake_minimum_required(VERSION 3.14) + +# This option is read by the other packages which are part of package:media_kit. +option(MEDIA_KIT_LIBS_AVAILABLE "package:media_kit libraries are available." ON) + +set(PROJECT_NAME "media_kit_libs_windows_video") +project(${PROJECT_NAME} LANGUAGES CXX) + +# Deal with MSVC incompatiblity +add_compile_definitions(_DISABLE_CONSTEXPR_MUTEX_CONSTRUCTOR) + +# ------------------------------------------------------------------------------ +function(download_and_verify url md5 locationForArchive) + # Check if the archive exists. + if(EXISTS "${locationForArchive}") + file(MD5 "${locationForArchive}" ARCHIVE_MD5) + + # If MD5 doesn't match, delete the archive to download again. + if(NOT md5 STREQUAL ARCHIVE_MD5) + file(REMOVE "${locationForArchive}") + message(STATUS "MD5 mismatch. File deleted.") + endif() + endif() + + # Download the archive if it doesn't exist. + if(NOT EXISTS "${locationForArchive}") + message(STATUS "Downloading archive from ${url}...") + file(DOWNLOAD "${url}" "${locationForArchive}") + message(STATUS "Downloaded archive to ${locationForArchive}.") + + # Verify MD5 of the newly downloaded file. + file(MD5 "${locationForArchive}" ARCHIVE_MD5) + + if(md5 STREQUAL ARCHIVE_MD5) + message(STATUS "${locationForArchive} Verification successful.") + else() + message(FATAL_ERROR "${locationForArchive} Integrity check failed, please try to re-build project again.") + endif() + endif() +endfunction() + +function(check_directory_exists_and_not_empty dir result_var) + # Check if the directory exists + if(EXISTS "${dir}") + # Check if the directory is not empty + file(GLOB dir_contents "${dir}/*") + + if(dir_contents) + set(${result_var} TRUE PARENT_SCOPE) + else() + set(${result_var} FALSE PARENT_SCOPE) + message(STATUS "Directory ${dir} exists but is empty!") + endif() + else() + set(${result_var} FALSE PARENT_SCOPE) + message(STATUS "Directory ${dir} does not exist!") + endif() +endfunction() + +# libmpv archive containing the pre-built shared libraries & headers. +# +# Vendored fork: pinned to a zhongfly/mpv-winbuild full build so that libmpv's +# bundled FFmpeg ships the truehd audio decoder and the hdmv_pgs_subtitle +# bitmap decoder — both are missing from upstream media-kit's stripped +# allow-listed build (media-kit/media-kit#1269 / #1371). See the vendored +# plugin README for the rationale and follow-ups. +set(LIBMPV "mpv-dev-x86_64-20260711-git-e5486b96d7.7z") + +# Download URL & MD5 hash of the libmpv archive. +set(LIBMPV_URL "https://github.com/zhongfly/mpv-winbuild/releases/download/2026-07-11-e5486b96d7/${LIBMPV}") +set(LIBMPV_MD5 "32c8d72df54259bcce914aeb620db261") + +# Download location of the libmpv archive. +set(LIBMPV_ARCHIVE "${CMAKE_BINARY_DIR}/${LIBMPV}") +set(LIBMPV_SRC "${CMAKE_BINARY_DIR}/libmpv") + +download_and_verify( + ${LIBMPV_URL} + ${LIBMPV_MD5} + ${LIBMPV_ARCHIVE} +) + +check_directory_exists_and_not_empty(${LIBMPV_SRC} LIBMPV_SRC_VALID) + +# Extract the libmpv archive. +if(NOT LIBMPV_SRC_VALID) + message(STATUS "Extracting ${LIBMPV}...") + make_directory("${LIBMPV_SRC}") + add_custom_target("${PROJECT_NAME}_LIBMPV_EXTRACT" ALL) + add_custom_command( + TARGET "${PROJECT_NAME}_LIBMPV_EXTRACT" + COMMAND "${CMAKE_COMMAND}" -E tar xzf "\"${LIBMPV_ARCHIVE}\"" + COMMAND xcopy "\"${LIBMPV_SRC}/include/mpv\"" "\"${LIBMPV_SRC}/mpv\"" /E /H /C /I + COMMAND rmdir "\"${LIBMPV_SRC}/include\"" /S /Q + COMMAND ren "\"${LIBMPV_SRC}/mpv\"" "\"include\"" + WORKING_DIRECTORY "${LIBMPV_SRC}" + ) +endif() + +# ------------------------------------------------------------------------------ + +# ANGLE archive containing the pre-built shared libraries & headers. +set(ANGLE "ANGLE.7z") + +# Download URL & MD5 hash of the ANGLE archive. +set(ANGLE_URL "https://github.com/alexmercerind/flutter-windows-ANGLE-OpenGL-ES/releases/download/v1.0.1/ANGLE.7z") +set(ANGLE_MD5 "e866f13e8d552348058afaafe869b1ed") + +# Download location of the ANGLE archive. +set(ANGLE_ARCHIVE "${CMAKE_BINARY_DIR}/${ANGLE}") +set(ANGLE_SRC "${CMAKE_BINARY_DIR}/ANGLE") + +download_and_verify( + ${ANGLE_URL} + ${ANGLE_MD5} + ${ANGLE_ARCHIVE} +) + +check_directory_exists_and_not_empty(${ANGLE_SRC} ANGLE_SRC_VALID) + +# Extract the ANGLE archive. +if(NOT ANGLE_SRC_VALID) + message(STATUS "Extracting ${ANGLE}...") + make_directory("${ANGLE_SRC}") + add_custom_target("${PROJECT_NAME}_ANGLE_EXTRACT" ALL) + add_custom_command( + TARGET "${PROJECT_NAME}_ANGLE_EXTRACT" + COMMAND "${CMAKE_COMMAND}" -E tar xzf "\"${ANGLE_ARCHIVE}\"" + WORKING_DIRECTORY "${ANGLE_SRC}" + ) +endif() + +# ------------------------------------------------------------------------------ +set(PLUGIN_NAME "media_kit_libs_windows_video_plugin") + +add_library( + ${PLUGIN_NAME} SHARED + "include/media_kit_libs_windows_video/media_kit_libs_windows_video_plugin_c_api.h" + "media_kit_libs_windows_video_plugin_c_api.cpp" +) + +apply_standard_settings(${PLUGIN_NAME}) + +set_target_properties( + ${PLUGIN_NAME} + PROPERTIES + CXX_VISIBILITY_PRESET + hidden +) +target_compile_definitions( + ${PLUGIN_NAME} + PRIVATE + FLUTTER_PLUGIN_IMPL +) + +target_include_directories( + ${PLUGIN_NAME} INTERFACE + "${CMAKE_CURRENT_SOURCE_DIR}/include" +) +target_link_libraries( + ${PLUGIN_NAME} + PRIVATE + flutter + flutter_wrapper_plugin +) + +set( + media_kit_libs_windows_video_bundled_libraries + "${LIBMPV_SRC}/libmpv-2.dll" + "${ANGLE_SRC}/d3dcompiler_47.dll" + "${ANGLE_SRC}/libEGL.dll" + "${ANGLE_SRC}/libGLESv2.dll" + "${ANGLE_SRC}/vk_swiftshader.dll" + "${ANGLE_SRC}/vulkan-1.dll" + "${ANGLE_SRC}/zlib.dll" + PARENT_SCOPE +) diff --git a/plugins/media_kit_libs_windows_video/windows/include/media_kit_libs_windows_video/media_kit_libs_windows_video_plugin_c_api.h b/plugins/media_kit_libs_windows_video/windows/include/media_kit_libs_windows_video/media_kit_libs_windows_video_plugin_c_api.h new file mode 100644 index 0000000..76a2521 --- /dev/null +++ b/plugins/media_kit_libs_windows_video/windows/include/media_kit_libs_windows_video/media_kit_libs_windows_video_plugin_c_api.h @@ -0,0 +1,24 @@ +#ifndef FLUTTER_PLUGIN_MEDIA_KIT_LIBS_WINDOWS_VIDEO_PLUGIN_C_API_H_ +#define FLUTTER_PLUGIN_MEDIA_KIT_LIBS_WINDOWS_VIDEO_PLUGIN_C_API_H_ + +#include + +#ifdef FLUTTER_PLUGIN_IMPL +#define FLUTTER_PLUGIN_EXPORT __declspec(dllexport) +#else +#define FLUTTER_PLUGIN_EXPORT __declspec(dllimport) +#endif + +#if defined(__cplusplus) +extern "C" { +#endif + +FLUTTER_PLUGIN_EXPORT void +MediaKitLibsWindowsVideoPluginCApiRegisterWithRegistrar( + FlutterDesktopPluginRegistrarRef registrar); + +#if defined(__cplusplus) +} // extern "C" +#endif + +#endif // FLUTTER_PLUGIN_MEDIA_KIT_LIBS_WINDOWS_VIDEO_PLUGIN_C_API_H_ diff --git a/plugins/media_kit_libs_windows_video/windows/media_kit_libs_windows_video_plugin_c_api.cpp b/plugins/media_kit_libs_windows_video/windows/media_kit_libs_windows_video_plugin_c_api.cpp new file mode 100644 index 0000000..edaaca6 --- /dev/null +++ b/plugins/media_kit_libs_windows_video/windows/media_kit_libs_windows_video_plugin_c_api.cpp @@ -0,0 +1,6 @@ +#include "include/media_kit_libs_windows_video/media_kit_libs_windows_video_plugin_c_api.h" + +#include + +void MediaKitLibsWindowsVideoPluginCApiRegisterWithRegistrar( + FlutterDesktopPluginRegistrarRef) {} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..9b60ae1 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,9 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: {} diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..0b634a9 --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,1597 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: "3b19a47f6ea7c2632760777c78174f47f6aec1e05f0cd611380d4593b8af1dbc" + url: "https://pub.dev" + source: hosted + version: "96.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "0c516bc4ad36a1a75759e54d5047cb9d15cded4459df01aa35a0b5ec7db2c2a0" + url: "https://pub.dev" + source: hosted + version: "10.2.0" + app_links: + dependency: "direct main" + description: + name: app_links + sha256: "9d3c82f634c7f5b5c752f7ee46b67724246043f5e1d5fc1b433dd5b38d780dbe" + url: "https://pub.dev" + source: hosted + version: "7.2.0" + app_links_linux: + dependency: transitive + description: + name: app_links_linux + sha256: f5f7173a78609f3dfd4c2ff2c95bd559ab43c80a87dc6a095921d96c05688c81 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + app_links_platform_interface: + dependency: transitive + description: + name: app_links_platform_interface + sha256: "78a18580eecac98108d1eef52a7db668bc317714f5205e616973363326efe333" + url: "https://pub.dev" + source: hosted + version: "2.0.3" + app_links_web: + dependency: transitive + description: + name: app_links_web + sha256: af060ed76183f9e2b87510a9480e56a5352b6c249778d07bd2c95fc35632a555 + url: "https://pub.dev" + source: hosted + version: "1.0.4" + archive: + dependency: transitive + description: + name: archive + sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff + url: "https://pub.dev" + source: hosted + version: "4.0.9" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + build: + dependency: transitive + description: + name: build + sha256: a156715e7cd728130c592f30552575908aae5b100005fbc1f0fb16b3c03a3d10 + url: "https://pub.dev" + source: hosted + version: "4.0.6" + build_cli_annotations: + dependency: transitive + description: + name: build_cli_annotations + sha256: e563c2e01de8974566a1998410d3f6f03521788160a02503b0b1f1a46c7b3d95 + url: "https://pub.dev" + source: hosted + version: "2.1.1" + build_config: + dependency: transitive + description: + name: build_config + sha256: "4070d2a59f8eec34c97c86ceb44403834899075f66e8a9d59706f8e7834f6f71" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957 + url: "https://pub.dev" + source: hosted + version: "4.1.1" + build_runner: + dependency: "direct dev" + description: + name: build_runner + sha256: "1523ce62448ebac2c15a8ba5fbad8acac169788658a7dd2a1c2d9c2a9318b9a6" + url: "https://pub.dev" + source: hosted + version: "2.15.0" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + sha256: "34e4067d30ce212937df995f03b69992eea683539ceeac7f679a1f1eba055b56" + url: "https://pub.dev" + source: hosted + version: "8.12.6" + cached_network_image: + dependency: "direct main" + description: + name: cached_network_image + sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + cached_network_image_platform_interface: + dependency: transitive + description: + name: cached_network_image_platform_interface + sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829" + url: "https://pub.dev" + source: hosted + version: "4.1.1" + cached_network_image_web: + dependency: transitive + description: + name: cached_network_image_web + sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062" + url: "https://pub.dev" + source: hosted + version: "1.3.1" + canvas_danmaku: + dependency: "direct main" + description: + name: canvas_danmaku + sha256: "59018315feefe497dd6b5a5c8059a1c40a4849b985f273704acc2faf20cd0d98" + url: "https://pub.dev" + source: hosted + version: "0.3.1" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + cli_config: + dependency: transitive + description: + name: cli_config + sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec + url: "https://pub.dev" + source: hosted + version: "0.2.0" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c + url: "https://pub.dev" + source: hosted + version: "0.4.2" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + coverage: + dependency: transitive + description: + name: coverage + sha256: "956a3de0725ca232ad353565a8290d3357592bf4250f6f298a185e2d949c5d3d" + url: "https://pub.dev" + source: hosted + version: "1.15.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "92c9c43c383bfa1c32079d3bc492d55d6d4318044b7b47edaff8971cbb555c51" + url: "https://pub.dev" + source: hosted + version: "0.3.5+4" + crypto: + dependency: "direct main" + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + csslib: + dependency: transitive + description: + name: csslib + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: "29f7ecc274a86d32920b1d9cfc7502fa87220da41ec60b55f329559d5732e2b2" + url: "https://pub.dev" + source: hosted + version: "3.1.7" + dbus: + dependency: transitive + description: + name: dbus + sha256: "0ce9b0a839e6dee59a37a623d2fc26a35bbbe6404213e419b0d6411023d62645" + url: "https://pub.dev" + source: hosted + version: "0.7.14" + dio: + dependency: "direct main" + description: + name: dio + sha256: ea2bad3c89a27635ce2d85cce4d6b199da49a5a48ec77b03e45b65a3b90922b0 + url: "https://pub.dev" + source: hosted + version: "5.10.0" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: dd58dc3861eb36edb13b217efc006a1c21e5bbc341de8c229b85634fa5e362e4 + url: "https://pub.dev" + source: hosted + version: "2.2.0" + fake_async: + dependency: "direct dev" + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + ffi_leak_tracker: + dependency: transitive + description: + name: ffi_leak_tracker + sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97" + url: "https://pub.dev" + source: hosted + version: "0.1.2" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + file_selector: + dependency: "direct main" + description: + name: file_selector + sha256: bd15e43e9268db636b53eeaca9f56324d1622af30e5c34d6e267649758c84d9a + url: "https://pub.dev" + source: hosted + version: "1.1.0" + file_selector_android: + dependency: transitive + description: + name: file_selector_android + sha256: "6a26687fa65cbc28a5345c7ae6f227e89f0b47740978a4c475b1a625da7a331b" + url: "https://pub.dev" + source: hosted + version: "0.5.2+8" + file_selector_ios: + dependency: transitive + description: + name: file_selector_ios + sha256: e2ecf2885c121691ce13b60db3508f53c01f869fb6e8dc5c1cfa771e4c46aeca + url: "https://pub.dev" + source: hosted + version: "0.5.3+5" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" + url: "https://pub.dev" + source: hosted + version: "0.9.4" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" + url: "https://pub.dev" + source: hosted + version: "0.9.5" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_web: + dependency: transitive + description: + name: file_selector_web + sha256: "73181fbc5257776d8ecaa6a94ab3c8e920ad143b9132a6d984a9271dfc6928d3" + url: "https://pub.dev" + source: hosted + version: "0.9.5" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" + url: "https://pub.dev" + source: hosted + version: "0.9.3+5" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + fjs: + dependency: "direct main" + description: + name: fjs + sha256: cc59bcd505a63a63ae51d0cbf13f9825bd10347ed0d177677eb0c0ee42206086 + url: "https://pub.dev" + source: hosted + version: "3.2.0" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_cache_manager: + dependency: transitive + description: + name: flutter_cache_manager + sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + flutter_launcher_icons: + dependency: "direct dev" + description: + name: flutter_launcher_icons + sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7" + url: "https://pub.dev" + source: hosted + version: "0.14.4" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_localizations: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_riverpod: + dependency: "direct main" + description: + name: flutter_riverpod + sha256: "9255e1e3ad6e38906a1b4f8287678f95f378744c5b46b1985588543f3f19046e" + url: "https://pub.dev" + source: hosted + version: "3.3.2" + flutter_rust_bridge: + dependency: "direct dev" + description: + name: flutter_rust_bridge + sha256: e87d6b9ee934dcd24a128ccb2bd91905d2d5fe5c06245d6a8f5477d4907a437a + url: "https://pub.dev" + source: hosted + version: "2.12.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + forui: + dependency: "direct main" + description: + name: forui + sha256: "68c656bc1aa70040647124b2028116fd099a3d345f84e28daeff2b2c63982ced" + url: "https://pub.dev" + source: hosted + version: "0.23.0" + forui_assets: + dependency: transitive + description: + name: forui_assets + sha256: bc7201e28a4dc4a9e0e65c849b9d9e10b5fcfd48a75b3ba7d912d5543044db6b + url: "https://pub.dev" + source: hosted + version: "0.23.0" + freezed: + dependency: "direct dev" + description: + name: freezed + sha256: f23ea33b3863f119b58ed1b586e881a46bd28715ddcc4dbc33104524e3434131 + url: "https://pub.dev" + source: hosted + version: "3.2.5" + freezed_annotation: + dependency: "direct main" + description: + name: freezed_annotation + sha256: "7294967ff0a6d98638e7acb774aac3af2550777accd8149c90af5b014e6d44d8" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + fvp: + dependency: "direct main" + description: + name: fvp + sha256: ac06c5ecdf65abb2ce413c9ca6c73fd74bff7c394879c17a98d6b94056b71c52 + url: "https://pub.dev" + source: hosted + version: "0.37.3" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + go_router: + dependency: "direct main" + description: + name: go_router + sha256: "5922b2861e2235a3504896f0d6fa07d84141b480cf52eecd2f42cd25585a9e8a" + url: "https://pub.dev" + source: hosted + version: "17.3.0" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + gtk: + dependency: transitive + description: + name: gtk + sha256: "4ff85b2a16724029dd9e5bbb5a94b6918f9973f74ba571c949d2002801879cf5" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + hooks: + dependency: transitive + description: + name: hooks + sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + html: + dependency: transitive + description: + name: html + sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" + url: "https://pub.dev" + source: hosted + version: "0.15.6" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + image: + dependency: transitive + description: + name: image + sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce + url: "https://pub.dev" + source: hosted + version: "4.8.0" + intl: + dependency: transitive + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.dev" + source: hosted + version: "0.20.2" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + jni: + dependency: transitive + description: + name: jni + sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f + url: "https://pub.dev" + source: hosted + version: "1.0.0" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + json_annotation: + dependency: "direct main" + description: + name: json_annotation + sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80" + url: "https://pub.dev" + source: hosted + version: "4.12.0" + json_serializable: + dependency: "direct dev" + description: + name: json_serializable + sha256: ffcd10cde35a93b2abbbcc26bd9971f4ca93763e8abe78d855e3c4177797e501 + url: "https://pub.dev" + source: hosted + version: "6.14.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.dev" + source: hosted + version: "0.12.19" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + media3_engine: + dependency: "direct main" + description: + path: "plugins/media3_engine" + relative: true + source: path + version: "0.0.1" + media_kit: + dependency: "direct main" + description: + name: media_kit + sha256: ae9e79597500c7ad6083a3c7b7b7544ddabfceacce7ae5c9709b0ec16a5d6643 + url: "https://pub.dev" + source: hosted + version: "1.2.6" + media_kit_libs_android_video: + dependency: transitive + description: + name: media_kit_libs_android_video + sha256: "3f6274e5ab2de512c286a25c327288601ee445ed8ac319e0ef0b66148bd8f76c" + url: "https://pub.dev" + source: hosted + version: "1.3.8" + media_kit_libs_ios_video: + dependency: transitive + description: + name: media_kit_libs_ios_video + sha256: b5382994eb37a4564c368386c154ad70ba0cc78dacdd3fb0cd9f30db6d837991 + url: "https://pub.dev" + source: hosted + version: "1.1.4" + media_kit_libs_linux: + dependency: transitive + description: + name: media_kit_libs_linux + sha256: "2b473399a49ec94452c4d4ae51cfc0f6585074398d74216092bf3d54aac37ecf" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + media_kit_libs_macos_video: + dependency: transitive + description: + name: media_kit_libs_macos_video + sha256: f26aa1452b665df288e360393758f84b911f70ffb3878032e1aabba23aa1032d + url: "https://pub.dev" + source: hosted + version: "1.1.4" + media_kit_libs_video: + dependency: "direct main" + description: + name: media_kit_libs_video + sha256: "2b235b5dac79c6020e01eef5022c6cc85fedc0df1738aadc6ea489daa12a92a9" + url: "https://pub.dev" + source: hosted + version: "1.0.7" + media_kit_libs_windows_video: + dependency: "direct overridden" + description: + path: "plugins/media_kit_libs_windows_video" + relative: true + source: path + version: "1.0.11+smplayer.1" + media_kit_video: + dependency: "direct main" + description: + name: media_kit_video + sha256: afaa509e7b7e0bf247557a3a740cde903a52c34ace9810f94500e127bd7b043d + url: "https://pub.dev" + source: hosted + version: "2.0.1" + meta: + dependency: transitive + description: + name: meta + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + url: "https://pub.dev" + source: hosted + version: "1.18.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed" + url: "https://pub.dev" + source: hosted + version: "9.4.1" + octo_image: + dependency: transitive + description: + name: octo_image + sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + package_info_plus: + dependency: "direct main" + description: + name: package_info_plus + sha256: f5c435dc0e0d461e5b32471a870f769b6a1cc46930637efe24fbc535314e78ad + url: "https://pub.dev" + source: hosted + version: "10.2.0" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: db762cb2f4f25ee60fb6359773861b0f199e00b90d237bd85a76a1e806b46ef4 + url: "https://pub.dev" + source: hosted + version: "4.1.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 + url: "https://pub.dev" + source: hosted + version: "2.1.6" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.dev" + source: hosted + version: "1.5.2" + posix: + dependency: transitive + description: + name: posix + sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07" + url: "https://pub.dev" + source: hosted + version: "6.5.0" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" + reorderable_grid: + dependency: "direct main" + description: + name: reorderable_grid + sha256: "15873d8afa6c0a106f0e165f4fbb2bb92dbfe18837e0c9f5d62ac4e26448863d" + url: "https://pub.dev" + source: hosted + version: "1.0.13" + riverpod: + dependency: transitive + description: + name: riverpod + sha256: "17100416c51db7810c71a7bb2c34d1f881faa0074fd452afb0c4db6f8f126c76" + url: "https://pub.dev" + source: hosted + version: "3.3.2" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" + source: hosted + version: "0.28.0" + safe_local_storage: + dependency: transitive + description: + name: safe_local_storage + sha256: "7483b3d5e8976f0bd263647c03b96131ee8e43f48b56fa8a8ec459e8515d74b0" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + screen_brightness: + dependency: "direct main" + description: + name: screen_brightness + sha256: e0edf92c08889e8f493cde291e7c687db2b4a1471f2371c074070b75d7c7d79b + url: "https://pub.dev" + source: hosted + version: "2.1.11" + screen_brightness_android: + dependency: transitive + description: + name: screen_brightness_android + sha256: "2008ad8e9527cc968f7a4de1ec58b476d495b3c612a149dbd6550c4f046da147" + url: "https://pub.dev" + source: hosted + version: "2.1.6" + screen_brightness_ios: + dependency: transitive + description: + name: screen_brightness_ios + sha256: "352d355e8523a186ba1e494b74218e5ca96e51975a0630b8ed89fbb7ff609762" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + screen_brightness_macos: + dependency: transitive + description: + name: screen_brightness_macos + sha256: b0957237b842d846a84363b69f229b339a8f91faced55041e245b2ebff7b0142 + url: "https://pub.dev" + source: hosted + version: "2.1.4" + screen_brightness_ohos: + dependency: transitive + description: + name: screen_brightness_ohos + sha256: "569a2c97909a50894b81487619eb7654f5358443a5af05f840c19261f49c0940" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + screen_brightness_platform_interface: + dependency: transitive + description: + name: screen_brightness_platform_interface + sha256: "2de60c0ba569b898950029cc1f7e9dd72bda44a22beb5054aac331cb6fce2ff2" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + screen_brightness_windows: + dependency: transitive + description: + name: screen_brightness_windows + sha256: "368b9c822e1671de81616e48150006e39eff2a434957e47ee638b09a32b2297c" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + screen_retriever: + dependency: "direct main" + description: + name: screen_retriever + sha256: ace919117a7520c13a50a6259e60c4a0d4cbe98809468792a91b5c5adada2aa6 + url: "https://pub.dev" + source: hosted + version: "0.2.2" + screen_retriever_linux: + dependency: transitive + description: + name: screen_retriever_linux + sha256: "7b52006a5ceae1f3d5af7f77188c3290d6e7d8ded16d99809bea84967c65c257" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + screen_retriever_macos: + dependency: transitive + description: + name: screen_retriever_macos + sha256: a1489b99cce597c45a54b9aae1cd94c8d4705353b7e0bb2457a6e4de44e0ad8a + url: "https://pub.dev" + source: hosted + version: "0.2.2" + screen_retriever_platform_interface: + dependency: transitive + description: + name: screen_retriever_platform_interface + sha256: "94a5535277510a63184ca178ce12a1449bc0b38618879aa1c18bf57369c5064a" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + screen_retriever_windows: + dependency: transitive + description: + name: screen_retriever_windows + sha256: dafc6922b0bfbf1d48cf3ccbf519b4fff47bdcb820da1728ea6db675fecc9324 + url: "https://pub.dev" + source: hosted + version: "0.2.2" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf + url: "https://pub.dev" + source: hosted + version: "2.5.5" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "93ae5884a9df5d3bb696825bceb3a17590754548b5d740eba51500afc8d088f5" + url: "https://pub.dev" + source: hosted + version: "2.4.26" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 + url: "https://pub.dev" + source: hosted + version: "1.1.3" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: ec37cc0e6694374cbef59ed79685572c870a54ede6fa30a3e420feb3adffea02 + url: "https://pub.dev" + source: hosted + version: "4.2.3" + source_helper: + dependency: transitive + description: + name: source_helper + sha256: "4227d54ceefd0bb8ca4c8fcb96e1719dc53f1ee1b6e2ca9d7a6069da160e4eae" + url: "https://pub.dev" + source: hosted + version: "1.3.12" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b + url: "https://pub.dev" + source: hosted + version: "2.1.2" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" + url: "https://pub.dev" + source: hosted + version: "0.10.13" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + sqflite: + dependency: transitive + description: + name: sqflite + sha256: "58a799e6ac17dd32fbab93813d39ed835a75ccc0f8f85b8955fe318c6712b082" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: d0548f9d7422a2dae99ec6f8b0a3074463b132d216fa5ba0d230eeefc901983b + url: "https://pub.dev" + source: hosted + version: "2.4.3" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: "5bf6a55c166e73bf651ba7ec3ed486e577620e3dc8f3a9c6a258a8031b624590" + url: "https://pub.dev" + source: hosted + version: "2.5.11" + sqflite_darwin: + dependency: transitive + description: + name: sqflite_darwin + sha256: c86ca18b8f666bbf903924687fe21cc16fc385d086005067e26619ca530bef9f + url: "https://pub.dev" + source: hosted + version: "2.4.3+1" + sqflite_platform_interface: + dependency: transitive + description: + name: sqflite_platform_interface + sha256: f84939f84350d92d04416f8bc4dc52d3896aec7716cc9e80cf0146342139dc50 + url: "https://pub.dev" + source: hosted + version: "2.4.1" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + state_notifier: + dependency: transitive + description: + name: state_notifier + sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb + url: "https://pub.dev" + source: hosted + version: "1.0.0" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.dev" + source: hosted + version: "2.1.1" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + sugar: + dependency: transitive + description: + name: sugar + sha256: a0a97f1d3552a3ef4cbec7c6e62c7b52c151cd5c11e984e8311700a918f73dc5 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: "93b153dcb6a26dcddee6ca087dd634b53e38c10b5aa163e8e49501a776456153" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test: + dependency: transitive + description: + name: test + sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20" + url: "https://pub.dev" + source: hosted + version: "1.31.0" + test_api: + dependency: transitive + description: + name: test_api + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + url: "https://pub.dev" + source: hosted + version: "0.7.11" + test_core: + dependency: transitive + description: + name: test_core + sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34" + url: "https://pub.dev" + source: hosted + version: "0.6.17" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + universal_platform: + dependency: transitive + description: + name: universal_platform + sha256: "64e16458a0ea9b99260ceb5467a214c1f298d647c659af1bff6d3bf82536b1ec" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + uri_parser: + dependency: transitive + description: + name: uri_parser + sha256: "051c62e5f693de98ca9f130ee707f8916e2266945565926be3ff20659f7853ce" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: b413d49b73867ac08dd2f9890efd3cc11f2a0e577618d50843440a1fb3776c32 + url: "https://pub.dev" + source: hosted + version: "6.3.32" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.dev" + source: hosted + version: "3.2.5" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" + uuid: + dependency: "direct main" + description: + name: uuid + sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" + url: "https://pub.dev" + source: hosted + version: "4.5.3" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + video_player: + dependency: transitive + description: + name: video_player + sha256: "48a7bdaa38a3d50ec10c78627abdbfad863fdf6f0d6e08c7c3c040cfd80ae36f" + url: "https://pub.dev" + source: hosted + version: "2.11.1" + video_player_android: + dependency: transitive + description: + name: video_player_android + sha256: "15bfd0598f8d8946bf0e2d50e2ca1a0db9491589a8bb7f6f9a388c0b48aef764" + url: "https://pub.dev" + source: hosted + version: "2.10.0" + video_player_avfoundation: + dependency: transitive + description: + name: video_player_avfoundation + sha256: "76097729ef0c976937945afa53f1ca3afa9b50c9a95909ba347bcf93270466fd" + url: "https://pub.dev" + source: hosted + version: "2.10.0" + video_player_platform_interface: + dependency: transitive + description: + name: video_player_platform_interface + sha256: "92c0fbabe20c788e71fd10d26cea998d0d253282e65d145aed0818731cf593ce" + url: "https://pub.dev" + source: hosted + version: "6.9.0" + video_player_web: + dependency: transitive + description: + name: video_player_web + sha256: "9f3c00be2ef9b76a95d94ac5119fb843dca6f2c69e6c9968f6f2b6c9e7afbdeb" + url: "https://pub.dev" + source: hosted + version: "2.4.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" + url: "https://pub.dev" + source: hosted + version: "15.2.0" + wakelock_plus: + dependency: "direct main" + description: + name: wakelock_plus + sha256: "824c5bba0f800e86d32e57d3d1843c531f090005cc89d9a837933e6601093d53" + url: "https://pub.dev" + source: hosted + version: "1.6.1" + wakelock_plus_platform_interface: + dependency: transitive + description: + name: wakelock_plus_platform_interface + sha256: b13f99e992e7ae6a152e16c5559d3c07ff445b13330192662494e614ca3e7d7b + url: "https://pub.dev" + source: hosted + version: "1.5.1" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + win32: + dependency: transitive + description: + name: win32 + sha256: ba6f4bba816c8d7e3c1580e170f3786d216951cc6b94babc3b814c08d2cb2738 + url: "https://pub.dev" + source: hosted + version: "6.3.0" + window_manager: + dependency: "direct main" + description: + name: window_manager + sha256: "05c231fd7b23d2380f14c5cc10b7b93d60d4fa4a2fb4e0f032de27e44b5560e9" + url: "https://pub.dev" + source: hosted + version: "0.5.2" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.12.0 <4.0.0" + flutter: ">=3.44.0" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..dda6d76 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,138 @@ +name: smplayer +description: SM Emby - Emby 媒体服务器客户端 +publish_to: "none" +version: 0.2.7 + +environment: + sdk: ^3.12.0 + flutter: ">=3.44.0" + +dependencies: + flutter: + sdk: flutter + + # 本地化(GlobalMaterial/Cupertino/Widgets 本地化 + zh-CN locale, + # 用于让 CJK 文本按简体中文选择字形并提供中文 Material 文案) + flutter_localizations: + sdk: flutter + + # 状态管理 + flutter_riverpod: ^3.3.2 + + # 路由 + go_router: ^17.3.0 + + # HTTP + dio: ^5.7.0 + + # Primary video engine: fvp (libmdk) standalone, via package:fvp/mdk.dart. + # libmdk is pinned to v0.37.0 on Windows and Android: Windows via + # scripts/prepare-fvp-windows-deps.ps1, Android via FVP_DEPS_URL + # (dev-android scripts + release-android.yml). macOS remains governed by + # fvp's CocoaPods `mdk` constraint. Do NOT use FVP_DEPS_LATEST nightly. + fvp: 0.37.3 + + # Secondary video engine: media_kit (mpv/libmpv). + # Coexists with fvp on macOS: both are dynamic xcframeworks with different + # install names and different FFmpeg major versions (mdk→libavcodec.62 weak, + # mpv→Avcodec@60), so they do not collide at link or runtime. + media_kit: ^1.2.6 + media_kit_video: ^2.0.1 + media_kit_libs_video: ^1.0.7 + + # Tertiary video engine (Android only): Media3 ExoPlayer + FFmpeg audio decoder. + # 仅 Android 注册;本地 path 依赖,不发布 pub。AAR (media3-decoder-ffmpeg + # arm64-v8a) 由 scripts/build_media3_ffmpeg.sh 生成,放在 android/app/libs/。 + media3_engine: + path: plugins/media3_engine + + # 图片缓存 + cached_network_image: ^3.4.1 + + # 本地存储 + shared_preferences: ^2.3.4 + path_provider: ^2.1.5 + package_info_plus: ^10.1.0 + file_selector: ^1.0.3 + + # 深链 / 外部浏览器(Trakt OAuth 账号链接) + app_links: ^7.2.0 + url_launcher: ^6.3.1 + + # 加密(SHA-256 校验) + crypto: ^3.0.0 + + # 工具 + uuid: ^4.5.1 + + # Codegen 注解(运行时) + freezed_annotation: ^3.1.0 + json_annotation: ^4.12.0 + + # 桌面窗口管理 + window_manager: ^0.5.2 + screen_retriever: ^0.2.2 + canvas_danmaku: ^0.3.1 + wakelock_plus: ^1.2.8 + screen_brightness: ^2.1.11 + forui: ^0.23.0 + reorderable_grid: ^1.0.13 + # 脚本组件 JS 引擎(QuickJS via Rust,全平台统一;替代 flutter_js—— + # 其 macOS/iOS JSC 路径同步跑在主 isolate 上,失控脚本会带崩整个进程) + fjs: ^3.2.0 + +flutter_launcher_icons: + image_path: "assets/icon/app_icon.png" + macos: + generate: true + image_path: "assets/icon/app_icon.png" + windows: + generate: true + image_path: "assets/icon/app_icon.png" + +dependency_overrides: + # Windows: vendor fork with a full libmpv build (upstream 1.0.11 ships a + # stripped FFmpeg missing truehd audio + hdmv_pgs_subtitle bitmap decoders, + # media-kit/media-kit#1269 / #1371). See plugins/media_kit_libs_windows_video/ + # README.md for the rationale and follow-ups. + media_kit_libs_windows_video: + path: plugins/media_kit_libs_windows_video + +dev_dependencies: + flutter_launcher_icons: ^0.14.3 + flutter_lints: ^6.0.0 + + # 单元测试 + flutter_test: + sdk: flutter + fake_async: ^1.3.2 + # 仅测试用:ExternalLibrary(为 flutter test 显式加载 libfjs 动态库) + flutter_rust_bridge: ^2.12.0 + + # Codegen 工具链 + build_runner: ^2.4.13 + freezed: ^3.2.3 + json_serializable: ^6.14.0 + +flutter: + uses-material-design: true + + # fjs 的 SwiftPM 集成依赖包内 fjs.xcframework 的符号链接结构,而 pub.dev + # 发布会把符号链接压平(Versions/Current 变成真实目录),Xcode 处理该 + # framework 时报 "Couldn't resolve framework symlink"。关闭本项目的 SwiftPM + # 集成,让 fjs 走 CocoaPods(darwin/fjs.podspec → cargokit,见根目录 + # cargokit_options.yaml 的预编译产物配置);其余插件本就经 Podfile 构建。 + config: + enable-swift-package-manager: false + + assets: + - assets/icon/app_icon.png + - assets/js/cheerio.min.js + + fonts: + # 简体中文字体(variable,单文件覆盖全部字重)。仅作为 Latin 主字体 + # (Segoe UI / GeistSans)的 fontFamilyFallback,使中文渲染为简体字形, + # 不改变英文/数字的既有观感,并保证跨平台(Windows/macOS)一致。 + - family: Noto Sans SC + fonts: + - asset: assets/fonts/NotoSansSC.ttf diff --git a/scripts/build-installer.ps1 b/scripts/build-installer.ps1 new file mode 100644 index 0000000..28aedac --- /dev/null +++ b/scripts/build-installer.ps1 @@ -0,0 +1,87 @@ + +[CmdletBinding()] +param( + [string]$Version, + [switch]$SkipFlutterBuild +) + +$ErrorActionPreference = 'Stop' + +$ProjectRoot = Split-Path -Parent $PSScriptRoot +Set-Location $ProjectRoot +. (Join-Path $PSScriptRoot 'version-common.ps1') +Write-Host "==> Project root: $ProjectRoot" -ForegroundColor Cyan + +if (-not $Version) { + $Version = Get-SmPlayerCurrentVersion -Platform windows -ProjectRoot $ProjectRoot +} +Write-Host "==> Version: $Version" -ForegroundColor Cyan + +$IsccCandidates = @( + "$Env:ProgramFiles\Inno Setup 6\ISCC.exe", + "${Env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe", + "$Env:LOCALAPPDATA\Programs\Inno Setup 6\ISCC.exe", + "$Env:ProgramFiles\Inno Setup 5\ISCC.exe", + "${Env:ProgramFiles(x86)}\Inno Setup 5\ISCC.exe", + "$Env:LOCALAPPDATA\Programs\Inno Setup 5\ISCC.exe" +) +$Iscc = $IsccCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1 +if (-not $Iscc) { + $cmd = Get-Command iscc.exe -ErrorAction SilentlyContinue + if ($cmd) { $Iscc = $cmd.Source } +} +if (-not $Iscc) { + Write-Host "" + Write-Host "[X] ISCC.exe not found." -ForegroundColor Red + Write-Host " Install: https://jrsoftware.org/isdl.php" -ForegroundColor Yellow + Write-Host " Or: winget install -e --id JRSoftware.InnoSetup" -ForegroundColor Yellow + exit 1 +} +Write-Host "==> ISCC: $Iscc" -ForegroundColor Cyan + +if (-not $SkipFlutterBuild) { + Write-Host '==> flutter pub get' -ForegroundColor Cyan + & flutter pub get + if ($LASTEXITCODE -ne 0) { throw "flutter pub get failed (exit $LASTEXITCODE)" } + + $prepareFvpScript = Join-Path $PSScriptRoot 'prepare-fvp-windows-deps.ps1' + & powershell -ExecutionPolicy Bypass -File $prepareFvpScript + if ($LASTEXITCODE -ne 0) { + throw "Failed to prepare pinned fvp Windows dependencies (exit $LASTEXITCODE)" + } + + $flutterBuildArgs = @('build', 'windows', '--release', '--build-name', $Version) + $flutterBuildArgs = Add-SmPlayerVersionDartDefine -Arguments $flutterBuildArgs -Version $Version + + Write-Host "==> flutter $($flutterBuildArgs -join ' ')" -ForegroundColor Cyan + $Env:FVP_DEPS_LATEST = $null + & flutter @flutterBuildArgs + if ($LASTEXITCODE -ne 0) { throw "flutter build failed (exit $LASTEXITCODE)" } +} else { + Write-Host "==> Skipping flutter build (--SkipFlutterBuild)" -ForegroundColor Yellow +} + +$ReleaseDir = Join-Path $ProjectRoot "build\windows\x64\runner\Release" +$Exe = Join-Path $ReleaseDir "smplayer.exe" +if (-not (Test-Path $Exe)) { + throw "Build artifact not found: $Exe (re-run without -SkipFlutterBuild)" +} + +$Iss = Join-Path $ProjectRoot "installer\sm_emby.iss" +$OutputDir = Join-Path $ProjectRoot "build\installer" +New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null + +Write-Host "==> Building installer" -ForegroundColor Cyan +& $Iscc "/DAppVersion=$Version" $Iss +if ($LASTEXITCODE -ne 0) { throw "ISCC failed (exit $LASTEXITCODE)" } + +$Setup = Join-Path $OutputDir "smplayer-setup-$Version-x64.exe" +if (Test-Path $Setup) { + $size = (Get-Item $Setup).Length / 1MB + Write-Host "" + Write-Host "[OK] Installer generated:" -ForegroundColor Green + Write-Host " $Setup" -ForegroundColor Green + Write-Host (" Size: {0:N2} MB" -f $size) -ForegroundColor Green +} else { + Write-Host "[!] Expected artifact missing, check $OutputDir" -ForegroundColor Yellow +} diff --git a/scripts/build-macos.sh b/scripts/build-macos.sh new file mode 100644 index 0000000..04b3c25 --- /dev/null +++ b/scripts/build-macos.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$REPO_ROOT" +source "$REPO_ROOT/scripts/version-common.sh" + +APP_VERSION="$(smplayer_current_version macos)" +echo "[build-macos] App version: $APP_VERSION (windows/pc line)" + +BUILD_ARGS=(build macos --release) +if [[ $# -gt 0 ]]; then + BUILD_ARGS+=("$@") +fi +if ! smplayer_has_flutter_option --build-name "${BUILD_ARGS[@]}"; then + BUILD_ARGS+=(--build-name "$APP_VERSION") +fi +if ! smplayer_has_dart_define SMPLAYER_VERSION "${BUILD_ARGS[@]}"; then + BUILD_ARGS+=("--dart-define=SMPLAYER_VERSION=$APP_VERSION") +fi + +echo "[build-macos] flutter ${BUILD_ARGS[*]}" +exec flutter "${BUILD_ARGS[@]}" diff --git a/scripts/build_dmg.sh b/scripts/build_dmg.sh new file mode 100755 index 0000000..3f96e75 --- /dev/null +++ b/scripts/build_dmg.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$REPO_ROOT" +source "$REPO_ROOT/scripts/version-common.sh" + +if [[ "$(uname -s)" != "Darwin" ]]; then + echo "error: this script must run on macOS" >&2 + exit 1 +fi + +SKIP_BUILD=0 +for arg in "$@"; do + case "$arg" in + --no-build) SKIP_BUILD=1 ;; + -h|--help) sed -n '2,13p' "$0"; exit 0 ;; + *) echo "error: unknown arg: $arg" >&2; exit 2 ;; + esac +done + +VERSION="$(smplayer_current_version macos)" + +APP_DISPLAY_NAME="smPlayer" +VOL_NAME="$APP_DISPLAY_NAME" +DMG_NAME="${APP_DISPLAY_NAME}-${VERSION}-macos.dmg" +APP_SRC="build/macos/Build/Products/Release/sm_emby.app" +DIST_DIR="dist" +DMG_PATH="${DIST_DIR}/${DMG_NAME}" + +if [[ "$SKIP_BUILD" -eq 0 ]]; then + BUILD_ARGS=(build macos --release) + if ! smplayer_has_flutter_option --build-name "${BUILD_ARGS[@]}"; then + BUILD_ARGS+=(--build-name "$VERSION") + fi + if ! smplayer_has_dart_define SMPLAYER_VERSION "${BUILD_ARGS[@]}"; then + BUILD_ARGS+=("--dart-define=SMPLAYER_VERSION=$VERSION") + fi + + echo "==> flutter ${BUILD_ARGS[*]}" + flutter "${BUILD_ARGS[@]}" +fi + +if [[ ! -d "$APP_SRC" ]]; then + echo "error: app bundle not found at $APP_SRC" >&2 + echo " run without --no-build, or build first: flutter build macos --release" >&2 + exit 1 +fi + +STAGE_DIR="$(mktemp -d -t smemby-dmg)" +cleanup() { + if [[ -n "${MOUNTED_DEV:-}" ]] && hdiutil info | grep -q "$MOUNTED_DEV"; then + hdiutil detach "$MOUNTED_DEV" -quiet || true + fi + rm -rf "$STAGE_DIR" +} +trap cleanup EXIT + +echo "==> staging .app at $STAGE_DIR" +mkdir -p "$STAGE_DIR/dmg" +cp -R "$APP_SRC" "$STAGE_DIR/dmg/${APP_DISPLAY_NAME}.app" +ln -s /Applications "$STAGE_DIR/dmg/Applications" + +mkdir -p "$DIST_DIR" +rm -f "$DMG_PATH" + +echo "==> hdiutil create $DMG_PATH" +hdiutil create \ + -volname "$VOL_NAME" \ + -srcfolder "$STAGE_DIR/dmg" \ + -ov -format UDZO \ + "$DMG_PATH" >/dev/null + +echo "==> verifying DMG mounts" +ATTACH_OUT="$(LANG=C LC_ALL=C hdiutil attach "$DMG_PATH" -nobrowse -readonly -noautoopen)" +MOUNTED_DEV="$(printf '%s\n' "$ATTACH_OUT" | awk '/^\/dev\//{print $1; exit}')" +MOUNT_POINT="$(printf '%s\n' "$ATTACH_OUT" | awk -F'\t' '/\/Volumes\//{print $NF; exit}')" + +if [[ -z "$MOUNTED_DEV" || -z "$MOUNT_POINT" ]]; then + echo "error: failed to parse hdiutil attach output" >&2 + echo "$ATTACH_OUT" >&2 + exit 1 +fi + +if [[ ! -d "$MOUNT_POINT/${APP_DISPLAY_NAME}.app" ]] || [[ ! -L "$MOUNT_POINT/Applications" ]]; then + echo "error: DMG layout verification failed (missing app or Applications symlink)" >&2 + hdiutil detach "$MOUNTED_DEV" -quiet || true + exit 1 +fi +hdiutil detach "$MOUNTED_DEV" -quiet +MOUNTED_DEV="" + +SIZE_HUMAN="$(du -h "$DMG_PATH" | awk '{print $1}')" +echo +echo "✓ DMG ready: $DMG_PATH ($SIZE_HUMAN)" +echo " Unsigned: first launch on another Mac requires right-click -> Open." diff --git a/scripts/build_media3_ffmpeg.sh b/scripts/build_media3_ffmpeg.sh new file mode 100755 index 0000000..9721a51 --- /dev/null +++ b/scripts/build_media3_ffmpeg.sh @@ -0,0 +1,336 @@ +#!/usr/bin/env bash + +set -euo pipefail + + +MEDIA3_TAG='1.10.1' +FFMPEG_BRANCH='release/6.0' +NDK_PATH="${ANDROID_NDK_HOME:-}" +HOST_PLATFORM='' +CLEAN=0 +ANDROID_ABI='21' +ENABLED_DECODERS=(dca ac3 eac3 truehd opus vorbis flac) + +usage() { + cat <<'USAGE' +Usage: scripts/build_media3_ffmpeg.sh [options] + +Options: + --clean 编前先清空工作目录 + --media3-tag androidx/media tag (default: 1.10.1) + --ffmpeg-branch FFmpeg branch (default: release/6.0) + --ndk-path Android NDK 路径(推荐 r27d LTS;默认 $ANDROID_NDK_HOME) + --host-platform darwin-x86_64 / linux-x86_64 + (省略时根据 uname 推断;Apple Silicon 强烈建议 + 通过 Docker linux-x86_64 容器执行) + -h, --help 显示本帮助 +USAGE +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --clean) CLEAN=1; shift ;; + --media3-tag) MEDIA3_TAG="$2"; shift 2 ;; + --ffmpeg-branch) FFMPEG_BRANCH="$2"; shift 2 ;; + --ndk-path) NDK_PATH="$2"; shift 2 ;; + --host-platform) HOST_PLATFORM="$2"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) echo "unknown option: $1" >&2; usage; exit 2 ;; + esac +done + + +require_cmd() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "ERROR: $1 not found in PATH" >&2 + exit 1 + fi +} +require_cmd git +require_cmd shasum + +if [[ -z "${NDK_PATH}" ]]; then + echo 'ERROR: NDK path not specified. Use --ndk-path or set ANDROID_NDK_HOME.' >&2 + echo 'Pin to NDK r27d LTS (27.3.13750724) — ABI-compatible with Media3 1.10.1 build_ffmpeg.sh.' >&2 + exit 1 +fi +if [[ ! -d "${NDK_PATH}" ]]; then + echo "ERROR: NDK path not a directory: ${NDK_PATH}" >&2 + exit 1 +fi + +SDK_PATH="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}" +if [[ -z "${SDK_PATH}" ]]; then + for candidate in \ + "${HOME}/Library/Android/sdk" \ + "${HOME}/Android/Sdk" \ + "/opt/android-sdk" \ + "/usr/local/share/android-sdk"; do + if [[ -d "${candidate}/platforms" ]]; then + SDK_PATH="${candidate}" + echo "[detect] Android SDK at ${SDK_PATH}" + break + fi + done +fi +if [[ -z "${SDK_PATH}" || ! -d "${SDK_PATH}/platforms" ]]; then + echo 'ERROR: Android SDK not found. Set ANDROID_HOME or install Android Studio SDK.' >&2 + echo ' Required: $ANDROID_HOME/platforms (android-34+) + build-tools.' >&2 + exit 1 +fi + +if [[ -z "${HOST_PLATFORM}" ]]; then + uname_s="$(uname -s)" + uname_m="$(uname -m)" + case "${uname_s}-${uname_m}" in + Darwin-x86_64) HOST_PLATFORM='darwin-x86_64' ;; + Darwin-arm64) + HOST_PLATFORM='darwin-x86_64' + echo 'WARN: host is Apple Silicon; Media3 README only documents darwin-x86_64.' >&2 + echo ' Build will proceed under Rosetta. For reproducibility prefer a' >&2 + echo ' Docker linux-x86_64 container (see plugins/media3_engine/README.md).' >&2 + ;; + Linux-x86_64) HOST_PLATFORM='linux-x86_64' ;; + *) echo "ERROR: unsupported host ${uname_s}-${uname_m}. Pass --host-platform." >&2; exit 1 ;; + esac +fi + + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +WORK_ROOT="${REPO_ROOT}/.media3-build" +MEDIA_REPO="${WORK_ROOT}/media" +FFMPEG_MODULE_PATH="${MEDIA_REPO}/libraries/decoder_ffmpeg/src/main" +FFMPEG_DIR="${FFMPEG_MODULE_PATH}/jni/ffmpeg" +OUT_LIBS_DIR="${REPO_ROOT}/android/app/libs" +AAR_NAME="media3-decoder-ffmpeg-${MEDIA3_TAG}-arm64.aar" +OUT_AAR="${OUT_LIBS_DIR}/${AAR_NAME}" +PATCH_BACKUP_DIR='' +PATCHED_FILES=() + +backup_patch_file() { + local file="$1" + if [[ -z "${PATCH_BACKUP_DIR}" ]]; then + PATCH_BACKUP_DIR="$(mktemp -d)" + fi + local backup="${PATCH_BACKUP_DIR}/${#PATCHED_FILES[@]}" + cp "${file}" "${backup}" + PATCHED_FILES+=("${file}:${backup}") +} + +restore_patched_files() { + local entry file backup + for entry in "${PATCHED_FILES[@]}"; do + file="${entry%%:*}" + backup="${entry#*:}" + if [[ -f "${backup}" ]]; then + cp "${backup}" "${file}" + fi + done + if [[ -n "${PATCH_BACKUP_DIR}" ]]; then + rm -rf "${PATCH_BACKUP_DIR}" + fi +} +trap restore_patched_files EXIT + +if [[ ${CLEAN} -eq 1 && -d "${WORK_ROOT}" ]]; then + echo "[clean] removing ${WORK_ROOT}" + rm -rf "${WORK_ROOT}" +fi +mkdir -p "${WORK_ROOT}" "${OUT_LIBS_DIR}" + + +if [[ ! -d "${MEDIA_REPO}/.git" ]]; then + echo "[clone] androidx/media @ ${MEDIA3_TAG}" + git clone --depth 1 --branch "${MEDIA3_TAG}" \ + https://github.com/androidx/media.git "${MEDIA_REPO}" +else + echo "[skip] ${MEDIA_REPO} already exists; pass --clean to refetch" +fi + + +if [[ ! -d "${FFMPEG_DIR}/.git" ]]; then + echo "[clone] FFmpeg ${FFMPEG_BRANCH}" + mkdir -p "$(dirname "${FFMPEG_DIR}")" + git clone --depth 1 --branch "${FFMPEG_BRANCH}" \ + https://git.ffmpeg.org/ffmpeg.git "${FFMPEG_DIR}" +else + echo "[skip] ${FFMPEG_DIR} already exists" +fi + + +JNI_BUILD_SH="${FFMPEG_MODULE_PATH}/jni/build_ffmpeg.sh" +if [[ ! -f "${JNI_BUILD_SH}" ]]; then + echo "ERROR: ${JNI_BUILD_SH} not found (androidx/media tag ${MEDIA3_TAG} 内未包含)" >&2 + exit 1 +fi +if ! grep -q '# SM-PATCH arm64-only' "${JNI_BUILD_SH}"; then + echo "[patch] forcing arm64-v8a only in ${JNI_BUILD_SH##*/}" + backup_patch_file "${JNI_BUILD_SH}" + python3 - "${JNI_BUILD_SH}" <<'PY' || { echo 'ERROR: failed to patch build_ffmpeg.sh (upstream layout changed?)' >&2; exit 1; } +import re, sys +p = sys.argv[1] +src = open(p, 'r', encoding='utf-8').read() +if '# SM-PATCH arm64-only' in src: + sys.exit(0) +KEEP = 'arm64-v8a' +block_re = re.compile( + r'\./configure\s*\\\s*\n' + r'(?:.*\n)*?' + r'\s*--libdir=android-libs/([\w-]+)\s*\\\s*\n' + r'(?:.*\n)*?' + r'make clean\s*\n', + re.M, +) +removed = [] +chunks = [] +last = 0 +for m in block_re.finditer(src): + abi = m.group(1) + if abi == KEEP: + continue + chunks.append(src[last:m.start()]) + last = m.end() + removed.append(abi) +chunks.append(src[last:]) +if not removed: + sys.stderr.write('ERROR: no non-arm64 configure blocks found; upstream layout may have changed.\n') + sys.exit(2) +result = ''.join(chunks).replace( + 'set -eu\n', + f'set -eu\n# SM-PATCH arm64-only (removed: {",".join(removed)})\n', + 1, +) +open(p, 'w', encoding='utf-8').write(result) +sys.stderr.write(f'[ok] removed ABI blocks: {",".join(removed)}\n') +PY +fi + + +echo "[build] FFmpeg native libs (decoders: ${ENABLED_DECODERS[*]})" +pushd "${FFMPEG_MODULE_PATH}/jni" >/dev/null +NDK_PATH="${NDK_PATH}" \ +HOST_PLATFORM="${HOST_PLATFORM}" \ +ANDROID_ABI="${ANDROID_ABI}" \ + ./build_ffmpeg.sh \ + "${FFMPEG_MODULE_PATH}" \ + "${NDK_PATH}" \ + "${HOST_PLATFORM}" \ + "${ANDROID_ABI}" \ + "${ENABLED_DECODERS[@]}" +popd >/dev/null + + +echo '[build] Gradle :libraries:decoder_ffmpeg:assembleRelease' +cat >"${MEDIA_REPO}/local.properties" <&2 + exit 1 +fi +COMMON_CONFIG="${MEDIA_REPO}/common_config.gradle" +if ! grep -q '// SM-PATCH ndkVersion' "${COMMON_CONFIG}"; then + echo "[patch] injecting ndkVersion=${NDK_VERSION} into common_config.gradle" + backup_patch_file "${COMMON_CONFIG}" + python3 - "${COMMON_CONFIG}" "${NDK_VERSION}" <<'PY' || { echo 'ERROR: failed to inject ndkVersion' >&2; exit 1; } +import re, sys +p, version = sys.argv[1], sys.argv[2] +src = open(p, 'r', encoding='utf-8').read() +patched, n = re.subn( + r'^android\s*\{\s*\n', + f'android {{\n ndkVersion = "{version}" // SM-PATCH ndkVersion\n', + src, count=1, flags=re.M, +) +if n != 1: + sys.stderr.write('ERROR: failed to find `android {` in common_config.gradle\n') + sys.exit(2) +open(p, 'w', encoding='utf-8').write(patched) +PY +fi + +DECODER_BUILD_GRADLE="${MEDIA_REPO}/libraries/decoder_ffmpeg/build.gradle" +if ! grep -q '// SM-PATCH abiFilters' "${DECODER_BUILD_GRADLE}"; then + echo "[patch] restricting decoder_ffmpeg abiFilters to arm64-v8a" + backup_patch_file "${DECODER_BUILD_GRADLE}" + python3 - "${DECODER_BUILD_GRADLE}" <<'PY' || { echo 'ERROR: failed to inject abiFilters' >&2; exit 1; } +import re, sys +p = sys.argv[1] +src = open(p, 'r', encoding='utf-8').read() +inject = ( + "android {\n" + " defaultConfig {\n" + " ndk {\n" + " abiFilters 'arm64-v8a' // SM-PATCH abiFilters\n" + " }\n" + " }\n" + "}\n\n" +) +patched, n = re.subn( + r'(apply from: "\$gradle\.ext\.androidxMediaSettingsDir/common_config\.gradle"\n)', + r'\1\n' + inject, + src, count=1, +) +if n != 1: + sys.stderr.write('ERROR: failed to find common_config apply line in decoder_ffmpeg/build.gradle\n') + sys.exit(2) +open(p, 'w', encoding='utf-8').write(patched) +PY +fi + +pushd "${MEDIA_REPO}" >/dev/null +ANDROID_HOME="${SDK_PATH}" ANDROID_SDK_ROOT="${SDK_PATH}" \ + ./gradlew --no-daemon :lib-decoder-ffmpeg:assembleRelease +popd >/dev/null + + +GRADLE_AAR='' +for pattern in \ + "${MEDIA_REPO}/libraries/decoder_ffmpeg/buildout/outputs/aar/lib-decoder-ffmpeg-release.aar" \ + "${MEDIA_REPO}/libraries/decoder_ffmpeg/build/outputs/aar/lib-decoder-ffmpeg-release.aar" \ + "${MEDIA_REPO}/libraries/decoder_ffmpeg/buildout/outputs/aar/decoder_ffmpeg-release.aar" \ + "${MEDIA_REPO}/libraries/decoder_ffmpeg/build/outputs/aar/decoder_ffmpeg-release.aar"; do + if [[ -f "${pattern}" ]]; then + GRADLE_AAR="${pattern}"; break + fi +done +if [[ -z "${GRADLE_AAR}" ]]; then + echo 'ERROR: AAR not found under decoder_ffmpeg/{buildout,build}/outputs/aar/. 检查 Gradle 输出' >&2 + exit 1 +fi +cp "${GRADLE_AAR}" "${OUT_AAR}" +SHA256="$(shasum -a 256 "${OUT_AAR}" | awk '{print $1}')" + + +CHECKSUMS="${OUT_LIBS_DIR}/CHECKSUMS.txt" +touch "${CHECKSUMS}" +python3 - "${CHECKSUMS}" "${AAR_NAME}" "${SHA256}" <<'PY' +import sys, re +p, name, sha = sys.argv[1], sys.argv[2], sys.argv[3] +with open(p, 'r', encoding='utf-8') as f: + lines = [ln for ln in f.read().splitlines() if not ln.endswith(name)] +lines.append(f'{sha} {name}') +with open(p, 'w', encoding='utf-8') as f: + f.write('\n'.join(lines) + '\n') +PY + + +cat < flutter pub get' -ForegroundColor Yellow + $pubArgs = @('pub', 'get') + if ($Offline) { $pubArgs += '--offline' } + & flutter @pubArgs + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + Save-PubGetStamp +} else { + Write-Host '[dev-run] 依赖未变更 -> 跳过 pub get' -ForegroundColor Green +} + +Prepare-PinnedFvpWindowsDependencies + +$appVersion = Get-SmPlayerCurrentVersion -Platform windows -ProjectRoot $repoRoot +Write-Host "[dev-run] App version: $appVersion (windows/pc line)" -ForegroundColor Cyan + +$runArgs = @('run', '-d', $Device, '--no-pub') +if ($FlutterArgs) { $runArgs += $FlutterArgs } +$runArgs = Add-SmPlayerVersionDartDefine -Arguments $runArgs -Version $appVersion + +Write-Host "[dev-run] flutter $($runArgs -join ' ')" -ForegroundColor Cyan +Write-Host "[dev-run] 启动准备耗时: $([int]$sw.Elapsed.TotalMilliseconds) ms" -ForegroundColor DarkGray + +& flutter @runArgs +exit $LASTEXITCODE diff --git a/scripts/dev.ps1 b/scripts/dev.ps1 new file mode 100644 index 0000000..6e68d44 --- /dev/null +++ b/scripts/dev.ps1 @@ -0,0 +1,142 @@ + +$Command = 'help' +$Rest = @() +if ($args.Count -gt 0) { + $Command = [string]$args[0] + if ($args.Count -gt 1) { + $Rest = @($args[1..($args.Count - 1)]) + } +} + +$ErrorActionPreference = 'Stop' + +$RepoRoot = Split-Path -Parent $PSScriptRoot +$ScriptsDir = $PSScriptRoot +$PinnedMdkReleaseUrl = 'https://github.com/wang-bin/mdk-sdk/releases/download/v0.37.0' +Set-Location $RepoRoot +. (Join-Path $ScriptsDir 'version-common.ps1') + +function Show-Help { + @" +SM-Emby Unified Dev Entry + + scripts\dev.ps1 [args...] + +Subcommands: + run Smart dev run -- prepare the pinned Windows MDK SDK and skip unchanged pub get + pass-through args: -Device, -Force, -Offline, -RefreshFvpDeps, -FlutterArgs + android Android dev run -- 自动检测真机/模拟器 + pass-through args: -Device , -Emulator , -Force, -Offline, -FlutterArgs + installer Build .exe installer (alias of build-installer.ps1) + pass-through args: -SkipFlutterBuild, -Version + build Build Windows release with the project-pinned MDK SDK + build-apk Build Android arm64 release with the project-pinned MDK SDK + gen dart run build_runner build --delete-conflicting-outputs + clean flutter clean + pub flutter pub get + analyze flutter analyze + doctor flutter doctor -v + help This help + +Examples: + scripts\dev.ps1 run + scripts\dev.ps1 run -Force -FlutterArgs --release + scripts\dev.ps1 run -RefreshFvpDeps + scripts\dev.ps1 android + scripts\dev.ps1 android -Device 18fa8f58 + scripts\dev.ps1 android -Emulator Pixel_10_Pro_XL + scripts\dev.ps1 installer + scripts\dev.ps1 installer -SkipFlutterBuild + scripts\dev.ps1 build + scripts\dev.ps1 build-apk + scripts\dev.ps1 gen +"@ | Write-Host +} + +function Invoke-Sub([string]$ScriptName) { + $path = Join-Path $ScriptsDir $ScriptName + if (-not (Test-Path $path)) { throw "Script not found: $path" } + & $path @args + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +} + +function Invoke-Flutter([string[]]$Args) { + Write-Host "[dev] flutter $($Args -join ' ')" -ForegroundColor Cyan + & flutter @Args + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +} + +function Prepare-PinnedFvpWindowsDependencies { + Invoke-Flutter @('pub', 'get') + + $prepareScript = Join-Path $ScriptsDir 'prepare-fvp-windows-deps.ps1' + & powershell -ExecutionPolicy Bypass -File $prepareScript + if ($LASTEXITCODE -ne 0) { + throw "Failed to prepare pinned fvp Windows dependencies (exit $LASTEXITCODE)" + } +} + +function Prepare-PinnedFvpAndroidDependencies { + $Env:FVP_DEPS_URL = $PinnedMdkReleaseUrl + $Env:FVP_DEPS_LATEST = $null + + Invoke-Flutter @('pub', 'get') + + $prepareScript = Join-Path $ScriptsDir 'prepare_fvp_android_deps.dart' + & dart $prepareScript --project-root $RepoRoot + if ($LASTEXITCODE -ne 0) { + throw "Failed to prepare pinned fvp Android dependencies (exit $LASTEXITCODE)" + } +} + +switch ($Command.ToLower()) { + 'run' { Invoke-Sub 'dev-run.ps1' @Rest } + 'android' { Invoke-Sub 'dev-android.ps1' @Rest } + 'installer' { Invoke-Sub 'build-installer.ps1' @Rest } + 'build' { + Prepare-PinnedFvpWindowsDependencies + + $appVersion = Get-SmPlayerCurrentVersion -Platform windows -ProjectRoot $RepoRoot + Write-Host "[dev] App version: $appVersion (windows/pc line)" -ForegroundColor Cyan + + $flutterBuildArgs = @('build', 'windows', '--release') + if ($Rest) { $flutterBuildArgs += $Rest } + $flutterBuildArgs = Add-SmPlayerBuildName -Arguments $flutterBuildArgs -Version $appVersion + $flutterBuildArgs = Add-SmPlayerVersionDartDefine -Arguments $flutterBuildArgs -Version $appVersion + Invoke-Flutter $flutterBuildArgs + } + 'build-apk' { + Prepare-PinnedFvpAndroidDependencies + + $appVersion = Get-SmPlayerCurrentVersion -Platform android -ProjectRoot $RepoRoot + $buildNumber = ConvertTo-SmPlayerBuildNumber -Version $appVersion + Write-Host "[dev] App version: $appVersion (android line), build number: $buildNumber" -ForegroundColor Cyan + + $flutterBuildArgs = @('build', 'apk', '--release', '--target-platform', 'android-arm64') + if ($Rest) { $flutterBuildArgs += $Rest } + $flutterBuildArgs = Add-SmPlayerBuildName -Arguments $flutterBuildArgs -Version $appVersion + if (-not (Test-SmPlayerFlutterOptionPresent -Arguments $flutterBuildArgs -OptionName '--build-number')) { + $flutterBuildArgs += @('--build-number', [string]$buildNumber) + } + $flutterBuildArgs = Add-SmPlayerVersionDartDefine -Arguments $flutterBuildArgs -Version $appVersion + Invoke-Flutter $flutterBuildArgs + } + 'gen' { + Write-Host '[dev] dart run build_runner build --delete-conflicting-outputs' -ForegroundColor Cyan + & dart run build_runner build --delete-conflicting-outputs + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + } + 'clean' { Invoke-Flutter @('clean') } + 'pub' { Invoke-Flutter @('pub', 'get') } + 'analyze' { Invoke-Flutter @('analyze') } + 'doctor' { Invoke-Flutter @('doctor', '-v') } + 'help' { Show-Help } + '--help' { Show-Help } + '-h' { Show-Help } + default { + Write-Host "Unknown subcommand: $Command" -ForegroundColor Red + Write-Host '' + Show-Help + exit 1 + } +} diff --git a/scripts/dev_android.dart b/scripts/dev_android.dart new file mode 100644 index 0000000..8905c86 --- /dev/null +++ b/scripts/dev_android.dart @@ -0,0 +1,471 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +const _fvpDepsUrl = + 'https://github.com/wang-bin/mdk-sdk/releases/download/v0.37.0'; +const _androidApplicationId = 'com.smplayer.smplayer'; +const _debugApplicationId = '$_androidApplicationId.dev'; +const _maxEmulatorWait = Duration(seconds: 120); +const _pollInterval = Duration(seconds: 3); + +Future main(List arguments) async { + try { + final options = _Options.parse(arguments); + if (options.help) { + stdout.writeln(_usage); + return; + } + exitCode = await _run(options); + } on FormatException catch (error) { + stderr.writeln('error: ${error.message}'); + stderr.writeln(_usage); + exitCode = 2; + } on ProcessException catch (error) { + stderr.writeln('error: $error'); + exitCode = 1; + } on StateError catch (error) { + stderr.writeln('error: ${error.message}'); + exitCode = 1; + } +} + +Future _run(_Options options) async { + final stopwatch = Stopwatch()..start(); + final projectRoot = options.projectRoot ?? _defaultProjectRoot; + final environment = _flutterEnvironment(); + var deviceId = options.device; + + if (deviceId != null) { + stdout.writeln('[dev-android] 使用指定设备: $deviceId'); + } else { + deviceId = await _androidDevice(projectRoot, environment); + if (deviceId == null) { + final emulatorId = + options.emulator ?? await _firstEmulator(projectRoot, environment); + if (emulatorId == null) { + stderr + ..writeln('[dev-android] 未找到 Android 设备,请:') + ..writeln(' 1. 连接真机(开启 USB 调试)') + ..writeln( + ' 2. 或创建模拟器: ' + 'flutter emulators --create --name MyEmulator', + ) + ..writeln(' 也可用 --device 直接指定设备'); + return 1; + } + stdout + ..writeln('[dev-android] 启动模拟器: $emulatorId') + ..writeln('[dev-android] 等待模拟器启动...'); + await Process.start( + 'flutter', + ['emulators', '--launch', emulatorId], + workingDirectory: projectRoot, + environment: environment, + includeParentEnvironment: false, + runInShell: Platform.isWindows, + mode: ProcessStartMode.detached, + ); + deviceId = await _waitForDevice(projectRoot, environment); + if (deviceId == null) { + stderr.writeln( + '[dev-android] 模拟器启动超时 ' + '(${_maxEmulatorWait.inSeconds}s),请手动启动后重试', + ); + return 1; + } + } + stdout.writeln('[dev-android] 目标设备: $deviceId'); + } + + if (_needsPubGet(projectRoot, options.force)) { + stdout.writeln('[dev-android] 依赖有变更 -> flutter pub get'); + final pubExit = await _inheritFlutter( + ['pub', 'get', if (options.offline) '--offline'], + projectRoot, + environment, + ); + if (pubExit != 0) return pubExit; + } else { + stdout.writeln('[dev-android] 依赖未变更 -> 跳过 pub get'); + } + + final prepareFvpExit = await _prepareFvpAndroidDependencies( + projectRoot, + environment, + ); + if (prepareFvpExit != 0) return prepareFvpExit; + + final appVersion = await _appVersion(projectRoot); + final versionBuildNumber = await _appBuildNumber(projectRoot, appVersion); + final installedBuildNumber = await _installedAppBuildNumber( + projectRoot, + deviceId, + environment, + ); + final appBuildNumber = + installedBuildNumber != null && installedBuildNumber > versionBuildNumber + ? installedBuildNumber + : versionBuildNumber; + stdout.writeln( + '[dev-android] App version: $appVersion (android line), ' + 'build number: $appBuildNumber', + ); + if (installedBuildNumber != null && + installedBuildNumber > versionBuildNumber) { + stdout.writeln( + '[dev-android] 设备已安装更高版本号 $installedBuildNumber,' + '开发包沿用该版本号以避免降级重装', + ); + } + environment['SMPLAYER_ANDROID_VERSION_CODE'] = '$appBuildNumber'; + final runArguments = [ + 'run', + '-d', + deviceId, + '--no-pub', + ...options.flutterArguments, + ]; + if (!_hasDartDefine(runArguments, 'SMPLAYER_VERSION')) { + runArguments.add('--dart-define=SMPLAYER_VERSION=$appVersion'); + } + + stdout + ..writeln('[dev-android] flutter ${runArguments.join(' ')}') + ..writeln('[dev-android] 启动准备耗时: ${stopwatch.elapsedMilliseconds} ms'); + return _inheritFlutter(runArguments, projectRoot, environment); +} + +Future _prepareFvpAndroidDependencies( + String projectRoot, + Map environment, +) async { + final prepareScript = File( + '${File.fromUri(Platform.script).parent.path}' + '${Platform.pathSeparator}prepare_fvp_android_deps.dart', + ); + final result = await Process.run( + Platform.resolvedExecutable, + [prepareScript.path, '--project-root', projectRoot], + workingDirectory: projectRoot, + environment: environment, + includeParentEnvironment: false, + ); + stdout.write(result.stdout); + stderr.write(result.stderr); + return result.exitCode; +} + +Map _flutterEnvironment() { + final environment = Map.of(Platform.environment) + ..['FVP_DEPS_URL'] = _fvpDepsUrl + ..remove('FVP_DEPS_LATEST'); + if (!Platform.isWindows) { + if ((environment['FLUTTER_STORAGE_BASE_URL'] ?? '').isEmpty) { + environment['FLUTTER_STORAGE_BASE_URL'] = 'https://storage.flutter-io.cn'; + } + if ((environment['PUB_HOSTED_URL'] ?? '').isEmpty) { + environment['PUB_HOSTED_URL'] = 'https://pub.flutter-io.cn'; + } + } + return environment; +} + +bool _needsPubGet(String projectRoot, bool force) { + if (force) return true; + final packageConfig = File( + '$projectRoot${Platform.pathSeparator}.dart_tool' + '${Platform.pathSeparator}package_config.json', + ); + final pubspec = File('$projectRoot${Platform.pathSeparator}pubspec.yaml'); + if (!packageConfig.existsSync() || !pubspec.existsSync()) return true; + final configTime = packageConfig.lastModifiedSync(); + if (pubspec.lastModifiedSync().isAfter(configTime)) return true; + final lock = File('$projectRoot${Platform.pathSeparator}pubspec.lock'); + return lock.existsSync() && lock.lastModifiedSync().isAfter(configTime); +} + +Future _androidDevice( + String projectRoot, + Map environment, +) async { + final result = await _flutter( + ['devices', '--machine'], + projectRoot, + environment, + ); + if (result.exitCode != 0) return null; + try { + final devices = jsonDecode('${result.stdout}'); + if (devices is! List) return null; + for (final device in devices) { + if (device is Map && + '${device['targetPlatform']}'.startsWith('android')) { + final id = '${device['id']}'; + if (id.isNotEmpty && id != 'null') return id; + } + } + } on FormatException { + return null; + } + return null; +} + +Future _firstEmulator( + String projectRoot, + Map environment, +) async { + final result = await _flutter(['emulators'], projectRoot, environment); + if (result.exitCode != 0) return null; + final emulatorPattern = RegExp( + r'^\s*(\S+)\s+.*\bandroid\b', + caseSensitive: false, + ); + for (final line in '${result.stdout}'.split(RegExp(r'\r?\n'))) { + final match = emulatorPattern.firstMatch(line); + if (match != null) return match[1]; + } + return null; +} + +Future _waitForDevice( + String projectRoot, + Map environment, +) async { + for ( + var waited = _pollInterval; + waited <= _maxEmulatorWait; + waited += _pollInterval + ) { + await Future.delayed(_pollInterval); + final deviceId = await _androidDevice(projectRoot, environment); + if (deviceId != null) return deviceId; + stdout.writeln('[dev-android] 等待中... (${waited.inSeconds}s)'); + } + return null; +} + +Future _appVersion(String projectRoot) async { + final result = await _runVersionCommand(projectRoot, ['current', 'android']); + return '${result.stdout}'.trim(); +} + +Future _appBuildNumber(String projectRoot, String appVersion) async { + final result = await _runVersionCommand(projectRoot, [ + 'build-number', + appVersion, + ]); + final buildNumber = int.tryParse('${result.stdout}'.trim()); + if (buildNumber == null || buildNumber <= 0) { + throw StateError('Invalid Android build number: ${result.stdout}'); + } + return buildNumber; +} + +Future _installedAppBuildNumber( + String projectRoot, + String deviceId, + Map environment, +) async { + final adbExecutable = _androidDebugBridgeExecutable(projectRoot, environment); + try { + final result = await Process.run( + adbExecutable, + ['-s', deviceId, 'shell', 'dumpsys', 'package', _debugApplicationId], + workingDirectory: projectRoot, + environment: environment, + includeParentEnvironment: false, + runInShell: Platform.isWindows, + ); + if (result.exitCode != 0) return null; + final versionCodeMatch = RegExp( + r'\bversionCode=(\d+)\b', + ).firstMatch('${result.stdout}'); + return versionCodeMatch == null + ? null + : int.tryParse(versionCodeMatch.group(1)!); + } on ProcessException { + return null; + } +} + +String _androidDebugBridgeExecutable( + String projectRoot, + Map environment, +) { + final sdkDirectories = [ + environment['ANDROID_SDK_ROOT'], + environment['ANDROID_HOME'], + _androidSdkDirectoryFromLocalProperties(projectRoot), + if (Platform.isWindows && environment['LOCALAPPDATA'] != null) + '${environment['LOCALAPPDATA']}${Platform.pathSeparator}Android' + '${Platform.pathSeparator}sdk', + ]; + final executableName = Platform.isWindows ? 'adb.exe' : 'adb'; + for (final sdkDirectory in sdkDirectories) { + if (sdkDirectory == null || sdkDirectory.trim().isEmpty) continue; + final executable = File( + '${sdkDirectory.trim()}${Platform.pathSeparator}platform-tools' + '${Platform.pathSeparator}$executableName', + ); + if (executable.existsSync()) return executable.path; + } + return executableName; +} + +String? _androidSdkDirectoryFromLocalProperties(String projectRoot) { + final propertiesFile = File( + '$projectRoot${Platform.pathSeparator}android' + '${Platform.pathSeparator}local.properties', + ); + if (!propertiesFile.existsSync()) return null; + for (final line in propertiesFile.readAsLinesSync()) { + final trimmedLine = line.trim(); + if (!trimmedLine.startsWith('sdk.dir=')) continue; + return trimmedLine + .substring('sdk.dir='.length) + .replaceAll(r'\:', ':') + .replaceAll(r'\\', r'\'); + } + return null; +} + +Future _runVersionCommand( + String projectRoot, + List arguments, +) async { + final result = await Process.run(Platform.resolvedExecutable, [ + '${File.fromUri(Platform.script).parent.path}' + '${Platform.pathSeparator}version.dart', + ...arguments, + '--project-root', + projectRoot, + ]); + if (result.exitCode != 0) { + throw StateError('${result.stderr}'.trim()); + } + return result; +} + +bool _hasDartDefine(List arguments, String name) { + var previous = ''; + for (final argument in arguments) { + if (argument.startsWith('--dart-define=$name=') || + (previous == '--dart-define' && argument.startsWith('$name='))) { + return true; + } + previous = argument; + } + return false; +} + +Future _flutter( + List arguments, + String projectRoot, + Map environment, +) { + return Process.run( + 'flutter', + arguments, + workingDirectory: projectRoot, + environment: environment, + includeParentEnvironment: false, + runInShell: Platform.isWindows, + ); +} + +Future _inheritFlutter( + List arguments, + String projectRoot, + Map environment, +) async { + final process = await Process.start( + 'flutter', + arguments, + workingDirectory: projectRoot, + environment: environment, + includeParentEnvironment: false, + runInShell: Platform.isWindows, + mode: ProcessStartMode.inheritStdio, + ); + return process.exitCode; +} + +final class _Options { + const _Options({ + this.device, + this.emulator, + this.projectRoot, + this.force = false, + this.offline = false, + this.help = false, + this.flutterArguments = const [], + }); + + factory _Options.parse(List arguments) { + String? device; + String? emulator; + String? projectRoot; + var force = false; + var offline = false; + var help = false; + var flutterArguments = []; + + for (var index = 0; index < arguments.length; index++) { + final argument = arguments[index]; + switch (argument.toLowerCase()) { + case '--device' || '-device': + device = _valueAfter(arguments, ++index, argument); + case '--emulator' || '-emulator': + emulator = _valueAfter(arguments, ++index, argument); + case '--project-root': + projectRoot = _valueAfter(arguments, ++index, argument); + case '--force' || '-force': + force = true; + case '--offline' || '-offline': + offline = true; + case '-h' || '--help': + help = true; + case '--' || '-flutterargs': + flutterArguments = arguments.skip(index + 1).toList(); + index = arguments.length; + default: + throw FormatException("Unknown argument '$argument'."); + } + } + return _Options( + device: device, + emulator: emulator, + projectRoot: projectRoot, + force: force, + offline: offline, + help: help, + flutterArguments: flutterArguments, + ); + } + + final String? device; + final String? emulator; + final String? projectRoot; + final bool force; + final bool offline; + final bool help; + final List flutterArguments; +} + +String _valueAfter(List arguments, int index, String option) { + if (index >= arguments.length) { + throw FormatException('Missing value for $option.'); + } + return arguments[index]; +} + +String get _defaultProjectRoot => + File.fromUri(Platform.script).parent.parent.path; + +const _usage = '''Usage: + dart scripts/dev_android.dart [--device ] [--emulator ] + [--force] [--offline] [-- ] + +PowerShell aliases -Device, -Emulator, -Force, -Offline, and -FlutterArgs +remain supported.'''; diff --git a/scripts/logcat-media3.sh b/scripts/logcat-media3.sh new file mode 100644 index 0000000..1167e78 --- /dev/null +++ b/scripts/logcat-media3.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash + +set -euo pipefail + +KEEP_TAGS=( + "flutter:V" + "ExoPlayerImpl:V" + "MediaCodecVideoRenderer:V" + "MediaCodecRenderer:V" + "MediaCodecInfo:V" + "VideoCapabilities:V" + "Media3Engine:V" + "Media3PlatformView:V" + "Media3Player:V" + "Media3PlayerEngine:V" + "Media3PlayerInstance:V" + "DolbyVisionConfig:V" + "HevcReader:V" + "Mp4Extractor:I" + "DefaultTrackSelector:I" + "EventLogger:V" + "AndroidRuntime:E" +) + +RAW=0 +if [[ "${1:-}" == "--raw" ]]; then + RAW=1 +fi + +if [[ $RAW -eq 0 ]]; then + adb logcat -c +fi + +exec adb logcat "${KEEP_TAGS[@]}" "*:S" diff --git a/scripts/prepare-fvp-windows-deps.ps1 b/scripts/prepare-fvp-windows-deps.ps1 new file mode 100644 index 0000000..4582ee2 --- /dev/null +++ b/scripts/prepare-fvp-windows-deps.ps1 @@ -0,0 +1,219 @@ + +[CmdletBinding()] +param( + [string]$Url = '', + [switch]$ForceRefresh +) + +$ErrorActionPreference = 'Stop' +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$MdkVersion = '0.37.0' + +function Test-SevenZipArchive { + param([string]$Path) + if (-not (Test-Path $Path)) { return $false } + $stream = [System.IO.File]::OpenRead($Path) + try { + if ($stream.Length -lt 6) { return $false } + $buffer = New-Object byte[] 6 + [void]$stream.Read($buffer, 0, 6) + } finally { + $stream.Dispose() + } + $magic = 0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C + for ($i = 0; $i -lt 6; $i++) { + if ($buffer[$i] -ne $magic[$i]) { return $false } + } + return $true +} + +function Resolve-CMakeExecutable { + $pathCommand = Get-Command 'cmake.exe' -ErrorAction SilentlyContinue + if ($pathCommand) { + return $pathCommand.Source + } + + $visualStudioInstallations = @() + $vswherePath = Join-Path ${Env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe' + if (Test-Path $vswherePath) { + $visualStudioInstallations += @( + & $vswherePath -products '*' -property installationPath 2>$null + ) + } + + foreach ($programFilesRoot in @($Env:ProgramFiles, ${Env:ProgramFiles(x86)})) { + if (-not $programFilesRoot) { continue } + foreach ($edition in @('BuildTools', 'Community', 'Professional', 'Enterprise')) { + $visualStudioInstallations += Join-Path $programFilesRoot "Microsoft Visual Studio\2022\$edition" + } + } + + foreach ($installationPath in $visualStudioInstallations | Select-Object -Unique) { + if (-not $installationPath) { continue } + $candidate = Join-Path $installationPath 'Common7\IDE\CommonExtensions\Microsoft\CMake\CMake\bin\cmake.exe' + if (Test-Path $candidate) { + return $candidate + } + } + + $androidSdkRoots = @( + $Env:ANDROID_SDK_ROOT, + $Env:ANDROID_HOME, + $(if ($Env:LOCALAPPDATA) { Join-Path $Env:LOCALAPPDATA 'Android\sdk' }) + ) | Where-Object { $_ -and (Test-Path $_) } | Select-Object -Unique + + foreach ($androidSdkRoot in $androidSdkRoots) { + $cmakeRoot = Join-Path $androidSdkRoot 'cmake' + if (-not (Test-Path $cmakeRoot)) { continue } + $candidate = Get-ChildItem $cmakeRoot -Directory -ErrorAction SilentlyContinue | + Sort-Object Name -Descending | + ForEach-Object { Join-Path $_.FullName 'bin\cmake.exe' } | + Where-Object { Test-Path $_ } | + Select-Object -First 1 + if ($candidate) { + return $candidate + } + } + + return $null +} + +$ProjectRoot = Split-Path -Parent $PSScriptRoot +Set-Location $ProjectRoot + +$PackageConfig = Join-Path $ProjectRoot '.dart_tool\package_config.json' +if (-not (Test-Path $PackageConfig)) { + throw "package_config.json not found: $PackageConfig (run flutter pub get first)" +} + +$config = Get-Content $PackageConfig -Raw | ConvertFrom-Json +$fvp = $config.packages | Where-Object { $_.name -eq 'fvp' } | Select-Object -First 1 +if (-not $fvp) { + throw 'Package fvp not found in .dart_tool/package_config.json' +} + +$fvpRoot = [Uri]$fvp.rootUri +if (-not $fvpRoot.IsFile) { + throw "Unsupported fvp rootUri: $($fvp.rootUri)" +} + +$fvpWindowsDir = Join-Path $fvpRoot.LocalPath 'windows' +if (-not (Test-Path $fvpWindowsDir)) { + throw "fvp windows directory not found: $fvpWindowsDir" +} + +$sdkDir = Join-Path $fvpWindowsDir 'mdk-sdk' +$sdkVersionMarkerPath = Join-Path $sdkDir '.smplayer-mdk-version' +$archivePath = Join-Path $fvpWindowsDir "mdk-sdk-windows-x64-v$MdkVersion-vs2026.7z" + +if ($ForceRefresh) { + Write-Host "==> Force refreshing fvp mdk-sdk v$MdkVersion" -ForegroundColor Yellow + if (Test-Path $sdkDir) { + Remove-Item $sdkDir -Recurse -Force + } + if (Test-Path $archivePath) { + Remove-Item $archivePath -Force + } +} + +if (Test-Path $sdkDir) { + $installedMdkVersion = if (Test-Path $sdkVersionMarkerPath) { + (Get-Content $sdkVersionMarkerPath -Raw).Trim() + } else { + '' + } + if ($installedMdkVersion -eq $MdkVersion) { + Write-Host "==> fvp mdk-sdk v$MdkVersion already exists: $sdkDir" -ForegroundColor Green + exit 0 + } + + $displayVersion = if ($installedMdkVersion) { $installedMdkVersion } else { 'unknown' } + Write-Host "==> Replacing cached mdk-sdk v$displayVersion with v$MdkVersion" -ForegroundColor Yellow + Remove-Item $sdkDir -Recurse -Force +} + +$candidates = @() +if ($Url) { + $candidates += $Url +} else { + $candidates += "https://github.com/wang-bin/mdk-sdk/releases/download/v$MdkVersion/mdk-sdk-windows-x64-vs2026.7z" +} + +if ((Test-Path $archivePath) -and -not (Test-SevenZipArchive $archivePath)) { + Write-Host "==> Cached archive is not a valid 7z, removing: $archivePath" -ForegroundColor Yellow + Remove-Item $archivePath -Force +} + +if (-not (Test-Path $archivePath)) { + $downloaded = $false + :outer foreach ($candidate in $candidates) { + $maxAttempts = 3 + for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) { + Write-Host "==> Downloading mdk-sdk (attempt $attempt/$maxAttempts): $candidate" -ForegroundColor Cyan + try { + Invoke-WebRequest -Uri $candidate -OutFile $archivePath ` + -UseBasicParsing -MaximumRedirection 10 ` + -UserAgent 'Mozilla/5.0' -TimeoutSec 120 + } catch { + Write-Host " download failed: $($_.Exception.Message)" -ForegroundColor Yellow + if (Test-Path $archivePath) { Remove-Item $archivePath -Force } + if ($attempt -lt $maxAttempts) { + $delay = $attempt * 10 + Write-Host " retrying in ${delay}s..." -ForegroundColor Yellow + Start-Sleep -Seconds $delay + } + continue + } + if (Test-SevenZipArchive $archivePath) { + $downloaded = $true + break outer + } + $size = (Get-Item $archivePath).Length + Write-Host " not a valid 7z archive (size=$size bytes), trying next" -ForegroundColor Yellow + Remove-Item $archivePath -Force + break # bad content, skip retries for this URL + } + } + if (-not $downloaded) { + throw "Failed to download a valid mdk-sdk archive from: $($candidates -join ', ')" + } +} + +Write-Host "==> Extracting mdk-sdk to: $fvpWindowsDir" -ForegroundColor Cyan + +$sevenZipCommand = Get-Command '7z.exe' -ErrorAction SilentlyContinue +$sevenZip = if ($sevenZipCommand) { + $sevenZipCommand.Source +} else { + @( + "$Env:ProgramFiles\7-Zip\7z.exe", + "${Env:ProgramFiles(x86)}\7-Zip\7z.exe" + ) | Where-Object { Test-Path $_ } | Select-Object -First 1 +} + +if ($sevenZip) { + & $sevenZip x '-y' "-o$fvpWindowsDir" $archivePath +} else { + $cmakeExecutable = Resolve-CMakeExecutable + if (-not $cmakeExecutable) { + throw 'Neither 7-Zip nor CMake was found. Install the Visual Studio C++ CMake tools or 7-Zip, then retry.' + } + Write-Host "==> Using CMake: $cmakeExecutable" -ForegroundColor DarkGray + Push-Location $fvpWindowsDir + try { + & $cmakeExecutable -E tar xvf $archivePath + } finally { + Pop-Location + } +} + +if ($LASTEXITCODE -ne 0) { + throw "Failed to extract mdk-sdk (exit $LASTEXITCODE)" +} + +if (-not (Test-Path $sdkDir)) { + throw "mdk-sdk not found after extraction: $sdkDir" +} + +Set-Content -Path $sdkVersionMarkerPath -Value $MdkVersion -NoNewline +Write-Host "==> fvp mdk-sdk v$MdkVersion ready: $sdkDir" -ForegroundColor Green diff --git a/scripts/prepare_fvp_android_deps.dart b/scripts/prepare_fvp_android_deps.dart new file mode 100644 index 0000000..b802a13 --- /dev/null +++ b/scripts/prepare_fvp_android_deps.dart @@ -0,0 +1,130 @@ +import 'dart:convert'; +import 'dart:io'; + +const _pinnedMdkVersion = '0.37.0'; + +Future main(List arguments) async { + try { + final projectRoot = _parseProjectRoot(arguments); + await _invalidateStaleFvpAndroidSdk(projectRoot); + } on FormatException catch (error) { + stderr.writeln('error: ${error.message}'); + exitCode = 2; + } on FileSystemException catch (error) { + stderr.writeln('error: ${error.message}'); + exitCode = 1; + } on StateError catch (error) { + stderr.writeln('error: ${error.message}'); + exitCode = 1; + } +} + +String _parseProjectRoot(List arguments) { + if (arguments.isEmpty) return _defaultProjectRoot; + if (arguments.length == 2 && arguments.first == '--project-root') { + return Directory(arguments.last).absolute.path; + } + throw const FormatException( + 'Usage: dart scripts/prepare_fvp_android_deps.dart ' + '[--project-root ]', + ); +} + +Future _invalidateStaleFvpAndroidSdk(String projectRoot) async { + final packageConfigFile = File( + '$projectRoot${Platform.pathSeparator}.dart_tool' + '${Platform.pathSeparator}package_config.json', + ); + if (!await packageConfigFile.exists()) { + throw StateError( + 'package_config.json not found: ${packageConfigFile.path} ' + '(run flutter pub get first)', + ); + } + + final packageConfig = jsonDecode(await packageConfigFile.readAsString()); + if (packageConfig is! Map) { + throw StateError('Invalid package_config.json root object'); + } + final packages = packageConfig['packages']; + if (packages is! List) { + throw StateError('Invalid package_config.json packages list'); + } + + Map? fvpPackage; + for (final package in packages) { + if (package is Map && package['name'] == 'fvp') { + fvpPackage = package; + break; + } + } + if (fvpPackage == null) { + throw StateError('Package fvp not found in package_config.json'); + } + + final rootUriValue = fvpPackage['rootUri']; + if (rootUriValue is! String || rootUriValue.isEmpty) { + throw StateError('Package fvp has an invalid rootUri'); + } + final fvpRootUri = packageConfigFile.uri.resolve(rootUriValue); + if (fvpRootUri.scheme != 'file') { + throw StateError('Unsupported fvp rootUri: $rootUriValue'); + } + + final fvpRootDirectory = Directory.fromUri(fvpRootUri); + final fvpAndroidDirectory = Directory( + '${fvpRootDirectory.path}${Platform.pathSeparator}android', + ); + if (!await fvpAndroidDirectory.exists()) { + throw StateError( + 'fvp Android directory not found: ${fvpAndroidDirectory.path}', + ); + } + + final sdkDirectory = Directory( + '${fvpAndroidDirectory.path}${Platform.pathSeparator}mdk-sdk', + ); + final sdkArchive = File( + '${fvpAndroidDirectory.path}${Platform.pathSeparator}mdk-sdk-android.7z', + ); + final versionMarker = File( + '${fvpAndroidDirectory.path}' + '${Platform.pathSeparator}.smplayer-mdk-version', + ); + final findMdkFile = File( + '${sdkDirectory.path}${Platform.pathSeparator}lib' + '${Platform.pathSeparator}cmake' + '${Platform.pathSeparator}FindMDK.cmake', + ); + + final installedVersion = await versionMarker.exists() + ? (await versionMarker.readAsString()).trim() + : ''; + final sdkIsReady = + installedVersion == _pinnedMdkVersion && await findMdkFile.exists(); + if (sdkIsReady) { + stdout.writeln( + '[fvp-android] mdk-sdk v$_pinnedMdkVersion is ready: ' + '${sdkDirectory.path}', + ); + return; + } + + final displayVersion = installedVersion.isEmpty + ? 'unknown' + : installedVersion; + stdout.writeln( + '[fvp-android] Invalidating cached mdk-sdk v$displayVersion; ' + 'the next Flutter build will download v$_pinnedMdkVersion.', + ); + if (await sdkDirectory.exists()) { + await sdkDirectory.delete(recursive: true); + } + if (await sdkArchive.exists()) { + await sdkArchive.delete(); + } + await versionMarker.writeAsString(_pinnedMdkVersion, flush: true); +} + +String get _defaultProjectRoot => + File.fromUri(Platform.script).parent.parent.absolute.path; diff --git a/scripts/release-tag.ps1 b/scripts/release-tag.ps1 new file mode 100644 index 0000000..5907673 --- /dev/null +++ b/scripts/release-tag.ps1 @@ -0,0 +1,25 @@ +param( + [Parameter(Mandatory = $true)] + [ValidateSet('pc', 'windows', 'android', 'both')] + [string]$Platform, + [switch]$DryRun, + [switch]$NoPush, + [string]$Remote = 'origin' +) + +$ErrorActionPreference = 'Stop' +$projectRoot = Split-Path -Parent $PSScriptRoot +$arguments = @( + (Join-Path $PSScriptRoot 'version.dart'), + 'tag', + $Platform, + '--project-root', + $projectRoot, + '--remote', + $Remote +) +if ($DryRun) { $arguments += '--dry-run' } +if ($NoPush) { $arguments += '--no-push' } + +& dart @arguments +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } diff --git a/scripts/release-tag.sh b/scripts/release-tag.sh new file mode 100755 index 0000000..1412f4e --- /dev/null +++ b/scripts/release-tag.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +exec dart "$REPO_ROOT/scripts/version.dart" tag "$@" --project-root "$REPO_ROOT" diff --git a/scripts/version-common.ps1 b/scripts/version-common.ps1 new file mode 100644 index 0000000..775a1b9 --- /dev/null +++ b/scripts/version-common.ps1 @@ -0,0 +1,71 @@ + +$script:SmPlayerProjectRoot = Split-Path -Parent $PSScriptRoot +$script:SmPlayerVersionCli = Join-Path $PSScriptRoot 'version.dart' + +function Get-SmPlayerCurrentVersion { + param( + [Parameter(Mandatory = $true)][string]$Platform, + [string]$ProjectRoot = $script:SmPlayerProjectRoot + ) + + return (& dart $script:SmPlayerVersionCli current $Platform --project-root $ProjectRoot) +} + +function ConvertTo-SmPlayerBuildNumber { + param([Parameter(Mandatory = $true)][string]$Version) + + return [int](& dart $script:SmPlayerVersionCli build-number $Version) +} + +function Test-SmPlayerFlutterOptionPresent { + param( + [string[]]$Arguments = @(), + [Parameter(Mandatory = $true)][string]$OptionName + ) + + foreach ($argument in @($Arguments)) { + if ($argument -eq $OptionName -or $argument.StartsWith("$OptionName=")) { return $true } + } + return $false +} + +function Test-SmPlayerDartDefinePresent { + param( + [string[]]$Arguments = @(), + [Parameter(Mandatory = $true)][string]$DefineName + ) + + $previousArgument = '' + foreach ($argument in @($Arguments)) { + if ($argument -like "--dart-define=$DefineName=*") { return $true } + if ($previousArgument -eq '--dart-define' -and $argument -like "$DefineName=*") { return $true } + $previousArgument = $argument + } + return $false +} + +function Add-SmPlayerVersionDartDefine { + param( + [string[]]$Arguments = @(), + [Parameter(Mandatory = $true)][string]$Version + ) + + $result = @($Arguments) + if (-not (Test-SmPlayerDartDefinePresent -Arguments $result -DefineName 'SMPLAYER_VERSION')) { + $result += "--dart-define=SMPLAYER_VERSION=$Version" + } + return $result +} + +function Add-SmPlayerBuildName { + param( + [string[]]$Arguments = @(), + [Parameter(Mandatory = $true)][string]$Version + ) + + $result = @($Arguments) + if (-not (Test-SmPlayerFlutterOptionPresent -Arguments $result -OptionName '--build-name')) { + $result += @('--build-name', $Version) + } + return $result +} diff --git a/scripts/version-common.sh b/scripts/version-common.sh new file mode 100644 index 0000000..80df260 --- /dev/null +++ b/scripts/version-common.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash + +SMPLAYER_VERSION_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SMPLAYER_PROJECT_ROOT="$(cd "$SMPLAYER_VERSION_SCRIPT_DIR/.." && pwd)" + +_smplayer_version() { + dart "$SMPLAYER_VERSION_SCRIPT_DIR/version.dart" "$@" \ + --project-root "$SMPLAYER_PROJECT_ROOT" +} + +smplayer_current_version() { + _smplayer_version current "$1" +} + +smplayer_build_number() { + _smplayer_version build-number "$1" +} + +smplayer_has_flutter_option() { + local option_name="$1" + shift + for argument in "$@"; do + [[ "$argument" == "$option_name" || "$argument" == "$option_name="* ]] && return 0 + done + return 1 +} + +smplayer_has_dart_define() { + local define_name="$1" + shift + local previous_argument='' + for argument in "$@"; do + [[ "$argument" == "--dart-define=${define_name}="* ]] && return 0 + [[ "$previous_argument" == '--dart-define' && "$argument" == "${define_name}="* ]] && return 0 + previous_argument="$argument" + done + return 1 +} diff --git a/scripts/version.dart b/scripts/version.dart new file mode 100644 index 0000000..2aa02d2 --- /dev/null +++ b/scripts/version.dart @@ -0,0 +1,302 @@ +import 'dart:io'; + +const _fallbackVersion = '0.0.1'; +final _versionPattern = RegExp(r'^(\d+)\.(\d+)\.(\d+)(?:\+[0-9A-Za-z.-]+)?$'); + +typedef _Release = ({ + String platform, + String prefix, + String source, + _Version version, +}); + +final class _Version implements Comparable<_Version> { + const _Version(this.major, this.minor, this.patch); + + factory _Version.parse(String value) { + final match = _versionPattern.firstMatch(value); + if (match == null) { + throw FormatException( + "Unsupported version '$value'. Expected MAJOR.MINOR.PATCH.", + ); + } + return _Version( + int.parse(match[1]!), + int.parse(match[2]!), + int.parse(match[3]!), + ); + } + + final int major; + final int minor; + final int patch; + + _Version get nextPatch => _Version(major, minor, patch + 1); + int get buildNumber => major * 1000000 + minor * 1000 + patch; + + @override + int compareTo(_Version other) { + final majorComparison = major.compareTo(other.major); + if (majorComparison != 0) return majorComparison; + final minorComparison = minor.compareTo(other.minor); + if (minorComparison != 0) return minorComparison; + return patch.compareTo(other.patch); + } + + @override + String toString() => '$major.$minor.$patch'; +} + +Future main(List arguments) async { + try { + await _run(arguments); + } on FormatException catch (error) { + stderr.writeln('error: ${error.message}'); + stderr.writeln(_usage); + exitCode = 2; + } on StateError catch (error) { + stderr.writeln('error: ${error.message}'); + exitCode = 1; + } +} + +Future _run(List arguments) async { + if (arguments.isEmpty || arguments.contains('--help')) { + stdout.writeln(_usage); + if (arguments.isEmpty) exitCode = 2; + return; + } + + final command = arguments.first; + final options = _parseOptions(arguments.skip(1).toList()); + final projectRoot = options.projectRoot ?? _defaultProjectRoot; + + switch (command) { + case 'current': + _expectOnePlatform(options); + stdout.writeln( + await _currentVersion(options.positional.single, projectRoot), + ); + case 'next': + _expectOnePlatform(options); + final release = await _nextRelease( + options.positional.single, + projectRoot, + ); + stdout.writeln( + '${release.platform} ${release.version} ${release.source}', + ); + case 'build-number': + if (options.positional.length != 1 || options.hasTagOptions) { + throw const FormatException('build-number requires one version.'); + } + stdout.writeln(_Version.parse(options.positional.single).buildNumber); + case 'tag': + if (options.positional.length != 1) { + throw const FormatException('tag requires one platform.'); + } + await _tag(options.positional.single, projectRoot, options); + default: + throw FormatException("Unknown command '$command'."); + } +} + +void _expectOnePlatform(_Options options) { + if (options.positional.length != 1 || options.hasTagOptions) { + throw const FormatException('current/next requires one platform.'); + } +} + +({String platform, String prefix}) _resolvePlatform(String value) { + return switch (value.trim().toLowerCase()) { + 'windows' || + 'pc' || + 'macos' || + 'darwin' => (platform: 'windows', prefix: 'v'), + 'android' => (platform: 'android', prefix: 'android-v'), + _ => throw FormatException( + "Unsupported platform '$value'. Expected windows, pc, macos, or android.", + ), + }; +} + +Future<_Version> _currentVersion(String platform, String projectRoot) async { + final pubspec = _pubspecVersion(projectRoot); + final latest = await _latestTag(platform, projectRoot); + return latest == null || latest.version.compareTo(pubspec) < 0 + ? pubspec + : latest.version; +} + +Future<_Release> _nextRelease(String platform, String projectRoot) async { + final resolved = _resolvePlatform(platform); + final pubspec = _pubspecVersion(projectRoot); + final latest = await _latestTag(resolved.platform, projectRoot); + if (latest == null || latest.version.compareTo(pubspec) < 0) { + return ( + platform: resolved.platform, + prefix: resolved.prefix, + source: 'pubspec:$pubspec', + version: pubspec, + ); + } + return ( + platform: resolved.platform, + prefix: resolved.prefix, + source: latest.tag, + version: latest.version.nextPatch, + ); +} + +_Version _pubspecVersion(String projectRoot) { + final pubspec = File('$projectRoot${Platform.pathSeparator}pubspec.yaml'); + if (!pubspec.existsSync()) return _Version.parse(_fallbackVersion); + final match = RegExp( + r'^version:\s*(\d+\.\d+\.\d+(?:\+[0-9A-Za-z.-]+)?)\s*$', + multiLine: true, + ).firstMatch(pubspec.readAsStringSync()); + return _Version.parse(match?[1] ?? _fallbackVersion); +} + +Future<({String tag, _Version version})?> _latestTag( + String platform, + String projectRoot, +) async { + final resolved = _resolvePlatform(platform); + final result = await Process.run('git', [ + 'tag', + '--list', + '${resolved.prefix}*', + ], workingDirectory: projectRoot); + if (result.exitCode != 0) return null; + + final pattern = RegExp( + '^${RegExp.escape(resolved.prefix)}' + r'(\d+\.\d+\.\d+(?:\+[0-9A-Za-z.-]+)?)$', + ); + ({String tag, _Version version})? latest; + for (final tag in '${result.stdout}'.split(RegExp(r'\r?\n'))) { + final match = pattern.firstMatch(tag.trim()); + if (match == null) continue; + final candidate = (tag: tag.trim(), version: _Version.parse(match[1]!)); + if (latest == null || candidate.version.compareTo(latest.version) > 0) { + latest = candidate; + } + } + return latest; +} + +Future _tag( + String requestedPlatform, + String projectRoot, + _Options options, +) async { + final platforms = requestedPlatform.toLowerCase() == 'both' + ? const ['windows', 'android'] + : [_resolvePlatform(requestedPlatform).platform]; + + for (final platform in platforms) { + final release = await _nextRelease(platform, projectRoot); + final tag = '${release.prefix}${release.version}'; + final existing = await _git(['tag', '--list', tag], projectRoot); + if ('${existing.stdout}'.trim().isNotEmpty) { + throw StateError("Tag '$tag' already exists. Aborting."); + } + + stdout.writeln('[${release.platform}] ${release.source} -> $tag'); + if (options.dryRun) { + stdout.writeln(' DryRun: skip create/push.'); + continue; + } + + await _git(['tag', tag], projectRoot); + if (options.noPush) { + stdout.writeln(' Created local tag (--no-push).'); + continue; + } + await _git(['push', options.remote, tag], projectRoot); + stdout.writeln(' Pushed to ${options.remote}.'); + } +} + +Future _git(List arguments, String projectRoot) async { + final result = await Process.run( + 'git', + arguments, + workingDirectory: projectRoot, + ); + if (result.exitCode != 0) { + throw StateError( + '${result.stderr}'.trim().isEmpty + ? 'git ${arguments.join(' ')} failed with exit ${result.exitCode}.' + : '${result.stderr}'.trim(), + ); + } + return result; +} + +_Options _parseOptions(List arguments) { + final positional = []; + String? projectRoot; + var dryRun = false; + var noPush = false; + var remote = 'origin'; + + for (var index = 0; index < arguments.length; index++) { + final argument = arguments[index]; + switch (argument) { + case '--dry-run': + dryRun = true; + case '--no-push': + noPush = true; + case '--project-root' || '--remote': + if (++index >= arguments.length) { + throw FormatException('$argument requires a value.'); + } + if (argument == '--project-root') { + projectRoot = arguments[index]; + } else { + remote = arguments[index]; + } + default: + if (argument.startsWith('--project-root=')) { + projectRoot = argument.substring('--project-root='.length); + } else if (argument.startsWith('--remote=')) { + remote = argument.substring('--remote='.length); + } else if (argument.startsWith('-')) { + throw FormatException("Unknown option '$argument'."); + } else { + positional.add(argument); + } + } + } + if (remote.isEmpty) throw const FormatException('remote cannot be empty.'); + return _Options(positional, projectRoot, dryRun, noPush, remote); +} + +final class _Options { + const _Options( + this.positional, + this.projectRoot, + this.dryRun, + this.noPush, + this.remote, + ); + + final List positional; + final String? projectRoot; + final bool dryRun; + final bool noPush; + final String remote; + + bool get hasTagOptions => dryRun || noPush || remote != 'origin'; +} + +String get _defaultProjectRoot => + File.fromUri(Platform.script).parent.parent.path; + +const _usage = '''Usage: + dart scripts/version.dart current + dart scripts/version.dart next + dart scripts/version.dart build-number + dart scripts/version.dart tag [--dry-run] [--no-push] [--remote=]'''; diff --git a/test/core/contracts/trakt_ids_test.dart b/test/core/contracts/trakt_ids_test.dart new file mode 100644 index 0000000..1ba1b54 --- /dev/null +++ b/test/core/contracts/trakt_ids_test.dart @@ -0,0 +1,23 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:smplayer/core/contracts/trakt/trakt_ids.dart'; + +void main() { + test('fromJson 统一解析 Trakt 外部 ID', () { + final ids = TraktIds.fromJson({ + 'trakt': 1.0, + 'imdb': 'tt123', + 'tmdb': 2, + 'tvdb': 3.0, + 'slug': 'demo', + }); + + expect(ids.toJson(), { + 'trakt': 1, + 'imdb': 'tt123', + 'tmdb': 2, + 'tvdb': 3, + 'slug': 'demo', + }); + expect(TraktIds.fromJson(null).toJson(), isEmpty); + }); +} diff --git a/test/core/contracts/trakt_sync_job_test.dart b/test/core/contracts/trakt_sync_job_test.dart new file mode 100644 index 0000000..c786dcc --- /dev/null +++ b/test/core/contracts/trakt_sync_job_test.dart @@ -0,0 +1,27 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:smplayer/core/contracts/trakt/trakt_sync_job.dart'; + +void main() { + test('旧队列 JSON 的冗余字段可忽略且调度字段保留', () { + final job = TraktSyncJob.fromJson({ + 'id': 'job-1', + 'type': 'scrobble_pause', + 'body': { + 'movie': {'title': 'Demo'}, + }, + 'progress': 42.0, + 'createdAt': '2026-01-01T00:00:00Z', + 'updatedAt': '2026-01-02T00:00:00Z', + 'lastError': 'transient', + 'retryCount': 3, + 'nextRetryAt': '2026-01-03T00:00:00Z', + 'exhausted': false, + }); + + expect(job.type, TraktSyncJobType.scrobblePause); + expect(job.retryCount, 3); + expect(job.nextRetryAt, DateTime.utc(2026, 1, 3)); + expect(job.toJson(), isNot(contains('progress'))); + expect(job.toJson(), isNot(contains('lastError'))); + }); +} diff --git a/test/core/domain/usecases/backup_script_widget_roundtrip_test.dart b/test/core/domain/usecases/backup_script_widget_roundtrip_test.dart new file mode 100644 index 0000000..40f7792 --- /dev/null +++ b/test/core/domain/usecases/backup_script_widget_roundtrip_test.dart @@ -0,0 +1,294 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:smplayer/core/contracts/danmaku.dart'; +import 'package:smplayer/core/contracts/script_widget.dart'; +import 'package:smplayer/core/contracts/server.dart'; +import 'package:smplayer/core/domain/ports/danmaku_sources_store.dart'; +import 'package:smplayer/core/domain/ports/script_widget_repository.dart'; +import 'package:smplayer/core/domain/ports/server_repository.dart'; +import 'package:smplayer/core/domain/ports/session_repository.dart'; +import 'package:smplayer/core/contracts/auth.dart'; +import 'package:smplayer/core/domain/usecases/backup_usecases.dart'; + +void main() { + test('导出的备份包能被再次导入并保留脚本模块与订阅', () async { + final timestamp = DateTime.utc(2026, 7, 13); + final source = _MemorySetup.withScriptWidget(timestamp: timestamp); + final export = ExportBackupUseCase( + serverRepository: source.serverRepository, + sessionRepository: source.sessionRepository, + danmakuSourcesStore: source.danmakuSourcesStore, + scriptWidgetRepository: source.scriptWidgetRepository, + now: () => timestamp, + ); + final backupResult = await export.execute(); + + final destination = _MemorySetup.empty(); + final import = ImportBackupUseCase( + serverRepository: destination.serverRepository, + sessionRepository: destination.sessionRepository, + danmakuSourcesStore: destination.danmakuSourcesStore, + scriptWidgetRepository: destination.scriptWidgetRepository, + ); + final importResult = await import.execute(backupResult.json); + + expect(backupResult.scriptWidgetCount, 1); + expect(backupResult.scriptWidgetSubscriptionCount, 1); + expect(importResult.scriptWidgetCount, 1); + expect(importResult.scriptWidgetSubscriptionCount, 1); + + final restoredWidgets = await destination.scriptWidgetRepository + .listWidgets(); + expect(restoredWidgets.single.manifest.id, 'demo-widget'); + expect(restoredWidgets.single.globalParams, { + 'apiKey': 'secret-token', + }); + + final restoredSubscriptions = await destination.scriptWidgetRepository + .listSubscriptions(); + expect( + restoredSubscriptions.single.url, + 'https://example.com/catalog.fwd', + ); + }); +} + +class _MemorySetup { + final ServerRepository serverRepository; + final SessionRepository sessionRepository; + final DanmakuSourcesStore danmakuSourcesStore; + final ScriptWidgetRepository scriptWidgetRepository; + + _MemorySetup({ + required this.serverRepository, + required this.sessionRepository, + required this.danmakuSourcesStore, + required this.scriptWidgetRepository, + }); + + factory _MemorySetup.empty() { + return _MemorySetup( + serverRepository: _MemoryServerRepository(), + sessionRepository: _MemorySessionRepository(), + danmakuSourcesStore: _MemoryDanmakuSourcesStore(), + scriptWidgetRepository: _MemoryScriptWidgetRepository(), + ); + } + + factory _MemorySetup.withScriptWidget({required DateTime timestamp}) { + final setup = _MemorySetup.empty(); + setup.scriptWidgetRepository.upsertWidget( + InstalledScriptWidget( + manifest: const ScriptWidgetManifest( + id: 'demo-widget', + title: 'Demo', + ), + scriptSource: 'const WidgetMetadata = { id: "demo-widget" };', + globalParams: const {'apiKey': 'secret-token'}, + installedAt: timestamp, + updatedAt: timestamp, + ), + ); + setup.scriptWidgetRepository.upsertSubscription( + ScriptWidgetSubscription( + url: 'https://example.com/catalog.fwd', + catalog: const ScriptWidgetCatalog(title: '示例订阅'), + refreshedAt: timestamp, + ), + ); + return setup; + } +} + +class _MemoryServerRepository implements ServerRepository { + final List _servers = []; + + @override + Future> list() async => + List.unmodifiable(_servers); + + @override + Future findById(String id) async { + for (final server in _servers) { + if (server.id == id) return server; + } + return null; + } + + @override + Future findByBaseUrl(String baseUrl) async { + for (final server in _servers) { + if (server.baseUrl == baseUrl) return server; + } + return null; + } + + @override + Future create(EmbyServer server) async { + _servers.add(server); + return server; + } + + @override + Future update(EmbyServer server) async { + final existingIndex = _servers.indexWhere( + (candidate) => candidate.id == server.id, + ); + if (existingIndex < 0) { + _servers.add(server); + } else { + _servers[existingIndex] = server; + } + return server; + } + + @override + Future deleteById(String id) async { + _servers.removeWhere((server) => server.id == id); + } + + @override + Future touchConnectedAt(String id, String updatedAt) async {} + + @override + Future replaceAll(List servers) async { + _servers + ..clear() + ..addAll(servers); + } +} + +class _MemorySessionRepository implements SessionRepository { + final Map _sessionsByServerId = {}; + String? _activeServerId; + + @override + Future set(SessionData session) async { + _sessionsByServerId[session.serverId] = session; + return session; + } + + @override + Future setMany(List sessions) async { + for (final session in sessions) { + _sessionsByServerId[session.serverId] = session; + } + } + + @override + Future getByServerId(String serverId) async { + return _sessionsByServerId[serverId]; + } + + @override + Future getActive() async { + final activeId = _activeServerId; + if (activeId == null) return null; + return _sessionsByServerId[activeId]; + } + + @override + Future getActiveServerId() async => _activeServerId; + + @override + Future> listAll() async => + List.unmodifiable(_sessionsByServerId.values); + + @override + Future setActive(String serverId) async { + _activeServerId = serverId; + } + + @override + Future clear([String? serverId]) async { + if (serverId == null) { + _sessionsByServerId.clear(); + _activeServerId = null; + return; + } + _sessionsByServerId.remove(serverId); + if (_activeServerId == serverId) _activeServerId = null; + } + + @override + Future replaceAll( + List sessions, { + String? activeServerId, + }) async { + _sessionsByServerId + ..clear() + ..addEntries( + sessions.map((session) => MapEntry(session.serverId, session)), + ); + _activeServerId = activeServerId; + } +} + +class _MemoryDanmakuSourcesStore implements DanmakuSourcesStore { + List _sources = []; + + @override + Future> list() async => + List.from(_sources); + + @override + Future replaceAll(List sources) async { + _sources = List.from(sources); + } +} + +class _MemoryScriptWidgetRepository implements ScriptWidgetRepository { + final Map _widgets = + {}; + final Map _subscriptions = + {}; + + @override + Future> listWidgets() async => + _widgets.values.toList(growable: false); + + @override + Future findWidget(String widgetId) async { + return _widgets[widgetId]; + } + + @override + Future upsertWidget(InstalledScriptWidget widget) async { + _widgets[widget.manifest.id] = widget; + } + + @override + Future deleteWidget(String widgetId) async { + _widgets.remove(widgetId); + } + + @override + Future> listSubscriptions() async => + _subscriptions.values.toList(growable: false); + + @override + Future upsertSubscription(ScriptWidgetSubscription subscription) async { + _subscriptions[subscription.url] = subscription; + } + + @override + Future replaceAllWidgets(List widgets) async { + _widgets + ..clear() + ..addEntries( + widgets.map((widget) => MapEntry(widget.manifest.id, widget)), + ); + } + + @override + Future replaceAllSubscriptions( + List subscriptions, + ) async { + _subscriptions + ..clear() + ..addEntries( + subscriptions.map( + (subscription) => MapEntry(subscription.url, subscription), + ), + ); + } +} diff --git a/test/core/domain/usecases/cross_server_search_empty_skip_test.dart b/test/core/domain/usecases/cross_server_search_empty_skip_test.dart new file mode 100644 index 0000000..dcb4d81 --- /dev/null +++ b/test/core/domain/usecases/cross_server_search_empty_skip_test.dart @@ -0,0 +1,181 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:smplayer/core/contracts/auth.dart'; +import 'package:smplayer/core/contracts/cancellation.dart'; +import 'package:smplayer/core/contracts/library.dart'; +import 'package:smplayer/core/contracts/server.dart'; +import 'package:smplayer/core/domain/ports/emby_gateway.dart'; +import 'package:smplayer/core/domain/ports/server_repository.dart'; +import 'package:smplayer/core/domain/ports/session_repository.dart'; +import 'package:smplayer/core/domain/usecases/media_usecases.dart'; + +class _FakeSessionRepository implements SessionRepository { + _FakeSessionRepository(this.sessions, {this.activeServerId}); + final List sessions; + final String? activeServerId; + + @override + Future> listAll() async => sessions; + + @override + Future getActiveServerId() async => activeServerId; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeServerRepository implements ServerRepository { + _FakeServerRepository(this.servers); + final List servers; + + @override + Future> list() async => servers; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _CountingEmbyGateway implements EmbyGateway { + _CountingEmbyGateway({this.itemsToReturn = const []}); + List itemsToReturn; + int libraryQueryCount = 0; + + @override + Future> getLibraryItems({ + required AuthedRequestContext ctx, + String? parentId, + String? includeItemTypes, + List? genreIds, + String? searchTerm, + String? anyProviderIdEquals, + List? personIds, + String? fields, + String? enableImageTypes, + bool? groupProgramsBySeries, + int? imageTypeLimit, + int? startIndex, + int? limit, + bool? recursive, + String? sortBy, + SortOrder? sortOrder, + String? filters, + CancellationToken? cancelToken, + }) async { + libraryQueryCount++; + return itemsToReturn; + } + + @override + Future getItemDetail( + AuthedRequestContext ctx, + String itemId, + ) async => itemsToReturn.firstWhere((it) => it.Id == itemId); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +EmbyServer _server(String id) => EmbyServer( + id: id, + name: id, + baseUrl: 'http://$id', + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', +); + +SessionData _session(String serverId) => SessionData( + serverId: serverId, + token: 'token-$serverId', + userId: 'user-$serverId', + userName: serverId, + createdAt: '2026-01-01T00:00:00Z', +); + + +Future> _collectCards( + CrossServerSearchUseCase useCase, + CrossServerSearchReq request, +) async { + final events = await useCase.executeStream(request).toList(); + return events.isEmpty ? const [] : events.last; +} + +void main() { + const movieReq = CrossServerSearchReq( + anchorType: 'Movie', + itemName: 'Movie One', + providerIds: {'Tmdb': '42'}, + ); + + ({CrossServerSearchUseCase uc, _CountingEmbyGateway gateway}) build({ + List remoteItems = const [], + }) { + final gateway = _CountingEmbyGateway(itemsToReturn: remoteItems); + final uc = CrossServerSearchUseCase( + sessionRepository: _FakeSessionRepository([ + _session('active'), + _session('remote'), + ], activeServerId: 'active'), + serverRepository: _FakeServerRepository([ + _server('active'), + _server('remote'), + ]), + embyGateway: gateway, + ); + return (uc: uc, gateway: gateway); + } + + group('CrossServerSearchUseCase 空服务器跳过(streak+TTL)', () { + test('连续 3 次空结果后,该服务器被跳过不再探测', () async { + final (:uc, :gateway) = build(); + + for (var round = 1; round <= 3; round++) { + final cards = await _collectCards(uc, movieReq); + expect(cards, isEmpty, reason: 'round $round 应为空结果'); + } + final queriesAfterThreeRounds = gateway.libraryQueryCount; + expect(queriesAfterThreeRounds, greaterThan(0)); + + final cards = await _collectCards(uc, movieReq); + expect(cards, isEmpty); + expect( + gateway.libraryQueryCount, + queriesAfterThreeRounds, + reason: '第 4 轮应命中 streak 跳过,网关不应再被探测', + ); + }); + + test('未达阈值(2 次空)时仍继续探测', () async { + final (:uc, :gateway) = build(); + + await _collectCards(uc, movieReq); + await _collectCards(uc, movieReq); + final queriesAfterTwoRounds = gateway.libraryQueryCount; + + await _collectCards(uc, movieReq); + expect(gateway.libraryQueryCount, greaterThan(queriesAfterTwoRounds)); + }); + + test('命中结果会清空 streak 并产出多版本 cards', () async { + final movie = EmbyRawItem.fromJson(const { + 'Id': 'm1', + 'Name': 'Movie One', + 'Type': 'Movie', + 'MediaSources': [ + {'Id': 'ms1', 'Name': '1080p'}, + {'Id': 'ms2', 'Name': '4K'}, + ], + }); + final (:uc, :gateway) = build(remoteItems: [movie]); + + final cards = await _collectCards(uc, movieReq); + expect(cards, hasLength(2)); + expect(cards.map((c) => c.mediaSourceId), containsAll(['ms1', 'ms2'])); + expect(cards.every((c) => c.serverId == 'remote'), isTrue); + + gateway.itemsToReturn = const []; + final baseline = gateway.libraryQueryCount; + await _collectCards(uc, movieReq); + expect(gateway.libraryQueryCount, greaterThan(baseline)); + }); + }); +} diff --git a/test/core/domain/usecases/cross_server_search_provider_filter_test.dart b/test/core/domain/usecases/cross_server_search_provider_filter_test.dart new file mode 100644 index 0000000..1edc32a --- /dev/null +++ b/test/core/domain/usecases/cross_server_search_provider_filter_test.dart @@ -0,0 +1,275 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:smplayer/core/contracts/auth.dart'; +import 'package:smplayer/core/contracts/cancellation.dart'; +import 'package:smplayer/core/contracts/library.dart'; +import 'package:smplayer/core/contracts/server.dart'; +import 'package:smplayer/core/domain/ports/emby_gateway.dart'; +import 'package:smplayer/core/domain/ports/server_repository.dart'; +import 'package:smplayer/core/domain/ports/session_repository.dart'; +import 'package:smplayer/core/domain/usecases/media_usecases.dart'; + +class _FakeSessionRepository implements SessionRepository { + @override + Future getActiveServerId() async => 'active'; + + @override + Future> listAll() async => [ + _buildSession('active'), + _buildSession('remote'), + ]; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeServerRepository implements ServerRepository { + @override + Future> list() async => [ + _buildServer('active'), + _buildServer('remote'), + ]; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _ProviderFilteringEmbyGateway implements EmbyGateway { + _ProviderFilteringEmbyGateway({ + this.providerMovieItems = const [], + this.nameSearchMovieItems = const [], + this.seriesItems = const [], + }); + + final List providerMovieItems; + final List nameSearchMovieItems; + final List seriesItems; + + int providerMovieQueryCount = 0; + int nameSearchMovieQueryCount = 0; + int seriesQueryCount = 0; + int detailQueryCount = 0; + int seasonsQueryCount = 0; + + Iterable get _allItems => [ + ...providerMovieItems, + ...nameSearchMovieItems, + ...seriesItems, + ]; + + @override + Future> getLibraryItems({ + required AuthedRequestContext ctx, + String? parentId, + String? includeItemTypes, + List? genreIds, + String? searchTerm, + String? anyProviderIdEquals, + List? personIds, + String? fields, + String? enableImageTypes, + bool? groupProgramsBySeries, + int? imageTypeLimit, + int? startIndex, + int? limit, + bool? recursive, + String? sortBy, + SortOrder? sortOrder, + String? filters, + CancellationToken? cancelToken, + }) async { + if (includeItemTypes == 'Movie' && anyProviderIdEquals != null) { + providerMovieQueryCount++; + return providerMovieItems; + } + if (includeItemTypes == 'Movie' && searchTerm != null) { + nameSearchMovieQueryCount++; + return nameSearchMovieItems; + } + if (includeItemTypes == 'Series') { + seriesQueryCount++; + return seriesItems; + } + return const []; + } + + @override + Future getItemDetail( + AuthedRequestContext ctx, + String itemId, + ) async { + detailQueryCount++; + return _allItems.firstWhere((item) => item.Id == itemId); + } + + @override + Future> getSeriesSeasons( + AuthedRequestContext ctx, + String seriesId, { + CancellationToken? cancelToken, + }) async { + seasonsQueryCount++; + return const []; + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +EmbyServer _buildServer(String serverId) => EmbyServer( + id: serverId, + name: serverId, + baseUrl: 'http://$serverId', + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', +); + +SessionData _buildSession(String serverId) => SessionData( + serverId: serverId, + token: 'token-$serverId', + userId: 'user-$serverId', + userName: serverId, + createdAt: '2026-01-01T00:00:00Z', +); + +EmbyRawItem _buildMovie({ + required String itemId, + required String mediaSourceId, + Map? providerIds, +}) => EmbyRawItem.fromJson({ + 'Id': itemId, + 'Name': 'Movie One', + 'Type': 'Movie', + if (providerIds != null) 'ProviderIds': providerIds, + 'MediaSources': [ + {'Id': mediaSourceId, 'Name': '4K'}, + ], +}); + +CrossServerSearchUseCase _buildUseCase( + _ProviderFilteringEmbyGateway embyGateway, +) => CrossServerSearchUseCase( + sessionRepository: _FakeSessionRepository(), + serverRepository: _FakeServerRepository(), + embyGateway: embyGateway, +); + +Future> _collectCards( + CrossServerSearchUseCase useCase, + CrossServerSearchReq request, +) async { + final emittedCardLists = await useCase.executeStream(request).toList(); + return emittedCardLists.isEmpty ? const [] : emittedCardLists.last; +} + +void main() { + const movieRequest = CrossServerSearchReq( + anchorType: 'Movie', + itemName: 'Movie One', + providerIds: {'Tmdb': '42'}, + ); + + group('CrossServerSearchUseCase provider-id filtering', () { + test('drops ignored provider-query results before detail fetch', () async { + final mismatchedMovie = _buildMovie( + itemId: 'unrelated-movie', + mediaSourceId: 'unrelated-source', + providerIds: const {'Tmdb': '999'}, + ); + final embyGateway = _ProviderFilteringEmbyGateway( + providerMovieItems: [mismatchedMovie], + nameSearchMovieItems: [mismatchedMovie], + ); + + final cards = await _collectCards( + _buildUseCase(embyGateway), + movieRequest, + ); + + expect(cards, isEmpty); + expect(embyGateway.providerMovieQueryCount, 1); + expect(embyGateway.nameSearchMovieQueryCount, 1); + expect(embyGateway.detailQueryCount, 0); + }); + + test( + 'keeps provider-query results with a normalized matching id', + () async { + final matchingMovie = _buildMovie( + itemId: 'matching-movie', + mediaSourceId: 'matching-source', + providerIds: const {'tmdb': '42'}, + ); + final embyGateway = _ProviderFilteringEmbyGateway( + providerMovieItems: [matchingMovie], + ); + + final cards = await _collectCards( + _buildUseCase(embyGateway), + movieRequest, + ); + + expect(cards, hasLength(1)); + expect(cards.single.mediaSourceId, 'matching-source'); + expect(embyGateway.nameSearchMovieQueryCount, 0); + expect(embyGateway.detailQueryCount, 1); + }, + ); + + test( + 'drops conflicting name results but keeps unscanned results', + () async { + final conflictingMovie = _buildMovie( + itemId: 'conflicting-movie', + mediaSourceId: 'conflicting-source', + providerIds: const {'Tmdb': '999'}, + ); + final unscannedMovie = _buildMovie( + itemId: 'unscanned-movie', + mediaSourceId: 'unscanned-source', + ); + final embyGateway = _ProviderFilteringEmbyGateway( + nameSearchMovieItems: [conflictingMovie, unscannedMovie], + ); + + final cards = await _collectCards( + _buildUseCase(embyGateway), + movieRequest, + ); + + expect(cards, hasLength(1)); + expect(cards.single.landingItemId, 'unscanned-movie'); + expect(cards.single.mediaSourceId, 'unscanned-source'); + expect(embyGateway.detailQueryCount, 1); + }, + ); + + test('rejects and caches a mismatched remote series identity', () async { + final mismatchedSeries = EmbyRawItem.fromJson(const { + 'Id': 'unrelated-series', + 'Name': 'Unrelated Series', + 'Type': 'Series', + 'ProviderIds': {'Tmdb': '999'}, + }); + final embyGateway = _ProviderFilteringEmbyGateway( + seriesItems: [mismatchedSeries], + ); + final useCase = _buildUseCase(embyGateway); + const episodeRequest = CrossServerSearchReq( + anchorType: 'Episode', + itemName: 'Episode One', + providerIds: {}, + seriesProviderIds: {'Tmdb': '42'}, + parentIndexNumber: 1, + indexNumber: 1, + ); + + final firstCards = await _collectCards(useCase, episodeRequest); + final secondCards = await _collectCards(useCase, episodeRequest); + + expect(firstCards, isEmpty); + expect(secondCards, isEmpty); + expect(embyGateway.seriesQueryCount, 1); + expect(embyGateway.seasonsQueryCount, 0); + }); + }); +} diff --git a/test/core/domain/usecases/script_widget_service_test.dart b/test/core/domain/usecases/script_widget_service_test.dart new file mode 100644 index 0000000..261d73f --- /dev/null +++ b/test/core/domain/usecases/script_widget_service_test.dart @@ -0,0 +1,500 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:smplayer/core/contracts/danmaku.dart'; +import 'package:smplayer/core/contracts/script_widget.dart'; +import 'package:smplayer/core/domain/errors.dart'; +import 'package:smplayer/core/domain/ports/danmaku_sources_store.dart'; +import 'package:smplayer/core/domain/ports/script_widget_remote_gateway.dart'; +import 'package:smplayer/core/domain/ports/script_widget_repository.dart'; +import 'package:smplayer/core/domain/ports/script_widget_runtime.dart'; +import 'package:smplayer/core/domain/usecases/script_widget/script_widget_service.dart'; + +class _MemoryScriptWidgetRepository implements ScriptWidgetRepository { + final Map widgets = + {}; + final Map subscriptions = + {}; + + @override + Future deleteWidget(String widgetId) async { + widgets.remove(widgetId); + } + + @override + Future findWidget(String widgetId) async { + return widgets[widgetId]; + } + + @override + Future> listSubscriptions() async { + return subscriptions.values.toList(growable: false); + } + + @override + Future> listWidgets() async { + return widgets.values.toList(growable: false); + } + + @override + Future upsertSubscription(ScriptWidgetSubscription subscription) async { + subscriptions[subscription.url] = subscription; + } + + @override + Future upsertWidget(InstalledScriptWidget widget) async { + widgets[widget.manifest.id] = widget; + } + + @override + Future replaceAllWidgets(List widgets) async { + this.widgets + ..clear() + ..addEntries( + widgets.map((widget) => MapEntry(widget.manifest.id, widget)), + ); + } + + @override + Future replaceAllSubscriptions( + List subscriptions, + ) async { + this.subscriptions + ..clear() + ..addEntries( + subscriptions.map( + (subscription) => MapEntry(subscription.url, subscription), + ), + ); + } +} + +class _StaticScriptWidgetRuntime implements ScriptWidgetRuntime { + _StaticScriptWidgetRuntime(this.manifest, {this.invocationResult}); + + ScriptWidgetManifest manifest; + final Object? invocationResult; + final Set loadedWidgetIds = {}; + Map? lastInvocationParams; + Completer? loadGate; + int loadCount = 0; + + @override + Future dispose() async {} + + @override + Future invokeModuleFunction( + String widgetId, + String functionName, + Map params, + ) async { + lastInvocationParams = Map.from(params); + return invocationResult; + } + + @override + Future inspectWidget(String scriptSource) async { + return manifest; + } + + @override + bool isWidgetLoaded(String widgetId) => loadedWidgetIds.contains(widgetId); + + @override + Future loadWidget(String scriptSource) async { + loadCount++; + await loadGate?.future; + loadedWidgetIds.add(manifest.id); + return manifest; + } + + @override + Future unloadWidget(String widgetId) async { + loadedWidgetIds.remove(widgetId); + } +} + +class _UnusedRemoteGateway implements ScriptWidgetRemoteGateway { + @override + Future fetchText(String url) { + throw UnsupportedError('This test does not perform remote requests.'); + } +} + +class _StaticRemoteGateway implements ScriptWidgetRemoteGateway { + final String scriptSource; + + const _StaticRemoteGateway(this.scriptSource); + + @override + Future fetchText(String url) async => scriptSource; +} + +class _MemoryDanmakuSourcesStore implements DanmakuSourcesStore { + List sources = []; + + @override + Future> list() async { + return List.from(sources); + } + + @override + Future replaceAll(List sources) async { + this.sources = List.from(sources); + } +} + +void main() { + test('视频契约兼容字段别名与宽松数值类型', () { + final item = ScriptVideoItem.fromJson({ + 'id': 42, + 'title': '示例影片', + 'poster_url': 'https://example.com/poster.jpg', + 'rating': '8.6', + 'duration': '3600', + }); + final resource = ScriptVideoResource.fromJson({ + 'url': 'https://example.com/video.m3u8', + 'headers': {'Referer': 'https://example.com', 'X': 1}, + }); + + expect(item.id, '42'); + expect(item.posterPath, 'https://example.com/poster.jpg'); + expect(item.rating, 8.6); + expect(item.duration, 3600); + expect(resource.customHeaders, { + 'Referer': 'https://example.com', + 'X': '1', + }); + }); + + test('模块文本状态兼容 subTitle 并保留错误说明', () { + final items = decodeVideoItems([ + { + 'id': 'err_cf', + 'type': 'text', + 'title': '被 Cloudflare 拦截', + 'subTitle': '请稍后重试', + }, + ]); + + expect(items, hasLength(1)); + expect(items.single.type, 'text'); + expect(items.single.title, '被 Cloudflare 拦截'); + expect(items.single.description, '请稍后重试'); + }); + + test('模块列表兼容 JSON 字符串返回并从链接生成缺失 ID', () { + final items = decodeVideoItems( + jsonEncode([ + { + 'type': 'link', + 'title': 91, + 'link': 'https://example.com/videos/91', + }, + ]), + ); + + expect(items, hasLength(1)); + expect(items.single.id, 'https://example.com/videos/91'); + expect(items.single.title, '91'); + }); + + test('模块返回 null 或不可识别文本时抛出明确错误', () { + expect( + () => decodeVideoItems(null), + throwsA( + isA().having( + (error) => error.message, + 'message', + '模块返回了无法识别的内容格式', + ), + ), + ); + expect(() => decodeVideoItems('not-json'), throwsA(isA())); + expect(decodeVideoItems(const []), isEmpty); + }); + + test('sectionMode 保留有效分组并使用回退标题', () { + final sections = decodeVideoSections( + [ + { + 'title': '热门', + 'items': [ + {'id': 'movie-1', 'title': '影片一'}, + ], + }, + { + 'data': [ + {'id': 'movie-2', 'title': '影片二'}, + ], + }, + {'title': '空分组', 'items': []}, + ], + fallbackTitle: '模块内容', + preserveSections: true, + ); + + expect(sections, hasLength(2)); + expect(sections[0].title, '热门'); + expect(sections[0].items.single.id, 'movie-1'); + expect(sections[1].title, '模块内容'); + expect(sections[1].items.single.id, 'movie-2'); + }); + + test('模块调用补全选项默认值且调用参数优先', () async { + const module = ScriptWidgetModule( + title: '直播', + functionName: 'getVideos', + params: [ + ScriptWidgetParam( + name: 'category', + type: 'enumeration', + enumOptions: [ + ScriptWidgetOption(value: 'jsonkawayi'), + ScriptWidgetOption(value: 'jsonmihu'), + ], + ), + ScriptWidgetParam( + name: 'source', + value: 'explicit', + enumOptions: [ + ScriptWidgetOption(value: 'fallback'), + ], + ), + ScriptWidgetParam( + name: 'legacy', + placeholders: [ + ScriptWidgetOption(value: 'legacy-first'), + ], + ), + ], + ); + const manifest = ScriptWidgetManifest( + id: 'live-widget', + title: '直播模块', + modules: [module], + ); + final timestamp = DateTime.utc(2026, 7, 13); + final repository = _MemoryScriptWidgetRepository(); + repository.widgets[manifest.id] = InstalledScriptWidget( + manifest: manifest, + scriptSource: 'widget source', + installedAt: timestamp, + updatedAt: timestamp, + ); + final runtime = _StaticScriptWidgetRuntime( + manifest, + invocationResult: const [], + ); + final service = ScriptWidgetService( + repository: repository, + remoteGateway: _UnusedRemoteGateway(), + runtime: runtime, + ); + + await service.loadModuleSections( + manifest.id, + module, + const {}, + ); + expect(runtime.lastInvocationParams, { + 'category': 'jsonkawayi', + 'source': 'explicit', + 'legacy': 'legacy-first', + }); + + await service.loadModuleSections( + manifest.id, + module, + const {'category': 'jsonmihu'}, + ); + expect(runtime.lastInvocationParams, { + 'category': 'jsonmihu', + 'source': 'explicit', + 'legacy': 'legacy-first', + }); + }); + + test('远程订阅中的相对模块 URL 相对订阅地址解析', () async { + final repository = _MemoryScriptWidgetRepository(); + final service = ScriptWidgetService( + repository: repository, + remoteGateway: _UnusedRemoteGateway(), + runtime: _StaticScriptWidgetRuntime( + const ScriptWidgetManifest(id: 'unused', title: 'Unused'), + ), + ); + final rawCatalog = jsonEncode({ + 'title': '示例订阅', + 'widgets': [ + { + 'id': 'relative', + 'title': '相对模块', + 'url': '../widgets/relative.js', + }, + { + 'id': 'absolute', + 'title': '绝对模块', + 'url': 'https://cdn.example.com/absolute.js', + }, + ], + }); + + final subscription = await service.importSubscriptionSource( + rawCatalog, + sourceId: 'https://example.com/catalogs/main/catalog.fwd', + ); + + expect( + subscription.catalog.widgets[0].url, + 'https://example.com/catalogs/widgets/relative.js', + ); + expect( + subscription.catalog.widgets[1].url, + 'https://cdn.example.com/absolute.js', + ); + }); + + test('弹幕模块安装与启停会同步来源并通知宿主刷新', () async { + final repository = _MemoryScriptWidgetRepository(); + final sourcesStore = _MemoryDanmakuSourcesStore(); + var changeNotificationCount = 0; + final service = ScriptWidgetService( + repository: repository, + remoteGateway: _UnusedRemoteGateway(), + runtime: _StaticScriptWidgetRuntime( + const ScriptWidgetManifest( + id: 'danmaku-widget', + title: '脚本弹幕', + modules: [ + ScriptWidgetModule( + id: 'danmu', + title: '弹幕', + functionName: 'searchDanmu', + type: 'danmu', + ), + ], + ), + ), + danmakuSourcesStore: sourcesStore, + onDanmakuSourcesChanged: () async { + changeNotificationCount++; + }, + ); + + await service.installFromSource('widget source'); + expect(sourcesStore.sources.single.url, 'jsw://danmaku-widget'); + expect(sourcesStore.sources.single.enabled, isTrue); + + await service.setWidgetEnabled('danmaku-widget', false); + expect(sourcesStore.sources.single.enabled, isFalse); + expect(changeNotificationCount, 2); + }); + + test('并发首次调用只加载一次模块运行环境', () async { + final timestamp = DateTime.utc(2026, 7, 12); + final repository = _MemoryScriptWidgetRepository(); + repository.widgets['concurrent-widget'] = InstalledScriptWidget( + manifest: const ScriptWidgetManifest( + id: 'concurrent-widget', + title: '并发模块', + ), + scriptSource: 'concurrent source', + installedAt: timestamp, + updatedAt: timestamp, + ); + final runtime = _StaticScriptWidgetRuntime( + const ScriptWidgetManifest(id: 'concurrent-widget', title: '并发模块'), + )..loadGate = Completer(); + final service = ScriptWidgetService( + repository: repository, + remoteGateway: _UnusedRemoteGateway(), + runtime: runtime, + ); + + final firstInvocation = service.invoke( + 'concurrent-widget', + 'loadContent', + const {}, + ); + final secondInvocation = service.invoke( + 'concurrent-widget', + 'loadContent', + const {}, + ); + await Future.delayed(Duration.zero); + + expect(runtime.loadCount, 1); + runtime.loadGate!.complete(); + await Future.wait(>[ + firstInvocation, + secondInvocation, + ]); + expect(runtime.loadCount, 1); + }); + + test('运行环境失效后下一次调用会重新加载', () async { + final timestamp = DateTime.utc(2026, 7, 12); + final repository = _MemoryScriptWidgetRepository(); + repository.widgets['reload-widget'] = InstalledScriptWidget( + manifest: const ScriptWidgetManifest(id: 'reload-widget', title: '重载模块'), + scriptSource: 'reload source', + installedAt: timestamp, + updatedAt: timestamp, + ); + final runtime = _StaticScriptWidgetRuntime( + const ScriptWidgetManifest(id: 'reload-widget', title: '重载模块'), + ); + final service = ScriptWidgetService( + repository: repository, + remoteGateway: _UnusedRemoteGateway(), + runtime: runtime, + ); + + await service.invoke( + 'reload-widget', + 'loadContent', + const {}, + ); + runtime.loadedWidgetIds.clear(); + await service.invoke( + 'reload-widget', + 'loadContent', + const {}, + ); + + expect(runtime.loadCount, 2); + }); + + test('远程更新返回不同模块 ID 时拒绝覆盖已安装模块', () async { + final timestamp = DateTime.utc(2026, 7, 12); + final repository = _MemoryScriptWidgetRepository(); + final installedWidget = InstalledScriptWidget( + manifest: const ScriptWidgetManifest(id: 'original-widget', title: '原模块'), + scriptSource: 'original source', + sourceUrl: 'https://example.com/original.js', + installedAt: timestamp, + updatedAt: timestamp, + ); + repository.widgets['original-widget'] = installedWidget; + final runtime = _StaticScriptWidgetRuntime( + const ScriptWidgetManifest(id: 'other-widget', title: '其他模块'), + ); + final service = ScriptWidgetService( + repository: repository, + remoteGateway: const _StaticRemoteGateway('replacement source'), + runtime: runtime, + ); + + await expectLater( + service.refreshWidget('original-widget'), + throwsA(isA()), + ); + + expect(repository.widgets['original-widget'], installedWidget); + expect(repository.widgets.containsKey('other-widget'), isFalse); + expect(runtime.loadCount, 0); + }); +} diff --git a/test/core/domain/usecases/server_reorder_test.dart b/test/core/domain/usecases/server_reorder_test.dart new file mode 100644 index 0000000..f45528d --- /dev/null +++ b/test/core/domain/usecases/server_reorder_test.dart @@ -0,0 +1,53 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:smplayer/core/contracts/server.dart'; +import 'package:smplayer/core/domain/ports/server_repository.dart'; +import 'package:smplayer/core/domain/usecases/server_usecases.dart'; + +class _CapturingServerRepository implements ServerRepository { + List? replacedServers; + + @override + Future replaceAll(List servers) async { + replacedServers = List.of(servers); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +void main() { + group('ReorderServersUseCase', () { + test('persists the provided server order using replaceAll', () async { + final repository = _CapturingServerRepository(); + final usecase = ReorderServersUseCase(serverRepository: repository); + final originalServers = [ + _server(id: 'first'), + _server(id: 'second'), + _server(id: 'third'), + ]; + final reorderedServers = [ + originalServers[1], + originalServers[2], + originalServers[0], + ]; + + await usecase.execute(reorderedServers); + + expect(repository.replacedServers?.map((server) => server.id), [ + 'second', + 'third', + 'first', + ]); + }); + }); +} + +EmbyServer _server({required String id}) { + return EmbyServer( + id: id, + name: 'Server $id', + baseUrl: 'https://$id.example.com/emby', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + ); +} diff --git a/test/core/domain/usecases/tmdb_availability_stream_test.dart b/test/core/domain/usecases/tmdb_availability_stream_test.dart new file mode 100644 index 0000000..e13cd1f --- /dev/null +++ b/test/core/domain/usecases/tmdb_availability_stream_test.dart @@ -0,0 +1,176 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:smplayer/core/contracts/auth.dart'; +import 'package:smplayer/core/contracts/cancellation.dart'; +import 'package:smplayer/core/contracts/library.dart'; +import 'package:smplayer/core/contracts/server.dart'; +import 'package:smplayer/core/domain/ports/emby_gateway.dart'; +import 'package:smplayer/core/domain/ports/server_repository.dart'; +import 'package:smplayer/core/domain/ports/session_repository.dart'; +import 'package:smplayer/core/domain/usecases/media_usecases.dart'; + +class _FakeSessionRepository implements SessionRepository { + _FakeSessionRepository(this.sessions); + final List sessions; + + @override + Future> listAll() async => sessions; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeServerRepository implements ServerRepository { + _FakeServerRepository(this.servers); + final List servers; + + @override + Future> list() async => servers; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeEmbyGateway implements EmbyGateway { + _FakeEmbyGateway(this.onGetLibraryItems); + final Future> Function(AuthedRequestContext ctx) + onGetLibraryItems; + + @override + Future> getLibraryItems({ + required AuthedRequestContext ctx, + String? parentId, + String? includeItemTypes, + List? genreIds, + String? searchTerm, + String? anyProviderIdEquals, + List? personIds, + String? fields, + String? enableImageTypes, + bool? groupProgramsBySeries, + int? imageTypeLimit, + int? startIndex, + int? limit, + bool? recursive, + String? sortBy, + SortOrder? sortOrder, + String? filters, + CancellationToken? cancelToken, + }) => onGetLibraryItems(ctx); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +EmbyServer _server(String id) => EmbyServer( + id: id, + name: id, + baseUrl: 'http://$id', + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', +); + +SessionData _session(String serverId) => SessionData( + serverId: serverId, + token: 'token-$serverId', + userId: 'user-$serverId', + userName: serverId, + createdAt: '2026-01-01T00:00:00Z', +); + +EmbyRawItem _item(String id) => EmbyRawItem(Id: id, Name: id, Type: 'Movie'); + +TmdbAvailabilityUseCase _uc({ + required List serverIds, + required Future> Function(AuthedRequestContext ctx) + onGetLibraryItems, + Duration settleTimeout = const Duration(milliseconds: 100), +}) => TmdbAvailabilityUseCase( + sessionRepository: _FakeSessionRepository( + serverIds.map(_session).toList(growable: false), + ), + serverRepository: _FakeServerRepository( + serverIds.map(_server).toList(growable: false), + ), + embyGateway: _FakeEmbyGateway(onGetLibraryItems), + settleTimeout: settleTimeout, +); + +void main() { + const req = TmdbAvailabilityReq(tmdbId: '42', mediaType: 'movie'); + + test('慢服务器挂起:settleTimeout 先发已完成快照,迟到命中追加补全', () async { + final hang = Completer>(); + final uc = _uc( + serverIds: ['fast', 'slow'], + onGetLibraryItems: (ctx) => ctx.baseUrl == 'http://slow' + ? hang.future + : Future.value([_item('item-fast')]), + ); + + final emissions = >[]; + final done = Completer(); + uc + .executeStream(req) + .listen( + (hits) => emissions.add( + hits.map((h) => h.serverId).toList(growable: false), + ), + onDone: done.complete, + ); + + await Future.delayed(const Duration(milliseconds: 300)); + expect(emissions, [ + ['fast'], + ]); + + hang.complete([_item('item-slow')]); + await done.future; + expect(emissions, hasLength(2)); + expect(emissions.last, containsAll(['fast', 'slow'])); + }); + + test('全部服务器先于 settleTimeout 返回:只发一次快照并立即关流', () async { + final uc = _uc( + serverIds: ['a', 'b'], + onGetLibraryItems: (ctx) => Future.value([_item('item-${ctx.baseUrl}')]), + settleTimeout: const Duration(seconds: 10), + ); + + final sw = Stopwatch()..start(); + final emissions = await uc.executeStream(req).toList(); + sw.stop(); + + expect(sw.elapsed, lessThan(const Duration(seconds: 2))); + expect(emissions, hasLength(1)); + expect(emissions.single.map((h) => h.serverId), containsAll(['a', 'b'])); + }); + + test('迟到探测未命中(null)不重复发相同快照', () async { + final hang = Completer>(); + final uc = _uc( + serverIds: ['fast', 'slow'], + onGetLibraryItems: (ctx) => ctx.baseUrl == 'http://slow' + ? hang.future + : Future.value([_item('item-fast')]), + ); + + final emissionsFuture = uc.executeStream(req).toList(); + await Future.delayed(const Duration(milliseconds: 300)); + hang.complete(const []); + final emissions = await emissionsFuture; + + expect(emissions, hasLength(1)); + expect(emissions.single.map((h) => h.serverId), ['fast']); + }); + + test('settleTimeout 默认 3 秒', () { + final uc = TmdbAvailabilityUseCase( + sessionRepository: _FakeSessionRepository(const []), + serverRepository: _FakeServerRepository(const []), + embyGateway: _FakeEmbyGateway((_) async => const []), + ); + expect(uc.settleTimeout, const Duration(seconds: 3)); + }); +} diff --git a/test/core/infra/emby_api/emby_headers_test.dart b/test/core/infra/emby_api/emby_headers_test.dart new file mode 100644 index 0000000..3936be6 --- /dev/null +++ b/test/core/infra/emby_api/emby_headers_test.dart @@ -0,0 +1,59 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:smplayer/core/infra/emby_api/emby_headers.dart'; + +void main() { + group('EmbyRequestHeaders.media', () { + test('媒体流 UA 使用基础 UA', () { + final headers = EmbyRequestHeaders.media( + deviceId: '6f43c257-2d5e-407e-9827-0a59d37ceb97', + deviceName: 'TestDevice', + token: 't', + userId: 'u', + ); + + expect(headers['User-Agent'], EmbyRequestHeaders.userAgent); + expect(headers['Accept'], '*/*'); + expect(headers['Accept-Language'], EmbyRequestHeaders.acceptLanguage); + }); + }); + + group('EmbyRequestHeaders.directMedia', () { + test('播放器直连头不包含凭据或设备标识', () { + final headers = EmbyRequestHeaders.directMedia(); + final normalizedNames = headers.keys + .map((name) => name.toLowerCase()) + .toSet(); + + expect(headers['User-Agent'], EmbyRequestHeaders.userAgent); + expect(headers['Accept'], '*/*'); + expect(normalizedNames, isNot(contains('authorization'))); + expect(normalizedNames, isNot(contains('cookie'))); + expect(normalizedNames, isNot(contains('x-emby-token'))); + expect(normalizedNames, isNot(contains('x-mediabrowser-token'))); + expect(normalizedNames, isNot(contains('x-emby-authorization'))); + expect(normalizedNames, isNot(contains('x-emby-device-id'))); + }); + }); + + group('SenPlayer 身份对齐', () { + test('UA 为 SenPlayer 固定值', () { + expect(EmbyRequestHeaders.userAgent, 'SenPlayer/6.2.0'); + expect(EmbyRequestHeaders.clientName, 'SenPlayer'); + expect(EmbyRequestHeaders.version, '6.1.0'); + }); + + test('auth 头字面格式与 SenPlayer 身份一致', () { + final auth = EmbyAuthHeader.build( + deviceId: 'd', + deviceName: 'Mac', + token: 't', + userId: 'u', + ); + expect( + auth, + 'MediaBrowser Token="t", Emby UserId="u", Client="SenPlayer", ' + 'Device="Mac", DeviceId="d", Version="6.1.0"', + ); + }); + }); +} diff --git a/test/core/infra/emby_api/playback_resolver_prepare_test.dart b/test/core/infra/emby_api/playback_resolver_prepare_test.dart new file mode 100644 index 0000000..f0c67b4 --- /dev/null +++ b/test/core/infra/emby_api/playback_resolver_prepare_test.dart @@ -0,0 +1,244 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:smplayer/core/contracts/library.dart'; +import 'package:smplayer/core/contracts/playback.dart'; +import 'package:smplayer/core/domain/ports/emby_gateway.dart'; +import 'package:smplayer/core/infra/emby_api/playback_resolver.dart'; + + +class _UnusedEmbyGateway implements EmbyGateway { + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +void main() { + const ctx = AuthedRequestContext( + baseUrl: 'https://emby.example.com', + token: 'tok123', + userId: 'user1', + ); + + final resolver = PlaybackResolver( + gateway: _UnusedEmbyGateway(), + deviceId: 'device-1', + deviceName: 'TestBox', + ); + + EmbyRawItem item({int? resumeTicks}) => EmbyRawItem( + Id: 'item1', + Name: 'Movie One', + Type: 'Movie', + RunTimeTicks: 600 * 10000000, + UserData: resumeTicks == null + ? null + : EmbyRawUserData(PlaybackPositionTicks: resumeTicks), + ); + + Map playbackInfo(Map source) => { + 'PlaySessionId': 'ps1', + 'MediaSources': [source], + }; + + group('PlaybackResolver.prepare 流地址优先级', () { + test('DirectStreamUrl 优先,playMethod=DirectStream', () async { + final result = await resolver.prepare( + ctx: ctx, + payload: const PlaybackRequestPayload(itemId: 'item1'), + preloadedItem: item(), + preloadedPlaybackInfo: playbackInfo({ + 'Id': 's1', + 'Container': 'mkv', + 'DirectStreamUrl': '/Videos/item1/stream.mkv?x=1', + 'TranscodingUrl': '/Videos/item1/main.m3u8', + 'SupportsDirectStream': true, + }), + ); + expect( + result.streamUrl, + 'https://emby.example.com/Videos/item1/stream.mkv?x=1&api_key=tok123', + ); + expect(result.playMethod, 'DirectStream'); + expect(result.directStream, isTrue); + }); + + test('无 DirectStreamUrl 时用 TranscodingUrl,playMethod=Transcode', () async { + final result = await resolver.prepare( + ctx: ctx, + payload: const PlaybackRequestPayload(itemId: 'item1'), + preloadedItem: item(), + preloadedPlaybackInfo: playbackInfo({ + 'Id': 's1', + 'Container': 'mkv', + 'TranscodingUrl': '/Videos/item1/main.m3u8', + }), + ); + expect(result.streamUrl, contains('/Videos/item1/main.m3u8')); + expect(result.streamUrl, contains('api_key=tok123')); + expect(result.playMethod, 'Transcode'); + }); + + test('跨域绝对 CDN 地址不追加 Emby token', () async { + final result = await resolver.prepare( + ctx: ctx, + payload: const PlaybackRequestPayload(itemId: 'item1'), + preloadedItem: item(), + preloadedPlaybackInfo: playbackInfo({ + 'Id': 's1', + 'Container': 'mkv', + 'DirectStreamUrl': + 'https://cdn.example.net/media/movie.mkv?signature=signed', + }), + ); + + expect( + result.streamUrl, + 'https://cdn.example.net/media/movie.mkv?signature=signed', + ); + expect(result.streamUrl, isNot(contains('tok123'))); + expect(result.streamUrl, isNot(contains('api_key'))); + }); + + test('部署在子路径下时保留 Emby base path', () async { + const subpathContext = AuthedRequestContext( + baseUrl: 'https://emby.example.com/emby', + token: 'tok123', + userId: 'user1', + ); + final result = await resolver.prepare( + ctx: subpathContext, + payload: const PlaybackRequestPayload(itemId: 'item1'), + preloadedItem: item(), + preloadedPlaybackInfo: playbackInfo({ + 'Id': 's1', + 'Container': 'mkv', + 'DirectStreamUrl': '/Videos/item1/stream.mkv', + }), + ); + + expect( + result.streamUrl, + 'https://emby.example.com/emby/Videos/item1/stream.mkv?api_key=tok123', + ); + }); + + test('两者皆无时回退静态流(含容器扩展名与鉴权参数)', () async { + final result = await resolver.prepare( + ctx: ctx, + payload: const PlaybackRequestPayload(itemId: 'item1'), + preloadedItem: item(), + preloadedPlaybackInfo: playbackInfo({'Id': 's1', 'Container': 'mp4'}), + ); + expect( + result.streamUrl, + startsWith('https://emby.example.com/Videos/item1/stream.mp4?'), + ); + expect(result.streamUrl, contains('api_key=tok123')); + expect(result.streamUrl, contains('MediaSourceId=s1')); + expect(result.streamUrl, contains('PlaySessionId=ps1')); + expect(result.playMethod, 'DirectStream'); + }); + + test('按 mediaSourceId 选源,未命中回退首源', () async { + final info = { + 'MediaSources': [ + {'Id': 's1', 'DirectStreamUrl': '/Videos/item1/a.mkv'}, + {'Id': 's2', 'DirectStreamUrl': '/Videos/item1/b.mkv'}, + ], + }; + final picked = await resolver.prepare( + ctx: ctx, + payload: const PlaybackRequestPayload( + itemId: 'item1', + mediaSourceId: 's2', + ), + preloadedItem: item(), + preloadedPlaybackInfo: info, + ); + expect(picked.streamUrl, contains('/Videos/item1/b.mkv')); + + final fallback = await resolver.prepare( + ctx: ctx, + payload: const PlaybackRequestPayload( + itemId: 'item1', + mediaSourceId: 'missing', + ), + preloadedItem: item(), + preloadedPlaybackInfo: info, + ); + expect(fallback.streamUrl, contains('/Videos/item1/a.mkv')); + }); + }); + + group('PlaybackResolver.prepare 续播位置', () { + Future startSecondsFor(PlaybackRequestPayload payload) async { + final result = await resolver.prepare( + ctx: ctx, + payload: payload, + preloadedItem: item(resumeTicks: 120 * 10000000), + preloadedPlaybackInfo: playbackInfo({ + 'Id': 's1', + 'DirectStreamUrl': '/Videos/item1/a.mkv', + }), + ); + return result.startPositionSeconds; + } + + test('playFromStart 强制从头', () async { + expect( + await startSecondsFor( + const PlaybackRequestPayload(itemId: 'item1', playFromStart: true), + ), + 0, + ); + }); + + test('显式 startPositionTicks 优先于 UserData', () async { + expect( + await startSecondsFor( + const PlaybackRequestPayload( + itemId: 'item1', + startPositionTicks: 60 * 10000000, + ), + ), + 60, + ); + }); + + test('默认取 UserData.PlaybackPositionTicks', () async { + expect( + await startSecondsFor(const PlaybackRequestPayload(itemId: 'item1')), + 120, + ); + }); + }); + + group('PlaybackResolver.resolveSubtitles', () { + test('未提供 baseUrl/token 时外挂轨全部过滤,仅留内封(详尽用例见 ' + 'playback_resolver_subtitles_test.dart)', () { + final subtitles = PlaybackResolver.resolveSubtitles(const [ + EmbyRawMediaStream(Index: 1, Type: 'Subtitle', Title: 'embedded'), + EmbyRawMediaStream( + Index: 2, + Type: 'Subtitle', + Codec: 'srt', + IsExternal: true, + ), + EmbyRawMediaStream( + Index: 3, + Type: 'Subtitle', + Codec: 'srt', + DeliveryMethod: 'External', + ), + EmbyRawMediaStream( + Index: 4, + Type: 'Subtitle', + Codec: 'srt', + DeliveryUrl: '/subs/4.srt', + ), + EmbyRawMediaStream(Index: 5, Type: 'Audio'), + ]); + expect(subtitles, hasLength(1)); + expect(subtitles.single.index, 1); + expect(subtitles.single.title, 'embedded'); + }); + }); +} diff --git a/test/core/infra/emby_api/playback_resolver_subtitles_test.dart b/test/core/infra/emby_api/playback_resolver_subtitles_test.dart new file mode 100644 index 0000000..91a9c23 --- /dev/null +++ b/test/core/infra/emby_api/playback_resolver_subtitles_test.dart @@ -0,0 +1,295 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:smplayer/core/contracts/library.dart'; +import 'package:smplayer/core/infra/emby_api/playback_resolver.dart'; + +void main() { + const baseUrl = 'https://emby.example.com'; + const token = 'tok123'; + const itemId = 'item1'; + const mediaSourceId = 'src9'; + + EmbyRawMediaStream embeddedSubtitle({ + int index = 2, + String codec = 'subrip', + String? title, + String? language, + bool isDefault = false, + bool isForced = false, + }) => EmbyRawMediaStream( + Index: index, + Type: 'Subtitle', + Codec: codec, + Title: title, + Language: language, + IsDefault: isDefault, + IsForced: isForced, + ); + + EmbyRawMediaStream externalSubtitle({ + int index = 10, + String codec = 'srt', + String? deliveryUrl = '/Videos/item1/10/Subtitles/stream.srt', + String? deliveryMethod = 'External', + bool isExternal = true, + String? title, + String? language, + }) => EmbyRawMediaStream( + Index: index, + Type: 'Subtitle', + Codec: codec, + IsExternal: isExternal, + DeliveryMethod: deliveryMethod, + DeliveryUrl: deliveryUrl, + Title: title, + Language: language, + ); + + group('PlaybackResolver.resolveSubtitles 外挂字幕', () { + test('内封轨保留且 isExternal=false(未传 itemId 时无抽取 URL)', () { + final subtitles = PlaybackResolver.resolveSubtitles( + [embeddedSubtitle(index: 2, title: '简中', language: 'chi')], + baseUrl: baseUrl, + token: token, + ); + expect(subtitles, hasLength(1)); + expect(subtitles.single.index, 2); + expect(subtitles.single.isExternal, isFalse); + expect(subtitles.single.deliveryUrl, isNull); + expect(subtitles.single.codec, 'subrip'); + }); + + test('外挂文本轨保留,deliveryUrl 补全 base 与 api_key', () { + final subtitles = PlaybackResolver.resolveSubtitles( + [externalSubtitle(index: 10, codec: 'srt', language: 'chi')], + baseUrl: baseUrl, + token: token, + ); + expect(subtitles, hasLength(1)); + final track = subtitles.single; + expect(track.isExternal, isTrue); + expect( + track.deliveryUrl, + '$baseUrl/Videos/item1/10/Subtitles/stream.srt?api_key=$token', + ); + }); + + test('deliveryUrl 已是绝对地址时不重复补 base,只补 api_key', () { + final subtitles = PlaybackResolver.resolveSubtitles( + [ + externalSubtitle( + deliveryUrl: 'https://cdn.example.com/subs/a.ass', + codec: 'ass', + ), + ], + baseUrl: baseUrl, + token: token, + ); + expect( + subtitles.single.deliveryUrl, + 'https://cdn.example.com/subs/a.ass?api_key=$token', + ); + }); + + test('外挂位图轨(pgs/vobsub/dvdsub)被过滤', () { + for (final bitmapCodec in ['pgssub', 'pgs', 'dvdsub', 'sup', 'vobsub']) { + final subtitles = PlaybackResolver.resolveSubtitles( + [externalSubtitle(codec: bitmapCodec)], + baseUrl: baseUrl, + token: token, + ); + expect(subtitles, isEmpty, reason: 'codec=$bitmapCodec 应被过滤'); + } + }); + + test('内封位图轨保留(由引擎渲染,行为不变)', () { + final subtitles = PlaybackResolver.resolveSubtitles( + [embeddedSubtitle(codec: 'pgssub')], + baseUrl: baseUrl, + token: token, + ); + expect(subtitles, hasLength(1)); + expect(subtitles.single.isExternal, isFalse); + }); + + test('外挂轨缺 DeliveryUrl 时被过滤', () { + final subtitles = PlaybackResolver.resolveSubtitles( + [externalSubtitle(deliveryUrl: null)], + baseUrl: baseUrl, + token: token, + ); + expect(subtitles, isEmpty); + }); + + test('未提供 baseUrl/token 时外挂轨被过滤(内封不受影响)', () { + final subtitles = PlaybackResolver.resolveSubtitles([ + embeddedSubtitle(index: 2), + externalSubtitle(index: 10), + ]); + expect(subtitles, hasLength(1)); + expect(subtitles.single.index, 2); + }); + + test('仅 DeliveryMethod==External(无 IsExternal 标记)也按外挂处理', () { + final subtitles = PlaybackResolver.resolveSubtitles( + [externalSubtitle(isExternal: false, deliveryMethod: 'External')], + baseUrl: baseUrl, + token: token, + ); + expect(subtitles.single.isExternal, isTrue); + }); + + test('混合列表保持原顺序,各归其类', () { + final subtitles = PlaybackResolver.resolveSubtitles( + [ + embeddedSubtitle(index: 2, codec: 'ass'), + externalSubtitle(index: 10, codec: 'srt'), + externalSubtitle(index: 11, codec: 'pgssub'), + embeddedSubtitle(index: 3, codec: 'subrip'), + ], + baseUrl: baseUrl, + token: token, + ); + expect(subtitles.map((t) => t.index).toList(), [2, 10, 3]); + expect(subtitles.map((t) => t.isExternal).toList(), [false, true, false]); + }); + }); + + group('PlaybackResolver.resolveSubtitles 内封文本抽取地址', () { + test('内封文本轨生成服务端抽取 deliveryUrl(isExternal 仍为 false)', () { + final subtitles = PlaybackResolver.resolveSubtitles( + [embeddedSubtitle(index: 2, codec: 'subrip', language: 'chi')], + baseUrl: baseUrl, + token: token, + itemId: itemId, + mediaSourceId: mediaSourceId, + ); + expect(subtitles, hasLength(1)); + final track = subtitles.single; + expect(track.isExternal, isFalse); + + + expect( + track.deliveryUrl, + '$baseUrl/Videos/$itemId/$mediaSourceId/Subtitles/2/0/Stream.subrip' + '?api_key=$token', + ); + }); + + test('扩展名用 codec 原名 passthrough(webvtt 归一化为 vtt)', () { + final cases = {'subrip': 'subrip', 'ssa': 'ssa', 'webvtt': 'vtt'}; + cases.forEach((codec, ext) { + final subtitles = PlaybackResolver.resolveSubtitles( + [embeddedSubtitle(index: 4, codec: codec)], + baseUrl: baseUrl, + token: token, + itemId: itemId, + mediaSourceId: mediaSourceId, + ); + expect( + subtitles.single.deliveryUrl, + '$baseUrl/Videos/$itemId/$mediaSourceId/Subtitles/4/0/Stream.$ext' + '?api_key=$token', + reason: 'codec=$codec 应映射为 .$ext', + ); + }); + }); + + test('srt/ass/vtt codec 直接用作扩展名,含 /0/ 段', () { + final cases = {'srt': 'srt', 'ass': 'ass', 'vtt': 'vtt'}; + cases.forEach((codec, ext) { + final subtitles = PlaybackResolver.resolveSubtitles( + [embeddedSubtitle(index: 5, codec: codec)], + baseUrl: baseUrl, + token: token, + itemId: itemId, + mediaSourceId: mediaSourceId, + ); + expect( + subtitles.single.deliveryUrl, + endsWith('/Subtitles/5/0/Stream.$ext?api_key=$token'), + ); + }); + }); + + test('内封位图轨不生成抽取 URL(deliveryUrl 为 null)', () { + for (final bitmapCodec in ['pgssub', 'pgs', 'dvdsub', 'vobsub', 'sup']) { + final subtitles = PlaybackResolver.resolveSubtitles( + [embeddedSubtitle(index: 6, codec: bitmapCodec)], + baseUrl: baseUrl, + token: token, + itemId: itemId, + mediaSourceId: mediaSourceId, + ); + expect(subtitles, hasLength(1)); + expect( + subtitles.single.deliveryUrl, + isNull, + reason: 'codec=$bitmapCodec 不应生成抽取 URL', + ); + } + }); + + test('缺 mediaSourceId 时内封文本轨不生成抽取 URL', () { + final subtitles = PlaybackResolver.resolveSubtitles( + [embeddedSubtitle(index: 2, codec: 'subrip')], + baseUrl: baseUrl, + token: token, + itemId: itemId, + ); + expect(subtitles.single.deliveryUrl, isNull); + }); + + test('外挂轨不受内封抽取逻辑影响,仍用自身 DeliveryUrl', () { + final subtitles = PlaybackResolver.resolveSubtitles( + [ + embeddedSubtitle(index: 2, codec: 'subrip'), + externalSubtitle(index: 10, codec: 'srt'), + ], + baseUrl: baseUrl, + token: token, + itemId: itemId, + mediaSourceId: mediaSourceId, + ); + final embedded = subtitles.firstWhere((t) => !t.isExternal); + final external = subtitles.firstWhere((t) => t.isExternal); + expect( + embedded.deliveryUrl, + '$baseUrl/Videos/$itemId/$mediaSourceId/Subtitles/2/0/Stream.subrip' + '?api_key=$token', + ); + expect( + external.deliveryUrl, + '$baseUrl/Videos/item1/10/Subtitles/stream.srt?api_key=$token', + ); + }); + }); + + group('PlaybackResolver.isTextSubtitleCodec', () { + test('文本 codec 判定大小写不敏感', () { + for (final textCodec in [ + 'srt', + 'SUBRIP', + 'vtt', + 'WebVTT', + 'ass', + 'ssa', + ]) { + expect( + PlaybackResolver.isTextSubtitleCodec(textCodec), + isTrue, + reason: 'codec=$textCodec', + ); + } + }); + + test('位图/未知/null codec 返回 false', () { + for (final nonText in ['pgssub', 'dvdsub', 'sup', 'unknown', null]) { + expect( + PlaybackResolver.isTextSubtitleCodec(nonText), + isFalse, + reason: 'codec=$nonText', + ); + } + }); + }); +} diff --git a/test/core/infra/player_engine/fvp_network_error_test.dart b/test/core/infra/player_engine/fvp_network_error_test.dart new file mode 100644 index 0000000..eda10d4 --- /dev/null +++ b/test/core/infra/player_engine/fvp_network_error_test.dart @@ -0,0 +1,97 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:smplayer/core/infra/player_engine/fvp_player_engine.dart'; + +void main() { + group('isFvpNetworkError', () { + test('命中 tcp:/tls: 前缀', () { + expect(isFvpNetworkError('tcp: connection lost'), isTrue); + expect(isFvpNetworkError('tls: certificate verify failed'), isTrue); + }); + + test('命中 TLS 层关键字(Windows 直连的主要场景)', () { + expect(isFvpNetworkError('TLS handshake failed'), isTrue); + expect(isFvpNetworkError('SSL_read: SSL_ERROR_SSL, WANT_WRITE'), isTrue); + expect( + isFvpNetworkError('reader: tls: handshake error (err=-1)'), + isTrue, + ); + }); + + test('命中连接层关键字', () { + expect(isFvpNetworkError('Connection refused'), isTrue); + expect(isFvpNetworkError('connection reset by peer'), isTrue); + expect(isFvpNetworkError('connection timed out'), isTrue); + expect(isFvpNetworkError('Network is unreachable'), isTrue); + expect(isFvpNetworkError('Host not found'), isTrue); + expect(isFvpNetworkError('name resolution failed'), isTrue); + expect(isFvpNetworkError('No route to host'), isTrue); + }); + + test('命中 HTTP 状态码文本', () { + expect(isFvpNetworkError('HTTP error 403 Forbidden'), isTrue); + expect(isFvpNetworkError('HTTP error 502 Bad Gateway'), isTrue); + expect(isFvpNetworkError('Server returned 401 Unauthorized'), isTrue); + expect(isFvpNetworkError('Server returned 503'), isTrue); + }); + + test('命中 ffmpeg 协议缺失(TLS 后端被剥离时的典型 detail)', () { + expect(isFvpNetworkError('Protocol not found'), isTrue); + }); + + test('不命中解码器与非网络错误', () { + expect(isFvpNetworkError('Failed to allocate decoder'), isFalse); + expect(isFvpNetworkError('Error while decoding frame'), isFalse); + expect(isFvpNetworkError('unsupported pixel format'), isFalse); + expect(isFvpNetworkError('decoder timeout waiting for frame'), isFalse); + }); + + test('空串与无关文本不命中', () { + expect(isFvpNetworkError(''), isFalse); + expect(isFvpNetworkError('reader.buffering'), isFalse); + expect(isFvpNetworkError('render.video 1st_frame'), isFalse); + }); + }); + + group('fvpNetworkErrorPayload', () { + test('只转换 IO 类别中的已识别网络错误', () { + expect( + fvpNetworkErrorPayload( + category: 'reader.error', + errorCode: -1, + detail: 'Connection reset by peer', + ), + 'reader: Connection reset by peer (err=-1)', + ); + }); + + test('忽略 demuxer 解码错误和非负事件', () { + expect( + fvpNetworkErrorPayload( + category: 'demuxer.error', + errorCode: -1, + detail: 'Invalid data found when processing input', + ), + isNull, + ); + expect( + fvpNetworkErrorPayload( + category: 'reader.error', + errorCode: 0, + detail: 'Connection reset by peer', + ), + isNull, + ); + }); + + test('忽略非 IO 类别,即使 detail 含网络词', () { + expect( + fvpNetworkErrorPayload( + category: 'decoder.video', + errorCode: -1, + detail: 'TLS handshake failed', + ), + isNull, + ); + }); + }); +} diff --git a/test/core/infra/player_engine/media3_player_engine_construct_test.dart b/test/core/infra/player_engine/media3_player_engine_construct_test.dart new file mode 100644 index 0000000..f5eb3df --- /dev/null +++ b/test/core/infra/player_engine/media3_player_engine_construct_test.dart @@ -0,0 +1,416 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/foundation.dart' + show ValueNotifier, debugDefaultTargetPlatformOverride; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:smplayer/core/infra/player_engine/media3_player_engine.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const methodChannel = MethodChannel('smplayer/media3_engine'); + const eventChannel = MethodChannel('smplayer/media3_engine/events'); + + setUp(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(methodChannel, (call) async { + switch (call.method) { + case 'create': + return { + 'playerId': 7, + 'ffmpegAvailable': true, + 'ffmpegVersion': 'test-ffmpeg', + }; + case 'open': + case 'setVolume': + case 'setResizeMode': + case 'replayKeyState': + case 'dispose': + return null; + } + fail('Unexpected media3 method call: ${call.method}'); + }); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(eventChannel, (call) async => null); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(methodChannel, null); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(eventChannel, null); + debugDefaultTargetPlatformOverride = null; + }); + + group('Media3PlayerEngine 平台守卫', () { + test('桌面(isAndroid=false)构造抛 UnsupportedError', () { + expect( + () => Media3PlayerEngine(isAndroid: false), + throwsA(isA()), + ); + }); + + test('Android(isAndroid=true)构造不抛', () { + final engine = Media3PlayerEngine(isAndroid: true); + addTearDown(engine.dispose); + expect(engine.displayName, 'media3'); + }); + + test('open 成功后请求 native 重放关键状态', () async { + final calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(methodChannel, (call) async { + calls.add(call.method); + switch (call.method) { + case 'create': + return { + 'playerId': 7, + 'ffmpegAvailable': true, + 'ffmpegVersion': 'test-ffmpeg', + }; + case 'open': + case 'setVolume': + case 'replayKeyState': + case 'dispose': + return null; + } + fail('Unexpected media3 method call: ${call.method}'); + }); + + final engine = Media3PlayerEngine(isAndroid: true); + addTearDown(engine.dispose); + + await engine.open('http://127.0.0.1/video.mkv'); + + expect(calls, containsAllInOrder(['open', 'replayKeyState'])); + }); + }); + + group('Media3PlayerEngine Android 视频视图', () { + test('源码固定使用 Hybrid Composition,避免旧设备 TLHC/HC fallback 改变 HDR 路径', () { + final source = File( + 'lib/core/infra/player_engine/media3_player_engine.dart', + ).readAsStringSync(); + + expect(source, contains('initExpensiveAndroidView')); + expect(source, isNot(contains('initSurfaceAndroidView'))); + }); + + test('native SurfaceView 不主动改 z-order,避免 Flutter HC 下黑屏', () { + final source = File( + 'plugins/media3_engine/android/src/main/kotlin/com/smplayer/media3_engine/Media3PlatformView.kt', + ).readAsStringSync(); + + expect(source, isNot(contains('setZOrderMediaOverlay(true)'))); + expect(source, isNot(contains('setZOrderOnTop(true)'))); + }); + + testWidgets('playerId 就位后使用原生 SurfaceView PlatformView', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + try { + final engine = Media3PlayerEngine(isAndroid: true); + final fit = ValueNotifier(BoxFit.contain); + addTearDown(engine.dispose); + addTearDown(fit.dispose); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Builder( + builder: (context) => + SizedBox.expand(child: engine.buildVideoView(context, fit)), + ), + ), + ); + expect(find.byType(PlatformViewLink), findsNothing); + + await engine.open('http://127.0.0.1/video.mkv'); + await tester.pump(); + + final view = tester.widget( + find.byType(PlatformViewLink), + ); + expect(view.viewType, 'smplayer/media3_surface'); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }); + + testWidgets('open 等待 native ready 时仍先挂载视频 PlatformView', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + final openCompleter = Completer(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(methodChannel, (call) async { + switch (call.method) { + case 'create': + return { + 'playerId': 7, + 'ffmpegAvailable': true, + 'ffmpegVersion': 'test-ffmpeg', + }; + case 'open': + return openCompleter.future; + case 'setVolume': + case 'setResizeMode': + case 'replayKeyState': + case 'dispose': + return null; + } + fail('Unexpected media3 method call: ${call.method}'); + }); + + try { + final engine = Media3PlayerEngine(isAndroid: true); + final fit = ValueNotifier(BoxFit.contain); + addTearDown(engine.dispose); + addTearDown(fit.dispose); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Builder( + builder: (context) => + SizedBox.expand(child: engine.buildVideoView(context, fit)), + ), + ), + ); + expect(find.byType(PlatformViewLink), findsNothing); + + final openFuture = engine.open('http://127.0.0.1/video.mkv'); + await tester.pump(); + await tester.pump(); + + expect(openCompleter.isCompleted, isFalse); + expect(find.byType(PlatformViewLink), findsOneWidget); + + openCompleter.complete(); + await openFuture; + } finally { + debugDefaultTargetPlatformOverride = null; + } + }); + + testWidgets('playerId 就位与 fit 变化会同步 resize mode 到 native', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + final resizeModes = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(methodChannel, (call) async { + switch (call.method) { + case 'create': + return { + 'playerId': 7, + 'ffmpegAvailable': true, + 'ffmpegVersion': 'test-ffmpeg', + }; + case 'open': + case 'setVolume': + case 'dispose': + case 'replayKeyState': + return null; + case 'setResizeMode': + expect(call.arguments, containsPair('playerId', 7)); + resizeModes.add( + (call.arguments as Map)['mode']! as String, + ); + return null; + } + fail('Unexpected media3 method call: ${call.method}'); + }); + + try { + final engine = Media3PlayerEngine(isAndroid: true); + final fit = ValueNotifier(BoxFit.contain); + addTearDown(engine.dispose); + addTearDown(fit.dispose); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Builder( + builder: (context) => + SizedBox.expand(child: engine.buildVideoView(context, fit)), + ), + ), + ); + + await engine.open('http://127.0.0.1/video.mkv'); + await tester.pump(); + expect(resizeModes, ['fit']); + + fit.value = BoxFit.fill; + await tester.pump(); + expect(resizeModes, ['fit', 'fill']); + + fit.value = BoxFit.cover; + await tester.pump(); + expect(resizeModes, ['fit', 'fill', 'zoom']); + + fit.value = BoxFit.contain; + await tester.pump(); + expect(resizeModes, ['fit', 'fill', 'zoom', 'fit']); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }); + }); + + group('Media3 native deferred prepare', () { + test('源码包含 surface 到达超时和 pending open 取消完成逻辑', () { + final source = File( + 'plugins/media3_engine/android/src/main/kotlin/com/smplayer/media3_engine/Media3PlayerInstance.kt', + ).readAsStringSync(); + + expect(source, contains('SURFACE_ATTACH_TIMEOUT_MS')); + expect(source, contains('surface not attached within timeout')); + expect(source, contains('cancelPendingOpen')); + expect(source, contains('open_cancelled')); + expect(source, contains('clearPendingSurfaceTimeout')); + }); + + test('MethodChannel 暴露 replayKeyState', () { + final source = File( + 'plugins/media3_engine/android/src/main/kotlin/com/smplayer/media3_engine/Media3EnginePlugin.kt', + ).readAsStringSync(); + + expect(source, contains('"replayKeyState"')); + expect(source, contains('player.replayKeyState()')); + }); + }); + + group('Media3 single-flight _ensureCreated', () { + test('并发 prewarmNative + open 只执行一次 native create', () async { + int createCallCount = 0; + final createCompleter = Completer(); + + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(methodChannel, (call) async { + switch (call.method) { + case 'create': + createCallCount++; + return createCompleter.future; + case 'open': + expect( + (call.arguments as Map)['playerId'], + equals(42), + reason: 'open must use the playerId returned by create', + ); + return null; + case 'setVolume': + case 'replayKeyState': + case 'dispose': + return null; + } + fail('Unexpected media3 method call: ${call.method}'); + }); + + final engine = Media3PlayerEngine(isAndroid: true); + addTearDown(engine.dispose); + + final prewarmFuture = engine.prewarmNative(); + final openFuture = engine.open('http://127.0.0.1/video.mkv'); + + expect( + createCallCount, + equals(1), + reason: 'concurrent prewarm + open should share one create call', + ); + + createCompleter.complete({ + 'playerId': 42, + 'ffmpegAvailable': true, + 'ffmpegVersion': 'test-ffmpeg', + }); + + await prewarmFuture; + await openFuture; + + expect( + createCallCount, + equals(1), + reason: 'create must have been called exactly once', + ); + }); + + test('dispose 期间 create 返回时释放孤儿 native player', () async { + final createCompleter = Completer(); + final disposedPlayerIds = []; + + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(methodChannel, (call) async { + switch (call.method) { + case 'create': + return createCompleter.future; + case 'dispose': + final disposePlayerId = + (call.arguments as Map)['playerId'] as int; + disposedPlayerIds.add(disposePlayerId); + return null; + case 'setVolume': + return null; + } + fail('Unexpected media3 method call: ${call.method}'); + }); + + final engine = Media3PlayerEngine(isAndroid: true); + + final prewarmFuture = engine.prewarmNative(); + + await engine.dispose(); + + createCompleter.complete({ + 'playerId': 99, + 'ffmpegAvailable': true, + 'ffmpegVersion': 'test-ffmpeg', + }); + + await prewarmFuture; + + expect( + disposedPlayerIds, + contains(99), + reason: 'orphan native player created after dispose must be released', + ); + }); + + test('create 失败后允许下次重试', () async { + int createAttempts = 0; + + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(methodChannel, (call) async { + switch (call.method) { + case 'create': + createAttempts++; + if (createAttempts == 1) { + throw PlatformException( + code: 'INIT_FAILED', + message: 'simulated first-create failure', + ); + } + return { + 'playerId': 7, + 'ffmpegAvailable': true, + 'ffmpegVersion': 'test-ffmpeg', + }; + case 'open': + case 'setVolume': + case 'replayKeyState': + case 'dispose': + return null; + } + fail('Unexpected media3 method call: ${call.method}'); + }); + + final engine = Media3PlayerEngine(isAndroid: true); + addTearDown(engine.dispose); + + expect(engine.prewarmNative(), throwsA(isA())); + await Future.delayed(Duration.zero); + + await engine.open('http://127.0.0.1/video.mkv'); + expect(createAttempts, equals(2), reason: 'retry after first failure'); + }); + }); +} diff --git a/test/core/infra/player_engine/mpv_network_error_test.dart b/test/core/infra/player_engine/mpv_network_error_test.dart new file mode 100644 index 0000000..cbc468c --- /dev/null +++ b/test/core/infra/player_engine/mpv_network_error_test.dart @@ -0,0 +1,56 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:smplayer/core/infra/player_engine/media_kit_player_engine.dart'; + +void main() { + group('isMpvNetworkError', () { + test('命中 tcp:/tls: 前缀', () { + expect( + isMpvNetworkError('tcp: ffurl_read returned 0xffffff99'), + isTrue, + ); + expect(isMpvNetworkError('tls: error during handshake'), isTrue); + }); + + test('命中 HTTP open 失败', () { + expect( + isMpvNetworkError( + 'Failed to open https://cdn.example/v.mkv.', + ), + isTrue, + ); + }); + + test('命中连接类关键词', () { + expect(isMpvNetworkError('connection reset by peer'), isTrue); + expect(isMpvNetworkError('Connection refused'), isTrue); + expect(isMpvNetworkError('connection timed out'), isTrue); + expect(isMpvNetworkError('Network is unreachable'), isTrue); + expect(isMpvNetworkError('Host not found'), isTrue); + expect(isMpvNetworkError('name resolution failed'), isTrue); + }); + + test('命中 TLS 与网络超时', () { + expect(isMpvNetworkError('TLS handshake failed'), isTrue); + expect(isMpvNetworkError('connection timed out'), isTrue); + }); + + test('不命中非网络 timeout 文本', () { + expect(isMpvNetworkError('decoder timeout waiting for frame'), isFalse); + expect(isMpvNetworkError('cplayer timeout while waiting'), isFalse); + }); + + test('命中 HTTP 错误码', () { + expect(isMpvNetworkError('http error 403'), isTrue); + expect(isMpvNetworkError('http error 502'), isTrue); + }); + + test('不命中解码器错误', () { + expect(isMpvNetworkError('Error while decoding frame'), isFalse); + expect(isMpvNetworkError('Failed to open decoder'), isFalse); + }); + + test('空串不命中', () { + expect(isMpvNetworkError(''), isFalse); + }); + }); +} diff --git a/test/core/infra/player_engine/terminal_stream_error_test.dart b/test/core/infra/player_engine/terminal_stream_error_test.dart new file mode 100644 index 0000000..997e06a --- /dev/null +++ b/test/core/infra/player_engine/terminal_stream_error_test.dart @@ -0,0 +1,41 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:smplayer/core/infra/player_engine/player_engine.dart'; + +void main() { + group('streamOpenErrorHttpStatus', () { + test('从回填的 error 串解析状态码', () { + expect( + streamOpenErrorHttpStatus('Failed to open http://h/v.mp4. (http 403)'), + 403, + ); + expect(streamOpenErrorHttpStatus('boom (http 502)'), 502); + }); + + test('无状态码返回 null', () { + expect(streamOpenErrorHttpStatus('Failed to open http://h/v.mp4.'), null); + expect(streamOpenErrorHttpStatus('connection reset by peer'), null); + expect(streamOpenErrorHttpStatus(''), null); + }); + }); + + group('isTerminalStreamOpenError', () { + test('客户端 4xx 视为确定性失败(跳过重连)', () { + expect(isTerminalStreamOpenError('open failed (http 403)'), isTrue); + expect(isTerminalStreamOpenError('open failed (http 404)'), isTrue); + expect(isTerminalStreamOpenError('open failed (http 410)'), isTrue); + expect(isTerminalStreamOpenError('open failed (http 401)'), isTrue); + }); + + test('瞬时类 408/429 仍允许重连', () { + expect(isTerminalStreamOpenError('open failed (http 408)'), isFalse); + expect(isTerminalStreamOpenError('open failed (http 429)'), isFalse); + }); + + test('5xx 与无状态码不视为确定性失败', () { + expect(isTerminalStreamOpenError('open failed (http 502)'), isFalse); + expect(isTerminalStreamOpenError('open failed (http 503)'), isFalse); + expect(isTerminalStreamOpenError('Failed to open http://h/v.mp4.'), isFalse); + expect(isTerminalStreamOpenError('connection reset by peer'), isFalse); + }); + }); +} diff --git a/test/core/infra/quickjs_widget_runtime_test.dart b/test/core/infra/quickjs_widget_runtime_test.dart new file mode 100644 index 0000000..a5a8353 --- /dev/null +++ b/test/core/infra/quickjs_widget_runtime_test.dart @@ -0,0 +1,662 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:fjs/fjs.dart'; +import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart' + show ExternalLibrary; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:smplayer/core/contracts/error.dart'; +import 'package:smplayer/core/domain/errors.dart'; +import 'package:smplayer/core/infra/script_widget/quickjs_widget_runtime.dart'; +import 'package:smplayer/core/infra/script_widget/safe_script_http_client.dart'; +import 'package:smplayer/core/infra/script_widget/widget_host_bridge.dart'; + +const String _widgetScript = r''' +const WidgetMetadata = { + id: 'runtime-test', + title: 'Runtime Test', + version: '1.0.0', + requiredVersion: '0.0.1', + modules: [] +}; + +async function inspectHost(params) { + const document = Widget.html.load( + '
示例内容
' + ); + const item = document('.item').first(); + return { + value: params.value, + storedValue: Widget.storage.get('seed'), + text: item.text().trim(), + href: item.attr('href') + }; +} + +async function loadDetail(link) { + return { id: 'detail', title: link }; +} +'''; + + +Future _tryInitFjs() async { + final environmentPath = Platform.environment['FJS_LIBRARY_PATH']; + final candidatePaths = [ + if (environmentPath != null && environmentPath.isNotEmpty) environmentPath, + 'build/fjs_native/release/libfjs.dylib', + 'build/fjs_native/release/libfjs.so', + 'build/fjs_native/release/fjs.dll', + ]; + for (final path in candidatePaths) { + if (!File(path).existsSync()) continue; + try { + await LibFjs.init(externalLibrary: ExternalLibrary.open(path)); + return true; + } on StateError { + return true; + } catch (_) {} + } + try { + await LibFjs.init(); + return true; + } on StateError { + return true; + } catch (_) { + return false; + } +} + +Future main() async { + TestWidgetsFlutterBinding.ensureInitialized(); + + final fjsReady = await _tryInitFjs(); + + setUp(() { + SharedPreferences.setMockInitialValues({ + 'smplayer.script_widget.storage.runtime-test': jsonEncode( + {'seed': 'persisted'}, + ), + }); + }); + + group( + 'QuickJsWidgetRuntime', + () { + test('加载元数据并调用宿主 storage 与 HTML API', () async { + final runtime = QuickJsWidgetRuntime(); + addTearDown(runtime.dispose); + + final manifest = await runtime.loadWidget(_widgetScript); + final result = await runtime.invokeModuleFunction( + manifest.id, + 'inspectHost', + {'value': 7}, + ); + + expect(manifest.title, 'Runtime Test'); + expect(result, { + 'value': 7, + 'storedValue': 'persisted', + 'text': '示例内容', + 'href': '/detail', + }); + }); + + test('ForwardWidgets 原始参数调用保持字符串参数', () async { + final runtime = QuickJsWidgetRuntime(); + addTearDown(runtime.dispose); + await runtime.loadWidget(_widgetScript); + + final result = await runtime.invokeModuleFunction( + 'runtime-test', + 'loadDetail', + { + '__forwardRawArgument': 'https://example.com/detail/42', + 'link': 'ignored wrapper value', + }, + ); + + expect(result, { + 'id': 'detail', + 'title': 'https://example.com/detail/42', + }); + }); + + test('卸载模块后拒绝继续调用', () async { + final runtime = QuickJsWidgetRuntime(); + addTearDown(runtime.dispose); + await runtime.loadWidget(_widgetScript); + await runtime.unloadWidget('runtime-test'); + + await expectLater( + runtime.invokeModuleFunction( + 'runtime-test', + 'inspectHost', + {}, + ), + throwsA(isA()), + ); + }); + + test('DOM 助手覆盖 closest、顶层 text 与 console.debug', () async { + final runtime = QuickJsWidgetRuntime(); + addTearDown(runtime.dispose); + await runtime.loadWidget(_domHelpersWidgetScript); + + final result = await runtime.invokeModuleFunction( + 'runtime-dom-test', + 'inspectDomHelpers', + {}, + ); + + expect(result, { + 'honeypotAncestorClass': 'col-lg-8', + 'noAncestorForNormal': true, + 'topLevelText': '你好世界', + 'consoleDebugCallable': true, + }); + }); + + + test('Widget.dom 句柄 API 支持 parse/select/text/attr/html', () async { + final runtime = QuickJsWidgetRuntime(); + addTearDown(runtime.dispose); + await runtime.loadWidget(_domHandleWidgetScript); + + final result = await runtime.invokeModuleFunction( + 'runtime-dom-handle-test', + 'inspectDomHandles', + {}, + ); + + expect(result, { + 'itemText': '示例', + 'itemHref': '/detail', + 'scopedCount': 1, + 'outerScopedCount': 2, + 'docHtmlContainsItem': true, + 'missingAttr': null, + }); + }); + + test( + '模块使用 statusCode 校验 http 响应时可正常拿到抓取的 HTML', + () async { + final httpClient = _StubbedScriptHttpClient( + responseHtmlByUrl: { + 'https://sample.local/list': _sampleFakeListHtml, + }, + ); + final runtime = QuickJsWidgetRuntime( + hostBridge: WidgetHostBridge(httpClient: httpClient), + ); + addTearDown(runtime.dispose); + await runtime.loadWidget(_httpStatusCodeWidgetScript); + + final rawItems = await runtime.invokeModuleFunction( + 'runtime-http-test', + 'fetchItems', + {}, + ); + + expect(rawItems, >[ + { + 'title': '正常条目', + 'link': 'https://sample.local/normal', + }, + ]); + }, + ); + + test('模块超时后运行环境被卸载,宿主 Future 迟到完成不会出错', () async { + final httpClient = _StubbedScriptHttpClient( + responseHtmlByUrl: { + 'https://sample.local/list': _sampleFakeListHtml, + }, + responseDelay: const Duration(milliseconds: 150), + ); + final runtime = QuickJsWidgetRuntime( + hostBridge: WidgetHostBridge(httpClient: httpClient), + invocationTimeout: const Duration(milliseconds: 50), + ); + addTearDown(runtime.dispose); + await runtime.loadWidget(_httpStatusCodeWidgetScript); + + await expectLater( + runtime.invokeModuleFunction( + 'runtime-http-test', + 'fetchItems', + {}, + ), + throwsA( + isA().having( + (error) => error.code, + 'code', + ErrorCode.serverTimeout, + ), + ), + ); + + + await Future.delayed(const Duration(milliseconds: 250)); + expect(runtime.isWidgetLoaded('runtime-http-test'), isFalse); + }); + + test('async 函数返回 List 时得到规范化后的 Dart 集合', () async { + final runtime = QuickJsWidgetRuntime(); + addTearDown(runtime.dispose); + await runtime.loadWidget(_asyncReturnShapesWidgetScript); + + final result = await runtime.invokeModuleFunction( + 'runtime-async-shapes-test', + 'returnList', + {}, + ); + + expect(result, isA>()); + expect(result, >[ + {'title': '正常', 'link': '/a'}, + {'title': '也是正常', 'link': '/b'}, + ]); + }); + + test('async 函数显式返回 null 时上层拿到 Dart null', () async { + final runtime = QuickJsWidgetRuntime(); + addTearDown(runtime.dispose); + await runtime.loadWidget(_asyncReturnShapesWidgetScript); + + final result = await runtime.invokeModuleFunction( + 'runtime-async-shapes-test', + 'returnNull', + {}, + ); + + expect(result, isNull); + }); + + test('async 函数抛异常时映射为 DomainError.internalError', () async { + final runtime = QuickJsWidgetRuntime(); + addTearDown(runtime.dispose); + await runtime.loadWidget(_asyncReturnShapesWidgetScript); + + await expectLater( + runtime.invokeModuleFunction( + 'runtime-async-shapes-test', + 'throwError', + {}, + ), + throwsA( + isA().having( + (error) => error.code, + 'code', + ErrorCode.internalError, + ), + ), + ); + }); + + + test('脚本尾部使用 CommonJS 导出时仍能安装并调用', () async { + final runtime = QuickJsWidgetRuntime(); + addTearDown(runtime.dispose); + + final manifest = await runtime.loadWidget(_commonJsExportsWidgetScript); + expect(manifest.id, 'runtime-commonjs-test'); + expect(manifest.modules, hasLength(1)); + + final result = await runtime.invokeModuleFunction( + manifest.id, + 'reportEnvironment', + {}, + ); + expect(result, { + 'hasModule': true, + 'hasExports': true, + 'hasGlobal': true, + 'hasSelf': true, + 'exportedTitle': 'CommonJS Test', + }); + }); + + + test('Widget.storage 支持 Web Storage 风格的 getItem/setItem 别名', () async { + final runtime = QuickJsWidgetRuntime(); + addTearDown(runtime.dispose); + await runtime.loadWidget(_storageAliasWidgetScript); + + final result = await runtime.invokeModuleFunction( + 'runtime-storage-alias-test', + 'roundTrip', + {}, + ); + expect(result, { + 'writtenValue': 'session-token-42', + 'readBackValue': 'session-token-42', + 'valueAfterRemoval': null, + }); + }); + + + test( + '死循环脚本在 invocationTimeout 后被终止并卸载', + () async { + final runtime = QuickJsWidgetRuntime( + invocationTimeout: const Duration(seconds: 2), + ); + addTearDown(runtime.dispose); + await runtime.loadWidget(_infiniteLoopWidgetScript); + + await expectLater( + runtime.invokeModuleFunction( + 'runtime-watchdog-test', + 'spinForever', + {}, + ), + throwsA( + isA().having( + (error) => error.code, + 'code', + ErrorCode.serverTimeout, + ), + ), + ); + expect(runtime.isWidgetLoaded('runtime-watchdog-test'), isFalse); + }, + timeout: const Timeout(Duration(seconds: 15)), + ); + }, + skip: fjsReady + ? false + : '未找到 fjs 原生库(libfjs)。构建方式见本文件 _tryInitFjs 注释。', + ); + + group('normalizeQuickJsValue', () { + test( + 'Map 与 List 递归规范化为 Map', + () { + final normalized = normalizeQuickJsValue({ + 'items': [ + {'id': 'foo', 'value': 1}, + {'id': 'bar', 'value': 2}, + ], + 'total': 2, + }); + + expect(normalized, isA>()); + final map = normalized! as Map; + expect(map['total'], 2); + final items = (map['items']! as List) + .cast>(); + expect(items, hasLength(2)); + expect(items.first, {'id': 'foo', 'value': 1}); + }, + ); + + test('原始类型与 null 保持不变', () { + expect(normalizeQuickJsValue(null), isNull); + expect(normalizeQuickJsValue('hello'), 'hello'); + expect(normalizeQuickJsValue(42), 42); + expect(normalizeQuickJsValue(true), true); + }); + + test('非字符串键会被转为字符串键,避免下游 Map 类型转换失败', () { + final normalized = normalizeQuickJsValue({ + 1: 'one', + 2: 'two', + }); + + expect(normalized, {'1': 'one', '2': 'two'}); + }); + }); +} + +const String _domHelpersWidgetScript = r''' +const WidgetMetadata = { + id: 'runtime-dom-test', + title: 'DOM Helpers Test', + version: '1.0.0', + requiredVersion: '0.0.1', + modules: [] +}; + +async function inspectDomHelpers() { + const document = Widget.html.load( + '
' + + '
' + + ' 正常' + + '
' + + '
' + + '
' + + ' 蜜罐' + + '
' + + '
' + + '
' + ); + const normalTitle = document('.videos-text-align').eq(0).find('.title'); + const honeypotTitle = document('.videos-text-align').eq(1).find('.title'); + + const honeypotAncestor = honeypotTitle.closest('.col-lg-8'); + const normalAncestor = normalTitle.closest('.col-lg-8'); + + const paragraphDocument = Widget.html.load('

你好世界

'); + const topLevelText = paragraphDocument.text().trim(); + + var consoleDebugCallable = false; + try { + console.debug('探针'); + consoleDebugCallable = true; + } catch (error) {} + + return { + honeypotAncestorClass: honeypotAncestor.attr('class'), + noAncestorForNormal: normalAncestor.length === 0, + topLevelText: topLevelText, + consoleDebugCallable: consoleDebugCallable + }; +} +'''; + +const String _domHandleWidgetScript = r''' +const WidgetMetadata = { + id: 'runtime-dom-handle-test', + title: 'DOM Handle Test', + version: '1.0.0', + requiredVersion: '0.0.1', + modules: [] +}; + +async function inspectDomHandles() { + const docHandle = Widget.dom.parse( + '
' + + '' + + '其他' + + '
' + ); + const wrapHandles = Widget.dom.select(docHandle, '.wrap'); + const scopedItems = Widget.dom.select(wrapHandles[0], '.item'); + const allItems = Widget.dom.select(docHandle, '.item'); + return { + itemText: Widget.dom.text(scopedItems[0]), + itemHref: Widget.dom.attr(scopedItems[0], 'href'), + scopedCount: scopedItems.length, + outerScopedCount: allItems.length, + docHtmlContainsItem: Widget.dom.html(docHandle).indexOf('class="item"') >= 0, + missingAttr: Widget.dom.attr(scopedItems[0], 'data-missing') + }; +} +'''; + +const String _asyncReturnShapesWidgetScript = r''' +const WidgetMetadata = { + id: 'runtime-async-shapes-test', + title: 'Async Return Shapes Test', + version: '1.0.0', + requiredVersion: '0.0.1', + modules: [] +}; + +async function returnList() { + return [ + { title: '正常', link: '/a' }, + { title: '也是正常', link: '/b' } + ]; +} + +async function returnNull() { + return null; +} + +async function throwError() { + throw new Error('模块脚本内的显式错误'); +} +'''; + +const String _commonJsExportsWidgetScript = r''' +const WidgetMetadata = { + id: 'runtime-commonjs-test', + title: 'CommonJS Test', + version: '1.0.0', + requiredVersion: '0.0.1', + modules: [ + { + title: '环境探测', + functionName: 'reportEnvironment', + params: [] + } + ] +}; + +async function reportEnvironment() { + return { + hasModule: typeof module === 'object' && module !== null, + hasExports: typeof exports === 'object' && exports !== null, + hasGlobal: typeof global === 'object' && global !== null, + hasSelf: typeof self === 'object' && self !== null, + exportedTitle: module.exports.metadata ? module.exports.metadata.title : '' + }; +} + +module.exports = { + metadata: WidgetMetadata, + reportEnvironment: reportEnvironment +}; +'''; + +const String _storageAliasWidgetScript = r''' +const WidgetMetadata = { + id: 'runtime-storage-alias-test', + title: 'Storage Alias Test', + version: '1.0.0', + requiredVersion: '0.0.1', + modules: [] +}; + +async function roundTrip() { + Widget.storage.setItem('token', 'session-token-42'); + const written = Widget.storage.getItem('token'); + Widget.storage.removeItem('token'); + return { + writtenValue: 'session-token-42', + readBackValue: written, + valueAfterRemoval: Widget.storage.getItem('token') || null + }; +} +'''; + +const String _infiniteLoopWidgetScript = r''' +const WidgetMetadata = { + id: 'runtime-watchdog-test', + title: 'Watchdog Test', + version: '1.0.0', + requiredVersion: '0.0.1', + modules: [] +}; + +async function spinForever() { + var counter = 0; + while (true) { + counter = (counter + 1) % 1000003; + if (counter < 0) break; + } + return counter; +} +'''; + +const String _httpStatusCodeWidgetScript = r''' +const WidgetMetadata = { + id: 'runtime-http-test', + title: 'HTTP StatusCode Test', + version: '1.0.0', + requiredVersion: '0.0.1', + modules: [] +}; + +async function fetchItems() { + const response = await Widget.http.get('https://sample.local/list'); + if (!response || response.statusCode !== 200) { + throw new Error('意料之外的响应:' + (response && response.statusCode)); + } + const document = Widget.html.load(response.data); + return Array.from(document('.videos-text-align')).map(function(element) { + const wrapper = document(element); + const link = wrapper.find('a').attr('href'); + if (!link) return null; + if (wrapper.closest('.col-lg-8').length > 0) return null; + return { + title: wrapper.find('.title').text().trim(), + link: link + }; + }).filter(function(item) { return item != null; }); +} +'''; + +const String _sampleFakeListHtml = + '
' + '
' + '' + '正常条目' + '
' + '
' + '
' + '' + '蜜罐' + '
' + '
' + '
'; + +class _StubbedScriptHttpClient extends SafeScriptHttpClient { + final Map responseHtmlByUrl; + final Duration responseDelay; + + _StubbedScriptHttpClient({ + required this.responseHtmlByUrl, + this.responseDelay = Duration.zero, + }); + + @override + Future request({ + required Uri uri, + String method = 'GET', + Map headers = const {}, + Map queryParameters = const {}, + Object? body, + bool followRedirects = true, + }) async { + if (responseDelay > Duration.zero) { + await Future.delayed(responseDelay); + } + final resolvedUrl = uri.toString(); + final responseHtml = responseHtmlByUrl[resolvedUrl]; + if (responseHtml == null) { + throw StateError('未注册 HTTP mock:$resolvedUrl'); + } + return SafeScriptHttpResponse( + data: responseHtml, + text: responseHtml, + statusCode: 200, + reasonPhrase: 'OK', + headers: const {'content-type': 'text/html'}, + ); + } +} diff --git a/test/core/infra/safe_script_http_client_test.dart b/test/core/infra/safe_script_http_client_test.dart new file mode 100644 index 0000000..6659616 --- /dev/null +++ b/test/core/infra/safe_script_http_client_test.dart @@ -0,0 +1,218 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:smplayer/core/infra/script_widget/safe_script_http_client.dart'; + +void main() { + group('脚本 HTTP 地址校验', () { + test('允许公网 IPv4 与 IPv6 地址', () { + expect(isPublicScriptAddress(InternetAddress('8.8.8.8')), isTrue); + expect( + isPublicScriptAddress(InternetAddress('2606:4700:4700::1111')), + isTrue, + ); + }); + + test('拒绝真实威胁的 IPv4 地址(本机、LAN、云元数据、多播)', () { + const blockedAddresses = [ + '0.0.0.0', + '10.0.0.1', + '127.0.0.1', + '169.254.169.254', + '172.16.0.1', + '192.0.0.1', + '192.168.1.1', + '224.0.0.1', + '255.255.255.255', + ]; + + for (final address in blockedAddresses) { + expect( + isPublicScriptAddress(InternetAddress(address)), + isFalse, + reason: address, + ); + } + }); + + test( + '允许 CGNAT、文档、benchmark 段(代理 fake-ip 池会用到)', + () { + const allowedAddresses = [ + '100.64.0.1', + '192.0.2.1', + '198.18.0.31', + '198.51.100.1', + '203.0.113.1', + ]; + + for (final address in allowedAddresses) { + expect( + isPublicScriptAddress(InternetAddress(address)), + isTrue, + reason: address, + ); + } + }, + ); + + test('拒绝 IPv6 回环、ULA、链路本地和 IPv4 映射回环', () { + const blockedAddresses = [ + '::', + '::1', + '::ffff:127.0.0.1', + 'fc00::1', + 'fd12:3456::1', + 'fe80::1', + ]; + + for (final address in blockedAddresses) { + expect( + isPublicScriptAddress(InternetAddress(address)), + isFalse, + reason: address, + ); + } + }); + + test('允许 IPv6 文档段与 6to4 段(同样常见于 fake-ip 池)', () { + const allowedAddresses = [ + '2001:db8::1', + '2002::1', + '3fff::1', + ]; + + for (final address in allowedAddresses) { + expect( + isPublicScriptAddress(InternetAddress(address)), + isTrue, + reason: address, + ); + } + }); + }); + + group('过滤可用地址', () { + test('保留 DNS 里能用的公网地址,丢弃真正的保留段地址', () { + final filtered = filterPublicScriptAddresses( + host: 'example.com', + addresses: [ + InternetAddress('fe80::1'), + InternetAddress('8.8.8.8'), + InternetAddress('10.0.0.5'), + ], + ); + + expect( + filtered.map((address) => address.address).toList(), + ['8.8.8.8'], + ); + }); + + test('全部为保留段地址时抛错并在消息里列出被拒地址', () { + expect( + () => filterPublicScriptAddresses( + host: 'example.internal', + addresses: [ + InternetAddress('10.0.0.1'), + InternetAddress('192.168.1.1'), + ], + ), + throwsA( + isA().having( + (exception) => exception.message, + 'message', + allOf( + contains('example.internal'), + contains('10.0.0.1'), + contains('192.168.1.1'), + ), + ), + ), + ); + }); + + test('地址列表为空时抛出 host 未解析错误', () { + expect( + () => filterPublicScriptAddresses( + host: 'unresolved.example', + addresses: const [], + ), + throwsA( + isA().having( + (exception) => exception.message, + 'message', + contains('unresolved.example'), + ), + ), + ); + }); + }); + + group('脚本 HTTP URI 校验', () { + test('仅允许无用户信息的绝对 HTTP 与 HTTPS URI', () { + expect( + isValidScriptHttpUri(Uri.parse('https://example.com/video')), + isTrue, + ); + expect( + isValidScriptHttpUri(Uri.parse('http://example.com/video')), + isTrue, + ); + expect( + isValidScriptHttpUri(Uri.parse('https://user@example.com/video')), + isFalse, + ); + expect(isValidScriptHttpUri(Uri.parse('file:///private/video')), isFalse); + expect(isValidScriptHttpUri(Uri.parse('/relative/video')), isFalse); + }); + + test('拒绝非白名单 HTTP 方法且不会发起网络解析', () async { + final client = SafeScriptHttpClient(); + + await expectLater( + client.request(uri: Uri.parse('https://example.com'), method: 'TRACE'), + throwsA(isA()), + ); + }); + }); + + group('脚本 HTTP 响应上限', () { + test('接受不超过上限的分块响应', () async { + final responseBytes = await collectScriptResponseBytes( + Stream>.fromIterable(>[ + [1, 2], + [3, 4], + ]), + declaredLength: 4, + maximumBytes: 4, + ); + + expect(responseBytes, [1, 2, 3, 4]); + }); + + test('在声明长度超过上限时立即拒绝', () async { + await expectLater( + collectScriptResponseBytes( + const Stream>.empty(), + declaredLength: 5, + maximumBytes: 4, + ), + throwsA(isA()), + ); + }); + + test('在实际流量超过上限时中止读取', () async { + await expectLater( + collectScriptResponseBytes( + Stream>.fromIterable(>[ + [1, 2, 3], + [4, 5], + ]), + maximumBytes: 4, + ), + throwsA(isA()), + ); + }); + }); +} diff --git a/test/core/infra/script_widget_danmaku_gateway_test.dart b/test/core/infra/script_widget_danmaku_gateway_test.dart new file mode 100644 index 0000000..36ffe65 --- /dev/null +++ b/test/core/infra/script_widget_danmaku_gateway_test.dart @@ -0,0 +1,155 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:smplayer/core/contracts/script_widget.dart'; +import 'package:smplayer/core/domain/ports/script_widget_remote_gateway.dart'; +import 'package:smplayer/core/domain/ports/script_widget_repository.dart'; +import 'package:smplayer/core/domain/ports/script_widget_runtime.dart'; +import 'package:smplayer/core/domain/usecases/script_widget/script_widget_service.dart'; +import 'package:smplayer/core/infra/danmaku_api/script_widget_danmaku_gateway.dart'; + +class _SingleWidgetRepository implements ScriptWidgetRepository { + _SingleWidgetRepository(this.widget); + + final InstalledScriptWidget widget; + + @override + Future findWidget(String widgetId) async { + return widget.manifest.id == widgetId ? widget : null; + } + + @override + Future> listWidgets() async { + return [widget]; + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _DanmakuScriptRuntime implements ScriptWidgetRuntime { + String? lastFunctionName; + Map? lastParams; + bool loaded = false; + + @override + Future dispose() async {} + + @override + Future invokeModuleFunction( + String widgetId, + String functionName, + Map params, + ) async { + lastFunctionName = functionName; + lastParams = Map.from(params); + return switch (functionName) { + 'searchDanmu' => { + 'animes': [ + {'animeId': 42, 'animeTitle': '示例剧集'}, + ], + }, + 'getDetail' => { + 'episodes': [ + { + 'episodeId': 101, + 'episodeTitle': '第 1 集', + 'episodeNumber': '1', + }, + { + 'episodeId': 102, + 'episodeTitle': '第 2 集', + 'episodeNumber': '2', + }, + ], + }, + 'getComments' => { + 'comments': [ + {'cid': 9, 'p': '1.5,1,16711680', 'm': '测试弹幕'}, + ], + }, + _ => null, + }; + } + + @override + Future inspectWidget(String scriptSource) async { + return const ScriptWidgetManifest(id: 'danmaku-test', title: 'Danmaku'); + } + + @override + bool isWidgetLoaded(String widgetId) => loaded && widgetId == 'danmaku-test'; + + @override + Future loadWidget(String scriptSource) async { + loaded = true; + return const ScriptWidgetManifest(id: 'danmaku-test', title: 'Danmaku'); + } + + @override + Future unloadWidget(String widgetId) async { + if (widgetId == 'danmaku-test') loaded = false; + } +} + +class _UnusedRemoteGateway implements ScriptWidgetRemoteGateway { + @override + Future fetchText(String url) { + throw UnsupportedError('This test does not perform remote requests.'); + } +} + +void main() { + late _DanmakuScriptRuntime runtime; + late ScriptWidgetDanmakuGateway gateway; + + setUp(() { + runtime = _DanmakuScriptRuntime(); + final timestamp = DateTime.utc(2026, 7, 12); + final service = ScriptWidgetService( + repository: _SingleWidgetRepository( + InstalledScriptWidget( + manifest: const ScriptWidgetManifest( + id: 'danmaku-test', + title: 'Danmaku', + ), + scriptSource: 'widget source', + installedAt: timestamp, + updatedAt: timestamp, + ), + ), + remoteGateway: _UnusedRemoteGateway(), + runtime: runtime, + ); + gateway = ScriptWidgetDanmakuGateway(loadService: () async => service); + }); + + test('文件名中的季集信息映射到 searchDanmu 并筛选目标分集', () async { + final matches = await gateway.match( + 'jsw://danmaku-test', + '示例剧集 S02E02', + 1800, + ); + + expect(matches, hasLength(1)); + expect(matches.single.episodeId, 102); + expect(matches.single.animeId, 42); + expect(matches.single.animeTitle, '示例剧集'); + }); + + test('getComments 响应映射为标准弹幕评论', () async { + final comments = await gateway.fetchComments( + 'jsw://danmaku-test', + 102, + chConvert: 1, + ); + + expect(runtime.lastFunctionName, 'getComments'); + expect(runtime.lastParams, { + 'commentId': 102, + 'chConvert': 1, + }); + expect(comments.single.cid, 9); + expect(comments.single.time, 1.5); + expect(comments.single.text, '测试弹幕'); + expect(comments.single.color.toARGB32(), 0xFFFF0000); + }); +} diff --git a/test/core/infra/storage/json_script_widget_repository_test.dart b/test/core/infra/storage/json_script_widget_repository_test.dart new file mode 100644 index 0000000..19cd151 --- /dev/null +++ b/test/core/infra/storage/json_script_widget_repository_test.dart @@ -0,0 +1,85 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:smplayer/core/contracts/script_widget.dart'; +import 'package:smplayer/core/infra/storage/json_script_widget_repository.dart'; + +void main() { + test('模块与订阅可以跨仓储实例持久化并按标题排序', () async { + final temporaryDirectory = await Directory.systemTemp.createTemp( + 'smplayer-script-widget-repository-', + ); + addTearDown(() => temporaryDirectory.delete(recursive: true)); + final repositoryFile = File( + '${temporaryDirectory.path}${Platform.pathSeparator}widgets.json', + ); + final repository = JsonScriptWidgetRepository(file: repositoryFile); + final timestamp = DateTime.utc(2026, 7, 12); + + await repository.upsertWidget( + _installedWidget(id: 'zeta', title: '最后', timestamp: timestamp), + ); + await repository.upsertWidget( + _installedWidget(id: 'alpha', title: '开头', timestamp: timestamp), + ); + await repository.upsertSubscription( + ScriptWidgetSubscription( + url: 'https://example.com/catalog.fwd', + catalog: const ScriptWidgetCatalog( + title: '示例订阅', + widgets: [ + ScriptWidgetCatalogEntry( + id: 'alpha', + title: '开头', + url: 'https://example.com/alpha.js', + ), + ], + ), + refreshedAt: timestamp, + ), + ); + + final reloadedRepository = JsonScriptWidgetRepository(file: repositoryFile); + final widgets = await reloadedRepository.listWidgets(); + final subscriptions = await reloadedRepository.listSubscriptions(); + + expect(widgets.map((widget) => widget.manifest.id), [ + 'alpha', + 'zeta', + ]); + expect(subscriptions.single.catalog.title, '示例订阅'); + expect( + subscriptions.single.catalog.widgets.single.url, + 'https://example.com/alpha.js', + ); + }); + + test('损坏的仓储文件安全降级为空状态', () async { + final temporaryDirectory = await Directory.systemTemp.createTemp( + 'smplayer-script-widget-corrupt-', + ); + addTearDown(() => temporaryDirectory.delete(recursive: true)); + final repositoryFile = File( + '${temporaryDirectory.path}${Platform.pathSeparator}widgets.json', + ); + await repositoryFile.writeAsString('{not valid json'); + + final repository = JsonScriptWidgetRepository(file: repositoryFile); + + expect(await repository.listWidgets(), isEmpty); + expect(await repository.listSubscriptions(), isEmpty); + }); +} + +InstalledScriptWidget _installedWidget({ + required String id, + required String title, + required DateTime timestamp, +}) { + return InstalledScriptWidget( + manifest: ScriptWidgetManifest(id: id, title: title), + scriptSource: 'const WidgetMetadata = { id: "$id", title: "$title" };', + installedAt: timestamp, + updatedAt: timestamp, + ); +} diff --git a/test/core/infra/storage/search_history_store_test.dart b/test/core/infra/storage/search_history_store_test.dart new file mode 100644 index 0000000..45b3187 --- /dev/null +++ b/test/core/infra/storage/search_history_store_test.dart @@ -0,0 +1,18 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:smplayer/core/infra/storage/search_history_store.dart'; + +void main() { + test('旧 JSON 搜索历史读取后迁移为字符串列表', () async { + SharedPreferences.setMockInitialValues({ + 'smplayer.search_history': jsonEncode(['电影', '剧集']), + }); + + expect(await SearchHistoryStore().list(), ['电影', '剧集']); + + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getStringList('smplayer.search_history'), ['电影', '剧集']); + }); +} diff --git a/test/core/infra/widget_host_bridge_test.dart b/test/core/infra/widget_host_bridge_test.dart new file mode 100644 index 0000000..e6fb1d1 --- /dev/null +++ b/test/core/infra/widget_host_bridge_test.dart @@ -0,0 +1,86 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:smplayer/core/infra/script_widget/widget_host_bridge.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('sharedCache 按模块隔离且不读取旧全局缓存', () async { + SharedPreferences.setMockInitialValues({ + 'smplayer.script_widget.shared_cache.widget-a': jsonEncode( + {'owner': 'widget-a', 'value': 1}, + ), + 'smplayer.script_widget.shared_cache.widget-b': jsonEncode( + {'owner': 'widget-b', 'value': 2}, + ), + 'smplayer.script_widget.shared_cache': jsonEncode({ + 'owner': 'legacy-global', + }), + }); + final bridge = WidgetHostBridge(); + + final widgetAData = await bridge.loadBootstrapData('widget-a'); + final widgetBData = await bridge.loadBootstrapData('widget-b'); + final widgetCData = await bridge.loadBootstrapData('widget-c'); + + expect(widgetAData.sharedCache, { + 'owner': 'widget-a', + 'value': 1, + }); + expect(widgetBData.sharedCache, { + 'owner': 'widget-b', + 'value': 2, + }); + expect(widgetCData.sharedCache, isEmpty); + }); + + test('损坏的模块缓存不会污染其他模块的启动数据', () async { + SharedPreferences.setMockInitialValues({ + 'smplayer.script_widget.shared_cache.invalid-widget': '{invalid', + 'smplayer.script_widget.shared_cache.valid-widget': jsonEncode( + {'ready': true}, + ), + }); + final bridge = WidgetHostBridge(); + + final invalidWidgetData = await bridge.loadBootstrapData('invalid-widget'); + final validWidgetData = await bridge.loadBootstrapData('valid-widget'); + + expect(invalidWidgetData.sharedCache, isEmpty); + expect(validWidgetData.sharedCache, {'ready': true}); + }); + + group('formatConsoleValue', () { + test('展开 JS Error marker 为 name/message/stack 拼接', () { + final formatted = formatConsoleValue({ + '__consoleErrorMarker': true, + 'name': 'TypeError', + 'message': '视频列表加载失败:网络超时', + 'stack': 'at getList (91porn_int.js:848)', + }); + + expect( + formatted, + 'TypeError: 视频列表加载失败:网络超时\nat getList (91porn_int.js:848)', + ); + }); + + test('普通 Map 走 JSON 编码而不是 toString()', () { + final formatted = formatConsoleValue({ + 'status': 403, + 'reason': 'blocked', + }); + + expect(formatted, '{"status":403,"reason":"blocked"}'); + }); + + test('字符串和基本类型保持原样', () { + expect(formatConsoleValue('hello'), 'hello'); + expect(formatConsoleValue(42), '42'); + expect(formatConsoleValue(true), 'true'); + expect(formatConsoleValue(null), 'null'); + }); + }); +} diff --git a/test/core/media/external_subtitle_loader_test.dart b/test/core/media/external_subtitle_loader_test.dart new file mode 100644 index 0000000..f1beacb --- /dev/null +++ b/test/core/media/external_subtitle_loader_test.dart @@ -0,0 +1,204 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:smplayer/core/media/embedded_subtitle_cues.dart'; +import 'package:smplayer/core/media/external_subtitle_loader.dart'; + +void main() { + group('ExternalSubtitleLoader.parseSubtitleContent — SRT', () { + const srt = ''' +1 +00:00:01,000 --> 00:00:02,500 +第一句 +第二行 + +2 +00:00:04,000 --> 00:00:06,000 +Second cue +'''; + + test('解析编号块、时间轴与多行文本,剥 HTML 标签', () { + final cues = ExternalSubtitleLoader.parseSubtitleContent(srt); + expect(cues, hasLength(2)); + expect(cues[0].start, const Duration(seconds: 1)); + expect(cues[0].end, const Duration(milliseconds: 2500)); + expect(cues[0].text, '第一句\n第二行'); + expect(cues[1].text, 'Second cue'); + }); + + test('容忍 CRLF 换行与点号毫秒分隔', () { + const crlfSrt = '1\r\n00:00:01.000 --> 00:00:02.000\r\nhello\r\n\r\n'; + final cues = ExternalSubtitleLoader.parseSubtitleContent(crlfSrt); + expect(cues, hasLength(1)); + expect(cues.single.text, 'hello'); + }); + + test('无时间轴的垃圾块被跳过', () { + const noisy = ''' +garbage block + +1 +00:00:01,000 --> 00:00:02,000 +ok +'''; + final cues = ExternalSubtitleLoader.parseSubtitleContent(noisy); + expect(cues, hasLength(1)); + expect(cues.single.text, 'ok'); + }); + + test('end < start 的非法 cue 被丢弃', () { + const invalid = ''' +1 +00:00:05,000 --> 00:00:02,000 +bad + +2 +00:00:06,000 --> 00:00:07,000 +good +'''; + final cues = ExternalSubtitleLoader.parseSubtitleContent(invalid); + expect(cues, hasLength(1)); + expect(cues.single.text, 'good'); + }); + }); + + group('ExternalSubtitleLoader.parseSubtitleContent — VTT', () { + const vtt = ''' +WEBVTT + +NOTE this is a comment + +cue-1 +00:01.000 --> 00:02.000 +Hello world + +00:00:03.000 --> 00:00:04.500 +第二条 +'''; + + test('识别 WEBVTT 头,支持省略小时位与 cue 标识行', () { + final cues = ExternalSubtitleLoader.parseSubtitleContent(vtt); + expect(cues, hasLength(2)); + expect(cues[0].start, const Duration(seconds: 1)); + expect(cues[0].text, 'Hello world'); + expect(cues[1].start, const Duration(seconds: 3)); + expect(cues[1].end, const Duration(milliseconds: 4500)); + expect(cues[1].text, '第二条'); + }); + }); + + group('ExternalSubtitleLoader.parseSubtitleContent — ASS 降级纯文本', () { + const ass = ''' +[Script Info] +Title: Test + +[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +Dialogue: 0,0:00:01.00,0:00:02.50,Default,,0,0,0,,{\\an8\\fs20}你好,世界\\N第二行 +Dialogue: 0,0:00:04.00,0:00:05.00,Sign,,0,0,0,,{\\pos(1,2)}m 0 0 l 100 0 +Dialogue: 0,0:00:06.00,0:00:07.00,Default,,0,0,0,,plain text +'''; + + test('剥样式覆盖标签、\\N 转行、Text 内逗号保留', () { + final cues = ExternalSubtitleLoader.parseSubtitleContent(ass); + final first = cues.first; + expect(first.start, const Duration(seconds: 1)); + expect(first.end, const Duration(milliseconds: 2500)); + expect(first.text, '你好,世界\n第二行'); + expect(first.text, isNot(contains('an8'))); + expect(cues.last.text, 'plain text'); + }); + + test('自定义 Format 字段顺序按声明解析', () { + const customFormat = ''' +[Events] +Format: Start, End, Text +Dialogue: 0:00:01.00,0:00:02.00,custom order +'''; + final cues = ExternalSubtitleLoader.parseSubtitleContent(customFormat); + expect(cues, hasLength(1)); + expect(cues.single.start, const Duration(seconds: 1)); + expect(cues.single.text, 'custom order'); + }); + + test('非法时间戳的 Dialogue 行被跳过', () { + const invalidTime = ''' +[Events] +Dialogue: 0,bad,0:00:02.00,Default,,0,0,0,,skip me +Dialogue: 0,0:00:03.00,0:00:04.00,Default,,0,0,0,,keep me +'''; + final cues = ExternalSubtitleLoader.parseSubtitleContent(invalidTime); + expect(cues, hasLength(1)); + expect(cues.single.text, 'keep me'); + }); + }); + + group('parseSubtitleContent — 容错', () { + test('空内容/纯垃圾返回空列表而非抛异常', () { + expect(ExternalSubtitleLoader.parseSubtitleContent(''), isEmpty); + expect( + ExternalSubtitleLoader.parseSubtitleContent('random garbage\nfoo bar'), + isEmpty, + ); + }); + }); + + group('SubtitleCueSource.textAt', () { + SubtitleCueSource sourceFromSrt() => SubtitleCueSource( + ExternalSubtitleLoader.parseSubtitleContent(''' +1 +00:00:01,000 --> 00:00:03,000 +first + +2 +00:00:02,000 --> 00:00:04,000 +overlap + +3 +00:00:10,000 --> 00:00:12,000 +later +'''), + ); + + test('位置命中单条 cue', () { + final source = sourceFromSrt(); + expect(source.textAt(const Duration(milliseconds: 1500)), ['first']); + expect(source.textAt(const Duration(seconds: 11)), ['later']); + }); + + test('重叠 cue 全部返回、按 start 顺序', () { + final source = sourceFromSrt(); + expect(source.textAt(const Duration(milliseconds: 2500)), [ + 'first', + 'overlap', + ]); + }); + + test('二分跳过历史 cue 时仍保留跨越多个短 cue 的长字幕', () { + final source = SubtitleCueSource(const [ + TimedSubtitle( + start: Duration(seconds: 1), + end: Duration(seconds: 20), + text: 'long', + ), + TimedSubtitle( + start: Duration(seconds: 2), + end: Duration(seconds: 3), + text: 'expired', + ), + TimedSubtitle( + start: Duration(seconds: 10), + end: Duration(seconds: 12), + text: 'current', + ), + ]); + + expect(source.textAt(const Duration(seconds: 11)), ['long', 'current']); + }); + + test('无命中返回空列表', () { + final source = sourceFromSrt(); + expect(source.textAt(Duration.zero), isEmpty); + expect(source.textAt(const Duration(seconds: 5)), isEmpty); + expect(source.textAt(const Duration(seconds: 20)), isEmpty); + }); + }); +} diff --git a/test/features/aggregated/aggregated_grouping_test.dart b/test/features/aggregated/aggregated_grouping_test.dart new file mode 100644 index 0000000..fd5a6ec --- /dev/null +++ b/test/features/aggregated/aggregated_grouping_test.dart @@ -0,0 +1,319 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:smplayer/core/contracts/library.dart'; +import 'package:smplayer/features/aggregated/aggregated_grouping.dart'; + +GlobalSearchServerResult _server( + String id, + String name, + List items, +) => GlobalSearchServerResult( + serverId: id, + serverName: name, + serverUrl: 'http://$id', + token: 'tok-$id', + userId: 'user-$id', + items: items, +); + +EmbyRawItem _item( + String id, + String name, { + String? type, + Map? providerIds, + String? lastPlayedDate, + int? playbackPositionTicks, + int? runtimeTicks, + bool? played, +}) => EmbyRawItem( + Id: id, + Name: name, + Type: type, + RunTimeTicks: runtimeTicks, + UserData: playbackPositionTicks != null || played != null + ? EmbyRawUserData( + PlaybackPositionTicks: playbackPositionTicks, + Played: played, + ) + : null, + extra: { + if (providerIds != null) 'ProviderIds': providerIds, + if (lastPlayedDate != null) 'UserData': {'LastPlayedDate': lastPlayedDate}, + }, +); + +void main() { + group('flatten – default behavior (dedup off)', () { + test('returns all items from all servers', () { + final results = [ + _server('s1', 'S1', [_item('a', 'Movie A')]), + _server('s2', 'S2', [_item('b', 'Movie B')]), + ]; + final entries = flatten(results); + expect(entries.length, 2); + }); + + test('respects serverFilter', () { + final results = [ + _server('s1', 'S1', [_item('a', 'A')]), + _server('s2', 'S2', [_item('b', 'B')]), + ]; + final entries = flatten(results, serverFilter: 's1'); + expect(entries.length, 1); + expect(entries.first.source.serverId, 's1'); + }); + }); + + group('flatten – dedup on, serverFilter null', () { + test('collapses same TMDB Movie to newest LastPlayedDate', () { + final results = [ + _server('s1', 'S1', [ + _item( + 'a', + 'Movie', + type: 'Movie', + providerIds: {'Tmdb': '100'}, + lastPlayedDate: '2026-06-20T10:00:00Z', + ), + ]), + _server('s2', 'S2', [ + _item( + 'b', + 'Movie', + type: 'Movie', + providerIds: {'Tmdb': '100'}, + lastPlayedDate: '2026-06-24T15:00:00Z', + ), + ]), + ]; + final entries = flatten(results, deduplicateAcrossServers: true); + expect(entries.length, 1); + expect(entries.first.source.serverId, 's2'); + expect(entries.first.sources.map((source) => source.source.serverId), [ + 's2', + 's1', + ]); + }); + + test('preserves each server item and its independent playback state', () { + final results = [ + _server('s1', 'S1', [ + _item( + 'a', + 'Movie', + type: 'Movie', + providerIds: {'Tmdb': '100'}, + lastPlayedDate: '2026-06-20T10:00:00Z', + playbackPositionTicks: 100, + runtimeTicks: 1000, + ), + ]), + _server('s2', 'S2', [ + _item( + 'b', + 'Movie', + type: 'Movie', + providerIds: {'Tmdb': '100'}, + lastPlayedDate: '2026-06-24T15:00:00Z', + playbackPositionTicks: 800, + runtimeTicks: 1000, + ), + ]), + ]; + + final entry = flatten(results, deduplicateAcrossServers: true).single; + + expect(entry.sources, hasLength(2)); + expect(entry.sources[0].item.UserData?.PlaybackPositionTicks, 800); + expect(entry.sources[1].item.UserData?.PlaybackPositionTicks, 100); + expect(entry.sources[0].item.RunTimeTicks, 1000); + expect(entry.sources[1].item.RunTimeTicks, 1000); + }); + + test('keeps input order when sources have equal LastPlayedDate', () { + final results = [ + _server('s1', 'S1', [ + _item( + 'a', + 'Movie', + type: 'Movie', + providerIds: {'Tmdb': '100'}, + lastPlayedDate: '2026-06-24T15:00:00Z', + ), + ]), + _server('s2', 'S2', [ + _item( + 'b', + 'Movie', + type: 'Movie', + providerIds: {'Tmdb': '100'}, + lastPlayedDate: '2026-06-24T15:00:00Z', + ), + ]), + ]; + + final entry = flatten(results, deduplicateAcrossServers: true).single; + + expect(entry.sources.map((source) => source.source.serverId), [ + 's1', + 's2', + ]); + }); + }); + + group('flatten – dedup on + serverFilter non-null', () { + test('skips dedup when server filter is active', () { + final results = [ + _server('s1', 'S1', [ + _item('a', 'Movie', type: 'Movie', providerIds: {'Tmdb': '100'}), + ]), + _server('s2', 'S2', [ + _item('b', 'Movie', type: 'Movie', providerIds: {'Tmdb': '100'}), + ]), + ]; + final entries = flatten( + results, + serverFilter: 's1', + deduplicateAcrossServers: true, + ); + expect(entries.length, 1); + expect(entries.first.source.serverId, 's1'); + expect(entries.first.sources, hasLength(1)); + }); + }); + + group('no ProviderIds – never deduped', () { + test('items without ProviderIds stay separate', () { + final results = [ + _server('s1', 'S1', [_item('a', 'Same Name', type: 'Movie')]), + _server('s2', 'S2', [_item('b', 'Same Name', type: 'Movie')]), + ]; + final entries = flatten(results, deduplicateAcrossServers: true); + expect(entries.length, 2); + }); + }); + + group('Type namespace isolation', () { + test('Movie and Series with same tmdb id stay separate', () { + final results = [ + _server('s1', 'S1', [ + _item('a', 'Title', type: 'Movie', providerIds: {'Tmdb': '100'}), + ]), + _server('s2', 'S2', [ + _item('b', 'Title', type: 'Series', providerIds: {'Tmdb': '100'}), + ]), + ]; + final entries = flatten(results, deduplicateAcrossServers: true); + expect(entries.length, 2); + }); + }); + + group('ProviderIds key case insensitivity', () { + test('Tmdb vs tmdb vs TMDB all match', () { + final results = [ + _server('s1', 'S1', [ + _item( + 'a', + 'M', + type: 'Movie', + providerIds: {'Tmdb': '99'}, + lastPlayedDate: '2026-06-20T00:00:00Z', + ), + ]), + _server('s2', 'S2', [ + _item( + 'b', + 'M', + type: 'Movie', + providerIds: {'tmdb': '99'}, + lastPlayedDate: '2026-06-21T00:00:00Z', + ), + ]), + _server('s3', 'S3', [ + _item( + 'c', + 'M', + type: 'Movie', + providerIds: {'TMDB': '99'}, + lastPlayedDate: '2026-06-22T00:00:00Z', + ), + ]), + ]; + final entries = flatten(results, deduplicateAcrossServers: true); + expect(entries.length, 1); + expect(entries.first.source.serverId, 's3'); + }); + }); + + group('null LastPlayedDate handling', () { + test('null loses to valid date', () { + final results = [ + _server('s1', 'S1', [ + _item('a', 'M', type: 'Movie', providerIds: {'Tmdb': '1'}), + ]), + _server('s2', 'S2', [ + _item( + 'b', + 'M', + type: 'Movie', + providerIds: {'Tmdb': '1'}, + lastPlayedDate: '2026-06-01T00:00:00Z', + ), + ]), + ]; + final entries = flatten(results, deduplicateAcrossServers: true); + expect(entries.length, 1); + expect(entries.first.source.serverId, 's2'); + }); + + test('both null keeps first encountered', () { + final results = [ + _server('s1', 'S1', [ + _item('a', 'M', type: 'Movie', providerIds: {'Tmdb': '1'}), + ]), + _server('s2', 'S2', [ + _item('b', 'M', type: 'Movie', providerIds: {'Tmdb': '1'}), + ]), + ]; + final entries = flatten(results, deduplicateAcrossServers: true); + expect(entries.length, 1); + expect(entries.first.source.serverId, 's1'); + }); + }); + + group('transitive matching', () { + test('A(tmdb:1) B(tmdb:1,imdb:tt2) C(imdb:tt2) collapse to one', () { + final results = [ + _server('s1', 'S1', [ + _item( + 'a', + 'M', + type: 'Movie', + providerIds: {'Tmdb': '1'}, + lastPlayedDate: '2026-06-20T00:00:00Z', + ), + ]), + _server('s2', 'S2', [ + _item( + 'b', + 'M', + type: 'Movie', + providerIds: {'Tmdb': '1', 'Imdb': 'tt2'}, + lastPlayedDate: '2026-06-22T00:00:00Z', + ), + ]), + _server('s3', 'S3', [ + _item( + 'c', + 'M', + type: 'Movie', + providerIds: {'Imdb': 'tt2'}, + lastPlayedDate: '2026-06-21T00:00:00Z', + ), + ]), + ]; + final entries = flatten(results, deduplicateAcrossServers: true); + expect(entries.length, 1); + expect(entries.first.source.serverId, 's2'); + }); + }); +} diff --git a/test/features/aggregated/widgets/aggregated_media_card_test.dart b/test/features/aggregated/widgets/aggregated_media_card_test.dart new file mode 100644 index 0000000..d43da69 --- /dev/null +++ b/test/features/aggregated/widgets/aggregated_media_card_test.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:smplayer/core/contracts/library.dart'; +import 'package:smplayer/features/aggregated/widgets/aggregated_media_card.dart'; + +void main() { + testWidgets('multi-source badge does not trigger the whole card', ( + tester, + ) async { + var cardTapCount = 0; + var sourceBadgeTapCount = 0; + + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: 420, + child: AggregatedMediaCard( + item: const EmbyRawItem(Id: 'movie-1', Name: 'Movie'), + imageUrl: null, + title: 'Movie', + serverName: 'Alpha', + serverSourceCount: 2, + onTap: () => cardTapCount++, + onServerSourcesTap: () => sourceBadgeTapCount++, + ), + ), + ), + ), + ), + ), + ); + + expect(find.text('Alpha 2服'), findsOneWidget); + expect(find.byIcon(Icons.keyboard_arrow_down_rounded), findsOneWidget); + + await tester.tap(find.text('Alpha 2服')); + await tester.pump(); + + expect(sourceBadgeTapCount, 1); + expect(cardTapCount, 0); + + await tester.tap(find.text('Movie')); + await tester.pump(); + + expect(cardTapCount, 1); + }); + + testWidgets('single-source badge keeps the existing passive appearance', ( + tester, + ) async { + var cardTapCount = 0; + + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: 420, + child: AggregatedMediaCard( + item: const EmbyRawItem(Id: 'movie-1', Name: 'Movie'), + imageUrl: null, + title: 'Movie', + serverName: 'Alpha', + onTap: () => cardTapCount++, + ), + ), + ), + ), + ), + ), + ); + + expect(find.text('Alpha'), findsOneWidget); + expect(find.byIcon(Icons.keyboard_arrow_down_rounded), findsNothing); + + await tester.tap(find.text('Alpha')); + await tester.pump(); + + expect(cardTapCount, 1); + }); +} diff --git a/test/features/detail/android_detail_geometry_test.dart b/test/features/detail/android_detail_geometry_test.dart new file mode 100644 index 0000000..4309146 --- /dev/null +++ b/test/features/detail/android_detail_geometry_test.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:smplayer/features/detail/android/android_detail_geometry.dart'; +import 'package:smplayer/features/detail/widgets/detail_scroll_progress.dart'; + +void main() { + testWidgets('landscape banner height respects the body height limit', ( + tester, + ) async { + late double bannerHeight; + late double collapseExtent; + + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData( + size: Size(800, 400), + padding: EdgeInsets.only(top: 24), + ), + child: Builder( + builder: (context) { + bannerHeight = computeAndroidDetailBannerHeight(context); + collapseExtent = computeAndroidDetailCollapseExtent(context); + return const SizedBox.shrink(); + }, + ), + ), + ); + + expect(bannerHeight, closeTo(206.8, 0.01)); + expect(collapseExtent, closeTo(138.8, 0.01)); + }); + + testWidgets('scroll progress reaches one at the collapse extent', ( + tester, + ) async { + late double halfProgress; + late double completeProgress; + + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(size: Size(400, 800)), + child: Builder( + builder: (context) { + halfProgress = computeDetailScrollProgress( + context, + 120, + collapseExtent: 240, + ); + completeProgress = computeDetailScrollProgress( + context, + 240, + collapseExtent: 240, + ); + return const SizedBox.shrink(); + }, + ), + ), + ); + + expect(halfProgress, 0.5); + expect(completeProgress, 1.0); + }); +} diff --git a/test/features/detail/emby_detail_helpers_test.dart b/test/features/detail/emby_detail_helpers_test.dart new file mode 100644 index 0000000..785a5b7 --- /dev/null +++ b/test/features/detail/emby_detail_helpers_test.dart @@ -0,0 +1,35 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:smplayer/core/contracts/library.dart'; +import 'package:smplayer/features/detail/emby_detail_helpers.dart'; + +void main() { + group('detailNavTitle', () { + test('uses series name for selected episode', () { + const episode = EmbyRawItem( + Id: 'ep1', + Name: '第 1 集', + Type: 'Episode', + SeriesName: '我的剧', + ); + + expect(detailNavTitle(episode, null), '我的剧'); + }); + + test('uses loaded series item before episode name', () { + const episode = EmbyRawItem( + Id: 'ep1', + Name: '第 1 集', + Type: 'Episode', + ); + const series = EmbyRawItem(Id: 'series1', Name: '正片剧名', Type: 'Series'); + + expect(detailNavTitle(episode, series), '正片剧名'); + }); + + test('keeps movie title', () { + const movie = EmbyRawItem(Id: 'movie1', Name: '电影名', Type: 'Movie'); + + expect(detailNavTitle(movie, null), '电影名'); + }); + }); +} diff --git a/test/features/detail/model/playback_return_progress_test.dart b/test/features/detail/model/playback_return_progress_test.dart new file mode 100644 index 0000000..c59c1da --- /dev/null +++ b/test/features/detail/model/playback_return_progress_test.dart @@ -0,0 +1,27 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:smplayer/core/contracts/library.dart'; +import 'package:smplayer/features/detail/model/selected_episode_merge.dart'; + +void main() { + group('applyPlaybackReturnProgress', () { + test('overrides stale playback position', () { + const item = EmbyRawItem( + Id: 'ep1', + Name: 'Episode 1', + UserData: EmbyRawUserData(PlaybackPositionTicks: 10), + ); + + final updated = applyPlaybackReturnProgress(item, 500); + + expect(updated.UserData?.PlaybackPositionTicks, 500); + }); + + test('creates UserData when detail has none', () { + const item = EmbyRawItem(Id: 'movie1', Name: 'Movie 1'); + + final updated = applyPlaybackReturnProgress(item, 300); + + expect(updated.UserData?.PlaybackPositionTicks, 300); + }); + }); +} diff --git a/test/features/detail/stream_selector_summary_test.dart b/test/features/detail/stream_selector_summary_test.dart new file mode 100644 index 0000000..6477d9f --- /dev/null +++ b/test/features/detail/stream_selector_summary_test.dart @@ -0,0 +1,76 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:smplayer/core/contracts/library.dart'; +import 'package:smplayer/features/detail/widgets/stream_selector_actions.dart'; + +void main() { + const chineseSubtitle = EmbyRawMediaStream( + Type: 'Subtitle', + Language: 'chi', + DisplayTitle: 'Chinese Simplified (SRT)', + ); + const untitledSubtitle = EmbyRawMediaStream(Type: 'Subtitle'); + const dtsAudio = EmbyRawMediaStream( + Type: 'Audio', + Language: 'eng', + DisplayTitle: 'DTS 5.1', + ); + const untitledAudio = EmbyRawMediaStream(Type: 'Audio'); + + group('subtitleSummaryLabel', () { + test('returns 关闭 when no subtitle selected', () { + expect(subtitleSummaryLabel(const [chineseSubtitle], null), '关闭'); + }); + + test('returns 关闭 when index is out of range', () { + expect(subtitleSummaryLabel(const [chineseSubtitle], 5), '关闭'); + }); + + test('prefers Language over DisplayTitle for the compact button', () { + expect(subtitleSummaryLabel(const [chineseSubtitle], 0), 'chi'); + }); + + test('falls back to positional label when stream has no metadata', () { + expect( + subtitleSummaryLabel(const [chineseSubtitle, untitledSubtitle], 1), + '字幕 2', + ); + }); + }); + + group('audioSummaryLabel', () { + test('returns 默认 when index is out of range', () { + expect(audioSummaryLabel(const [dtsAudio], 3), '默认'); + }); + + test('prefers Language over DisplayTitle for the compact button', () { + expect(audioSummaryLabel(const [dtsAudio], 0), 'eng'); + }); + + test('falls back to positional label when stream has no metadata', () { + expect(audioSummaryLabel(const [dtsAudio, untitledAudio], 1), '音轨 2'); + }); + }); + + group('versionSummaryLabel', () { + const namedSource = EmbyRawMediaSource(Id: 'a', Name: '4K HDR 版本'); + const unnamedSource = EmbyRawMediaSource(Id: 'b'); + + test('uses the source name when present', () { + expect( + versionSummaryLabel(const [namedSource, unnamedSource], 0), + '4K HDR 版本', + ); + }); + + test('falls back to positional label when the source has no name', () { + expect( + versionSummaryLabel(const [namedSource, unnamedSource], 1), + '版本 2', + ); + }); + + test('falls back to 版本 when index is out of range', () { + expect(versionSummaryLabel(const [namedSource], 9), '版本'); + }); + }); +} diff --git a/test/features/history/widgets/history_source_picker_test.dart b/test/features/history/widgets/history_source_picker_test.dart new file mode 100644 index 0000000..0898e31 --- /dev/null +++ b/test/features/history/widgets/history_source_picker_test.dart @@ -0,0 +1,171 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:smplayer/core/contracts/library.dart'; +import 'package:smplayer/features/aggregated/aggregated_grouping.dart'; +import 'package:smplayer/features/history/widgets/history_source_picker.dart'; + +void main() { + testWidgets('shows each server independent playback progress', ( + tester, + ) async { + final now = DateTime(2026, 7, 11, 12); + final partiallyWatchedSource = _entrySource( + serverId: 'alpha', + serverName: 'Alpha', + itemId: 'alpha-movie', + playbackPositionTicks: 600000000, + runtimeTicks: 1200000000, + lastPlayedDate: now.subtract(const Duration(hours: 1)), + ); + final completedSource = _entrySource( + serverId: 'beta', + serverName: 'Beta', + itemId: 'beta-movie', + played: true, + runtimeTicks: 1200000000, + lastPlayedDate: now.subtract(const Duration(hours: 2)), + ); + final unknownProgressSource = _entrySource( + serverId: 'gamma', + serverName: 'Gamma', + itemId: 'gamma-movie', + ); + final entry = AggregatedEntry( + item: partiallyWatchedSource.item, + source: partiallyWatchedSource.source, + sources: [partiallyWatchedSource, completedSource, unknownProgressSource], + ); + AggregatedEntrySource? selectedSource; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: HistorySourcePickerContent( + entry: entry, + now: now, + onSourceSelected: (source) => selectedSource = source, + ), + ), + ), + ); + + expect(find.text('最近播放'), findsOneWidget); + expect(find.text('已看 50% · 剩余 1 分钟 · 1 小时前'), findsOneWidget); + expect(find.text('已看完 · 2 小时前'), findsOneWidget); + expect(find.text('进度未知 · 未记录播放时间'), findsOneWidget); + + final progressIndicators = tester.widgetList( + find.byType(LinearProgressIndicator), + ); + expect(progressIndicators.map((indicator) => indicator.value), [0.5, 1, 0]); + + await tester.tap(find.text('Beta')); + await tester.pump(); + + expect(selectedSource, same(completedSource)); + }); + + testWidgets('adaptive picker lets the user choose another server', ( + tester, + ) async { + tester.view.physicalSize = const Size(480, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final now = DateTime(2026, 7, 11, 12); + final alphaSource = _entrySource( + serverId: 'alpha', + serverName: 'Alpha', + itemId: 'alpha-movie', + playbackPositionTicks: 600000000, + runtimeTicks: 1200000000, + lastPlayedDate: now.subtract(const Duration(hours: 1)), + ); + final betaSource = _entrySource( + serverId: 'beta', + serverName: 'Beta', + itemId: 'beta-movie', + playbackPositionTicks: 300000000, + runtimeTicks: 1200000000, + lastPlayedDate: now.subtract(const Duration(hours: 2)), + ); + final entry = AggregatedEntry( + item: alphaSource.item, + source: alphaSource.source, + sources: [alphaSource, betaSource], + ); + AggregatedEntrySource? selectedSource; + + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () async { + selectedSource = await showHistorySourcePicker( + context, + entry: entry, + now: now, + ); + }, + child: const Text('打开服务器选择'), + ), + ), + ), + ), + ), + ); + + await tester.tap(find.text('打开服务器选择')); + await tester.pumpAndSettle(); + + expect(find.text('选择服务器'), findsOneWidget); + expect(find.text('Alpha'), findsOneWidget); + expect(find.text('Beta'), findsOneWidget); + + await tester.tap(find.text('Beta')); + await tester.pumpAndSettle(); + + expect(selectedSource, same(betaSource)); + expect(find.text('选择服务器'), findsNothing); + }); +} + +AggregatedEntrySource _entrySource({ + required String serverId, + required String serverName, + required String itemId, + int? playbackPositionTicks, + int? runtimeTicks, + bool? played, + DateTime? lastPlayedDate, +}) { + final item = EmbyRawItem( + Id: itemId, + Name: 'Movie', + Type: 'Movie', + RunTimeTicks: runtimeTicks, + UserData: playbackPositionTicks != null || played != null + ? EmbyRawUserData( + PlaybackPositionTicks: playbackPositionTicks, + Played: played, + ) + : null, + extra: { + if (lastPlayedDate != null) + 'UserData': {'LastPlayedDate': lastPlayedDate}, + }, + ); + final serverResult = GlobalSearchServerResult( + serverId: serverId, + serverName: serverName, + serverUrl: 'https://$serverId.example', + token: 'token-$serverId', + userId: 'user-$serverId', + items: [item], + ); + return AggregatedEntrySource(item: item, source: serverResult); +} diff --git a/test/features/home/widgets/server_dropdown_button_test.dart b/test/features/home/widgets/server_dropdown_button_test.dart new file mode 100644 index 0000000..8d345b8 --- /dev/null +++ b/test/features/home/widgets/server_dropdown_button_test.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:forui/forui.dart'; +import 'package:smplayer/core/contracts/auth.dart'; +import 'package:smplayer/features/home/widgets/server_dropdown_button.dart'; +import 'package:smplayer/providers/home_page_mode_provider.dart'; +import 'package:smplayer/providers/session_provider.dart'; + +void main() { + testWidgets('server mode uses a capsule dropdown trigger', (tester) async { + await _pumpButton(tester, mode: HomePageMode.server); + + expect(find.byType(BackdropFilter), findsOneWidget); + expect(find.byType(FPopoverMenu), findsOneWidget); + expect(find.text('Alpha'), findsOneWidget); + + await tester.tap(find.text('Alpha')); + await tester.pumpAndSettle(); + + expect(find.text('推荐'), findsOneWidget); + expect(find.text('Beta'), findsOneWidget); + }); + + testWidgets('recommend mode uses the same capsule dropdown trigger', ( + tester, + ) async { + await _pumpButton(tester, mode: HomePageMode.recommend); + + expect(find.byType(BackdropFilter), findsOneWidget); + expect(find.byType(FPopoverMenu), findsOneWidget); + expect(find.text('推荐'), findsOneWidget); + + await tester.tap(find.text('推荐')); + await tester.pumpAndSettle(); + + expect(find.text('Alpha'), findsOneWidget); + expect(find.text('Beta'), findsOneWidget); + }); +} + +Future _pumpButton( + WidgetTester tester, { + required HomePageMode mode, +}) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + homePageModeProvider.overrideWith((_) => mode), + sessionProvider.overrideWith(_FakeSessionNotifier.new), + ], + child: const MaterialApp( + home: Scaffold(body: Center(child: ServerDropdownButton())), + ), + ), + ); + await tester.pumpAndSettle(); +} + +class _FakeSessionNotifier extends SessionNotifier { + @override + Future build() async => const SessionListRes( + activeServerId: 'alpha', + sessions: [ + AuthedSession( + serverId: 'alpha', + serverName: 'Alpha', + serverUrl: 'https://alpha.example', + token: 'token-a', + userId: 'user-a', + userName: 'User A', + isActive: true, + ), + AuthedSession( + serverId: 'beta', + serverName: 'Beta', + serverUrl: 'https://beta.example', + token: 'token-b', + userId: 'user-b', + userName: 'User B', + isActive: false, + ), + ], + ); +} diff --git a/test/features/player/emby_playback_reporter_test.dart b/test/features/player/emby_playback_reporter_test.dart new file mode 100644 index 0000000..6b9994b --- /dev/null +++ b/test/features/player/emby_playback_reporter_test.dart @@ -0,0 +1,68 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:smplayer/core/contracts/playback.dart'; +import 'package:smplayer/core/domain/ports/emby_gateway.dart'; +import 'package:smplayer/features/player/services/emby_playback_reporter.dart'; + +class _CountingEmbyGateway implements EmbyGateway { + int startedCount = 0; + int progressCount = 0; + int stoppedCount = 0; + + @override + Future reportPlaybackProgress({ + required AuthedRequestContext ctx, + required PlaybackReportPayload payload, + }) async { + progressCount++; + } + + @override + Future reportPlaybackStarted({ + required AuthedRequestContext ctx, + required PlaybackReportPayload payload, + }) async { + startedCount++; + } + + @override + Future reportPlaybackStopped({ + required AuthedRequestContext ctx, + required PlaybackReportPayload payload, + }) async { + stoppedCount++; + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +void main() { + test('禁用的上报器不会向 Emby 发送任何播放事件', () async { + final gateway = _CountingEmbyGateway(); + final reporter = EmbyPlaybackReporter( + gateway: gateway, + context: const AuthedRequestContext(baseUrl: '', token: '', userId: ''), + enabled: false, + ); + addTearDown(reporter.dispose); + const payload = PlaybackReportPayload(itemId: 'direct', positionTicks: 0); + + await reporter.reportStarted(payload); + await reporter.reportProgress(payload); + await reporter.reportStopped(payload); + reporter.startProgressTimer( + ({required bool isPaused, String? eventName}) => PlaybackReportPayload( + itemId: 'direct', + positionTicks: 0, + isPaused: isPaused, + eventName: eventName, + ), + ); + + expect(gateway.startedCount, 0); + expect(gateway.progressCount, 0); + expect(gateway.stoppedCount, 0); + expect(reporter.startedReported, isFalse); + expect(reporter.stoppedReported, isFalse); + }); +} diff --git a/test/features/player/player_clock_text_test.dart b/test/features/player/player_clock_text_test.dart new file mode 100644 index 0000000..12fd7a9 --- /dev/null +++ b/test/features/player/player_clock_text_test.dart @@ -0,0 +1,11 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:smplayer/features/player/widgets/player_clock_text.dart'; + +void main() { + test('formatClock 补零到两位并用 24 小时制', () { + expect(formatClock(DateTime(2026, 7, 2, 9, 5)), '09:05'); + expect(formatClock(DateTime(2026, 7, 2, 22, 47)), '22:47'); + expect(formatClock(DateTime(2026, 7, 2, 0, 0)), '00:00'); + expect(formatClock(DateTime(2026, 7, 2, 23, 59)), '23:59'); + }); +} diff --git a/test/features/player/session/initial_subtitle_resolver_test.dart b/test/features/player/session/initial_subtitle_resolver_test.dart new file mode 100644 index 0000000..7766856 --- /dev/null +++ b/test/features/player/session/initial_subtitle_resolver_test.dart @@ -0,0 +1,107 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:smplayer/core/contracts/playback.dart'; +import 'package:smplayer/features/player/session/initial_subtitle_resolver.dart'; +import 'package:smplayer/providers/subtitle_settings_provider.dart'; + +const subtitles = [ + EmbySubtitleTrack(index: 2, language: 'chi', title: '简体中文'), + EmbySubtitleTrack(index: 3, language: 'eng', title: 'English'), +]; + +RememberedSubtitleTrack rememberedSubtitle({ + int streamIndex = 2, + String? language, + String? title, +}) { + return RememberedSubtitleTrack( + streamIndex: streamIndex, + language: language, + title: title, + updatedAt: DateTime(2026), + ); +} + +InitialSubtitleSelection resolveSelection({ + List availableSubtitles = subtitles, + int? preferredSubtitleStreamIndex, + int? defaultSubtitleStreamIndex, + RememberedSubtitleTrack? remembered, +}) { + return resolveInitialSubtitleSelection( + subtitles: availableSubtitles, + preferredSubtitleStreamIndex: preferredSubtitleStreamIndex, + defaultSubtitleStreamIndex: defaultSubtitleStreamIndex, + rememberedSubtitle: remembered, + ); +} + +void main() { + test('显式 route 轨优先于记忆与服务器默认轨', () { + final selection = resolveSelection( + preferredSubtitleStreamIndex: 3, + defaultSubtitleStreamIndex: 2, + remembered: rememberedSubtitle(streamIndex: 2), + ); + + expect(selection.type, InitialSubtitleSelectionType.track); + expect(selection.track?.index, 3); + }); + + test('route -1 且没有记忆时禁用字幕', () { + final selection = resolveSelection( + preferredSubtitleStreamIndex: -1, + defaultSubtitleStreamIndex: 2, + ); + + expect(selection.type, InitialSubtitleSelectionType.disabled); + expect(selection.track, isNull); + }); + + test('禁用字幕记忆优先于 route -1 与服务器默认轨', () { + final selection = resolveSelection( + preferredSubtitleStreamIndex: -1, + defaultSubtitleStreamIndex: 2, + remembered: RememberedSubtitleTrack( + streamIndex: -1, + updatedAt: DateTime(2026), + ), + ); + + expect(selection.type, InitialSubtitleSelectionType.disabled); + }); + + test('跨集记忆按语言与标题匹配可用轨', () { + final selection = resolveSelection( + defaultSubtitleStreamIndex: 3, + remembered: rememberedSubtitle( + streamIndex: 99, + language: 'CHI', + title: '简体中文', + ), + ); + + expect(selection.type, InitialSubtitleSelectionType.track); + expect(selection.track?.index, 2); + }); + + test('记忆未命中时回落服务器默认轨', () { + final selection = resolveSelection( + defaultSubtitleStreamIndex: 3, + remembered: rememberedSubtitle( + streamIndex: 99, + language: 'jpn', + title: '日本語', + ), + ); + + expect(selection.type, InitialSubtitleSelectionType.track); + expect(selection.track?.index, 3); + }); + + test('没有 route、记忆或服务器默认轨时保持引擎状态', () { + final selection = resolveSelection(availableSubtitles: const []); + + expect(selection.type, InitialSubtitleSelectionType.unchanged); + expect(selection.track, isNull); + }); +} diff --git a/test/features/player/session/media3_network_error_test.dart b/test/features/player/session/media3_network_error_test.dart new file mode 100644 index 0000000..a88eac9 --- /dev/null +++ b/test/features/player/session/media3_network_error_test.dart @@ -0,0 +1,28 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:smplayer/core/infra/player_engine/media3_player_engine.dart'; + +void main() { + group('isMedia3NetworkError', () { + test('命中所有 ERROR_CODE_IO_* 网络类错误', () { + expect( + isMedia3NetworkError('ERROR_CODE_IO_NETWORK_CONNECTION_FAILED'), + isTrue, + ); + expect(isMedia3NetworkError('ERROR_CODE_IO_BAD_HTTP_STATUS'), isTrue); + expect(isMedia3NetworkError('ERROR_CODE_IO_FILE_NOT_FOUND'), isTrue); + }); + + test('反直觉边界:TIMEOUT 不带 IO_ 前缀 → 不命中(走宽限等待)', () { + expect(isMedia3NetworkError('ERROR_CODE_TIMEOUT'), isFalse); + }); + + test('解码/解析类错误不命中', () { + expect(isMedia3NetworkError('ERROR_CODE_DECODING_FAILED'), isFalse); + expect( + isMedia3NetworkError('ERROR_CODE_PARSING_CONTAINER_MALFORMED'), + isFalse, + ); + expect(isMedia3NetworkError('playback_error'), isFalse); + }); + }); +} diff --git a/test/features/player/session/player_engine_holder_test.dart b/test/features/player/session/player_engine_holder_test.dart new file mode 100644 index 0000000..86f0a8d --- /dev/null +++ b/test/features/player/session/player_engine_holder_test.dart @@ -0,0 +1,155 @@ +import 'dart:ui' as ui; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:smplayer/core/contracts/cancellation.dart'; +import 'package:smplayer/core/infra/player_engine/player_engine.dart'; +import 'package:smplayer/features/player/session/player_engine_holder.dart'; +import 'package:smplayer/features/player/session/player_engine_resolution.dart'; + +void main() { + group('PlayerEngineHolder', () { + test('同 kind 复用:第二次 acquire 不再调 create', () async { + final holder = PlayerEngineHolder(); + var createCalls = 0; + _FakeEngine factory(String tag) { + createCalls++; + return _FakeEngine(tag); + } + + final first = await holder.acquire( + PlayerEngineKind.media3, + () => factory('a'), + ); + final second = await holder.acquire( + PlayerEngineKind.media3, + () => factory('b'), + ); + + expect(createCalls, 1); + expect(identical(first, second), isTrue); + expect((second as _FakeEngine).tag, 'a'); + }); + + test('异 kind dispose+create:旧引擎先 dispose 再新建', () async { + final holder = PlayerEngineHolder(); + final disposeOrder = []; + final acquireOrder = []; + + final fvp = _FakeEngine( + 'fvp', + onDispose: () async { + await Future.delayed(const Duration(milliseconds: 10)); + disposeOrder.add('fvp'); + }, + ); + await holder.acquire(PlayerEngineKind.fvp, () => fvp); + acquireOrder.add('fvp'); + + final media3 = _FakeEngine('media3'); + final second = await holder.acquire(PlayerEngineKind.media3, () { + acquireOrder.add('media3-create'); + return media3; + }); + + expect(disposeOrder, ['fvp']); + expect(acquireOrder, ['fvp', 'media3-create']); + expect(second, media3); + expect(fvp.disposed, isTrue); + expect(media3.disposed, isFalse); + }); + }); +} + +class _FakeEngine implements PlayerEngine { + final String tag; + final Future Function()? onDispose; + bool disposed = false; + _FakeEngine(this.tag, {this.onDispose}); + + @override + Future dispose() async { + if (disposed) return; + if (onDispose != null) await onDispose!(); + disposed = true; + } + + @override + String get displayName => tag; + + @override + bool get usesPlatformSurface => false; + + @override + Future totalRxBytes() async => null; + + @override + bool isNetworkStreamError(String error) => false; + + @override + void configureHardwareDecoding(String mode) {} + + @override + Future open( + String url, { + Duration start = Duration.zero, + bool play = true, + Map? headers, + CancellationToken? cancel, + }) async {} + + @override + Future play() async {} + @override + Future pause() async {} + @override + Future stop() async {} + @override + Future seek(Duration position) async {} + @override + Future setRate(double rate) async {} + @override + Future setVolume(double volume) async {} + @override + Future toggleMute() async {} + @override + Future setAudioTrack(AudioTrack track) async {} + @override + Future setSubtitleTrack(SubtitleTrack track) async {} + + @override + void setNativeSubtitleRendering(bool enabled) {} + + @override + void setSubtitleStyle({ + required double fontSize, + required double bgOpacity, + required int position, + }) {} + + @override + Widget buildVideoView(BuildContext context, ValueListenable fit) => + const SizedBox.shrink(); + + Future getProperty(String name) async => ''; + + @override + PlayerState get state => PlayerState(); + + @override + PlayerStreams get stream => PlayerStreams(); + + @override + ValueListenable get textureId => ValueNotifier(null); + + @override + ValueListenable get textureVersion => ValueNotifier(0); + + @override + ValueNotifier get videoSize => ValueNotifier(null); + + @override + ValueListenable get debugStats => + ValueNotifier(null); +} diff --git a/test/features/player/session/player_engine_resolution_test.dart b/test/features/player/session/player_engine_resolution_test.dart new file mode 100644 index 0000000..774b14d --- /dev/null +++ b/test/features/player/session/player_engine_resolution_test.dart @@ -0,0 +1,236 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:smplayer/features/player/session/player_engine_resolution.dart'; +import 'package:smplayer/providers/player_engine_override_provider.dart'; + +void main() { + group('resolvePlayerEngineKind - media3 override', () { + test('Android + 非 DV/ProRes → media3', () { + expect( + resolvePlayerEngineKind( + PlayerEngineOverride.media3, + isDolbyVision: false, + videoCodec: 'h264', + isAndroid: true, + ), + PlayerEngineKind.media3, + ); + }); + + test('桌面 → mpv(不论 DV / codec)', () { + expect( + resolvePlayerEngineKind( + PlayerEngineOverride.media3, + isDolbyVision: false, + videoCodec: 'h264', + isAndroid: false, + ), + PlayerEngineKind.mpv, + ); + expect( + resolvePlayerEngineKind( + PlayerEngineOverride.media3, + isDolbyVision: true, + videoCodec: 'prores', + isAndroid: false, + ), + PlayerEngineKind.mpv, + ); + }); + + test('Android + DV → media3(用户显式选 media3 时直通 DV)', () { + expect( + resolvePlayerEngineKind( + PlayerEngineOverride.media3, + isDolbyVision: true, + videoCodec: 'hevc', + isAndroid: true, + ), + PlayerEngineKind.media3, + ); + }); + + test('Android + ProRes → fvp(MediaCodec 不解 ProRes)', () { + expect( + resolvePlayerEngineKind( + PlayerEngineOverride.media3, + isDolbyVision: false, + videoCodec: 'prores', + isAndroid: true, + ), + PlayerEngineKind.fvp, + ); + }); + }); + + group('resolvePlayerEngineKind - auto behavior', () { + test('auto + Android + 非 DV/ProRes → media3', () { + expect( + resolvePlayerEngineKind( + PlayerEngineOverride.auto, + isDolbyVision: false, + videoCodec: 'h264', + isAndroid: true, + ), + PlayerEngineKind.media3, + ); + }); + + test('auto + Android + DV → media3', () { + expect( + resolvePlayerEngineKind( + PlayerEngineOverride.auto, + isDolbyVision: true, + videoCodec: 'hevc', + isAndroid: true, + ), + PlayerEngineKind.media3, + ); + }); + + test('auto + Android + ProRes → fvp', () { + expect( + resolvePlayerEngineKind( + PlayerEngineOverride.auto, + isDolbyVision: false, + videoCodec: 'prores', + isAndroid: true, + ), + PlayerEngineKind.fvp, + ); + }); + + test('auto + 桌面 → mpv', () { + expect( + resolvePlayerEngineKind( + PlayerEngineOverride.auto, + isDolbyVision: false, + videoCodec: 'h264', + isAndroid: false, + ), + PlayerEngineKind.mpv, + ); + }); + + test('auto + 桌面 DV → fvp', () { + expect( + resolvePlayerEngineKind( + PlayerEngineOverride.auto, + isDolbyVision: true, + videoCodec: 'hevc', + isAndroid: false, + ), + PlayerEngineKind.fvp, + ); + }); + + test('fvp override → 总是 fvp', () { + expect( + resolvePlayerEngineKind( + PlayerEngineOverride.fvp, + isDolbyVision: false, + videoCodec: 'h264', + isAndroid: false, + ), + PlayerEngineKind.fvp, + ); + }); + + test('mpv override → 总是 mpv(即便 DV)', () { + expect( + resolvePlayerEngineKind( + PlayerEngineOverride.mpv, + isDolbyVision: true, + videoCodec: 'hevc', + isAndroid: true, + ), + PlayerEngineKind.mpv, + ); + }); + }); + + group('playerEngineKindFromDisplayName', () { + test('media3 → PlayerEngineKind.media3', () { + expect( + playerEngineKindFromDisplayName('media3'), + PlayerEngineKind.media3, + ); + }); + + test('fvp / mpv 仍可反查', () { + expect(playerEngineKindFromDisplayName('fvp'), PlayerEngineKind.fvp); + expect(playerEngineKindFromDisplayName('mpv'), PlayerEngineKind.mpv); + }); + + test('未知 displayName → null', () { + expect(playerEngineKindFromDisplayName('unknown'), isNull); + }); + }); + + group('nextAutoFallbackEngine', () { + test('auto + media3 失败(非 DV)→ mpv', () { + expect( + nextAutoFallbackEngine( + override: PlayerEngineOverride.auto, + failedEngineKind: PlayerEngineKind.media3, + isDolbyVision: false, + ), + PlayerEngineKind.mpv, + ); + }); + + test('auto + media3 失败(DV)→ 跳过 mpv 直达 fvp', () { + expect( + nextAutoFallbackEngine( + override: PlayerEngineOverride.auto, + failedEngineKind: PlayerEngineKind.media3, + isDolbyVision: true, + ), + PlayerEngineKind.fvp, + ); + }); + + test('auto + mpv 失败 → fvp', () { + expect( + nextAutoFallbackEngine( + override: PlayerEngineOverride.auto, + failedEngineKind: PlayerEngineKind.mpv, + isDolbyVision: false, + ), + PlayerEngineKind.fvp, + ); + }); + + test('auto + fvp 失败 → 无回退', () { + expect( + nextAutoFallbackEngine( + override: PlayerEngineOverride.auto, + failedEngineKind: PlayerEngineKind.fvp, + isDolbyVision: true, + ), + isNull, + ); + }); + + test('非 auto 不自动回退', () { + expect( + nextAutoFallbackEngine( + override: PlayerEngineOverride.media3, + failedEngineKind: PlayerEngineKind.media3, + isDolbyVision: true, + ), + isNull, + ); + }); + + test('未知失败引擎不自动回退', () { + expect( + nextAutoFallbackEngine( + override: PlayerEngineOverride.auto, + failedEngineKind: null, + isDolbyVision: false, + ), + isNull, + ); + }); + }); +} diff --git a/test/features/player/session/relink_scheduler_test.dart b/test/features/player/session/relink_scheduler_test.dart new file mode 100644 index 0000000..049e593 --- /dev/null +++ b/test/features/player/session/relink_scheduler_test.dart @@ -0,0 +1,47 @@ +import 'package:fake_async/fake_async.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:smplayer/features/player/session/relink_scheduler.dart'; + +void main() { + group('RelinkScheduler', () { + test('schedule cancels pending sustain timer (attempts must accumulate)', + () { + fakeAsync((async) { + final s = RelinkScheduler(); + var gaveUp = false; + + void failOnce() { + s.schedule(onRelink: (_) {}, onGiveUp: () => gaveUp = true); + + async.flushTimers(flushPeriodicTimers: false); + s.armSustain(sustain: const Duration(seconds: 10)); + + async.elapse(const Duration(seconds: 5)); + } + + + failOnce(); + failOnce(); + failOnce(); + expect(gaveUp, isFalse); + s.schedule(onRelink: (_) {}, onGiveUp: () => gaveUp = true); + expect(gaveUp, isTrue); + s.dispose(); + }); + }); + + test('sustained playback resets attempts', () { + fakeAsync((async) { + final s = RelinkScheduler(); + s.schedule(onRelink: (_) {}, onGiveUp: () {}); + async.flushTimers(flushPeriodicTimers: false); + expect(s.attempts, 1); + s.armSustain(sustain: const Duration(seconds: 10)); + async.elapse(const Duration(seconds: 11)); + expect(s.attempts, 0); + s.dispose(); + }); + }); + }); +} diff --git a/test/features/player/session/stall_detector_test.dart b/test/features/player/session/stall_detector_test.dart new file mode 100644 index 0000000..2f5ca9a --- /dev/null +++ b/test/features/player/session/stall_detector_test.dart @@ -0,0 +1,129 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:smplayer/features/player/session/stall_detector.dart'; + +void main() { + group('StallDetector', () { + test('有字节统计时下载继续便不判定停滞', () { + final detector = StallDetector(threshold: const Duration(seconds: 10)); + + expect( + detector.update( + position: const Duration(seconds: 5), + bufferedPosition: const Duration(seconds: 5), + bytesDownloaded: 100, + isActivelyPlaying: true, + now: Duration.zero, + ), + isFalse, + ); + expect( + detector.update( + position: const Duration(seconds: 5), + bufferedPosition: const Duration(seconds: 5), + bytesDownloaded: 200, + isActivelyPlaying: true, + now: const Duration(seconds: 12), + ), + isFalse, + ); + }); + + test('无字节统计时缓冲终点继续前进便不判定停滞', () { + final detector = StallDetector(threshold: const Duration(seconds: 10)); + + detector.update( + position: const Duration(seconds: 5), + bufferedPosition: const Duration(seconds: 5), + bytesDownloaded: null, + isActivelyPlaying: true, + now: Duration.zero, + ); + expect( + detector.update( + position: const Duration(seconds: 5), + bufferedPosition: const Duration(seconds: 8), + bytesDownloaded: null, + isActivelyPlaying: true, + now: const Duration(seconds: 12), + ), + isFalse, + ); + }); + + test('累计字节首次变为可用时视为传输进展', () { + final detector = StallDetector(threshold: const Duration(seconds: 10)); + + detector.update( + position: const Duration(seconds: 5), + bufferedPosition: const Duration(seconds: 5), + bytesDownloaded: null, + isActivelyPlaying: true, + now: Duration.zero, + ); + expect( + detector.update( + position: const Duration(seconds: 5), + bufferedPosition: const Duration(seconds: 5), + bytesDownloaded: 100, + isActivelyPlaying: true, + now: const Duration(seconds: 12), + ), + isFalse, + ); + }); + + test('无字节统计且仍有明显缓冲余量时不重连', () { + final detector = StallDetector(threshold: const Duration(seconds: 10)); + + detector.update( + position: const Duration(seconds: 5), + bufferedPosition: const Duration(seconds: 20), + bytesDownloaded: null, + isActivelyPlaying: true, + now: Duration.zero, + ); + expect( + detector.update( + position: const Duration(seconds: 5), + bufferedPosition: const Duration(seconds: 20), + bytesDownloaded: null, + isActivelyPlaying: true, + now: const Duration(seconds: 12), + ), + isFalse, + ); + }); + + test('所有可用进展信号停止到阈值后只触发一次', () { + final detector = StallDetector(threshold: const Duration(seconds: 10)); + + detector.update( + position: const Duration(seconds: 5), + bufferedPosition: const Duration(seconds: 5), + bytesDownloaded: null, + isActivelyPlaying: true, + now: Duration.zero, + ); + expect( + detector.update( + position: const Duration(seconds: 5), + bufferedPosition: const Duration(seconds: 5), + bytesDownloaded: null, + isActivelyPlaying: true, + now: const Duration(seconds: 10), + ), + isTrue, + ); + expect( + detector.update( + position: const Duration(seconds: 5), + bufferedPosition: const Duration(seconds: 5), + bytesDownloaded: null, + isActivelyPlaying: true, + now: const Duration(seconds: 20), + ), + isFalse, + ); + }); + }); +} diff --git a/test/features/player/session/subtitle_track_matcher_test.dart b/test/features/player/session/subtitle_track_matcher_test.dart new file mode 100644 index 0000000..1567235 --- /dev/null +++ b/test/features/player/session/subtitle_track_matcher_test.dart @@ -0,0 +1,55 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:smplayer/core/contracts/playback.dart'; +import 'package:smplayer/features/player/session/initial_subtitle_resolver.dart'; +import 'package:smplayer/providers/subtitle_settings_provider.dart'; + +RememberedSubtitleTrack _remembered({ + int streamIndex = 3, + String? language, + String? title, +}) { + return RememberedSubtitleTrack( + streamIndex: streamIndex, + language: language, + title: title, + updatedAt: DateTime(2026), + ); +} + +void main() { + const embedded = [ + EmbySubtitleTrack(index: 2, language: 'chi', title: '简体中文'), + EmbySubtitleTrack(index: 3, language: 'chi', title: '繁體中文'), + EmbySubtitleTrack(index: 4, language: 'eng', title: 'English'), + EmbySubtitleTrack(index: 5, title: 'Signs & Songs'), + ]; + + test('lang + title 精确命中(大小写不敏感)', () { + final r = _remembered(language: 'CHI', title: '繁體中文'); + expect(matchRememberedSubtitle(r, embedded), 3); + }); + + test('language 为 null 时按 title 命中', () { + final r = _remembered(streamIndex: 99, title: 'signs & songs'); + expect(matchRememberedSubtitle(r, embedded), 5); + }); + + test('title 不匹配时回落仅 lang 宽松匹配', () { + final r = _remembered(language: 'chi', title: '不存在的标题'); + expect(matchRememberedSubtitle(r, embedded), 2); + }); + + test('lang/title 全空时按 streamIndex 直配', () { + final r = _remembered(streamIndex: 4); + expect(matchRememberedSubtitle(r, embedded), 4); + }); + + test('全部 miss 返回 null', () { + final r = _remembered(streamIndex: 99, language: 'jpn', title: '日本語'); + expect(matchRememberedSubtitle(r, embedded), isNull); + }); + + test('空轨道列表返回 null', () { + expect(matchRememberedSubtitle(_remembered(), const []), isNull); + }); +} diff --git a/test/features/player/widgets/android/android_bottom_transport_controls_test.dart b/test/features/player/widgets/android/android_bottom_transport_controls_test.dart new file mode 100644 index 0000000..63f2bd3 --- /dev/null +++ b/test/features/player/widgets/android/android_bottom_transport_controls_test.dart @@ -0,0 +1,111 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:smplayer/features/player/widgets/android/android_bottom_transport_controls.dart'; + +import '../../../../helpers/fake_player_engine.dart'; + +void main() { + testWidgets('renders prev play next order', (tester) async { + final player = FakePlayerEngine(playing: true); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + backgroundColor: Colors.black, + body: Center( + child: AndroidBottomTransportControls( + player: player, + hasPrev: true, + hasNext: true, + onPrevItem: () {}, + onNextItem: () {}, + ), + ), + ), + ), + ); + + expect(find.byIcon(Icons.skip_previous), findsOneWidget); + expect(find.byIcon(Icons.pause), findsOneWidget); + expect(find.byIcon(Icons.skip_next), findsOneWidget); + + final prevCenter = tester.getCenter(find.byIcon(Icons.skip_previous)); + final playCenter = tester.getCenter(find.byIcon(Icons.pause)); + final nextCenter = tester.getCenter(find.byIcon(Icons.skip_next)); + + expect(prevCenter.dx < playCenter.dx, isTrue); + expect(playCenter.dx < nextCenter.dx, isTrue); + + await player.dispose(); + }); + + testWidgets('invokes prev/next callbacks and toggles playback', ( + tester, + ) async { + final player = FakePlayerEngine(playing: true); + var prevTaps = 0; + var nextTaps = 0; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: AndroidBottomTransportControls( + player: player, + hasPrev: true, + hasNext: true, + onPrevItem: () => prevTaps++, + onNextItem: () => nextTaps++, + ), + ), + ), + ), + ); + + await tester.tap(find.byIcon(Icons.skip_previous)); + await tester.tap(find.byIcon(Icons.skip_next)); + await tester.tap(find.byIcon(Icons.pause)); + await tester.pump(); + + expect(prevTaps, 1); + expect(nextTaps, 1); + expect(player.pauseCalls, 1); + expect(player.playCalls, 0); + + await player.dispose(); + }); + + testWidgets('disables prev/next when unavailable', (tester) async { + final player = FakePlayerEngine(playing: true); + var prevTaps = 0; + var nextTaps = 0; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: AndroidBottomTransportControls( + player: player, + hasPrev: false, + hasNext: false, + onPrevItem: () => prevTaps++, + onNextItem: () => nextTaps++, + ), + ), + ), + ), + ); + + final prevBtn = tester.widget( + find.widgetWithIcon(IconButton, Icons.skip_previous), + ); + final nextBtn = tester.widget( + find.widgetWithIcon(IconButton, Icons.skip_next), + ); + + expect(prevBtn.onPressed, isNull); + expect(nextBtn.onPressed, isNull); + + await player.dispose(); + }); +} diff --git a/test/features/player/widgets/android/android_gesture_feedback_test.dart b/test/features/player/widgets/android/android_gesture_feedback_test.dart new file mode 100644 index 0000000..c72a610 --- /dev/null +++ b/test/features/player/widgets/android/android_gesture_feedback_test.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:smplayer/features/player/widgets/android/android_gesture_feedback.dart'; + +void main() { + Widget buildSubject({required bool seekIsForward}) { + return MaterialApp( + home: SizedBox( + width: 360, + height: 240, + child: AndroidGestureFeedback( + data: GestureFeedbackData( + kind: GestureFeedbackKind.seek, + seekTarget: const Duration(minutes: 1), + totalDuration: const Duration(minutes: 10), + seekIsForward: seekIsForward, + ), + ), + ), + ); + } + + testWidgets('seek preview shows rewind icon for backward seek', ( + tester, + ) async { + await tester.pumpWidget(buildSubject(seekIsForward: false)); + + expect(find.byIcon(Icons.fast_rewind), findsOneWidget); + expect(find.byIcon(Icons.fast_forward), findsNothing); + }); + + testWidgets('seek preview shows forward icon for forward seek', ( + tester, + ) async { + await tester.pumpWidget(buildSubject(seekIsForward: true)); + + expect(find.byIcon(Icons.fast_forward), findsOneWidget); + expect(find.byIcon(Icons.fast_rewind), findsNothing); + }); + + testWidgets('long press speed uses light background', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: SizedBox( + width: 360, + height: 240, + child: AndroidGestureFeedback( + data: GestureFeedbackData( + kind: GestureFeedbackKind.longPressSpeed, + speedRate: 3, + ), + ), + ), + ), + ); + + final container = tester.widget( + find.descendant( + of: find.byType(AndroidGestureFeedback), + matching: find.byType(Container), + ), + ); + final decoration = container.decoration as BoxDecoration; + + expect(decoration.color, const Color(0x45181818)); + }); +} diff --git a/test/features/player/widgets/player_hold_speed_prompt_test.dart b/test/features/player/widgets/player_hold_speed_prompt_test.dart new file mode 100644 index 0000000..48c3cec --- /dev/null +++ b/test/features/player/widgets/player_hold_speed_prompt_test.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:smplayer/features/player/widgets/player_hold_speed_prompt.dart'; + +void main() { + testWidgets('hold speed prompt uses light background', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Stack( + children: [HoldSpeedPrompt(visible: true, isLeft: false, rate: 3)], + ), + ), + ); + + final container = tester.widget( + find.descendant( + of: find.byType(HoldSpeedPrompt), + matching: find.byType(Container), + ), + ); + final decoration = container.decoration as BoxDecoration; + + expect(decoration.color, const Color(0x45181818)); + }); +} diff --git a/test/features/player/widgets/player_video_widget_hold_speed_test.dart b/test/features/player/widgets/player_video_widget_hold_speed_test.dart new file mode 100644 index 0000000..9deb131 --- /dev/null +++ b/test/features/player/widgets/player_video_widget_hold_speed_test.dart @@ -0,0 +1,68 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:smplayer/features/player/widgets/player_hold_speed_prompt.dart'; +import 'package:smplayer/features/player/widgets/player_video_widget.dart'; + +import '../../../helpers/fake_player_engine.dart'; + +void main() { + testWidgets('keyboard hold speed prompt hides immediately on release', ( + tester, + ) async { + final player = FakePlayerEngine(playing: true); + final fit = ValueNotifier(BoxFit.contain); + final rates = []; + + await tester.pumpWidget( + MaterialApp( + home: SizedBox( + width: 800, + height: 450, + child: PlayerVideoWidget( + player: player, + fit: fit, + topBar: const [], + bottomBar: const SizedBox.shrink(), + overlays: const [], + onControlsAreaTap: null, + isFullscreen: false, + onToggleFullscreen: () async {}, + isPip: false, + keyboardEnabled: true, + currentRate: 1, + onSetRate: rates.add, + suppressControlsChrome: true, + ), + ), + ), + ); + await tester.pump(); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(const Duration(milliseconds: 200)); + + expect(_promptOpacity(tester), 1); + expect(rates.last, 3); + + await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + + expect(_promptOpacity(tester), 0); + expect(rates.last, 1); + + fit.dispose(); + await player.dispose(); + }); +} + +double _promptOpacity(WidgetTester tester) { + return tester + .widget( + find.descendant( + of: find.byType(HoldSpeedPrompt), + matching: find.byType(AnimatedOpacity), + ), + ) + .opacity; +} diff --git a/test/features/player/widgets/track_selector_button_test.dart b/test/features/player/widgets/track_selector_button_test.dart new file mode 100644 index 0000000..fa8c59b --- /dev/null +++ b/test/features/player/widgets/track_selector_button_test.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:smplayer/core/infra/player_engine/player_engine.dart'; +import 'package:smplayer/features/player/widgets/track_selector_button.dart'; + +import '../../../helpers/fake_player_engine.dart'; + +void main() { + testWidgets('音轨按钮由初始 tracks 决定显隐', (tester) async { + final player = FakePlayerEngine( + playing: true, + tracks: const PlayerTracks( + audio: [ + AudioTrack(id: '1'), + AudioTrack(id: '2'), + ], + ), + ); + addTearDown(player.dispose); + + await tester.pumpWidget( + MaterialApp( + home: AudioTracksButton( + player: player, + isPanelOpen: false, + onTogglePanel: () {}, + ), + ), + ); + + expect(find.byTooltip('音轨'), findsOneWidget); + }); + + testWidgets('字幕按钮响应 tracks stream 更新', (tester) async { + final player = FakePlayerEngine(playing: true); + addTearDown(player.dispose); + + await tester.pumpWidget( + MaterialApp( + home: SubtitleTracksButton( + player: player, + embySubtitles: const [], + isPanelOpen: false, + onTogglePanel: () {}, + ), + ), + ); + expect(find.byTooltip('字幕'), findsNothing); + + player.addTracks( + const PlayerTracks(subtitle: [SubtitleTrack(id: 'embedded')]), + ); + await tester.pump(); + + expect(find.byTooltip('字幕'), findsOneWidget); + }); +} diff --git a/test/features/settings/playback_settings_tab_engine_test.dart b/test/features/settings/playback_settings_tab_engine_test.dart new file mode 100644 index 0000000..82422d1 --- /dev/null +++ b/test/features/settings/playback_settings_tab_engine_test.dart @@ -0,0 +1,60 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:smplayer/features/settings/widgets/player_engine_row.dart'; +import 'package:smplayer/providers/player_engine_override_provider.dart'; + +void main() { + group('PlayerEngineRow.visibleOptions', () { + test('Android 含 4 项', () { + final opts = PlayerEngineRow.visibleOptions(isAndroid: true); + expect(opts, [ + PlayerEngineOverride.auto, + PlayerEngineOverride.fvp, + PlayerEngineOverride.mpv, + PlayerEngineOverride.media3, + ]); + }); + + test('桌面含 3 项,不含 media3', () { + final opts = PlayerEngineRow.visibleOptions(isAndroid: false); + expect(opts, [ + PlayerEngineOverride.auto, + PlayerEngineOverride.fvp, + PlayerEngineOverride.mpv, + ]); + expect(opts.contains(PlayerEngineOverride.media3), isFalse); + }); + }); + + group('PlayerEngineRow.coerceValue', () { + test('桌面 stale media3 → auto', () { + expect( + PlayerEngineRow.coerceValue( + PlayerEngineOverride.media3, + isAndroid: false, + ), + PlayerEngineOverride.auto, + ); + }); + + test('Android media3 保持 media3', () { + expect( + PlayerEngineRow.coerceValue( + PlayerEngineOverride.media3, + isAndroid: true, + ), + PlayerEngineOverride.media3, + ); + }); + + test('其它值在两个平台都原样保留', () { + for (final v in [ + PlayerEngineOverride.auto, + PlayerEngineOverride.fvp, + PlayerEngineOverride.mpv, + ]) { + expect(PlayerEngineRow.coerceValue(v, isAndroid: true), v); + expect(PlayerEngineRow.coerceValue(v, isAndroid: false), v); + } + }); + }); +} diff --git a/test/helpers/fake_player_engine.dart b/test/helpers/fake_player_engine.dart new file mode 100644 index 0000000..dbd34e3 --- /dev/null +++ b/test/helpers/fake_player_engine.dart @@ -0,0 +1,136 @@ +import 'dart:ui' as ui; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import 'package:smplayer/core/contracts/cancellation.dart'; +import 'package:smplayer/core/infra/player_engine/player_engine.dart'; + +class FakePlayerEngine implements PlayerEngine { + FakePlayerEngine({ + required bool playing, + PlayerTracks tracks = const PlayerTracks(), + }) { + state + ..playing = playing + ..tracks = tracks; + } + + final PlayerStreams _streams = PlayerStreams(); + final ValueNotifier _textureId = ValueNotifier(null); + final ValueNotifier _textureVersion = ValueNotifier(0); + final ValueNotifier _videoSize = ValueNotifier(null); + final ValueNotifier _debugStats = + ValueNotifier(null); + + @override + final PlayerState state = PlayerState(); + + int playCalls = 0; + int pauseCalls = 0; + + @override + PlayerStreams get stream => _streams; + + @override + ValueListenable get textureId => _textureId; + + @override + ValueListenable get textureVersion => _textureVersion; + + @override + ValueNotifier get videoSize => _videoSize; + + @override + String get displayName => 'fake'; + + @override + bool get usesPlatformSurface => false; + + @override + Future totalRxBytes() async => null; + + @override + bool isNetworkStreamError(String error) => false; + + @override + ValueListenable get debugStats => _debugStats; + + @override + void configureHardwareDecoding(String mode) {} + + @override + Future open( + String url, { + Duration start = Duration.zero, + bool play = true, + Map? headers, + CancellationToken? cancel, + }) async {} + + @override + Future play() async { + playCalls++; + state.playing = true; + stream.addPlaying(true); + } + + @override + Future pause() async { + pauseCalls++; + state.playing = false; + stream.addPlaying(false); + } + + @override + Future stop() async {} + + @override + Future seek(Duration position) async {} + + @override + Future setRate(double rate) async {} + + @override + Future setVolume(double volume) async {} + + @override + Future toggleMute() async {} + + @override + Future setAudioTrack(AudioTrack track) async {} + + @override + Future setSubtitleTrack(SubtitleTrack track) async {} + + void addTracks(PlayerTracks tracks) { + state.tracks = tracks; + stream.addTracks(tracks); + } + + @override + void setNativeSubtitleRendering(bool enabled) {} + + @override + void setSubtitleStyle({ + required double fontSize, + required double bgOpacity, + required int position, + }) {} + + @override + Widget buildVideoView(BuildContext context, ValueListenable fit) { + return const SizedBox.shrink(); + } + + Future getProperty(String name) async => ''; + + @override + Future dispose() async { + _streams.dispose(); + _textureId.dispose(); + _textureVersion.dispose(); + _videoSize.dispose(); + _debugStats.dispose(); + } +} diff --git a/test/providers/player_engine_override_provider_test.dart b/test/providers/player_engine_override_provider_test.dart new file mode 100644 index 0000000..14b6286 --- /dev/null +++ b/test/providers/player_engine_override_provider_test.dart @@ -0,0 +1,62 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:smplayer/providers/player_engine_override_provider.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const key = 'smplayer.player_engine_override'; + + ProviderContainer makeContainer() { + final c = ProviderContainer(); + addTearDown(c.dispose); + return c; + } + + test('默认(无持久化值)build 返回 auto', () async { + SharedPreferences.setMockInitialValues({}); + final c = makeContainer(); + final value = await c.read(playerEngineOverrideProvider.future); + expect(value, PlayerEngineOverride.auto); + }); + + test('setOverride(media3) 后 build 返回 media3 且持久化为 name', () async { + SharedPreferences.setMockInitialValues({}); + final c = makeContainer(); + await c.read(playerEngineOverrideProvider.future); + + await c + .read(playerEngineOverrideProvider.notifier) + .setOverride(PlayerEngineOverride.media3); + + expect( + c.read(playerEngineOverrideProvider).value, + PlayerEngineOverride.media3, + ); + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getString(key), 'media3'); + }); + + test('持久化值为 media3 时 build 读回 media3', () async { + SharedPreferences.setMockInitialValues({key: 'media3'}); + final c = makeContainer(); + final value = await c.read(playerEngineOverrideProvider.future); + expect(value, PlayerEngineOverride.media3); + }); + + test('损坏字符串容错返回 auto', () async { + SharedPreferences.setMockInitialValues({key: 'garbage-value'}); + final c = makeContainer(); + final value = await c.read(playerEngineOverrideProvider.future); + expect(value, PlayerEngineOverride.auto); + }); + + test('byName(media3) 不抛', () { + expect(() => PlayerEngineOverride.values.byName('media3'), returnsNormally); + expect( + PlayerEngineOverride.values.byName('media3'), + PlayerEngineOverride.media3, + ); + }); +} diff --git a/test/providers/sm_account_tmdb_auto_enable_test.dart b/test/providers/sm_account_tmdb_auto_enable_test.dart new file mode 100644 index 0000000..e1b7074 --- /dev/null +++ b/test/providers/sm_account_tmdb_auto_enable_test.dart @@ -0,0 +1,137 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:smplayer/core/domain/ports/sm_account_gateway.dart'; +import 'package:smplayer/providers/sm_account_provider.dart'; +import 'package:smplayer/providers/tmdb_settings_provider.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const tmdbEnabledKey = 'smplayer.tmdb_enabled'; + const tmdbAccessModeKey = 'smplayer.tmdb_access_mode'; + const tmdbApiKeyKey = 'smplayer.tmdb_api_key'; + const tmdbLanguageKey = 'smplayer.tmdb_language'; + + ProviderContainer makeContainer(SmLoginResult loginResult) { + final c = ProviderContainer( + overrides: [ + smAccountGatewayProvider.overrideWithValue( + _FakeSmAccountGateway(loginResult), + ), + ], + ); + addTearDown(c.dispose); + return c; + } + + test('VIP login enables TMDB through sm-server proxy', () async { + SharedPreferences.setMockInitialValues({ + tmdbEnabledKey: false, + tmdbAccessModeKey: TmdbAccessMode.official.name, + tmdbApiKeyKey: 'keep-me', + tmdbLanguageKey: 'en-US', + }); + final c = makeContainer( + const SmLoginResult( + ok: true, + email: 'vip@example.com', + accessToken: 'access', + refreshToken: 'refresh', + expiresIn: 900, + isVip: true, + ), + ); + + await c.read(smAccountProvider.future); + final result = await c + .read(smAccountProvider.notifier) + .login('vip@example.com', 'password'); + + expect(result.ok, isTrue); + final settings = await c.read(tmdbSettingsProvider.future); + expect(settings.enabled, isTrue); + expect(settings.accessMode, TmdbAccessMode.smAccount); + expect(settings.apiKey, 'keep-me'); + expect(settings.language, 'en-US'); + + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getBool(tmdbEnabledKey), isTrue); + expect(prefs.getString(tmdbAccessModeKey), TmdbAccessMode.smAccount.name); + }); + + test('non-VIP login leaves TMDB settings unchanged', () async { + SharedPreferences.setMockInitialValues({ + tmdbEnabledKey: false, + tmdbAccessModeKey: TmdbAccessMode.official.name, + }); + final c = makeContainer( + const SmLoginResult( + ok: true, + email: 'user@example.com', + accessToken: 'access', + refreshToken: 'refresh', + expiresIn: 900, + ), + ); + + await c.read(smAccountProvider.future); + final result = await c + .read(smAccountProvider.notifier) + .login('user@example.com', 'password'); + + expect(result.ok, isTrue); + final settings = await c.read(tmdbSettingsProvider.future); + expect(settings.enabled, isFalse); + expect(settings.accessMode, TmdbAccessMode.official); + + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getBool(tmdbEnabledKey), isFalse); + expect(prefs.getString(tmdbAccessModeKey), TmdbAccessMode.official.name); + }); +} + +class _FakeSmAccountGateway implements SmAccountGateway { + final SmLoginResult loginResult; + + _FakeSmAccountGateway(this.loginResult); + + @override + Future login({ + required String baseUrl, + required String email, + required String password, + }) async => loginResult; + + @override + Future logout({ + required String baseUrl, + required String refreshToken, + }) async {} + + @override + Future refreshAccessToken({ + required String baseUrl, + required String refreshToken, + }) async => const SmTokenRefreshResult(ok: false); + + @override + Future registerStart({ + required String baseUrl, + required String email, + }) async => SmActionResult.failure('unused'); + + @override + Future registerVerify({ + required String baseUrl, + required String email, + required String password, + required String code, + }) async => loginResult; + + @override + Future resendCode({ + required String baseUrl, + required String email, + }) async => SmActionResult.failure('unused'); +} diff --git a/test/router/player_route_transition_test.dart b/test/router/player_route_transition_test.dart new file mode 100644 index 0000000..024d402 --- /dev/null +++ b/test/router/player_route_transition_test.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; + +import 'package:smplayer/providers/appearance_provider.dart'; +import 'package:smplayer/router/app_router.dart'; +import 'package:smplayer/router/page_transitions.dart'; + +void main() { + group('player route transition regression', () { + late GoRouter router; + + setUp(() { + final container = ProviderContainer( + overrides: [initialLocationProvider.overrideWithValue('/')], + ); + router = container.read(appRouterProvider); + }); + + test('player route uses platform-aware player transition', () { + GoRoute? playerRoute; + for (final route in router.configuration.routes) { + if (route is GoRoute && route.path == '/player/:itemId') { + playerRoute = route; + break; + } + for (final sub in route.routes) { + if (sub is GoRoute && sub.path == '/player/:itemId') { + playerRoute = sub; + break; + } + } + if (playerRoute != null) break; + } + + expect( + playerRoute, + isNotNull, + reason: '/player/:itemId route must exist', + ); + + final page = playerRoute!.pageBuilder!( + _FakeBuildContext(), + GoRouterState( + router.configuration, + uri: Uri.parse('/player/test123'), + matchedLocation: '/player/test123', + pathParameters: const {'itemId': 'test123'}, + fullPath: '/player/:itemId', + pageKey: const ValueKey('/player/test123'), + ), + ); + + expect( + page, + isA(), + reason: 'player route must use a custom player transition', + ); + final transitionPage = page as CustomTransitionPage; + expect( + transitionPage.transitionsBuilder, + same(PageTransitions.player), + reason: 'player route must avoid horizontal motion on Android', + ); + expect(transitionPage.transitionDuration, PageTransitions.playerForward); + expect( + transitionPage.reverseTransitionDuration, + PageTransitions.playerReverse, + ); + }); + }); +} + +class _FakeBuildContext extends Fake implements BuildContext {} diff --git a/test/scripts/dev_android_cli_test.dart b/test/scripts/dev_android_cli_test.dart new file mode 100644 index 0000000..35cb8d4 --- /dev/null +++ b/test/scripts/dev_android_cli_test.dart @@ -0,0 +1,160 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('Android launcher forwards args and pins the FVP SDK', () async { + final project = await Directory.systemTemp.createTemp('smplayer-android-'); + addTearDown(() => project.delete(recursive: true)); + await File( + '${project.path}/pubspec.yaml', + ).writeAsString('version: 1.2.3\n'); + final staleFvpSdk = await _writeStaleFvpAndroidSdk(project); + + final bin = Directory('${project.path}/bin')..createSync(); + final log = File('${project.path}/flutter.log'); + await _writeFakeFlutter(bin, log); + + final environment = Map.of(Platform.environment) + ..['PATH'] = + '${bin.path}${Platform.isWindows ? ';' : ':'}' + '${Platform.environment['PATH'] ?? ''}' + ..['ANDROID_HOME'] = '' + ..['ANDROID_SDK_ROOT'] = '' + ..['LOCALAPPDATA'] = project.path + ..['SMPLAYER_TEST_LOG'] = log.path + ..['FVP_DEPS_LATEST'] = 'stale'; + final result = await Process.run(_dartExecutable, [ + File('scripts/dev_android.dart').absolute.path, + '--project-root', + project.path, + '--force', + '--offline', + '--', + '--release', + '--dart-define', + 'SMPLAYER_VERSION=9.9.9', + ], environment: environment); + + expect(result.exitCode, 0, reason: '${result.stderr}'); + expect('${result.stdout}', contains('test-device')); + expect('${result.stdout}', contains('App version: 1.2.3')); + final calls = await log.readAsString(); + expect(calls, contains('ARGS:pub get --offline')); + expect( + calls, + contains( + 'ARGS:run -d test-device --no-pub --release ' + '--dart-define SMPLAYER_VERSION=9.9.9', + ), + ); + expect(calls, contains('VERSION_CODE:1002003')); + expect(calls, contains('FVP:$_fvpDepsUrl')); + expect(calls, isNot(contains('LATEST:stale'))); + expect(staleFvpSdk.sdkDirectory.existsSync(), isFalse); + expect(staleFvpSdk.sdkArchive.existsSync(), isFalse); + expect(await staleFvpSdk.versionMarker.readAsString(), '0.37.0'); + if (!Platform.isWindows) { + expect(calls, contains('STORAGE:https://storage.flutter-io.cn')); + expect(calls, contains('PUB:https://pub.flutter-io.cn')); + } + }); +} + +Future<({File sdkArchive, Directory sdkDirectory, File versionMarker})> +_writeStaleFvpAndroidSdk(Directory project) async { + final fvpDirectory = Directory('${project.path}/fvp')..createSync(); + final fvpRootUriWithoutTrailingSlash = fvpDirectory.uri + .toString() + .replaceFirst(RegExp(r'/$'), ''); + final fvpAndroidDirectory = Directory('${fvpDirectory.path}/android') + ..createSync(); + final sdkDirectory = Directory( + '${fvpAndroidDirectory.path}/mdk-sdk/lib/cmake', + )..createSync(recursive: true); + await File('${sdkDirectory.path}/FindMDK.cmake').writeAsString('stale'); + final sdkArchive = File('${fvpAndroidDirectory.path}/mdk-sdk-android.7z'); + await sdkArchive.writeAsString('stale'); + final versionMarker = File( + '${fvpAndroidDirectory.path}/.smplayer-mdk-version', + ); + await versionMarker.writeAsString('0.36.0'); + + final packageConfigDirectory = Directory('${project.path}/.dart_tool') + ..createSync(); + await File( + '${packageConfigDirectory.path}/package_config.json', + ).writeAsString(''' +{ + "configVersion": 2, + "packages": [ + { + "name": "fvp", + "rootUri": ${_jsonString(fvpRootUriWithoutTrailingSlash)}, + "packageUri": "lib/", + "languageVersion": "3.0" + } + ] +} +'''); + + return ( + sdkArchive: sdkArchive, + sdkDirectory: Directory('${fvpAndroidDirectory.path}/mdk-sdk'), + versionMarker: versionMarker, + ); +} + +String _jsonString(String value) => '"${value.replaceAll('"', r'\"')}"'; + +Future _writeFakeFlutter(Directory bin, File log) async { + if (Platform.isWindows) { + await File('${bin.path}/flutter.bat').writeAsString('''@echo off +if "%~1 %~2"=="devices --machine" ( + echo [{"id":"test-device","targetPlatform":"android-arm64"}] + exit /b 0 +) +>>"%SMPLAYER_TEST_LOG%" echo ARGS:%* +>>"%SMPLAYER_TEST_LOG%" echo FVP:%FVP_DEPS_URL% +>>"%SMPLAYER_TEST_LOG%" echo LATEST:%FVP_DEPS_LATEST% +>>"%SMPLAYER_TEST_LOG%" echo STORAGE:%FLUTTER_STORAGE_BASE_URL% +>>"%SMPLAYER_TEST_LOG%" echo PUB:%PUB_HOSTED_URL% +>>"%SMPLAYER_TEST_LOG%" echo VERSION_CODE:%SMPLAYER_ANDROID_VERSION_CODE% +>>"%SMPLAYER_TEST_LOG%" echo REQUIRE_SIGNING:%SMPLAYER_REQUIRE_RELEASE_SIGNING% +'''); + return; + } + + final flutter = File('${bin.path}/flutter'); + await flutter.writeAsString(r'''#!/usr/bin/env bash +if [[ "$1 $2" == 'devices --machine' ]]; then + printf '[{"id":"test-device","targetPlatform":"android-arm64"}]\n' + exit 0 +fi +printf 'ARGS:%s\n' "$*" >> "$SMPLAYER_TEST_LOG" +printf 'FVP:%s\n' "$FVP_DEPS_URL" >> "$SMPLAYER_TEST_LOG" +printf 'LATEST:%s\n' "${FVP_DEPS_LATEST-}" >> "$SMPLAYER_TEST_LOG" +printf 'STORAGE:%s\n' "$FLUTTER_STORAGE_BASE_URL" >> "$SMPLAYER_TEST_LOG" +printf 'PUB:%s\n' "$PUB_HOSTED_URL" >> "$SMPLAYER_TEST_LOG" +printf 'VERSION_CODE:%s\n' "$SMPLAYER_ANDROID_VERSION_CODE" >> "$SMPLAYER_TEST_LOG" +printf 'REQUIRE_SIGNING:%s\n' "$SMPLAYER_REQUIRE_RELEASE_SIGNING" >> "$SMPLAYER_TEST_LOG" +'''); + await Process.run('chmod', ['+x', flutter.path]); +} + +const _fvpDepsUrl = + 'https://github.com/wang-bin/mdk-sdk/releases/download/v0.37.0'; + +String get _dartExecutable { + final currentExecutable = File(Platform.resolvedExecutable); + if (!currentExecutable.path.toLowerCase().contains('flutter_tester')) { + return currentExecutable.path; + } + + final flutterCacheDirectory = currentExecutable.parent.parent.parent.parent; + final executableName = Platform.isWindows ? 'dart.exe' : 'dart'; + return File( + '${flutterCacheDirectory.path}${Platform.pathSeparator}dart-sdk' + '${Platform.pathSeparator}bin${Platform.pathSeparator}$executableName', + ).path; +} diff --git a/test/scripts/version_cli_test.dart b/test/scripts/version_cli_test.dart new file mode 100644 index 0000000..5999be0 --- /dev/null +++ b/test/scripts/version_cli_test.dart @@ -0,0 +1,86 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test( + 'version CLI resolves tags and pubspec fallback', + () async { + final project = await Directory.systemTemp.createTemp( + 'smplayer-version-', + ); + addTearDown(() => project.delete(recursive: true)); + await File( + '${project.path}/pubspec.yaml', + ).writeAsString('version: 1.2.3+4\n'); + + await _git(project.path, ['init', '-q']); + await _git(project.path, ['config', 'user.email', 'test@example.com']); + await _git(project.path, ['config', 'user.name', 'Version Test']); + await _git(project.path, ['add', 'pubspec.yaml']); + await _git(project.path, ['commit', '-qm', 'initial']); + await _git(project.path, ['tag', 'v1.9.9']); + await _git(project.path, ['tag', 'v1.10.0']); + + expect(await _run(project.path, ['current', 'pc']), '1.10.0'); + expect(await _run(project.path, ['current', 'macos']), '1.10.0'); + expect(await _run(project.path, ['current', 'android']), '1.2.3'); + expect( + await _run(project.path, ['next', 'windows']), + 'windows 1.10.1 v1.10.0', + ); + expect( + await _run(project.path, ['next', 'android']), + 'android 1.2.3 pubspec:1.2.3', + ); + expect(await _run(project.path, ['build-number', '1.2.3+4']), '1002003'); + expect( + await _run(project.path, ['tag', 'both', '--dry-run']), + '[windows] v1.10.0 -> v1.10.1\n' + ' DryRun: skip create/push.\n' + '[android] pubspec:1.2.3 -> android-v1.2.3\n' + ' DryRun: skip create/push.', + ); + }, + timeout: const Timeout(Duration(minutes: 2)), + ); +} + +Future _run(String projectRoot, List arguments) async { + final result = await Process.run(_dartExecutablePath(), [ + File('scripts/version.dart').absolute.path, + ...arguments, + '--project-root', + projectRoot, + ]); + expect(result.exitCode, 0, reason: '${result.stderr}'); + return '${result.stdout}'.trim(); +} + +String _dartExecutablePath() { + final resolvedExecutable = File(Platform.resolvedExecutable); + final executableName = resolvedExecutable.uri.pathSegments.last.toLowerCase(); + if (!executableName.startsWith('flutter_tester')) { + return resolvedExecutable.path; + } + + var flutterRoot = resolvedExecutable.parent; + for (var parentLevel = 0; parentLevel < 5; parentLevel++) { + flutterRoot = flutterRoot.parent; + } + final executableFileName = Platform.isWindows ? 'dart.exe' : 'dart'; + return File( + '${flutterRoot.path}${Platform.pathSeparator}bin${Platform.pathSeparator}' + 'cache${Platform.pathSeparator}dart-sdk${Platform.pathSeparator}bin' + '${Platform.pathSeparator}$executableFileName', + ).path; +} + +Future _git(String projectRoot, List arguments) async { + final result = await Process.run( + 'git', + arguments, + workingDirectory: projectRoot, + ); + expect(result.exitCode, 0, reason: '${result.stderr}'); +} diff --git a/test/shared/mappers/media_image_url_landscape_card_test.dart b/test/shared/mappers/media_image_url_landscape_card_test.dart new file mode 100644 index 0000000..c1ef229 --- /dev/null +++ b/test/shared/mappers/media_image_url_landscape_card_test.dart @@ -0,0 +1,287 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:smplayer/core/contracts/library.dart'; +import 'package:smplayer/shared/mappers/media_image_url.dart'; + +const _baseUrl = 'https://emby.example.com'; +const _token = 'test-token'; + +EmbyRawItem _makeItem({ + String id = 'item-1', + String? type, + String? primaryImageTag, + Map? imageTags, + List? backdropImageTags, + Map extra = const {}, +}) { + return EmbyRawItem( + Id: id, + Name: 'Test', + Type: type, + PrimaryImageTag: primaryImageTag, + ImageTags: imageTags, + BackdropImageTags: backdropImageTags, + extra: extra, + ); +} + +void main() { + group('EmbyImageUrl.landscapeCard fallback chain', () { + test('returns Primary when PrimaryImageTag is present', () { + final item = _makeItem(primaryImageTag: 'primary-tag-1'); + + final url = EmbyImageUrl.landscapeCard( + baseUrl: _baseUrl, + token: _token, + item: item, + ); + + expect(url, isNotNull); + expect(url, contains('/Images/Primary')); + expect(url, contains('tag=primary-tag-1')); + }); + + test('returns Primary from ImageTags map when PrimaryImageTag is null', () { + final item = _makeItem(imageTags: {'Primary': 'map-primary-tag'}); + + final url = EmbyImageUrl.landscapeCard( + baseUrl: _baseUrl, + token: _token, + item: item, + ); + + expect(url, isNotNull); + expect(url, contains('/Images/Primary')); + expect(url, contains('tag=map-primary-tag')); + }); + + test('falls back to Thumb when Primary is missing', () { + final item = _makeItem(imageTags: {'Thumb': 'thumb-tag-1'}); + + final url = EmbyImageUrl.landscapeCard( + baseUrl: _baseUrl, + token: _token, + item: item, + ); + + expect(url, isNotNull); + expect(url, contains('/Images/Thumb')); + expect(url, contains('tag=thumb-tag-1')); + }); + + test('falls back to Backdrop when Primary and Thumb are missing', () { + final item = _makeItem(backdropImageTags: ['backdrop-tag-1']); + + final url = EmbyImageUrl.landscapeCard( + baseUrl: _baseUrl, + token: _token, + item: item, + ); + + expect(url, isNotNull); + expect(url, contains('/Images/Backdrop')); + expect(url, contains('tag=backdrop-tag-1')); + }); + + test('falls back to parent Thumb from extra JSON', () { + final item = _makeItem( + extra: { + 'ParentThumbItemId': 'parent-1', + 'ParentThumbImageTag': 'parent-thumb-tag', + }, + ); + + final url = EmbyImageUrl.landscapeCard( + baseUrl: _baseUrl, + token: _token, + item: item, + ); + + expect(url, isNotNull); + expect(url, contains('/Items/parent-1/Images/Thumb')); + expect(url, contains('tag=parent-thumb-tag')); + }); + + test('falls back to parent Backdrop from extra JSON', () { + final item = _makeItem( + extra: { + 'ParentBackdropItemId': 'parent-2', + 'ParentBackdropImageTags': ['parent-backdrop-tag'], + }, + ); + + final url = EmbyImageUrl.landscapeCard( + baseUrl: _baseUrl, + token: _token, + item: item, + ); + + expect(url, isNotNull); + expect(url, contains('/Items/parent-2/Images/Backdrop')); + expect(url, contains('tag=parent-backdrop-tag')); + }); + + test('falls back to series Primary from extra JSON as last resort', () { + final item = _makeItem( + extra: { + 'SeriesId': 'series-1', + 'SeriesPrimaryImageTag': 'series-primary-tag', + }, + ); + + final url = EmbyImageUrl.landscapeCard( + baseUrl: _baseUrl, + token: _token, + item: item, + ); + + expect(url, isNotNull); + expect(url, contains('/Items/series-1/Images/Primary')); + expect(url, contains('tag=series-primary-tag')); + }); + + test('returns null when all image sources are absent', () { + final item = _makeItem(); + + final url = EmbyImageUrl.landscapeCard( + baseUrl: _baseUrl, + token: _token, + item: item, + ); + + expect(url, isNull); + }); + + test('respects priority: Primary beats Thumb beats Backdrop', () { + final item = _makeItem( + primaryImageTag: 'primary-tag', + imageTags: {'Primary': 'primary-tag', 'Thumb': 'thumb-tag'}, + backdropImageTags: ['backdrop-tag'], + ); + + final url = EmbyImageUrl.landscapeCard( + baseUrl: _baseUrl, + token: _token, + item: item, + ); + + expect(url, contains('/Images/Primary')); + expect(url, contains('tag=primary-tag')); + }); + + test('parent Thumb takes priority over parent Backdrop', () { + final item = _makeItem( + extra: { + 'ParentThumbItemId': 'parent-1', + 'ParentThumbImageTag': 'parent-thumb-tag', + 'ParentBackdropItemId': 'parent-2', + 'ParentBackdropImageTags': ['parent-backdrop-tag'], + 'SeriesId': 'series-1', + 'SeriesPrimaryImageTag': 'series-primary-tag', + }, + ); + + final url = EmbyImageUrl.landscapeCard( + baseUrl: _baseUrl, + token: _token, + item: item, + ); + + expect(url, contains('/Items/parent-1/Images/Thumb')); + }); + + test('forwards maxWidth and quality parameters', () { + final item = _makeItem(primaryImageTag: 'tag'); + + final url = EmbyImageUrl.landscapeCard( + baseUrl: _baseUrl, + token: _token, + item: item, + maxWidth: 500, + quality: 80, + ); + + expect(url, contains('maxWidth=500')); + expect(url, contains('quality=80')); + }); + }); + + group('EmbyImageUrl.landscapeCard movie-specific ordering', () { + test('movie prefers Backdrop over Primary', () { + final item = _makeItem( + type: 'Movie', + primaryImageTag: 'poster-tag', + backdropImageTags: ['backdrop-tag'], + ); + + final url = EmbyImageUrl.landscapeCard( + baseUrl: _baseUrl, + token: _token, + item: item, + ); + + expect(url, contains('/Images/Backdrop')); + expect(url, contains('tag=backdrop-tag')); + }); + + test('movie falls back to Thumb when Backdrop is missing', () { + final item = _makeItem( + type: 'Movie', + primaryImageTag: 'poster-tag', + imageTags: {'Primary': 'poster-tag', 'Thumb': 'thumb-tag'}, + ); + + final url = EmbyImageUrl.landscapeCard( + baseUrl: _baseUrl, + token: _token, + item: item, + ); + + expect(url, contains('/Images/Thumb')); + expect(url, contains('tag=thumb-tag')); + }); + + test('movie falls back to Primary (poster) as last resort', () { + final item = _makeItem(type: 'Movie', primaryImageTag: 'poster-tag'); + + final url = EmbyImageUrl.landscapeCard( + baseUrl: _baseUrl, + token: _token, + item: item, + ); + + expect(url, contains('/Images/Primary')); + expect(url, contains('tag=poster-tag')); + }); + + test('movie returns tagless Primary URL when all tag sources absent', () { + final item = _makeItem(type: 'Movie'); + + final url = EmbyImageUrl.landscapeCard( + baseUrl: _baseUrl, + token: _token, + item: item, + ); + + expect(url, isNotNull); + expect(url, contains('/Items/item-1/Images/Primary')); + expect(url, isNot(contains('tag='))); + }); + + test('episode still prefers Primary over Backdrop', () { + final item = _makeItem( + type: 'Episode', + primaryImageTag: 'screenshot-tag', + backdropImageTags: ['backdrop-tag'], + ); + + final url = EmbyImageUrl.landscapeCard( + baseUrl: _baseUrl, + token: _token, + item: item, + ); + + expect(url, contains('/Images/Primary')); + expect(url, contains('tag=screenshot-tag')); + }); + }); +} diff --git a/test/shared/version_grouping_test.dart b/test/shared/version_grouping_test.dart new file mode 100644 index 0000000..15bcba4 --- /dev/null +++ b/test/shared/version_grouping_test.dart @@ -0,0 +1,141 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:smplayer/shared/widgets/version_grouping.dart'; +import 'package:smplayer/shared/widgets/version_source_card.dart'; + +void main() { + VersionSourceCardData createEntry({ + required String serverName, + required String sourceKey, + String? serverKey, + int? sizeBytes = 1000, + String resolutionLabel = '4K', + bool isCurrent = false, + bool selected = false, + }) { + return VersionSourceCardData( + topLabel: '$resolutionLabel SDR', + serverName: serverName, + serverKey: serverKey ?? sourceKey.split('|').first, + sourceKey: sourceKey, + sizeBytes: sizeBytes, + resolutionLabel: resolutionLabel, + isCurrent: isCurrent, + selected: selected, + ); + } + + group('groupVersionEntries', () { + test('merges equal size and resolution in source order', () { + final firstEntry = createEntry( + serverName: 'Server A', + sourceKey: 'server-a|source-a', + ); + final secondEntry = createEntry( + serverName: 'Server B', + sourceKey: 'server-b|source-b', + ); + + final groups = groupVersionEntries([firstEntry, secondEntry]); + + expect(groups, hasLength(1)); + expect(groups.single.representative, same(firstEntry)); + expect(groups.single.members, [firstEntry, secondEntry]); + expect(groups.single.serverNames, ['Server A', 'Server B']); + }); + + test('does not merge sources without a positive file size', () { + final missingSizeEntry = createEntry( + serverName: 'Server A', + sourceKey: 'server-a|source-a', + sizeBytes: null, + ); + final zeroSizeEntry = createEntry( + serverName: 'Server B', + sourceKey: 'server-b|source-b', + sizeBytes: 0, + ); + + final groups = groupVersionEntries([missingSizeEntry, zeroSizeEntry]); + + expect(groups, hasLength(2)); + expect(groups[0].members, [missingSizeEntry]); + expect(groups[1].members, [zeroSizeEntry]); + }); + + test('keeps different resolution labels in separate groups', () { + final ultraHdEntry = createEntry( + serverName: 'Server A', + sourceKey: 'server-a|source-a', + ); + final fullHdEntry = createEntry( + serverName: 'Server B', + sourceKey: 'server-b|source-b', + resolutionLabel: '1080P', + ); + + final groups = groupVersionEntries([ultraHdEntry, fullHdEntry]); + + expect(groups, hasLength(2)); + }); + + test('does not merge duplicate files from the same server', () { + final firstEntry = createEntry( + serverName: 'Server A', + sourceKey: 'server-a|source-a', + ); + final secondEntry = createEntry( + serverName: 'Server A', + sourceKey: 'server-a|source-b', + ); + + final groups = groupVersionEntries([firstEntry, secondEntry]); + + expect(groups, hasLength(2)); + }); + + test( + 'uses selected member as visible server without moving representative', + () { + final representative = createEntry( + serverName: 'Server A', + sourceKey: 'server-a|source-a', + ); + final selectedMember = createEntry( + serverName: 'Server B', + sourceKey: 'server-b|source-b', + selected: true, + ); + + final group = groupVersionEntries([ + representative, + selectedMember, + ]).single; + + expect(group.representative, same(representative)); + expect(group.activeMember, same(selectedMember)); + expect(group.displayData.serverName, 'Server B'); + expect(group.displayData.sourceKey, 'server-b|source-b'); + expect(group.displayData.selected, isTrue); + expect(group.displayData.mergedServerNames, ['Server A', 'Server B']); + }, + ); + + test('propagates current state from any member to collapsed card', () { + final representative = createEntry( + serverName: 'Server A', + sourceKey: 'server-a|source-a', + ); + final currentMember = createEntry( + serverName: 'Server B', + sourceKey: 'server-b|source-b', + isCurrent: true, + ); + + final group = groupVersionEntries([representative, currentMember]).single; + + expect(group.hasCurrentMember, isTrue); + expect(group.displayData.isCurrent, isTrue); + expect(group.displayData.serverName, 'Server B'); + }); + }); +} diff --git a/test/shared/widgets/material_controls_select_test.dart b/test/shared/widgets/material_controls_select_test.dart new file mode 100644 index 0000000..a32447c --- /dev/null +++ b/test/shared/widgets/material_controls_select_test.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:forui/forui.dart'; +import 'package:smplayer/shared/widgets/setting_select.dart'; + +void main() { + Widget host({ + required String value, + required ValueChanged onChanged, + }) { + return MaterialApp( + home: FTheme( + data: FThemes.zinc.light.desktop, + child: Scaffold( + body: Center( + child: SettingSelect( + value: value, + options: const ['a', 'b', 'c'], + labelOf: (v) => 'label-$v', + onChanged: onChanged, + ), + ), + ), + ), + ); + } + + testWidgets('触发行显示选中项 label', (tester) async { + await tester.pumpWidget(host(value: 'b', onChanged: (_) {})); + await tester.pumpAndSettle(); + expect(find.text('label-b'), findsWidgets); + }); + + testWidgets('选另一项触发 onChanged', (tester) async { + String? picked; + await tester.pumpWidget(host(value: 'a', onChanged: (v) => picked = v)); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(SettingSelect)); + await tester.pumpAndSettle(); + + await tester.tap(find.text('label-c').last); + await tester.pumpAndSettle(); + + expect(picked, 'c'); + }); + + testWidgets('Select 可放在 Row 的 trailing 位置', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: FTheme( + data: FThemes.zinc.light.desktop, + child: Scaffold( + body: SizedBox( + width: 480, + child: Row( + children: [ + const Expanded(child: Text('设置项')), + SettingSelect( + value: 'b', + options: const ['a', 'b', 'c'], + labelOf: (v) => 'label-$v', + onChanged: (_) {}, + ), + ], + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('label-b'), findsWidgets); + expect(tester.takeException(), isNull); + }); +} diff --git a/windows/.gitignore b/windows/.gitignore new file mode 100644 index 0000000..d492d0d --- /dev/null +++ b/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/windows/CMakeLists.txt b/windows/CMakeLists.txt new file mode 100644 index 0000000..2dbcf74 --- /dev/null +++ b/windows/CMakeLists.txt @@ -0,0 +1,108 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(smplayer LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "smplayer") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(VERSION 3.14...3.25) + +# Define build configuration option. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() +# Define settings for the Profile build mode. +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/windows/flutter/CMakeLists.txt b/windows/flutter/CMakeLists.txt new file mode 100644 index 0000000..903f489 --- /dev/null +++ b/windows/flutter/CMakeLists.txt @@ -0,0 +1,109 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + ${FLUTTER_TARGET_PLATFORM} $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/windows/runner/CMakeLists.txt b/windows/runner/CMakeLists.txt new file mode 100644 index 0000000..af622a5 --- /dev/null +++ b/windows/runner/CMakeLists.txt @@ -0,0 +1,44 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Runner sources contain UTF-8 (non-ASCII) comments; force MSVC to read them as +# UTF-8 regardless of the system code page to avoid C4819 under /WX. +target_compile_options(${BINARY_NAME} PRIVATE "/utf-8") + +# Add preprocessor definitions for the build version. +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/windows/runner/Runner.rc b/windows/runner/Runner.rc new file mode 100644 index 0000000..2c0b611 --- /dev/null +++ b/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD +#else +#define VERSION_AS_NUMBER 1,0,0,0 +#endif + +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "com.smemby" "\0" + VALUE "FileDescription", "smplayer" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "smplayer" "\0" + VALUE "LegalCopyright", "Copyright (C) 2026 com.smemby. All rights reserved." "\0" + VALUE "OriginalFilename", "smplayer.exe" "\0" + VALUE "ProductName", "smplayer" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/windows/runner/flutter_window.cpp b/windows/runner/flutter_window.cpp new file mode 100644 index 0000000..bbe7d78 --- /dev/null +++ b/windows/runner/flutter_window.cpp @@ -0,0 +1,68 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + + flutter_controller_->engine()->SetNextFrameCallback([&]() { + this->Show(); + }); + + + flutter_controller_->ForceRedraw(); + + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/windows/runner/flutter_window.h b/windows/runner/flutter_window.h new file mode 100644 index 0000000..6a49853 --- /dev/null +++ b/windows/runner/flutter_window.h @@ -0,0 +1,33 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + + +class FlutterWindow : public Win32Window { + public: + + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + + flutter::DartProject project_; + + + std::unique_ptr flutter_controller_; +}; + +#endif diff --git a/windows/runner/main.cpp b/windows/runner/main.cpp new file mode 100644 index 0000000..654b9cb --- /dev/null +++ b/windows/runner/main.cpp @@ -0,0 +1,46 @@ +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" +#include "app_links/app_links_plugin_c_api.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + if (SendAppLinkToInstance()) { + return EXIT_SUCCESS; + } + + + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = + GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + if (!window.Create(L"smplayer", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/windows/runner/resource.h b/windows/runner/resource.h new file mode 100644 index 0000000..9224fe6 --- /dev/null +++ b/windows/runner/resource.h @@ -0,0 +1,13 @@ + + +#define IDI_APP_ICON 101 + + +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/windows/runner/resources/app_icon.ico b/windows/runner/resources/app_icon.ico new file mode 100644 index 0000000..4d48a58 Binary files /dev/null and b/windows/runner/resources/app_icon.ico differ diff --git a/windows/runner/runner.exe.manifest b/windows/runner/runner.exe.manifest new file mode 100644 index 0000000..153653e --- /dev/null +++ b/windows/runner/runner.exe.manifest @@ -0,0 +1,14 @@ + + + + + PerMonitorV2 + + + + + + + + + diff --git a/windows/runner/utils.cpp b/windows/runner/utils.cpp new file mode 100644 index 0000000..fd030dc --- /dev/null +++ b/windows/runner/utils.cpp @@ -0,0 +1,65 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + unsigned int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + -1, nullptr, 0, nullptr, nullptr) + -1; + int input_length = (int)wcslen(utf16_string); + std::string utf8_string; + if (target_length == 0 || target_length > utf8_string.max_size()) { + return utf8_string; + } + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, utf8_string.data(), target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/windows/runner/utils.h b/windows/runner/utils.h new file mode 100644 index 0000000..469e8a3 --- /dev/null +++ b/windows/runner/utils.h @@ -0,0 +1,16 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + + +void CreateAndAttachConsole(); + + +std::string Utf8FromUtf16(const wchar_t* utf16_string); + + +std::vector GetCommandLineArguments(); + +#endif diff --git a/windows/runner/win32_window.cpp b/windows/runner/win32_window.cpp new file mode 100644 index 0000000..b3f43f1 --- /dev/null +++ b/windows/runner/win32_window.cpp @@ -0,0 +1,277 @@ +#include "win32_window.h" + +#include +#include + +#include "resource.h" + +namespace { + + +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + + +constexpr const wchar_t kGetPreferredBrightnessRegKey[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; + + +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + + +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + + +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + } + FreeLibrary(user32_module); +} + +} + + +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + + const wchar_t* GetWindowClass(); + + + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::Create(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + UpdateTheme(window); + + return OnCreate(); +} + +bool Win32Window::Show() { + return ShowWindow(window_handle_, SW_SHOWNORMAL); +} + + +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + + case WM_DWMCOLORIZATIONCOLORCHANGED: + UpdateTheme(hwnd); + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + + return true; +} + +void Win32Window::OnDestroy() { + +} + +void Win32Window::UpdateTheme(HWND const window) { + DWORD light_mode; + DWORD light_mode_size = sizeof(light_mode); + LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, + kGetPreferredBrightnessRegValue, + RRF_RT_REG_DWORD, nullptr, &light_mode, + &light_mode_size); + + if (result == ERROR_SUCCESS) { + BOOL enable_dark_mode = light_mode == 0; + DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, + &enable_dark_mode, sizeof(enable_dark_mode)); + } +} diff --git a/windows/runner/win32_window.h b/windows/runner/win32_window.h new file mode 100644 index 0000000..747869a --- /dev/null +++ b/windows/runner/win32_window.h @@ -0,0 +1,88 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + + +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + + bool Create(const std::wstring& title, const Point& origin, const Size& size); + + + bool Show(); + + + void Destroy(); + + + void SetChildContent(HWND content); + + + HWND GetHandle(); + + + void SetQuitOnClose(bool quit_on_close); + + + RECT GetClientArea(); + + protected: + + + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + + virtual bool OnCreate(); + + + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + + static void UpdateTheme(HWND const window); + + bool quit_on_close_ = false; + + + HWND window_handle_ = nullptr; + + + HWND child_content_ = nullptr; +}; + +#endif