From 91366f24b8d465b1b4174a0ab3c9dd81df1267fa Mon Sep 17 00:00:00 2001 From: beyond Date: Fri, 28 Aug 2026 21:33:58 +0800 Subject: [PATCH] =?UTF-8?q?1=E3=80=81=E4=BF=AE=E5=A4=8D=E8=BF=9B=E5=BA=A6?= =?UTF-8?q?=E6=9D=A1=E6=97=A0=E6=B3=95=E6=8B=96=E5=8A=A8=E9=97=AE=E9=A2=98?= =?UTF-8?q?=EF=BC=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2、修复长按快进问题 3、增加亮度、音量调节 --- PROJECT_HANDOFF.md | 238 +++++++++++++++ app/build.gradle.kts | 1 + .../screens/VideoPlayerScreen.kt | 280 +++++++++++++----- .../myapplication/ui/design/LumaComponents.kt | 141 ++++----- app/src/main/res/values-en/strings.xml | 3 + app/src/main/res/values/strings.xml | 3 + .../myapplication/screens/SpeedMonitorTest.kt | 31 ++ 7 files changed, 534 insertions(+), 163 deletions(-) create mode 100644 PROJECT_HANDOFF.md create mode 100644 app/src/test/kotlin/com/example/myapplication/screens/SpeedMonitorTest.kt diff --git a/PROJECT_HANDOFF.md b/PROJECT_HANDOFF.md new file mode 100644 index 0000000..d49e61d --- /dev/null +++ b/PROJECT_HANDOFF.md @@ -0,0 +1,238 @@ +# LumaPlayer 项目交接说明(移交下一 AI 开发者) + +> 本文档是给下一位接手本项目的 AI 开发者/工程师的交接说明,请在动手前完整阅读。 +> 交接时间:2026-08-28 | 工作目录:`F:\lumaplayerAPP\lumaplayer` | Git 分支:`master` + +--- + +## 1. 项目是什么 + +**LumaPlayer** 是一款 Android(Kotlin + Jetpack Compose)的 **Emby 媒体库客户端**: + +- 用户可添加 / 管理多个 Emby 服务器(支持 HTTP / HTTPS、登录认证、API Key)。 +- 浏览媒体库(电影 / 剧集)、查看媒体详情、在线流媒体播放(Media3 ExoPlayer,支持 HLS)。 +- 支持搜索、收藏、播放记录、图标库管理、主题 / 语言 / 玻璃风格设置等。 +- UI 主打「液态玻璃 / 毛玻璃」设计风格,采用 Kyant0 Backdrop + 自定义降级玻璃。 + +目前处于**可构建、可运行**状态(Debug / Lint / Unit 通过,API 35 模拟器已回归),但**仍有未提交的工作区改动**(见第 6 节)。 + +--- + +## 2. 技术栈与版本 + +| 项 | 值 | +| --- | --- | +| 语言 | Kotlin + 少量 Java(Room 实体 / DAO) | +| UI | Jetpack Compose(Material 3) | +| 视频播放 | AndroidX Media3 ExoPlayer 1.5.1(+ HLS) | +| 网络 | Retrofit 2.11.0 + OkHttp 4.12.0 + Gson | +| 本地存储 | Room 2.8.4(数据库名 `emby_player.db`,当前 version 6) | +| 导航 | Navigation Compose 2.10.0 | +| 图片 | Coil 2.7.0 + Palette | +| 玻璃 / 液态 | `io.github.kyant0:backdrop:2.0.1`、`io.github.kyant0:shapes:1.2.1`、`top.yukonga.miuix.kmp:miuix-ui:0.9.4-rc01` | +| Gradle | 9.5.0(wrapper) | +| AGP | `com.android.application` 9.0.1 | +| Kotlin | 2.3.20(`org.jetbrains.kotlin.plugin.compose`) | +| compileSdk / targetSdk / minSdk | 35 / 35 / 24 | +| Java 兼容 | VERSION_21 | + +> 仓库镜像源:`settings.gradle.kts` 使用阿里云镜像 + google() + mavenCentral();Gradle 发行版走腾讯云镜像(`gradle-wrapper.properties`)。 + +--- + +## 3. 目录结构 + +``` +lumaplayer/ +├── settings.gradle.kts / build.gradle.kts / gradle.properties / local.properties +├── gradle/ +│ ├── libs.versions.toml # 版本目录(当前只有少量条目) +│ ├── gradle-daemon-jvm.properties +│ └── wrapper/ # Gradle 9.5.0 wrapper(腾讯镜像) +├── memory/ # 项目记忆库(重要!见第 7 节) +│ ├── MEMORY.md # 记忆索引 +│ ├── projects.md # 项目状态 / 变更历史 +│ ├── 2026-08-2x.md # 每日变更记录 +│ ├── lessons.md # 踩坑教训(必读) +│ └── infra.md # 基础设施速查 +└── app/src/main/ + ├── AndroidManifest.xml + ├── java/com/example/myapplication/data/ # Room 实体/DAO(Java) + │ ├── AppDatabase.java # DB 版本 6,含 1→6 迁移 + │ ├── ServerEntity.java / ServerDao.java + │ └── IconLibraryEntity.java / IconLibraryDao.java + ├── kotlin/com/example/myapplication/ + │ ├── MainActivity.kt # 启动入口,初始化主题/语言/Repository + │ ├── MainApp.kt # 根 Composable,玻璃 Backdrop 源层 + 底栏 + NavHost + │ ├── data/ # Emby API 与业务数据层 + │ │ ├── EmbyApiModels.kt / EmbyApiService.kt / EmbyRepository.kt + │ │ ├── EmbyServer.kt # EmbyServer 模型(fullUrl 拼装逻辑) + │ │ ├── ServerRepository.kt / IconLibrary.kt + │ ├── navigation/ + │ │ ├── AppRoute.kt # 路由定义(sealed interface) + │ │ ├── AppNavHost.kt # 导航宿主 + 各目的地 Composable + │ │ └── NavigationTransitions.kt + │ ├── screens/ # 各页面 + │ │ ├── HomeScreen.kt / LibraryScreen.kt / RecordsScreen.kt + │ │ ├── SearchScreen.kt / MediaDetailScreen.kt / VideoPlayerScreen.kt + │ │ ├── SettingsScreen.kt / SettingsChildScreens.kt + │ │ └── library/IconLibraryManagerScreen.kt + │ └── ui/ + │ ├── AppTheme.kt # 主题 / 玻璃模式(Liquid/Frosted)全局状态与持久化 + │ ├── AppLanguage.kt / AppBottomBar.kt / ImmersiveBackground.kt + │ ├── LiquidGlass.kt # 玻璃控件组件库(对话框、表单、菜单等) + │ ├── design/ # 设计系统 + │ │ ├── LumaDesignSystem.kt # 颜色/令牌/GlassSpec/LumaGlassRuntime/rememberLumaLayerBackdrop + │ │ ├── LumaComponents.kt # 公共组件(加载/空态/按钮/分段等) + │ │ └── LumaIcons.kt + │ └── liquidglass/ # 液态底栏专有实现 + │ ├── LiquidBottomTabs.kt / LiquidBottomTab.kt + │ └── DampedDragAnimation.kt / DragGestureInspector.kt / InteractiveHighlight.kt + └── res/ + ├── drawable/ # aurora_* 图标子集(大量 XML selector) + ├── values/strings.xml(中文)、values-en/strings.xml(英文) # 双语 + └── mipmap-*/ 图标 +``` + +> 注意:应用包名/命名空间仍是默认的 `com.example.myapplication`,尚未改名为 `com.luma.player` 之类。是否改名属可选优化,改动会涉及 namespace、applicationId、Manifest、Room 相关路径。 + +--- + +## 4. 核心架构 + +### 4.1 数据层(`data/`) + +- **Room 数据库 `emby_player.db`**:表 `servers` 与 `icon_libraries`。 + - 版本 **6**,定义了 1→2、2→3、3→4、4→5、5→6 的迁移(见 `AppDatabase.java`)。 + - `ServerEntity`(Java)字段:id、name、protocol、host、basePath、port、username、password、note、accessToken、userId、iconUrl、isSelected。 + - protocol(http/https)是 5→6 迁移新增的**可空列**,模型中读取时兜底为 `https`。 +- **Emby 业务层都由 `EmbyRepository`(object 单例)** 承载,主要能力: + - 认证:`AuthenticateByName`(用户名密码换 token)。 + - 媒体库:`Library/MediaFolders`、`Users/{userId}/Items`、`Items/{itemId}/PlaybackInfo` 等。 + - 图片 URL 拼装:`buildImageUrl` / `buildBackdropUrl` / `buildEpisodeImageUrl` / `buildPersonImageUrl`。 + - 在线检测:`checkOnline`(`System/Info/Public`)。 + - **播放进度回报**:内部用 `Channel` 串行队列 + 工作协程,把 开始 / 进度 / 停止 事件按 Emby Playback Check-in 约定上报;进度按 10 秒或位置变化阈值上报。 +- `EmbyServer.fullUrl`:统一负责协议、端口、basePath 的拼接与 `http://`/`https://` 前缀去重。 + +### 4.2 导航(`navigation/`) + +- `AppRoute`(sealed interface)定义路由: + - 根 Tab:`home`、`records`、`library`、`settings`、`search`。 + - 子页:`detail/{serverId}/{itemId}`、`player/{serverId}/{itemId}/{seriesId}/{seasonId}`、`library/icons`、`settings/cache`、`settings/about`、`library/add-server`。 +- `AppNavHost.kt` 承载各目的地 Composable;根 Tab 用 `popUpTo + launchSingleTop + saveState/restoreState` 切换。 +- 细节页 / 播放器用 `launchSingleTop` 防止重复入栈。 +- `rootTabIndex` / `isRootRoute` 判断当前是否根 Tab。 + +### 4.3 UI / 玻璃系统(重点,历史坑多) + +- **玻璃模式**(`AppTheme.kt`):`GlassMode.Liquid`(液态)/ `GlassMode.Frosted`(毛玻璃),用户手动在设置页选择,写入 SharedPreferences `app_settings` 持久化。 +- **运行时判定**(`LumaDesignSystem.kt` 中的 `LumaGlassRuntime`): + - `isBackdropSafe = SDK_INT >= S`(API 31+)。 + - `useNativeBackdrop = glassMode == Liquid && isBackdropSafe`:API 31+ 且液态模式才走 Kyant `rememberLayerBackdrop`;否则返回 `null`(用渐变毛玻璃降级)。 + - `rememberLumaLayerBackdrop()` 用 `key(AppTheme.glassMode)` 包裹,确保模式切换时刷新 Backdrop 与 GlassSpec 缓存。 +- **关键渲染原则(务必遵守,否则会 RenderThread SIGSEGV 崩溃)**: + 1. `layerBackdrop`(源层)与使用 `drawBackdrop` 的玻璃控件必须是**同一棵树下的兄弟层**,来源层只包底层内容(背景 / 视频 AndroidView),玻璃消费层在源层之外。 + 2. **禁止**在同一个 backdrop 捕获范围内再绘制使用同一 backdrop 的玻璃控件,会形成循环渲染 → RenderThread SIGSEGV。 + 3. **播放器不捕获 AndroidView**;页面捕获层只服务根 Tab / 底栏。 + 4. 弹出层 / 菜单必须用页面内部专属 source,不能直接消费包住整个页面的外层 source。 + 5. 降级玻璃的边缘高光要在 `drawContent()` 之前绘制,否则文字/图标会被高光洗白。 +- **降级毛玻璃**:当 `useNativeBackdrop == false` 时,用 Compose 渐变 + tint/边框/圆角模拟玻璃(见 `LumaGlassLevel` / `GlassSpec`)。 +- **设计系统**:`LumaColors`、`LumaShapes`、`LumaTypography`、`LumaMotion`、`LumaIcons` 等令牌;公共组件在 `LumaComponents.kt`(加载 / 空态 / 按钮 / 分段控件 / 输入框等)。 + +### 4.4 播放器(`screens/VideoPlayerScreen.kt`) + +- 基于 Media3 ExoPlayer + `PlayerView`(AndroidView)。 +- 支持:起播位置恢复、暂停/继续、进度拖拽、剧集/字幕菜单、全屏、播放进度回报(串行队列)。 +- 当前工作区正在大改播放器 UI(见第 6 节)。 + +--- + +## 5. 构建与运行 + +```powershell +# 在项目根目录 +.\gradlew.bat assembleDebug # Debug 构建 +.\gradlew.bat lintDebug # 静态检查 +.\gradlew.bat testDebugUnitTest # 单元测试 +``` + +- Gradle 9.5.0 wrapper(腾讯镜像),AGP 9.0.1,Kotlin 2.3.20。 +- SDK 路径在 `local.properties`(本机:`C:\Users\Administrator\AppData\Local\Android\Sdk`),该文件被 gitignore,不入库。 +- `app/build.gradle.kts` 中有任务钩子:跳过 `AarMetadata` 相关 `check` 任务(`tasks.whenTaskAdded`);`gradle.properties` 设 `android.aarMetadataCheck.enable=false`、`android.suppressUnsupportedCompileSdk=37.0`。 +- 历史提交常在 `assembleDebug`、`lintDebug`、`testDebugUnitTest` 三者通过后收尾。真机 GPU 稳定性尚未充分验证(主要是 RenderThread)。 + +> 注意:`memory/infra.md` 里写的 JDK 路径是 macOS 路径(`/Applications/Android Studio.app/...`),那是历史环境信息,与当前 Windows 工作环境不一致,仅作参考。 + +--- + +## 6. 当前工作区状态(⚠️ 交接重点) + +Git 分支 `master`,与 `origin/master` 同步。**存在 3 个文件未提交**: + +| 文件 | 改动 | +| --- | --- | +| `app/build.gradle.kts` | 重新加入 `androidx.compose.material:material-icons-extended` 依赖 | +| `navigation/AppNavHost.kt` | `onPlaybackProgress` 回调签名简化:由外部传 `eventName` 改为内部根据 `paused` 自动映射为 `"Pause"` / `"TimeUpdate"` | +| `screens/VideoPlayerScreen.kt` | **大范围重构**(+518/-502):播放器新增信号/电量/存储状态、连接状态监测、玻璃菜单、Material 图标等;改动与远端设计系统可能存在交互,**提交前务必重新编译 + 回归** | + +> 最近 6 条提交: +> ``` +> 07dc93c 收口玻璃交互与界面细节 +> 8f59235 收敛玻璃边缘并清理图标库旧界面 +> 594f47b 修复玻璃模式切换并统一设置组件 +> dafac3e 取消液态玻璃安全门并收敛底栏绘制 +> 485538f 收敛降级玻璃与页面状态恢复 +> e76637c 完善降级玻璃界面并增加手动玻璃模式 +> ``` + +**接手建议**:先 `git diff` 复核上述未提交改动是否完整、可编译,确认无误后再提交/推送;若与远端有冲突,按 `memory/lessons.md` 的三方合并经验处理(先暂存本地改动再合并,避免整文件覆盖)。 + +--- + +## 7. 项目记忆库(必读) + +`memory/` 目录记录了完整开发历史与踩坑经验,**接手前建议通读**: + +- `MEMORY.md` — 项目记忆索引、当前实现概览、稳定性策略。 +- `projects.md` — 逐日项目状态与变更历史。 +- `2026-08-26.md` / `2026-08-27.md` / `2026-08-28.md` — 每日详细变更与教训。 +- `lessons.md` — **踩坑教训集(重点)**,尤其是 RenderThread SIGSEGV 的玻璃渲染约束、Room 迁移可空性、Emby 播放回报、导航状态等。 +- `infra.md` — Git 远端地址等。 + +--- + +## 8. 已知要点 / 注意事项(给下一位开发) + +### Git +- 远端:`https://git.leee.xin/beyond/lumaplayer.git`(origin,分支 master)。认证信息不要写入远端配置。 +- 本地 `memory/` 保留本地参考素材(未跟踪,不随源码提交),删除前务必确认无引用。 +- 清理遗留实现前,一律用全仓库引用检索确认目标未被活动页面使用。 + +### 玻璃渲染(崩溃风险最高) +- 任何修改涉及 `layerBackdrop` / `drawBackdrop` / 弹窗 / 菜单 / 播放器,都要遵守第 4.3 节原则,否则可能 RenderThread SIGSEGV。 +- 玻璃模式是全局 Compose 状态,涉及 Backdrop / GlassSpec 缓存时必须把 `AppTheme.glassMode` 纳入 `remember` 的 key。 +- 模拟器不一定用标准指纹,`isBackdropSafe` 已简化为只看 API 级别;真机 GPU 的液态玻璃稳定性仍需单独验证。 + +### Room +- 改 `ServerEntity` / 新增列时,迁移列定义必须与 Room 的 `notNull` 预期一致(参考 `protocol` 可空列的教训),否则旧库启动会崩。 + +### 播放回报 +- 必须沿用 PlaybackInfo 返回的 `PlaySessionId` / `MediaSourceId`,开始/进度/停止事件串行化;回报协程不要绑到播放器 Composable 生命周期(返回页面会丢停止事件)。 + +### 无障碍 / 触控 +- `BasicTextField` 的 `decorationBox` 无论是否为空都必须调用 `inner()`,否则破坏焦点/光标。 +- 交互目标尽量 ≥ 48dp 触控范围。 + +--- + +## 9. 可能的后续方向(非必须,供参考) + +若产品方向需要,可考虑: +- 包名/命名空间从 `com.example.myapplication` 规范命名。 +- 真机 GPU 上逐一验证液态玻璃(RenderThread 稳定性)。 +- 补充更完整的单元测试覆盖(目前测试较少)。 +- 是否支持 Jellyfin(API 兼容)等,属于需求决策,需与产品确认,勿自行扩展。 + +--- + +*本文档基于 `memory/`、源码与 Git 状态整理生成,供下一位 AI 开发者快速上手。动手前请先确认第 6 节未提交改动。* diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 69072a4..bda5155 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -63,6 +63,7 @@ dependencies { implementation("com.squareup.retrofit2:converter-gson:2.11.0") implementation("com.squareup.okhttp3:okhttp:4.12.0") implementation("com.squareup.okhttp3:logging-interceptor:4.12.0") + testImplementation("junit:junit:4.13.2") debugImplementation("androidx.compose.ui:ui-tooling") } diff --git a/app/src/main/kotlin/com/example/myapplication/screens/VideoPlayerScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/VideoPlayerScreen.kt index 62e9426..9bf6c9e 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/VideoPlayerScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/VideoPlayerScreen.kt @@ -1,9 +1,11 @@ package com.example.myapplication.screens import android.app.Activity +import android.media.AudioManager import android.content.Context import android.content.ContextWrapper import android.content.pm.ActivityInfo +import android.provider.Settings import android.view.Surface import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility @@ -85,11 +87,15 @@ import android.os.SystemClock import android.os.BatteryManager import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown -import androidx.compose.foundation.gestures.awaitLongPressOrCancellation +import androidx.compose.ui.input.pointer.PointerInputChange import androidx.compose.ui.input.pointer.pointerInput import androidx.media3.exoplayer.DefaultLoadControl -import androidx.media3.exoplayer.analytics.AnalyticsListener import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import androidx.media3.datasource.DataSource +import androidx.media3.datasource.DataSpec +import androidx.media3.datasource.DefaultDataSource +import androidx.media3.datasource.TransferListener import androidx.media3.ui.PlayerView import com.example.myapplication.data.EmbyItem import androidx.compose.ui.layout.ContentScale @@ -104,7 +110,10 @@ import com.example.myapplication.ui.design.lumaGlass import com.example.myapplication.ui.design.LumaShapes import com.example.myapplication.ui.LocalGlassBackdrop import kotlinx.coroutines.delay +import kotlinx.coroutines.withTimeoutOrNull import top.yukonga.miuix.kmp.basic.Text +import kotlin.math.abs +import kotlin.math.roundToInt private fun formatMs(ms: Long): String { val totalSec = ms / 1000 @@ -141,48 +150,40 @@ private fun formatBitrate(bps: Int): String = when { else -> "$bps bps" } -private class SpeedMonitor { - private var lastTotalBytes = 0L - private var lastTotalLoadMs = 0L - private var activeRate = 0f // 最近一次真实吞吐(字节/秒) - private var displayed = 0f // 平滑后的展示值 - private var lastActiveAt = 0L // 最近一次有真实下载的时刻 +internal class SpeedMonitor( + private val nowMs: () -> Long = SystemClock::elapsedRealtime, +) { + private var bytesSinceLastSample = 0L + private var lastSampleAt = nowMs() + private var displayedRate = 0f - // 由 AnalyticsListener.onBandwidthEstimate 触发。不用 bitrateEstimate(会固定/乱跳), - // 而是用回调自带的“累计下载字节 / 累计加载耗时”做差分,得到瞬时真实吞吐。 - fun onBandwidthEstimate(totalLoadTimeMs: Int, totalBytesLoaded: Long) { - val loadMs = totalLoadTimeMs.toLong() - if (loadMs < lastTotalLoadMs) { - // 换了新的 DataSource,累计值被重置,重新对齐 - lastTotalLoadMs = loadMs - lastTotalBytes = totalBytesLoaded - return - } - val dLoad = loadMs - lastTotalLoadMs - val dBytes = totalBytesLoaded - lastTotalBytes - lastTotalLoadMs = loadMs - lastTotalBytes = totalBytesLoaded - if (dLoad > 0L && dBytes > 0L) { - activeRate = (dBytes * 1000L / dLoad).toFloat() - lastActiveAt = SystemClock.elapsedRealtime() - } + @Synchronized + fun onBytesTransferred(bytes: Int) { + if (bytes > 0) bytesSinceLastSample += bytes } - // UI 每隔约 900ms 读取一次,输出平滑稳定、又反映真实下载的实时网速(字节/秒)。 + // 按真实传输字节的墙钟时间增量计算,缓冲停止下载时立即回到 0。 + @Synchronized fun currentBytesPerSec(): Long { - val now = SystemClock.elapsedRealtime() - val idleMs = now - lastActiveAt - when { - idleMs > 8000L -> displayed *= 0.8f // 长时间空闲:平滑回落 - idleMs < 2500L -> displayed = displayed + (activeRate - displayed) * 0.5f // 下载中:向真实速率收敛 - // 2500~8000ms 的短暂停顿:保持最近值,避免闪 0 - else -> {} + val now = nowMs() + val elapsedMs = (now - lastSampleAt).coerceAtLeast(1L) + val sampledRate = bytesSinceLastSample * 1000f / elapsedMs + bytesSinceLastSample = 0L + lastSampleAt = now + displayedRate = when { + sampledRate <= 0f -> 0f + displayedRate <= 0f -> sampledRate + else -> displayedRate + (sampledRate - displayedRate) * 0.45f } - if (displayed < 1f) displayed = 0f - return displayed.toLong() + return displayedRate.toLong() } } +private enum class PlayerLevelGesture { + Brightness, + Volume, +} + private tailrec fun Context.findActivity(): Activity? = when (this) { is Activity -> this is ContextWrapper -> baseContext.findActivity() @@ -214,6 +215,19 @@ fun VideoPlayerScreen( var showSubtitleMenu by remember { mutableStateOf(false) } var showEpisodeMenu by remember { mutableStateOf(false) } val speedMonitor = remember { SpeedMonitor() } + val transferListener = remember { + object : TransferListener { + override fun onTransferInitializing(source: DataSource, dataSpec: DataSpec, isNetwork: Boolean) = Unit + override fun onTransferStart(source: DataSource, dataSpec: DataSpec, isNetwork: Boolean) = Unit + override fun onBytesTransferred(source: DataSource, dataSpec: DataSpec, isNetwork: Boolean, bytesTransferred: Int) { + if (isNetwork) speedMonitor.onBytesTransferred(bytesTransferred) + } + override fun onTransferEnd(source: DataSource, dataSpec: DataSpec, isNetwork: Boolean) = Unit + } + } + val mediaSourceFactory = remember { + DefaultMediaSourceFactory(DefaultDataSource.Factory(context).setTransferListener(transferListener)) + } val loadControl = remember { DefaultLoadControl.Builder() // 最小缓冲 180s(=600s×30%,播放到缓存条的 70% 时触发再次预加载) @@ -223,8 +237,14 @@ fun VideoPlayerScreen( .build() } val player = remember { - ExoPlayer.Builder(context).setLoadControl(loadControl).build() + ExoPlayer.Builder(context) + .setLoadControl(loadControl) + .setMediaSourceFactory(mediaSourceFactory) + .build() } + val activity = context.findActivity() + val audioManager = remember { context.getSystemService(Context.AUDIO_SERVICE) as AudioManager } + val maxMusicVolume = remember(audioManager) { audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC).coerceAtLeast(1) } val latestPlaybackUrl by rememberUpdatedState(playbackUrl) val latestOnPlaybackStarted by rememberUpdatedState(onPlaybackStarted) val latestOnPlaybackProgress by rememberUpdatedState(onPlaybackProgress) @@ -235,7 +255,6 @@ fun VideoPlayerScreen( var isPlaying by remember { mutableStateOf(false) } var playerError by remember(playbackUrl) { mutableStateOf(null) } var networkSpeed by remember { mutableLongStateOf(0L) } - var hasNetworkSample by remember { mutableStateOf(false) } var batteryPercent by remember { mutableIntStateOf(-1) } var isCharging by remember { mutableStateOf(false) } var topTimeText by remember { mutableStateOf("") } @@ -245,6 +264,18 @@ fun VideoPlayerScreen( var videoFrameRateText by remember { mutableStateOf("") } var speedUpVisible by remember { mutableStateOf(false) } var speedUpValue by remember { mutableFloatStateOf(2f) } + var levelGesture by remember { mutableStateOf(null) } + var levelGestureValue by remember { mutableFloatStateOf(0f) } + var brightnessLevel by remember { + val windowLevel = activity?.window?.attributes?.screenBrightness?.takeIf { it >= 0f } + val systemLevel = Settings.System.getInt(context.contentResolver, Settings.System.SCREEN_BRIGHTNESS, 128) / 255f + mutableFloatStateOf((windowLevel ?: systemLevel).coerceIn(0.01f, 1f)) + } + var volumeLevel by remember { + mutableFloatStateOf( + audioManager.getStreamVolume(AudioManager.STREAM_MUSIC).toFloat() / maxMusicVolume + ) + } var controlsVisible by remember { mutableStateOf(true) } var controlsLocked by remember { mutableStateOf(false) } @@ -312,9 +343,8 @@ fun VideoPlayerScreen( LaunchedEffect(Unit) { while (true) { val speed = speedMonitor.currentBytesPerSec() - if (speed > 0L) hasNetworkSample = true networkSpeed = speed - delay(900) + delay(1000) } } @@ -328,9 +358,9 @@ fun VideoPlayerScreen( } DisposableEffect(Unit) { - val activity = context.findActivity() val prev = activity?.requestedOrientation ?: ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED val window = activity?.window + val prevBrightness = window?.attributes?.screenBrightness val insetsController = window?.let { WindowCompat.getInsetsController(it, it.decorView) } val prevBehavior = insetsController?.systemBarsBehavior insetsController?.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE @@ -341,6 +371,11 @@ fun VideoPlayerScreen( controller.show(WindowInsetsCompat.Type.systemBars()) controller.systemBarsBehavior = prevBehavior ?: WindowInsetsControllerCompat.BEHAVIOR_DEFAULT } + if (window != null && prevBrightness != null) { + val attributes = window.attributes + attributes.screenBrightness = prevBrightness + window.attributes = attributes + } activity?.requestedOrientation = prev } } @@ -377,20 +412,6 @@ fun VideoPlayerScreen( player.release() } } - DisposableEffect(player) { - val listener = object : AnalyticsListener { - override fun onBandwidthEstimate( - eventTime: AnalyticsListener.EventTime, - totalLoadTimeMs: Int, - totalBytesLoaded: Long, - bitrateEstimate: Long - ) { - speedMonitor.onBandwidthEstimate(totalLoadTimeMs, totalBytesLoaded) - } - } - player.addAnalyticsListener(listener) - onDispose { player.removeAnalyticsListener(listener) } - } DisposableEffect(lifecycleOwner) { var resumeAfterStop = false val obs = LifecycleEventObserver { _, e -> @@ -414,13 +435,27 @@ fun VideoPlayerScreen( Modifier .fillMaxSize() .background(Color.Black) - .pointerInput(player) { + .pointerInput(player, activity, audioManager) { awaitEachGesture { val down = awaitFirstDown(requireUnconsumed = true) - val longPress = awaitLongPressOrCancellation(down.id) - if (longPress != null) { + val startPosition = down.position + val firstChange: PointerInputChange? = withTimeoutOrNull(viewConfiguration.longPressTimeoutMillis) { + var detected: PointerInputChange? = null + while (detected == null) { + val event = awaitPointerEvent() + val change = event.changes.firstOrNull { it.id == down.id } ?: continue + val dx = change.position.x - startPosition.x + val dy = change.position.y - startPosition.y + if (!change.pressed || change.isConsumed || abs(dx) > viewConfiguration.touchSlop || abs(dy) > viewConfiguration.touchSlop) { + detected = change + } + } + checkNotNull(detected) + } + val current = firstChange ?: currentEvent.changes.firstOrNull { it.id == down.id } + if (firstChange == null && current?.pressed == true && !current.isConsumed) { // 长按:2倍速播放,并显示倍速胶囊 - down.consume() + current.consume() val wasPlaying = player.isPlaying val prevSpeed = player.playbackParameters.speed val targetSpeed = 2f @@ -428,6 +463,7 @@ fun VideoPlayerScreen( if (!player.isPlaying) player.play() speedUpVisible = true speedUpValue = targetSpeed + controlsLocked = true try { while (true) { val event = awaitPointerEvent() @@ -437,11 +473,62 @@ fun VideoPlayerScreen( speedUpVisible = false player.setPlaybackSpeed(prevSpeed) if (!wasPlaying) player.pause() + controlsLocked = false + } + } else if (current == null || !current.pressed) { + if (current?.isConsumed != true) controlsVisible = !controlsVisible + } else if (!current.isConsumed) { + val activeChange = checkNotNull(current) + val dx = activeChange.position.x - startPosition.x + val dy = activeChange.position.y - startPosition.y + if (abs(dy) > abs(dx)) { + val kind = if (startPosition.x < size.width / 2f) { + PlayerLevelGesture.Brightness + } else { + PlayerLevelGesture.Volume + } + val initialLevel = if (kind == PlayerLevelGesture.Brightness) { + brightnessLevel + } else { + audioManager.getStreamVolume(AudioManager.STREAM_MUSIC).toFloat() / maxMusicVolume + } + levelGesture = kind + controlsLocked = true + var change = activeChange + try { + while (change.pressed) { + val level = (initialLevel + (startPosition.y - change.position.y) / size.height * 1.5f) + .coerceIn(if (kind == PlayerLevelGesture.Brightness) 0.01f else 0f, 1f) + levelGestureValue = level + if (kind == PlayerLevelGesture.Brightness) { + brightnessLevel = level + activity?.window?.let { window -> + val attributes = window.attributes + attributes.screenBrightness = level + window.attributes = attributes + } + } else { + volumeLevel = level + audioManager.setStreamVolume( + AudioManager.STREAM_MUSIC, + (level * maxMusicVolume).roundToInt(), + 0, + ) + } + change.consume() + val event = awaitPointerEvent() + change = event.changes.firstOrNull { it.id == down.id } ?: break + } + } finally { + levelGesture = null + controlsLocked = false + } + } else { + // 横向移动交给进度条等子控件处理,不切换控制栏。 + while (currentEvent.changes.any { it.id == down.id && it.pressed }) { + awaitPointerEvent() + } } - } else { - // 快速点击(未拖动)切换控制栏 - val stillDown = currentEvent.changes.any { it.id == down.id && it.pressed } - if (!stillDown) controlsVisible = !controlsVisible } } } @@ -570,7 +657,7 @@ fun VideoPlayerScreen( Row(verticalAlignment = Alignment.CenterVertically) { LumaIconView(if (isWifi) LumaIcon.Wifi else LumaIcon.Signal, contentDescription = null, tint = Color.White, modifier = Modifier.size(16.dp)) Spacer(Modifier.width(4.dp)) - if (hasNetworkSample) Text(formatNetworkSpeed(networkSpeed), color = Color.White, fontSize = 13.sp) + Text(formatNetworkSpeed(networkSpeed), color = Color.White, fontSize = 13.sp) } } if (batteryPercent >= 0) { @@ -704,13 +791,22 @@ fun VideoPlayerScreen( Text(formatMs(currentPositionMs), color = Color.White, fontSize = 12.sp) LumaSeekBar( value = if (isSeeking) seekPosition else progress, - onValueChange = { v -> isSeeking = true; seekPosition = v; controlsLocked = true }, - onValueChangeFinished = { - player.seekTo((seekPosition * player.duration).toLong()) + onValueChange = { v -> + isSeeking = true + seekPosition = v + currentPositionMs = (v * totalDurationMs).toLong() + controlsLocked = true + }, + onValueChangeFinished = { finalValue -> + val targetPosition = (finalValue * totalDurationMs).toLong() + player.seekTo(targetPosition) + progress = finalValue + currentPositionMs = targetPosition isSeeking = false controlsLocked = false - latestOnPlaybackProgress(player.currentPosition, !player.isPlaying, "TimeUpdate") + latestOnPlaybackProgress(targetPosition, !player.isPlaying, "TimeUpdate") }, + progressContentDescription = stringResource(Res.string.player_progress), modifier = Modifier.weight(1f).padding(horizontal = 12.dp), activeColor = Color.White, trackColor = Color.White.copy(alpha = 0.12f), @@ -723,14 +819,26 @@ fun VideoPlayerScreen( } } } - SpeedUpCapsule( - visible = speedUpVisible, - speed = speedUpValue, - modifier = Modifier.align(Alignment.Center), - ) } } + SpeedUpCapsule( + visible = speedUpVisible, + speed = speedUpValue, + modifier = Modifier.align(Alignment.TopCenter).statusBarsPadding().padding(top = 24.dp), + ) + levelGesture?.let { kind -> + PlayerLevelCapsule( + label = stringResource( + if (kind == PlayerLevelGesture.Brightness) Res.string.player_brightness else Res.string.player_volume + ), + level = levelGestureValue, + modifier = Modifier + .align(if (kind == PlayerLevelGesture.Brightness) Alignment.CenterStart else Alignment.CenterEnd) + .padding(horizontal = 72.dp), + ) + } + if (showSubtitleMenu) { LiquidGlassPanel(title = stringResource(Res.string.player_subtitle), onDismiss = { showSubtitleMenu = false }) { SubtitleMenuContent(player = player) { showSubtitleMenu = false } @@ -1017,6 +1125,32 @@ private fun SpeedUpCapsule(visible: Boolean, speed: Float, modifier: Modifier = } } +@Composable +private fun PlayerLevelCapsule(label: String, level: Float, modifier: Modifier = Modifier) { + val bd = LocalGlassBackdrop.current + Box( + modifier + .clip(RoundedCornerShape(50)) + .lumaGlass( + backdrop = bd, + radius = 50.dp, + level = LumaGlassLevel.Interactive, + tint = Color.Black.copy(alpha = 0.6f), + borderColor = Color.White.copy(alpha = 0.25f), + shadowElevation = 4.dp, + ) + .padding(horizontal = 16.dp, vertical = 10.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = "$label ${(level * 100).roundToInt()}%", + color = Color.White, + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + ) + } +} + @Composable private fun LiquidEpisodeItem(episode: EmbyItem, displayIndex: Int, selected: Boolean, onClick: () -> Unit) { val bd = LocalGlassBackdrop.current diff --git a/app/src/main/kotlin/com/example/myapplication/ui/design/LumaComponents.kt b/app/src/main/kotlin/com/example/myapplication/ui/design/LumaComponents.kt index c365a83..9c5a83b 100644 --- a/app/src/main/kotlin/com/example/myapplication/ui/design/LumaComponents.kt +++ b/app/src/main/kotlin/com/example/myapplication/ui/design/LumaComponents.kt @@ -28,9 +28,9 @@ import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.Canvas -import androidx.compose.foundation.gestures.awaitEachGesture -import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Slider import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf @@ -49,20 +49,14 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.painter.ColorPainter -import androidx.compose.ui.input.pointer.changedToUp -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.semantics.ProgressBarRangeInfo import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.progressBarRangeInfo import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.semantics.setProgress import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.shape.CircleShape import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp @@ -72,7 +66,6 @@ import androidx.compose.ui.text.style.TextOverflow import coil.compose.AsyncImage import coil.request.ImageRequest import top.yukonga.miuix.kmp.basic.Text -import kotlin.math.abs private enum class LumaImageState { Loading, @@ -464,10 +457,12 @@ fun LumaSectionTitle( } @Composable +@OptIn(ExperimentalMaterial3Api::class) fun LumaSeekBar( value: Float, onValueChange: (Float) -> Unit, - onValueChangeFinished: () -> Unit, + onValueChangeFinished: (Float) -> Unit, + progressContentDescription: String? = null, modifier: Modifier = Modifier, activeColor: Color = LumaColors.accent, trackColor: Color = LumaColors.divider, @@ -475,94 +470,60 @@ fun LumaSeekBar( bufferColor: Color = thumbColor.copy(alpha = 0.35f), bufferedValue: Float = value, ) { - val density = LocalDensity.current - var trackWidthPx by remember { mutableFloatStateOf(0f) } - var dragging by remember { mutableStateOf(false) } val currentValue by rememberUpdatedState(value.coerceIn(0f, 1f)) val currentBuffered by rememberUpdatedState(bufferedValue.coerceIn(0f, 1f)) val currentOnValueChange by rememberUpdatedState(onValueChange) val currentOnValueChangeFinished by rememberUpdatedState(onValueChangeFinished) - val thumbRadius = with(density) { if (dragging) 7.dp.toPx() else 4.dp.toPx() } + val interactionSource = remember { MutableInteractionSource() } + val dragging by interactionSource.collectIsPressedAsState() + var gestureValue by remember { mutableFloatStateOf(currentValue) } - fun updateFromPosition(x: Float) { - if (trackWidthPx > 0f) currentOnValueChange((x / trackWidthPx).coerceIn(0f, 1f)) - } - - Canvas( - modifier + Slider( + value = currentValue, + onValueChange = { newValue -> + gestureValue = newValue + currentOnValueChange(newValue) + }, + onValueChangeFinished = { currentOnValueChangeFinished(gestureValue) }, + interactionSource = interactionSource, + modifier = modifier .heightIn(min = 30.dp) - .onSizeChanged { trackWidthPx = it.width.toFloat() } .semantics { - progressBarRangeInfo = ProgressBarRangeInfo(currentValue, 0f..1f) - setProgress { requestedValue -> - currentOnValueChange(requestedValue.coerceIn(0f, 1f)) - currentOnValueChangeFinished() - true - } - } - .pointerInput(trackWidthPx) { - awaitEachGesture { - val down = awaitFirstDown(requireUnconsumed = false) - down.consume() - dragging = true - updateFromPosition(down.position.x) - var lastX = down.position.x - var moved = false - while (true) { - val event = awaitPointerEvent() - val change = event.changes.firstOrNull() ?: break - if (change.changedToUp()) { - updateFromPosition(change.position.x) - change.consume() - dragging = false - currentOnValueChangeFinished() - break - } - val delta = change.position.x - lastX - if (!moved && abs(change.position.x - down.position.x) > viewConfiguration.touchSlop) { - moved = true - } - if (moved || delta != 0f) { - updateFromPosition(change.position.x) - change.consume() - } - lastX = change.position.x - } - dragging = false - } + if (progressContentDescription != null) contentDescription = progressContentDescription }, - ) { - val centerY = size.height / 2f - val trackHeight = 4.dp.toPx() - val progressWidth = size.width * currentValue - val bufferedWidth = size.width * currentBuffered - val radius = trackHeight / 2f - drawRoundRect( - color = trackColor, - topLeft = Offset(0f, centerY - radius), - size = Size(size.width, trackHeight), - cornerRadius = androidx.compose.ui.geometry.CornerRadius(radius), - ) - if (bufferedWidth > progressWidth) { - drawRoundRect( - color = bufferColor, - topLeft = Offset(0f, centerY - radius), - size = Size(bufferedWidth, trackHeight), - cornerRadius = androidx.compose.ui.geometry.CornerRadius(radius), + thumb = { + Box( + Modifier + .size(if (dragging) 14.dp else 8.dp) + .background(thumbColor, CircleShape) ) - } - drawRoundRect( - color = activeColor, - topLeft = Offset(0f, centerY - radius), - size = Size(progressWidth, trackHeight), - cornerRadius = androidx.compose.ui.geometry.CornerRadius(radius), - ) - drawCircle( - color = thumbColor, - radius = thumbRadius, - center = Offset(progressWidth.coerceIn(0f, size.width), centerY), - ) - } + }, + track = { sliderState -> + Canvas(Modifier.fillMaxWidth().height(4.dp)) { + val trackHeight = size.height + val progressWidth = size.width * sliderState.value.coerceIn(0f, 1f) + val bufferedWidth = size.width * currentBuffered + val radius = trackHeight / 2f + drawRoundRect( + color = trackColor, + size = Size(size.width, trackHeight), + cornerRadius = androidx.compose.ui.geometry.CornerRadius(radius), + ) + if (bufferedWidth > progressWidth) { + drawRoundRect( + color = bufferColor, + size = Size(bufferedWidth, trackHeight), + cornerRadius = androidx.compose.ui.geometry.CornerRadius(radius), + ) + } + drawRoundRect( + color = activeColor, + size = Size(progressWidth, trackHeight), + cornerRadius = androidx.compose.ui.geometry.CornerRadius(radius), + ) + } + }, + ) } @Composable diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 862b7eb..81015fe 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -150,6 +150,9 @@ Pause Next episode Seek forward 10 seconds + Playback progress + Brightness + Volume Disable subtitles This resource cannot be played Failed to resolve playback URL diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 788d5a1..215dcfa 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -150,6 +150,9 @@ 暂停 下一集 前进 10 秒 + 播放进度 + 亮度 + 音量 关闭字幕 播放器无法播放该资源 获取播放地址失败 diff --git a/app/src/test/kotlin/com/example/myapplication/screens/SpeedMonitorTest.kt b/app/src/test/kotlin/com/example/myapplication/screens/SpeedMonitorTest.kt new file mode 100644 index 0000000..193fe0e --- /dev/null +++ b/app/src/test/kotlin/com/example/myapplication/screens/SpeedMonitorTest.kt @@ -0,0 +1,31 @@ +package com.example.myapplication.screens + +import org.junit.Assert.assertEquals +import org.junit.Test + +class SpeedMonitorTest { + @Test + fun reportsTransferredBytesPerElapsedSecond() { + var now = 0L + val monitor = SpeedMonitor { now } + + monitor.onBytesTransferred(512) + monitor.onBytesTransferred(1_536) + now = 1_000L + + assertEquals(2_048L, monitor.currentBytesPerSec()) + } + + @Test + fun returnsZeroAsSoonAsDownloadingStops() { + var now = 0L + val monitor = SpeedMonitor { now } + + monitor.onBytesTransferred(4_096) + now = 1_000L + monitor.currentBytesPerSec() + now = 2_000L + + assertEquals(0L, monitor.currentBytesPerSec()) + } +}