1、修复进度条无法拖动问题,
2、修复长按快进问题 3、增加亮度、音量调节
This commit is contained in:
1 parent
07dc93cd07
commit
91366f24b8
7 files changed
+534
-163
No files matched your search
@@ -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 节未提交改动。*
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user