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