创建 Android Studio AdvancedPlayer 项目

创建 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">&lt;uses-permission android:name="android.permission.INTERNET" /&gt; &lt;uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /&gt; &lt;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"&gt; &lt;activity android:name=".MainActivity" android:exported="true" android:label="@string/app_name" android:theme="@style/Theme.AdvancedPlayer"&gt; &lt;intent-filter&gt; &lt;action android:name="android.intent.action.MAIN" /&gt; &lt;category android:name="android.intent.category.LAUNCHER" /&gt; &lt;/intent-filter&gt; &lt;/activity&gt; &lt;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" /&gt; &lt;/application&gt;</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>&lt;!-- Theme for activities that explicitly want no action bar --&gt; &lt;style name="Theme.AdvancedPlayer.NoActionBar" parent="Theme.AdvancedPlayer"&gt; &lt;item name="windowActionBar"&gt;false&lt;/item&gt; &lt;item name="windowNoTitle"&gt;true&lt;/item&gt; &lt;/style&gt;</resources> (请注意,themes.xml 中的颜色引用是示例,实际会使用 Material3 的颜色体系。如果需要自定义,请在 core-uiColor.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>&lt;style name="Theme.AdvancedPlayer.NoActionBar" parent="Theme.AdvancedPlayer"&gt; &lt;item name="windowActionBar"&gt;false&lt;/item&gt; &lt;item name="windowNoTitle"&gt;true&lt;/item&gt; &lt;/style&gt;</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&lt;PlayHistoryEntity?&gt; @Query("SELECT * FROM playback_history WHERE channelId = :channelId") suspend fun getByChannelId(channelId: String): PlayHistoryEntity? @Query("DELETE FROM playback_history WHERE lastWatchedTimestamp &lt; :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&lt;Float&gt; = context.dataStore.data .map { preferences -&gt; preferences[Keys.PLAYBACK_SPEED] ?: 1.0f } suspend fun setPlaybackSpeed(speed: Float) { context.dataStore.edit { settings -&gt; 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 实例,并将其传递给 AndroidViewPlayerControls
  • DebugOverlay: 确保了 com.example.BuildConfig.DEBUG 的正确引用(它需要一个 buildConfigFieldbuild.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.")
        }
    }
}

No Comments

Send Comment Edit Comment


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
Previous
Next