+track selectors for player

This commit is contained in:
1
2026-05-14 15:17:16 +08:00
parent 40ca9b8e52
commit b5b033bf52
4 changed files with 385 additions and 172 deletions

View File

@@ -1,19 +1,109 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
import com.android.build.gradle.internal.api.BaseVariantOutputImpl
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
id("com.android.application") version "8.10.1" apply false
id("org.jetbrains.kotlin.android") version "2.2.0" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.2.0" apply false
id("com.android.application") version "8.10.1"
id("org.jetbrains.kotlin.android") version "2.2.0"
id("org.jetbrains.kotlin.plugin.compose") version "2.2.0"
id("com.google.gms.google-services")
id("kotlin-parcelize")
kotlin("plugin.serialization") version "2.2.0"
}
buildscript {
repositories {
// Check that you have the following line (if not, add it):
google() // Google's Maven repository
maven("https://jitpack.io")
android {
namespace = "com.donut.mixfile"
compileSdk = 36
defaultConfig {
applicationId = "com.donut.mixfile"
minSdk = 26
targetSdk = 36
versionCode = 160
versionName = "2.0.11.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary = true
}
}
dependencies {
classpath("com.google.gms:google-services:4.4.3")
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
signingConfig = signingConfigs.getByName("debug")
}
}
applicationVariants.all {
val variant = this
variant.outputs
.map { it as BaseVariantOutputImpl }
.forEach { output ->
val outputFileName =
"MixFile-${variant.baseName}-${variant.versionName}.apk"
output.outputFileName = outputFileName
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_1_8)
}
}
buildFeatures {
compose = true
}
packaging {
resources {
excludes += "/META-INF/*"
}
}
}
dependencies {
// 引用 libs 目录下的所有 aar
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.aar"))))
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0")
implementation("com.github.InvertGeek:mixfile-core:2.0.11")
implementation("androidx.compose.material:material-icons-extended:1.7.8")
implementation("com.tencent:mmkv:1.3.14")
implementation("net.engawapg.lib:zoomable:1.6.1")
implementation("io.coil-kt:coil:2.6.0")
implementation("io.coil-kt:coil-compose:2.6.0")
implementation("io.coil-kt:coil-gif:2.6.0")
implementation("io.coil-kt:coil-svg:2.6.0")
implementation("io.coil-kt:coil-video:2.6.0")
implementation("androidx.media3:media3-exoplayer:1.10.0") // [Required] androidx.media3 ExoPlayer dependency
implementation("androidx.media3:media3-session:1.10.0") // [Required] MediaSession Extension dependency
implementation("androidx.media3:media3-ui:1.10.0") // [Required] Base Player UI
implementation("androidx.navigation:navigation-compose:2.9.3")
implementation("com.google.firebase:firebase-analytics:23.0.0")
implementation("androidx.core:core-ktx:1.17.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.9.2")
implementation("androidx.activity:activity-compose:1.10.1")
implementation("androidx.appcompat:appcompat:1.7.1")
implementation(platform("androidx.compose:compose-bom:2024.08.00"))
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.3.0")
androidTestImplementation("androidx.test.espresso:espresso-core:3.7.0")
androidTestImplementation(platform("androidx.compose:compose-bom:2024.08.00"))
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")
}

View File

