diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 7e3836854..19c747f56 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -197,8 +197,11 @@ dependencies {
implementation(libs.androidx.compose.animation.graphics)
implementation(libs.mediasession)
implementation(libs.androidx.documentfile)
+ implementation(libs.androidx.datastore.core)
+ implementation(libs.androidx.datastore.preferences)
implementation(libs.bundles.coil)
+
implementation(platform(libs.koin.bom))
implementation(libs.bundles.koin)
@@ -212,11 +215,21 @@ dependencies {
implementation(libs.room.ktx)
implementation(libs.kotlinx.immutable.collections)
+ implementation(libs.kotlinx.coroutines.guava)
implementation(libs.kotlinx.serialization.json)
+
implementation(libs.okhttp)
implementation(libs.jsoup)
implementation(libs.androidx.media3.common)
+ implementation(libs.androidx.media3.datasource.okhttp)
implementation(libs.androidx.media3.effect)
+ implementation(libs.androidx.media3.exoplayer)
+ implementation(libs.androidx.media3.exoplayer.dash)
+ implementation(libs.androidx.media3.exoplayer.hls)
+ implementation(libs.androidx.media3.exoplayer.rtsp)
+ implementation(libs.androidx.media3.session)
+ implementation(libs.androidx.media3.ui)
+ implementation(libs.androidx.media3.ui.compose)
implementation(libs.androidx.media3.transformer)
implementation(platform(libs.sora.editor.bom))
implementation(libs.sora.editor)
@@ -226,11 +239,17 @@ dependencies {
coreLibraryDesugaring(libs.desugar.jdk.libs)
implementation(libs.truetype.parser)
+ implementation(libs.juniversalchardet)
+ implementation(libs.ass.media)
implementation(libs.fsaf)
+
+
implementation(libs.mediainfo.lib)
implementation("com.llamatik:library:1.4.0")
implementation(files("libs/mpvlib.aar"))
+ implementation(files("libs/media3ext-release.aar"))
+
// Network protocol libraries
implementation(libs.smbj)
diff --git a/app/libs/media3ext-release.aar b/app/libs/media3ext-release.aar
new file mode 100644
index 000000000..5dac47408
Binary files /dev/null and b/app/libs/media3ext-release.aar differ
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 3959fcf22..90eef724a 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -332,6 +332,24 @@
+
+
+
+
+
+
+
+
"Background Playback"
PlayerButton.AMBIENT_MODE -> "Ambience Mode"
PlayerButton.TIME_NETWORK -> "Time + Network"
+ PlayerButton.VIDEO_FILTERS -> "Video Filters"
PlayerButton.NONE -> "None"
}
diff --git a/app/src/main/java/app/gyrolet/mpvrx/preferences/PlayerPreferences.kt b/app/src/main/java/app/gyrolet/mpvrx/preferences/PlayerPreferences.kt
index e0c880402..4186e2363 100644
--- a/app/src/main/java/app/gyrolet/mpvrx/preferences/PlayerPreferences.kt
+++ b/app/src/main/java/app/gyrolet/mpvrx/preferences/PlayerPreferences.kt
@@ -93,6 +93,8 @@ class PlayerPreferences(
val keepScreenOnWhenPaused = preferenceStore.getBoolean("keep_screen_on_when_paused", false)
val autoplayAfterScreenUnlock = preferenceStore.getBoolean("autoplay_after_screen_unlock", false)
+ val enableExoPlayer = preferenceStore.getBoolean("enable_exoplayer", false)
+
// Custom Buttons - JSON List
val customButtons = preferenceStore.getString("custom_buttons_json", "[]")
diff --git a/app/src/main/java/app/gyrolet/mpvrx/presentation/crash/CrashActivity.kt b/app/src/main/java/app/gyrolet/mpvrx/presentation/crash/CrashActivity.kt
index 1c5be52fd..cbc358a40 100644
--- a/app/src/main/java/app/gyrolet/mpvrx/presentation/crash/CrashActivity.kt
+++ b/app/src/main/java/app/gyrolet/mpvrx/presentation/crash/CrashActivity.kt
@@ -32,7 +32,6 @@ import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.FilledIconButton
-import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBarDefaults
import androidx.compose.material3.OutlinedButton
@@ -70,6 +69,7 @@ import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.android.ext.android.inject
+import org.koin.core.context.GlobalContext
import java.io.BufferedReader
import java.io.File
import java.io.InputStreamReader
@@ -81,21 +81,36 @@ class CrashActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
+ val exception = intent.getStringExtra("exception") ?: "No exception provided"
+ android.util.Log.e("CrashActivity", "CRASH DETECTED: $exception")
lifecycle.coroutineScope.launch {
logcat = collectLogcat()
}
setContent {
- val dark by appearancePreferences.darkMode.collectAsState()
- val isSystemInDarkTheme = isSystemInDarkTheme()
- val isDarkMode = dark == DarkMode.Dark || (dark == DarkMode.System && isSystemInDarkTheme)
- enableEdgeToEdge(
- SystemBarStyle.auto(
- lightScrim = Color.White.toArgb(),
- darkScrim = Color.Transparent.toArgb(),
- ) { isDarkMode },
- )
- MpvrxTheme {
- CrashScreen(intent.getStringExtra("exception") ?: "")
+ if (GlobalContext.getOrNull() != null) {
+ val dark by appearancePreferences.darkMode.collectAsState()
+ val isSystemInDarkTheme = isSystemInDarkTheme()
+ val isDarkMode = dark == DarkMode.Dark || (dark == DarkMode.System && isSystemInDarkTheme)
+ enableEdgeToEdge(
+ SystemBarStyle.auto(
+ lightScrim = Color.White.toArgb(),
+ darkScrim = Color.Transparent.toArgb(),
+ ) { isDarkMode },
+ )
+ MpvrxTheme {
+ CrashScreen(intent.getStringExtra("exception") ?: "")
+ }
+ } else {
+ val isDarkMode = isSystemInDarkTheme()
+ enableEdgeToEdge(
+ SystemBarStyle.auto(
+ lightScrim = Color.White.toArgb(),
+ darkScrim = Color.Transparent.toArgb(),
+ ) { isDarkMode },
+ )
+ MaterialTheme {
+ CrashScreen(intent.getStringExtra("exception") ?: "")
+ }
}
}
}
diff --git a/app/src/main/java/app/gyrolet/mpvrx/presentation/crash/GlobalExceptionHandler.kt b/app/src/main/java/app/gyrolet/mpvrx/presentation/crash/GlobalExceptionHandler.kt
index d23e076b2..c79d583c3 100644
--- a/app/src/main/java/app/gyrolet/mpvrx/presentation/crash/GlobalExceptionHandler.kt
+++ b/app/src/main/java/app/gyrolet/mpvrx/presentation/crash/GlobalExceptionHandler.kt
@@ -15,7 +15,9 @@ class GlobalExceptionHandler(
val intent = Intent(context, activity)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK)
- intent.putExtra("exception", e.stackTraceToString())
+ val stackTrace = e.stackTraceToString()
+ android.util.Log.e("GlobalExceptionHandler", "CRASH DETECTED: $stackTrace")
+ intent.putExtra("exception", stackTrace)
context.startActivity(intent)
exitProcess(0)
}
diff --git a/app/src/main/java/app/gyrolet/mpvrx/ui/icons/Icons.kt b/app/src/main/java/app/gyrolet/mpvrx/ui/icons/Icons.kt
index e5aa95389..24d2ebd49 100644
--- a/app/src/main/java/app/gyrolet/mpvrx/ui/icons/Icons.kt
+++ b/app/src/main/java/app/gyrolet/mpvrx/ui/icons/Icons.kt
@@ -297,6 +297,9 @@ object Icons {
val Refresh = Shared.Refresh
val Remove = Shared.Remove
val RemoveCircle = Shared.RemoveCircle
+ val Repeat = Shared.Repeat
+ val RepeatOn = Shared.RepeatOn
+ val RepeatOne = Shared.RepeatOne
val ResetIso = Shared.ResetIso
val RoundedCorner = Shared.RoundedCorner
val ScreenRotation = Shared.ScreenRotation
@@ -422,6 +425,8 @@ object Icons {
val PictureInPictureAlt = Shared.PictureInPictureAlt
val PlaylistAdd = Shared.PlaylistAdd
val Repeat = Shared.Repeat
+ val RepeatOn = Shared.RepeatOn
+ val RepeatOne = Shared.RepeatOne
val ScreenRotation = Shared.ScreenRotation
val Search = Shared.Search
val Settings = Shared.Settings
diff --git a/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/PlayerControlsShared.kt b/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/PlayerControlsShared.kt
index 16c3befe2..55b691413 100644
--- a/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/PlayerControlsShared.kt
+++ b/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/PlayerControlsShared.kt
@@ -917,6 +917,15 @@ fun RenderPlayerButton(
}
}
+ PlayerButton.VIDEO_FILTERS -> {
+ ControlsButton(
+ icon = Icons.Default.Tune,
+ onClick = { onOpenPanel(Panels.VideoFilters) },
+ color = if (hideBackground) controlColor else MaterialTheme.colorScheme.onSurface,
+ modifier = Modifier.size(buttonSize),
+ )
+ }
+
PlayerButton.NONE -> { /* Do nothing */
}
}
diff --git a/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/PreferencesScreen.kt b/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/PreferencesScreen.kt
index b93c7be00..942fada46 100644
--- a/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/PreferencesScreen.kt
+++ b/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/PreferencesScreen.kt
@@ -30,9 +30,19 @@ import app.gyrolet.mpvrx.R
import app.gyrolet.mpvrx.presentation.Screen
import app.gyrolet.mpvrx.ui.utils.LocalBackStack
import app.gyrolet.mpvrx.ui.utils.popSafely
+import app.gyrolet.mpvrx.preferences.preference.collectAsState
import kotlinx.serialization.Serializable
import me.zhanghai.compose.preference.Preference
import me.zhanghai.compose.preference.ProvidePreferenceLocals
+import org.koin.compose.koinInject
+import androidx.compose.runtime.getValue
+import app.gyrolet.mpvrx.exoplayer.settings.screens.medialibrary.ExoMediaLibraryPreferencesScreen
+import app.gyrolet.mpvrx.exoplayer.settings.screens.player.ExoPlayerPreferencesScreen
+import app.gyrolet.mpvrx.exoplayer.settings.screens.gesture.ExoGesturePreferencesScreen
+import app.gyrolet.mpvrx.exoplayer.settings.screens.decoder.ExoDecoderPreferencesScreen
+import app.gyrolet.mpvrx.exoplayer.settings.screens.audio.ExoAudioPreferencesScreen
+import app.gyrolet.mpvrx.exoplayer.settings.screens.subtitle.ExoSubtitlePreferencesScreen
+import app.gyrolet.mpvrx.exoplayer.settings.screens.general.ExoGeneralPreferencesScreen
@Serializable
object PreferencesScreen : Screen {
@@ -40,6 +50,11 @@ object PreferencesScreen : Screen {
@Composable
override fun Content() {
val backstack = LocalBackStack.current
+ android.util.Log.d("PreferencesScreen", "Content() starting")
+ val playerPreferences = koinInject()
+ val enableExoPlayer by playerPreferences.enableExoPlayer.collectAsState()
+ android.util.Log.d("PreferencesScreen", "enableExoPlayer: $enableExoPlayer")
+
Scaffold(
topBar = {
TopAppBar(
@@ -103,118 +118,314 @@ object PreferencesScreen : Screen {
}
// ── 1. Appearance ─────────────────────────────────────────────────
- item { PreferenceSectionHeader(title = stringResource(R.string.pref_section_appearance)) }
- item {
- PreferenceCard {
- Preference(
- title = { Text(stringResource(R.string.pref_appearance_title)) },
- summary = { Text(stringResource(R.string.pref_appearance_summary), color = MaterialTheme.colorScheme.outline) },
- icon = { Icon(Icons.Outlined.Palette, contentDescription = null, tint = MaterialTheme.colorScheme.primary) },
- onClick = { backstack.add(AppearancePreferencesScreen) },
- )
+ if (!enableExoPlayer) {
+ item { PreferenceSectionHeader(title = stringResource(R.string.pref_section_appearance)) }
+ item {
+ PreferenceCard {
+ Preference(
+ title = { Text(stringResource(R.string.pref_appearance_title)) },
+ summary = {
+ Text(
+ stringResource(R.string.pref_appearance_summary),
+ color = MaterialTheme.colorScheme.outline
+ )
+ },
+ icon = {
+ Icon(
+ Icons.Outlined.Palette,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary
+ )
+ },
+ onClick = { backstack.add(AppearancePreferencesScreen) },
+ )
+ }
}
}
// ── 2. Playback ───────────────────────────────────────────────────
- item { PreferenceSectionHeader(title = stringResource(R.string.pref_section_playback)) }
- item {
- PreferenceCard {
- Preference(
- title = { Text(stringResource(R.string.pref_player)) },
- summary = { Text(stringResource(R.string.pref_player_summary), color = MaterialTheme.colorScheme.outline) },
- icon = { Icon(Icons.Default.Slideshow, contentDescription = null, tint = MaterialTheme.colorScheme.primary) },
- onClick = { backstack.add(PlayerPreferencesScreen) },
- )
- PreferenceDivider()
- Preference(
- title = { Text(stringResource(R.string.pref_decoder)) },
- summary = { Text(stringResource(R.string.pref_decoder_summary), color = MaterialTheme.colorScheme.outline) },
- icon = { Icon(Icons.Default.DeveloperBoard, contentDescription = null, tint = MaterialTheme.colorScheme.primary) },
- onClick = { backstack.add(DecoderPreferencesScreen) },
- )
- PreferenceDivider()
- Preference(
- title = { Text(stringResource(R.string.pref_audio)) },
- summary = { Text(stringResource(R.string.pref_audio_summary), color = MaterialTheme.colorScheme.outline) },
- icon = { Icon(Icons.Outlined.Audiotrack, contentDescription = null, tint = MaterialTheme.colorScheme.primary) },
- onClick = { backstack.add(AudioPreferencesScreen) },
- )
+ if (!enableExoPlayer) {
+ item { PreferenceSectionHeader(title = stringResource(R.string.pref_section_playback)) }
+ item {
+ PreferenceCard {
+ Preference(
+ title = { Text(stringResource(R.string.pref_player)) },
+ summary = {
+ Text(
+ stringResource(R.string.pref_player_summary),
+ color = MaterialTheme.colorScheme.outline
+ )
+ },
+ icon = {
+ Icon(
+ Icons.Default.Slideshow,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary
+ )
+ },
+ onClick = { backstack.add(PlayerPreferencesScreen) },
+ )
+ PreferenceDivider()
+ Preference(
+ title = { Text(stringResource(R.string.pref_decoder)) },
+ summary = {
+ Text(
+ stringResource(R.string.pref_decoder_summary),
+ color = MaterialTheme.colorScheme.outline
+ )
+ },
+ icon = {
+ Icon(
+ Icons.Default.DeveloperBoard,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary
+ )
+ },
+ onClick = { backstack.add(DecoderPreferencesScreen) },
+ )
+ PreferenceDivider()
+ Preference(
+ title = { Text(stringResource(R.string.pref_audio)) },
+ summary = {
+ Text(
+ stringResource(R.string.pref_audio_summary),
+ color = MaterialTheme.colorScheme.outline
+ )
+ },
+ icon = {
+ Icon(
+ Icons.Outlined.Audiotrack,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary
+ )
+ },
+ onClick = { backstack.add(AudioPreferencesScreen) },
+ )
+ }
}
}
// ── 3. Gestures & Controls ────────────────────────────────────────
- item { PreferenceSectionHeader(title = stringResource(R.string.pref_section_gestures_controls)) }
- item {
- PreferenceCard {
- Preference(
- title = { Text(stringResource(R.string.pref_gesture)) },
- summary = { Text(stringResource(R.string.pref_gesture_summary), color = MaterialTheme.colorScheme.outline) },
- icon = { Icon(Icons.Outlined.Gesture, contentDescription = null, tint = MaterialTheme.colorScheme.primary) },
- onClick = { backstack.add(GesturePreferencesScreen) },
- )
- PreferenceDivider()
- Preference(
- title = { Text(stringResource(R.string.pref_layout_title)) },
- summary = { Text(stringResource(R.string.pref_layout_summary), color = MaterialTheme.colorScheme.outline) },
- icon = { Icon(Icons.Default.GridView, contentDescription = null, tint = MaterialTheme.colorScheme.primary) },
- onClick = { backstack.add(PlayerControlsPreferencesScreen) },
- )
+ if (!enableExoPlayer) {
+ item { PreferenceSectionHeader(title = stringResource(R.string.pref_section_gestures_controls)) }
+ item {
+ PreferenceCard {
+ Preference(
+ title = { Text(stringResource(R.string.pref_gesture)) },
+ summary = {
+ Text(
+ stringResource(R.string.pref_gesture_summary),
+ color = MaterialTheme.colorScheme.outline
+ )
+ },
+ icon = {
+ Icon(
+ Icons.Outlined.Gesture,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary
+ )
+ },
+ onClick = { backstack.add(GesturePreferencesScreen) },
+ )
+ PreferenceDivider()
+ Preference(
+ title = { Text(stringResource(R.string.pref_layout_title)) },
+ summary = {
+ Text(
+ stringResource(R.string.pref_layout_summary),
+ color = MaterialTheme.colorScheme.outline
+ )
+ },
+ icon = {
+ Icon(
+ Icons.Default.GridView,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary
+ )
+ },
+ onClick = { backstack.add(PlayerControlsPreferencesScreen) },
+ )
+ }
}
}
// ── 4. Subtitles ──────────────────────────────────────────────────
- item { PreferenceSectionHeader(title = stringResource(R.string.pref_section_subtitles)) }
- item {
- PreferenceCard {
- Preference(
- title = { Text(stringResource(R.string.pref_subtitles)) },
- summary = { Text(stringResource(R.string.pref_subtitles_summary), color = MaterialTheme.colorScheme.outline) },
- icon = { Icon(Icons.Outlined.Subtitles, contentDescription = null, tint = MaterialTheme.colorScheme.primary) },
- onClick = { backstack.add(SubtitlesPreferencesScreen) },
- )
+ if (!enableExoPlayer) {
+ item { PreferenceSectionHeader(title = stringResource(R.string.pref_section_subtitles)) }
+ item {
+ PreferenceCard {
+ Preference(
+ title = { Text(stringResource(R.string.pref_subtitles)) },
+ summary = {
+ Text(
+ stringResource(R.string.pref_subtitles_summary),
+ color = MaterialTheme.colorScheme.outline
+ )
+ },
+ icon = {
+ Icon(
+ Icons.Outlined.Subtitles,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary
+ )
+ },
+ onClick = { backstack.add(SubtitlesPreferencesScreen) },
+ )
+ }
}
}
// ── 5. Storage ────────────────────────────────────────────────────
- item { PreferenceSectionHeader(title = stringResource(R.string.pref_section_storage)) }
- item {
- PreferenceCard {
- Preference(
- title = { Text(stringResource(R.string.pref_folders_title)) },
- summary = { Text(stringResource(R.string.pref_section_storage_summary), color = MaterialTheme.colorScheme.outline) },
- icon = { Icon(Icons.Outlined.Folder, contentDescription = null, tint = MaterialTheme.colorScheme.primary) },
- onClick = { backstack.add(FoldersPreferencesScreen) },
- )
+ if (!enableExoPlayer) {
+ item { PreferenceSectionHeader(title = stringResource(R.string.pref_section_storage)) }
+ item {
+ PreferenceCard {
+ Preference(
+ title = { Text(stringResource(R.string.pref_folders_title)) },
+ summary = {
+ Text(
+ stringResource(R.string.pref_section_storage_summary),
+ color = MaterialTheme.colorScheme.outline
+ )
+ },
+ icon = {
+ Icon(
+ Icons.Outlined.Folder,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary
+ )
+ },
+ onClick = { backstack.add(FoldersPreferencesScreen) },
+ )
+ }
}
}
// ── 6. AI Integration ──────────────────────────────────────────────
- item { PreferenceSectionHeader(title = stringResource(R.string.pref_section_ai)) }
- item {
- PreferenceCard {
- Preference(
- title = { Text(stringResource(R.string.pref_section_ai_title)) },
- summary = { Text(stringResource(R.string.pref_section_ai_summary), color = MaterialTheme.colorScheme.outline) },
- icon = { Icon(Icons.Default.AutoAwesome, contentDescription = null, tint = MaterialTheme.colorScheme.primary) },
- onClick = { backstack.add(AiIntegrationScreen) },
- )
+ if (!enableExoPlayer) {
+ item { PreferenceSectionHeader(title = stringResource(R.string.pref_section_ai)) }
+ item {
+ PreferenceCard {
+ Preference(
+ title = { Text(stringResource(R.string.pref_section_ai_title)) },
+ summary = {
+ Text(
+ stringResource(R.string.pref_section_ai_summary),
+ color = MaterialTheme.colorScheme.outline
+ )
+ },
+ icon = {
+ Icon(
+ Icons.Default.AutoAwesome,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary
+ )
+ },
+ onClick = { backstack.add(AiIntegrationScreen) },
+ )
+ }
}
}
// ── 7. Advanced ───────────────────────────────────────────────────
- item { PreferenceSectionHeader(title = stringResource(R.string.pref_section_advanced)) }
+ if (!enableExoPlayer) {
+ item { PreferenceSectionHeader(title = stringResource(R.string.pref_section_advanced)) }
+ item {
+ PreferenceCard {
+ Preference(
+ title = { Text(stringResource(R.string.pref_advanced)) },
+ summary = {
+ Text(
+ stringResource(R.string.pref_advanced_summary),
+ color = MaterialTheme.colorScheme.outline
+ )
+ },
+ icon = {
+ Icon(
+ Icons.Alternatives.AdvancedSettings,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary
+ )
+ },
+ onClick = { backstack.add(AdvancedPreferencesScreen) },
+ )
+ }
+ }
+ }
+
+ // ── 8. ExoPlayer ──────────────────────────────────────────────────
+ item { PreferenceSectionHeader(title = "ExoPlayer") }
item {
PreferenceCard {
- Preference(
- title = { Text(stringResource(R.string.pref_advanced)) },
- summary = { Text(stringResource(R.string.pref_advanced_summary), color = MaterialTheme.colorScheme.outline) },
- icon = { Icon(Icons.Alternatives.AdvancedSettings, contentDescription = null, tint = MaterialTheme.colorScheme.primary) },
- onClick = { backstack.add(AdvancedPreferencesScreen) },
+ me.zhanghai.compose.preference.SwitchPreference(
+ value = enableExoPlayer,
+ onValueChange = { playerPreferences.enableExoPlayer.set(it) },
+ title = { Text("Enable ExoPlayer") },
+ summary = { Text("Use ExoPlayer as the video engine instead of mpv") },
+ icon = {
+ Icon(
+ Icons.Default.PlayCircle,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary
+ )
+ },
)
+
+ if (enableExoPlayer) {
+ // Ported settings from One-Player
+ Preference(
+ title = { Text("Media Library") },
+ summary = { Text("Ignore .nomedia, thumbnail generation", color = MaterialTheme.colorScheme.outline) },
+ icon = { Icon(Icons.Default.Movie, contentDescription = null, tint = MaterialTheme.colorScheme.primary) },
+ onClick = { backstack.add(ExoMediaLibraryPreferencesScreen) },
+ )
+ PreferenceDivider()
+ Preference(
+ title = { Text("Player") },
+ summary = { Text("Resume, speed, loop mode, orientation", color = MaterialTheme.colorScheme.outline) },
+ icon = { Icon(Icons.Default.PlayArrow, contentDescription = null, tint = MaterialTheme.colorScheme.primary) },
+ onClick = { backstack.add(ExoPlayerPreferencesScreen) },
+ )
+ PreferenceDivider()
+ Preference(
+ title = { Text("Gestures") },
+ summary = { Text("Double tap, seek, zoom, volume, brightness", color = MaterialTheme.colorScheme.outline) },
+ icon = { Icon(Icons.Outlined.Gesture, contentDescription = null, tint = MaterialTheme.colorScheme.primary) },
+ onClick = { backstack.add(ExoGesturePreferencesScreen) },
+ )
+ PreferenceDivider()
+ Preference(
+ title = { Text("Video Processing") },
+ summary = { Text("Decoder priority, video filters", color = MaterialTheme.colorScheme.outline) },
+ icon = { Icon(Icons.Default.DeveloperBoard, contentDescription = null, tint = MaterialTheme.colorScheme.primary) },
+ onClick = { backstack.add(ExoDecoderPreferencesScreen) },
+ )
+ PreferenceDivider()
+ Preference(
+ title = { Text("Audio") },
+ summary = { Text("Preferred languages, focus, volume memory", color = MaterialTheme.colorScheme.outline) },
+ icon = { Icon(Icons.Default.Audiotrack, contentDescription = null, tint = MaterialTheme.colorScheme.primary) },
+ onClick = { backstack.add(ExoAudioPreferencesScreen) },
+ )
+ PreferenceDivider()
+ Preference(
+ title = { Text("Subtitle") },
+ summary = { Text("Font, auto-load, encoding, appearance", color = MaterialTheme.colorScheme.outline) },
+ icon = { Icon(Icons.Default.Subtitles, contentDescription = null, tint = MaterialTheme.colorScheme.primary) },
+ onClick = { backstack.add(ExoSubtitlePreferencesScreen) },
+ )
+ PreferenceDivider()
+ Preference(
+ title = { Text("General") },
+ summary = { Text("Cache, backup, restore", color = MaterialTheme.colorScheme.outline) },
+ icon = { Icon(Icons.Outlined.Settings, contentDescription = null, tint = MaterialTheme.colorScheme.primary) },
+ onClick = { backstack.add(ExoGeneralPreferencesScreen) },
+ )
+ }
}
}
- // ── 7. About ──────────────────────────────────────────────────────
+ // ── 9. About ──────────────────────────────────────────────────────
item { PreferenceSectionHeader(title = stringResource(R.string.pref_section_about)) }
item {
PreferenceCard {
diff --git a/app/src/main/java/app/gyrolet/mpvrx/utils/media/MediaInfoOps.kt b/app/src/main/java/app/gyrolet/mpvrx/utils/media/MediaInfoOps.kt
index 721ac6515..a7ddc7ef7 100644
--- a/app/src/main/java/app/gyrolet/mpvrx/utils/media/MediaInfoOps.kt
+++ b/app/src/main/java/app/gyrolet/mpvrx/utils/media/MediaInfoOps.kt
@@ -8,6 +8,14 @@ import kotlinx.coroutines.withContext
import net.mediaarea.mediainfo.lib.MediaInfo
object MediaInfoOps {
+ init {
+ try {
+ Class.forName("net.mediaarea.mediainfo.lib.MediaInfo")
+ android.util.Log.d("MediaInfoOps", "MediaInfo library found")
+ } catch (e: Throwable) {
+ android.util.Log.e("MediaInfoOps", "MediaInfo library NOT found or failed to load!", e)
+ }
+ }
/**
* Extract detailed media information from a video file
*/
@@ -420,6 +428,7 @@ object MediaInfoOps {
retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE)
?.toFloatOrNull() ?: 0f,
hasEmbeddedSubtitles = false,
+ subtitleCodec = "",
)
} finally {
retriever.release()
@@ -462,4 +471,15 @@ object MediaInfoOps {
}
}.getOrDefault(0)
}
+
+ /**
+ * Formats duration in milliseconds to a human-readable string (HH:MM:SS.mmm)
+ */
+ fun formatDuration(durationMs: Long): String {
+ val hours = durationMs / 3600000
+ val minutes = (durationMs % 3600000) / 60000
+ val seconds = (durationMs % 60000) / 1000
+ val millis = durationMs % 1000
+ return "%02d:%02d:%02d.%03d".format(hours, minutes, seconds, millis)
+ }
}
diff --git a/app/src/main/java/app/gyrolet/mpvrx/utils/media/MediaUtils.kt b/app/src/main/java/app/gyrolet/mpvrx/utils/media/MediaUtils.kt
index d11c1db89..492a68a80 100644
--- a/app/src/main/java/app/gyrolet/mpvrx/utils/media/MediaUtils.kt
+++ b/app/src/main/java/app/gyrolet/mpvrx/utils/media/MediaUtils.kt
@@ -6,7 +6,10 @@ import android.net.Uri
import androidx.core.content.FileProvider
import androidx.core.net.toUri
import app.gyrolet.mpvrx.domain.media.model.Video
+import app.gyrolet.mpvrx.exoplayer.ExoPlayerActivity
import app.gyrolet.mpvrx.ui.player.PlayerActivity
+import app.gyrolet.mpvrx.preferences.PlayerPreferences
+import org.koin.java.KoinJavaComponent.inject
import app.gyrolet.mpvrx.ui.player.PlayerLookupHints
import app.gyrolet.mpvrx.utils.history.RecentlyPlayedOps
import `is`.xyz.mpv.Utils
@@ -42,6 +45,15 @@ data class PlaybackSubtitleTrack(
* bypassing MediaUtils.
*/
object MediaUtils {
+ private val playerPreferences: PlayerPreferences by inject(PlayerPreferences::class.java)
+
+ private fun getPlayerActivityClass(): Class<*> {
+ return if (playerPreferences.enableExoPlayer.get()) {
+ ExoPlayerActivity::class.java
+ } else {
+ PlayerActivity::class.java
+ }
+ }
/**
* Play video content from any source.
*
@@ -68,7 +80,7 @@ object MediaUtils {
when (source) {
is Video -> {
val intent = Intent(Intent.ACTION_VIEW, source.uri)
- intent.setClass(context, PlayerActivity::class.java)
+ intent.setClass(context, getPlayerActivityClass())
intent.putExtra("internal_launch", true) // Enables subtitle autoload
applyPlaybackExtras(
intent = intent,
@@ -113,7 +125,7 @@ object MediaUtils {
}
val intent = Intent(Intent.ACTION_VIEW, uri)
- intent.setClass(context, PlayerActivity::class.java)
+ intent.setClass(context, getPlayerActivityClass())
intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
applyPlaybackExtras(
diff --git a/app/src/main/kotlin/app/gyrolet/mpvrx/exoplayer/ExoPlayerActivity.kt b/app/src/main/kotlin/app/gyrolet/mpvrx/exoplayer/ExoPlayerActivity.kt
new file mode 100644
index 000000000..7c468806c
--- /dev/null
+++ b/app/src/main/kotlin/app/gyrolet/mpvrx/exoplayer/ExoPlayerActivity.kt
@@ -0,0 +1,892 @@
+package app.gyrolet.mpvrx.exoplayer
+
+import android.annotation.SuppressLint
+import android.content.ComponentName
+import android.content.Intent
+import android.content.pm.ActivityInfo
+import android.graphics.Color
+import android.net.Uri
+import android.os.Build
+import android.os.Bundle
+import android.os.Handler
+import android.provider.MediaStore
+import android.view.KeyEvent
+import android.view.PixelCopy
+import android.view.SurfaceView
+import android.view.View
+import android.view.WindowManager
+import android.widget.Toast
+import androidx.activity.SystemBarStyle
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.activity.result.contract.ActivityResultContracts.RequestPermission
+import androidx.appcompat.app.AppCompatActivity
+import androidx.compose.runtime.getValue
+import org.koin.androidx.viewmodel.ext.android.viewModel
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.ExperimentalComposeUiApi
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.semantics.testTagsAsResourceId
+import androidx.core.content.ContextCompat
+import androidx.core.content.IntentCompat
+import androidx.core.graphics.createBitmap
+import androidx.core.util.Consumer
+import androidx.lifecycle.compose.LifecycleStartEffect
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.media3.common.C
+import androidx.media3.common.MediaItem
+import androidx.media3.common.MediaMetadata
+import androidx.media3.common.Player
+import androidx.media3.session.MediaController
+import androidx.media3.session.SessionToken
+import com.google.common.util.concurrent.ListenableFuture
+import java.io.File
+import java.io.IOException
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+import java.util.concurrent.CopyOnWriteArrayList
+import kotlin.coroutines.resume
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.guava.await
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlinx.coroutines.withContext
+import app.gyrolet.mpvrx.exoplayer.core.common.Logger
+import app.gyrolet.mpvrx.exoplayer.core.common.extensions.applyNavigationBarStyle
+import app.gyrolet.mpvrx.exoplayer.core.common.extensions.applyPrivacyProtection
+import app.gyrolet.mpvrx.exoplayer.core.common.extensions.canonicalPathOrSelf
+import app.gyrolet.mpvrx.exoplayer.core.common.extensions.getFilenameFromUri
+import app.gyrolet.mpvrx.exoplayer.core.common.extensions.getMediaContentUri
+import app.gyrolet.mpvrx.exoplayer.core.common.extensions.isSubtitleExtension
+import app.gyrolet.mpvrx.exoplayer.core.common.extensions.resolvePrivacyPreviewScrim
+import app.gyrolet.mpvrx.exoplayer.core.common.extensions.scanFileForContentUri
+import app.gyrolet.mpvrx.exoplayer.core.model.ScreenOrientation
+import app.gyrolet.mpvrx.exoplayer.core.model.Video
+import app.gyrolet.mpvrx.ui.theme.MpvrxTheme
+import app.gyrolet.mpvrx.exoplayer.feature.player.extensions.OpenDocumentWithInitialUri
+import app.gyrolet.mpvrx.exoplayer.feature.player.extensions.copy
+import app.gyrolet.mpvrx.exoplayer.feature.player.extensions.registerForSuspendActivityResult
+import app.gyrolet.mpvrx.exoplayer.feature.player.extensions.setMetadataExtras
+import app.gyrolet.mpvrx.exoplayer.core.data.repository.OnlineSubtitleRepository
+import app.gyrolet.mpvrx.exoplayer.core.data.repository.InvalidOnlineSubtitleSchemeException
+import app.gyrolet.mpvrx.exoplayer.core.data.repository.InvalidOnlineSubtitleUrlException
+import app.gyrolet.mpvrx.exoplayer.core.data.repository.InvalidOnlineSubtitleExtensionException
+import app.gyrolet.mpvrx.exoplayer.core.data.repository.OnlineSubtitleTooLargeException
+import app.gyrolet.mpvrx.exoplayer.core.data.repository.EmptyOnlineSubtitleException
+import app.gyrolet.mpvrx.exoplayer.core.data.repository.OnlineSubtitleDownloadFailedException
+import app.gyrolet.mpvrx.exoplayer.feature.player.extensions.toActivityOrientation
+import app.gyrolet.mpvrx.exoplayer.feature.player.extensions.uriToSubtitleConfiguration
+import app.gyrolet.mpvrx.exoplayer.feature.player.service.addSubtitleTrack
+import app.gyrolet.mpvrx.exoplayer.feature.player.service.stopPlayerSession
+import app.gyrolet.mpvrx.exoplayer.feature.player.MediaPlayerScreen
+import app.gyrolet.mpvrx.exoplayer.feature.player.PlayerViewModel
+import app.gyrolet.mpvrx.R
+import org.koin.android.ext.android.inject
+
+internal data class PlaybackPlaylist(
+ val items: List,
+ val currentIndex: Int,
+)
+
+internal data class PlaybackTarget(
+ val sourceUriString: String,
+ val playbackUriString: String,
+ val currentPath: String?,
+)
+
+internal fun buildPlaybackPlaylistFromItems(
+ playlistItems: List,
+ playbackTarget: PlaybackTarget,
+): PlaybackPlaylist {
+ if (playlistItems.isEmpty()) {
+ return PlaybackPlaylist(
+ items = listOf(playbackTarget.playbackUriString),
+ currentIndex = 0,
+ )
+ }
+
+ val currentIndex = playlistItems.indexOfFirst { uriString ->
+ uriString == playbackTarget.playbackUriString ||
+ uriString == playbackTarget.sourceUriString
+ }
+ if (currentIndex >= 0) {
+ return PlaybackPlaylist(
+ items = playlistItems,
+ currentIndex = currentIndex,
+ )
+ }
+
+ return PlaybackPlaylist(
+ items = listOf(playbackTarget.playbackUriString) + playlistItems,
+ currentIndex = 0,
+ )
+}
+
+internal fun buildPlaybackPlaylist(
+ playlistVideos: List