1、修复进度条无法拖动问题,

2、修复长按快进问题
3、增加亮度、音量调节
This commit is contained in:
beyond committed 2026-08-28 21:33:58 +08:00
1 parent 07dc93cd07
commit 91366f24b8
7 files changed
+534 -163

No files matched your search

+238
View File
@@ -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 节未提交改动。*
+1
View File
@@ -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")
}
@@ -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<String?>(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<PlayerLevelGesture?>(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<PointerInputChange>(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
@@ -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
+3
View File
@@ -150,6 +150,9 @@
<string name="player_pause">Pause</string>
<string name="player_next_episode">Next episode</string>
<string name="player_seek_forward">Seek forward 10 seconds</string>
<string name="player_progress">Playback progress</string>
<string name="player_brightness">Brightness</string>
<string name="player_volume">Volume</string>
<string name="player_disable_subtitle">Disable subtitles</string>
<string name="player_cannot_play">This resource cannot be played</string>
<string name="playback_url_failed">Failed to resolve playback URL</string>
+3
View File
@@ -150,6 +150,9 @@
<string name="player_pause">暂停</string>
<string name="player_next_episode">下一集</string>
<string name="player_seek_forward">前进 10 秒</string>
<string name="player_progress">播放进度</string>
<string name="player_brightness">亮度</string>
<string name="player_volume">音量</string>
<string name="player_disable_subtitle">关闭字幕</string>
<string name="player_cannot_play">播放器无法播放该资源</string>
<string name="playback_url_failed">获取播放地址失败</string>
@@ -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())
}
}