@@ -6,39 +6,30 @@ import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.SkipNext
import androidx.compose.material.icons.filled.SkipPrevious
import androidx.compose.material3.AssistChip
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.media3.common.C
import androidx.media3.common.TrackSelectionOverride
import androidx.media3.exoplayer.ExoPlayer
import com.donut.mixfile.ui.component.common.MixDialogBuilder
import com.donut.mixfile.ui.component.common.SingleSelectItemList
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BottomControl(
visible: Boolean,
@@ -50,12 +41,8 @@ fun BottomControl(
) {
AnimatedVisibility(
visible = visible,
enter = slideInVertically(
initialOffsetY = { it } // 从顶部(负方向)滑入
),
exit = slideOutVertically(
targetOffsetY = { it } // 向上(负方向)滑出
),
enter = slideInVertically { it },
exit = slideOutVertically { it },
modifier = modifier
) {
Column(
@@ -64,95 +51,40 @@ fun BottomControl(
.background(Color.Black.copy(alpha = 0.1f))
.padding(5.dp),
) {
Slider(
value = progress,
onValueChange = { newValue ->
player.pause()
// 1. 进度条:修复了回调逻辑,通过 onTrackTimeChange 同步状态
PlayerProgressSlider(
progress = progress,
onSeek = { newValue ->
player.seekTo((player.duration * newValue).toLong())
onTrackTimeChange(newValue)
},
onValueChangeFinished = {
player.play()
},
modifier = Modifier
.fillMaxWidth(),
thumb = {
Box(
modifier = Modifier
.size(20.dp)
) {
Box(
modifier = Modifier
.size(15.dp) // 自定义滑块大小
.align(Alignment.Center)
.background(Color.White, CircleShape)
)
}
},
track = { sliderState ->
SliderDefaults.Track(
sliderState = sliderState
)
},
onSeekFinished = { player.play() }
)
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 10.dp)
.padding(bottom = 10.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
// 左侧:播放控制与时间
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(15.dp)
) {
var lastSeek = remember { System.currentTimeMillis() }
if (player.mediaItemCount > 1) {
IconButton(
modifier = Modifier.scale(1f),
onClick = {
if (System.currentTimeMillis() - lastSeek < 500) {
return@IconButton
}
lastSeek = System.currentTimeMillis()
player.seekToPreviousMediaItem()
},
) {
Icon(
modifier = Modifier.size(100.dp),
imageVector = Icons.Default.SkipPrevious,
contentDescription = "Previous",
tint = Color.White
)
IconButton(onClick = { player.seekToPreviousMediaItem() }) {
Icon(Icons.Default.SkipPrevious, "Previous", tint = Color.White)
}
IconButton(
modifier = Modifier.scale(1f),
onClick = {
if (System.currentTimeMillis() - lastSeek < 500) {
return@IconButton
}
lastSeek = System.currentTimeMillis()
player.seekToNextMediaItem()
},
) {
Icon(
modifier = Modifier.size(100.dp),
imageVector = Icons.Default.SkipNext,
contentDescription = "Next",
tint = Color.White
)
IconButton(onClick = { player.seekToNextMediaItem() }) {
Icon(Icons.Default.SkipNext, "Next", tint = Color.White)
}
} else {
Row(modifier = Modifier.width(10.dp)) { }
}
Text(
text = "${formatTime((player.duration * progress).toLong())}/${
formatTime(
player.duration.coerceAtLeast(
0
)
player.duration.coerceAtLeast(0)
)
}",
color = Color.White,
@@ -160,91 +92,133 @@ fun BottomControl(
)
}
// 右侧:轨道、选集、速度设置
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(15.dp)
) {
if (player.mediaItemCount > 1) {
AssistChip(
onClick = {
MixDialogBuilder(
"选集",
colorScheme = playerColorScheme
).apply {
val indexMap =
videos.mapIndexed { index, uri -> index to uri }
setContent {
SingleSelectItemList(
indexMap,
currentOption = indexMap[player.currentMediaItemIndex],
getLabel = {
"${it.first + 1} - ${it.second.fragment}"
}
) {
player.seekToDefaultPosition(it.first)
closeDialog()
}
}
setDefaultNegative("取消")
show()
}
},
label = {
Text(
fontWeight = FontWeight.Bold,
text = "选集",
color = Color.White,
fontSize = 12.sp
)
}
)
}
val tracks = player.currentTracks
// 2. 音轨 (支持去重)
val audioGroups =
remember(tracks) { tracks.groups.filter { it.type == C.TRACK_TYPE_AUDIO } }
if (audioGroups.size > 1) {
PlayerSettingChip("音轨") {
val infos = TrackUtils.getFormattedTracks(audioGroups, "音轨")
val options = infos.map { it.label }
val current =
infos.find { it.isSelected }?.label ?: options.firstOrNull() ?: ""
val currentSpeed = player.playbackParameters.speed
AssistChip(
onClick = {
MixDialogBuilder(
"播放速度",
colorScheme = playerColorScheme
).apply {
MixDialogBuilder("选择音轨", colorScheme = playerColorScheme).apply {
setContent {
SingleSelectItemList(
listOf(
"0.25",
"0.5",
"0.75",
"1.0",
"1.25",
"1.5",
"1.75",
"2.0",
"2.5",
"3.0"
), currentOption = "${currentSpeed}"
) {
player.setPlaybackSpeed(it.toFloat())
options,
currentOption = current
) { selected ->
val target = infos.find { it.label == selected }!!
player.trackSelectionParameters =
player.trackSelectionParameters
.buildUpon()
.setOverrideForType(
TrackSelectionOverride(
target.group,
target.index
)
)
.build()
closeDialog()
}
}
setDefaultNegative("取消")
show()
}
},
label = {
Text(
fontWeight = FontWeight.Bold,
text = "速度: ${currentSpeed}x",
color = Color.White,
fontSize = 12.sp
)
}
)
}
// 字幕选择
val textGroups = remember(tracks) {
tracks.groups.filter { it.type == C.TRACK_TYPE_TEXT }
}
if (textGroups.isNotEmpty()) {
PlayerSettingChip("字幕") {
val infos = TrackUtils.getFormattedTracks(textGroups, "字幕")
val options = listOf("关闭") + infos.map { it.label }
val current = infos.find { it.isSelected }?.label ?: "关闭"
MixDialogBuilder("选择字幕", colorScheme = playerColorScheme).apply {
setContent {
SingleSelectItemList(
options,
currentOption = current
) { selected ->
val builder = player.trackSelectionParameters.buildUpon()
if (selected == "关闭") {
builder.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true)
} else {
val target = infos.find { it.label == selected }!!
builder.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false)
.setOverrideForType(
TrackSelectionOverride(
target.group,
target.index
)
)
}
player.trackSelectionParameters = builder.build()
closeDialog()
}
}
show()
}
}
}
// 选集选择
if (player.mediaItemCount > 1) {
PlayerSettingChip("选集") {
val indexMap = videos.mapIndexed { index, uri -> index to uri }
MixDialogBuilder("选集", colorScheme = playerColorScheme).apply {
setContent {
SingleSelectItemList(
indexMap,
currentOption = indexMap.getOrNull(player.currentMediaItemIndex),
getLabel = { "${it.first + 1} - ${it.second.fragment ?: "未命名"}" }
) {
player.seekToDefaultPosition(it.first)
closeDialog()
}
}
show()
}
}
}
// 速度选择
val currentSpeed = player.playbackParameters.speed
PlayerSettingChip("速度: ${currentSpeed}x") {
MixDialogBuilder("播放速度", colorScheme = playerColorScheme).apply {
setContent {
val speeds = listOf(
"0.25",
"0.5",
"0.75",
"1.0",
"1.25",
"1.5",
"1.75",
"2.0",
"2.5",
"3.0"
)
SingleSelectItemList(speeds, currentOption = "$currentSpeed") {
player.setPlaybackSpeed(it.toFloat())
closeDialog()
}
}
show()
}
}
}
}
}
}
}
}

View File

@@ -0,0 +1,152 @@
package com.donut.mixfile.activity.video.player
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.AssistChip
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.media3.common.C
import androidx.media3.common.TrackGroup
import androidx.media3.common.TrackSelectionOverride
import androidx.media3.common.Tracks
import androidx.media3.exoplayer.ExoPlayer
import com.donut.mixfile.ui.component.common.MixDialogBuilder
import com.donut.mixfile.ui.component.common.SingleSelectItemList
object TrackUtils {
/**
* 获取格式化后的轨道列表,处理重复 Label
*/
fun getFormattedTracks(
groups: List<Tracks.Group>,
defaultPrefix: String = "轨道"
): List<TrackInfo> {
val options = mutableListOf<TrackInfo>()
val labelCounter = mutableMapOf<String, Int>()
groups.forEach { group ->
for (i in 0 until group.length) {
val format = group.getTrackFormat(i)
val baseLabel = format.label ?: format.language ?: defaultPrefix
val count = labelCounter.getOrDefault(baseLabel, 0) + 1
labelCounter[baseLabel] = count
val finalLabel = if (count > 1) "$baseLabel-$count" else baseLabel
options.add(
TrackInfo(
finalLabel,
group.mediaTrackGroup,
i,
group.isSelected && group.isTrackSelected(i)
)
)
}
}
return options
}
data class TrackInfo(
val label: String,
val group: TrackGroup,
val index: Int,
val isSelected: Boolean
)
}
/**
* 显示字幕/音轨选择弹窗
*/
fun showTrackSelector(
title: String,
player: ExoPlayer,
trackType: @C.TrackType Int,
colorScheme: ColorScheme,
hasDisableOption: Boolean = false
) {
val groups = player.currentTracks.groups.filter { it.type == trackType }
val trackInfos = TrackUtils.getFormattedTracks(
groups,
if (trackType == C.TRACK_TYPE_TEXT) "字幕" else "音轨"
)
val options =
if (hasDisableOption) listOf("关闭") + trackInfos.map { it.label } else trackInfos.map { it.label }
val currentLabel =
trackInfos.find { it.isSelected }?.label ?: if (hasDisableOption) "关闭" else ""
MixDialogBuilder(title, colorScheme = colorScheme).apply {
setContent {
SingleSelectItemList(options, currentOption = currentLabel) { selected ->
val builder = player.trackSelectionParameters.buildUpon()
if (selected == "关闭") {
builder.setTrackTypeDisabled(trackType, true)
} else {
val info = trackInfos.find { it.label == selected }!!
builder.setTrackTypeDisabled(trackType, false)
.setOverrideForType(TrackSelectionOverride(info.group, info.index))
}
player.trackSelectionParameters = builder.build()
closeDialog()
}
}
setDefaultNegative("取消")
show()
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PlayerProgressSlider(
progress: Float,
onSeek: (Float) -> Unit,
onSeekFinished: () -> Unit
) {
Slider(
value = progress,
onValueChange = onSeek, // 直接执行传入的 seek 逻辑
onValueChangeFinished = onSeekFinished,
modifier = Modifier.fillMaxWidth(),
thumb = {
Box(modifier = Modifier.size(20.dp)) {
Box(
modifier = Modifier
.size(15.dp)
.align(Alignment.Center)
.background(Color.White, CircleShape)
)
}
},
track = { sliderState ->
SliderDefaults.Track(sliderState = sliderState)
}
)
}
@Composable
fun PlayerSettingChip(label: String, onClick: () -> Unit) {
AssistChip(
onClick = onClick,
label = {
Text(
text = label,
color = Color.White,
fontSize = 12.sp,
fontWeight = FontWeight.Bold
)
}
)
}

View File

@@ -6,9 +6,6 @@ import androidx.compose.material3.Text
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import com.donut.mixfile.server.core.Uploader
import com.donut.mixfile.server.core.uploaders.A1Uploader
import com.donut.mixfile.server.core.uploaders.A2Uploader
import com.donut.mixfile.server.core.uploaders.A3Uploader
import com.donut.mixfile.server.core.uploaders.base.HttpUploader
import com.donut.mixfile.ui.component.common.MixDialogBuilder
import com.donut.mixfile.util.cachedMutableOf