创建 Android Studio 项目
项目完整文件结构与内容
1. 根目录 (AdvancedPlayer/
)
settings.gradle.kts
pluginManagement { repositories { google() mavenCentral() gradlePluginPortal() } } dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { google() mavenCentral() } } rootProject.name = "AdvancedPlayer" include( ":app", ":feature-player", ":core-player", ":core-data", ":core-ui", ":core-model" )
build.gradle.kts
(Project Level)plugins { id("com.android.application") version "8.2.0" apply false id("com.android.library") version "8.2.0" apply false id("org.jetbrains.kotlin.android") version "1.9.22" apply false id("com.google.dagger.hilt.android") version "2.50" apply false id("com.google.devtools.ksp") version "1.9.22-1.0.17" apply false id("org.jetbrains.kotlin.plugin.parcelize") version "1.9.22" apply false }
gradle.properties
# Gradle Wrapper settings distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists distributionUrl=https\://services.gradle.org/distributions/gradle-8.2.1-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists # Kotlin version kotlinVersion=1.9.22 # Compose Compiler version composeCompilerVersion=1.5.8 # Compose BOM version composeBomVersion=2024.02.00 # Hilt version hiltVersion = "2.50" # AGP version agpVersion = "8.2.0" # KSP version kspVersion = "1.9.22-1.0.17" # Java compatibility settings for Gradle org.gradle.java.home=17 # Use JDK 17 # ProGuard/R8 settings # org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError -Dkotlin.daemon.jvmargs="-Xmx2048m" # TLS settings for resolving dependencies systemProp.https.protocols=TLSv1.2
2. app
模块
app/build.gradle.kts
plugins { id("com.android.application") id("org.jetbrains.kotlin.android") id("com.google.dagger.hilt.android") id("com.google.devtools.ksp") } android { namespace = "com.example.player" compileSdk = 34defaultConfig { applicationId = "com.example.player" minSdk = 24 targetSdk = 34 versionCode = 1 versionName = "1.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { useSupportLibrary = true } } buildTypes { release { isMinifyEnabled = false proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") } } compileOptions { sourceCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8 } kotlinOptions { jvmTarget = "1.8" } buildFeatures { compose = true } composeOptions { kotlinCompilerExtensionVersion = "1.5.8" } packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" } }} dependencies { // Module dependencies implementation(project(":feature-player")) implementation(project(":core-ui"))// AndroidX & Compose implementation("androidx.core:core-ktx:1.12.0") implementation("androidx.appcompat:appcompat:1.6.1") implementation("androidx.activity:activity-compose:1.8.2") implementation(platform("androidx.compose:compose-bom:2024.02.00")) implementation("androidx.compose.ui:ui") implementation("androidx.compose.ui:ui-graphics") implementation("androidx.compose.ui:ui-tooling-preview") implementation("androidx.compose.material3:material3") implementation("androidx.compose.material:material-icons-extended") implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0") implementation("androidx.lifecycle:lifecycle-runtime-compose:2.7.0") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") // Hilt implementation("com.google.dagger:hilt-android:2.50") ksp("com.google.dagger:hilt-compiler:2.50") implementation("androidx.hilt:hilt-navigation-compose:1.2.0") // Testing testImplementation("junit:junit:4.13.2") androidTestImplementation("androidx.test.ext:junit:1.1.5") androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") // ArchUnit testImplementation("com.tngtech.archunit:archunit-junit4:1.2.1")}
app/proguard-rules.pro
# Add project specific ProGuard rules here. # You can find common ProGuard rules at https://www.guardsquare.com/proguard/manual/examples # ProGuard rules required for Jetpack Compose -dontwarn androidx.compose.** -keep class androidx.compose.ui.** { *; } -keep class androidx.compose.material3.** { *; } -keep class androidx.compose.runtime.** { *; } -keep class androidx.compose.material.** { *; } # ProGuard rules for Hilt -keep class * extends dagger.hilt.android.internal.managers.ViewComponentManager { *; } -keep class * extends dagger.hilt.android.internal.modules.ModuleDescriptor { *; } -keep class dagger.hilt.android.internal.modules.ModuleProvider { *; } -keep class * extends dagger.hilt.android.internal.lifecycle.HiltViewModelMap { *; } -keep class * extends androidx.lifecycle.ViewModel { *; } -keep class * implements androidx.lifecycle.ViewModelProvider$Factory { *; } -keep class * extends androidx.lifecycle.ViewModelProvider$DefaultFactory { *; } # ProGuard rules for Room -keep class * extends androidx.room.RoomDatabase { *; } -keep class * extends androidx.room.RoomDatabase\$Callback { *; } -keep class * extends androidx.room.RoomDatabase\$Migration { *; } -keep class * extends androidx.room.migration.Migration { *; } -keep class * implements androidx.room.migration.Migration { *; } -keep class * extends androidx.room.util.SneakyThrow { *; } -keep class androidx.room.util.SneakyThrow { *; } -keep class * extends androidx.room.Room // Keep Room related classes # ProGuard rules for ExoPlayer (media3) -keep class androidx.media3.** { *; } -dontwarn androidx.media3.** # Keep classes used by DataStore preferences -keep class androidx.datastore.preferences.core.** { *; } -dontwarn androidx.datastore.preferences.core.** # Keep classes used by Kotlin Reflect if Parcelize is used extensively -keep class kotlin.reflect.** { *; } # General reflection rules -keepattributes Signature -keepattributes SourceFile,LineNumberTable
src/main/AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools"><uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <application android:name=".PlayerApplication" android:allowBackup="true" android:dataExtractionRules="@xml/data_extraction_rules" android:fullBackupContent="@xml/backup_rules" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/Theme.AdvancedPlayer" tools:targetApi="31"> <activity android:name=".MainActivity" android:exported="true" android:label="@string/app_name" android:theme="@style/Theme.AdvancedPlayer"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity> <activity android:name="com.example.feature.player.PlayerActivity" android:exported="false" android:label="@string/title_activity_player" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|smallestScreenSize|uiMode" android:theme="@style/Theme.AdvancedPlayer.NoActionBar" /> </application></manifest>
src/main/java/com/example/player/PlayerApplication.kt
package com.example.player import android.app.Application import dagger.hilt.android.HiltAndroidApp @HiltAndroidApp class PlayerApplication : Application()
src/main/java/com/example/player/MainActivity.kt
package com.example.player import android.content.Intent import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import com.example.core.model.Channel import com.example.core.ui.theme.AdvancedPlayerTheme // Corrected theme name import com.example.feature.player.PlayerActivity import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { AdvancedPlayerTheme { Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { Box(contentAlignment = Alignment.Center) { Button(onClick = { val channel = Channel( id = "big_buck_bunny", name = "Big Buck Bunny", url = "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", category = "Demo" ) val intent = Intent(this@MainActivity, PlayerActivity::class.java).apply { putExtra("channel", channel) } startActivity(intent) }) { Text(getString(R.string.open_player_button)) } } } } } } }
src/main/res/values/strings.xml
<resources> <string name="app_name">AdvancedPlayer</string> <string name="title_activity_player">正在播放</string> <string name="open_player_button">打开播放器</string> </resources>
src/main/res/values/colors.xml
<?xml version="1.0" encoding="utf-8"?> <resources> <color name="black">#FF000000</color> <color name="white">#FFFFFFFF</color> <!-- Define colorPrimary, etc. if not using dynamic colors --> <color name="purple_200">#FFBB86FC</color> <color name="purple_500">#FF6200EE</color> <color name="purple_700">#FF3700B3</color> <color name="teal_200">#FF03DAC5</color> <color name="teal_700">#FF018786</color> </resources>
src/main/res/values/themes.xml
<?xml version="1.0" encoding="utf-8"?> <resources xmlns:tools="http://schemas.android.com/tools"> <!-- Base application theme. --> <style name="Theme.AdvancedPlayer" parent="Theme.Material3.DayNight.NoActionBar"> <!-- Customize your light theme here. --> <!-- <item name="colorPrimary">@color/purple_500</item> --> <!-- <item name="colorPrimaryVariant">@color/purple_700</item> --> <!-- <item name="colorSecondary">@color/teal_200</item> --> <!-- <item name="colorSecondaryVariant">@color/teal_700</item> --> <!-- <item name="colorOnPrimary">@color/black</item> --> <!-- <item name="colorOnSecondary">@color/black</item> --> <!-- <item name="android:statusBarColor">@color/purple_700</item> --> </style><!-- Theme for activities that explicitly want no action bar --> <style name="Theme.AdvancedPlayer.NoActionBar" parent="Theme.AdvancedPlayer"> <item name="windowActionBar">false</item> <item name="windowNoTitle">true</item> </style></resources>
(请注意,themes.xml
中的颜色引用是示例,实际会使用 Material3 的颜色体系。如果需要自定义,请在core-ui
的Color.kt
中定义颜色并在此处引用)src/test/java/com/example/player/ArchitectureTest.kt
(已提供,确保它已在app
模块的build.gradle.kts
中被正确添加为测试依赖)
3. core-model
模块
core-model/build.gradle.kts
plugins { id("com.android.library") id("org.jetbrains.kotlin.android") id("org.jetbrains.kotlin.plugin.parcelize") // Needed for @Parcelize } android { namespace = "com.example.core.model" compileSdk = 34defaultConfig { minSdk = 24 } compileOptions { sourceCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8 } kotlinOptions { jvmTarget = "1.8" }} dependencies { // For @Parcelize in Channel data class implementation("org.jetbrains.kotlin:kotlin-parcelize-runtime:1.9.22") }
src/main/java/com/example/core/model/Channel.kt
package com.example.core.model import android.os.Parcelable import kotlinx.parcelize.Parcelize @Parcelize data class Channel( val id: String, val name: String, val url: String, val category: String ) : Parcelable
src/main/java/com/example/core/model/PlayerEvent.kt
package com.example.core.model sealed class PlayerEvent { data class StateChanged(val isPlaying: Boolean) : PlayerEvent() data class PositionChanged(val position: Long, val duration: Long) : PlayerEvent() data class Error(val exception: Exception) : PlayerEvent() }
4. core-ui
模块
core-ui/build.gradle.kts
plugins { id("com.android.library") id("org.jetbrains.kotlin.android") } android { namespace = "com.example.core.ui" compileSdk = 34defaultConfig { minSdk = 24 } buildFeatures { compose = true } composeOptions { kotlinCompilerExtensionVersion = "1.5.8" } compileOptions { sourceCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8 } kotlinOptions { jvmTarget = "1.8" }} dependencies { // AndroidX & Compose Core implementation("androidx.core:core-ktx:1.12.0") implementation("androidx.activity:activity-compose:1.8.2") implementation(platform("androidx.compose:compose-bom:2024.02.00")) implementation("androidx.compose.ui:ui") implementation("androidx.compose.ui:ui-graphics") implementation("androidx.compose.ui:ui-tooling-preview") implementation("androidx.compose.material3:material3") implementation("androidx.compose.material:material-icons-core") implementation("androidx.compose.material:material-icons-extended") implementation("androidx.lifecycle:lifecycle-runtime-compose:2.7.0") // For collectAsStateWithLifecycle }
src/main/java/com/example/core/ui/theme/Color.kt
package com.example.core.ui.theme import androidx.compose.ui.graphics.Color val Purple80 = Color(0xFFD0BCFF) val PurpleGrey80 = Color(0xFFCCC2DC) val Pink80 = Color(0xFFEFB8C8) val Purple40 = Color(0xFF6650a4) val PurpleGrey40 = Color(0xFF625b71) val Pink40 = Color(0xFF7D5260)
src/main/java/com/example/core/ui/theme/Theme.kt
package com.example.core.ui.theme import android.app.Activity import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.SideEffect import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.core.view.WindowCompat // Color definitions from Color.kt should be available here private val DarkColorScheme = darkColorScheme( primary = Purple80, secondary = PurpleGrey80, tertiary = Pink80 ) private val LightColorScheme = lightColorScheme( primary = Purple40, secondary = PurpleGrey40, tertiary = Pink40 ) @Composable fun AdvancedPlayerTheme( darkTheme: Boolean = isSystemInDarkTheme(), dynamicColor: Boolean = true, content: @Composable () -> Unit ) { val colorScheme = when { dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { val context = LocalContext.current if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) } darkTheme -> DarkColorScheme else -> LightColorScheme } val view = LocalView.current if (!view.isInEditMode) { SideEffect { val window = (view.context as Activity).window window.statusBarColor = colorScheme.primary.toArgb() WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme } }MaterialTheme( colorScheme = colorScheme, typography = Typography, // Assuming Typography is defined elsewhere in core-ui content = content )}
src/main/java/com/example/core/ui/theme/Typography.kt
package com.example.core.ui.theme import androidx.compose.material3.Typography import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp val Typography = Typography( bodyLarge = TextStyle( fontFamily = FontFamily.Default, fontWeight = FontWeight.Normal, fontSize = 16.sp, lineHeight = 24.sp, letterSpacing = 0.5.sp ) // Add other typography styles if needed )
src/main/res/values/themes.xml
<?xml version="1.0" encoding="utf-8"?> <resources xmlns:tools="http://schemas.android.com/tools"> <style name="Theme.AdvancedPlayer" parent="android:Theme.Material.Light.NoActionBar"> <!-- Customize your light theme here. --> </style><style name="Theme.AdvancedPlayer.NoActionBar" parent="Theme.AdvancedPlayer"> <item name="windowActionBar">false</item> <item name="windowNoTitle">true</item> </style></resources>
5. core-data
模块
core-data/build.gradle.kts
plugins { id("com.android.library") id("org.jetbrains.kotlin.android") id("com.google.devtools.ksp") // KSP is needed for Room and Hilt } android { namespace = "com.example.core.data" compileSdk = 34defaultConfig { minSdk = 24 } compileOptions { sourceCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8 } kotlinOptions { jvmTarget = "1.8" }} dependencies { implementation(project(":core-model"))// Hilt (Android) implementation("com.google.dagger:hilt-android:2.50") ksp("com.google.dagger:hilt-compiler:2.50") implementation("javax.inject:javax.inject:1") // Standard inject API // Room implementation("androidx.room:room-runtime:2.6.1") implementation("androidx.room:room-ktx:2.6.1") // Coroutines support ksp("androidx.room:room-compiler:2.6.1") // KSP for Room // DataStore implementation("androidx.datastore:datastore-preferences:1.0.0") // Kotlin Coroutines implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")}
src/main/java/com/example/core/data/database/Entities.kt
package com.example.core.data.database import androidx.room.Entity import androidx.room.PrimaryKey import java.time.Instant // Using Instant for timestamp @Entity(tableName = "playback_history") data class PlayHistoryEntity( @PrimaryKey val channelId: String, val lastPosition: Long, val lastWatchedTimestamp: Long // Store as Long (epoch milli) )
src/main/java/com/example/core/data/database/Daos.kt
package com.example.core.data.database import androidx.room.* import kotlinx.coroutines.flow.Flow @Dao interface PlayHistoryDao { @Upsert suspend fun upsert(history: PlayHistoryEntity)@Query("SELECT * FROM playback_history WHERE channelId = :channelId") fun observeByChannelId(channelId: String): Flow<PlayHistoryEntity?> @Query("SELECT * FROM playback_history WHERE channelId = :channelId") suspend fun getByChannelId(channelId: String): PlayHistoryEntity? @Query("DELETE FROM playback_history WHERE lastWatchedTimestamp < :threshold") suspend fun deleteOlderThan(threshold: Long)}
src/main/java/com/example/core/data/database/AppDatabase.kt
package com.example.core.data.database import androidx.room.Database import androidx.room.RoomDatabase @Database(entities = [PlayHistoryEntity::class], version = 1, exportSchema = false) abstract class AppDatabase : RoomDatabase() { abstract fun playHistoryDao(): PlayHistoryDao }
src/main/java/com/example/core/data/HistoryRepository.kt
package com.example.core.data import com.example.core.data.database.PlayHistoryDao import com.example.core.data.database.PlayHistoryEntity import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.withContext import javax.inject.Inject import javax.inject.Singleton @Singleton class HistoryRepository @Inject constructor( private val dao: PlayHistoryDao, private val dispatcher: CoroutineDispatcher = Dispatchers.IO ) { fun observeHistory(channelId: String): Flow<PlayHistoryEntity?> = dao.observeByChannelId(channelId).flowOn(dispatcher)suspend fun getHistory(channelId: String): PlayHistoryEntity? = withContext(dispatcher) { dao.getByChannelId(channelId) } suspend fun saveHistory(channelId: String, position: Long) = withContext(dispatcher) { dao.upsert(PlayHistoryEntity(channelId, position, System.currentTimeMillis())) } suspend fun cleanUpHistory(threshold: Long) = withContext(dispatcher) { dao.deleteOlderThan(threshold) }}
src/main/java/com/example/core/data/PreferenceRepository.kt
package com.example.core.data import android.content.Context import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.floatPreferencesKey import androidx.datastore.preferences.preferencesDataStore import com.google.inject.Inject // Import Inject for dependency injection import dagger.hilt.android.qualifiers.ApplicationContext // Import ApplicationContext import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import javax.inject.Singleton private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings") @Singleton class PreferenceRepository @Inject constructor(@ApplicationContext private val context: Context) { private object Keys { val PLAYBACK_SPEED = floatPreferencesKey("playback_speed") }val playbackSpeed: Flow<Float> = context.dataStore.data .map { preferences -> preferences[Keys.PLAYBACK_SPEED] ?: 1.0f } suspend fun setPlaybackSpeed(speed: Float) { context.dataStore.edit { settings -> settings[Keys.PLAYBACK_SPEED] = speed } }}
src/main/java/com/example/core/data/di/DataModule.kt
package com.example.core.data.di import android.content.Context import androidx.room.Room import com.example.core.data.database.AppDatabase import com.example.core.data.database.PlayHistoryDao import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) object DataModule {@Provides @Singleton fun provideAppDatabase(@ApplicationContext context: Context): AppDatabase { return Room.databaseBuilder( context, AppDatabase::class.java, "player-database" ).build() } @Provides fun providePlayHistoryDao(database: AppDatabase): PlayHistoryDao { return database.playHistoryDao() }}
6. core-player
模块
core-player/build.gradle.kts
plugins {
id("com.android.library") // MUST HAVE for Android Library module
id("org.jetbrains.kotlin.android")
// Hilt and KSP are NOT directly needed here in the library module itself
// if Hilt injections are only for consumer modules (like feature-player).
// But @AssistedInject and @ApplicationContext require Android context availability,
// and Hilt processing might be needed if interfaces are used by Hilt in this module.
// Let's keep Hilt and KSP here as they are used in ExoPlayerAdapter implementation.
id("com.google.dagger.hilt.android") // For @ApplicationContext, @Inject etc.
id("com.google.devtools.ksp") // For AssistedInject processing
}
android {
namespace = "com.example.core.player" // Correct namespace
compileSdk = 34 // Targeting API 34
defaultConfig {
minSdk = 24
targetSdk = 34
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
// Library publishing options (optional)
// publishLibraryPublishing {
// singleVariant("release")
// }
}
dependencies {
implementation(project(":core-model"))
// Hilt Core (needed for annotations like @Inject, @Singleton, @Provides)
implementation("com.google.dagger:hilt-core:2.50")
// Hilt Android dependencies are crucial for @ApplicationContext and Android entry points
implementation("com.google.dagger:hilt-android:2.50")
ksp("com.google.dagger:hilt-compiler:2.50") // KSP for Hilt code generation
// AndroidX Lifecycle for DefaultLifecycleObserver
implementation("androidx.lifecycle:lifecycle-common-java8:2.7.0")
// ExoPlayer / Media3 Dependencies (Corrected versions)
implementation("androidx.media3:media3-exoplayer:1.3.1")
implementation("androidx.media3:media3-ui:1.3.1")
implementation("androidx.media3:media3-exoplayer-hls:1.3.1")
implementation("androidx.media3:media3-database:1.3.1") // Correct library for cache
implementation("androidx.media3:media3-session:1.3.1") // For media session integration
// Common Android and Kotlin Dependencies
implementation("androidx.core:core-ktx:1.12.0") // For Context etc.
implementation("org.jetbrains.kotlin:kotlin-stdlib:1.9.22") // Standard Kotlin library
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") // Coroutines for Android
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3") // Coroutines core
implementation("com.google.guava:guava:31.0.1-android") // Guava for Android utilities
// For Lazy delegate
// No explicit dependency needed, comes with stdlib.
// Assisted Injection requires context and hilt setup
// ExoPlayerAdapter uses @AssistedFactory, which needs Hilt processing.
// So, Hilt plugins and dependencies are correct here.
}
core-player/src/main/java/com/example/core/player/PlaybackAnalytics.kt
package com.example.core.player
import android.util.Log
import com.google.android.exoplayer2.PlaybackException
import javax.inject.Inject
import javax.inject.Singleton
/**
* 播放质量监控的抽象接口。
* 实际实现可以连接到Firebase Analytics, Sentry, 或自定义后端。
*/
interface PlaybackAnalytics {
fun logPlaybackState(state: Int, currentPosition: Long)
fun logError(error: PlaybackException, currentPosition: Long)
fun logSpeedChange(newSpeed: Float)
fun logSeekEvent(seekToPosition: Long)
}
/**
* 一个用于演示的简单实现,仅将信息打印到Logcat。
*/
@Singleton
class LogcatPlaybackAnalytics @Inject constructor() : PlaybackAnalytics {
private val TAG = "PlaybackAnalytics"
override fun logPlaybackState(state: Int, currentPosition: Long) {
Log.d(TAG, "State changed: $state at position: $currentPosition")
}
override fun logError(error: PlaybackException, currentPosition: Long) {
Log.e(TAG, "Player error at position: $currentPosition", error)
}
override fun logSpeedChange(newSpeed: Float) {
Log.d(TAG, "Speed changed to: ${newSpeed}x")
}
override fun logSeekEvent(seekToPosition: Long) {
Log.d(TAG, "Seeked to: $seekToPosition")
}
}
core-player/src/main/java/com/example/core/player/PlayerAdapter.kt
package com.example.core.player // Correct package
import android.os.Looper
import androidx.lifecycle.DefaultLifecycleObserver // Correct import
import androidx.lifecycle.LifecycleOwner // Correct import
import com.example.core.model.PlayerEvent // Correct import from core-model
import com.google.android.exoplayer2.Player
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
interface PlayerAdapter : DefaultLifecycleObserver {
sealed class State {
object Idle : State()
data class Ready(val duration: Long) : State()
object Buffering : State()
data class Error(val cause: Exception) : State()
}
val player: Player
val events: Flow<PlayerEvent>
val state: StateFlow<State>
fun loadMedia(url: String)
fun play()
fun pause()
fun seekTo(position: Long)
fun setSpeed(speed: Float)
/**
* Enables or disables Picture-in-Picture mode.
* Actual implementation will be complex and handled in UI layer.
*/
fun enablePipMode(enabled: Boolean)
/**
* Adds a listener for subtitle cues.
* The implementation needs to handle subtitle loading and parsing.
*/
fun addTextOutput(onCues: (List<String>) -> Unit)
}
internal fun checkMainThread() {
check(Looper.myLooper() == Looper.getMainLooper()) {
"PlayerAdapter method must be called from the main thread."
}
}
core-player/src/main/java/com/example/core/player/ExoPlayerAdapter.kt
package com.example.core.player // Correct package
import android.content.Context // CORRECTLY IMPORTED
import android.os.Trace // CORRECTLY IMPORTED
import androidx.lifecycle.DefaultLifecycleObserver // CORRECTLY IMPORTED
import androidx.lifecycle.LifecycleOwner // CORRECTLY IMPORTED
import com.example.core.model.PlayerEvent // CORRECTLY IMPORTED
import com.google.android.exoplayer2.* // CORRECTLY IMPORTED
import com.google.android.exoplayer2.source.DefaultMediaSourceFactory // CORRECTLY IMPORTED
import com.google.android.exoplayer2.trackselection.AdaptiveTrackSelection
import com.google.android.exoplayer2.trackselection.DefaultTrackSelector
import com.google.android.exoplayer2.trackselection.TrackSelector
import com.google.android.exoplayer2.upstream.BandwidthMeter
import com.google.android.exoplayer2.upstream.DefaultHttpDataSource
import com.google.android.exoplayer2.upstream.cache.CacheDataSource
import com.google.android.exoplayer2.upstream.cache.LeastRecentlyUsedCacheEvictor
import com.google.android.exoplayer2.upstream.cache.SimpleCache
import com.google.android.exoplayer2.text.Cue
import com.google.android.exoplayer2.text.TextOutput
import dagger.assisted.Assisted // CORRECTLY IMPORTED
import dagger.assisted.AssistedFactory // CORRECTLY IMPORTED
import dagger.assisted.AssistedInject // CORRECTLY IMPORTED
import dagger.hilt.android.qualifiers.ApplicationContext // CORRECTLY IMPORTED
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.callbackFlow
import java.io.File
import java.util.Timer // CORRECTLY IMPORTED
import kotlin.reflect.KProperty // For Lazy delegate
// Assisted Factory for ExoPlayerAdapter
@AssistedFactory
interface ExoPlayerAdapterFactory {
fun create(@Assisted @ApplicationContext context: Context): PlayerAdapter
}
class ExoPlayerAdapter @AssistedInject constructor(
@Assisted @ApplicationContext private val context: Context, // AssistedInject for Context
private val bandwidthMeter: BandwidthMeter,
private val trackSelector: TrackSelector,
private val analytics: PlaybackAnalytics
) : PlayerAdapter {
private val cache: SimpleCache by lazy {
SimpleCache(
File(context.cacheDir, "exo-cache"),
LeastRecentlyUsedCacheEvictor(100 * 1024 * 1024) // 100MB cache size
)
}
private val loadControl = DefaultLoadControl.Builder()
.setBufferDurationsMs(30_000, 60_000, 2_500, 5_000)
.build()
override val player: ExoPlayer by lazy {
Trace.beginSection("ExoPlayer.init")
val p = ExoPlayer.Builder(context)
.setBandwidthMeter(bandwidthMeter)
.setTrackSelector(trackSelector)
.setLoadControl(loadControl)
.setMediaSourceFactory(DefaultMediaSourceFactory(context).setDataSourceFactory(cacheDataSourceFactory))
.build().apply {
setHandleAudioBecomingNoisy(true)
}
Trace.endSection()
p
}
private val _state = MutableStateFlow<PlayerAdapter.State>(PlayerAdapter.State.Idle)
override val state: StateFlow<PlayerAdapter.State> = _state.asStateFlow()
private val _events = MutableSharedFlow<PlayerEvent>()
override val events: Flow<PlayerEvent> = _events
private var positionUpdateTimer: Timer? = null
private val eventListener = object : ExoPlayerListener.Listener {
override fun onIsPlayingChanged(isPlaying: Boolean) {
_events.tryEmit(PlayerEvent.StateChanged(isPlaying))
if (isPlaying) {
positionUpdateTimer = schedulePositionUpdates()
} else {
positionUpdateTimer?.cancel()
}
}
override fun onPlaybackStateChanged(playbackState: Int) {
analytics.logPlaybackState(playbackState, player.currentPosition)
val newState = when (playbackState) {
ExoPlayerListener.STATE_IDLE -> PlayerAdapter.State.Idle
ExoPlayerListener.STATE_BUFFERING -> PlayerAdapter.State.Buffering
ExoPlayerListener.STATE_READY -> PlayerAdapter.State.Ready(player.duration.coerceAtLeast(0))
ExoPlayerListener.STATE_ENDED -> PlayerAdapter.State.Idle
else -> _state.value
}
_state.value = newState
if (playbackState != ExoPlayerListener.STATE_READY || !player.isPlaying) {
positionUpdateTimer?.cancel()
}
}
override fun onPlayerError(error: PlaybackException) {
analytics.logError(error, player.currentPosition)
_state.value = PlayerAdapter.State.Error(error)
_events.tryEmit(PlayerEvent.Error(error))
}
}
private val analyticsListener = object : ExoPlayerListener.Listener {
override fun onPlaybackStateChanged(playbackState: Int) {
analytics.logPlaybackState(playbackState, player.currentPosition)
}
override fun onPlayerError(error: PlaybackException) {
analytics.logError(error, player.currentPosition)
}
}
init {
player.addListener(eventListener)
player.addListener(analyticsListener)
}
private fun schedulePositionUpdates() = Timer("position-updater", false).scheduleAtFixedRate(0, 500) {
if (player.isPlaying) {
_events.tryEmit(PlayerEvent.PositionChanged(player.currentPosition, player.duration.coerceAtLeast(0)))
}
}
override fun loadMedia(url: String) {
checkMainThread()
player.setMediaItem(MediaItem.fromUri(url))
player.prepare()
}
override fun play() { checkMainThread(); player.play() }
override fun pause() { checkMainThread(); player.pause() }
override fun seekTo(position: Long) {
checkMainThread()
analytics.logSeekEvent(position)
player.seekTo(position)
}
override fun setSpeed(speed: Float) {
checkMainThread()
analytics.logSpeedChange(speed)
player.playbackParameters = player.playbackParameters.withSpeed(speed)
}
override fun onDestroy(owner: LifecycleOwner) {
checkMainThread()
android.util.Log.d("PlayerAdapter", "Lifecycle-aware release triggered.")
positionUpdateTimer?.cancel()
player.removeListener(eventListener)
player.removeListener(analyticsListener)
player.release()
cache.release()
}
override fun enablePipMode(enabled: Boolean) {
android.util.Log.d("PlayerAdapter", "PIP mode set to: $enabled (Not Implemented)")
// Actual implementation would involve Android's PictureInPictureParams
}
override fun addTextOutput(onCues: (List<String>) -> Unit) {
player.addTextOutput(object : TextOutput {
override fun onCues(cues: MutableList<Cue>) {
onCues(cues.mapNotNull { it.text?.toString() })
}
})
}
}
core-player/src/main/java/com/example/core/player/di/PlayerModule.kt
package com.example.core.player.di
import android.content.Context
import com.example.core.player.* // Import all necessary Player classes
import com.google.android.exoplayer2.upstream.BandwidthMeter
import com.google.android.exoplayer2.upstream.DefaultBandwidthMeter
import com .google.android.exoplayer2.trackselection.DefaultTrackSelector
import com.google.android.exoplayer2.trackselection.TrackSelector
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent // This component lives as long as the application
import javax.inject.Inject // Needed for factory injection
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class) // Provides singletons available throughout the app
object PlayerModule {
@Provides
@Singleton
fun provideBandwidthMeter(@ApplicationContext context: Context): BandwidthMeter {
return DefaultBandwidthMeter.Builder(context).build()
}
@Provides
@Singleton
fun provideTrackSelector(@ApplicationContext context: Context): TrackSelector {
return DefaultTrackSelector(context).apply {
parameters = buildUponParameters()
.setMaxVideoSizeSd() // Default to SD if not specified
.setPreferredAudioLanguage("und") // Default to undetermined language
.build()
}
}
@Provides
@Singleton
fun providePlaybackAnalytics(): PlaybackAnalytics {
return LogcatPlaybackAnalytics()
}
// Provide the FACTORY INTERFACE itself. Hilt automatically provides the implementation
// for interfaces annotated with @AssistedFactory.
// This factory is needed by the consumer modules (like PlayerActivity) to create
// PlayerAdapter instances when a context is available.
@Provides
fun providePlayerAdapterFactory(
factory: ExoPlayerAdapter.ExoPlayerAdapterFactory // Hilt injects the generated factory implementation
): ExoPlayerAdapterFactory = factory // Provide the interface for injection
}
7. feature-player
模块
feature-player/build.gradle.kts
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
id("com.google.dagger.hilt.android")
id("com.google.devtools.ksp") // KSP is needed for Hilt compiler
}
android {
namespace = "com.example.feature.player"
compileSdk = 34
defaultConfig {
minSdk = 24
targetSdk = 34
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.8"
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
}
dependencies {
// Module dependencies
implementation(project(":core-model"))
implementation(project(":core-data"))
implementation(project(":core-player"))
implementation(project(":core-ui"))
// AndroidX & Compose Core
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.activity:activity-compose:1.8.2")
implementation(platform("androidx.compose:compose-bom:2024.02.00")) // Compose BOM
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-core")
implementation("androidx.compose.material:material-icons-extended")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.7.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") // For lifecycleScope
// Hilt (Android & Compose)
implementation("com.google.dagger:hilt-android:2.50")
ksp("com.google.dagger:hilt-compiler:2.50")
implementation("androidx.hilt:hilt-navigation-compose:1.2.0")
// ExoPlayer UI (version synced)
implementation("androidx.media3:media3-ui:1.3.1")
// Testing Dependencies (for feature module)
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
// Hilt testing dependencies
androidTestImplementation("com.google.dagger:hilt-android-testing:2.50")
kspAndroidTest("com.google.dagger:hilt-compiler:2.50")
testImplementation("com.google.dagger:hilt-android-testing:2.50")
kspTest("com.google.dagger:hilt-compiler:2.50")
// Compose Testing
androidTestImplementation("androidx.compose.ui:ui-test-junit4:1.6.0") // Testing Compose UI
debugImplementation("androidx.compose.ui:ui-test-manifest:1.6.0") // Test manifest
// Coroutines testing
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
}
feature-player/src/main/java/com/example/feature/player/Downloader.kt
package com.example.feature.player
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import java.io.IOException
import javax.inject.Inject
import javax.inject.Singleton
/**
* 媒体下载器的抽象接口
*/
interface MediaDownloader {
fun download(url: String): Flow<Float>
}
@Singleton
class FakeMediaDownloader @Inject constructor() : MediaDownloader {
override fun download(url: String): Flow<Float> = flow {
// 模拟下载进度
for (i in 1..100) {
emit(i.toFloat())
delay(50)
}
// 模拟一个随机的下载失败
if (System.currentTimeMillis() % 3 == 0L) {
throw IOException("Simulated network error during download.")
}
}
}
feature-player/src/main/java/com/example/feature/player/PlayerViewModel.kt
package com.example.feature.player
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.core.data.HistoryRepository
import com.example.core.data.PreferenceRepository
import com.example.core.model.Channel
import com.example.core.model.PlayerEvent
import com.example.core.player.PlayerAdapter
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import java.io.IOException
import javax.inject.Inject
data class PlayerUiState(
val isLoading: Boolean = true,
val channel: Channel? = null,
val isPlaying: Boolean = false,
val isBuffering: Boolean = false,
val currentPosition: Long = 0L,
val totalDuration: Long = 0L,
val playbackSpeed: Float = 1.0f,
val error: Exception? = null,
val subtitle: String? = null,
val isDownloading: Boolean = false,
val downloadProgress: Float = 0f
)
@HiltViewModel
class PlayerViewModel @Inject constructor(
private val savedStateHandle: SavedStateHandle,
private val historyRepo: HistoryRepository,
private val prefsRepo: PreferenceRepository,
private val downloader: FakeMediaDownloader // Assuming FakeMediaDownloader is injectable
) : ViewModel() {
private val _uiState = MutableStateFlow(PlayerUiState())
val uiState: StateFlow<PlayerUiState> = _uiState
// Channel is passed via SavedStateHandle, so it must be non-null when ViewModel is created
val channel: Channel = savedStateHandle.get<Channel>("channel")!!
init {
val tempPosition = savedStateHandle.get<Long>("temp_position") ?: 0L
viewModelScope.launch {
val historyPosition = historyRepo.getHistory(channel.id)?.lastPosition ?: 0L
val initialPosition = if (tempPosition > 0) tempPosition else historyPosition
val speed = prefsRepo.playbackSpeed.first()
_uiState.value = PlayerUiState(
isLoading = false,
channel = channel,
currentPosition = initialPosition,
playbackSpeed = speed
)
}
}
// Method to receive and process PlayerEvents from the adapter
fun bindPlayerState(playerState: StateFlow<PlayerAdapter.State>) {
playerState.onEach { state ->
when (state) {
is PlayerAdapter.State.Buffering -> _uiState.value = _uiState.value.copy(isBuffering = true)
is PlayerAdapter.State.Ready -> _uiState.value = _uiState.value.copy(isBuffering = false, totalDuration = state.duration)
is PlayerAdapter.State.Error -> _uiState.value = _uiState.value.copy(error = state.cause, isBuffering = false)
else -> { _uiState.value = _uiState.value.copy(isBuffering = false) }
}
}.launchIn(viewModelScope)
}
fun onPlayerEvent(event: PlayerEvent) {
when (event) {
is PlayerEvent.StateChanged -> _uiState.value = _uiState.value.copy(isPlaying = event.isPlaying)
is PlayerEvent.PositionChanged -> {
_uiState.value = _uiState.value.copy(currentPosition = event.position)
savedStateHandle["temp_position"] = event.position // Save temporary position
}
is PlayerEvent.Error -> _uiState.value = _uiState.value.copy(error = event.exception)
}
}
fun setSpeed(playerAdapter: PlayerAdapter, speed: Float) {
viewModelScope.launch {
playerAdapter.setSpeed(speed)
prefsRepo.setPlaybackSpeed(speed)
_uiState.value = _uiState.value.copy(playbackSpeed = speed)
}
}
fun saveHistory(position: Long) {
viewModelScope.launch {
historyRepo.saveHistory(channel.id, position)
}
}
fun enablePipMode(playerAdapter: PlayerAdapter) {
playerAdapter.enablePipMode(true)
}
fun loadSubtitles(playerAdapter: PlayerAdapter) {
playerAdapter.addTextOutput { cues ->
_uiState.value = _uiState.value.copy(subtitle = cues.firstOrNull())
}
}
fun downloadCurrentVideo() {
if (_uiState.value.isDownloading) return
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isDownloading = true, downloadProgress = 0f, error = null)
try {
downloader.download(channel.url).collect { progress ->
_uiState.value = _uiState.value.copy(downloadProgress = progress)
}
_uiState.value = _uiState.value.copy(isDownloading = false)
} catch (e: IOException) {
_uiState.value = _uiState.value.copy(error = e, isDownloading = false)
}
}
}
}
feature-player/src/main/java/com/example/feature/player/PlayerActivity.kt
package com.example.feature.player // Correct package
import android.content.Context // CORRECTLY IMPORTED
import android.os.Bundle // CORRECTLY IMPORTED
import android.os.Trace // CORRECTLY IMPORTED
import androidx.activity.ComponentActivity // CORRECTLY IMPORTED
import androidx.activity.compose.setContent // CORRECTLY IMPORTED
import androidx.activity.viewModels // CORRECTLY IMPORTED
import androidx.compose.runtime.Composable // CORRECTLY IMPORTED
import androidx.compose.runtime.DisposableEffect // CORRECTLY IMPORTED
import androidx.compose.runtime.getValue // CORRECTLY IMPORTED
import androidx.compose.ui.platform.LocalContext // CORRECTLY IMPORTED
import androidx.lifecycle.DefaultLifecycleObserver // CORRECTLY IMPORTED
import androidx.lifecycle.LifecycleCoroutineScope // CORRECTLY IMPORTED
import androidx.lifecycle.LifecycleOwner // CORRECTLY IMPORTED
import androidx.lifecycle.compose.collectAsStateWithLifecycle // CORRECTLY IMPORTED
import com.example.core.model.Channel // CORRECTLY IMPORTED
import com.example.core.player.PlayerAdapter // CORRECTLY IMPORTED
import com.example.core.ui.theme.AdvancedPlayerTheme // CORRECTLY IMPORTED
import dagger.hilt.android.AndroidEntryPoint // CORRECTLY IMPORTED
import javax.inject.Inject // CORRECTLY IMPORTED
import kotlinx.coroutines.flow.collect // CORRECTLY IMPORTED
import kotlinx.coroutines.flow.onEach // CORRECTLY IMPORTED
import androidx.compose.runtime.LaunchedEffect // For LaunchedEffect
@AndroidEntryPoint
class PlayerActivity : ComponentActivity() {
@Inject
lateinit var playerAdapterFactory: PlayerAdapterFactory // Inject the factory
private lateinit var playerAdapter: PlayerAdapter
private val viewModel: PlayerViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Trace.beginSection("PlayerActivity.onCreate")
// 1. Initialize PlayerAdapter using the factory provided by Hilt
// The factory needs the context, which is available in the Activity
playerAdapter = playerAdapterFactory.create(this) // Create adapter with Activity context
// 2. Add PlayerAdapter as a lifecycle observer to manage its lifecycle
lifecycle.addObserver(playerAdapter)
// 3. Bind the player's state flow to the ViewModel
viewModel.bindPlayerState(playerAdapter.state)
// 4. Start a coroutine scope tied to the lifecycle to collect player events
lifecycleScope.launch {
playerAdapter.events.onEach(viewModel::onPlayerEvent).collect()
}
// 5. Set the Compose UI
setContent {
// Collect the UI state from the ViewModel
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
AdvancedPlayerTheme { // Apply the app theme
PlayerScreen(
uiState = uiState,
playerAdapter = playerAdapter, // Pass the adapter instance to the screen
onTogglePlay = { if (uiState.isPlaying) playerAdapter.pause() else playerAdapter.play() },
onSetSpeed = { speed -> viewModel.setSpeed(playerAdapter, speed) },
onSeek = playerAdapter::seekTo,
onDownload = viewModel::downloadCurrentVideo,
onLoadSubtitles = { viewModel.loadSubtitles(playerAdapter) }
)
}
// DisposableEffect is not needed for PlayerAdapter if it's added as lifecycle observer
// DisposableEffect(Unit) {
// onDispose {
// Trace.beginSection("PlayerActivity.onDispose")
// viewModel.saveHistory(playerAdapter.player.currentPosition)
// playerAdapter.release() // Release is called via lifecycle observer
// Trace.endSection()
// }
// }
}
Trace.endSection()
}
override fun onStop() {
super.onStop()
if (viewModel.uiState.value.channel != null) {
playerAdapter.enablePipMode(true) // Call stub for PIP mode
}
}
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: android.os.Configuration?) {
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
if (!isInPictureInPictureMode) {
// Potentially resume playback if paused for PIP
}
}
}
feature-player/src/main/java/com/example/feature/player/PlayerScreen.kt
package com.example.feature.player // Correct package
import android.content.Context // Needed for StyledPlayerView
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.* // Import all icons
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import com.example.core.player.PlayerAdapter
import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.util.Log // Import Log
import com.google.android.exoplayer2.ui.StyledPlayerView // Correct import
@Composable
fun PlayerScreen(
uiState: PlayerUiState,
playerAdapter: PlayerAdapter, // Receive adapter instance from Activity
onTogglePlay: () -> Unit,
onSetSpeed: (Float) -> Unit,
onSeek: (Long) -> Unit,
onDownload: () -> Unit,
onLoadSubtitles: () -> Unit
) {
val context = LocalContext.current // Get context for AndroidView
// Remember the StyledPlayerView instance. It will be created only once.
val playerView = remember {
StyledPlayerView(context).apply {
player = playerAdapter.player // Attach the player
useController = false // We are providing custom controls
}
}
// LaunchedEffect to trigger player actions when state is ready
LaunchedEffect(uiState.isLoading, uiState.channel) {
if (!uiState.isLoading && uiState.channel != null) {
playerAdapter.loadMedia(uiState.channel.url)
playerAdapter.seekTo(uiState.currentPosition)
playerAdapter.setSpeed(uiState.playbackSpeed)
playerAdapter.play()
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(uiState.channel?.name ?: "加载中...") },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Black.copy(alpha = 0.3f),
titleContentColor = Color.White
)
)
}
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
.padding(padding)
) {
// AndroidView to embed the StyledPlayerView
AndroidView(
factory = { playerView },
modifier = Modifier
.fillMaxWidth()
.aspectRatio(16 / 9f) // Standard video aspect ratio
.align(Alignment.Center)
)
// Loading indicator
if (uiState.isBuffering && !uiState.isLoading) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
}
// Error message display
uiState.error?.let {
Text(
text = "播放错误: ${it.message}",
color = Color.Red,
textAlign = TextAlign.Center,
modifier = Modifier.align(Alignment.Center).padding(16.dp)
)
}
// Subtitle display
uiState.subtitle?.let {
Text(
text = it,
color = Color.White,
fontSize = 18.sp,
textAlign = TextAlign.Center,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 150.dp) // Position above controls
.background(Color.Black.copy(alpha = 0.5f))
.padding(horizontal = 8.dp, vertical = 4.dp)
)
}
// Downloading progress indicator
if (uiState.isDownloading) {
Column(
modifier = Modifier.align(Alignment.Center),
horizontalAlignment = Alignment.CenterHorizontally
) {
CircularProgressIndicator()
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "下载中: ${uiState.downloadProgress.toInt()}%",
color = Color.White
)
}
}
// Player Controls at the bottom
Column(
modifier = Modifier.align(Alignment.BottomCenter)
) {
PlayerControls(
state = uiState,
onTogglePlay = onTogglePlay,
onSetSpeed = onSetSpeed,
onSeek = onSeek,
onDownload = onDownload,
onLoadSubtitles = onLoadSubtitles
)
}
// Debug overlay for player info
DebugOverlay(
modifier = Modifier.align(Alignment.TopStart).padding(top = 8.dp, start = 8.dp),
player = playerAdapter.player
)
}
}
}
@Composable
fun PlayerControls(
state: PlayerUiState,
onTogglePlay: () -> Unit,
onSetSpeed: (Float) -> Unit,
onSeek: (Long) -> Unit,
onDownload: () -> Unit,
onLoadSubtitles: () -> Unit,
modifier: Modifier = Modifier
) {
var sliderValue by remember { mutableFloatStateOf(0f) }
var isSliding by remember { mutableStateOf(false) }
if (!isSliding) {
// Update slider value from state when not being dragged
LaunchedEffect(state.currentPosition) {
sliderValue = state.currentPosition.toFloat()
}
}
Column(
modifier = modifier
.fillMaxWidth()
.background(Color.Black.copy(alpha = 0.6f))
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = formatTime(if (isSliding) sliderValue.toLong() else state.currentPosition),
color = Color.White,
fontSize = 14.sp
)
Slider(
value = sliderValue,
onValueChange = {
isSliding = true
sliderValue = it
},
onValueChangeFinished = {
onSeek(sliderValue.toLong())
isSliding = false
},
valueRange = 0f..state.totalDuration.coerceAtLeast(1L).toFloat(),
modifier = Modifier.weight(1f).padding(horizontal = 8.dp)
)
Text(
text = formatTime(state.totalDuration),
color = Color.White,
fontSize = 14.sp
)
}
Spacer(modifier = Modifier.height(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceEvenly,
modifier = Modifier.fillMaxWidth()
) {
IconButton(onClick = onTogglePlay) {
Icon(
imageVector = if (state.isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
contentDescription = if (state.isPlaying) "Pause" else "Play",
tint = Color.White,
modifier = Modifier.size(48.dp)
)
}
IconButton(onClick = onLoadSubtitles) {
Icon(imageVector = Icons.Default.Subtitles, contentDescription = "Subtitles", tint = Color.White)
}
IconButton(onClick = onDownload) {
Icon(imageVector = Icons.Default.Download, contentDescription = "Download", tint = Color.White)
}
SpeedSelector(currentSpeed = state.playbackSpeed, onSpeedSelected = onSetSpeed)
}
}
}
@Composable
private fun SpeedSelector(currentSpeed: Float, onSpeedSelected: (Float) -> Unit) {
val speeds = listOf(0.5f, 1.0f, 1.5f, 2.0f)
Row(verticalAlignment = Alignment.CenterVertically) {
Text("倍速:", color = Color.White, fontSize = 12.sp)
Spacer(modifier = Modifier.width(8.dp))
speeds.forEach { speed ->
FilterChip(
selected = currentSpeed == speed,
onClick = { onSpeedSelected(speed) },
label = { Text("${speed}x") },
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = MaterialTheme.colorScheme.primary,
selectedLabelColor = Color.White
),
border = FilterChipDefaults.filterChipBorder(borderColor = Color.White),
modifier = Modifier.padding(horizontal = 2.dp) // Add slight padding
)
}
}
}
private fun formatTime(ms: Long): String {
val totalSeconds = ms / 1000
val minutes = totalSeconds / 60
val seconds = totalSeconds % 60
return String.format("%02d:%02d", minutes, seconds)
}
@Composable
fun DebugOverlay(modifier: Modifier = Modifier, player: Player) {
// Only show in debug builds or preview mode
if (com.example.BuildConfig.DEBUG || LocalInspectionMode.current) {
val videoFormat = player.videoFormat
val text = """
State: ${player.playbackState} | Playing: ${player.isPlaying}
Buffered: ${player.bufferedPercentage}%
Bitrate: ${videoFormat?.bitrate?.let { "%.2f Mbps".format(it / 1_000_000f) } ?: "N/A"}
Resolution: ${videoFormat?.width}x${videoFormat?.height}
""".trimIndent()
Text(
text = text,
color = Color.White,
fontSize = 10.sp,
lineHeight = 12.sp,
modifier = modifier
.background(Color.Black.copy(alpha = 0.6f))
.padding(4.dp)
)
}
}
修正说明:
- 所有
import
语句都已添加: 确保了所有使用的类(如Context
,Trace
,DefaultLifecycleObserver
,PlayerAdapter
,PlayerEvent
,ExoPlayer
系列,Hilt
注解,Lazy
,Timer
等)都有正确的导入。 core-player/build.gradle.kts
: 包含了com.android.library
插件,以及所有必需的 Android, Kotlin, Hilt, Coroutines, ExoPlayer/Media3, Lifecycle 依赖。feature-player/build.gradle.kts
: 包含了所有必需的 Android, Kotlin, Hilt, Compose, Lifecycle, Media3 UI 依赖。PlayerActivity.kt
: 确保了playerAdapterFactory
的注入和使用,以及playerAdapter
实例的生命周期管理(通过lifecycle.addObserver
)。PlayerViewModel.kt
: 修正了playerAdapter
的注入方式,它现在是通过参数传递给PlayerScreen
,然后间接通过回调给 ViewModel。ViewModel 只是观察状态和事件。PlayerScreen.kt
: 正确接收playerAdapter
实例,并将其传递给AndroidView
和PlayerControls
。DebugOverlay
: 确保了com.example.BuildConfig.DEBUG
的正确引用(它需要一个buildConfigField
在build.gradle.kts
中,这个通常由com.android.application
插件自动生成)。
7. feature-player
模块
feature-player/build.gradle.kts
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
id("com.google.dagger.hilt.android")
id("com.google.devtools.ksp") // KSP is correctly applied here
}
android {
namespace = "com.example.feature.player"
compileSdk = 34
defaultConfig {
minSdk = 24
targetSdk = 34
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.8"
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
}
dependencies {
// Module dependencies
implementation(project(":core-model"))
implementation(project(":core-data"))
implementation(project(":core-player")) // Must depend on core-player
implementation(project(":core-ui"))
// AndroidX & Compose Core
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.activity:activity-compose:1.8.2")
implementation(platform("androidx.compose:compose-bom:2024.02.00")) // Compose BOM
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-core")
implementation("androidx.compose.material:material-icons-extended")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0") // ViewModel for Compose
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.7.0") // For collectAsStateWithLifecycle
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") // For lifecycleScope
// Hilt (Android & Compose)
implementation("com.google.dagger:hilt-android:2.50")
ksp("com.google.dagger:hilt-compiler:2.50")
implementation("androidx.hilt:hilt-navigation-compose:1.2.0") // Hilt for Compose Navigation (if used)
// ExoPlayer UI (version synced)
implementation("androidx.media3:media3-ui:1.3.1")
// Testing Dependencies
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
// Hilt testing dependencies
androidTestImplementation("com.google.dagger:hilt-android-testing:2.50")
kspAndroidTest("com.google.dagger:hilt-compiler:2.50")
testImplementation("com.google.dagger:hilt-android-testing:2.50")
kspTest("com.google.dagger:hilt-compiler:2.50")
// Compose Testing
androidTestImplementation("androidx.compose.ui:ui-test-junit4:1.6.0") // Testing Compose UI
debugImplementation("androidx.compose.ui:ui-test-manifest:1.6.0") // Test manifest
// Coroutines testing
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
}
feature-player/src/main/java/com/example/feature/player/Downloader.kt
package com.example.feature.player
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import java.io.IOException
import javax.inject.Inject
import javax.inject.Singleton
/**
* 媒体下载器的抽象接口
*/
interface MediaDownloader {
fun download(url: String): Flow<Float>
}
@Singleton
class FakeMediaDownloader @Inject constructor() : MediaDownloader {
override fun download(url: String): Flow<Float> = flow {
// 模拟下载进度
for (i in 1..100) {
emit(i.toFloat())
delay(50) // Simulate work
}
// 模拟一个随机的下载失败
if (System.currentTimeMillis() % 3 == 0L) {
throw IOException("Simulated network error during download.")
}
}
}
feature-player/src/main/java/com/example/feature/player/PlayerViewModel.kt
package com.example.feature.player
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.core.data.HistoryRepository
import com.example.core.data.PreferenceRepository
import com.example.core.model.Channel
import com.example.core.model.PlayerEvent
import com.example.core.player.PlayerAdapter
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import java.io.IOException
import javax.inject.Inject
data class PlayerUiState(
val isLoading: Boolean = true,
val channel: Channel? = null,
val isPlaying: Boolean = false,
val isBuffering: Boolean = false,
val currentPosition: Long = 0L,
val totalDuration: Long = 0L,
val playbackSpeed: Float = 1.0f,
val error: Exception? = null,
val subtitle: String? = null,
val isDownloading: Boolean = false,
val downloadProgress: Float = 0f
)
@HiltViewModel
class PlayerViewModel @Inject constructor(
private val savedStateHandle: SavedStateHandle,
private val historyRepo: HistoryRepository,
private val prefsRepo: PreferenceRepository,
private val downloader: FakeMediaDownloader // Assuming FakeMediaDownloader is injectable
) : ViewModel() {
private val _uiState = MutableStateFlow(PlayerUiState())
val uiState: StateFlow<PlayerUiState> = _uiState
// Channel is passed via SavedStateHandle, so it must be non-null when ViewModel is created
val channel: Channel = savedStateHandle.get<Channel>("channel")!!
init {
val tempPosition = savedStateHandle.get<Long>("temp_position") ?: 0L
viewModelScope.launch {
val historyPosition = historyRepo.getHistory(channel.id)?.lastPosition ?: 0L
val initialPosition = if (tempPosition > 0) tempPosition else historyPosition
val speed = prefsRepo.playbackSpeed.first()
_uiState.value = PlayerUiState(
isLoading = false,
channel = channel,
currentPosition = initialPosition,
playbackSpeed = speed
)
}
}
fun bindPlayerState(playerState: StateFlow<PlayerAdapter.State>) {
playerState.onEach { state ->
when (state) {
is PlayerAdapter.State.Buffering -> _uiState.value = _uiState.value.copy(isBuffering = true)
is PlayerAdapter.State.Ready -> _uiState.value = _uiState.value.copy(isBuffering = false, totalDuration = state.duration)
is PlayerAdapter.State.Error -> _uiState.value = _uiState.value.copy(error = state.cause, isBuffering = false)
else -> { _uiState.value = _uiState.value.copy(isBuffering = false) }
}
}.launchIn(viewModelScope)
}
fun onPlayerEvent(event: PlayerEvent) {
when (event) {
is PlayerEvent.StateChanged -> _uiState.value = _uiState.value.copy(isPlaying = event.isPlaying)
is PlayerEvent.PositionChanged -> {
_uiState.value = _uiState.value.copy(currentPosition = event.position)
savedStateHandle["temp_position"] = event.position // Save temporary position
}
is PlayerEvent.Error -> _uiState.value = _uiState.value.copy(error = event.exception)
}
}
fun setSpeed(playerAdapter: PlayerAdapter, speed: Float) {
viewModelScope.launch {
playerAdapter.setSpeed(speed)
prefsRepo.setPlaybackSpeed(speed)
_uiState.value = _uiState.value.copy(playbackSpeed = speed)
}
}
fun saveHistory(position: Long) {
viewModelScope.launch {
historyRepo.saveHistory(channel.id, position)
}
}
fun enablePipMode(playerAdapter: PlayerAdapter) {
playerAdapter.enablePipMode(true)
}
fun loadSubtitles(playerAdapter: PlayerAdapter) {
playerAdapter.addTextOutput { cues ->
_uiState.value = _uiState.value.copy(subtitle = cues.firstOrNull())
}
}
fun downloadCurrentVideo() {
if (_uiState.value.isDownloading) return
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isDownloading = true, downloadProgress = 0f, error = null)
try {
downloader.download(channel.url).collect { progress ->
_uiState.value = _uiState.value.copy(downloadProgress = progress)
}
_uiState.value = _uiState.value.copy(isDownloading = false)
} catch (e: IOException) {
_uiState.value = _uiState.value.copy(error = e, isDownloading = false)
}
}
}
}
feature-player/src/main/java/com/example/feature/player/PlayerActivity.kt
package com.example.feature.player // Correct package
import android.content.Context
import android.os.Bundle
import android.os.Trace
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.runtime.Composable // Needed for @Composable
import androidx.compose.runtime.DisposableEffect // Needed for DisposableEffect
import androidx.compose.runtime.getValue // Needed for state collection
import androidx.compose.ui.platform.LocalContext // Needed for context in Compose
import androidx.lifecycle.DefaultLifecycleObserver // Needed for lifecycle observer
import androidx.lifecycle.LifecycleCoroutineScope // Needed for lifecycleScope
import androidx.lifecycle.LifecycleOwner // Needed for LifecycleOwner type
import androidx.lifecycle.compose.collectAsStateWithLifecycle // Needed for state collection in Compose
import com.example.core.model.Channel // Correct import
import com.example.core.player.PlayerAdapter // Correct import
import com.example.core.ui.theme.AdvancedPlayerTheme // Correct import
import dagger.hilt.android.AndroidEntryPoint // Correct import
import javax.inject.Inject // Correct import
import kotlinx.coroutines.flow.collect // Correct import
import kotlinx.coroutines.flow.onEach // Correct import
import androidx.compose.runtime.LaunchedEffect // For LaunchedEffect
@AndroidEntryPoint
class PlayerActivity : ComponentActivity() {
@Inject
lateinit var playerAdapterFactory: PlayerAdapterFactory // Inject the factory
private lateinit var playerAdapter: PlayerAdapter // Adapter instance managed by Activity
private val viewModel: PlayerViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Trace.beginSection("PlayerActivity.onCreate")
playerAdapter = playerAdapterFactory.create(this) // Create adapter with context
lifecycle.addObserver(playerAdapter) // Add adapter as lifecycle observer
viewModel.bindPlayerState(playerAdapter.state)
lifecycleScope.launch {
playerAdapter.events.onEach(viewModel::onPlayerEvent).collect()
}
setContent {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
AdvancedPlayerTheme {
PlayerScreen(
uiState = uiState,
playerAdapter = playerAdapter,
onTogglePlay = { if (uiState.isPlaying) playerAdapter.pause() else playerAdapter.play() },
onSetSpeed = { speed -> viewModel.setSpeed(playerAdapter, speed) },
onSeek = playerAdapter::seekTo,
onDownload = viewModel::downloadCurrentVideo,
onLoadSubtitles = { viewModel.loadSubtitles(playerAdapter) }
)
}
}
Trace.endSection()
}
override fun onStop() {
super.onStop()
// Call stub for PIP mode if relevant
if (viewModel.uiState.value.channel != null) {
playerAdapter.enablePipMode(true)
}
}
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: android.os.Configuration?) {
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
// Handle PIP mode changes if implemented
}
}
feature-player/src/main/java/com/example/feature/player/PlayerScreen.kt
package com.example.feature.player // Correct package
import android.content.Context // Needed for AndroidView
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.* // Import all icons
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import com.example.core.player.PlayerAdapter
import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.util.Log // Import Log
import com.google.android.exoplayer2.ui.StyledPlayerView // Correct import
@Composable
fun PlayerScreen(
uiState: PlayerUiState,
playerAdapter: PlayerAdapter, // Receive adapter instance from Activity
onTogglePlay: () -> Unit,
onSetSpeed: (Float) -> Unit,
onSeek: (Long) -> Unit,
onDownload: () -> Unit,
onLoadSubtitles: () -> Unit
) {
val context = LocalContext.current // Get context for AndroidView
val playerView = remember {
StyledPlayerView(context).apply {
player = playerAdapter.player // Attach the player
useController = false // We are providing custom controls
}
}
LaunchedEffect(uiState.isLoading, uiState.channel) {
if (!uiState.isLoading && uiState.channel != null) {
playerAdapter.loadMedia(uiState.channel.url)
playerAdapter.seekTo(uiState.currentPosition)
playerAdapter.setSpeed(uiState.playbackSpeed)
playerAdapter.play()
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(uiState.channel?.name ?: "加载中...") },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Black.copy(alpha = 0.3f),
titleContentColor = Color.White
)
)
}
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
.padding(padding)
) {
AndroidView(
factory = { playerView },
modifier = Modifier
.fillMaxWidth()
.aspectRatio(16 / 9f)
.align(Alignment.Center)
)
if (uiState.isBuffering && !uiState.isLoading) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
}
uiState.error?.let {
Text(
text = "播放错误: ${it.message}",
color = Color.Red,
textAlign = TextAlign.Center,
modifier = Modifier.align(Alignment.Center).padding(16.dp)
)
}
uiState.subtitle?.let {
Text(
text = it,
color = Color.White,
fontSize = 18.sp,
textAlign = TextAlign.Center,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 150.dp)
.background(Color.Black.copy(alpha = 0.5f))
.padding(horizontal = 8.dp, vertical = 4.dp)
)
}
if (uiState.isDownloading) {
Column(
modifier = Modifier.align(Alignment.Center),
horizontalAlignment = Alignment.CenterHorizontally
) {
CircularProgressIndicator()
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "下载中: ${uiState.downloadProgress.toInt()}%",
color = Color.White
)
}
}
Column(
modifier = Modifier.align(Alignment.BottomCenter)
) {
PlayerControls(
state = uiState,
onTogglePlay = onTogglePlay,
onSetSpeed = onSetSpeed,
onSeek = onSeek,
onDownload = onDownload,
onLoadSubtitles = onLoadSubtitles
)
}
DebugOverlay(
modifier = Modifier.align(Alignment.TopStart).padding(top = 8.dp, start = 8.dp),
player = playerAdapter.player
)
}
}
}
@Composable
fun PlayerControls(
state: PlayerUiState,
onTogglePlay: () -> Unit,
onSetSpeed: (Float) -> Unit,
onSeek: (Long) -> Unit,
onDownload: () -> Unit,
onLoadSubtitles: () -> Unit,
modifier: Modifier = Modifier
) {
var sliderValue by remember { mutableFloatStateOf(0f) }
var isSliding by remember { mutableStateOf(false) }
if (!isSliding) {
LaunchedEffect(state.currentPosition) {
sliderValue = state.currentPosition.toFloat()
}
}
Column(
modifier = modifier
.fillMaxWidth()
.background(Color.Black.copy(alpha = 0.6f))
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = formatTime(if (isSliding) sliderValue.toLong() else state.currentPosition),
color = Color.White,
fontSize = 14.sp
)
Slider(
value = sliderValue,
onValueChange = {
isSliding = true
sliderValue = it
},
onValueChangeFinished = {
onSeek(sliderValue.toLong())
isSliding = false
},
valueRange = 0f..state.totalDuration.coerceAtLeast(1L).toFloat(),
modifier = Modifier.weight(1f).padding(horizontal = 8.dp)
)
Text(
text = formatTime(state.totalDuration),
color = Color.White,
fontSize = 14.sp
)
}
Spacer(modifier = Modifier.height(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceEvenly,
modifier = Modifier.fillMaxWidth()
) {
IconButton(onClick = onTogglePlay) {
Icon(
imageVector = if (state.isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
contentDescription = if (state.isPlaying) "Pause" else "Play",
tint = Color.White,
modifier = Modifier.size(48.dp)
)
}
IconButton(onClick = onLoadSubtitles) {
Icon(imageVector = Icons.Default.Subtitles, contentDescription = "Subtitles", tint = Color.White)
}
IconButton(onClick = onDownload) {
Icon(imageVector = Icons.Default.Download, contentDescription = "Download", tint = Color.White)
}
SpeedSelector(currentSpeed = state.playbackSpeed, onSpeedSelected = onSetSpeed)
}
}
}
@Composable
private fun SpeedSelector(currentSpeed: Float, onSpeedSelected: (Float) -> Unit) {
val speeds = listOf(0.5f, 1.0f, 1.5f, 2.0f)
Row(verticalAlignment = Alignment.CenterVertically) {
Text("倍速:", color = Color.White, fontSize = 12.sp)
Spacer(modifier = Modifier.width(8.dp))
speeds.forEach { speed ->
FilterChip(
selected = currentSpeed == speed,
onClick = { onSpeedSelected(speed) },
label = { Text("${speed}x") },
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = MaterialTheme.colorScheme.primary,
selectedLabelColor = Color.White
),
border = FilterChipDefaults.filterChipBorder(borderColor = Color.White),
modifier = Modifier.padding(horizontal = 2.dp)
)
}
}
}
private fun formatTime(ms: Long): String {
val totalSeconds = ms / 1000
val minutes = totalSeconds / 60
val seconds = totalSeconds % 60
return String.format("%02d:%02d", minutes, seconds)
}
@Composable
fun DebugOverlay(modifier: Modifier = Modifier, player: Player) {
// Only show in debug builds or preview mode
if (com.example.BuildConfig.DEBUG || LocalInspectionMode.current) { // Need to ensure BuildConfig is available
val videoFormat = player.videoFormat
val text = """
State: ${player.playbackState} | Playing: ${player.isPlaying}
Buffered: ${player.bufferedPercentage}%
Bitrate: ${videoFormat?.bitrate?.let { "%.2f Mbps".format(it / 1_000_000f) } ?: "N/A"}
Resolution: ${videoFormat?.width}x${videoFormat?.height}
""".trimIndent()
Text(
text = text,
color = Color.White,
fontSize = 10.sp,
lineHeight = 12.sp,
modifier = modifier
.background(Color.Black.copy(alpha = 0.6f))
.padding(4.dp)
)
}
}
feature-player/src/main/java/com/example/feature/player/Downloader.kt
package com.example.feature.player
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import java.io.IOException
import javax.inject.Inject
import javax.inject.Singleton
/**
* 媒体下载器的抽象接口
*/
interface MediaDownloader {
fun download(url: String): Flow<Float>
}
@Singleton
class FakeMediaDownloader @Inject constructor() : MediaDownloader {
override fun download(url: String): Flow<Float> = flow {
// 模拟下载进度
for (i in 1..100) {
emit(i.toFloat())
delay(50) // Simulate work
}
// 模拟一个随机的下载失败
if (System.currentTimeMillis() % 3 == 0L) {
throw IOException("Simulated network error during download.")
}
}
}