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())
+ }
+}