diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9d89a58..6e484ef 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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().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") } \ No newline at end of file diff --git a/app/src/main/java/com/donut/mixfile/activity/video/player/BottomControl.kt b/app/src/main/java/com/donut/mixfile/activity/video/player/BottomControl.kt index 01160c7..2391d94 100644 --- a/app/src/main/java/com/donut/mixfile/activity/video/player/BottomControl.kt +++ b/app/src/main/java/com/donut/mixfile/activity/video/player/BottomControl.kt @@ -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() + } + } } - - } - } } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/donut/mixfile/activity/video/player/PlayerUtils.kt b/app/src/main/java/com/donut/mixfile/activity/video/player/PlayerUtils.kt new file mode 100644 index 0000000..f7e2da7 --- /dev/null +++ b/app/src/main/java/com/donut/mixfile/activity/video/player/PlayerUtils.kt @@ -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, + defaultPrefix: String = "轨道" + ): List { + val options = mutableListOf() + val labelCounter = mutableMapOf() + + 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 + ) + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/donut/mixfile/server/CustomUploader.kt b/app/src/main/java/com/donut/mixfile/server/CustomUploader.kt index de39dd7..6225fef 100644 --- a/app/src/main/java/com/donut/mixfile/server/CustomUploader.kt +++ b/app/src/main/java/com/donut/mixfile/server/CustomUploader.kt @@ -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