diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f45185a66..84102ac3c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -17,7 +17,7 @@ android { applicationId = "app.marlboroadvance.mpvex" minSdk = 26 targetSdk = 36 - versionCode = 128 + versionCode = 129 versionName = "1.2.9" vectorDrawables { @@ -237,6 +237,17 @@ dependencies { implementation(libs.nanohttpd) implementation(libs.lazycolumnscrollbar) implementation(libs.reorderable) + + // LIQUID GLASS DEPENDENCIES + // AndroidLiquidGlass 2.0.0-alpha03 (Contains the advanced lens effects) + implementation("io.github.kyant0:backdrop:2.0.0-alpha03") + + // SHAPES LIBRARY 1.2.0 (MINIMUM REQUIRED for G2 continuous curves) + implementation("io.github.kyant0:shapes:1.2.0") + + // DataStore for preferences persistence (To toggle the UI on/off) + implementation("androidx.datastore:datastore-preferences:1.0.0") + implementation("androidx.graphics:graphics-core:1.0.4") } /* ---------------- Git helpers ---------------- */ diff --git a/app/src/main/java/app/marlboroadvance/mpvex/MainActivity.kt b/app/src/main/java/app/marlboroadvance/mpvex/MainActivity.kt index de64dbab9..3f2516ec5 100644 --- a/app/src/main/java/app/marlboroadvance/mpvex/MainActivity.kt +++ b/app/src/main/java/app/marlboroadvance/mpvex/MainActivity.kt @@ -22,6 +22,7 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.toArgb @@ -50,6 +51,11 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.withContext import org.koin.android.ext.android.inject +// --- NEW LIQUID IMPORTS --- +import com.kyant.backdrop.backdrops.layerBackdrop +import com.kyant.backdrop.backdrops.rememberLayerBackdrop +import app.marlboroadvance.mpvex.ui.components.liquid.LocalLiquidBackdrop + /** * Main entry point for the application */ @@ -57,10 +63,8 @@ class MainActivity : ComponentActivity() { private val appearancePreferences by inject() private val networkRepository by inject() - // Create a coroutine scope tied to the activity lifecycle private val activityScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) - // Register the ActivityResultLauncher at class level private val mediaAccessLauncher = registerForActivityResult( ActivityResultContracts.StartIntentSenderForResult() ) { result -> @@ -72,11 +76,9 @@ class MainActivity : ComponentActivity() { PermissionUtils.setMediaAccessLauncher(mediaAccessLauncher) - // Register proxy lifecycle observer for network streaming lifecycle.addObserver(app.marlboroadvance.mpvex.ui.browser.networkstreaming.proxy.ProxyLifecycleObserver()) setContent { - // Set up theme and edge-to-edge display val dark by appearancePreferences.darkMode.collectAsState() val isSystemInDarkTheme = isSystemInDarkTheme() val isDarkMode = dark == DarkMode.Dark || (dark == DarkMode.System && isSystemInDarkTheme) @@ -87,14 +89,20 @@ class MainActivity : ComponentActivity() { ) { isDarkMode }, ) - // Auto-connect to saved network connections LaunchedEffect(Unit) { autoConnectToNetworks() } MpvexTheme { + // Correct initialization of the backdrop engine + val backdrop = rememberLayerBackdrop() + Surface { - Navigator() + CompositionLocalProvider( + LocalLiquidBackdrop provides backdrop + ) { + Navigator() + } } } } @@ -108,14 +116,9 @@ class MainActivity : ComponentActivity() { } } - /** - * Auto-connect to network connections that are marked for auto-connection - */ private suspend fun autoConnectToNetworks() { - // Delay auto-connect to let UI settle first kotlinx.coroutines.delay(500) - // Use coroutineScope for properly structured concurrency withContext(Dispatchers.IO) { try { val autoConnectConnections = networkRepository.getAutoConnectConnections() @@ -137,15 +140,12 @@ class MainActivity : ComponentActivity() { } } catch (e: Exception) { withContext(Dispatchers.Main) { - Log.e("MainActivity", "Error during auto-connect", e) + Log.e("MainActivity", "Error during auto-connect", e) } } } } - /** - * Navigator that handles screen transitions and provides shared states - */ @Composable fun Navigator() { val backstack = rememberNavBackStack(MainScreen) @@ -156,7 +156,6 @@ class MainActivity : ComponentActivity() { val context = LocalContext.current val currentVersion = BuildConfig.VERSION_NAME.replace("-dev", "") - // Conditionally initialize update feature based on build config val updateViewModel: UpdateViewModel? = if (BuildConfig.ENABLE_UPDATE_FEATURE) { viewModel(context as ComponentActivity) } else { @@ -166,7 +165,6 @@ class MainActivity : ComponentActivity() { val isDownloading by (updateViewModel?.isDownloading ?: MutableStateFlow(false)).collectAsState() val downloadProgress by (updateViewModel?.downloadProgress ?: MutableStateFlow(0f)).collectAsState() - // Provide both LocalBackStack and the LazyList/Grid states to all screens CompositionLocalProvider( LocalBackStack provides typedBackstack ) { @@ -186,35 +184,34 @@ class MainActivity : ComponentActivity() { transitionSpec = { ( fadeIn(animationSpec = tween(220)) + - slideIn(animationSpec = tween(220)) { IntOffset(it.width / 2, 0) } + slideIn(animationSpec = tween(220)) { IntOffset(it.width / 2, 0) } ) togetherWith ( fadeOut(animationSpec = tween(220)) + slideOut(animationSpec = tween(220)) { IntOffset(-it.width / 2, 0) } ) }, predictivePopTransitionSpec = { - ( + ( fadeIn(animationSpec = tween(220)) + scaleIn( animationSpec = tween(220, delayMillis = 30), initialScale = .9f, TransformOrigin(-1f, .5f), - ) + ) ) togetherWith ( fadeOut(animationSpec = tween(220)) + scaleOut( animationSpec = tween(220, delayMillis = 30), targetScale = .9f, - TransformOrigin(-1f, .5f), + TransformOrigin(-1f, .5f), ) ) }, ) - // Display Update Dialog when appropriate (only if update feature is enabled) if (BuildConfig.ENABLE_UPDATE_FEATURE && updateViewModel != null) { when (updateState) { - is UpdateViewModel.UpdateState.Available -> { + is UpdateViewModel.UpdateState.Available -> { val release = (updateState as UpdateViewModel.UpdateState.Available).release UpdateDialog( release = release, @@ -227,7 +224,7 @@ class MainActivity : ComponentActivity() { onIgnore = { updateViewModel.ignoreVersion(release.tagName.removePrefix("v")) } ) } - is UpdateViewModel.UpdateState.ReadyToInstall -> { + is UpdateViewModel.UpdateState.ReadyToInstall -> { val release = (updateState as UpdateViewModel.UpdateState.ReadyToInstall).release UpdateDialog( release = release, @@ -240,7 +237,7 @@ class MainActivity : ComponentActivity() { onIgnore = { updateViewModel.ignoreVersion(release.tagName.removePrefix("v")) } ) } - else -> {} + else -> {} } } } diff --git a/app/src/main/java/app/marlboroadvance/mpvex/preferences/LiquidUIPreferences.kt b/app/src/main/java/app/marlboroadvance/mpvex/preferences/LiquidUIPreferences.kt new file mode 100644 index 000000000..f075dc372 --- /dev/null +++ b/app/src/main/java/app/marlboroadvance/mpvex/preferences/LiquidUIPreferences.kt @@ -0,0 +1,78 @@ +package app.marlboroadvance.mpvex.preferences + +import android.content.Context +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.floatPreferencesKey +import androidx.datastore.preferences.core.longPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +private val Context.liquidUIDataStore by preferencesDataStore(name = "liquid_ui_prefs") + +// --- THE TARGET ENUM --- +// Adding CARD here automatically generates the entire UI tab in AppearancePreferencesScreen! +enum class LiquidTarget(val id: String, val title: String) { + NAV("nav", "Navigation"), + BUTTON("btn", "Buttons"), + DIALOG("dlg", "Dialogs"), + CARD("card", "Browser Cards") +} + +class LiquidUIPreferences(context: Context) { + private val dataStore = context.liquidUIDataStore + + companion object { + val LIQUID_UI_ENABLED = booleanPreferencesKey("liquid_ui_enabled") + val LIQUID_TOGGLE_COLOR = longPreferencesKey("liquid_toggle_color") + val LIQUID_SLIDER_COLOR = longPreferencesKey("liquid_slider_color") + + // Legacy Keys to prevent crashes in other screens + val LIQUID_BLUR_ENABLED = booleanPreferencesKey("liquid_blur_enabled") + val LIQUID_LENS_ENABLED = booleanPreferencesKey("liquid_lens_enabled") + val LIQUID_VIBRANCY_ENABLED_LEGACY = booleanPreferencesKey("liquid_vibrancy_enabled_legacy") + } + + // Master Toggles + val liquidUIEnabledFlow: Flow = dataStore.data.map { it[LIQUID_UI_ENABLED] ?: false } + val liquidToggleColorFlow: Flow = dataStore.data.map { it[LIQUID_TOGGLE_COLOR] ?: 0xFF4CAF50 } + val liquidSliderColorFlow: Flow = dataStore.data.map { it[LIQUID_SLIDER_COLOR] ?: 0xFF2196F3 } + + suspend fun setLiquidUIEnabled(enabled: Boolean) { dataStore.edit { it[LIQUID_UI_ENABLED] = enabled } } + suspend fun setToggleColor(color: Long) { dataStore.edit { it[LIQUID_TOGGLE_COLOR] = color } } + suspend fun setSliderColor(color: Long) { dataStore.edit { it[LIQUID_SLIDER_COLOR] = color } } + + // --- DYNAMIC TARGET FLOWS --- + fun blurRadiusFlow(target: LiquidTarget): Flow = dataStore.data.map { it[floatPreferencesKey("${target.id}_blur")] ?: 0f } + fun refractionHeightFlow(target: LiquidTarget): Flow = dataStore.data.map { it[floatPreferencesKey("${target.id}_height")] ?: 40f } + fun refractionAmountFlow(target: LiquidTarget): Flow = dataStore.data.map { it[floatPreferencesKey("${target.id}_amount")] ?: 23f } + // CHANGED: default raised 0.15f → 0.5f. Old value let backdrop text bleed through navigation/dialog + // glass; 0.5f is the Backdrop docs' recommended balance of "glass look" vs. text readability. + fun tintAlphaFlow(target: LiquidTarget): Flow = dataStore.data.map { it[floatPreferencesKey("${target.id}_alpha")] ?: 0.5f } + + fun chromaticAberrationFlow(target: LiquidTarget): Flow = dataStore.data.map { it[booleanPreferencesKey("${target.id}_chromatic")] ?: false } + fun depthEffectFlow(target: LiquidTarget): Flow = dataStore.data.map { it[booleanPreferencesKey("${target.id}_depth")] ?: true } + fun vibrancyEnabledFlow(target: LiquidTarget): Flow = dataStore.data.map { it[booleanPreferencesKey("${target.id}_vibrancy")] ?: true } + + // --- DYNAMIC TARGET SETTERS --- + suspend fun setBlurRadius(target: LiquidTarget, value: Float) { dataStore.edit { it[floatPreferencesKey("${target.id}_blur")] = value } } + suspend fun setRefractionHeight(target: LiquidTarget, value: Float) { dataStore.edit { it[floatPreferencesKey("${target.id}_height")] = value } } + suspend fun setRefractionAmount(target: LiquidTarget, value: Float) { dataStore.edit { it[floatPreferencesKey("${target.id}_amount")] = value } } + suspend fun setTintAlpha(target: LiquidTarget, value: Float) { dataStore.edit { it[floatPreferencesKey("${target.id}_alpha")] = value } } + + suspend fun setChromaticAberration(target: LiquidTarget, enabled: Boolean) { dataStore.edit { it[booleanPreferencesKey("${target.id}_chromatic")] = enabled } } + suspend fun setDepthEffect(target: LiquidTarget, enabled: Boolean) { dataStore.edit { it[booleanPreferencesKey("${target.id}_depth")] = enabled } } + suspend fun setVibrancyEnabled(target: LiquidTarget, enabled: Boolean) { dataStore.edit { it[booleanPreferencesKey("${target.id}_vibrancy")] = enabled } } + + // --- LEGACY FALLBACKS (Keeps LiquidUISettingsScreen.kt from crashing!) --- + val liquidBlurEnabledFlow: Flow = dataStore.data.map { it[LIQUID_BLUR_ENABLED] ?: true } + val liquidLensEnabledFlow: Flow = dataStore.data.map { it[LIQUID_LENS_ENABLED] ?: true } + val liquidVibrancyEnabledFlow: Flow = dataStore.data.map { it[LIQUID_VIBRANCY_ENABLED_LEGACY] ?: true } + + suspend fun setBlurEnabled(enabled: Boolean) { dataStore.edit { it[LIQUID_BLUR_ENABLED] = enabled } } + suspend fun setLensEnabled(enabled: Boolean) { dataStore.edit { it[LIQUID_LENS_ENABLED] = enabled } } + + // This perfectly satisfies the old screen while leaving the new engine untouched! + suspend fun setVibrancyEnabled(enabled: Boolean) { dataStore.edit { it[LIQUID_VIBRANCY_ENABLED_LEGACY] = enabled } } +} diff --git a/app/src/main/java/app/marlboroadvance/mpvex/preferences/PlayerButton.kt b/app/src/main/java/app/marlboroadvance/mpvex/preferences/PlayerButton.kt index a0cd1b378..600f590e3 100644 --- a/app/src/main/java/app/marlboroadvance/mpvex/preferences/PlayerButton.kt +++ b/app/src/main/java/app/marlboroadvance/mpvex/preferences/PlayerButton.kt @@ -2,6 +2,7 @@ package app.marlboroadvance.mpvex.preferences import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.automirrored.outlined.Segment import androidx.compose.material.icons.outlined.AspectRatio import androidx.compose.material.icons.outlined.Audiotrack import androidx.compose.material.icons.outlined.Bookmarks @@ -16,6 +17,7 @@ import androidx.compose.material.icons.outlined.Subtitles import androidx.compose.material.icons.outlined.Title import androidx.compose.material.icons.outlined.Flip import androidx.compose.material.icons.outlined.Repeat +import androidx.compose.material.icons.outlined.Segment import androidx.compose.material.icons.outlined.ZoomIn import androidx.compose.material.icons.outlined.FastForward import androidx.compose.material.icons.outlined.Shuffle @@ -52,7 +54,7 @@ enum class PlayerButton( SHUFFLE(Icons.Outlined.Shuffle), MIRROR(Icons.Outlined.Flip), VERTICAL_FLIP(Icons.Outlined.Flip), - AB_LOOP(Icons.Outlined.Repeat), + AB_LOOP(Icons.AutoMirrored.Outlined.Segment), CUSTOM_SKIP(Icons.Outlined.FastForward), BACKGROUND_PLAYBACK(Icons.Outlined.Headset), AMBIENT_MODE(Icons.Outlined.BlurOn), diff --git a/app/src/main/java/app/marlboroadvance/mpvex/preferences/PlayerPreferences.kt b/app/src/main/java/app/marlboroadvance/mpvex/preferences/PlayerPreferences.kt index 6e2fcdc76..cebd75ca0 100644 --- a/app/src/main/java/app/marlboroadvance/mpvex/preferences/PlayerPreferences.kt +++ b/app/src/main/java/app/marlboroadvance/mpvex/preferences/PlayerPreferences.kt @@ -74,15 +74,6 @@ class PlayerPreferences( val customButtons = preferenceStore.getString("custom_buttons_json", "[]") // Ambience Mode - val ambientBlurSamples = preferenceStore.getInt("ambient_blur_samples", 24) - val ambientMaxRadius = preferenceStore.getFloat("ambient_max_radius", 0.18f) - val ambientGlowIntensity = preferenceStore.getFloat("ambient_glow_intensity", 1.4f) - val ambientSatBoost = preferenceStore.getFloat("ambient_sat_boost", 1.2f) - val ambientDitherNoise = preferenceStore.getFloat("ambient_dither_noise", 0.0f) - val ambientBezelDepth = preferenceStore.getFloat("ambient_bezel_depth", 0.0f) - val ambientVignetteStrength = preferenceStore.getFloat("ambient_vignette_strength", 0.5f) - val ambientWarmth = preferenceStore.getFloat("ambient_warmth", 0.0f) - val ambientEdgeSmooth = preferenceStore.getFloat("ambient_edge_smooth", 0.02f) - val ambientFadeCurve = preferenceStore.getFloat("ambient_fade_curve", 1.5f) - val ambientOpacity = preferenceStore.getFloat("ambient_opacity", 1.0f) + val isAmbientEnabled = preferenceStore.getBoolean("ambient_enabled", false) + } diff --git a/app/src/main/java/app/marlboroadvance/mpvex/preferences/SeekbarStyle.kt b/app/src/main/java/app/marlboroadvance/mpvex/preferences/SeekbarStyle.kt index 2635509cd..137c98c68 100644 --- a/app/src/main/java/app/marlboroadvance/mpvex/preferences/SeekbarStyle.kt +++ b/app/src/main/java/app/marlboroadvance/mpvex/preferences/SeekbarStyle.kt @@ -4,4 +4,5 @@ enum class SeekbarStyle { Standard, Wavy, Thick, + Liquid, } diff --git a/app/src/main/java/app/marlboroadvance/mpvex/presentation/components/PlayerSheet.kt b/app/src/main/java/app/marlboroadvance/mpvex/presentation/components/PlayerSheet.kt index 93ebc9a19..997380843 100644 --- a/app/src/main/java/app/marlboroadvance/mpvex/presentation/components/PlayerSheet.kt +++ b/app/src/main/java/app/marlboroadvance/mpvex/presentation/components/PlayerSheet.kt @@ -60,6 +60,11 @@ import kotlinx.coroutines.flow.filter import kotlinx.coroutines.launch import kotlin.math.roundToInt +// --- NEW LIQUID IMPORTS --- +import app.marlboroadvance.mpvex.ui.components.liquid.LocalLiquidBackdrop +import app.marlboroadvance.mpvex.ui.components.liquid.LiquidGlassSurface +import app.marlboroadvance.mpvex.preferences.LiquidTarget + private val sheetAnimationSpec = tween(350) @SuppressLint("ConfigurationScreenWidthHeight") @@ -135,45 +140,60 @@ fun PlayerSheet( }, contentAlignment = Alignment.BottomCenter, ) { - Surface( - modifier = - Modifier - .sizeIn(maxWidth = maxWidth, maxHeight = maxHeight) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - onClick = {}, - ).nestedScroll( - remember(anchoredDraggableState) { - anchoredDraggableState.preUpPostDownNestedScrollConnection() - }, - ).then(modifier) - .offset { - IntOffset( - 0, - anchoredDraggableState.offset - .takeIf { it.isFinite() } - ?.roundToInt() - ?: 0, - ) - }.anchoredDraggable( - state = anchoredDraggableState, - orientation = Orientation.Vertical, - ).windowInsetsPadding( - WindowInsets.systemBars - .only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal), - ).imePadding(), - shape = MaterialTheme.shapes.extraLarge.copy(bottomEnd = ZeroCornerSize, bottomStart = ZeroCornerSize), - color = surfaceColor ?: MaterialTheme.colorScheme.surface, - tonalElevation = tonalElevation, - content = { - BackHandler( - enabled = anchoredDraggableState.targetValue == 0, - onBack = internalOnDismissRequest, + val backdrop = LocalLiquidBackdrop.current + val sheetModifier = Modifier + .sizeIn(maxWidth = maxWidth, maxHeight = maxHeight) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = {}, + ).nestedScroll( + remember(anchoredDraggableState) { + anchoredDraggableState.preUpPostDownNestedScrollConnection() + }, + ).then(modifier) + .offset { + IntOffset( + 0, + anchoredDraggableState.offset + .takeIf { it.isFinite() } + ?.roundToInt() + ?: 0, ) - content() - }, - ) + }.anchoredDraggable( + state = anchoredDraggableState, + orientation = Orientation.Vertical, + ).windowInsetsPadding( + WindowInsets.systemBars + .only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal), + ).imePadding() + + val sheetContent: @Composable () -> Unit = { + BackHandler( + enabled = anchoredDraggableState.targetValue == 0, + onBack = internalOnDismissRequest, + ) + content() + } + + if (backdrop != null) { + LiquidGlassSurface( + backdrop = backdrop, + target = LiquidTarget.DIALOG, + shape = MaterialTheme.shapes.extraLarge.copy(bottomEnd = ZeroCornerSize, bottomStart = ZeroCornerSize), + modifier = sheetModifier + ) { + sheetContent() + } + } else { + Surface( + modifier = sheetModifier, + shape = MaterialTheme.shapes.extraLarge.copy(bottomEnd = ZeroCornerSize, bottomStart = ZeroCornerSize), + color = surfaceColor ?: MaterialTheme.colorScheme.surface, + tonalElevation = tonalElevation, + content = sheetContent, + ) + } LaunchedEffect(true) { backgroundAlpha = 0.5f diff --git a/app/src/main/java/app/marlboroadvance/mpvex/ui/browser/MainScreen.kt b/app/src/main/java/app/marlboroadvance/mpvex/ui/browser/MainScreen.kt index d4506ecba..26e21dc85 100644 --- a/app/src/main/java/app/marlboroadvance/mpvex/ui/browser/MainScreen.kt +++ b/app/src/main/java/app/marlboroadvance/mpvex/ui/browser/MainScreen.kt @@ -12,8 +12,14 @@ import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.slideOutVertically import androidx.compose.animation.togetherWith +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.PlaylistPlay @@ -21,6 +27,8 @@ import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.Language import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.Scaffold @@ -28,12 +36,14 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext @@ -48,240 +58,154 @@ import app.marlboroadvance.mpvex.ui.browser.selection.SelectionManager import kotlinx.coroutines.delay import kotlinx.serialization.Serializable +// --- LIQUID GLASS IMPORTS --- +import com.kyant.backdrop.backdrops.layerBackdrop +import com.kyant.backdrop.backdrops.rememberLayerBackdrop +import app.marlboroadvance.mpvex.ui.components.liquid.LiquidGlassSurface +import app.marlboroadvance.mpvex.preferences.LiquidUIPreferences +// ---------------------------- + @Serializable object MainScreen : Screen { - // Use a companion object to store state more persistently private var persistentSelectedTab: Int = 0 - // Shared state that can be updated by FileSystemBrowserScreen - @Volatile - private var isInSelectionModeShared: Boolean = false // Controls FAB visibility - - @Volatile - private var shouldHideNavigationBar: Boolean = false // Controls navigation bar visibility - - @Volatile - private var isBrowserBottomBarVisible: Boolean = false // Tracks browser bottom bar visibility - - @Volatile - private var sharedVideoSelectionManager: Any? = null - - // Check if the selection contains only videos and update navigation bar visibility accordingly - @Volatile - private var onlyVideosSelected: Boolean = false - - // Track when permission denied screen is showing to hide FAB - @Volatile - private var isPermissionDenied: Boolean = false + @Volatile private var isInSelectionModeShared: Boolean = false + @Volatile private var shouldHideNavigationBar: Boolean = false + @Volatile private var isBrowserBottomBarVisible: Boolean = false + @Volatile private var sharedVideoSelectionManager: Any? = null + @Volatile private var onlyVideosSelected: Boolean = false + @Volatile private var isPermissionDenied: Boolean = false - /** - * Update selection state and navigation bar visibility - * This method should be called whenever selection changes - */ - fun updateSelectionState( - isInSelectionMode: Boolean, - isOnlyVideosSelected: Boolean, - selectionManager: Any? - ) { + fun updateSelectionState(isInSelectionMode: Boolean, isOnlyVideosSelected: Boolean, selectionManager: Any?) { this.isInSelectionModeShared = isInSelectionMode this.onlyVideosSelected = isOnlyVideosSelected this.sharedVideoSelectionManager = selectionManager - - // Only hide navigation bar when videos are selected AND in selection mode - // This fixes the issue where bottom bar disappears when only videos are selected this.shouldHideNavigationBar = isInSelectionMode && isOnlyVideosSelected } - /** - * Update permission state to control FAB visibility - */ - fun updatePermissionState(isDenied: Boolean) { - this.isPermissionDenied = isDenied - } - - /** - * Get current permission denied state - */ + fun updatePermissionState(isDenied: Boolean) { this.isPermissionDenied = isDenied } fun getPermissionDeniedState(): Boolean = isPermissionDenied - - /** - * Update bottom navigation bar visibility based on floating bottom bar state - */ - fun updateBottomBarVisibility(shouldShow: Boolean) { - // Hide bottom navigation when floating bottom bar is visible - this.shouldHideNavigationBar = !shouldShow - } + fun updateBottomBarVisibility(shouldShow: Boolean) { this.shouldHideNavigationBar = !shouldShow } @Composable @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") override fun Content() { - var selectedTab by remember { - mutableIntStateOf(persistentSelectedTab) - } + var selectedTab by remember { mutableIntStateOf(persistentSelectedTab) } + val density = LocalDensity.current + // Read the Liquid UI Settings! val context = LocalContext.current - val density = LocalDensity.current + val liquidPrefs = remember { LiquidUIPreferences(context) } + val isLiquidUI by liquidPrefs.liquidUIEnabledFlow.collectAsState(false) - // Shared state (across the app) val isInSelectionMode = remember { mutableStateOf(isInSelectionModeShared) } val hideNavigationBar = remember { mutableStateOf(shouldHideNavigationBar) } val videoSelectionManager = remember { mutableStateOf?>(sharedVideoSelectionManager as? SelectionManager<*, *>) } - // Check for state changes to ensure UI updates LaunchedEffect(Unit) { while (true) { - // Update FAB visibility state - if (isInSelectionMode.value != isInSelectionModeShared) { - isInSelectionMode.value = isInSelectionModeShared - android.util.Log.d("MainScreen", "Selection mode changed to: $isInSelectionModeShared") - } - - // Update navigation bar visibility state - now considers if only videos are selected - if (hideNavigationBar.value != shouldHideNavigationBar) { - hideNavigationBar.value = shouldHideNavigationBar - android.util.Log.d("MainScreen", "Navigation bar visibility changed to: ${!shouldHideNavigationBar}, onlyVideosSelected: $onlyVideosSelected") - } - - // Update selection manager + if (isInSelectionMode.value != isInSelectionModeShared) isInSelectionMode.value = isInSelectionModeShared + if (hideNavigationBar.value != shouldHideNavigationBar) hideNavigationBar.value = shouldHideNavigationBar val currentManager = sharedVideoSelectionManager as? SelectionManager<*, *> - if (videoSelectionManager.value != currentManager) { - videoSelectionManager.value = currentManager - } - - // Minimal delay for polling - delay(16) // Roughly matches a frame at 60fps for responsive updates + if (videoSelectionManager.value != currentManager) videoSelectionManager.value = currentManager + delay(16) } } - // Update persistent state whenever tab changes - LaunchedEffect(selectedTab) { - android.util.Log.d("MainScreen", "selectedTab changed to: $selectedTab (was ${persistentSelectedTab})") - persistentSelectedTab = selectedTab - } + LaunchedEffect(selectedTab) { persistentSelectedTab = selectedTab } + + val bottomBarBackdrop = rememberLayerBackdrop { drawContent() } - // Scaffold with bottom navigation bar Scaffold( modifier = Modifier.fillMaxSize(), bottomBar = { - // Animated bottom navigation bar with slide animations AnimatedVisibility( visible = !hideNavigationBar.value, - enter = slideInVertically( - animationSpec = tween(durationMillis = 300), - initialOffsetY = { fullHeight -> fullHeight } - ), - exit = slideOutVertically( - animationSpec = tween(durationMillis = 300), - targetOffsetY = { fullHeight -> fullHeight } - ) + enter = slideInVertically(animationSpec = tween(durationMillis = 300), initialOffsetY = { it }), + exit = slideOutVertically(animationSpec = tween(durationMillis = 300), targetOffsetY = { it }) ) { - NavigationBar( - modifier = Modifier - .clip( - RoundedCornerShape( - topStart = 28.dp, - topEnd = 28.dp, - bottomStart = 0.dp, - bottomEnd = 0.dp - ) + + // THE TOGGLE LOGIC + if (isLiquidUI) { + // The Floating Glass Capsule (from the 1.0.4 fork, upgraded to alpha03) + LiquidGlassSurface( + backdrop = bottomBarBackdrop, + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 24.dp) + .fillMaxWidth() + .height(64.dp), + shape = RoundedCornerShape(32.dp) + ) { + Row( + modifier = Modifier.fillMaxSize().padding(horizontal = 8.dp), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically + ) { + val activeColor = MaterialTheme.colorScheme.primary + val inactiveColor = MaterialTheme.colorScheme.onSurfaceVariant + + IconButton(onClick = { selectedTab = 0 }) { + Icon(Icons.Filled.Home, "Home", modifier = Modifier.size(28.dp), tint = if (selectedTab == 0) activeColor else inactiveColor) + } + IconButton(onClick = { selectedTab = 1 }) { + Icon(Icons.Filled.History, "Recents", modifier = Modifier.size(28.dp), tint = if (selectedTab == 1) activeColor else inactiveColor) + } + IconButton(onClick = { selectedTab = 2 }) { + Icon(Icons.AutoMirrored.Filled.PlaylistPlay, "Playlists", modifier = Modifier.size(28.dp), tint = if (selectedTab == 2) activeColor else inactiveColor) + } + IconButton(onClick = { selectedTab = 3 }) { + Icon(Icons.Filled.Language, "Network", modifier = Modifier.size(28.dp), tint = if (selectedTab == 3) activeColor else inactiveColor) + } + } + } + } else { + // The Classic mpvEx Navigation Bar + NavigationBar( + modifier = Modifier.clip(RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp, bottomStart = 0.dp, bottomEnd = 0.dp)) + ) { + NavigationBarItem( + icon = { Icon(Icons.Filled.Home, contentDescription = "Home") }, + label = { Text("Home") }, + selected = selectedTab == 0, onClick = { selectedTab = 0 } + ) + NavigationBarItem( + icon = { Icon(Icons.Filled.History, contentDescription = "Recents") }, + label = { Text("Recents") }, + selected = selectedTab == 1, onClick = { selectedTab = 1 } ) - ) { - NavigationBarItem( - icon = { Icon(Icons.Filled.Home, contentDescription = "Home") }, - label = { Text("Home") }, - selected = selectedTab == 0, - onClick = { selectedTab = 0 } - ) - NavigationBarItem( - icon = { Icon(Icons.Filled.History, contentDescription = "Recents") }, - label = { Text("Recents") }, - selected = selectedTab == 1, - onClick = { selectedTab = 1 } - ) - NavigationBarItem( - icon = { Icon(Icons.AutoMirrored.Filled.PlaylistPlay, contentDescription = "Playlists") }, - label = { Text("Playlists") }, - selected = selectedTab == 2, - onClick = { selectedTab = 2 } - ) - NavigationBarItem( - icon = { Icon(Icons.Filled.Language, contentDescription = "Network") }, - label = { Text("Network") }, - selected = selectedTab == 3, - onClick = { selectedTab = 3 } - ) + NavigationBarItem( + icon = { Icon(Icons.AutoMirrored.Filled.PlaylistPlay, contentDescription = "Playlists") }, + label = { Text("Playlists") }, + selected = selectedTab == 2, onClick = { selectedTab = 2 } + ) + NavigationBarItem( + icon = { Icon(Icons.Filled.Language, contentDescription = "Network") }, + label = { Text("Network") }, + selected = selectedTab == 3, onClick = { selectedTab = 3 } + ) + } } } } ) { paddingValues -> - Box(modifier = Modifier.fillMaxSize()) { - // Always use 80dp bottom padding regardless of navigation bar visibility + // Only apply layerBackdrop if Liquid UI is enabled (saves battery!) + Box(modifier = Modifier.fillMaxSize().then(if (isLiquidUI) Modifier.layerBackdrop(bottomBarBackdrop) else Modifier)) { val fabBottomPadding = 80.dp AnimatedContent( targetState = selectedTab, transitionSpec = { - // Material 3 Expressive slide-in-fade animation (like Google Phone app) val slideDistance = with(density) { 48.dp.roundToPx() } val animationDuration = 250 - if (targetState > initialState) { - // Moving forward: slide in from right with fade - (slideInHorizontally( - animationSpec = tween( - durationMillis = animationDuration, - easing = FastOutSlowInEasing - ), - initialOffsetX = { slideDistance } - ) + fadeIn( - animationSpec = tween( - durationMillis = animationDuration, - easing = FastOutSlowInEasing - ) - )) togetherWith (slideOutHorizontally( - animationSpec = tween( - durationMillis = animationDuration, - easing = FastOutSlowInEasing - ), - targetOffsetX = { -slideDistance } - ) + fadeOut( - animationSpec = tween( - durationMillis = animationDuration / 2, - easing = FastOutSlowInEasing - ) - )) + (slideInHorizontally(animationSpec = tween(animationDuration, easing = FastOutSlowInEasing), initialOffsetX = { slideDistance }) + fadeIn(animationSpec = tween(animationDuration, easing = FastOutSlowInEasing))) togetherWith (slideOutHorizontally(animationSpec = tween(animationDuration, easing = FastOutSlowInEasing), targetOffsetX = { -slideDistance }) + fadeOut(animationSpec = tween(animationDuration / 2, easing = FastOutSlowInEasing))) } else { - // Moving backward: slide in from left with fade - (slideInHorizontally( - animationSpec = tween( - durationMillis = animationDuration, - easing = FastOutSlowInEasing - ), - initialOffsetX = { -slideDistance } - ) + fadeIn( - animationSpec = tween( - durationMillis = animationDuration, - easing = FastOutSlowInEasing - ) - )) togetherWith (slideOutHorizontally( - animationSpec = tween( - durationMillis = animationDuration, - easing = FastOutSlowInEasing - ), - targetOffsetX = { slideDistance } - ) + fadeOut( - animationSpec = tween( - durationMillis = animationDuration / 2, - easing = FastOutSlowInEasing - ) - )) + (slideInHorizontally(animationSpec = tween(animationDuration, easing = FastOutSlowInEasing), initialOffsetX = { -slideDistance }) + fadeIn(animationSpec = tween(animationDuration, easing = FastOutSlowInEasing))) togetherWith (slideOutHorizontally(animationSpec = tween(animationDuration, easing = FastOutSlowInEasing), targetOffsetX = { slideDistance }) + fadeOut(animationSpec = tween(animationDuration / 2, easing = FastOutSlowInEasing))) } }, label = "tab_animation" ) { targetTab -> - CompositionLocalProvider( - LocalNavigationBarHeight provides fabBottomPadding - ) { + CompositionLocalProvider(LocalNavigationBarHeight provides fabBottomPadding) { when (targetTab) { 0 -> FolderListScreen.Content() 1 -> RecentlyPlayedScreen.Content() @@ -295,5 +219,4 @@ object MainScreen : Screen { } } -// CompositionLocal for navigation bar height -val LocalNavigationBarHeight = compositionLocalOf { 0.dp } \ No newline at end of file +val LocalNavigationBarHeight = compositionLocalOf { 0.dp } diff --git a/app/src/main/java/app/marlboroadvance/mpvex/ui/browser/components/FloatingBottomBar.kt b/app/src/main/java/app/marlboroadvance/mpvex/ui/browser/components/FloatingBottomBar.kt index aae10b9c2..4541af840 100644 --- a/app/src/main/java/app/marlboroadvance/mpvex/ui/browser/components/FloatingBottomBar.kt +++ b/app/src/main/java/app/marlboroadvance/mpvex/ui/browser/components/FloatingBottomBar.kt @@ -6,6 +6,8 @@ import androidx.compose.animation.fadeOut import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBars @@ -24,124 +26,114 @@ import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp -/** - * Material 3 Floating Button Bar for file/folder operations - * Icon-only buttons in a floating pill-shaped surface - */ +// --- LIQUID GLASS IMPORTS --- +import app.marlboroadvance.mpvex.ui.components.liquid.LiquidGlassSurface +import com.kyant.backdrop.Backdrop +import app.marlboroadvance.mpvex.preferences.LiquidUIPreferences +import com.kyant.backdrop.backdrops.rememberLayerBackdrop +// ---------------------------- + @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -fun BrowserBottomBar( - isSelectionMode: Boolean, - onCopyClick: () -> Unit, - onMoveClick: () -> Unit, - onRenameClick: () -> Unit, - onDeleteClick: () -> Unit, - onAddToPlaylistClick: () -> Unit, - modifier: Modifier = Modifier, - showCopy: Boolean = true, - showMove: Boolean = true, - showRename: Boolean = true, - showDelete: Boolean = true, - showAddToPlaylist: Boolean = true, +fun FloatingBottomBar( + visible: Boolean, + backdrop:Backdrop, + showCopy: Boolean = false, + showMove: Boolean = false, + showRename: Boolean = false, + showDelete: Boolean = false, + showAddToPlaylist: Boolean = false, + onCopyClick: () -> Unit = {}, + onMoveClick: () -> Unit = {}, + onRenameClick: () -> Unit = {}, + onDeleteClick: () -> Unit = {}, + onAddToPlaylistClick: () -> Unit = {}, + modifier: Modifier = Modifier ) { + // Read the Liquid UI Settings! + val context = LocalContext.current + val liquidPrefs = remember { LiquidUIPreferences(context) } + val isLiquidUI by liquidPrefs.liquidUIEnabledFlow.collectAsState(false) + AnimatedVisibility( - visible = isSelectionMode, - modifier = modifier, + visible = visible, enter = fadeIn(), exit = fadeOut(), + modifier = modifier ) { - Surface( - modifier = Modifier - .windowInsetsPadding(WindowInsets.systemBars) - .padding(horizontal = 20.dp, vertical = 8.dp), - shape = RoundedCornerShape(32.dp), - color = MaterialTheme.colorScheme.surfaceContainerHigh, - tonalElevation = 3.dp, - shadowElevation = 8.dp - ) { + val contentRow = @Composable { Row( - modifier = Modifier.padding(horizontal = 12.dp, vertical = 12.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 8.dp), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically ) { FilledTonalIconButton( - onClick = onCopyClick, - enabled = showCopy, - modifier = Modifier.size(50.dp), - colors = IconButtonDefaults.filledTonalIconButtonColors( - containerColor = MaterialTheme.colorScheme.secondaryContainer, - contentColor = MaterialTheme.colorScheme.onSecondaryContainer - ) - ) { - Icon( - Icons.Filled.ContentCopy, - contentDescription = "Copy", - modifier = Modifier.size(24.dp) - ) - } + onClick = onCopyClick, enabled = showCopy, modifier = Modifier.size(50.dp), + colors = IconButtonDefaults.filledTonalIconButtonColors() + ) { Icon(Icons.Filled.ContentCopy, contentDescription = "Copy", modifier = Modifier.size(24.dp)) } FilledTonalIconButton( - onClick = onMoveClick, - enabled = showMove, - modifier = Modifier.size(50.dp), - colors = IconButtonDefaults.filledTonalIconButtonColors( - containerColor = MaterialTheme.colorScheme.secondaryContainer, - contentColor = MaterialTheme.colorScheme.onSecondaryContainer - ) - ) { - Icon( - Icons.AutoMirrored.Filled.DriveFileMove, - contentDescription = "Move", - modifier = Modifier.size(24.dp) - ) - } + onClick = onMoveClick, enabled = showMove, modifier = Modifier.size(50.dp), + colors = IconButtonDefaults.filledTonalIconButtonColors() + ) { Icon(Icons.AutoMirrored.Filled.DriveFileMove, contentDescription = "Move", modifier = Modifier.size(24.dp)) } FilledTonalIconButton( - onClick = onRenameClick, - enabled = showRename, - modifier = Modifier.size(50.dp), + onClick = onRenameClick, enabled = showRename, modifier = Modifier.size(50.dp), colors = IconButtonDefaults.filledTonalIconButtonColors( containerColor = MaterialTheme.colorScheme.secondaryContainer, contentColor = MaterialTheme.colorScheme.onSecondaryContainer ) - ) { - Icon( - Icons.Filled.DriveFileRenameOutline, - contentDescription = "Rename", - modifier = Modifier.size(24.dp) - ) - } + ) { Icon(Icons.Filled.DriveFileRenameOutline, contentDescription = "Rename", modifier = Modifier.size(24.dp)) } FilledTonalIconButton( - onClick = onAddToPlaylistClick, - enabled = showAddToPlaylist, - modifier = Modifier.size(50.dp), + onClick = onAddToPlaylistClick, enabled = showAddToPlaylist, modifier = Modifier.size(50.dp), colors = IconButtonDefaults.filledTonalIconButtonColors() - ) { - Icon( - Icons.AutoMirrored.Filled.PlaylistAdd, - contentDescription = "Add to Playlist", - modifier = Modifier.size(24.dp) - ) - } + ) { Icon(Icons.AutoMirrored.Filled.PlaylistAdd, contentDescription = "Add to Playlist", modifier = Modifier.size(24.dp)) } FilledTonalIconButton( - onClick = onDeleteClick, - enabled = showDelete, - modifier = Modifier.size(50.dp), + onClick = onDeleteClick, enabled = showDelete, modifier = Modifier.size(50.dp), colors = IconButtonDefaults.filledTonalIconButtonColors( containerColor = MaterialTheme.colorScheme.errorContainer, contentColor = MaterialTheme.colorScheme.onErrorContainer ) - ) { - Icon( - Icons.Filled.Delete, - contentDescription = "Delete", - modifier = Modifier.size(24.dp) - ) - } + ) { Icon(Icons.Filled.Delete, contentDescription = "Delete", modifier = Modifier.size(24.dp)) } + } + } + + // THE TOGGLE LOGIC + if (isLiquidUI) { + LiquidGlassSurface( + backdrop = backdrop, + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 16.dp) + .windowInsetsPadding(WindowInsets.systemBars) + .height(64.dp), + shape = RoundedCornerShape(24.dp) + ) { + contentRow() + } + } else { + // Fallback to the classic mpvEx solid surface + Surface( + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 16.dp) + .windowInsetsPadding(WindowInsets.systemBars) + .height(64.dp), + shape = RoundedCornerShape(24.dp), + color = MaterialTheme.colorScheme.surfaceContainerHighest, + shadowElevation = 6.dp + ) { + contentRow() } } } diff --git a/app/src/main/java/app/marlboroadvance/mpvex/ui/browser/filesystem/FileSystemBrowserScreen.kt b/app/src/main/java/app/marlboroadvance/mpvex/ui/browser/filesystem/FileSystemBrowserScreen.kt index 48258b76b..bdd4c42e2 100644 --- a/app/src/main/java/app/marlboroadvance/mpvex/ui/browser/filesystem/FileSystemBrowserScreen.kt +++ b/app/src/main/java/app/marlboroadvance/mpvex/ui/browser/filesystem/FileSystemBrowserScreen.kt @@ -1,5 +1,8 @@ package app.marlboroadvance.mpvex.ui.browser.filesystem +import app.marlboroadvance.mpvex.preferences.LiquidUIPreferences +import app.marlboroadvance.mpvex.ui.components.liquid.LiquidGlassCard +import com.kyant.backdrop.backdrops.rememberLayerBackdrop import android.content.Context import android.content.Intent import android.util.Log @@ -23,6 +26,9 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.graphics.Color +import com.kyant.backdrop.backdrops.layerBackdrop +import com.kyant.backdrop.backdrops.rememberLayerBackdrop import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ViewList import androidx.compose.material.icons.filled.AccountTree @@ -92,7 +98,7 @@ import app.marlboroadvance.mpvex.preferences.preference.collectAsState import app.marlboroadvance.mpvex.presentation.components.pullrefresh.PullRefreshBox import app.marlboroadvance.mpvex.ui.browser.cards.FolderCard import app.marlboroadvance.mpvex.ui.browser.cards.VideoCard -import app.marlboroadvance.mpvex.ui.browser.components.BrowserBottomBar +import app.marlboroadvance.mpvex.ui.browser.components.FloatingBottomBar import app.marlboroadvance.mpvex.ui.browser.components.BrowserTopBar import app.marlboroadvance.mpvex.ui.browser.dialogs.AddToPlaylistDialog import app.marlboroadvance.mpvex.ui.browser.dialogs.DeleteConfirmationDialog @@ -484,11 +490,16 @@ fun FileSystemBrowserScreen(path: String? = null) { onExpandedChange = { isFabExpanded.value = it }, ) + val floatingBarBackdrop = rememberLayerBackdrop { drawContent() } + // Main content Box(modifier = Modifier.fillMaxSize()) { Scaffold( + modifier = Modifier.layerBackdrop(floatingBarBackdrop), + containerColor = Color.Transparent, topBar = { if (isSearching) { + // Search mode - show search bar instead of top bar SearchBar( inputField = { @@ -878,43 +889,36 @@ fun FileSystemBrowserScreen(path: String? = null) { // Independent Floating Bottom Bar - positioned at absolute bottom // Play Store gating is intentionally bypassed here. - AnimatedVisibility( + FloatingBottomBar( + backdrop = floatingBarBackdrop, visible = showFloatingBottomBar, - enter = slideInVertically( - animationSpec = tween(durationMillis = animationDuration), - initialOffsetY = { fullHeight -> fullHeight } - ), - exit = slideOutVertically( - animationSpec = tween(durationMillis = animationDuration), - targetOffsetY = { fullHeight -> fullHeight } - ), + showCopy = true, + showMove = true, + showRename = videoSelectionManager.isSingleSelection, + showDelete = true, + showAddToPlaylist = true, + onCopyClick = { + operationType.value = CopyPasteOps.OperationType.Copy + if (CopyPasteOps.canUseDirectFileOperations()) { + folderPickerOpen.value = true + } else { + treePickerLauncher.launch(null) + } + }, + onMoveClick = { + operationType.value = CopyPasteOps.OperationType.Move + if (CopyPasteOps.canUseDirectFileOperations()) { + folderPickerOpen.value = true + } else { + treePickerLauncher.launch(null) + } + }, + onRenameClick = { renameDialogOpen.value = true }, + onDeleteClick = { deleteDialogOpen.value = true }, + onAddToPlaylistClick = { addToPlaylistDialogOpen.value = true }, modifier = Modifier.align(Alignment.BottomCenter) - ) { - BrowserBottomBar( - isSelectionMode = true, - onCopyClick = { - operationType.value = CopyPasteOps.OperationType.Copy - if (CopyPasteOps.canUseDirectFileOperations()) { - folderPickerOpen.value = true - } else { - treePickerLauncher.launch(null) - } - }, - onMoveClick = { - operationType.value = CopyPasteOps.OperationType.Move - if (CopyPasteOps.canUseDirectFileOperations()) { - folderPickerOpen.value = true - } else { - treePickerLauncher.launch(null) - } - }, - onRenameClick = { renameDialogOpen.value = true }, - onDeleteClick = { deleteDialogOpen.value = true }, - onAddToPlaylistClick = { addToPlaylistDialogOpen.value = true }, - showRename = videoSelectionManager.isSingleSelection, - modifier = Modifier.padding(bottom = 0.dp) // Zero bottom padding - absolute bottom - ) - } + ) + } // Dialogs PlayLinkSheet( @@ -1029,7 +1033,6 @@ fun FileSystemBrowserScreen(path: String? = null) { }, ) } -} /** * Recursively searches for files matching the query in a directory and its subdirectories @@ -1165,6 +1168,14 @@ private fun FileSystemBrowserContent( isInSelectionMode: Boolean = false, ) { val gesturePreferences = koinInject() + val context = LocalContext.current + val liquidPreferences = remember { LiquidUIPreferences(context) } + val liquidUIEnabled by liquidPreferences.liquidUIEnabledFlow.collectAsState(initial = false) + + // The engine that captures the screen behind the cards + val backdrop = rememberLayerBackdrop { + drawContent() + } val browserPreferences = koinInject() val thumbnailRepository = koinInject() val tapThumbnailToSelect by gesturePreferences.tapThumbnailToSelect.collectAsState() @@ -1303,19 +1314,24 @@ private fun FileSystemBrowserContent( lastModified = folder.lastModified / 1000, ) - FolderCard( - folder = folderModel, - isSelected = folderSelectionManager.isSelected(folder), - isRecentlyPlayed = false, - onClick = { onFolderClick(folder) }, - onLongClick = { onFolderLongClick(folder) }, - onThumbClick = if (tapThumbnailToSelect) { - { onFolderLongClick(folder) } - } else { - { onFolderClick(folder) } - }, - isGridMode = false, - ) + LiquidGlassCard( + backdrop = if (liquidUIEnabled) backdrop else null, + modifier = Modifier.padding(vertical = 4.dp) + ) { + FolderCard( + folder = folderModel, + isSelected = folderSelectionManager.isSelected(folder), + isRecentlyPlayed = false, + onClick = { onFolderClick(folder) }, + onLongClick = { onFolderLongClick(folder) }, + onThumbClick = if (tapThumbnailToSelect) { + { onFolderLongClick(folder) } + } else { + { onFolderClick(folder) } + }, + isGridMode = false, + ) + } } // Videos second @@ -1323,26 +1339,31 @@ private fun FileSystemBrowserContent( items = items.filterIsInstance(), key = { "${it.video.id}_${it.video.path}" }, ) { videoFile -> - VideoCard( - video = videoFile.video, - progressPercentage = videoFilesWithPlayback[videoFile.video.id], - isRecentlyPlayed = false, - isSelected = videoSelectionManager.isSelected(videoFile.video), - onClick = { onVideoClick(videoFile.video) }, - onLongClick = { onVideoLongClick(videoFile.video) }, - onThumbClick = if (tapThumbnailToSelect) { - { onVideoLongClick(videoFile.video) } - } else { - { onVideoClick(videoFile.video) } - }, - isGridMode = false, - showSubtitleIndicator = showSubtitleIndicator, - overrideShowSizeChip = null, - overrideShowResolutionChip = null, - useFolderNameStyle = false, - ) + LiquidGlassCard( + backdrop = if (liquidUIEnabled) backdrop else null, + modifier = Modifier.padding(vertical = 4.dp) + ) { + VideoCard( + video = videoFile.video, + progressPercentage = videoFilesWithPlayback[videoFile.video.id], + isRecentlyPlayed = false, + isSelected = videoSelectionManager.isSelected(videoFile.video), + onClick = { onVideoClick(videoFile.video) }, + onLongClick = { onVideoLongClick(videoFile.video) }, + onThumbClick = if (tapThumbnailToSelect) { + { onVideoLongClick(videoFile.video) } + } else { + { onVideoClick(videoFile.video) } + }, + isGridMode = false, + showSubtitleIndicator = showSubtitleIndicator, + overrideShowSizeChip = null, + overrideShowResolutionChip = null, + useFolderNameStyle = false, + ) + } } - } + } // Scrollbar with bottom padding to avoid overlap with navigation Box( @@ -1384,6 +1405,10 @@ private fun FileSystemSearchContent( val gesturePreferences = koinInject() val browserPreferences = koinInject() val tapThumbnailToSelect by gesturePreferences.tapThumbnailToSelect.collectAsState() + val context = LocalContext.current + val liquidPreferences = remember { LiquidUIPreferences(context) } + val liquidUIEnabled by liquidPreferences.liquidUIEnabledFlow.collectAsState(initial = false) + val backdrop = rememberLayerBackdrop { drawContent() } // Track scroll for FAB visibility in search mode with proper scroll direction detection val previousIndex = remember { mutableIntStateOf(0) } @@ -1485,15 +1510,20 @@ private fun FileSystemSearchContent( lastModified = folder.lastModified / 1000, ) - FolderCard( - folder = folderModel, - isSelected = false, - isRecentlyPlayed = false, - onClick = { onFolderClick(folder) }, - onLongClick = { }, - onThumbClick = { onFolderClick(folder) }, - isGridMode = false, - ) + LiquidGlassCard( + backdrop = if (liquidUIEnabled) backdrop else null, + modifier = Modifier.padding(vertical = 4.dp) + ) { + FolderCard( + folder = folderModel, + isSelected = false, + isRecentlyPlayed = false, + onClick = { onFolderClick(folder) }, + onLongClick = { }, + onThumbClick = { onFolderClick(folder) }, + isGridMode = false, + ) + } } // Videos second @@ -1501,22 +1531,27 @@ private fun FileSystemSearchContent( items = videos, key = { "search_video_${it.video.id}_${it.video.path}_${it.hashCode()}" }, ) { videoFile -> - VideoCard( - video = videoFile.video, - progressPercentage = videoFilesWithPlayback[videoFile.video.id], - isRecentlyPlayed = false, - isSelected = false, - onClick = { onVideoClick(videoFile.video) }, - onLongClick = { }, - onThumbClick = { onVideoClick(videoFile.video) }, - isGridMode = false, - showSubtitleIndicator = showSubtitleIndicator, - overrideShowSizeChip = null, - overrideShowResolutionChip = null, - useFolderNameStyle = false, - ) + LiquidGlassCard( + backdrop = if (liquidUIEnabled) backdrop else null, + modifier = Modifier.padding(vertical = 4.dp) + ) { + VideoCard( + video = videoFile.video, + progressPercentage = videoFilesWithPlayback[videoFile.video.id], + isRecentlyPlayed = false, + isSelected = false, + onClick = { onVideoClick(videoFile.video) }, + onLongClick = { }, + onThumbClick = { onVideoClick(videoFile.video) }, + isGridMode = false, + showSubtitleIndicator = showSubtitleIndicator, + overrideShowSizeChip = null, + overrideShowResolutionChip = null, + useFolderNameStyle = false, + ) + } } - } + } // Scrollbar with bottom padding to avoid overlap with navigation Box( diff --git a/app/src/main/java/app/marlboroadvance/mpvex/ui/browser/recentlyplayed/RecentlyPlayedScreen.kt b/app/src/main/java/app/marlboroadvance/mpvex/ui/browser/recentlyplayed/RecentlyPlayedScreen.kt index 9931a020e..d76e9e81d 100644 --- a/app/src/main/java/app/marlboroadvance/mpvex/ui/browser/recentlyplayed/RecentlyPlayedScreen.kt +++ b/app/src/main/java/app/marlboroadvance/mpvex/ui/browser/recentlyplayed/RecentlyPlayedScreen.kt @@ -184,8 +184,9 @@ object RecentlyPlayedScreen : Screen { onExpandedChange = { isFabExpanded.value = it }, ) - Scaffold( - topBar = { + Scaffold( + containerColor = androidx.compose.ui.graphics.Color.Transparent, + topBar = { BrowserTopBar( title = "Recently Played", isInSelectionMode = selectionManager.isInSelectionMode, diff --git a/app/src/main/java/app/marlboroadvance/mpvex/ui/browser/videolist/VideoListScreen.kt b/app/src/main/java/app/marlboroadvance/mpvex/ui/browser/videolist/VideoListScreen.kt index 3a014994f..941a32370 100644 --- a/app/src/main/java/app/marlboroadvance/mpvex/ui/browser/videolist/VideoListScreen.kt +++ b/app/src/main/java/app/marlboroadvance/mpvex/ui/browser/videolist/VideoListScreen.kt @@ -23,6 +23,9 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import com.kyant.backdrop.backdrops.layerBackdrop +import com.kyant.backdrop.backdrops.rememberLayerBackdrop import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.lazy.rememberLazyListState @@ -85,7 +88,7 @@ import app.marlboroadvance.mpvex.presentation.Screen import app.marlboroadvance.mpvex.presentation.components.pullrefresh.PullRefreshBox import app.marlboroadvance.mpvex.BuildConfig import app.marlboroadvance.mpvex.ui.browser.cards.VideoCard -import app.marlboroadvance.mpvex.ui.browser.components.BrowserBottomBar +import app.marlboroadvance.mpvex.ui.browser.components.FloatingBottomBar import app.marlboroadvance.mpvex.ui.browser.components.BrowserTopBar import app.marlboroadvance.mpvex.ui.browser.dialogs.AddToPlaylistDialog import app.marlboroadvance.mpvex.ui.browser.dialogs.DeleteConfirmationDialog @@ -224,6 +227,8 @@ data class VideoListScreen( var showFloatingBottomBar by remember { mutableStateOf(false) } val animationDuration = 300 + val floatingBarBackdrop = rememberLayerBackdrop { drawContent() } + // Handle selection mode changes with animation LaunchedEffect(selectionManager.isInSelectionMode) { if (selectionManager.isInSelectionMode) { @@ -254,7 +259,8 @@ data class VideoListScreen( } } - Scaffold( + Scaffold( + containerColor = Color.Transparent, topBar = { BrowserTopBar( title = displayFolderName, @@ -337,8 +343,9 @@ data class VideoListScreen( val autoScrollToLastPlayed by browserPreferences.autoScrollToLastPlayed.collectAsState() Box(modifier = Modifier.fillMaxSize()) { - VideoListContent( - folderId = bucketId, + Box(modifier = Modifier.fillMaxSize().layerBackdrop(floatingBarBackdrop)) { + VideoListContent( + folderId = bucketId, videosWithInfo = sortedVideosWithInfo, isLoading = isLoading && videos.isEmpty(), isRefreshing = isRefreshing, @@ -362,43 +369,38 @@ data class VideoListScreen( modifier = Modifier.padding(padding), showFloatingBottomBar = showFloatingBottomBar, ) + } // Floating Material 3 Button Group overlay with animation // Play Store gating is intentionally bypassed here. - AnimatedVisibility( + FloatingBottomBar( + backdrop = floatingBarBackdrop, visible = showFloatingBottomBar, - enter = slideInVertically( - animationSpec = tween(durationMillis = animationDuration), - initialOffsetY = { fullHeight -> fullHeight } - ), - exit = slideOutVertically( - animationSpec = tween(durationMillis = animationDuration), - targetOffsetY = { fullHeight -> fullHeight } - ), + showCopy = true, + showMove = true, + showRename = selectionManager.isSingleSelection, + showDelete = true, + showAddToPlaylist = true, + onCopyClick = { + operationType.value = CopyPasteOps.OperationType.Copy + if (CopyPasteOps.canUseDirectFileOperations()) { + folderPickerOpen.value = true + } else { + treePickerLauncher.launch(null) + } + }, + onMoveClick = { + operationType.value = CopyPasteOps.OperationType.Move + if (CopyPasteOps.canUseDirectFileOperations()) { + folderPickerOpen.value = true + } else { + treePickerLauncher.launch(null) + } + }, + onRenameClick = { renameDialogOpen.value = true }, + onDeleteClick = { deleteDialogOpen.value = true }, + onAddToPlaylistClick = { addToPlaylistDialogOpen.value = true }, modifier = Modifier.align(Alignment.BottomCenter) - ) { - BrowserBottomBar( - isSelectionMode = true, - onCopyClick = { - operationType.value = CopyPasteOps.OperationType.Copy - if (CopyPasteOps.canUseDirectFileOperations()) { - folderPickerOpen.value = true - } else { - treePickerLauncher.launch(null) - } - }, - onMoveClick = { - operationType.value = CopyPasteOps.OperationType.Move - if (CopyPasteOps.canUseDirectFileOperations()) { - folderPickerOpen.value = true - } else { - treePickerLauncher.launch(null) - } - }, - onRenameClick = { renameDialogOpen.value = true }, - onDeleteClick = { deleteDialogOpen.value = true }, - onAddToPlaylistClick = { addToPlaylistDialogOpen.value = true }, - showRename = selectionManager.isSingleSelection ) } } @@ -540,7 +542,6 @@ data class VideoListScreen( ) } } -} @Composable private fun VideoListContent( diff --git a/app/src/main/java/app/marlboroadvance/mpvex/ui/components/liquid/LiquidAlertDialog.kt b/app/src/main/java/app/marlboroadvance/mpvex/ui/components/liquid/LiquidAlertDialog.kt new file mode 100644 index 000000000..acc8fac5c --- /dev/null +++ b/app/src/main/java/app/marlboroadvance/mpvex/ui/components/liquid/LiquidAlertDialog.kt @@ -0,0 +1,46 @@ +package app.marlboroadvance.mpvex.ui.components.liquid + +import androidx.compose.foundation.background +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +@Composable +fun LiquidAlertDialog( + onDismissRequest: () -> Unit, + confirmButton: @Composable () -> Unit, + modifier: Modifier = Modifier, + dismissButton: @Composable (() -> Unit)? = null, + icon: @Composable (() -> Unit)? = null, + title: @Composable (() -> Unit)? = null, + text: @Composable (() -> Unit)? = null, +) { + val backdrop = LocalLiquidBackdrop.current + + // CHANGED: dialog scrim was previously `Color.White.copy(alpha = 0.15f)`. That flat 15%-opaque white was the + // root cause of the dialog text bleeding into menu/background text — and it didn't adapt to dark theme. + // New scrim: theme color (`surfaceContainerHigh`) at 0.85 alpha. Adapts to light/dark automatically and is + // opaque enough that text behind the dialog can't compete with text inside it. + val scrimColor = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.85f) + + AlertDialog( + onDismissRequest = onDismissRequest, + confirmButton = confirmButton, + dismissButton = dismissButton, + icon = icon, + title = title, + text = text, + modifier = if (backdrop != null) { + modifier.background( + color = scrimColor, + shape = MaterialTheme.shapes.extraLarge + ) + } else modifier, + containerColor = if (backdrop != null) Color.Transparent else MaterialTheme.colorScheme.surfaceContainerHigh, + tonalElevation = if (backdrop != null) 0.dp else 6.dp, + shape = MaterialTheme.shapes.extraLarge + ) +} diff --git a/app/src/main/java/app/marlboroadvance/mpvex/ui/components/liquid/LiquidCard.kt b/app/src/main/java/app/marlboroadvance/mpvex/ui/components/liquid/LiquidCard.kt new file mode 100644 index 000000000..0feec58d3 --- /dev/null +++ b/app/src/main/java/app/marlboroadvance/mpvex/ui/components/liquid/LiquidCard.kt @@ -0,0 +1,92 @@ +package app.marlboroadvance.mpvex.ui.components.liquid + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.material3.Card +import androidx.compose.material3.CardColors +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CardElevation +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Shape +import app.marlboroadvance.mpvex.preferences.LiquidTarget + +// --- VERSION 1: STANDARD CARD --- +@Composable +fun LiquidCard( + modifier: Modifier = Modifier, + shape: Shape = CardDefaults.shape, + colors: CardColors = CardDefaults.cardColors(), + elevation: CardElevation = CardDefaults.cardElevation(), + border: BorderStroke? = null, + target: LiquidTarget = LiquidTarget.CARD, + content: @Composable ColumnScope.() -> Unit +) { + val backdrop = LocalLiquidBackdrop.current + + if (backdrop != null) { + LiquidGlassSurface( + backdrop = backdrop, + target = target, + shape = shape, + modifier = modifier + ) { + Column(content = content) + } + } else { + Card( + modifier = modifier, + shape = shape, + colors = colors, + elevation = elevation, + border = border, + content = content + ) + } +} + +// --- VERSION 2: CLICKABLE CARD (Fixes the onClick error!) --- +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LiquidCard( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + shape: Shape = CardDefaults.shape, + colors: CardColors = CardDefaults.cardColors(), + elevation: CardElevation = CardDefaults.cardElevation(), + border: BorderStroke? = null, + target: LiquidTarget = LiquidTarget.CARD, + content: @Composable ColumnScope.() -> Unit +) { + val backdrop = LocalLiquidBackdrop.current + + if (backdrop != null) { + // LIQUID UI MODE WITH CLICK ACTION + LiquidGlassSurface( + backdrop = backdrop, + target = target, + shape = shape, + modifier = modifier.then( + if (enabled) Modifier.clickable(onClick = onClick) else Modifier + ) + ) { + Column(content = content) + } + } else { + // STANDARD CLICKABLE MATERIAL 3 CARD + Card( + onClick = onClick, + modifier = modifier, + enabled = enabled, + shape = shape, + colors = colors, + elevation = elevation, + border = border, + content = content + ) + } +} diff --git a/app/src/main/java/app/marlboroadvance/mpvex/ui/components/liquid/LiquidComponents.kt b/app/src/main/java/app/marlboroadvance/mpvex/ui/components/liquid/LiquidComponents.kt new file mode 100644 index 000000000..ac41d3156 --- /dev/null +++ b/app/src/main/java/app/marlboroadvance/mpvex/ui/components/liquid/LiquidComponents.kt @@ -0,0 +1,97 @@ +package app.marlboroadvance.mpvex.ui.components.liquid + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.dp +import com.kyant.backdrop.Backdrop +import app.marlboroadvance.mpvex.preferences.LiquidTarget +import app.marlboroadvance.mpvex.preferences.LiquidUIPreferences + +// Broadcasts the glass camera to any button that wants it! +val LocalLiquidBackdrop = androidx.compose.runtime.staticCompositionLocalOf { null } + +// ADDED: shared LiquidUIPreferences CompositionLocal. +// Why: previously every LiquidGlassSurface built its own DataStore wrapper via remember{ LiquidUIPreferences(context) }. +// With many glass surfaces on screen (nav + buttons + cards) that meant N wrappers all observing the same DataStore. +// MainActivity can now provide one instance for the whole tree; null fallback keeps old behavior working. +val LocalLiquidPreferences = androidx.compose.runtime.staticCompositionLocalOf { null } + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun TransparentLiquidButton( + modifier: Modifier = Modifier, + backdrop: Backdrop?, + shape: Shape = CircleShape, + target: LiquidTarget = LiquidTarget.BUTTON, + onClick: () -> Unit, + onLongClick: (() -> Unit)? = null, + content: @Composable () -> Unit +) { + if (backdrop == null) { + androidx.compose.foundation.layout.Box( + modifier = modifier + .clip(shape) + .combinedClickable(onClick = onClick, onLongClick = onLongClick), + contentAlignment = Alignment.Center + ) { content() } + return + } + + val interactionSource = remember { MutableInteractionSource() } + + // Calls the engine directly—it automatically reads your live DataStore flows! + LiquidGlassSurface( + backdrop = backdrop, + shape = shape, + target = target, + modifier = modifier + .clip(shape) + .combinedClickable( + interactionSource = interactionSource, + indication = ripple(), + onClick = onClick, + onLongClick = onLongClick + ) + ) { + androidx.compose.foundation.layout.Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + content() + } + } +} + +@Composable +fun LiquidGlassCard( + modifier: Modifier = Modifier, + backdrop: Backdrop?, + shape: Shape = RoundedCornerShape(12.dp), + content: @Composable () -> Unit +) { + if (backdrop == null) { + androidx.compose.foundation.layout.Box(modifier = modifier) { content() } + return + } + + LiquidGlassSurface( + backdrop = backdrop, + shape = shape, + target = LiquidTarget.DIALOG, + modifier = modifier + ) { + content() + } +} diff --git a/app/src/main/java/app/marlboroadvance/mpvex/ui/components/liquid/LiquidGlassSurface.kt b/app/src/main/java/app/marlboroadvance/mpvex/ui/components/liquid/LiquidGlassSurface.kt new file mode 100644 index 000000000..83f8aa170 --- /dev/null +++ b/app/src/main/java/app/marlboroadvance/mpvex/ui/components/liquid/LiquidGlassSurface.kt @@ -0,0 +1,104 @@ +package app.marlboroadvance.mpvex.ui.components.liquid + +import android.os.Build +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp + +import com.kyant.backdrop.Backdrop +import com.kyant.backdrop.drawBackdrop +import com.kyant.backdrop.effects.blur +import com.kyant.backdrop.effects.lens +import com.kyant.backdrop.effects.vibrancy + +import app.marlboroadvance.mpvex.preferences.LiquidUIPreferences +import app.marlboroadvance.mpvex.preferences.LiquidTarget + +@Composable +fun LiquidGlassSurface( + backdrop: Backdrop, + target: LiquidTarget = LiquidTarget.NAV, + modifier: Modifier = Modifier, + shape: Shape = RoundedCornerShape(24.dp), + defaultTintColor: Color = Color.White, + content: @Composable () -> Unit +) { + val context = LocalContext.current + // CHANGED: prefer shared LocalLiquidPreferences if MainActivity provided one (perf: one DataStore wrapper for whole tree). + // Fallback to a remembered instance keyed on applicationContext so it survives configuration changes + // and is not rebuilt on every recomposition. + val sharedPrefs = LocalLiquidPreferences.current + val liquidPrefs = sharedPrefs ?: remember(context.applicationContext) { + LiquidUIPreferences(context.applicationContext) + } + + val blurRadius by remember(liquidPrefs, target) { liquidPrefs.blurRadiusFlow(target) }.collectAsState(initial = 0f) + val refractionHeight by remember(liquidPrefs, target) { liquidPrefs.refractionHeightFlow(target) }.collectAsState(initial = 40f) + val refractionAmount by remember(liquidPrefs, target) { liquidPrefs.refractionAmountFlow(target) }.collectAsState(initial = 23f) + val chromaticAberration by remember(liquidPrefs, target) { liquidPrefs.chromaticAberrationFlow(target) }.collectAsState(initial = false) + val depthEffect by remember(liquidPrefs, target) { liquidPrefs.depthEffectFlow(target) }.collectAsState(initial = true) + val vibrancyEnabled by remember(liquidPrefs, target) { liquidPrefs.vibrancyEnabledFlow(target) }.collectAsState(initial = true) + // CHANGED: initial value 0.15f → 0.5f to match the new DataStore default; prevents a flash of see-through glass + // (where backdrop text would bleed through) on first frame before the flow emits. + val tintAlpha by remember(liquidPrefs, target) { liquidPrefs.tintAlphaFlow(target) }.collectAsState(initial = 0.5f) + + val density = LocalDensity.current + // ADDED (perf): hoist dp→px conversions out of the per-frame `effects` draw lambda. + // Previously `refractionHeight.dp.toPx()` etc. ran on every frame inside drawBackdrop's effects block. + // Now they only recompute when density or the underlying pref value actually changes. + val blurPx = remember(density, blurRadius) { with(density) { blurRadius.dp.toPx() } } + val refractionHeightPx = remember(density, refractionHeight) { with(density) { refractionHeight.dp.toPx() } } + val refractionAmountPx = remember(density, refractionAmount) { with(density) { refractionAmount.dp.toPx() } } + // ADDED: clamp tint alpha to [0,1] defensively; a stray out-of-range pref value would otherwise crash drawRect. + val safeTintAlpha = tintAlpha.coerceIn(0f, 1f) + + if (Build.VERSION.SDK_INT >= 33) { + Box( + modifier = modifier + .drawBackdrop( + backdrop = backdrop, + shape = { shape }, + effects = { + // CHANGED: enforce Backdrop docs' required effect order — color filter (vibrancy) → blur → lens. + // ADDED (perf): skip lens() entirely when refraction params are 0 — the lens shader is + // the most expensive effect in this pipeline, and running it with zero amount is wasted GPU work. + if (vibrancyEnabled) vibrancy() + if (blurPx > 0f) blur(blurPx) + if (refractionHeightPx > 0f && refractionAmountPx > 0f) { + lens( + refractionHeight = refractionHeightPx, + refractionAmount = refractionAmountPx, + depthEffect = depthEffect, + chromaticAberration = chromaticAberration + ) + } + }, + onDrawSurface = { drawRect(defaultTintColor.copy(alpha = safeTintAlpha)) } + ) + ) { content() } + } else { + // CHANGED: pre-API-33 fallback alpha is now coerced to ≥ 0.7f. + // Reason: this branch can't run blur/lens shaders (RuntimeShader is API 33+), so the tint is the ONLY + // thing separating foreground text from backdrop content. 0.5f looked transparent here even though it + // works fine on API 33+ where blur/lens further obscure the backdrop. + val fallbackAlpha = safeTintAlpha.coerceAtLeast(0.7f) + Box( + modifier = modifier + .background(defaultTintColor.copy(alpha = fallbackAlpha), shape) + .border(1.dp, Color.White.copy(alpha = 0.2f), shape) + .clip(shape) + ) { content() } + } +} diff --git a/app/src/main/java/app/marlboroadvance/mpvex/ui/components/liquid/LiquidSwitchPreference.kt b/app/src/main/java/app/marlboroadvance/mpvex/ui/components/liquid/LiquidSwitchPreference.kt new file mode 100644 index 000000000..321056d71 --- /dev/null +++ b/app/src/main/java/app/marlboroadvance/mpvex/ui/components/liquid/LiquidSwitchPreference.kt @@ -0,0 +1,53 @@ +package app.marlboroadvance.mpvex.ui.components.liquid + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import app.marlboroadvance.mpvex.preferences.LiquidUIPreferences + +@Composable +fun LiquidSwitchPreference( + value: Boolean, + onValueChange: (Boolean) -> Unit, + title: @Composable () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + icon: @Composable (() -> Unit)? = null, + summary: @Composable (() -> Unit)? = null +) { + val context = LocalContext.current + val preferences = remember { LiquidUIPreferences(context) } + + Row( + modifier = modifier + .fillMaxWidth() + .clickable(enabled = enabled) { onValueChange(!value) } + .padding(horizontal = 16.dp, vertical = 16.dp) + .alpha(if (enabled) 1f else 0.5f), + verticalAlignment = Alignment.CenterVertically + ) { + if (icon != null) { + Box(modifier = Modifier.padding(end = 16.dp)) { icon() } + } + Column(modifier = Modifier.weight(1f).padding(end = 16.dp)) { + title() + if (summary != null) summary() + } + AdaptiveToggle( + checked = value, + onCheckedChange = onValueChange, + preferences = preferences, + enabled = enabled + ) + } +} diff --git a/app/src/main/java/app/marlboroadvance/mpvex/ui/components/liquid/LiquidUIToggle.kt b/app/src/main/java/app/marlboroadvance/mpvex/ui/components/liquid/LiquidUIToggle.kt new file mode 100644 index 000000000..6427bc46e --- /dev/null +++ b/app/src/main/java/app/marlboroadvance/mpvex/ui/components/liquid/LiquidUIToggle.kt @@ -0,0 +1,201 @@ +package app.marlboroadvance.mpvex.ui.components.liquid + +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.draggable +import androidx.compose.foundation.gestures.rememberDraggableState +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Switch +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import com.kyant.backdrop.backdrops.layerBackdrop +import com.kyant.backdrop.backdrops.rememberLayerBackdrop +import com.kyant.backdrop.drawBackdrop +import com.kyant.backdrop.effects.blur +import com.kyant.backdrop.effects.lens +import app.marlboroadvance.mpvex.preferences.LiquidUIPreferences +import kotlin.math.roundToInt + +@Composable +fun LiquidToggle( + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + checkedColor: Color = Color(0xFF4CAF50), + uncheckedColor: Color = Color(0xFFBDBDBD), + thumbColor: Color = Color.White, + applyLiquidEffect: Boolean = true +) { + if (!applyLiquidEffect) { + Switch(checked = checked, onCheckedChange = onCheckedChange, modifier = modifier, enabled = enabled) + return + } + + // 1.0.6 Exact Dimensions + val trackWidthDp = 64.dp + val trackHeightDp = 28.dp + val thumbBaseWidthDp = 40.dp + val thumbBaseHeightDp = 24.dp + val paddingDp = 2.dp + + val density = LocalDensity.current + val dragWidthPx = with(density) { (trackWidthDp - thumbBaseWidthDp - (paddingDp * 2)).toPx() } + + var dragFraction by remember { mutableFloatStateOf(if (checked) 1f else 0f) } + var isDragging by remember { mutableStateOf(false) } + + LaunchedEffect(checked) { + if (!isDragging) dragFraction = if (checked) 1f else 0f + } + + val draggableState = rememberDraggableState { delta -> + dragFraction = (dragFraction + (delta / dragWidthPx)).coerceIn(0f, 1f) + } + + // The Sliding Physics + val animatedFraction by animateFloatAsState( + targetValue = if (isDragging) dragFraction else (if (checked) 1f else 0f), + animationSpec = spring(dampingRatio = 0.5f, stiffness = 400f), + label = "thumb_fraction" + ) + + val interactionSource = remember { MutableInteractionSource() } + val isPressed by interactionSource.collectIsPressedAsState() + + // The Squishy Press Physics + val pressProgress by animateFloatAsState( + targetValue = if (isPressed || isDragging) 1f else 0f, + animationSpec = spring(dampingRatio = 0.6f, stiffness = 800f), + label = "press_progress" + ) + + // Thumb physically SWELLS/EXPANDS when you press it (like a magnifying glass) + val thumbWidth by animateDpAsState( + targetValue = androidx.compose.ui.unit.lerp(thumbBaseWidthDp, 52.dp, pressProgress), + animationSpec = spring(dampingRatio = 0.6f, stiffness = 800f), + label = "thumb_width" + ) + + val thumbHeight by animateDpAsState( + targetValue = androidx.compose.ui.unit.lerp(thumbBaseHeightDp, 32.dp, pressProgress), + animationSpec = spring(dampingRatio = 0.6f, stiffness = 800f), + label = "thumb_height" + ) + + val dynamicTint = lerp(uncheckedColor, checkedColor, animatedFraction) + val trackBackdrop = rememberLayerBackdrop { drawContent() } + + Box( + modifier = modifier + .size(width = trackWidthDp, height = trackHeightDp) + .clickable( + enabled = enabled, + onClick = { onCheckedChange(!checked) }, + indication = null, + interactionSource = interactionSource + ) + .draggable( + state = draggableState, + orientation = Orientation.Horizontal, + enabled = enabled, + onDragStarted = { + isDragging = true + dragFraction = if (checked) 1f else 0f + }, + onDragStopped = { + isDragging = false + val targetChecked = dragFraction > 0.5f + onCheckedChange(targetChecked) + } + ), + contentAlignment = Alignment.CenterStart + ) { + // THE TRACK (Feeds colors to the glass) + Box( + modifier = Modifier + .fillMaxSize() + .clip(CircleShape) + .background(dynamicTint) + .layerBackdrop(trackBackdrop) + ) + + // THE LIQUID GLASS THUMB + val paddingPx = with(density) { paddingDp.toPx() } + val thumbOffsetPx = paddingPx + (dragWidthPx * animatedFraction) + + Box( + modifier = Modifier + .offset { IntOffset(thumbOffsetPx.roundToInt(), 0) } + .size(width = thumbWidth, height = thumbHeight) + .drawBackdrop( + backdrop = trackBackdrop, + shape = { CircleShape }, + effects = { + val currentBlur = 8f.dp.toPx() * (1f - pressProgress) + val currentHeight = 5f.dp.toPx() + (5f.dp.toPx() * pressProgress) + val currentAmount = 10f.dp.toPx() + (4f.dp.toPx() * pressProgress) + + if (currentBlur > 0f) { + blur(currentBlur) + } + + lens( + refractionHeight = currentHeight, + refractionAmount = currentAmount, + chromaticAberration = true + ) + }, + onDrawSurface = { + // Melts into pure transparent glass when pressed! + drawRect(thumbColor.copy(alpha = 1f - pressProgress)) + } + ) + ) + } +} + +@Composable +fun AdaptiveToggle( + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + preferences: LiquidUIPreferences, + enabled: Boolean = true +) { + val isLiquidUIEnabled = preferences.liquidUIEnabledFlow.collectAsState(false).value + val toggleColorLong = preferences.liquidToggleColorFlow.collectAsState(0xFF4CAF50).value + + LiquidToggle( + checked = checked, + onCheckedChange = onCheckedChange, + modifier = modifier, + enabled = enabled, + checkedColor = Color(toggleColorLong), + applyLiquidEffect = isLiquidUIEnabled + ) +} diff --git a/app/src/main/java/app/marlboroadvance/mpvex/ui/player/AmbientModeManager.kt b/app/src/main/java/app/marlboroadvance/mpvex/ui/player/AmbientModeManager.kt new file mode 100644 index 000000000..a72f0e1f4 --- /dev/null +++ b/app/src/main/java/app/marlboroadvance/mpvex/ui/player/AmbientModeManager.kt @@ -0,0 +1,271 @@ +package app.marlboroadvance.mpvex.ui.player + +import android.util.Log +import app.marlboroadvance.mpvex.preferences.PlayerPreferences +import `is`.xyz.mpv.MPVLib +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import java.io.File + +/** + * Manages ambient mode functionality for the video player. + * Handles shader generation, video scaling, and parameter management. + */ +class AmbientModeManager( + private val playerPreferences: PlayerPreferences, + private val cacheDir: File, + private val scope: CoroutineScope, + private val onShowText: (String) -> Unit +) { + companion object { + private const val TAG = "AmbientModeManager" + } + + // ==================== State Management ==================== + + private val _isAmbientEnabled = MutableStateFlow(playerPreferences.isAmbientEnabled.get()) + val isAmbientEnabled: StateFlow = _isAmbientEnabled.asStateFlow() + + private val _isAmbientLoading = MutableStateFlow(false) + val isAmbientLoading: StateFlow = _isAmbientLoading.asStateFlow() + + private var lastAmbientScaleX = -1.0 + private var lastAmbientScaleY = -1.0 + private var ambientDebounceJob: Job? = null + private var ambientShaderSeq = 0 + private var ambientShaderFile: File? = null + + // ==================== Public API ==================== + + fun toggleAmbientMode() { + _isAmbientEnabled.value = !_isAmbientEnabled.value + + // Save the Ambient Mode ON/OFF state permanently to preferences + playerPreferences.isAmbientEnabled.set(_isAmbientEnabled.value) + if (_isAmbientEnabled.value) { + lastAmbientScaleX = -1.0 // Force rewrite + updateAmbientStretch() + onShowText("Ambience Mode: ON") + } else { + disableAmbientShader() + onShowText("Ambience Mode: OFF") + } + } + + /** Called when the device orientation changes. Refreshes ambient shader for new dimensions. */ + fun onOrientationChanged(isPortrait: Boolean) { + if (_isAmbientEnabled.value) { + // Force shader refresh to adapt to new screen dimensions + lastAmbientScaleX = -1.0 + lastAmbientScaleY = -1.0 + // Small delay to let the new OSD dimensions settle + ambientDebounceJob?.cancel() + ambientDebounceJob = scope.launch { + delay(200) + updateAmbientStretch() + } + } + } + + /** Resets ambient mode to OFF when a new video file is loaded. */ + fun resetAmbientMode() { + if (!_isAmbientEnabled.value) return + + // Ambient Mode Persistent Fix for Next/Previous files + // DO NOT set _isAmbientEnabled.value = false + // Just temporarily remove the old shader and reset the scale + // so the new video starts with a clean slate before recalculating. + disableAmbientShader() + lastAmbientScaleX = -1.0 + lastAmbientScaleY = -1.0 + } + + /** + * Re-injects the ambient shader if ambient mode is currently ON. + * Called after Anime4K shader changes, since setPropertyString("glsl-shaders", ...) + * wipes ALL glsl-shaders including the ambient one. + */ + fun restartAmbientIfActive() { + if (!_isAmbientEnabled.value) return + // The old ambient shader file was wiped by the glsl-shaders reset. + // Clean up our local reference without trying to remove from MPV. + ambientShaderFile?.delete() + ambientShaderFile = null + lastAmbientScaleX = -1.0 // Force rewrite + // Small delay to let Anime4K shaders settle + ambientDebounceJob?.cancel() + ambientDebounceJob = scope.launch { + delay(200) + updateAmbientStretch() + } + } + + + + fun updateAmbientStretch() { + if (!_isAmbientEnabled.value) return + + runCatching { + val osdW = MPVLib.getPropertyInt("osd-width") ?: 1920 + val osdH = MPVLib.getPropertyInt("osd-height") ?: 1080 + + // Portrait mode: ambient glow goes on top/bottom (letterbox) + // Landscape mode: ambient glow goes on left/right (pillarbox) + // Both are handled by the same scaleX/scaleY math below + + var vidW = (MPVLib.getPropertyInt("video-params/w") ?: 1920).toDouble() + var vidH = (MPVLib.getPropertyInt("video-params/h") ?: 1080).toDouble() + val par = MPVLib.getPropertyDouble("video-params/par") ?: 1.0 + val rot = MPVLib.getPropertyInt("video-params/rotate") ?: 0 + + // Intercept autocrop boundaries — if a crop is active, use the cropped dimensions + // so the shader's aspect-ratio math matches the actual visible video area + val crop = MPVLib.getPropertyString("video-crop") ?: "" + val cropMatch = Regex("""^(\d+)x(\d+)""").find(crop) + if (cropMatch != null) { + vidW = cropMatch.groupValues[1].toDouble() + vidH = cropMatch.groupValues[2].toDouble() + } + + if (osdW <= 0 || osdH <= 0 || vidW <= 0.0 || vidH <= 0.0) return + + // Apply pixel aspect ratio (non-square pixels) + vidW *= par + // Swap dimensions for 90°/270° rotated videos (portrait shot stored as landscape) + if (rot == 90 || rot == 270) { val tmp = vidW; vidW = vidH; vidH = tmp } + + val screenAr = osdW.toDouble() / osdH.toDouble() + val vidAr = vidW / vidH + + // Scale the video to fill the screen — the shader remaps it back to the + // correct aspect ratio, so only the "overflow" area receives ambient glow. + val scaleX = if (screenAr > vidAr) screenAr / vidAr else 1.0 + val scaleY = if (vidAr > screenAr) vidAr / screenAr else 1.0 + + if (Math.abs(scaleX - lastAmbientScaleX) > 0.001 || + Math.abs(scaleY - lastAmbientScaleY) > 0.001) { + lastAmbientScaleX = scaleX + lastAmbientScaleY = scaleY + MPVLib.setPropertyDouble("video-scale-x", scaleX) + MPVLib.setPropertyDouble("video-scale-y", scaleY) + } + + // ── Generate GLSL shader ─────────────────────────────────────────────── + val shaderCode = buildAmbientShader( + sx = lastAmbientScaleX, + sy = lastAmbientScaleY + ) + + // Each reload gets a unique filename so MPV never reuses a cached + // compiled shader — incrementing seq guarantees a fresh compile every time. + val newFile = File(cacheDir, "ambient_${++ambientShaderSeq}.glsl") + newFile.writeText(shaderCode) + ambientShaderFile?.let { oldFile -> + runCatching { MPVLib.command("change-list", "glsl-shaders", "remove", oldFile.absolutePath) } + oldFile.delete() + } + MPVLib.command("change-list", "glsl-shaders", "append", newFile.absolutePath) + ambientShaderFile = newFile + }.onFailure { e -> + Log.e(TAG, "Failed to update ambient stretch", e) + } + } + + // ==================== Private Methods ==================== + + /** Disables the ambient shader and resets video scale. Safe to call from any state. */ + private fun disableAmbientShader() { + ambientDebounceJob?.cancel() + ambientShaderFile?.let { file -> + runCatching { MPVLib.command("change-list", "glsl-shaders", "remove", file.absolutePath) } + file.delete() + } + ambientShaderFile = null + runCatching { + MPVLib.setPropertyDouble("video-scale-x", 1.0) + MPVLib.setPropertyDouble("video-scale-y", 1.0) + } + } + + /** + * Builds a simplified YouTube-style ambient GLSL shader. + * Samples random areas from entire video with temporal stability and debanding. + */ + private fun buildAmbientShader( + sx: Double, + sy: Double + ): String = """ +//!HOOK OUTPUT +//!BIND HOOKED +//!DESC YouTube-Style Ambient Mode + +#define SCALE_X $sx +#define SCALE_Y $sy + +// Simple hash function for pseudo-random sampling +float hash(vec2 p) { + return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); +} + +vec4 hook() { + vec2 uv = HOOKED_pos; + vec2 video_uv = (uv - 0.5) * vec2(SCALE_X, SCALE_Y) + 0.5; + + // Return video pixel if inside video bounds + if (video_uv.x >= 0.0 && video_uv.x <= 1.0 && + video_uv.y >= 0.0 && video_uv.y <= 1.0) { + return HOOKED_tex(video_uv); + } + + // Ambient region - sample random areas from entire video + // Use fixed seed for temporal stability (color doesn't flicker) + vec3 avg_color = vec3(0.0); + int samples = 20; + float base_seed = 42.0; // Fixed seed for stability + + // Sample random positions across the entire video + for (int i = 0; i < samples; i++) { + float seed = base_seed + float(i) * 0.618034; + float x = hash(vec2(seed, 0.123)); + float y = hash(vec2(seed, 0.456)); + + vec2 sample_pos = vec2(x, y); + avg_color += HOOKED_tex(sample_pos).rgb; + } + + avg_color /= float(samples); + + // Boost saturation slightly for more vibrant glow + float luma = dot(avg_color, vec3(0.2126, 0.7152, 0.0722)); + avg_color = mix(vec3(luma), avg_color, 1.3); // 30% saturation boost + + // Increased brightness for more visible glow (30% instead of 20%) + avg_color *= 0.30; + + // Smooth fade based on distance from video edge + vec2 edge_uv = clamp(video_uv, 0.0, 1.0); + float dist = length(video_uv - edge_uv); + float fade = exp(-dist * 2.5); + avg_color *= fade; + + // Debanding: add subtle dither noise to eliminate color banding + float dither = hash(uv * 1000.0) * 0.004 - 0.002; // ±0.002 range + avg_color = clamp(avg_color + dither, 0.0, 1.0); + + return vec4(avg_color, 1.0); +} + """.trimIndent() + + // ==================== Cleanup ==================== + + fun cleanup() { + ambientDebounceJob?.cancel() + ambientShaderFile?.delete() + ambientShaderFile = null + } +} diff --git a/app/src/main/java/app/marlboroadvance/mpvex/ui/player/MPVView.kt b/app/src/main/java/app/marlboroadvance/mpvex/ui/player/MPVView.kt index 89d4d4f74..73e69b89e 100644 --- a/app/src/main/java/app/marlboroadvance/mpvex/ui/player/MPVView.kt +++ b/app/src/main/java/app/marlboroadvance/mpvex/ui/player/MPVView.kt @@ -344,7 +344,8 @@ class MPVView( MPVLib.setOptionString("secondary-sub-border-style", borderStyle) MPVLib.setOptionString("secondary-sub-shadow-offset", shadowOffset) MPVLib.setOptionString("secondary-sub-scale", subScale) - MPVLib.setOptionString("secondary-sub-pos", subPos) + // Position secondary subtitle at top (10) instead of bottom to avoid overlap with primary + MPVLib.setOptionString("secondary-sub-pos", "10") val scaleByWindow = if (subtitlesPreferences.scaleByWindow.get()) "yes" else "no" MPVLib.setOptionString("sub-scale-by-window", scaleByWindow) @@ -361,9 +362,10 @@ class MPVView( return } - // DEFENSIVE CHECK: Ensure mutual exclusion at initialization time + // Anime4K requires the legacy GPU path unless gpu-next is running on Vulkan. val gpuNextActive = decoderPreferences.gpuNext.get() - if (gpuNextActive) { + val useVulkan = decoderPreferences.useVulkan.get() + if (gpuNextActive && !useVulkan) { return // Abort shader loading to prevent incompatible state } @@ -398,10 +400,12 @@ class MPVView( val shaderChain = anime4kManager.getShaderChain(mode, quality) if (shaderChain.isNotEmpty()) { - // GPU optimizations for better performance - MPVLib.setOptionString("opengl-pbo", "yes") + // OpenGL-only tuning should not be pushed onto the Vulkan backend. + if (!useVulkan) { + MPVLib.setOptionString("opengl-pbo", "yes") + MPVLib.setOptionString("opengl-early-flush", "no") + } MPVLib.setOptionString("vd-lavc-dr", "yes") - MPVLib.setOptionString("opengl-early-flush", "no") // Apply shaders (MUST use setOptionString in initOptions!) MPVLib.setOptionString("glsl-shaders", shaderChain) diff --git a/app/src/main/java/app/marlboroadvance/mpvex/ui/player/PlayerEnums.kt b/app/src/main/java/app/marlboroadvance/mpvex/ui/player/PlayerEnums.kt index 3548096ac..8441e7919 100644 --- a/app/src/main/java/app/marlboroadvance/mpvex/ui/player/PlayerEnums.kt +++ b/app/src/main/java/app/marlboroadvance/mpvex/ui/player/PlayerEnums.kt @@ -101,7 +101,6 @@ enum class Sheets { VideoZoom, AspectRatios, Playlist, - AmbientConfig, FrameNavigation, } diff --git a/app/src/main/java/app/marlboroadvance/mpvex/ui/player/PlayerViewModel.kt b/app/src/main/java/app/marlboroadvance/mpvex/ui/player/PlayerViewModel.kt index 6a26ac85c..a81782975 100644 --- a/app/src/main/java/app/marlboroadvance/mpvex/ui/player/PlayerViewModel.kt +++ b/app/src/main/java/app/marlboroadvance/mpvex/ui/player/PlayerViewModel.kt @@ -38,13 +38,11 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update -import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.sync.Mutex @@ -52,9 +50,7 @@ import kotlinx.coroutines.sync.withLock import kotlinx.serialization.json.Json import org.koin.core.component.KoinComponent import org.koin.core.component.inject -import app.marlboroadvance.mpvex.ui.preferences.CustomButton import java.io.File -import androidx.core.net.toUri import androidx.documentfile.provider.DocumentFile import app.marlboroadvance.mpvex.preferences.AdvancedPreferences import kotlin.properties.ReadOnlyProperty @@ -185,6 +181,15 @@ class PlayerViewModel( val dur = MPVLib.getPropertyDouble("duration") if (dur != null && dur > 0) { _preciseDuration.value = dur.toFloat() + + // --- AMBIENT FIX: Adapt shader to new file dimensions --- + ambientModeManager.resetAmbientMode() + viewModelScope.launch { + // Slight delay ensures MPV's video-params (w/h/crop) are fully populated + delay(250) + ambientModeManager.updateAmbientStretch() + } + // -------------------------------------------------------- } } } @@ -316,44 +321,16 @@ class PlayerViewModel( val isVerticalFlipped: StateFlow = _isVerticalFlipped.asStateFlow() // ==================== Ambience Mode ====================================== - private val _isAmbientEnabled = MutableStateFlow(false) - val isAmbientEnabled: StateFlow = _isAmbientEnabled.asStateFlow() - - private val _ambientBlurSamples = MutableStateFlow(playerPreferences.ambientBlurSamples.get()) - val ambientBlurSamples: StateFlow = _ambientBlurSamples.asStateFlow() - - private val _ambientMaxRadius = MutableStateFlow(playerPreferences.ambientMaxRadius.get()) - val ambientMaxRadius: StateFlow = _ambientMaxRadius.asStateFlow() - - private val _ambientGlowIntensity = MutableStateFlow(playerPreferences.ambientGlowIntensity.get()) - val ambientGlowIntensity: StateFlow = _ambientGlowIntensity.asStateFlow() - - private val _ambientSatBoost = MutableStateFlow(playerPreferences.ambientSatBoost.get()) - val ambientSatBoost: StateFlow = _ambientSatBoost.asStateFlow() - - private val _ambientDitherNoise = MutableStateFlow(playerPreferences.ambientDitherNoise.get()) - val ambientDitherNoise: StateFlow = _ambientDitherNoise.asStateFlow() - - private val _ambientBezelDepth = MutableStateFlow(playerPreferences.ambientBezelDepth.get()) - val ambientBezelDepth: StateFlow = _ambientBezelDepth.asStateFlow() - - private val _ambientVignetteStrength = MutableStateFlow(playerPreferences.ambientVignetteStrength.get()) - val ambientVignetteStrength: StateFlow = _ambientVignetteStrength.asStateFlow() - - private val _ambientWarmth = MutableStateFlow(playerPreferences.ambientWarmth.get()) - val ambientWarmth: StateFlow = _ambientWarmth.asStateFlow() - - private val _ambientFadeCurve = MutableStateFlow(playerPreferences.ambientFadeCurve.get()) - val ambientFadeCurve: StateFlow = _ambientFadeCurve.asStateFlow() - - private val _ambientOpacity = MutableStateFlow(playerPreferences.ambientOpacity.get()) - val ambientOpacity: StateFlow = _ambientOpacity.asStateFlow() + // Ambient mode manager handles all ambient mode functionality + private val ambientModeManager = AmbientModeManager( + playerPreferences = playerPreferences, + cacheDir = host.context.cacheDir, + scope = viewModelScope, + onShowText = { text -> playerUpdate.value = PlayerUpdates.ShowText(text) } + ) - private var lastAmbientScaleX = -1.0 - private var lastAmbientScaleY = -1.0 - private var ambientDebounceJob: kotlinx.coroutines.Job? = null - private var ambientShaderSeq = 0 - private var ambientShaderFile: java.io.File? = null + // Expose ambient mode state through the manager + val isAmbientEnabled: StateFlow = ambientModeManager.isAmbientEnabled init { // Track selection is now handled by TrackSelector in PlayerActivity @@ -787,6 +764,33 @@ class PlayerViewModel( _externalSubtitles.clear() // Scan for previously downloaded/added subtitles scanLocalSubtitles(mediaTitle) + + // --- ADDED: Reset visual states for the new file for Ambient Mode Function by @Chinna95P --- + + // 1. Reset Aspect Ratio UI state and MPV properties to "Fit" + _videoAspect.value = VideoAspect.Fit + _currentAspectRatio.value = -1.0 + runCatching { + MPVLib.setPropertyDouble("panscan", 0.0) + MPVLib.setPropertyDouble("video-aspect-override", -1.0) + } + + // 2. Reset Video Zoom + if (_videoZoom.value != 0f) { + _videoZoom.value = 0f + runCatching { MPVLib.setPropertyDouble("video-zoom", 0.0) } + } + + // 3. Reset Video Pan + if (_videoPanX.value != 0f || _videoPanY.value != 0f) { + _videoPanX.value = 0f + _videoPanY.value = 0f + runCatching { + MPVLib.setPropertyDouble("video-pan-x", 0.0) + MPVLib.setPropertyDouble("video-pan-y", 0.0) + } + } + // --------------------------------------------------- } } @@ -2150,430 +2154,26 @@ class PlayerViewModel( // ==================== Ambient Mode Integration ==================== - fun toggleAmbientMode() { - _isAmbientEnabled.value = !_isAmbientEnabled.value - if (_isAmbientEnabled.value) { - lastAmbientScaleX = -1.0 // Force rewrite - updateAmbientStretch() - playerUpdate.value = PlayerUpdates.ShowText("Ambience Mode: ON") - } else { - disableAmbientShader() - playerUpdate.value = PlayerUpdates.ShowText("Ambience Mode: OFF") - } - } + fun toggleAmbientMode() = ambientModeManager.toggleAmbientMode() - /** Disables the ambient shader and resets video scale. Safe to call from any state. */ - private fun disableAmbientShader() { - ambientDebounceJob?.cancel() - ambientShaderFile?.let { file -> - runCatching { MPVLib.command("change-list", "glsl-shaders", "remove", file.absolutePath) } - file.delete() - } - ambientShaderFile = null - runCatching { - MPVLib.setPropertyDouble("video-scale-x", 1.0) - MPVLib.setPropertyDouble("video-scale-y", 1.0) - } - } + fun onOrientationChanged(isPortrait: Boolean) = ambientModeManager.onOrientationChanged(isPortrait) - /** Called when the device orientation changes. Refreshes ambient shader for new dimensions. */ - fun onOrientationChanged(isPortrait: Boolean) { - if (_isAmbientEnabled.value) { - // Force shader refresh to adapt to new screen dimensions - lastAmbientScaleX = -1.0 - lastAmbientScaleY = -1.0 - // Small delay to let the new OSD dimensions settle - ambientDebounceJob?.cancel() - ambientDebounceJob = viewModelScope.launch { - delay(200) - updateAmbientStretch() - } - } - } - - /** Resets ambient mode to OFF when a new video file is loaded. */ - fun resetAmbientMode() { - if (!_isAmbientEnabled.value) return - _isAmbientEnabled.value = false - disableAmbientShader() - } - - /** - * Re-injects the ambient shader if ambient mode is currently ON. - * Called after Anime4K shader changes, since setPropertyString("glsl-shaders", ...) - * wipes ALL glsl-shaders including the ambient one. - */ - fun restartAmbientIfActive() { - if (!_isAmbientEnabled.value) return - // The old ambient shader file was wiped by the glsl-shaders reset. - // Clean up our local reference without trying to remove from MPV. - ambientShaderFile?.delete() - ambientShaderFile = null - lastAmbientScaleX = -1.0 // Force rewrite - // Small delay to let Anime4K shaders settle - ambientDebounceJob?.cancel() - ambientDebounceJob = viewModelScope.launch { - delay(200) - updateAmbientStretch() - } - } - - fun updateAmbientParams( - blurSamples: Int = _ambientBlurSamples.value, - maxRadius: Float = _ambientMaxRadius.value, - glowIntensity: Float = _ambientGlowIntensity.value, - satBoost: Float = _ambientSatBoost.value, - ditherNoise: Float = _ambientDitherNoise.value, - bezelDepth: Float = _ambientBezelDepth.value, - vignetteStrength: Float = _ambientVignetteStrength.value, - warmth: Float = _ambientWarmth.value, - fadeCurve: Float = _ambientFadeCurve.value, - opacity: Float = _ambientOpacity.value - ) { - _ambientBlurSamples.value = blurSamples - _ambientMaxRadius.value = maxRadius - _ambientGlowIntensity.value = glowIntensity - _ambientSatBoost.value = satBoost - _ambientDitherNoise.value = ditherNoise - _ambientBezelDepth.value = bezelDepth - _ambientVignetteStrength.value = vignetteStrength - _ambientWarmth.value = warmth - _ambientFadeCurve.value = fadeCurve - _ambientOpacity.value = opacity - - // Persist to preferences - playerPreferences.ambientBlurSamples.set(blurSamples) - playerPreferences.ambientMaxRadius.set(maxRadius) - playerPreferences.ambientGlowIntensity.set(glowIntensity) - playerPreferences.ambientSatBoost.set(satBoost) - playerPreferences.ambientDitherNoise.set(ditherNoise) - playerPreferences.ambientBezelDepth.set(bezelDepth) - playerPreferences.ambientVignetteStrength.set(vignetteStrength) - playerPreferences.ambientWarmth.set(warmth) - playerPreferences.ambientFadeCurve.set(fadeCurve) - playerPreferences.ambientOpacity.set(opacity) - - // Debounce shader re-injection to avoid excessive GPU reloads - if (_isAmbientEnabled.value) { - ambientDebounceJob?.cancel() - ambientDebounceJob = viewModelScope.launch { - delay(150) - updateAmbientStretch() - } - } - } - - /** Fast profile — low GPU cost, still visually solid. */ - fun applyAmbientProfileFast() { - updateAmbientParams( - blurSamples = 16, maxRadius = 0.22f, glowIntensity = 1.4f, - satBoost = 1.2f, ditherNoise = 0.0f, bezelDepth = 0.0f, - vignetteStrength = 0.4f, warmth = 0.0f, fadeCurve = 1.6f, opacity = 1.0f - ) - } + fun resetAmbientMode() = ambientModeManager.resetAmbientMode() - /** Balanced profile — good quality/performance trade-off for most devices. */ - fun applyAmbientProfileBalanced() { - updateAmbientParams( - blurSamples = 24, maxRadius = 0.28f, glowIntensity = 1.45f, - satBoost = 1.25f, ditherNoise = 0.0f, bezelDepth = 0.0f, - vignetteStrength = 0.55f, warmth = 0.0f, fadeCurve = 1.7f, opacity = 1.0f - ) - } - - /** High Quality profile — maximum visual fidelity for high-end devices. */ - fun applyAmbientProfileHighQuality() { - updateAmbientParams( - blurSamples = 48, maxRadius = 0.35f, glowIntensity = 1.5f, - satBoost = 1.3f, ditherNoise = 0.0f, bezelDepth = 0.0f, - vignetteStrength = 0.7f, warmth = 0.0f, fadeCurve = 1.8f, opacity = 1.0f - ) - } - - fun updateAmbientStretch() { - if (!_isAmbientEnabled.value) return - - runCatching { - val osdW = MPVLib.getPropertyInt("osd-width") ?: 1920 - val osdH = MPVLib.getPropertyInt("osd-height") ?: 1080 - - // Portrait mode: ambient glow goes on top/bottom (letterbox) - // Landscape mode: ambient glow goes on left/right (pillarbox) - // Both are handled by the same scaleX/scaleY math below + fun restartAmbientIfActive() = ambientModeManager.restartAmbientIfActive() - var vidW = (MPVLib.getPropertyInt("video-params/w") ?: 1920).toDouble() - var vidH = (MPVLib.getPropertyInt("video-params/h") ?: 1080).toDouble() - val par = MPVLib.getPropertyDouble("video-params/par") ?: 1.0 - val rot = MPVLib.getPropertyInt("video-params/rotate") ?: 0 - - // Intercept autocrop boundaries — if a crop is active, use the cropped dimensions - // so the shader's aspect-ratio math matches the actual visible video area - val crop = MPVLib.getPropertyString("video-crop") ?: "" - val cropMatch = Regex("""^(\d+)x(\d+)""").find(crop) - if (cropMatch != null) { - vidW = cropMatch.groupValues[1].toDouble() - vidH = cropMatch.groupValues[2].toDouble() - } - - if (osdW <= 0 || osdH <= 0 || vidW <= 0.0 || vidH <= 0.0) return - - // Apply pixel aspect ratio (non-square pixels) - vidW *= par - // Swap dimensions for 90°/270° rotated videos (portrait shot stored as landscape) - if (rot == 90 || rot == 270) { val tmp = vidW; vidW = vidH; vidH = tmp } - - val screenAr = osdW.toDouble() / osdH.toDouble() - val vidAr = vidW / vidH - - // Scale the video to fill the screen — the shader remaps it back to the - // correct aspect ratio, so only the "overflow" area receives ambient glow. - val scaleX = if (screenAr > vidAr) screenAr / vidAr else 1.0 - val scaleY = if (vidAr > screenAr) vidAr / screenAr else 1.0 - - if (Math.abs(scaleX - lastAmbientScaleX) > 0.001 || - Math.abs(scaleY - lastAmbientScaleY) > 0.001) { - lastAmbientScaleX = scaleX - lastAmbientScaleY = scaleY - MPVLib.setPropertyDouble("video-scale-x", scaleX) - MPVLib.setPropertyDouble("video-scale-y", scaleY) - } - - // ── Snapshot current parameter values ───────────────────────────────── - val sx = lastAmbientScaleX - val sy = lastAmbientScaleY - val samples = _ambientBlurSamples.value - val radius = _ambientMaxRadius.value - val glow = _ambientGlowIntensity.value - val sat = _ambientSatBoost.value - val dither = _ambientDitherNoise.value - val bezel = _ambientBezelDepth.value - val vignette= _ambientVignetteStrength.value - val warmth = _ambientWarmth.value - val curve = _ambientFadeCurve.value - val opacity = _ambientOpacity.value - - // ── Generate GLSL shader ─────────────────────────────────────────────── - val shaderCode = buildAmbientShader( - sx = sx, sy = sy, - blurSamples = samples, maxRadius = radius, - glowIntensity = glow, satBoost = sat, - ditherNoise = dither, bezelDepth = bezel, - vignetteStrength = vignette, warmth = warmth, - fadeCurve = curve, opacity = opacity - ) - - // Each reload gets a unique filename so MPV never reuses a cached - // compiled shader — incrementing seq guarantees a fresh compile every time. - val newFile = File(host.context.cacheDir, "ambient_${++ambientShaderSeq}.glsl") - newFile.writeText(shaderCode) - ambientShaderFile?.let { oldFile -> - runCatching { MPVLib.command("change-list", "glsl-shaders", "remove", oldFile.absolutePath) } - oldFile.delete() - } - MPVLib.command("change-list", "glsl-shaders", "append", newFile.absolutePath) - ambientShaderFile = newFile - }.onFailure { e -> - Log.e(TAG, "Failed to update ambient stretch", e) - } - } - - /** - * Builds the True Ambient GLSL shader string with all parameters baked in - * as `#define` constants. The shader: - * 1. Detects the video region using aspect-ratio correction (SCALE_X/Y). - * 2. For interior pixels — returns the original (unscaled) video pixel. - * 3. For ambient pixels — samples the nearest video-edge with a - * Fibonacci-spiral blur kernel and composites the glowing result. - */ - private fun buildAmbientShader( - sx: Double, sy: Double, - blurSamples: Int, maxRadius: Float, - glowIntensity: Float, satBoost: Float, - ditherNoise: Float, bezelDepth: Float, - vignetteStrength: Float, warmth: Float, - fadeCurve: Float, opacity: Float - ): String = """ -//!HOOK OUTPUT -//!BIND HOOKED -//!DESC True Ambient Mode - -// ───────────────────────────────────────────────────────────────────────────── -// CONFIGURATION (all values injected at runtime — do not hand-edit) -// ───────────────────────────────────────────────────────────────────────────── - -// Blur quality: number of spiral samples (higher = smoother, more GPU cost) -#define BLUR_SAMPLES $blurSamples - -// Maximum blur spread radius in normalised UV coordinates -#define MAX_RADIUS $maxRadius - -// Ambient brightness multiplier (1.0 = neutral) -#define GLOW_INTENSITY $glowIntensity - -// Saturation boost applied to the ambient glow (1.0 = neutral) -#define SAT_BOOST $satBoost - -// Width of the soft blend zone at the video edge (0 = hard cut) -#define BEZEL_DEPTH $bezelDepth - -// Anti-banding dither noise amplitude -#define DITHER_NOISE $ditherNoise - -// Corner vignette strength (0.0 = none, 1.0 = full darkening) -#define VIGNETTE_STR $vignetteStrength - -// Color temperature shift (-1.0 = cooler/blue, 0.0 = neutral, +1.0 = warmer/orange) -#define WARMTH $warmth - -// Distance falloff power (1.0 = linear, 2.0 = quadratic, higher = tighter glow) -#define FADE_CURVE $fadeCurve - -// Overall ambient opacity multiplier -#define OPACITY $opacity - -// Aspect-ratio correction factors derived from video / screen dimensions -#define SCALE_X $sx -#define SCALE_Y $sy - -// ───────────────────────────────────────────────────────────────────────────── -// CONSTANTS -// ───────────────────────────────────────────────────────────────────────────── - -const float PI = 3.14159265358979; -const float PHI = 1.61803398874989; // Golden ratio — drives Fibonacci spiral - -// ───────────────────────────────────────────────────────────────────────────── -// UTILITY FUNCTIONS -// ───────────────────────────────────────────────────────────────────────────── - -// Hash-based pseudo-random scalar in [0, 1] -float rand(vec2 seed) { - return fract(sin(dot(seed, vec2(12.9898, 78.233))) * 43758.5453); -} - -// BT.709 perceptual luminance -float luma(vec3 rgb) { - return dot(rgb, vec3(0.2126, 0.7152, 0.0722)); -} - -// Luma-preserving saturation adjustment -vec3 adjust_saturation(vec3 rgb, float amount) { - return mix(vec3(luma(rgb)), rgb, amount); -} - -// Kelvin-style warm / cool color temperature shift -vec3 apply_warmth(vec3 rgb, float amount) { - rgb.r = clamp(rgb.r + amount * 0.060, 0.0, 1.0); - rgb.g = clamp(rgb.g + amount * 0.025, 0.0, 1.0); - rgb.b = clamp(rgb.b - amount * 0.080, 0.0, 1.0); - return rgb; -} - -// ───────────────────────────────────────────────────────────────────────────── -// MAIN HOOK -// ───────────────────────────────────────────────────────────────────────────── - -vec4 hook() { - // Current pixel position in normalised screen space [0, 1] - vec2 uv = HOOKED_pos; - - // Remap screen UV → original (pre-scale) video UV space. - // Pixels outside [0, 1] × [0, 1] are in the letterbox / pillarbox region. - vec2 video_uv = (uv - 0.5) * vec2(SCALE_X, SCALE_Y) + 0.5; - - // ── Hard boundary: return video pixel directly — zero ambient bleeds in ── - if (video_uv.x >= 0.0 && video_uv.x <= 1.0 && - video_uv.y >= 0.0 && video_uv.y <= 1.0) { - return HOOKED_tex(video_uv); - } - - // ── Ambient region: compute edge-directed glow ──────────────────────────── - - // Nearest point on the video border (clamped to [0, 1]) - vec2 edge_origin = clamp(video_uv, 0.0, 1.0); - - // Euclidean distance from this pixel to the video edge (used for fade-out). - // Constant 3.0 gives ~22 % brightness at half-radius and ~5 % at full-radius — - // visible glow across the whole pillarbox / letterbox area. - float edge_dist = length(video_uv - edge_origin); - float edge_fade = exp(-edge_dist * (3.0 / max(MAX_RADIUS, 0.001))); - - // Per-pixel rotation jitter to avoid banding in the spiral pattern - float jitter = rand(uv * HOOKED_size) * (PI * 2.0); - float angle_inc = PI * 2.0 / (PHI * PHI); // ~2.399 rad — golden angle - float inv_n = 1.0 / float(BLUR_SAMPLES); - - // Aspect correction keeps the blur kernel circular in screen space - vec2 aspect_fix = vec2(HOOKED_size.y / HOOKED_size.x, 1.0); - - vec3 acc_color = vec3(0.0); - float acc_weight = 0.0; - - // ── Fibonacci-spiral blur kernel ────────────────────────────────────────── - for (int i = 0; i < BLUR_SAMPLES; i++) { - float fi = float(i) + 0.5; - float r = sqrt(fi * inv_n) * MAX_RADIUS; - float theta = fi * angle_inc + jitter; - - // Sample from the nearest video-edge origin, spreading outward. - // This ensures ambient colours come from the actual video edge. - vec2 offset = vec2(cos(theta), sin(theta)) * r * aspect_fix; - vec2 sample_uv = clamp(edge_origin + offset, 0.0, 1.0); - vec3 sample_rgb = HOOKED_tex(sample_uv).rgb; - - // Weight = distance falloff × luminance bloom - // (brighter video pixels contribute more to the glow) - float dist_w = pow(max(1.0 / (1.0 + r * 40.0), 0.0), FADE_CURVE); - float luma_w = 1.0 + luma(sample_rgb) * 2.0; - float w = dist_w * luma_w; - - acc_color += sample_rgb * w; - acc_weight += w; - } - - // Normalise and apply global brightness - vec3 glow = (acc_color / max(acc_weight, 1e-5)) * GLOW_INTENSITY; - - // ── Post-processing ─────────────────────────────────────────────────────── - - glow = adjust_saturation(glow, SAT_BOOST); - glow = apply_warmth(glow, WARMTH); - - // Fade the glow out as distance from the video edge increases - glow *= edge_fade; - - // Radial vignette: corners receive less ambient light - float vig_r = length(uv - 0.5) * 2.0; - glow *= mix(1.0, smoothstep(1.3, 0.1, vig_r), VIGNETTE_STR); - - // Dither noise — breaks up colour banding in the gradient - float noise = rand(uv + vec2(fract(uv.x * 127.1), fract(uv.y * 311.7))); - glow = clamp(glow + DITHER_NOISE * (noise - 0.5), 0.0, 1.0); - - // ── Compositing ─────────────────────────────────────────────────────────── - // Bezel blend: transition from the nearest video-edge pixel outward into - // the ambient glow. BEZEL_DEPTH is in video-UV units; the blend lives - // entirely in the ambient region so no glow ever bleeds into the video. - float bezel = max(BEZEL_DEPTH, 0.001); - vec2 outside_dist = max(max(-video_uv, video_uv - vec2(1.0)), vec2(0.0)); - float dist_to_edge = max(outside_dist.x, outside_dist.y); - float bezel_alpha = smoothstep(0.0, bezel, dist_to_edge); - - // At dist=0 (right at edge): show the video edge pixel → seamless join. - // At dist≥bezel: show full ambient glow. - // OPACITY scales only rgb; alpha stays 1.0 so the output stays opaque. - vec4 edge_pixel = HOOKED_tex(edge_origin); - vec4 ambient_out = vec4(glow * OPACITY, 1.0); - - return mix(edge_pixel, ambient_out, bezel_alpha); -} - """.trimIndent() + fun updateAmbientStretch() = ambientModeManager.updateAmbientStretch() // ==================== Utility ==================== fun showToast(message: String) { Toast.makeText(host.context, message, Toast.LENGTH_SHORT).show() } + + override fun onCleared() { + super.onCleared() + ambientModeManager.cleanup() + } } // Extension functions diff --git a/app/src/main/java/app/marlboroadvance/mpvex/ui/player/controls/PlayerControls.kt b/app/src/main/java/app/marlboroadvance/mpvex/ui/player/controls/PlayerControls.kt index 27d1edc83..a14d0d046 100644 --- a/app/src/main/java/app/marlboroadvance/mpvex/ui/player/controls/PlayerControls.kt +++ b/app/src/main/java/app/marlboroadvance/mpvex/ui/player/controls/PlayerControls.kt @@ -1,5 +1,9 @@ package app.marlboroadvance.mpvex.ui.player.controls +import androidx.compose.ui.platform.LocalContext +import com.kyant.backdrop.backdrops.rememberLayerBackdrop +import app.marlboroadvance.mpvex.preferences.LiquidUIPreferences +import app.marlboroadvance.mpvex.ui.components.liquid.TransparentLiquidButton import androidx.activity.compose.LocalActivity import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.FastOutSlowInEasing @@ -158,6 +162,14 @@ fun PlayerControls( ) { val spacing = MaterialTheme.spacing val appearancePreferences = koinInject() + val context = LocalContext.current + val liquidUIPreferences = remember { LiquidUIPreferences(context) } + // Wired directly to the DataStore, defaults to false until toggled + val liquidUIEnabled by liquidUIPreferences.liquidUIEnabledFlow.collectAsState(initial = false) + + // The engine that captures the screen for the blur/lens effects + val backdrop = rememberLayerBackdrop { drawContent() } + val hideBackground by appearancePreferences.hidePlayerButtonsBackground.collectAsState() val playerPreferences = koinInject() val audioPreferences = koinInject() @@ -262,7 +274,7 @@ fun PlayerControls( label = "controls_transparent_overlay", ) - GestureHandler( + GestureHandler( viewModel = viewModel, interactionSource = interactionSource, ) @@ -273,6 +285,8 @@ fun PlayerControls( LocalRippleConfiguration provides playerRippleConfiguration, LocalPlayerButtonsClickEvent provides { resetControlsTimestamp = System.currentTimeMillis() }, LocalContentColor provides Color.White, + // THE CLEAN BROADCAST TOWER! + app.marlboroadvance.mpvex.ui.components.liquid.LocalLiquidBackdrop provides (if (liquidUIEnabled) backdrop else null), ) { CompositionLocalProvider( LocalLayoutDirection provides LayoutDirection.Ltr, @@ -617,37 +631,8 @@ fun PlayerControls( .horizontalScroll(rememberScrollState()) ) { customButtons.filter { it.isLeft }.forEach { button -> - val buttonInteractionSource = remember { MutableInteractionSource() } - Surface( - shape = CircleShape, - color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.85f), - contentColor = MaterialTheme.colorScheme.onSecondaryContainer, - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.1f)), - modifier = Modifier - .clip(CircleShape) - .combinedClickable( - interactionSource = buttonInteractionSource, - indication = ripple(), - onClick = { - resetControlsTimestamp = System.currentTimeMillis() - viewModel.callCustomButton(button.id) - }, - onLongClick = { - haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) - resetControlsTimestamp = System.currentTimeMillis() - viewModel.callCustomButtonLongPress(button.id) - } - ) - ) { - Text( - text = button.label, - modifier = Modifier - .padding(horizontal = 12.dp, vertical = 6.dp) - .basicMarquee(), - style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.Bold), - maxLines = 1, - softWrap = false - ) + LiquidCustomButton(button, liquidUIEnabled, backdrop, viewModel, haptic) { + resetControlsTimestamp = System.currentTimeMillis() } } } @@ -674,37 +659,8 @@ fun PlayerControls( .horizontalScroll(rememberScrollState(), reverseScrolling = true) ) { customButtons.filter { !it.isLeft }.forEach { button -> - val buttonInteractionSource = remember { MutableInteractionSource() } - Surface( - shape = CircleShape, - color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.85f), - contentColor = MaterialTheme.colorScheme.onSecondaryContainer, - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.1f)), - modifier = Modifier - .clip(CircleShape) - .combinedClickable( - interactionSource = buttonInteractionSource, - indication = ripple(), - onClick = { - resetControlsTimestamp = System.currentTimeMillis() - viewModel.callCustomButton(button.id) - }, - onLongClick = { - haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) - resetControlsTimestamp = System.currentTimeMillis() - viewModel.callCustomButtonLongPress(button.id) - } - ) - ) { - Text( - text = button.label, - modifier = Modifier - .padding(horizontal = 12.dp, vertical = 6.dp) - .basicMarquee(), - style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.Bold), - maxLines = 1, - softWrap = false - ) + LiquidCustomButton(button, liquidUIEnabled, backdrop, viewModel, haptic) { + resetControlsTimestamp = System.currentTimeMillis() } } } @@ -730,37 +686,8 @@ fun PlayerControls( .horizontalScroll(rememberScrollState()) ) { customButtons.forEach { button -> - val buttonInteractionSource = remember { MutableInteractionSource() } - Surface( - shape = CircleShape, - color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.85f), - contentColor = MaterialTheme.colorScheme.onSecondaryContainer, - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.1f)), - modifier = Modifier - .clip(CircleShape) - .combinedClickable( - interactionSource = buttonInteractionSource, - indication = ripple(), - onClick = { - resetControlsTimestamp = System.currentTimeMillis() - viewModel.callCustomButton(button.id) - }, - onLongClick = { - haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) - resetControlsTimestamp = System.currentTimeMillis() - viewModel.callCustomButtonLongPress(button.id) - } - ) - ) { - Text( - text = button.label, - modifier = Modifier - .padding(horizontal = 12.dp, vertical = 6.dp) - .basicMarquee(), - style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.Bold), - maxLines = 1, - softWrap = false - ) + LiquidCustomButton(button, liquidUIEnabled, backdrop, viewModel, haptic) { + resetControlsTimestamp = System.currentTimeMillis() } } } @@ -811,116 +738,190 @@ fun PlayerControls( ) } - else -> { - val buttonShadow = - Brush.radialGradient( - 0.0f to Color.Black.copy(alpha = 0.3f), - 0.7f to Color.Transparent, - 1.0f to Color.Transparent, - ) - - if (playlistMode && viewModel.hasPlaylistSupport()) { - androidx.compose.foundation.layout.Row( - horizontalArrangement = Arrangement.spacedBy(24.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Surface( - modifier = - Modifier - .size(56.dp) - .clip(CircleShape) - .clickable( - enabled = viewModel.hasPrevious(), - onClick = { - resetControlsTimestamp = System.currentTimeMillis() - if (viewModel.hasPrevious()) viewModel.playPrevious() - }, - ) - .then( - if (hideBackground) { - Modifier.background(brush = buttonShadow, shape = CircleShape) - } else { - Modifier - }, - ), - shape = CircleShape, - color = - if (!hideBackground) { - MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.55f) - } else { - Color.Transparent - }, - contentColor = MaterialTheme.colorScheme.onSurface, - tonalElevation = 0.dp, - shadowElevation = 0.dp, - border = - if (!hideBackground) { - BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)) - } else { - null - }, + else -> { + if (liquidUIEnabled) { + // --- LIQUID GLASS UI --- + if (playlistMode && viewModel.hasPlaylistSupport()) { + androidx.compose.foundation.layout.Row( + horizontalArrangement = Arrangement.spacedBy(24.dp), + verticalAlignment = Alignment.CenterVertically, ) { - Icon( - imageVector = Icons.Default.SkipPrevious, - contentDescription = "Previous", - tint = - if (viewModel.hasPrevious()) { - if (hideBackground) controlColor else MaterialTheme.colorScheme.onSurface - } else { - if (hideBackground) { - controlColor.copy(alpha = 0.38f) - } else { - MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) - } - }, - modifier = Modifier - .fillMaxSize() - .padding(MaterialTheme.spacing.small), - ) - } + TransparentLiquidButton( + modifier = Modifier.size(56.dp), + backdrop = backdrop, + onClick = { + resetControlsTimestamp = System.currentTimeMillis() + if (viewModel.hasPrevious()) viewModel.playPrevious() + } + ) { + Icon( + imageVector = Icons.Default.SkipPrevious, + contentDescription = "Previous", + tint = if (viewModel.hasPrevious()) Color.White else Color.White.copy(alpha = 0.38f), + modifier = Modifier.fillMaxSize().padding(MaterialTheme.spacing.small) + ) + } - Surface( - modifier = - Modifier - .size(64.dp) - .clip(CircleShape) - .clickable(interaction, ripple(), onClick = { - resetControlsTimestamp = System.currentTimeMillis() - viewModel.pauseUnpause() - }) - .then( - if (hideBackground) { - Modifier.background(brush = buttonShadow, shape = CircleShape) - } else { - Modifier - }, - ), - shape = CircleShape, - color = - if (!hideBackground) { - MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.55f) - } else { - Color.Transparent - }, - contentColor = if (hideBackground) controlColor else MaterialTheme.colorScheme.onSurface, - tonalElevation = 0.dp, - shadowElevation = 0.dp, - border = - if (!hideBackground) { - BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)) - } else { - null - }, + TransparentLiquidButton( + modifier = Modifier.size(64.dp), + backdrop = backdrop, + onClick = { + resetControlsTimestamp = System.currentTimeMillis() + viewModel.pauseUnpause() + } + ) { + Image( + painter = rememberAnimatedVectorPainter(icon, paused == false), + modifier = Modifier.fillMaxSize().padding(MaterialTheme.spacing.medium), + contentDescription = "Play/Pause", + colorFilter = ColorFilter.tint(Color.White) + ) + } + + TransparentLiquidButton( + modifier = Modifier.size(56.dp), + backdrop = backdrop, + onClick = { + resetControlsTimestamp = System.currentTimeMillis() + if (viewModel.hasNext()) viewModel.playNext() + } + ) { + Icon( + imageVector = Icons.Default.SkipNext, + contentDescription = "Next", + tint = if (viewModel.hasNext()) Color.White else Color.White.copy(alpha = 0.38f), + modifier = Modifier.fillMaxSize().padding(MaterialTheme.spacing.small) + ) + } + } + } else { + TransparentLiquidButton( + modifier = Modifier.size(64.dp), + backdrop = backdrop, + onClick = { + resetControlsTimestamp = System.currentTimeMillis() + viewModel.pauseUnpause() + } ) { Image( painter = rememberAnimatedVectorPainter(icon, paused == false), - modifier = Modifier - .fillMaxSize() - .padding(MaterialTheme.spacing.medium), - contentDescription = null, - colorFilter = ColorFilter.tint(LocalContentColor.current), + modifier = Modifier.fillMaxSize().padding(MaterialTheme.spacing.medium), + contentDescription = "Play/Pause", + colorFilter = ColorFilter.tint(Color.White) ) } + } + } else { + // --- STANDARD FALLBACK UI (EXACTLY AS IT WAS) --- + val buttonShadow = + Brush.radialGradient( + 0.0f to Color.Black.copy(alpha = 0.3f), + 0.7f to Color.Transparent, + 1.0f to Color.Transparent, + ) + + if (playlistMode && viewModel.hasPlaylistSupport()) { + androidx.compose.foundation.layout.Row( + horizontalArrangement = Arrangement.spacedBy(24.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Surface( + modifier = + Modifier + .size(56.dp) + .clip(CircleShape) + .clickable( + enabled = viewModel.hasPrevious(), + onClick = { + resetControlsTimestamp = System.currentTimeMillis() + if (viewModel.hasPrevious()) viewModel.playPrevious() + }, + ) + .then( + if (hideBackground) { + Modifier.background(brush = buttonShadow, shape = CircleShape) + } else { + Modifier + }, + ), + shape = CircleShape, + color = + if (!hideBackground) { + MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.55f) + } else { + Color.Transparent + }, + contentColor = MaterialTheme.colorScheme.onSurface, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + border = + if (!hideBackground) { + BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)) + } else { + null + }, + ) { + Icon( + imageVector = Icons.Default.SkipPrevious, + contentDescription = "Previous", + tint = + if (viewModel.hasPrevious()) { + if (hideBackground) controlColor else MaterialTheme.colorScheme.onSurface + } else { + if (hideBackground) { + controlColor.copy(alpha = 0.38f) + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + } + }, + modifier = Modifier + .fillMaxSize() + .padding(MaterialTheme.spacing.small), + ) + } + + Surface( + modifier = + Modifier + .size(64.dp) + .clip(CircleShape) + .clickable(interaction, ripple(), onClick = { + resetControlsTimestamp = System.currentTimeMillis() + viewModel.pauseUnpause() + }) + .then( + if (hideBackground) { + Modifier.background(brush = buttonShadow, shape = CircleShape) + } else { + Modifier + }, + ), + shape = CircleShape, + color = + if (!hideBackground) { + MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.55f) + } else { + Color.Transparent + }, + contentColor = if (hideBackground) controlColor else MaterialTheme.colorScheme.onSurface, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + border = + if (!hideBackground) { + BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)) + } else { + null + }, + ) { + Image( + painter = rememberAnimatedVectorPainter(icon, paused == false), + modifier = Modifier + .fillMaxSize() + .padding(MaterialTheme.spacing.medium), + contentDescription = null, + colorFilter = ColorFilter.tint(LocalContentColor.current), + ) + } Surface( modifier = @@ -1024,6 +1025,7 @@ fun PlayerControls( } } } + } AnimatedVisibility( visible = controlsShown && !areControlsLocked, @@ -1412,11 +1414,69 @@ fun PlayerControls( panelShown = panel, onDismissRequest = { onOpenPanel(Panels.None) }, ) - + // ========================================== // INJECT THE NATIVE PAGE 6 HARDWARE HUD HERE // ========================================== HardwareHudOverlay() - } + }} + +@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class) +@Composable +fun LiquidCustomButton( + button: PlayerViewModel.CustomButtonState, // FIXED: Correctly matching your ViewModel's button state! + liquidUIEnabled: Boolean, + backdrop: com.kyant.backdrop.Backdrop, + viewModel: PlayerViewModel, + haptic: androidx.compose.ui.hapticfeedback.HapticFeedback, + onInteract: () -> Unit +) { + if (liquidUIEnabled) { + TransparentLiquidButton( + backdrop = backdrop, + onClick = { + onInteract() + viewModel.callCustomButton(button.id) + }, + onLongClick = { + haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove) + onInteract() + viewModel.callCustomButtonLongPress(button.id) + } + ) { + Text( + text = button.label, + // FIXED: Cleaned up the modifier syntax! + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp).basicMarquee(), + style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.Bold), + maxLines = 1, + softWrap = false, + color = Color.White + ) + } + } else { + val interactionSource = remember { androidx.compose.foundation.interaction.MutableInteractionSource() } + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.85f), + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.1f)), + modifier = Modifier.clip(CircleShape).combinedClickable( + interactionSource = interactionSource, + indication = ripple(), + onClick = { onInteract(); viewModel.callCustomButton(button.id) }, + onLongClick = { haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove); onInteract(); viewModel.callCustomButtonLongPress(button.id) } + ) + ) { + Text( + text = button.label, + // FIXED: Cleaned up the modifier syntax! + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp).basicMarquee(), + style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.Bold), + maxLines = 1, + softWrap = false + ) + } + } } diff --git a/app/src/main/java/app/marlboroadvance/mpvex/ui/player/controls/PlayerControlsShared.kt b/app/src/main/java/app/marlboroadvance/mpvex/ui/player/controls/PlayerControlsShared.kt index 4e29b1add..8757f23f0 100644 --- a/app/src/main/java/app/marlboroadvance/mpvex/ui/player/controls/PlayerControlsShared.kt +++ b/app/src/main/java/app/marlboroadvance/mpvex/ui/player/controls/PlayerControlsShared.kt @@ -11,6 +11,7 @@ import androidx.compose.animation.shrinkHorizontally import androidx.compose.animation.togetherWith import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.interaction.MutableInteractionSource @@ -21,6 +22,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons @@ -83,6 +85,12 @@ import app.marlboroadvance.mpvex.ui.theme.controlColor import app.marlboroadvance.mpvex.ui.theme.spacing import dev.vivvvek.seeker.Segment +// --- NEW LIQUID IMPORTS --- +import app.marlboroadvance.mpvex.ui.components.liquid.LocalLiquidBackdrop +import app.marlboroadvance.mpvex.ui.components.liquid.TransparentLiquidButton +import app.marlboroadvance.mpvex.ui.components.liquid.LiquidGlassSurface +import app.marlboroadvance.mpvex.preferences.LiquidTarget + @Composable fun RenderPlayerButton( button: PlayerButton, @@ -104,6 +112,8 @@ fun RenderPlayerButton( buttonSize: Dp = 40.dp, ) { val clickEvent = LocalPlayerButtonsClickEvent.current + val backdrop = LocalLiquidBackdrop.current + when (button) { PlayerButton.BACK_ARROW -> { ControlsButton( @@ -116,70 +126,49 @@ fun RenderPlayerButton( PlayerButton.VIDEO_TITLE -> { val playlistModeEnabled = viewModel.hasPlaylistSupport() - val titleInteractionSource = remember { MutableInteractionSource() } - Surface( - shape = CircleShape, - color = - if (hideBackground) { - Color.Transparent - } else { - MaterialTheme.colorScheme.surfaceContainer.copy( - alpha = 0.55f, - ) - }, - contentColor = if (hideBackground) controlColor else MaterialTheme.colorScheme.onSurface, - tonalElevation = 0.dp, - shadowElevation = 0.dp, - border = - if (hideBackground) { - null - } else { - BorderStroke( - 1.dp, - MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f), - ) - }, - modifier = - Modifier - .height(buttonSize) - .clip(CircleShape) - .clickable( - interactionSource = titleInteractionSource, - indication = ripple( - bounded = true, - ), - enabled = playlistModeEnabled, - onClick = { - clickEvent() - onOpenSheet(Sheets.Playlist) - }, - ), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = - Modifier - .padding( - horizontal = MaterialTheme.spacing.extraSmall, - vertical = MaterialTheme.spacing.small, - ), + if (backdrop != null && !hideBackground) { + TransparentLiquidButton( + backdrop = backdrop, + modifier = Modifier.height(buttonSize), + shape = CircleShape, + onClick = { clickEvent(); onOpenSheet(Sheets.Playlist) } ) { - Text( - mediaTitle ?: "", - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.weight(1f, fill = false), - ) - viewModel.getPlaylistInfo()?.let { playlistInfo -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = MaterialTheme.spacing.extraSmall, vertical = MaterialTheme.spacing.small) + ) { Text( - " • $playlistInfo", - maxLines = 1, - overflow = TextOverflow.Visible, - style = MaterialTheme.typography.bodySmall, + mediaTitle ?: "", maxLines = 1, overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium, color = Color.White, + modifier = Modifier.weight(1f, fill = false), ) + viewModel.getPlaylistInfo()?.let { playlistInfo -> + Text(" • $playlistInfo", maxLines = 1, overflow = TextOverflow.Visible, color = Color.White, style = MaterialTheme.typography.bodySmall) + } + } + } + } else { + Surface( + shape = CircleShape, + color = if (hideBackground) Color.Transparent else MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.55f), + contentColor = if (hideBackground) controlColor else MaterialTheme.colorScheme.onSurface, + tonalElevation = 0.dp, shadowElevation = 0.dp, + border = if (hideBackground) null else BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), + modifier = Modifier.height(buttonSize).clip(CircleShape).clickable( + interactionSource = titleInteractionSource, indication = ripple(bounded = true), + enabled = playlistModeEnabled, onClick = { clickEvent(); onOpenSheet(Sheets.Playlist) } + ) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = MaterialTheme.spacing.extraSmall, vertical = MaterialTheme.spacing.small) + ) { + Text(mediaTitle ?: "", maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f, fill = false)) + viewModel.getPlaylistInfo()?.let { playlistInfo -> + Text(" • $playlistInfo", maxLines = 1, overflow = TextOverflow.Visible, style = MaterialTheme.typography.bodySmall) + } } } } @@ -188,8 +177,7 @@ fun RenderPlayerButton( PlayerButton.BOOKMARKS_CHAPTERS -> { if (chapters.isNotEmpty()) { ControlsButton( - Icons.Default.Bookmarks, - onClick = { onOpenSheet(Sheets.Chapters) }, + Icons.Default.Bookmarks, onClick = { onOpenSheet(Sheets.Chapters) }, color = if (hideBackground) controlColor else MaterialTheme.colorScheme.onSurface, modifier = Modifier.size(buttonSize), ) @@ -198,120 +186,78 @@ fun RenderPlayerButton( PlayerButton.PLAYBACK_SPEED -> { if (isSpeedNonOne) { - Surface( - shape = CircleShape, - color = if (hideBackground) Color.Transparent else MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.55f), - contentColor = if (hideBackground) controlColor else MaterialTheme.colorScheme.onSurface, - tonalElevation = 0.dp, - shadowElevation = 0.dp, - border = if (hideBackground) null else BorderStroke( - 1.dp, - MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f), - ), - modifier = Modifier - .height(buttonSize) - .clip(CircleShape) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = ripple(bounded = true), - onClick = { - clickEvent() - onOpenSheet(Sheets.PlaybackSpeed) - }, - ), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.extraSmall), - modifier = Modifier.padding( - horizontal = MaterialTheme.spacing.small, - vertical = MaterialTheme.spacing.small, - ), + if (backdrop != null && !hideBackground) { + TransparentLiquidButton( + backdrop = backdrop, modifier = Modifier.height(buttonSize), shape = CircleShape, + onClick = { clickEvent(); onOpenSheet(Sheets.PlaybackSpeed) } ) { - Icon( - imageVector = Icons.Default.Speed, - contentDescription = "Playback Speed", - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(20.dp), - ) - Text( - text = String.format("%.2fx", playbackSpeed), - maxLines = 1, - style = MaterialTheme.typography.bodyMedium, + Row( + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.extraSmall), + modifier = Modifier.padding(horizontal = MaterialTheme.spacing.small, vertical = MaterialTheme.spacing.small) + ) { + Icon(Icons.Default.Speed, contentDescription = "Playback Speed", tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp)) + Text(String.format("%.2fx", playbackSpeed), maxLines = 1, color = Color.White, style = MaterialTheme.typography.bodyMedium) + } + } + } else { + Surface( + shape = CircleShape, + color = if (hideBackground) Color.Transparent else MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.55f), + contentColor = if (hideBackground) controlColor else MaterialTheme.colorScheme.onSurface, + tonalElevation = 0.dp, shadowElevation = 0.dp, + border = if (hideBackground) null else BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), + modifier = Modifier.height(buttonSize).clip(CircleShape).clickable( + interactionSource = remember { MutableInteractionSource() }, indication = ripple(bounded = true), + onClick = { clickEvent(); onOpenSheet(Sheets.PlaybackSpeed) } ) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.extraSmall), + modifier = Modifier.padding(horizontal = MaterialTheme.spacing.small, vertical = MaterialTheme.spacing.small) + ) { + Icon(Icons.Default.Speed, contentDescription = "Playback Speed", tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp)) + Text(String.format("%.2fx", playbackSpeed), maxLines = 1, style = MaterialTheme.typography.bodyMedium) + } } } } else { - ControlsButton( - icon = Icons.Default.Speed, - onClick = { onOpenSheet(Sheets.PlaybackSpeed) }, - color = if (hideBackground) controlColor else MaterialTheme.colorScheme.onSurface, - modifier = Modifier.size(buttonSize), - ) + ControlsButton(icon = Icons.Default.Speed, onClick = { onOpenSheet(Sheets.PlaybackSpeed) }, color = if (hideBackground) controlColor else MaterialTheme.colorScheme.onSurface, modifier = Modifier.size(buttonSize)) } } PlayerButton.DECODER -> { - Surface( - shape = CircleShape, - color = - if (hideBackground) { - Color.Transparent - } else { - MaterialTheme.colorScheme.surfaceContainer.copy( - alpha = 0.55f, - ) - }, - contentColor = if (hideBackground) controlColor else MaterialTheme.colorScheme.onSurface, - tonalElevation = 0.dp, - shadowElevation = 0.dp, - border = - if (hideBackground) { - null - } else { - BorderStroke( - 1.dp, - MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f), - ) - }, - modifier = Modifier - .height(buttonSize) - .clip(CircleShape) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = ripple(bounded = true), - onClick = { - clickEvent() - onOpenSheet(Sheets.Decoders) - }, - ), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = - Modifier - .padding( - horizontal = MaterialTheme.spacing.medium, - vertical = MaterialTheme.spacing.small, - ), - ) { - Text( - text = decoder.title, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.bodyMedium, + if (backdrop != null && !hideBackground) { + TransparentLiquidButton( + backdrop = backdrop, + modifier = Modifier.width(56.dp).height(buttonSize), + shape = CircleShape, + onClick = { clickEvent(); onOpenSheet(Sheets.Decoders) } + ) { + Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) { + Text(decoder.title, maxLines = 1, color = Color.White, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodyMedium, fontWeight = androidx.compose.ui.text.font.FontWeight.Bold) + } + } + } else { + Surface( + shape = CircleShape, + color = if (hideBackground) Color.Transparent else MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.55f), + contentColor = if (hideBackground) controlColor else MaterialTheme.colorScheme.onSurface, + tonalElevation = 0.dp, shadowElevation = 0.dp, + border = if (hideBackground) null else BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), + modifier = Modifier.width(56.dp).height(buttonSize).clip(CircleShape).clickable( + interactionSource = remember { MutableInteractionSource() }, indication = ripple(bounded = true), + onClick = { clickEvent(); onOpenSheet(Sheets.Decoders) } ) + ) { + Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) { + Text(text = decoder.title, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodyMedium, fontWeight = androidx.compose.ui.text.font.FontWeight.Bold) + } } } } PlayerButton.SCREEN_ROTATION -> { - ControlsButton( - icon = Icons.Default.ScreenRotation, - onClick = viewModel::cycleScreenRotations, - color = if (hideBackground) controlColor else MaterialTheme.colorScheme.onSurface, - modifier = Modifier.size(buttonSize), - ) + ControlsButton(icon = Icons.Default.ScreenRotation, onClick = viewModel::cycleScreenRotations, color = if (hideBackground) controlColor else MaterialTheme.colorScheme.onSurface, modifier = Modifier.size(buttonSize)) } PlayerButton.FRAME_NAVIGATION -> { @@ -321,326 +267,133 @@ fun RenderPlayerButton( AnimatedContent( targetState = isExpanded, - transitionSpec = { - (fadeIn(animationSpec = tween(200)) + expandHorizontally(animationSpec = tween(250))) - .togetherWith(fadeOut(animationSpec = tween(200)) + shrinkHorizontally(animationSpec = tween(250))) - .using(SizeTransform(clip = false)) - }, + transitionSpec = { (fadeIn(animationSpec = tween(200)) + expandHorizontally(animationSpec = tween(250))).togetherWith(fadeOut(animationSpec = tween(200)) + shrinkHorizontally(animationSpec = tween(250))).using(SizeTransform(clip = false)) }, label = "FrameNavExpandCollapse", ) { expanded -> if (expanded) { - Surface( - shape = MaterialTheme.shapes.extraLarge, - color = MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.55f), - border = if (hideBackground) null else BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), - modifier = Modifier.height(buttonSize), - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(2.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(horizontal = 4.dp), - ) { - // Previous frame button - Surface( - shape = CircleShape, - color = Color.Transparent, - modifier = Modifier - .size(buttonSize - 4.dp) - .clip(CircleShape) - .clickable(onClick = { - viewModel.frameStepBackward() - viewModel.resetFrameNavigationTimer() - }), - ) { - Box(contentAlignment = Alignment.Center) { - Icon( - imageVector = Icons.Default.FastRewind, - contentDescription = "Previous Frame", - tint = if (hideBackground) controlColor else MaterialTheme.colorScheme.onSurface, - modifier = Modifier.size(20.dp), - ) + if (backdrop != null && !hideBackground) { + LiquidGlassSurface( + backdrop = backdrop, target = LiquidTarget.BUTTON, shape = MaterialTheme.shapes.extraLarge, modifier = Modifier.height(buttonSize) + ) { + Row(horizontalArrangement = Arrangement.spacedBy(2.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(horizontal = 4.dp)) { + Box(modifier = Modifier.size(buttonSize - 4.dp).clip(CircleShape).clickable(onClick = { viewModel.frameStepBackward(); viewModel.resetFrameNavigationTimer() }), contentAlignment = Alignment.Center) { Icon(Icons.Default.FastRewind, contentDescription = "Previous Frame", tint = Color.White, modifier = Modifier.size(20.dp)) } + if (isSnapshotLoading) { + Box(modifier = Modifier.size(buttonSize - 4.dp), contentAlignment = Alignment.Center) { CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.primary) } + } else { + @OptIn(ExperimentalFoundationApi::class) + Box(modifier = Modifier.size(buttonSize - 4.dp).clip(CircleShape).combinedClickable(onClick = { viewModel.takeSnapshot(context); viewModel.resetFrameNavigationTimer() }, onLongClick = { onOpenSheet(Sheets.FrameNavigation) }), contentAlignment = Alignment.Center) { Icon(Icons.Default.CameraAlt, contentDescription = "Take Screenshot", tint = Color.White, modifier = Modifier.size(20.dp)) } + } + Box(modifier = Modifier.size(buttonSize - 4.dp).clip(CircleShape).clickable(onClick = { viewModel.frameStepForward(); viewModel.resetFrameNavigationTimer() }), contentAlignment = Alignment.Center) { Icon(Icons.Default.FastForward, contentDescription = "Next Frame", tint = Color.White, modifier = Modifier.size(20.dp)) } } - } - - // Camera / Loading button - if (isSnapshotLoading) { - Surface( - shape = CircleShape, - color = MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.55f), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), - modifier = Modifier.size(buttonSize - 4.dp), - ) { - Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) { - CircularProgressIndicator( - modifier = Modifier.size(16.dp), - strokeWidth = 2.dp, - color = if (hideBackground) controlColor else MaterialTheme.colorScheme.primary, - ) - } - } - } else { - @OptIn(ExperimentalFoundationApi::class) - Surface( - shape = CircleShape, - color = MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.55f), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), - modifier = Modifier - .size(buttonSize - 4.dp) - .clip(CircleShape) - .combinedClickable( - onClick = { - viewModel.takeSnapshot(context) - viewModel.resetFrameNavigationTimer() - }, - onLongClick = { onOpenSheet(Sheets.FrameNavigation) }, - ), - ) { - Box(contentAlignment = Alignment.Center) { - Icon( - imageVector = Icons.Default.CameraAlt, - contentDescription = "Take Screenshot", - tint = if (hideBackground) controlColor else MaterialTheme.colorScheme.onSurface, - modifier = Modifier.size(20.dp), - ) - } - } - } - - // Next frame button - Surface( - shape = CircleShape, - color = Color.Transparent, - modifier = Modifier - .size(buttonSize - 4.dp) - .clip(CircleShape) - .clickable(onClick = { - viewModel.frameStepForward() - viewModel.resetFrameNavigationTimer() - }), - ) { - Box(contentAlignment = Alignment.Center) { - Icon( - imageVector = Icons.Default.FastForward, - contentDescription = "Next Frame", - tint = if (hideBackground) controlColor else MaterialTheme.colorScheme.onSurface, - modifier = Modifier.size(20.dp), - ) + } + } else { + Surface( + shape = MaterialTheme.shapes.extraLarge, + color = MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.55f), + border = if (hideBackground) null else BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), + modifier = Modifier.height(buttonSize) + ) { + Row(horizontalArrangement = Arrangement.spacedBy(2.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(horizontal = 4.dp)) { + Surface(shape = CircleShape, color = Color.Transparent, modifier = Modifier.size(buttonSize - 4.dp).clip(CircleShape).clickable(onClick = { viewModel.frameStepBackward(); viewModel.resetFrameNavigationTimer() })) { Box(contentAlignment = Alignment.Center) { Icon(Icons.Default.FastRewind, contentDescription = "Previous Frame", tint = if (hideBackground) controlColor else MaterialTheme.colorScheme.onSurface, modifier = Modifier.size(20.dp)) } } + if (isSnapshotLoading) { + Surface(shape = CircleShape, color = MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.55f), border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), modifier = Modifier.size(buttonSize - 4.dp)) { Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) { CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp, color = if (hideBackground) controlColor else MaterialTheme.colorScheme.primary) } } + } else { + @OptIn(ExperimentalFoundationApi::class) + Surface(shape = CircleShape, color = MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.55f), border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), modifier = Modifier.size(buttonSize - 4.dp).clip(CircleShape).combinedClickable(onClick = { viewModel.takeSnapshot(context); viewModel.resetFrameNavigationTimer() }, onLongClick = { onOpenSheet(Sheets.FrameNavigation) })) { Box(contentAlignment = Alignment.Center) { Icon(Icons.Default.CameraAlt, contentDescription = "Take Screenshot", tint = if (hideBackground) controlColor else MaterialTheme.colorScheme.onSurface, modifier = Modifier.size(20.dp)) } } } + Surface(shape = CircleShape, color = Color.Transparent, modifier = Modifier.size(buttonSize - 4.dp).clip(CircleShape).clickable(onClick = { viewModel.frameStepForward(); viewModel.resetFrameNavigationTimer() })) { Box(contentAlignment = Alignment.Center) { Icon(Icons.Default.FastForward, contentDescription = "Next Frame", tint = if (hideBackground) controlColor else MaterialTheme.colorScheme.onSurface, modifier = Modifier.size(20.dp)) } } } } } } else { - // Collapsed: Show camera icon button - ControlsButton( - icon = Icons.Default.Camera, - onClick = viewModel::toggleFrameNavigationExpanded, - onLongClick = { onOpenSheet(Sheets.FrameNavigation) }, - color = if (hideBackground) controlColor else MaterialTheme.colorScheme.onSurface, - modifier = Modifier.size(buttonSize), - ) + ControlsButton(icon = Icons.Default.Camera, onClick = viewModel::toggleFrameNavigationExpanded, onLongClick = { onOpenSheet(Sheets.FrameNavigation) }, color = if (hideBackground) controlColor else MaterialTheme.colorScheme.onSurface, modifier = Modifier.size(buttonSize)) } } } PlayerButton.VIDEO_ZOOM -> { if (kotlin.math.abs(currentZoom) >= 0.005f) { - @OptIn(ExperimentalFoundationApi::class) - Surface( - shape = CircleShape, - color = if (hideBackground) Color.Transparent else MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.55f), - contentColor = if (hideBackground) controlColor else MaterialTheme.colorScheme.onSurface, - tonalElevation = 0.dp, - shadowElevation = 0.dp, - border = if (hideBackground) null else BorderStroke( - 1.dp, - MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f), - ), - modifier = Modifier - .height(buttonSize) - .clip(CircleShape) - .combinedClickable( - interactionSource = remember { MutableInteractionSource() }, - indication = ripple(bounded = true), - onClick = { - clickEvent() - onOpenSheet(Sheets.VideoZoom) - }, - onLongClick = { - clickEvent() - viewModel.resetVideoZoom() - }, - ), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.extraSmall), - modifier = Modifier.padding( - horizontal = MaterialTheme.spacing.small, - vertical = MaterialTheme.spacing.small, - ), + if (backdrop != null && !hideBackground) { + TransparentLiquidButton( + backdrop = backdrop, modifier = Modifier.height(buttonSize), shape = CircleShape, + onClick = { clickEvent(); onOpenSheet(Sheets.VideoZoom) }, + onLongClick = { clickEvent(); viewModel.resetVideoZoom() } + ) { + Row( + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.extraSmall), + modifier = Modifier.padding(horizontal = MaterialTheme.spacing.small, vertical = MaterialTheme.spacing.small) + ) { + Icon(Icons.Default.ZoomIn, contentDescription = "Video Zoom", tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp)) + Text(String.format("%.0f%%", currentZoom * 100), maxLines = 1, color = Color.White, style = MaterialTheme.typography.bodyMedium) + } + } + } else { + @OptIn(ExperimentalFoundationApi::class) + Surface( + shape = CircleShape, color = if (hideBackground) Color.Transparent else MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.55f), + contentColor = if (hideBackground) controlColor else MaterialTheme.colorScheme.onSurface, border = if (hideBackground) null else BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), + modifier = Modifier.height(buttonSize).clip(CircleShape).combinedClickable(interactionSource = remember { MutableInteractionSource() }, indication = ripple(bounded = true), onClick = { clickEvent(); onOpenSheet(Sheets.VideoZoom) }, onLongClick = { clickEvent(); viewModel.resetVideoZoom() }) ) { - Icon( - imageVector = Icons.Default.ZoomIn, - contentDescription = "Video Zoom", - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(20.dp), - ) - Text( - text = String.format("%.0f%%", currentZoom * 100), - maxLines = 1, - style = MaterialTheme.typography.bodyMedium, - ) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.extraSmall), modifier = Modifier.padding(horizontal = MaterialTheme.spacing.small, vertical = MaterialTheme.spacing.small)) { + Icon(Icons.Default.ZoomIn, contentDescription = "Video Zoom", tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp)) + Text(String.format("%.0f%%", currentZoom * 100), maxLines = 1, style = MaterialTheme.typography.bodyMedium) + } } } } else { - ControlsButton( - Icons.Default.ZoomIn, - onClick = { - clickEvent() - onOpenSheet(Sheets.VideoZoom) - }, - onLongClick = { viewModel.resetVideoZoom() }, - color = if (hideBackground) controlColor else MaterialTheme.colorScheme.onSurface, - modifier = Modifier.size(buttonSize), - ) + ControlsButton(Icons.Default.ZoomIn, onClick = { clickEvent(); onOpenSheet(Sheets.VideoZoom) }, onLongClick = { viewModel.resetVideoZoom() }, color = if (hideBackground) controlColor else MaterialTheme.colorScheme.onSurface, modifier = Modifier.size(buttonSize)) } } - PlayerButton.PICTURE_IN_PICTURE -> { - ControlsButton( - Icons.Default.PictureInPictureAlt, - onClick = { activity.enterPipModeHidingOverlay() }, - color = if (hideBackground) controlColor else MaterialTheme.colorScheme.onSurface, - modifier = Modifier.size(buttonSize), - ) - } + PlayerButton.PICTURE_IN_PICTURE -> { ControlsButton(Icons.Default.PictureInPictureAlt, onClick = { activity.enterPipModeHidingOverlay() }, color = if (hideBackground) controlColor else MaterialTheme.colorScheme.onSurface, modifier = Modifier.size(buttonSize)) } PlayerButton.ASPECT_RATIO -> { ControlsButton( - icon = - when (aspect) { - VideoAspect.Fit -> Icons.Default.AspectRatio - VideoAspect.Stretch -> Icons.Default.ZoomOutMap - VideoAspect.Crop -> Icons.Default.FitScreen - }, - onClick = { - when (aspect) { - VideoAspect.Fit -> viewModel.changeVideoAspect(VideoAspect.Stretch) - VideoAspect.Stretch -> viewModel.changeVideoAspect(VideoAspect.Crop) - VideoAspect.Crop -> viewModel.changeVideoAspect(VideoAspect.Fit) - } - }, - onLongClick = { onOpenSheet(Sheets.AspectRatios) }, - color = if (hideBackground) controlColor else MaterialTheme.colorScheme.onSurface, - modifier = Modifier.size(buttonSize), + icon = when (aspect) { VideoAspect.Fit -> Icons.Default.AspectRatio; VideoAspect.Stretch -> Icons.Default.ZoomOutMap; VideoAspect.Crop -> Icons.Default.FitScreen }, + onClick = { when (aspect) { VideoAspect.Fit -> viewModel.changeVideoAspect(VideoAspect.Stretch); VideoAspect.Stretch -> viewModel.changeVideoAspect(VideoAspect.Crop); VideoAspect.Crop -> viewModel.changeVideoAspect(VideoAspect.Fit) } }, + onLongClick = { onOpenSheet(Sheets.AspectRatios) }, color = if (hideBackground) controlColor else MaterialTheme.colorScheme.onSurface, modifier = Modifier.size(buttonSize) ) } - PlayerButton.LOCK_CONTROLS -> { - ControlsButton( - Icons.Default.LockOpen, - onClick = viewModel::lockControls, - color = if (hideBackground) controlColor else MaterialTheme.colorScheme.onSurface, - modifier = Modifier.size(buttonSize), - ) - } + PlayerButton.LOCK_CONTROLS -> { ControlsButton(Icons.Default.LockOpen, onClick = viewModel::lockControls, color = if (hideBackground) controlColor else MaterialTheme.colorScheme.onSurface, modifier = Modifier.size(buttonSize)) } - PlayerButton.AUDIO_TRACK -> { - ControlsButton( - Icons.Default.Audiotrack, - onClick = { onOpenSheet(Sheets.AudioTracks) }, - onLongClick = { onOpenPanel(Panels.AudioDelay) }, - color = if (hideBackground) controlColor else MaterialTheme.colorScheme.onSurface, - modifier = Modifier.size(buttonSize), - ) - } + PlayerButton.AUDIO_TRACK -> { ControlsButton(Icons.Default.Audiotrack, onClick = { onOpenSheet(Sheets.AudioTracks) }, onLongClick = { onOpenPanel(Panels.AudioDelay) }, color = if (hideBackground) controlColor else MaterialTheme.colorScheme.onSurface, modifier = Modifier.size(buttonSize)) } - PlayerButton.SUBTITLES -> { - ControlsButton( - Icons.Default.Subtitles, - onClick = { onOpenSheet(Sheets.SubtitleTracks) }, - onLongClick = { onOpenPanel(Panels.SubtitleDelay) }, - color = if (hideBackground) controlColor else MaterialTheme.colorScheme.onSurface, - modifier = Modifier.size(buttonSize), - ) - } + PlayerButton.SUBTITLES -> { ControlsButton(Icons.Default.Subtitles, onClick = { onOpenSheet(Sheets.SubtitleTracks) }, onLongClick = { onOpenPanel(Panels.SubtitleDelay) }, color = if (hideBackground) controlColor else MaterialTheme.colorScheme.onSurface, modifier = Modifier.size(buttonSize)) } - PlayerButton.MORE_OPTIONS -> { - ControlsButton( - Icons.Default.MoreVert, - onClick = { onOpenSheet(Sheets.More) }, - onLongClick = { onOpenPanel(Panels.VideoFilters) }, - color = if (hideBackground) controlColor else MaterialTheme.colorScheme.onSurface, - modifier = Modifier.size(buttonSize), - ) - } + PlayerButton.MORE_OPTIONS -> { ControlsButton(Icons.Default.MoreVert, onClick = { onOpenSheet(Sheets.More) }, onLongClick = { onOpenPanel(Panels.VideoFilters) }, color = if (hideBackground) controlColor else MaterialTheme.colorScheme.onSurface, modifier = Modifier.size(buttonSize)) } PlayerButton.CURRENT_CHAPTER -> { if (isPortrait) { } else { - AnimatedVisibility( - chapters.getOrNull(currentChapter ?: 0) != null, - enter = fadeIn(), - exit = fadeOut(), - ) { - chapters.getOrNull(currentChapter ?: 0)?.let { chapter -> - CurrentChapter( - chapter = chapter, - onClick = { onOpenSheet(Sheets.Chapters) }, - ) - } + AnimatedVisibility(chapters.getOrNull(currentChapter ?: 0) != null, enter = fadeIn(), exit = fadeOut()) { + chapters.getOrNull(currentChapter ?: 0)?.let { chapter -> CurrentChapter(chapter = chapter, onClick = { onOpenSheet(Sheets.Chapters) }) } } } } PlayerButton.REPEAT_MODE -> { val repeatMode by viewModel.repeatMode.collectAsState() - val icon = when (repeatMode) { - app.marlboroadvance.mpvex.ui.player.RepeatMode.OFF -> Icons.Default.Repeat - app.marlboroadvance.mpvex.ui.player.RepeatMode.ONE -> Icons.Default.RepeatOne - app.marlboroadvance.mpvex.ui.player.RepeatMode.ALL -> Icons.Default.RepeatOn - } + val icon = when (repeatMode) { app.marlboroadvance.mpvex.ui.player.RepeatMode.OFF -> Icons.Default.Repeat; app.marlboroadvance.mpvex.ui.player.RepeatMode.ONE -> Icons.Default.RepeatOne; app.marlboroadvance.mpvex.ui.player.RepeatMode.ALL -> Icons.Default.RepeatOn } ControlsButton( - icon = icon, - onClick = viewModel::cycleRepeatMode, - color = if (hideBackground) { - when (repeatMode) { - app.marlboroadvance.mpvex.ui.player.RepeatMode.OFF -> controlColor - else -> MaterialTheme.colorScheme.primary - } - } else { - when (repeatMode) { - app.marlboroadvance.mpvex.ui.player.RepeatMode.OFF -> MaterialTheme.colorScheme.onSurface - else -> MaterialTheme.colorScheme.primary - } - }, - modifier = Modifier.size(buttonSize), + icon = icon, onClick = viewModel::cycleRepeatMode, + color = if (hideBackground) { when (repeatMode) { app.marlboroadvance.mpvex.ui.player.RepeatMode.OFF -> controlColor; else -> MaterialTheme.colorScheme.primary } } else { when (repeatMode) { app.marlboroadvance.mpvex.ui.player.RepeatMode.OFF -> MaterialTheme.colorScheme.onSurface; else -> MaterialTheme.colorScheme.primary } }, + modifier = Modifier.size(buttonSize) ) } PlayerButton.CUSTOM_SKIP -> { val playerPreferences = org.koin.compose.koinInject() - ControlsButton( - icon = Icons.Default.FastForward, - onClick = { viewModel.seekBy(playerPreferences.customSkipDuration.get()) }, - color = if (hideBackground) controlColor else MaterialTheme.colorScheme.onSurface, - modifier = Modifier.size(buttonSize), - ) + ControlsButton(icon = Icons.Default.FastForward, onClick = { viewModel.seekBy(playerPreferences.customSkipDuration.get()) }, color = if (hideBackground) controlColor else MaterialTheme.colorScheme.onSurface, modifier = Modifier.size(buttonSize)) } PlayerButton.SHUFFLE -> { - // Only show shuffle button if there's a playlist (more than one video) if (viewModel.hasPlaylistSupport()) { val shuffleEnabled by viewModel.shuffleEnabled.collectAsState() ControlsButton( - icon = if (shuffleEnabled) Icons.Default.ShuffleOn else Icons.Default.Shuffle, - onClick = viewModel::toggleShuffle, - color = if (hideBackground) { - if (shuffleEnabled) MaterialTheme.colorScheme.primary else controlColor - } else { - if (shuffleEnabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface - }, - modifier = Modifier.size(buttonSize), + icon = if (shuffleEnabled) Icons.Default.ShuffleOn else Icons.Default.Shuffle, onClick = viewModel::toggleShuffle, + color = if (hideBackground) { if (shuffleEnabled) MaterialTheme.colorScheme.primary else controlColor } else { if (shuffleEnabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface }, + modifier = Modifier.size(buttonSize) ) } } @@ -648,44 +401,23 @@ fun RenderPlayerButton( PlayerButton.MIRROR -> { val isMirrored by viewModel.isMirrored.collectAsState() ControlsButton( - icon = Icons.Default.Flip, - onClick = viewModel::toggleMirroring, - color = if (hideBackground) { - if (isMirrored) MaterialTheme.colorScheme.primary else controlColor - } else { - if (isMirrored) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface - }, - modifier = Modifier.size(buttonSize), + icon = Icons.Default.Flip, onClick = viewModel::toggleMirroring, + color = if (hideBackground) { if (isMirrored) MaterialTheme.colorScheme.primary else controlColor } else { if (isMirrored) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface }, + modifier = Modifier.size(buttonSize) ) } PlayerButton.VERTICAL_FLIP -> { val isVerticalFlipped by viewModel.isVerticalFlipped.collectAsState() - val vFlipColor = if (hideBackground) { - if (isVerticalFlipped) MaterialTheme.colorScheme.primary else controlColor + val vFlipColor = if (hideBackground) { if (isVerticalFlipped) MaterialTheme.colorScheme.primary else controlColor } else { if (isVerticalFlipped) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface } + + if (backdrop != null && !hideBackground) { + TransparentLiquidButton(backdrop = backdrop, onClick = viewModel::toggleVerticalFlip, modifier = Modifier.size(buttonSize)) { + Icon(imageVector = Icons.Default.Flip, contentDescription = "Vertical Flip", tint = if (isVerticalFlipped) MaterialTheme.colorScheme.primary else Color.White, modifier = Modifier.padding(MaterialTheme.spacing.small).size(20.dp).rotate(90f)) + } } else { - if (isVerticalFlipped) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface - } - Surface( - shape = CircleShape, - color = if (hideBackground) Color.Transparent else MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.55f), - contentColor = vFlipColor, - border = if (hideBackground) null else BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), - modifier = Modifier - .size(buttonSize) - .clip(CircleShape) - .clickable(onClick = viewModel::toggleVerticalFlip), - ) { - Box(contentAlignment = Alignment.Center) { - Icon( - imageVector = Icons.Default.Flip, - contentDescription = "Vertical Flip", - tint = vFlipColor, - modifier = Modifier - .padding(MaterialTheme.spacing.small) - .size(20.dp) - .rotate(90f), - ) + Surface(shape = CircleShape, color = if (hideBackground) Color.Transparent else MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.55f), contentColor = vFlipColor, border = if (hideBackground) null else BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), modifier = Modifier.size(buttonSize).clip(CircleShape).clickable(onClick = viewModel::toggleVerticalFlip)) { + Box(contentAlignment = Alignment.Center) { Icon(imageVector = Icons.Default.Flip, contentDescription = "Vertical Flip", tint = vFlipColor, modifier = Modifier.padding(MaterialTheme.spacing.small).size(20.dp).rotate(90f)) } } } } @@ -697,162 +429,66 @@ fun RenderPlayerButton( AnimatedContent( targetState = isExpanded, - transitionSpec = { - (fadeIn(animationSpec = tween(200)) + expandHorizontally(animationSpec = tween(250))) - .togetherWith(fadeOut(animationSpec = tween(200)) + shrinkHorizontally(animationSpec = tween(250))) - .using(SizeTransform(clip = false)) - }, + transitionSpec = { (fadeIn(animationSpec = tween(200)) + expandHorizontally(animationSpec = tween(250))).togetherWith(fadeOut(animationSpec = tween(200)) + shrinkHorizontally(animationSpec = tween(250))).using(SizeTransform(clip = false)) }, label = "ABLoopExpandCollapse", ) { expanded -> if (expanded) { - Surface( - shape = MaterialTheme.shapes.extraLarge, - color = MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.55f), - border = if (hideBackground) null else BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), - modifier = Modifier.height(buttonSize), - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(2.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(horizontal = 4.dp), - ) { - // Point A Button - always transparent background - Surface( - shape = CircleShape, - color = if (loopA != null) MaterialTheme.colorScheme.tertiaryContainer else Color.Transparent, - modifier = Modifier - .height(buttonSize - 4.dp) - .widthIn(min = buttonSize - 4.dp) - .clip(CircleShape) - .clickable(onClick = { viewModel.setLoopA() }), - ) { - Box(contentAlignment = Alignment.Center) { - Text( - text = if (loopA != null) viewModel.formatTimestamp(loopA!!) else "A", - style = MaterialTheme.typography.labelLarge, - color = if (loopA != null) MaterialTheme.colorScheme.onTertiaryContainer else MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(horizontal = if (loopA != null) 8.dp else 0.dp), - ) - } - } - - // Clear/Close Button - always has background - Surface( - shape = CircleShape, - color = MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.55f), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), - modifier = Modifier - .size(buttonSize - 4.dp) - .clip(CircleShape) - .clickable(onClick = { - viewModel.clearABLoop() - viewModel.toggleABLoopExpanded() - }), - ) { - Box(contentAlignment = Alignment.Center) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = "Clear Loop", - tint = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.size(16.dp), - ) - } - } - - // Point B Button - always transparent background - Surface( - shape = CircleShape, - color = if (loopB != null) MaterialTheme.colorScheme.tertiaryContainer else Color.Transparent, - modifier = Modifier - .height(buttonSize - 4.dp) - .widthIn(min = buttonSize - 4.dp) - .clip(CircleShape) - .clickable(onClick = { viewModel.setLoopB() }), - ) { - Box(contentAlignment = Alignment.Center) { - Text( - text = if (loopB != null) viewModel.formatTimestamp(loopB!!) else "B", - style = MaterialTheme.typography.labelLarge, - color = if (loopB != null) MaterialTheme.colorScheme.onTertiaryContainer else MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(horizontal = if (loopB != null) 8.dp else 0.dp), - ) + if (backdrop != null && !hideBackground) { + LiquidGlassSurface(backdrop = backdrop, target = LiquidTarget.BUTTON, shape = MaterialTheme.shapes.extraLarge, modifier = Modifier.height(buttonSize)) { + Row(horizontalArrangement = Arrangement.spacedBy(2.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(horizontal = 4.dp)) { + Box(modifier = Modifier.height(buttonSize - 4.dp).widthIn(min = buttonSize - 4.dp).clip(CircleShape).background(if (loopA != null) MaterialTheme.colorScheme.primary.copy(alpha = 0.5f) else Color.Transparent).clickable(onClick = { viewModel.setLoopA() }), contentAlignment = Alignment.Center) { Text(text = if (loopA != null) viewModel.formatTimestamp(loopA!!) else "A", style = MaterialTheme.typography.labelLarge, color = Color.White, modifier = Modifier.padding(horizontal = if (loopA != null) 8.dp else 0.dp)) } + Box(modifier = Modifier.size(buttonSize - 4.dp).clip(CircleShape).background(Color.White.copy(alpha = 0.2f)).clickable(onClick = { viewModel.clearABLoop(); viewModel.toggleABLoopExpanded() }), contentAlignment = Alignment.Center) { Icon(imageVector = Icons.Default.Close, contentDescription = "Clear Loop", tint = Color.White, modifier = Modifier.size(16.dp)) } + Box(modifier = Modifier.height(buttonSize - 4.dp).widthIn(min = buttonSize - 4.dp).clip(CircleShape).background(if (loopB != null) MaterialTheme.colorScheme.primary.copy(alpha = 0.5f) else Color.Transparent).clickable(onClick = { viewModel.setLoopB() }), contentAlignment = Alignment.Center) { Text(text = if (loopB != null) viewModel.formatTimestamp(loopB!!) else "B", style = MaterialTheme.typography.labelLarge, color = Color.White, modifier = Modifier.padding(horizontal = if (loopB != null) 8.dp else 0.dp)) } } + } + } else { + Surface(shape = MaterialTheme.shapes.extraLarge, color = MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.55f), border = if (hideBackground) null else BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), modifier = Modifier.height(buttonSize)) { + Row(horizontalArrangement = Arrangement.spacedBy(2.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(horizontal = 4.dp)) { + Surface(shape = CircleShape, color = if (loopA != null) MaterialTheme.colorScheme.tertiaryContainer else Color.Transparent, modifier = Modifier.height(buttonSize - 4.dp).widthIn(min = buttonSize - 4.dp).clip(CircleShape).clickable(onClick = { viewModel.setLoopA() })) { Box(contentAlignment = Alignment.Center) { Text(text = if (loopA != null) viewModel.formatTimestamp(loopA!!) else "A", style = MaterialTheme.typography.labelLarge, color = if (loopA != null) MaterialTheme.colorScheme.onTertiaryContainer else MaterialTheme.colorScheme.onSurface, modifier = Modifier.padding(horizontal = if (loopA != null) 8.dp else 0.dp)) } } + Surface(shape = CircleShape, color = MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.55f), border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), modifier = Modifier.size(buttonSize - 4.dp).clip(CircleShape).clickable(onClick = { viewModel.clearABLoop(); viewModel.toggleABLoopExpanded() })) { Box(contentAlignment = Alignment.Center) { Icon(imageVector = Icons.Default.Close, contentDescription = "Clear Loop", tint = MaterialTheme.colorScheme.onSurface, modifier = Modifier.size(16.dp)) } } + Surface(shape = CircleShape, color = if (loopB != null) MaterialTheme.colorScheme.tertiaryContainer else Color.Transparent, modifier = Modifier.height(buttonSize - 4.dp).widthIn(min = buttonSize - 4.dp).clip(CircleShape).clickable(onClick = { viewModel.setLoopB() })) { Box(contentAlignment = Alignment.Center) { Text(text = if (loopB != null) viewModel.formatTimestamp(loopB!!) else "B", style = MaterialTheme.typography.labelLarge, color = if (loopB != null) MaterialTheme.colorScheme.onTertiaryContainer else MaterialTheme.colorScheme.onSurface, modifier = Modifier.padding(horizontal = if (loopB != null) 8.dp else 0.dp)) } } } } } } else { - // Collapsed: Show "AB" text button - Surface( - shape = CircleShape, - color = if (hideBackground) Color.Transparent else MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.55f), - border = if (hideBackground) null else BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), - modifier = Modifier - .size(buttonSize) - .clip(CircleShape) - .clickable(onClick = viewModel::toggleABLoopExpanded), - ) { - Box(contentAlignment = Alignment.Center) { - Text( - text = "AB", - style = MaterialTheme.typography.labelLarge, - fontWeight = androidx.compose.ui.text.font.FontWeight.Bold, - color = if (loopA != null && loopB != null) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, - ) + if (backdrop != null && !hideBackground) { + TransparentLiquidButton(backdrop = backdrop, onClick = viewModel::toggleABLoopExpanded, modifier = Modifier.size(buttonSize)) { + Text(text = "AB", style = MaterialTheme.typography.labelLarge, fontWeight = androidx.compose.ui.text.font.FontWeight.Bold, color = if (loopA != null && loopB != null) MaterialTheme.colorScheme.primary else Color.White) + } + } else { + Surface(shape = CircleShape, color = if (hideBackground) Color.Transparent else MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.55f), border = if (hideBackground) null else BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), modifier = Modifier.size(buttonSize).clip(CircleShape).clickable(onClick = viewModel::toggleABLoopExpanded)) { + Box(contentAlignment = Alignment.Center) { Text(text = "AB", style = MaterialTheme.typography.labelLarge, fontWeight = androidx.compose.ui.text.font.FontWeight.Bold, color = if (loopA != null && loopB != null) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface) } } } } } } - PlayerButton.BACKGROUND_PLAYBACK -> { - ControlsButton( - icon = Icons.Default.Headset, - onClick = { activity.triggerBackgroundPlayback() }, - color = if (hideBackground) controlColor else MaterialTheme.colorScheme.onSurface, - modifier = Modifier.size(buttonSize), - ) - } + PlayerButton.BACKGROUND_PLAYBACK -> { ControlsButton(icon = Icons.Default.Headset, onClick = { activity.triggerBackgroundPlayback() }, color = if (hideBackground) controlColor else MaterialTheme.colorScheme.onSurface, modifier = Modifier.size(buttonSize)) } PlayerButton.AMBIENT_MODE -> { val isAmbientEnabled by viewModel.isAmbientEnabled.collectAsState() - @OptIn(ExperimentalFoundationApi::class) - Surface( - shape = CircleShape, - color = if (hideBackground) Color.Transparent else MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.55f), - contentColor = if (isAmbientEnabled) { - MaterialTheme.colorScheme.primary - } else { - if (hideBackground) controlColor else MaterialTheme.colorScheme.onSurface - }, - border = if (hideBackground) null else BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), - modifier = Modifier - .size(buttonSize) - .clip(CircleShape) - .combinedClickable( - interactionSource = remember { MutableInteractionSource() }, - indication = ripple(bounded = true), - onClick = { - clickEvent() - viewModel.toggleAmbientMode() - }, - onLongClick = { - clickEvent() - onOpenSheet(Sheets.AmbientConfig) - } - ), - ) { - Box(contentAlignment = Alignment.Center) { - Icon( - imageVector = if (isAmbientEnabled) Icons.Filled.BlurOn else Icons.Outlined.BlurOn, - contentDescription = "Ambience Mode", - tint = if (isAmbientEnabled) MaterialTheme.colorScheme.primary else (if (hideBackground) controlColor else MaterialTheme.colorScheme.onSurface), - modifier = Modifier.size(24.dp) - ) + + if (backdrop != null && !hideBackground) { + TransparentLiquidButton( + backdrop = backdrop, modifier = Modifier.size(buttonSize), + onClick = { clickEvent(); viewModel.toggleAmbientMode() } + ) { + Icon(imageVector = if (isAmbientEnabled) Icons.Filled.BlurOn else Icons.Outlined.BlurOn, contentDescription = "Ambience Mode", tint = if (isAmbientEnabled) MaterialTheme.colorScheme.primary else Color.White, modifier = Modifier.size(24.dp)) + } + } else { + @OptIn(ExperimentalFoundationApi::class) + Surface( + shape = CircleShape, color = if (hideBackground) Color.Transparent else MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.55f), + contentColor = if (isAmbientEnabled) { MaterialTheme.colorScheme.primary } else { if (hideBackground) controlColor else MaterialTheme.colorScheme.onSurface }, + border = if (hideBackground) null else BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), + modifier = Modifier.size(buttonSize).clip(CircleShape).clickable(interactionSource = remember { MutableInteractionSource() }, indication = ripple(bounded = true), onClick = { clickEvent(); viewModel.toggleAmbientMode() }) + ) { + Box(contentAlignment = Alignment.Center) { Icon(imageVector = if (isAmbientEnabled) Icons.Filled.BlurOn else Icons.Outlined.BlurOn, contentDescription = "Ambience Mode", tint = if (isAmbientEnabled) MaterialTheme.colorScheme.primary else (if (hideBackground) controlColor else MaterialTheme.colorScheme.onSurface), modifier = Modifier.size(24.dp)) } } } } - PlayerButton.NONE -> { /* Do nothing */ - } + PlayerButton.NONE -> { /* Do nothing */ } } } diff --git a/app/src/main/java/app/marlboroadvance/mpvex/ui/player/controls/PlayerPanels.kt b/app/src/main/java/app/marlboroadvance/mpvex/ui/player/controls/PlayerPanels.kt index 943fc10ea..6011c539a 100644 --- a/app/src/main/java/app/marlboroadvance/mpvex/ui/player/controls/PlayerPanels.kt +++ b/app/src/main/java/app/marlboroadvance/mpvex/ui/player/controls/PlayerPanels.kt @@ -14,6 +14,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import app.marlboroadvance.mpvex.ui.player.Panels import app.marlboroadvance.mpvex.ui.player.controls.components.panels.AudioDelayPanel @@ -21,6 +22,9 @@ import app.marlboroadvance.mpvex.ui.player.controls.components.panels.SubtitleDe import app.marlboroadvance.mpvex.ui.player.controls.components.panels.SubtitleSettingsPanel import app.marlboroadvance.mpvex.ui.player.controls.components.panels.VideoSettingsPanel +// --- NEW LIQUID IMPORT --- +import app.marlboroadvance.mpvex.ui.components.liquid.LocalLiquidBackdrop + @Composable fun PlayerPanels( panelShown: Panels, @@ -58,14 +62,27 @@ fun PlayerPanels( } val CARDS_MAX_WIDTH = 420.dp + val panelCardsColors: @Composable () -> CardColors = { - // Higher alpha for better readability in panels (less transparent) - val alpha = 0.85f + val backdrop = LocalLiquidBackdrop.current - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer.copy(alpha = alpha), - contentColor = MaterialTheme.colorScheme.onSurface, - disabledContainerColor = MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = alpha), - disabledContentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f), - ) + if (backdrop != null) { + // LIQUID UI MODE: Nested Glass Effect! + // We use a highly transparent white so the parent panel's blur shines right through! + CardDefaults.cardColors( + containerColor = Color.White.copy(alpha = 0.15f), + contentColor = Color.White, + disabledContainerColor = Color.White.copy(alpha = 0.05f), + disabledContentColor = Color.White.copy(alpha = 0.38f), + ) + } else { + // STANDARD MODE: The original solid grey colors + val alpha = 0.85f + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer.copy(alpha = alpha), + contentColor = MaterialTheme.colorScheme.onSurface, + disabledContainerColor = MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = alpha), + disabledContentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f), + ) + } } diff --git a/app/src/main/java/app/marlboroadvance/mpvex/ui/player/controls/PlayerSheets.kt b/app/src/main/java/app/marlboroadvance/mpvex/ui/player/controls/PlayerSheets.kt index 6a24429d8..f805a32fb 100644 --- a/app/src/main/java/app/marlboroadvance/mpvex/ui/player/controls/PlayerSheets.kt +++ b/app/src/main/java/app/marlboroadvance/mpvex/ui/player/controls/PlayerSheets.kt @@ -23,7 +23,6 @@ import app.marlboroadvance.mpvex.ui.player.controls.components.sheets.PlaylistSh import app.marlboroadvance.mpvex.ui.player.controls.components.sheets.SubtitlesSheet import app.marlboroadvance.mpvex.ui.player.controls.components.sheets.OnlineSubtitleSearchSheet import app.marlboroadvance.mpvex.ui.player.controls.components.sheets.VideoZoomSheet -import app.marlboroadvance.mpvex.ui.player.controls.components.sheets.AmbientSheet import app.marlboroadvance.mpvex.utils.media.MediaInfoParser import dev.vivvvek.seeker.Segment import kotlinx.collections.immutable.ImmutableList @@ -353,12 +352,5 @@ fun PlayerSheets( ) } } - - Sheets.AmbientConfig -> { - AmbientSheet( - viewModel = viewModel, - onDismissRequest = onDismissRequest - ) - } } } diff --git a/app/src/main/java/app/marlboroadvance/mpvex/ui/player/controls/components/ControlsButton.kt b/app/src/main/java/app/marlboroadvance/mpvex/ui/player/controls/components/ControlsButton.kt index 0386edf66..2df4cfdc4 100644 --- a/app/src/main/java/app/marlboroadvance/mpvex/ui/player/controls/components/ControlsButton.kt +++ b/app/src/main/java/app/marlboroadvance/mpvex/ui/player/controls/components/ControlsButton.kt @@ -9,81 +9,102 @@ import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.CatchingPokemon import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.ripple import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import app.marlboroadvance.mpvex.preferences.AppearancePreferences -import app.marlboroadvance.mpvex.preferences.preference.collectAsState import app.marlboroadvance.mpvex.ui.player.controls.LocalPlayerButtonsClickEvent import app.marlboroadvance.mpvex.ui.theme.spacing -import org.koin.compose.koinInject -@Suppress("ModifierClickableOrder") +// --- NEW LIQUID IMPORTS --- +import app.marlboroadvance.mpvex.ui.components.liquid.LocalLiquidBackdrop +import app.marlboroadvance.mpvex.ui.components.liquid.TransparentLiquidButton + @OptIn(ExperimentalFoundationApi::class) @Composable fun ControlsButton( icon: ImageVector, - onClick: () -> Unit, modifier: Modifier = Modifier, - onLongClick: () -> Unit = {}, - title: String? = null, color: Color? = null, + title: String? = null, + hideBackground: Boolean = false, + onClick: () -> Unit, + onLongClick: (() -> Unit)? = null, ) { + val clickEvent = LocalPlayerButtonsClickEvent.current val interactionSource = remember { MutableInteractionSource() } - val appearancePreferences = koinInject() - val hideBackground by appearancePreferences.hidePlayerButtonsBackground.collectAsState() + + // 1. TUNE INTO THE BROADCAST TOWER! + val backdrop = LocalLiquidBackdrop.current - val clickEvent = LocalPlayerButtonsClickEvent.current - Surface( - modifier = - modifier - .clip(CircleShape) - .combinedClickable( + // 2. IF LIQUID UI IS ON, DRAW THE GLASS BUTTON! + if (backdrop != null && !hideBackground) { + TransparentLiquidButton( + backdrop = backdrop, + modifier = modifier.size(40.dp), // Match standard sizing onClick = { - clickEvent() - onClick() + clickEvent() + onClick() + }, + onLongClick = onLongClick + ) { + Icon( + imageVector = icon, + contentDescription = title, + tint = color ?: Color.White, // Glass buttons look best with white icons + modifier = Modifier + .padding(MaterialTheme.spacing.small) + .size(20.dp), + ) + } + } else { + // 3. OTHERWISE, FALLBACK TO THE STANDARD BUTTON + Surface( + modifier = + modifier + .clip(CircleShape) + .combinedClickable( + onClick = { + clickEvent() + onClick() + }, + onLongClick = onLongClick, + interactionSource = interactionSource, + indication = ripple(), + ), + shape = CircleShape, + color = if (hideBackground) Color.Transparent else MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.55f), + contentColor = color ?: MaterialTheme.colorScheme.onSurface, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + border = + if (hideBackground) { + null + } else { + BorderStroke( + 1.dp, + MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f), + ) }, - onLongClick = onLongClick, - interactionSource = interactionSource, - indication = ripple(), - ), - shape = CircleShape, - color = if (hideBackground) Color.Transparent else MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.55f), - contentColor = color ?: MaterialTheme.colorScheme.onSurface, - tonalElevation = 0.dp, - shadowElevation = 0.dp, - border = - if (hideBackground) { - null - } else { - BorderStroke( - 1.dp, - MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f), + ) { + Icon( + imageVector = icon, + contentDescription = title, + tint = color ?: MaterialTheme.colorScheme.onSurface, + modifier = + Modifier + .padding(MaterialTheme.spacing.small) + .size(20.dp), ) - }, - ) { - Icon( - imageVector = icon, - contentDescription = title, - tint = color ?: MaterialTheme.colorScheme.onSurface, - modifier = - Modifier - .padding(MaterialTheme.spacing.small) - .size(20.dp), - ) + } } } @@ -103,12 +124,3 @@ fun ControlsGroup( content = content, ) } - -@Preview -@Composable -private fun PreviewControlsButton() { - ControlsButton( - Icons.Default.CatchingPokemon, - onClick = {}, - ) -} diff --git a/app/src/main/java/app/marlboroadvance/mpvex/ui/player/controls/components/CurrentChapter.kt b/app/src/main/java/app/marlboroadvance/mpvex/ui/player/controls/components/CurrentChapter.kt index 660dc7a69..a443ece32 100644 --- a/app/src/main/java/app/marlboroadvance/mpvex/ui/player/controls/components/CurrentChapter.kt +++ b/app/src/main/java/app/marlboroadvance/mpvex/ui/player/controls/components/CurrentChapter.kt @@ -34,91 +34,159 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import app.marlboroadvance.mpvex.preferences.AppearancePreferences -import app.marlboroadvance.mpvex.preferences.preference.collectAsState -import app.marlboroadvance.mpvex.ui.theme.controlColor import app.marlboroadvance.mpvex.ui.theme.spacing import dev.vivvvek.seeker.Segment import `is`.xyz.mpv.Utils -import org.koin.compose.koinInject +import app.marlboroadvance.mpvex.ui.components.liquid.LocalLiquidBackdrop +import app.marlboroadvance.mpvex.ui.components.liquid.LiquidGlassSurface +import app.marlboroadvance.mpvex.preferences.LiquidTarget @Composable fun CurrentChapter( chapter: Segment, + onClick: () -> Unit, modifier: Modifier = Modifier, - onClick: () -> Unit = {}, ) { - val appearancePreferences = koinInject() + val backdrop = LocalLiquidBackdrop.current - Surface( - modifier = - modifier - .height(45.dp) - .widthIn(max = 220.dp) - .clip(RoundedCornerShape(50)) - .clickable(onClick = onClick), - shape = RoundedCornerShape(50), - color = - MaterialTheme.colorScheme.surfaceContainer.copy( - alpha = 0.55f, - ), - contentColor = MaterialTheme.colorScheme.onSurface, - tonalElevation = 0.dp, - border = - BorderStroke( - 1.dp, - MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f), - ), - ) { - AnimatedContent( - targetState = chapter, - modifier = Modifier.padding(horizontal = MaterialTheme.spacing.medium, vertical = MaterialTheme.spacing.small), - transitionSpec = { - if (targetState.start > initialState.start) { - (slideInVertically { height -> height } + fadeIn()) - .togetherWith(slideOutVertically { height -> -height } + fadeOut()) - } else { - (slideInVertically { height -> -height } + fadeIn()) - .togetherWith(slideOutVertically { height -> height } + fadeOut()) - }.using( - SizeTransform(clip = false), - ) - }, - label = "Chapter", - ) { currentChapter -> - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.extraSmall), + if (backdrop != null) { + LiquidGlassSurface( + backdrop = backdrop, + target = LiquidTarget.BUTTON, + shape = RoundedCornerShape(100.dp), + modifier = modifier + .clip(RoundedCornerShape(100.dp)) + .clickable(onClick = onClick) ) { - Text( - text = Utils.prettyTime(currentChapter.start.toInt()), - fontWeight = FontWeight.Bold, - style = MaterialTheme.typography.bodyMedium, - maxLines = 1, - overflow = TextOverflow.Clip, - color = MaterialTheme.colorScheme.primary, - ) - currentChapter.name.let { - Text( - text = Typography.bullet.toString(), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.bodyMedium, - maxLines = 1, - color = MaterialTheme.colorScheme.onSurface, - overflow = TextOverflow.Clip, - ) - Text( - text = it, - textAlign = TextAlign.Center, - style = MaterialTheme.typography.bodyMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - fontWeight = FontWeight.ExtraBold, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.basicMarquee(), + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = MaterialTheme.spacing.medium, vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.small), + ) { + Icon( + imageVector = Icons.Default.Bookmarks, + contentDescription = "Chapter", + tint = Color.White, + modifier = Modifier.size(16.dp), + ) + AnimatedContent( + targetState = chapter, + transitionSpec = { + if (targetState.start > initialState.start) { + (slideInVertically { height -> height } + fadeIn()) + .togetherWith(slideOutVertically { height -> -height } + fadeOut()) + } else { + (slideInVertically { height -> -height } + fadeIn()) + .togetherWith(slideOutVertically { height -> height } + fadeOut()) + }.using(SizeTransform(clip = false)) + }, + label = "Chapter", + ) { currentChapter -> + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.extraSmall), + ) { + Text( + text = Utils.prettyTime(currentChapter.start.toInt()), + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Clip, + color = MaterialTheme.colorScheme.primary, + ) + currentChapter.name.let { + Text( + text = "•", + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + color = Color.White, + overflow = TextOverflow.Clip, + ) + Text( + text = it, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + fontWeight = FontWeight.ExtraBold, + color = Color.White, + modifier = Modifier.basicMarquee(), + ) + } + } + } + } + } + } else { + Surface( + modifier = modifier + .clip(RoundedCornerShape(100.dp)) + .clickable(onClick = onClick), + shape = RoundedCornerShape(100.dp), + color = MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.55f), + contentColor = MaterialTheme.colorScheme.onSurface, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = MaterialTheme.spacing.medium, vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.small), + ) { + Icon( + imageVector = Icons.Default.Bookmarks, + contentDescription = "Chapter", + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(16.dp), ) + AnimatedContent( + targetState = chapter, + transitionSpec = { + if (targetState.start > initialState.start) { + (slideInVertically { height -> height } + fadeIn()) + .togetherWith(slideOutVertically { height -> -height } + fadeOut()) + } else { + (slideInVertically { height -> -height } + fadeIn()) + .togetherWith(slideOutVertically { height -> height } + fadeOut()) + }.using(SizeTransform(clip = false)) + }, + label = "Chapter", + ) { currentChapter -> + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.extraSmall), + ) { + Text( + text = Utils.prettyTime(currentChapter.start.toInt()), + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Clip, + color = MaterialTheme.colorScheme.primary, + ) + currentChapter.name.let { + Text( + text = "•", + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + color = MaterialTheme.colorScheme.onSurface, + overflow = TextOverflow.Clip, + ) + Text( + text = it, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + fontWeight = FontWeight.ExtraBold, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.basicMarquee(), + ) + } + } + } } } - } } } diff --git a/app/src/main/java/app/marlboroadvance/mpvex/ui/player/controls/components/PlayerUpdates.kt b/app/src/main/java/app/marlboroadvance/mpvex/ui/player/controls/components/PlayerUpdates.kt index 8a33ecd8b..f7de0f553 100644 --- a/app/src/main/java/app/marlboroadvance/mpvex/ui/player/controls/components/PlayerUpdates.kt +++ b/app/src/main/java/app/marlboroadvance/mpvex/ui/player/controls/components/PlayerUpdates.kt @@ -20,6 +20,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily @@ -29,35 +30,51 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import app.marlboroadvance.mpvex.R import app.marlboroadvance.mpvex.ui.theme.spacing +import app.marlboroadvance.mpvex.ui.components.liquid.LocalLiquidBackdrop +import app.marlboroadvance.mpvex.ui.components.liquid.LiquidGlassSurface +import app.marlboroadvance.mpvex.preferences.LiquidTarget @Composable fun PlayerUpdate( modifier: Modifier = Modifier, - content: @Composable () -> Unit = {}, + content: @Composable () -> Unit, ) { - Surface( - shape = CircleShape, - color = MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.55f), - contentColor = MaterialTheme.colorScheme.onSurface, - tonalElevation = 0.dp, - shadowElevation = 0.dp, - border = BorderStroke( - 1.dp, - MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f), - ), - modifier = modifier - .height(45.dp) - .animateContentSize(), - ) { - Box( - modifier = Modifier.padding( - vertical = MaterialTheme.spacing.small, - horizontal = MaterialTheme.spacing.medium, - ), - contentAlignment = Alignment.Center, - ) { - content() - } + val backdrop = LocalLiquidBackdrop.current + if (backdrop != null) { + LiquidGlassSurface( + backdrop = backdrop, + target = LiquidTarget.BUTTON, + shape = CircleShape, + modifier = modifier + ) { + Box( + modifier = Modifier + .padding(vertical = 4.dp, horizontal = 12.dp) + .height(24.dp) + .widthIn(min = 24.dp), + contentAlignment = Alignment.Center, + ) { + content() + } + } + } else { + Surface( + modifier = modifier, + shape = CircleShape, + color = MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.55f), + contentColor = MaterialTheme.colorScheme.onSurface, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), + ) { + Box( + modifier = Modifier + .padding(vertical = 4.dp, horizontal = 12.dp) + .height(24.dp) + .widthIn(min = 24.dp), + contentAlignment = Alignment.Center, + ) { + content() + } + } } } @@ -67,13 +84,14 @@ fun TextPlayerUpdate( text: String, modifier: Modifier = Modifier, ) { + val backdrop = LocalLiquidBackdrop.current PlayerUpdate(modifier) { Text( text = text, fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.onSurface, + color = if (backdrop != null) Color.White else MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.bodyMedium, ) } @@ -87,17 +105,13 @@ fun MultipleSpeedPlayerUpdate( CompactSpeedIndicator(currentSpeed = currentSpeed, modifier = modifier) } -@Composable -@Preview -private fun PreviewMultipleSpeedPlayerUpdate() { - MultipleSpeedPlayerUpdate(currentSpeed = 2f) -} @Composable fun SeekPlayerUpdate( currentTime: String, seekDelta: String, modifier: Modifier = Modifier, ) { + val backdrop = LocalLiquidBackdrop.current PlayerUpdate(modifier) { Row( verticalAlignment = Alignment.CenterVertically, @@ -107,7 +121,7 @@ fun SeekPlayerUpdate( fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.onSurface, + color = if (backdrop != null) Color.White else MaterialTheme.colorScheme.onSurface, ) Text( @@ -116,8 +130,60 @@ fun SeekPlayerUpdate( fontWeight = FontWeight.Normal, textAlign = TextAlign.Center, style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + color = if (backdrop != null) Color.White else MaterialTheme.colorScheme.primary, ) } } } + +@Composable +fun DoubleTapSeekPlayerUpdate( + isRight: Boolean, + seekAmount: Int, + seekText: String?, + modifier: Modifier = Modifier, +) { + val backdrop = LocalLiquidBackdrop.current + val directionText = if (isRight) "Forward" else "Rewind" + PlayerUpdate(modifier.animateContentSize()) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + if (!isRight) { + Icon( + imageVector = Icons.Default.DoubleArrow, + contentDescription = null, + tint = if (backdrop != null) Color.White else MaterialTheme.colorScheme.onSurface, + modifier = + Modifier + .clip(CircleShape) + .background(if (backdrop != null) Color.White.copy(alpha = 0.2f) else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f)) + .padding(4.dp) + .rotate(180f), + ) + } + + Text( + text = seekText ?: "$directionText $seekAmount seconds", + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + color = if (backdrop != null) Color.White else MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(horizontal = 8.dp), + ) + + if (isRight) { + Icon( + imageVector = Icons.Default.DoubleArrow, + contentDescription = null, + tint = if (backdrop != null) Color.White else MaterialTheme.colorScheme.onSurface, + modifier = + Modifier + .clip(CircleShape) + .background(if (backdrop != null) Color.White.copy(alpha = 0.2f) else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f)) + .padding(4.dp), + ) + } + } + } +} diff --git a/app/src/main/java/app/marlboroadvance/mpvex/ui/player/controls/components/Seekbar.kt b/app/src/main/java/app/marlboroadvance/mpvex/ui/player/controls/components/Seekbar.kt index 9d0d39d8f..2c9869c46 100644 --- a/app/src/main/java/app/marlboroadvance/mpvex/ui/player/controls/components/Seekbar.kt +++ b/app/src/main/java/app/marlboroadvance/mpvex/ui/player/controls/components/Seekbar.kt @@ -2,12 +2,12 @@ package app.marlboroadvance.mpvex.ui.player.controls.components import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween -import androidx.compose.animation.core.rememberInfiniteTransition -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.RepeatMode import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.gestures.detectTapGestures @@ -21,11 +21,13 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.ripple import androidx.compose.material3.Slider -import androidx.compose.material3.SliderDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -39,29 +41,40 @@ import androidx.compose.runtime.withFrameMillis import androidx.compose.runtime.collectAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.clipRect -import androidx.compose.ui.graphics.drawscope.withTransform -import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.isSpecified import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.geometry.Size -import androidx.compose.foundation.background -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape +import kotlin.math.roundToInt + +// --- KYANT BACKDROP 2.0.0-ALPHA03 IMPORTS (FIXED) --- +import com.kyant.backdrop.backdrops.layerBackdrop +import com.kyant.backdrop.backdrops.rememberLayerBackdrop +import com.kyant.backdrop.drawBackdrop +import com.kyant.backdrop.effects.blur +import com.kyant.backdrop.effects.lens +import com.kyant.backdrop.effects.vibrancy +import com.kyant.backdrop.Backdrop +import com.kyant.backdrop.backdrops.rememberBackdrop +// ---------------------------------------------------- + import app.marlboroadvance.mpvex.ui.player.controls.LocalPlayerButtonsClickEvent import app.marlboroadvance.mpvex.ui.theme.spacing import app.marlboroadvance.mpvex.preferences.SeekbarStyle +import app.marlboroadvance.mpvex.preferences.LiquidUIPreferences import dev.vivvvek.seeker.Segment import `is`.xyz.mpv.Utils import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch @@ -86,21 +99,21 @@ fun SeekbarWithTimers( var isUserInteracting by remember { mutableStateOf(false) } var userPosition by remember { mutableFloatStateOf(position) } - // Animated position for smooth transitions + val context = LocalContext.current + val liquidPrefs = remember { LiquidUIPreferences(context) } + val isLiquidUI by liquidPrefs.liquidUIEnabledFlow.collectAsState(false) + val sliderColorLong by liquidPrefs.liquidSliderColorFlow.collectAsState(0xFF2196F3) + val liquidColor = Color(sliderColorLong) + val animatedPosition = remember { Animatable(position) } val scope = rememberCoroutineScope() - // Only animate position updates when user is not interacting LaunchedEffect(position, isUserInteracting) { if (!isUserInteracting && position != animatedPosition.value) { scope.launch { animatedPosition.animateTo( targetValue = position, - animationSpec = - tween( - durationMillis = 200, - easing = LinearEasing, - ), + animationSpec = tween(durationMillis = 200, easing = LinearEasing), ) } } @@ -114,27 +127,16 @@ fun SeekbarWithTimers( VideoTimer( value = if (isUserInteracting) userPosition else position, timersInverted.first, - onClick = { - clickEvent() - positionTimerOnClick() - }, + onClick = { clickEvent(); positionTimerOnClick() }, modifier = Modifier.width(92.dp), ) - // Seekbar with expanded touch area Box( - modifier = - Modifier - .weight(1f) - .height(48.dp) - .padding(vertical = 8.dp), // Add vertical padding for larger touch area + modifier = Modifier.weight(1f).height(48.dp).padding(vertical = 8.dp), contentAlignment = Alignment.Center, ) { - // Invisible expanded touch area Box( - modifier = Modifier - .fillMaxWidth() - .height(64.dp) // Larger touch area + modifier = Modifier.fillMaxWidth().height(64.dp) .pointerInput(Unit) { detectTapGestures( onTap = { offset -> @@ -143,7 +145,6 @@ fun SeekbarWithTimers( userPosition = newPosition.coerceIn(0f, duration) onValueChange(userPosition) scope.launch { - // Snap to user position immediately to prevent jumping animatedPosition.snapTo(userPosition) isUserInteracting = false onValueChangeFinished() @@ -153,12 +154,9 @@ fun SeekbarWithTimers( } .pointerInput(Unit) { detectDragGestures( - onDragStart = { - isUserInteracting = true - }, + onDragStart = { isUserInteracting = true }, onDragEnd = { scope.launch { - // Allow a tiny window for mpv/viewModel to sync back before releasing control delay(50) animatedPosition.snapTo(userPosition) isUserInteracting = false @@ -182,212 +180,222 @@ fun SeekbarWithTimers( } ) - // Visual seekbar (smaller, centered) Box( - modifier = Modifier - .fillMaxWidth() - .height(32.dp), + modifier = Modifier.fillMaxWidth().height(32.dp), contentAlignment = Alignment.Center, ) { - when (seekbarStyle) { - SeekbarStyle.Standard -> { - StandardSeekbar( - position = if (isUserInteracting) userPosition else animatedPosition.value, - duration = duration, - chapters = chapters, - isPaused = paused, - isScrubbing = isUserInteracting, - seekbarStyle = SeekbarStyle.Standard, - onSeek = { newPosition -> - if (!isUserInteracting) isUserInteracting = true - userPosition = newPosition - onValueChange(newPosition) - }, - onSeekFinished = { - scope.launch { animatedPosition.snapTo(userPosition) } - isUserInteracting = false - onValueChangeFinished() - }, - loopStart = loopStart, - loopEnd = loopEnd, - ) - } - SeekbarStyle.Wavy -> { - SquigglySeekbar( - position = if (isUserInteracting) userPosition else animatedPosition.value, - duration = duration, - chapters = chapters, - isPaused = paused, - isScrubbing = isUserInteracting, - useWavySeekbar = true, - seekbarStyle = SeekbarStyle.Wavy, - onSeek = { }, // Touch handled by parent - onSeekFinished = { }, // Touch handled by parent - loopStart = loopStart, - loopEnd = loopEnd, - ) - } - SeekbarStyle.Thick -> { - StandardSeekbar( - position = if (isUserInteracting) userPosition else animatedPosition.value, - duration = duration, - chapters = chapters, - isPaused = paused, - isScrubbing = isUserInteracting, - seekbarStyle = SeekbarStyle.Thick, - onSeek = { newPosition -> - if (!isUserInteracting) isUserInteracting = true - userPosition = newPosition - onValueChange(newPosition) - }, - onSeekFinished = { - scope.launch { animatedPosition.snapTo(userPosition) } - isUserInteracting = false - onValueChangeFinished() - }, - loopStart = loopStart, - loopEnd = loopEnd, - ) + if (isLiquidUI || seekbarStyle == SeekbarStyle.Liquid) { + LiquidSeekbar( + position = if (isUserInteracting) userPosition else animatedPosition.value, + duration = duration, + chapters = chapters, + isScrubbing = isUserInteracting, + loopStart = loopStart, + loopEnd = loopEnd, + liquidColor = liquidColor + ) + } else { + when (seekbarStyle) { + SeekbarStyle.Standard -> StandardSeekbar( + position = if (isUserInteracting) userPosition else animatedPosition.value, + duration = duration, chapters = chapters, isPaused = paused, isScrubbing = isUserInteracting, + seekbarStyle = SeekbarStyle.Standard, onSeek = { }, onSeekFinished = { }, loopStart = loopStart, loopEnd = loopEnd, + ) + SeekbarStyle.Wavy -> SquigglySeekbar( + position = if (isUserInteracting) userPosition else animatedPosition.value, + duration = duration, chapters = chapters, isPaused = paused, isScrubbing = isUserInteracting, + useWavySeekbar = true, seekbarStyle = SeekbarStyle.Wavy, onSeek = { }, onSeekFinished = { }, loopStart = loopStart, loopEnd = loopEnd, + ) + SeekbarStyle.Thick -> StandardSeekbar( + position = if (isUserInteracting) userPosition else animatedPosition.value, + duration = duration, chapters = chapters, isPaused = paused, isScrubbing = isUserInteracting, + seekbarStyle = SeekbarStyle.Thick, onSeek = { }, onSeekFinished = { }, loopStart = loopStart, loopEnd = loopEnd, + ) + else -> {} + } } } } - } VideoTimer( value = if (timersInverted.second) position - duration else duration, isInverted = timersInverted.second, - onClick = { - clickEvent() - durationTimerOnCLick() - }, + onClick = { clickEvent(); durationTimerOnCLick() }, modifier = Modifier.width(92.dp), ) } } +// ========================================================================= +// UPGRADED LIQUID SEEKBAR (Kyant 2.0.0-alpha03 + Squishy Physics!) +// ========================================================================= @Composable -private fun SquigglySeekbar( - position: Float, - duration: Float, - chapters: ImmutableList, - isPaused: Boolean, - isScrubbing: Boolean, - useWavySeekbar: Boolean, - seekbarStyle: SeekbarStyle, - onSeek: (Float) -> Unit, - onSeekFinished: () -> Unit, - loopStart: Float? = null, - loopEnd: Float? = null, - modifier: Modifier = Modifier, +fun LiquidSeekbar( + position: Float, + duration: Float, + chapters: ImmutableList, + isScrubbing: Boolean = false, + loopStart: Float? = null, + loopEnd: Float? = null, + liquidColor: Color = Color.Unspecified, + modifier: Modifier = Modifier, ) { - val primaryColor = MaterialTheme.colorScheme.primary - val surfaceVariant = MaterialTheme.colorScheme.surfaceVariant + val activeColor = if (liquidColor.isSpecified) liquidColor else MaterialTheme.colorScheme.primary + + // The Bouncy Physics Engine! + val pressProgress by animateFloatAsState( + targetValue = if (isScrubbing) 1f else 0f, + animationSpec = spring(dampingRatio = 0.5f, stiffness = 400f), + label = "pressProgress" + ) + + // Thumb smoothly squishes and widens when you drag it, just like the one you found! + val thumbWidth = androidx.compose.ui.unit.lerp(56.dp, 68.dp, pressProgress) + val thumbHeight = androidx.compose.ui.unit.lerp(32.dp, 24.dp, pressProgress) + + val trackBackdrop = rememberLayerBackdrop { drawContent() } + + androidx.compose.foundation.layout.BoxWithConstraints( + modifier = modifier + .fillMaxWidth() + .height(48.dp), + contentAlignment = Alignment.CenterStart + ) { + val trackWidthPx = constraints.maxWidth.toFloat() + val progress = if (duration > 0f) (position / duration).coerceIn(0f, 1f) else 0f + val playedPx = trackWidthPx * progress + + Canvas( + modifier = Modifier + .fillMaxWidth() + .height(8.dp) + .clip(CircleShape) + .layerBackdrop(trackBackdrop) + ) { + val chapterGapHalf = 1.dp.toPx() + val chapterGaps = chapters + .map { (it.start / duration).coerceIn(0f, 1f) * size.width } + .filter { it > 0f && it < size.width } + .map { x -> (x - chapterGapHalf) to (x + chapterGapHalf) } + + fun drawRangeWithGaps(rangeStart: Float, rangeEnd: Float, gaps: List>, color: Color) { + if (rangeEnd <= rangeStart) return + val relevantGaps = gaps.filter { (gStart, gEnd) -> gEnd > rangeStart && gStart < rangeEnd }.sortedBy { it.first } + var currentPos = rangeStart + for ((gStart, gEnd) in relevantGaps) { + val segmentEnd = gStart.coerceAtMost(rangeEnd) + if (segmentEnd > currentPos) { + drawRect(color, topLeft = Offset(currentPos, 0f), size = Size(segmentEnd - currentPos, size.height)) + } + currentPos = gEnd.coerceAtLeast(currentPos) + } + if (currentPos < rangeEnd) { + drawRect(color, topLeft = Offset(currentPos, 0f), size = Size(rangeEnd - currentPos, size.height)) + } + } + + drawRangeWithGaps(0f, size.width, chapterGaps, activeColor.copy(alpha = 0.3f)) + if (playedPx > 0) { + drawRangeWithGaps(0f, playedPx, chapterGaps, activeColor) + } + + if (loopStart != null || loopEnd != null) { + val loopColor = Color(0xFFFFB300) + val markerWidth = 2.dp.toPx() + if (loopStart != null) drawLine(color = loopColor, start = Offset((loopStart / duration).coerceIn(0f, 1f) * size.width, 0f), end = Offset((loopStart / duration).coerceIn(0f, 1f) * size.width, size.height), strokeWidth = markerWidth) + if (loopEnd != null) drawLine(color = loopColor, start = Offset((loopEnd / duration).coerceIn(0f, 1f) * size.width, 0f), end = Offset((loopEnd / duration).coerceIn(0f, 1f) * size.width, size.height), strokeWidth = markerWidth) + if (loopStart != null && loopEnd != null) drawRect(color = loopColor.copy(alpha = 0.3f), topLeft = Offset((minOf(loopStart, loopEnd) / duration).coerceIn(0f, 1f) * size.width, 0f), size = Size((maxOf(loopStart, loopEnd) / duration).coerceIn(0f, 1f) * size.width - (minOf(loopStart, loopEnd) / duration).coerceIn(0f, 1f) * size.width, size.height)) + } + } + + Box( + modifier = Modifier + .offset { androidx.compose.ui.unit.IntOffset((playedPx - (thumbWidth.toPx() / 2)).roundToInt(), 0) } + .width(thumbWidth) + .height(thumbHeight) + .drawBackdrop( + backdrop = trackBackdrop, + shape = { CircleShape }, + effects = { + vibrancy() + + // 3 & 4. Static Refraction + Depth Effect (With a slight lens boost when pressed!) + lens( + 10f.dp.toPx() * pressProgress, + 14f.dp.toPx() * pressProgress + ) + }, + onDrawSurface = { + drawRect(Color.White.copy(alpha = 1f - pressProgress)) + } + ) + ) + } +} - // Manual Interaction State Tracking - var isPressed by remember { mutableStateOf(false) } - var isDragged by remember { mutableStateOf(false) } - val isInteracting = isPressed || isDragged || isScrubbing - // Animation state + +// ========================================================================= +// ORIGINAL MPVEX SEEKBARS +// ========================================================================= + +@Composable +private fun SquigglySeekbar( + position: Float, duration: Float, chapters: ImmutableList, isPaused: Boolean, isScrubbing: Boolean, + useWavySeekbar: Boolean, seekbarStyle: SeekbarStyle, onSeek: (Float) -> Unit, onSeekFinished: () -> Unit, + loopStart: Float? = null, loopEnd: Float? = null, modifier: Modifier = Modifier, +) { + val activeColor = MaterialTheme.colorScheme.primary + val surfaceVariant = MaterialTheme.colorScheme.surfaceVariant var phaseOffset by remember { mutableFloatStateOf(0f) } var heightFraction by remember { mutableFloatStateOf(1f) } - val scope = rememberCoroutineScope() - // Wave parameters val waveLength = 80f val lineAmplitude = if (useWavySeekbar) 6f else 0f - val phaseSpeed = 10f // px per second + val phaseSpeed = 10f val transitionPeriods = 1.5f val minWaveEndpoint = 0f val matchedWaveEndpoint = 1f val transitionEnabled = true - // Animate height fraction based on paused state and scrubbing state LaunchedEffect(isPaused, isScrubbing, useWavySeekbar) { - if (!useWavySeekbar) { - heightFraction = 0f - return@LaunchedEffect - } - + if (!useWavySeekbar) { heightFraction = 0f; return@LaunchedEffect } scope.launch { val shouldFlatten = isPaused || isScrubbing - val targetHeight = if (shouldFlatten) 0f else 1f - val duration = if (shouldFlatten) 550 else 800 - val startDelay = if (shouldFlatten) 0L else 60L - - kotlinx.coroutines.delay(startDelay) - - val animator = Animatable(heightFraction) - animator.animateTo( - targetValue = targetHeight, - animationSpec = - tween( - durationMillis = duration, - easing = LinearEasing, - ), - ) { - heightFraction = value - } + kotlinx.coroutines.delay(if (shouldFlatten) 0L else 60L) + Animatable(heightFraction).animateTo( + targetValue = if (shouldFlatten) 0f else 1f, + animationSpec = tween(durationMillis = if (shouldFlatten) 550 else 800, easing = LinearEasing), + ) { heightFraction = value } } } - // Animate wave movement only when not paused LaunchedEffect(isPaused, useWavySeekbar) { if (isPaused || !useWavySeekbar) return@LaunchedEffect - var lastFrameTime = withFrameMillis { it } while (isActive) { withFrameMillis { frameTimeMillis -> - val deltaTime = (frameTimeMillis - lastFrameTime) / 1000f - phaseOffset += deltaTime * phaseSpeed - phaseOffset %= waveLength + phaseOffset = (phaseOffset + (frameTimeMillis - lastFrameTime) / 1000f * phaseSpeed) % waveLength lastFrameTime = frameTimeMillis } } } - Canvas( - modifier = - modifier - .fillMaxWidth() - .height(48.dp), - ) { + Canvas(modifier = modifier.fillMaxWidth().height(48.dp)) { val strokeWidth = 5.dp.toPx() val progress = if (duration > 0f) (position / duration).coerceIn(0f, 1f) else 0f val totalWidth = size.width val totalProgressPx = totalWidth * progress val centerY = size.height / 2f + val waveProgressPx = if (!transitionEnabled || progress > matchedWaveEndpoint) totalWidth * progress else totalWidth * (minWaveEndpoint + (matchedWaveEndpoint - minWaveEndpoint) * (progress / matchedWaveEndpoint).coerceIn(0f, 1f)) - // Calculate wave progress - val waveProgressPx = - if (!transitionEnabled || progress > matchedWaveEndpoint) { - totalWidth * progress - } else { - val t = (progress / matchedWaveEndpoint).coerceIn(0f, 1f) - totalWidth * (minWaveEndpoint + (matchedWaveEndpoint - minWaveEndpoint) * t) - } + fun computeAmplitude(x: Float, sign: Float): Float = if (transitionEnabled) sign * heightFraction * lineAmplitude * (((waveProgressPx + transitionPeriods * waveLength / 2f - x) / (transitionPeriods * waveLength)).coerceIn(0f, 1f)) else sign * heightFraction * lineAmplitude - // Helper function to compute amplitude - fun computeAmplitude( - x: Float, - sign: Float, - ): Float = - if (transitionEnabled) { - val length = transitionPeriods * waveLength - val coeff = ((waveProgressPx + length / 2f - x) / length).coerceIn(0f, 1f) - sign * heightFraction * lineAmplitude * coeff - } else { - sign * heightFraction * lineAmplitude - } - - // Build wavy path for played portion val path = Path() val waveStart = -phaseOffset - waveLength / 2f val waveEnd = if (transitionEnabled) totalWidth else waveProgressPx - path.moveTo(waveStart, centerY) - var currentX = waveStart var waveSign = 1f var currentAmp = computeAmplitude(currentX, waveSign) @@ -398,414 +406,142 @@ private fun SquigglySeekbar( val nextX = currentX + dist val midX = currentX + dist / 2f val nextAmp = computeAmplitude(nextX, waveSign) - - path.cubicTo( - midX, - centerY + currentAmp, - midX, - centerY + nextAmp, - nextX, - centerY + nextAmp, - ) - + path.cubicTo(midX, centerY + currentAmp, midX, centerY + nextAmp, nextX, centerY + nextAmp) currentAmp = nextAmp currentX = nextX } - // Draw path up to progress position using clipping val clipTop = lineAmplitude + strokeWidth val gapHalf = 1.dp.toPx() - fun drawPathWithGaps( - startX: Float, - endX: Float, - color: Color, - ) { + fun drawPathWithGaps(startX: Float, endX: Float, color: Color) { if (endX <= startX) return if (duration <= 0f) { - clipRect( - left = startX, - top = centerY - clipTop, - right = endX, - bottom = centerY + clipTop, - ) { - drawPath( - path = path, - color = color, - style = Stroke(width = strokeWidth, cap = StrokeCap.Round), - ) - } + clipRect(left = startX, top = centerY - clipTop, right = endX, bottom = centerY + clipTop) { drawPath(path = path, color = color, style = Stroke(width = strokeWidth, cap = StrokeCap.Round)) } return } - val gaps = - chapters - .map { (it.start / duration).coerceIn(0f, 1f) * totalWidth } - .filter { it in startX..endX } - .sorted() - .map { x -> (x - gapHalf).coerceAtLeast(startX) to (x + gapHalf).coerceAtMost(endX) } - + val gaps = chapters.map { (it.start / duration).coerceIn(0f, 1f) * totalWidth }.filter { it in startX..endX }.sorted().map { x -> (x - gapHalf).coerceAtLeast(startX) to (x + gapHalf).coerceAtMost(endX) } var segmentStart = startX for ((gapStart, gapEnd) in gaps) { - if (gapStart > segmentStart) { - clipRect( - left = segmentStart, - top = centerY - clipTop, - right = gapStart, - bottom = centerY + clipTop, - ) { - drawPath( - path = path, - color = color, - style = Stroke(width = strokeWidth, cap = StrokeCap.Round), - ) - } - } + if (gapStart > segmentStart) clipRect(left = segmentStart, top = centerY - clipTop, right = gapStart, bottom = centerY + clipTop) { drawPath(path = path, color = color, style = Stroke(width = strokeWidth, cap = StrokeCap.Round)) } segmentStart = gapEnd } - if (segmentStart < endX) { - clipRect( - left = segmentStart, - top = centerY - clipTop, - right = endX, - bottom = centerY + clipTop, - ) { - drawPath( - path = path, - color = color, - style = Stroke(width = strokeWidth, cap = StrokeCap.Round), - ) - } - } + if (segmentStart < endX) clipRect(left = segmentStart, top = centerY - clipTop, right = endX, bottom = centerY + clipTop) { drawPath(path = path, color = color, style = Stroke(width = strokeWidth, cap = StrokeCap.Round)) } } - // Played segment - drawPathWithGaps(0f, totalProgressPx, primaryColor) - - if (transitionEnabled) { - val disabledAlpha = 77f / 255f - drawPathWithGaps(totalProgressPx, totalWidth, primaryColor.copy(alpha = disabledAlpha)) - } else { - drawLine( - color = surfaceVariant.copy(alpha = 0.4f), - start = Offset(totalProgressPx, centerY), - end = Offset(totalWidth, centerY), - strokeWidth = strokeWidth, - cap = StrokeCap.Round, - ) - } - - // Draw round cap + drawPathWithGaps(0f, totalProgressPx, activeColor) + if (transitionEnabled) drawPathWithGaps(totalProgressPx, totalWidth, activeColor.copy(alpha = 77f / 255f)) else drawLine(color = surfaceVariant.copy(alpha = 0.4f), start = Offset(totalProgressPx, centerY), end = Offset(totalWidth, centerY), strokeWidth = strokeWidth, cap = StrokeCap.Round) + val startAmp = kotlin.math.cos(kotlin.math.abs(waveStart) / waveLength * (2f * kotlin.math.PI.toFloat())) - drawCircle( - color = primaryColor, - radius = strokeWidth / 2f, - center = Offset(0f, centerY + startAmp * lineAmplitude * heightFraction), - ) + drawCircle(color = activeColor, radius = strokeWidth / 2f, center = Offset(0f, centerY + startAmp * lineAmplitude * heightFraction)) - // Vertical Bar Thumb val barHalfHeight = (lineAmplitude + strokeWidth) - val barWidth = 5.dp.toPx() - - if (barHalfHeight > 0.5f) { - drawLine( - color = primaryColor, - start = Offset(totalProgressPx, centerY - barHalfHeight), - end = Offset(totalProgressPx, centerY + barHalfHeight), - strokeWidth = barWidth, - cap = StrokeCap.Round, - ) - } + if (barHalfHeight > 0.5f) drawLine(color = activeColor, start = Offset(totalProgressPx, centerY - barHalfHeight), end = Offset(totalProgressPx, centerY + barHalfHeight), strokeWidth = 5.dp.toPx(), cap = StrokeCap.Round) - // A-B Loop Indicators for SquigglySeekbar if (loopStart != null || loopEnd != null) { val loopColor = Color(0xFFFFB300) val markerWidth = 2.dp.toPx() - - if (loopStart != null && duration > 0f) { - val startPx = (loopStart / duration).coerceIn(0f, 1f) * totalWidth - drawLine( - color = loopColor, - start = Offset(startPx, centerY - lineAmplitude - strokeWidth), - end = Offset(startPx, centerY + lineAmplitude + strokeWidth), - strokeWidth = markerWidth, - ) - } - - if (loopEnd != null && duration > 0f) { - val endPx = (loopEnd / duration).coerceIn(0f, 1f) * totalWidth - drawLine( - color = loopColor, - start = Offset(endPx, centerY - lineAmplitude - strokeWidth), - end = Offset(endPx, centerY + lineAmplitude + strokeWidth), - strokeWidth = markerWidth, - ) - } - - if (loopStart != null && loopEnd != null && duration > 0f) { - val minPx = (minOf(loopStart, loopEnd) / duration).coerceIn(0f, 1f) * totalWidth - val maxPx = (maxOf(loopStart, loopEnd) / duration).coerceIn(0f, 1f) * totalWidth - drawRect( - color = loopColor.copy(alpha = 0.2f), - topLeft = Offset(minPx, centerY - lineAmplitude - strokeWidth), - size = Size(maxPx - minPx, (lineAmplitude + strokeWidth) * 2), - ) - } + if (loopStart != null && duration > 0f) drawLine(color = loopColor, start = Offset((loopStart / duration).coerceIn(0f, 1f) * totalWidth, centerY - lineAmplitude - strokeWidth), end = Offset((loopStart / duration).coerceIn(0f, 1f) * totalWidth, centerY + lineAmplitude + strokeWidth), strokeWidth = markerWidth) + if (loopEnd != null && duration > 0f) drawLine(color = loopColor, start = Offset((loopEnd / duration).coerceIn(0f, 1f) * totalWidth, centerY - lineAmplitude - strokeWidth), end = Offset((loopEnd / duration).coerceIn(0f, 1f) * totalWidth, centerY + lineAmplitude + strokeWidth), strokeWidth = markerWidth) + if (loopStart != null && loopEnd != null && duration > 0f) drawRect(color = loopColor.copy(alpha = 0.2f), topLeft = Offset((minOf(loopStart, loopEnd) / duration).coerceIn(0f, 1f) * totalWidth, centerY - lineAmplitude - strokeWidth), size = Size((maxOf(loopStart, loopEnd) / duration).coerceIn(0f, 1f) * totalWidth - (minOf(loopStart, loopEnd) / duration).coerceIn(0f, 1f) * totalWidth, (lineAmplitude + strokeWidth) * 2)) } } } @Composable -fun VideoTimer( - value: Float, - isInverted: Boolean, - modifier: Modifier = Modifier, - onClick: () -> Unit = {}, -) { +fun VideoTimer(value: Float, isInverted: Boolean, modifier: Modifier = Modifier, onClick: () -> Unit = {}) { val interactionSource = remember { MutableInteractionSource() } Text( - modifier = - modifier - .fillMaxHeight() - .clickable( - interactionSource = interactionSource, - indication = ripple(), - onClick = onClick, - ) - .wrapContentHeight(Alignment.CenterVertically), - text = Utils.prettyTime(value.toInt(), isInverted), - color = Color.White, - textAlign = TextAlign.Center, + modifier = modifier.fillMaxHeight().clickable(interactionSource = interactionSource, indication = ripple(), onClick = onClick).wrapContentHeight(Alignment.CenterVertically), + text = Utils.prettyTime(value.toInt(), isInverted), color = Color.White, textAlign = TextAlign.Center, ) } @Composable +@OptIn(ExperimentalMaterial3Api::class) fun StandardSeekbar( - position: Float, - duration: Float, - chapters: ImmutableList, - isPaused: Boolean = false, - isScrubbing: Boolean = false, - useWavySeekbar: Boolean = false, - seekbarStyle: SeekbarStyle = SeekbarStyle.Standard, - onSeek: (Float) -> Unit, - onSeekFinished: () -> Unit, - loopStart: Float? = null, - loopEnd: Float? = null, - modifier: Modifier = Modifier, + position: Float, duration: Float, chapters: ImmutableList, isPaused: Boolean = false, isScrubbing: Boolean = false, + seekbarStyle: SeekbarStyle = SeekbarStyle.Standard, onSeek: (Float) -> Unit, onSeekFinished: () -> Unit, + loopStart: Float? = null, loopEnd: Float? = null, modifier: Modifier = Modifier, ) { - val primaryColor = MaterialTheme.colorScheme.primary + val activeColor = MaterialTheme.colorScheme.primary val interactionSource = remember { MutableInteractionSource() } - - // Animation state (same as SquigglySeekbar) var heightFraction by remember { mutableFloatStateOf(1f) } val scope = rememberCoroutineScope() - // Animate height fraction based on paused state and scrubbing state (same as SquigglySeekbar) LaunchedEffect(isPaused, isScrubbing) { scope.launch { val shouldFlatten = isPaused || isScrubbing - val targetHeight = if (shouldFlatten) 0.7f else 1f // Slightly less dramatic for standard seekbar - val animationDuration = if (shouldFlatten) 550 else 800 - val startDelay = if (shouldFlatten) 0L else 60L - - kotlinx.coroutines.delay(startDelay) - - val animator = Animatable(heightFraction) - animator.animateTo( - targetValue = targetHeight, - animationSpec = tween( - durationMillis = animationDuration, - easing = LinearEasing, - ), - ) { - heightFraction = value - } + kotlinx.coroutines.delay(if (shouldFlatten) 0L else 60L) + Animatable(heightFraction).animateTo(targetValue = if (shouldFlatten) 0.7f else 1f, animationSpec = tween(durationMillis = if (shouldFlatten) 550 else 800, easing = LinearEasing)) { heightFraction = value } } } val isThick = seekbarStyle == SeekbarStyle.Thick val baseTrackHeight = if (isThick) 16.dp else 8.dp - val trackHeightDp = baseTrackHeight * heightFraction // Apply animation to track height + val trackHeightDp = baseTrackHeight * heightFraction val thumbWidth = 6.dp val thumbHeight = if (isThick) 16.dp else 24.dp val thumbShape = if (isThick) RoundedCornerShape(thumbWidth / 2) else CircleShape Slider( - value = position, - onValueChange = onSeek, - onValueChangeFinished = onSeekFinished, - valueRange = 0f..duration.coerceAtLeast(0.1f), - modifier = Modifier.fillMaxWidth(), - interactionSource = interactionSource, + value = position, onValueChange = onSeek, onValueChangeFinished = onSeekFinished, valueRange = 0f..duration.coerceAtLeast(0.1f), + modifier = Modifier.fillMaxWidth(), interactionSource = interactionSource, track = { sliderState -> - val disabledAlpha = 0.3f - - Canvas( - modifier = Modifier - .fillMaxWidth() - .height(trackHeightDp), - ) { + Canvas(modifier = Modifier.fillMaxWidth().height(trackHeightDp)) { val min = sliderState.valueRange.start val max = sliderState.valueRange.endInclusive val range = (max - min).takeIf { it > 0f } ?: 1f - val playedFraction = ((sliderState.value - min) / range).coerceIn(0f, 1f) - val playedPx = size.width * playedFraction val trackHeight = size.height - // Radius for the outer ends of the seekbar val outerRadius = trackHeight / 2f - - // MODIFIED: For Thick style, inner corners now match the outer rounding val innerRadius = if (isThick) outerRadius else 2.dp.toPx() - - val thumbTrackGapSize = 14.dp.toPx() - val gapHalf = thumbTrackGapSize / 2f + val gapHalf = 14.dp.toPx() / 2f val chapterGapHalf = 1.dp.toPx() val thumbGapStart = (playedPx - gapHalf).coerceIn(0f, size.width) val thumbGapEnd = (playedPx + gapHalf).coerceIn(0f, size.width) - - val chapterGaps = chapters - .map { (it.start / duration).coerceIn(0f, 1f) * size.width } - .filter { it > 0f && it < size.width } - .map { x -> (x - chapterGapHalf) to (x + chapterGapHalf) } + val chapterGaps = chapters.map { (it.start / duration).coerceIn(0f, 1f) * size.width }.filter { it > 0f && it < size.width }.map { x -> (x - chapterGapHalf) to (x + chapterGapHalf) } fun drawSegment(startX: Float, endX: Float, color: Color) { if (endX - startX < 0.5f) return - val path = Path() val isOuterLeft = startX <= 0.5f val isInnerLeft = kotlin.math.abs(startX - thumbGapEnd) < 0.5f - - val cornerRadiusLeft = when { - isOuterLeft -> androidx.compose.ui.geometry.CornerRadius(outerRadius) - isInnerLeft -> androidx.compose.ui.geometry.CornerRadius(innerRadius) - else -> androidx.compose.ui.geometry.CornerRadius.Zero - } - + val cornerRadiusLeft = when { isOuterLeft -> androidx.compose.ui.geometry.CornerRadius(outerRadius); isInnerLeft -> androidx.compose.ui.geometry.CornerRadius(innerRadius); else -> androidx.compose.ui.geometry.CornerRadius.Zero } val isOuterRight = endX >= size.width - 0.5f val isInnerRight = kotlin.math.abs(endX - thumbGapStart) < 0.5f - - val cornerRadiusRight = when { - isOuterRight -> androidx.compose.ui.geometry.CornerRadius(outerRadius) - isInnerRight -> androidx.compose.ui.geometry.CornerRadius(innerRadius) - else -> androidx.compose.ui.geometry.CornerRadius.Zero - } - - path.addRoundRect( - androidx.compose.ui.geometry.RoundRect( - left = startX, - top = 0f, - right = endX, - bottom = trackHeight, - topLeftCornerRadius = cornerRadiusLeft, - bottomLeftCornerRadius = cornerRadiusLeft, - topRightCornerRadius = cornerRadiusRight, - bottomRightCornerRadius = cornerRadiusRight - ) - ) + val cornerRadiusRight = when { isOuterRight -> androidx.compose.ui.geometry.CornerRadius(outerRadius); isInnerRight -> androidx.compose.ui.geometry.CornerRadius(innerRadius); else -> androidx.compose.ui.geometry.CornerRadius.Zero } + path.addRoundRect(androidx.compose.ui.geometry.RoundRect(left = startX, top = 0f, right = endX, bottom = trackHeight, topLeftCornerRadius = cornerRadiusLeft, bottomLeftCornerRadius = cornerRadiusLeft, topRightCornerRadius = cornerRadiusRight, bottomRightCornerRadius = cornerRadiusRight)) drawPath(path, color) } - fun drawRangeWithGaps( - rangeStart: Float, - rangeEnd: Float, - gaps: List>, - color: Color - ) { + fun drawRangeWithGaps(rangeStart: Float, rangeEnd: Float, gaps: List>, color: Color) { if (rangeEnd <= rangeStart) return - val relevantGaps = gaps - .filter { (gStart, gEnd) -> gEnd > rangeStart && gStart < rangeEnd } - .sortedBy { it.first } - + val relevantGaps = gaps.filter { (gStart, gEnd) -> gEnd > rangeStart && gStart < rangeEnd }.sortedBy { it.first } var currentPos = rangeStart for ((gStart, gEnd) in relevantGaps) { val segmentEnd = gStart.coerceAtMost(rangeEnd) - if (segmentEnd > currentPos) { - drawSegment(currentPos, segmentEnd, color) - } + if (segmentEnd > currentPos) drawSegment(currentPos, segmentEnd, color) currentPos = gEnd.coerceAtLeast(currentPos) } - if (currentPos < rangeEnd) { - drawSegment(currentPos, rangeEnd, color) - } + if (currentPos < rangeEnd) drawSegment(currentPos, rangeEnd, color) } - // 1. Unplayed Background - drawRangeWithGaps(thumbGapEnd, size.width, chapterGaps, primaryColor.copy(alpha = disabledAlpha)) - - // 2. Played - if (thumbGapStart > 0) { - drawRangeWithGaps(0f, thumbGapStart, chapterGaps, primaryColor) - } + drawRangeWithGaps(thumbGapEnd, size.width, chapterGaps, activeColor.copy(alpha = 0.3f)) + if (thumbGapStart > 0) drawRangeWithGaps(0f, thumbGapStart, chapterGaps, activeColor) - // 3. A-B Loop Indicators if (loopStart != null || loopEnd != null) { - val loopColor = Color(0xFFFFB300) // Amber/Gold color for loop + val loopColor = Color(0xFFFFB300) val markerWidth = 2.dp.toPx() - - // Draw loop start marker - if (loopStart != null) { - val startPx = (loopStart / duration).coerceIn(0f, 1f) * size.width - drawLine( - color = loopColor, - start = Offset(startPx, 0f), - end = Offset(startPx, size.height), - strokeWidth = markerWidth - ) - } - - // Draw loop end marker - if (loopEnd != null) { - val endPx = (loopEnd / duration).coerceIn(0f, 1f) * size.width - drawLine( - color = loopColor, - start = Offset(endPx, 0f), - end = Offset(endPx, size.height), - strokeWidth = markerWidth - ) - } - - // Draw connected segment if both are set - if (loopStart != null && loopEnd != null) { - val minPx = (minOf(loopStart, loopEnd) / duration).coerceIn(0f, 1f) * size.width - val maxPx = (maxOf(loopStart, loopEnd) / duration).coerceIn(0f, 1f) * size.width - - // Draw a semi-transparent overlay between A and B - drawRect( - color = loopColor.copy(alpha = 0.3f), - topLeft = Offset(minPx, 0f), - size = Size(maxPx - minPx, size.height) - ) - } + if (loopStart != null) drawLine(color = loopColor, start = Offset((loopStart / duration).coerceIn(0f, 1f) * size.width, 0f), end = Offset((loopStart / duration).coerceIn(0f, 1f) * size.width, size.height), strokeWidth = markerWidth) + if (loopEnd != null) drawLine(color = loopColor, start = Offset((loopEnd / duration).coerceIn(0f, 1f) * size.width, 0f), end = Offset((loopEnd / duration).coerceIn(0f, 1f) * size.width, size.height), strokeWidth = markerWidth) + if (loopStart != null && loopEnd != null) drawRect(color = loopColor.copy(alpha = 0.3f), topLeft = Offset((minOf(loopStart, loopEnd) / duration).coerceIn(0f, 1f) * size.width, 0f), size = Size((maxOf(loopStart, loopEnd) / duration).coerceIn(0f, 1f) * size.width - (minOf(loopStart, loopEnd) / duration).coerceIn(0f, 1f) * size.width, size.height)) } } }, - thumb = { - Box( - modifier = Modifier - .width(thumbWidth) - .height(thumbHeight) - .background(primaryColor, thumbShape) - ) - } - ) - } - -@Preview -@Composable -private fun PreviewSeekBar() { - SeekbarWithTimers( - position = 30f, - duration = 180f, - onValueChange = {}, - onValueChangeFinished = {}, - timersInverted = Pair(false, true), - positionTimerOnClick = {}, - durationTimerOnCLick = {}, - chapters = persistentListOf(), - paused = false, - ) + thumb = { Box(modifier = Modifier.width(thumbWidth).height(thumbHeight).background(activeColor, thumbShape)) } + ) } diff --git a/app/src/main/java/app/marlboroadvance/mpvex/ui/player/controls/components/SlideToUnlock.kt b/app/src/main/java/app/marlboroadvance/mpvex/ui/player/controls/components/SlideToUnlock.kt index 3cc4eda95..08dfad08d 100644 --- a/app/src/main/java/app/marlboroadvance/mpvex/ui/player/controls/components/SlideToUnlock.kt +++ b/app/src/main/java/app/marlboroadvance/mpvex/ui/player/controls/components/SlideToUnlock.kt @@ -2,11 +2,13 @@ package app.marlboroadvance.mpvex.ui.player.controls.components import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.tween +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding @@ -19,6 +21,7 @@ import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.LockOpen import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -33,13 +36,14 @@ import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import kotlinx.coroutines.launch import kotlin.math.roundToInt +import app.marlboroadvance.mpvex.ui.components.liquid.LocalLiquidBackdrop +import app.marlboroadvance.mpvex.ui.components.liquid.LiquidGlassSurface +import app.marlboroadvance.mpvex.preferences.LiquidTarget @Composable fun SlideToUnlock( @@ -47,58 +51,75 @@ fun SlideToUnlock( modifier: Modifier = Modifier, onDraggingChanged: (Boolean) -> Unit = {}, ) { - val coroutineScope = rememberCoroutineScope() - - var containerWidthPx by remember { mutableFloatStateOf(0f) } - val sliderSize = 56.dp - + val maxOffset = with(LocalDensity.current) { (200.dp - 64.dp).toPx() } val offsetX = remember { Animatable(0f) } + val coroutineScope = rememberCoroutineScope() var isDragging by remember { mutableStateOf(false) } - + + val showUnlockIcon = offsetX.value > maxOffset * 0.8f + Box( - modifier = modifier - .width(200.dp) - .height(64.dp) - .clip(RoundedCornerShape(32.dp)) - .background(Color.Black.copy(alpha = 0.6f)) - .padding(4.dp) - .onSizeChanged { size -> - containerWidthPx = size.width.toFloat() - }, + modifier = modifier.width(200.dp).height(64.dp), + contentAlignment = Alignment.CenterStart, ) { - val sliderSizePx = containerWidthPx * (56f / 192f) // Accounting for padding (200 - 8) - val maxOffset = if (containerWidthPx > 0f) containerWidthPx - sliderSizePx else 0f - val unlockThreshold = if (maxOffset > 0f) maxOffset * 0.85f else Float.MAX_VALUE - - // Background text - slightly to the right - Box( - modifier = Modifier - .matchParentSize() - .padding(start = 55.dp) - .alpha(if (maxOffset > 0f) 1f - (offsetX.value / maxOffset).coerceIn(0f, 1f) else 1f), - contentAlignment = Alignment.Center, + val backdrop = LocalLiquidBackdrop.current + if (backdrop != null) { + LiquidGlassSurface( + modifier = Modifier.width(200.dp).height(64.dp), + backdrop = backdrop, + target = LiquidTarget.BUTTON, + shape = RoundedCornerShape(100.dp), + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background( + MaterialTheme.colorScheme.primary.copy( + alpha = (offsetX.value / maxOffset).coerceIn(0f, 0.5f) + ) + ) + ) + } + } else { + Surface( + modifier = Modifier.width(200.dp).height(64.dp).alpha(0.5f), + shape = RoundedCornerShape(100.dp), + color = MaterialTheme.colorScheme.surfaceContainer, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background( + MaterialTheme.colorScheme.primary.copy( + alpha = (offsetX.value / maxOffset).coerceIn(0f, 0.5f) + ) + ) + ) + } + } + + Row( + modifier = Modifier.fillMaxSize(), + verticalAlignment = Alignment.CenterVertically, ) { + Spacer(modifier = Modifier.width(72.dp)) Text( - text = "Slide to Unlock", - color = Color.White.copy(alpha = 0.7f), - fontSize = 16.sp, - fontWeight = FontWeight.Medium, + text = "Slide to unlock", + style = MaterialTheme.typography.bodyMedium, + color = if (backdrop != null) Color.White.copy(alpha = 0.7f) else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + modifier = Modifier.alpha(1f - (offsetX.value / (maxOffset * 0.5f)).coerceIn(0f, 1f)), ) } - - // Slider button - val progress = if (maxOffset > 0f) (offsetX.value / maxOffset).coerceIn(0f, 1f) else 0f - val showUnlockIcon = progress > 0.5f - + Box( modifier = Modifier .offset { IntOffset(offsetX.value.roundToInt(), 0) } - .size(sliderSize) + .padding(4.dp) + .size(56.dp) .clip(CircleShape) .background(MaterialTheme.colorScheme.primary) - .pointerInput(containerWidthPx) { - if (containerWidthPx <= 0f) return@pointerInput - + .pointerInput(Unit) { detectHorizontalDragGestures( onDragStart = { isDragging = true @@ -107,16 +128,11 @@ fun SlideToUnlock( onDragEnd = { isDragging = false onDraggingChanged(false) - if (offsetX.value >= unlockThreshold) { - // Unlock triggered - instantly unlock without animation + if (offsetX.value >= maxOffset * 0.9f) { onUnlock() } else { - // Snap back coroutineScope.launch { - offsetX.animateTo( - targetValue = 0f, - animationSpec = tween(durationMillis = 300), - ) + offsetX.animateTo(targetValue = 0f, animationSpec = tween(durationMillis = 300)) } } }, @@ -124,10 +140,7 @@ fun SlideToUnlock( isDragging = false onDraggingChanged(false) coroutineScope.launch { - offsetX.animateTo( - targetValue = 0f, - animationSpec = tween(durationMillis = 300), - ) + offsetX.animateTo(targetValue = 0f, animationSpec = tween(durationMillis = 300)) } }, onHorizontalDrag = { _, dragAmount -> @@ -140,11 +153,7 @@ fun SlideToUnlock( }, contentAlignment = Alignment.Center, ) { - // Crossfade between lock and unlock icons - androidx.compose.animation.Crossfade( - targetState = showUnlockIcon, - animationSpec = tween(durationMillis = 200), - ) { showUnlock -> + androidx.compose.animation.Crossfade(targetState = showUnlockIcon, animationSpec = tween(durationMillis = 200)) { showUnlock -> Icon( imageVector = if (showUnlock) Icons.Filled.LockOpen else Icons.Filled.Lock, contentDescription = "Slide to unlock", diff --git a/app/src/main/java/app/marlboroadvance/mpvex/ui/player/controls/components/SpeedControlSlider.kt b/app/src/main/java/app/marlboroadvance/mpvex/ui/player/controls/components/SpeedControlSlider.kt index 0e585c8ae..f8b2be84d 100644 --- a/app/src/main/java/app/marlboroadvance/mpvex/ui/player/controls/components/SpeedControlSlider.kt +++ b/app/src/main/java/app/marlboroadvance/mpvex/ui/player/controls/components/SpeedControlSlider.kt @@ -47,191 +47,173 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import app.marlboroadvance.mpvex.ui.theme.spacing import kotlinx.coroutines.delay +import app.marlboroadvance.mpvex.ui.components.liquid.LocalLiquidBackdrop +import app.marlboroadvance.mpvex.ui.components.liquid.LiquidGlassSurface +import app.marlboroadvance.mpvex.preferences.LiquidTarget -/** - * A compact speed control display that shows available speed options (0.25x to 4x) - * with an indicator showing the current speed. Styled to match the zoom overlay. - * - * @param currentSpeed The current playback speed (0.25f to 4.0f) - * @param modifier Optional modifier for the container - */ @Composable -fun SpeedControlSlider( +fun CompactSpeedIndicator( currentSpeed: Float, modifier: Modifier = Modifier, ) { - // Speed presets from 0.25x to 4x - val speedPresets = listOf(0.25f, 0.5f, 1.0f, 1.5f, 2.0f, 2.5f, 3.0f, 4.0f) - - // Find the index of current speed in presets - val currentIndex = speedPresets.indexOfFirst { - kotlin.math.abs(it - currentSpeed) < 0.05f - }.coerceIn(0, speedPresets.size - 1) - - val primaryColor = MaterialTheme.colorScheme.primary - val onSurfaceColor = MaterialTheme.colorScheme.onSurface - // Use a Surface with less rounded corners instead of CircleShape - Surface( - shape = RoundedCornerShape(12.dp), - color = MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.55f), - contentColor = MaterialTheme.colorScheme.onSurface, - tonalElevation = 0.dp, - shadowElevation = 0.dp, - border = BorderStroke( - 1.dp, - MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f), - ), - modifier = modifier.animateContentSize(), - ) { - Box( - modifier = Modifier.padding( - vertical = MaterialTheme.spacing.small, - horizontal = MaterialTheme.spacing.medium, - ), - contentAlignment = Alignment.Center, - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp), + val backdrop = LocalLiquidBackdrop.current + + if (backdrop != null) { + LiquidGlassSurface( + backdrop = backdrop, + target = LiquidTarget.BUTTON, + shape = RoundedCornerShape(100.dp), + modifier = modifier ) { - // Speed labels - compact version - Row( - modifier = Modifier.width(280.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - speedPresets.forEach { speed -> - val isCurrentSpeed = kotlin.math.abs(currentSpeed - speed) < 0.05f - Text( - text = "${speed.format()}x", - fontSize = if (isCurrentSpeed) 13.sp else 10.sp, - fontWeight = if (isCurrentSpeed) FontWeight.Bold else FontWeight.Normal, - color = if (isCurrentSpeed) { - primaryColor - } else { - onSurfaceColor.copy(alpha = 0.7f) - }, - ) - } - } - - // Compact slider track - Canvas( - modifier = Modifier - .width(280.dp) - .height(3.dp), - ) { - val trackWidth = size.width - val trackHeight = 3.dp.toPx() - val centerY = size.height / 2 - val segmentWidth = trackWidth / (speedPresets.size - 1) - - // Background track - drawLine( - color = onSurfaceColor.copy(alpha = 0.35f), - start = Offset(0f, centerY), - end = Offset(trackWidth, centerY), - strokeWidth = trackHeight, - cap = StrokeCap.Round, - ) - - // Progress track (filled portion up to current speed) - val progressX = currentIndex * segmentWidth - drawLine( - color = primaryColor, - start = Offset(0f, centerY), - end = Offset(progressX, centerY), - strokeWidth = trackHeight, - cap = StrokeCap.Round, - ) - - // Draw tick marks for each speed preset - speedPresets.forEachIndexed { index, _ -> - val tickX = index * segmentWidth - drawCircle( - color = if (index <= currentIndex) { - primaryColor - } else { - onSurfaceColor.copy(alpha = 0.7f) - }, - radius = 2.5.dp.toPx(), - center = Offset(tickX, centerY), - ) - } - } - - // Current speed display - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - ) { - Icon( - imageVector = Icons.Filled.FastForward, - contentDescription = null, - modifier = Modifier.size(16.dp), + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = Modifier.padding(horizontal = MaterialTheme.spacing.medium, vertical = MaterialTheme.spacing.small) + ) { + Icon( + imageVector = Icons.Filled.FastForward, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = Color.White + ) + Text( + text = "${currentSpeed.format()}x", + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(start = 4.dp), + color = Color.White + ) + } + } + } else { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = modifier + .background( + color = MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.55f), + shape = RoundedCornerShape(100.dp) ) - Text( - text = "${currentSpeed.format()}x Speed Playing", - fontSize = 14.sp, - fontWeight = FontWeight.Bold, - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.padding(start = 4.dp), + .border( + BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), + shape = RoundedCornerShape(100.dp) ) - } + .padding(horizontal = MaterialTheme.spacing.medium, vertical = MaterialTheme.spacing.small) + ) { + Icon( + imageVector = Icons.Filled.FastForward, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurface + ) + Text( + text = "${currentSpeed.format()}x", + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(start = 4.dp), + color = MaterialTheme.colorScheme.onSurface + ) } - } } } -/** - * A compact speed indicator that shows just the icon and speed value. - * Used when dynamic speed overlay is collapsed or disabled. - * - * @param currentSpeed The current playback speed - * @param modifier Optional modifier for the container - */ +private fun Float.format(): String { + return when { + this % 1.0f == 0.0f -> String.format("%.0f", this) + this % 0.5f == 0.0f -> String.format("%.1f", this) + else -> String.format("%.2f", this) + } +} + @Composable -fun CompactSpeedIndicator( +fun SpeedControlSlider( currentSpeed: Float, + speedPresets: List = listOf(0.25f, 0.5f, 1.0f, 1.25f, 1.5f, 2.0f, 3.0f, 4.0f), modifier: Modifier = Modifier, ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - modifier = modifier - .background( - color = MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.55f), - shape = RoundedCornerShape(100.dp) - ) - .border( - BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), - shape = RoundedCornerShape(100.dp) - ) - .padding(horizontal = MaterialTheme.spacing.medium, vertical = MaterialTheme.spacing.small) - ) { - Icon( - imageVector = Icons.Filled.FastForward, - contentDescription = null, - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.onSurface - ) - Text( - text = "${currentSpeed.format()}x", - fontSize = 14.sp, - fontWeight = FontWeight.Bold, - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.padding(start = 4.dp), - color = MaterialTheme.colorScheme.onSurface // Explicitly set color - ) + var isExpanded by remember { mutableStateOf(false) } + + LaunchedEffect(currentSpeed) { + if (!isExpanded) { + isExpanded = true + } + delay(1500) + isExpanded = false } -} -/** - * Format float speed value to display with minimal decimal places - */ -private fun Float.format(): String { - return when { - this % 1.0f == 0.0f -> this.toInt().toString() - else -> String.format("%.2f", this).trimEnd('0').trimEnd('.') + Box( + modifier = modifier.height(36.dp), + contentAlignment = Alignment.Center + ) { + AnimatedVisibility( + visible = isExpanded, + enter = fadeIn() + expandHorizontally( + expandFrom = Alignment.CenterHorizontally, + animationSpec = spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessMedium) + ), + exit = fadeOut(animationSpec = tween(300)) + shrinkHorizontally( + shrinkTowards = Alignment.CenterHorizontally, + animationSpec = tween(300, delayMillis = 100) + ) + ) { + val primaryColor = MaterialTheme.colorScheme.primary + val onSurfaceColor = MaterialTheme.colorScheme.onSurface + + Row( + modifier = Modifier + .background( + color = MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.85f), + shape = RoundedCornerShape(100.dp) + ) + .border( + BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), + shape = RoundedCornerShape(100.dp) + ) + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Canvas(modifier = Modifier.width(100.dp).height(24.dp)) { + val trackWidth = size.width + val trackHeight = 4.dp.toPx() + val centerY = size.height / 2 + val segmentWidth = trackWidth / (speedPresets.size - 1) + + drawLine( + color = onSurfaceColor.copy(alpha = 0.35f), + start = Offset(0f, centerY), + end = Offset(trackWidth, centerY), + strokeWidth = trackHeight, + cap = StrokeCap.Round, + ) + + val progressX = (currentSpeed - speedPresets.first()) / (speedPresets.last() - speedPresets.first()) * trackWidth + + drawLine( + color = primaryColor, + start = Offset(0f, centerY), + end = Offset(progressX.coerceIn(0f, trackWidth), centerY), + strokeWidth = trackHeight, + cap = StrokeCap.Round, + ) + + drawCircle( + color = primaryColor, + radius = 6.dp.toPx(), + center = Offset(progressX.coerceIn(0f, trackWidth), centerY) + ) + } + + Text( + text = "${currentSpeed.format()}x", + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface + ) + } + } } } diff --git a/app/src/main/java/app/marlboroadvance/mpvex/ui/player/controls/components/VerticalSliders.kt b/app/src/main/java/app/marlboroadvance/mpvex/ui/player/controls/components/VerticalSliders.kt index e066e6d87..2b33abfae 100644 --- a/app/src/main/java/app/marlboroadvance/mpvex/ui/player/controls/components/VerticalSliders.kt +++ b/app/src/main/java/app/marlboroadvance/mpvex/ui/player/controls/components/VerticalSliders.kt @@ -31,6 +31,7 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -38,6 +39,11 @@ import app.marlboroadvance.mpvex.R import app.marlboroadvance.mpvex.ui.theme.spacing import kotlin.math.roundToInt +// --- NEW LIQUID IMPORTS --- +import app.marlboroadvance.mpvex.ui.components.liquid.LocalLiquidBackdrop +import app.marlboroadvance.mpvex.ui.components.liquid.LiquidGlassSurface +import app.marlboroadvance.mpvex.preferences.LiquidTarget + fun percentage( value: Float, range: ClosedFloatingPointRange, @@ -57,32 +63,37 @@ fun VerticalSlider( overflowRange: ClosedFloatingPointRange? = null, ) { val coercedValue = value.coerceIn(range) - Box( - modifier = - modifier - .height(120.dp) - .aspectRatio(0.2f) - .clip(RoundedCornerShape(16.dp)) - .background(MaterialTheme.colorScheme.background) - .border( - width = 1.dp, - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f), - shape = RoundedCornerShape(16.dp), - ), - contentAlignment = Alignment.BottomCenter, - ) { + val backdrop = LocalLiquidBackdrop.current + + val baseModifier = modifier + .height(120.dp) + .aspectRatio(0.2f) + .clip(RoundedCornerShape(16.dp)) + + val finalModifier = if (backdrop != null) { + baseModifier.background(Color.White.copy(alpha = 0.2f)) + } else { + baseModifier + .background(MaterialTheme.colorScheme.background) + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f), + shape = RoundedCornerShape(16.dp), + ) + } + + Box(modifier = finalModifier, contentAlignment = Alignment.BottomCenter) { val targetHeight by animateFloatAsState(percentage(coercedValue, range), label = "vsliderheight") Box( Modifier .fillMaxWidth() .fillMaxHeight(targetHeight) - .background(MaterialTheme.colorScheme.tertiary), + // Now it uses your dynamic primary theme color instead of hardcoded white! + .background(if (backdrop != null) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.tertiary), ) + if (overflowRange != null && overflowValue != null) { - val overflowHeight by animateFloatAsState( - percentage(overflowValue, overflowRange), - label = "vslideroverflowheight", - ) + val overflowHeight by animateFloatAsState(percentage(overflowValue, overflowRange), label = "vslideroverflowheight") Box( Modifier .fillMaxWidth() @@ -102,32 +113,37 @@ fun VerticalSlider( overflowRange: ClosedRange? = null, ) { val coercedValue = value.coerceIn(range) - Box( - modifier = - modifier - .height(120.dp) - .aspectRatio(0.2f) - .clip(RoundedCornerShape(16.dp)) - .background(MaterialTheme.colorScheme.background) - .border( - width = 1.dp, - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f), - shape = RoundedCornerShape(16.dp), - ), - contentAlignment = Alignment.BottomCenter, - ) { + val backdrop = LocalLiquidBackdrop.current + + val baseModifier = modifier + .height(120.dp) + .aspectRatio(0.2f) + .clip(RoundedCornerShape(16.dp)) + + val finalModifier = if (backdrop != null) { + baseModifier.background(Color.White.copy(alpha = 0.2f)) + } else { + baseModifier + .background(MaterialTheme.colorScheme.background) + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f), + shape = RoundedCornerShape(16.dp), + ) + } + + Box(modifier = finalModifier, contentAlignment = Alignment.BottomCenter) { val targetHeight by animateFloatAsState(percentage(coercedValue, range), label = "vsliderheight") Box( Modifier .fillMaxWidth() .fillMaxHeight(targetHeight) - .background(MaterialTheme.colorScheme.tertiary), + // Now it uses your dynamic primary theme color instead of hardcoded white! + .background(if (backdrop != null) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.tertiary), ) + if (overflowRange != null && overflowValue != null) { - val overflowHeight by animateFloatAsState( - percentage(overflowValue, overflowRange), - label = "vslideroverflowheight", - ) + val overflowHeight by animateFloatAsState(percentage(overflowValue, overflowRange), label = "vslideroverflowheight") Box( Modifier .fillMaxWidth() @@ -145,37 +161,61 @@ fun BrightnessSlider( modifier: Modifier = Modifier, ) { val coercedBrightness = brightness.coerceIn(range) - Surface( - modifier = modifier, - shape = RoundedCornerShape(20.dp), - color = MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.55f), - contentColor = MaterialTheme.colorScheme.onSurface, - tonalElevation = 0.dp, - shadowElevation = 0.dp, - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), - ) { - Column( - modifier = Modifier.padding(horizontal = 12.dp, vertical = 16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.smaller), + val backdrop = LocalLiquidBackdrop.current + + if (backdrop != null) { + LiquidGlassSurface( + backdrop = backdrop, + target = LiquidTarget.BUTTON, // Connects to the Buttons Settings tab! + shape = RoundedCornerShape(20.dp), + modifier = modifier ) { - Text( - (coercedBrightness * 100).toInt().toString(), - style = MaterialTheme.typography.bodySmall, - ) - VerticalSlider( - coercedBrightness, - range, - ) - Icon( - when (percentage(coercedBrightness, range)) { - in 0f..0.3f -> Icons.Default.BrightnessLow - in 0.3f..0.6f -> Icons.Default.BrightnessMedium - in 0.6f..1f -> Icons.Default.BrightnessHigh - else -> Icons.Default.BrightnessMedium - }, - contentDescription = null, - ) + Column( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.smaller), + ) { + Text((coercedBrightness * 100).toInt().toString(), style = MaterialTheme.typography.bodySmall, color = Color.White) + VerticalSlider(coercedBrightness, range) + Icon( + when (percentage(coercedBrightness, range)) { + in 0f..0.3f -> Icons.Default.BrightnessLow + in 0.3f..0.6f -> Icons.Default.BrightnessMedium + in 0.6f..1f -> Icons.Default.BrightnessHigh + else -> Icons.Default.BrightnessMedium + }, + contentDescription = null, + tint = Color.White + ) + } + } + } else { + Surface( + modifier = modifier, + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.55f), + contentColor = MaterialTheme.colorScheme.onSurface, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), + ) { + Column( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.smaller), + ) { + Text((coercedBrightness * 100).toInt().toString(), style = MaterialTheme.typography.bodySmall) + VerticalSlider(coercedBrightness, range) + Icon( + when (percentage(coercedBrightness, range)) { + in 0f..0.3f -> Icons.Default.BrightnessLow + in 0.3f..0.6f -> Icons.Default.BrightnessMedium + in 0.6f..1f -> Icons.Default.BrightnessHigh + else -> Icons.Default.BrightnessMedium + }, + contentDescription = null, + ) + } } } } @@ -190,42 +230,84 @@ fun VolumeSlider( displayAsPercentage: Boolean = false, ) { val percentage = (percentage(volume, range) * 100).roundToInt() - Surface( - modifier = modifier, - shape = RoundedCornerShape(20.dp), - color = MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.55f), - contentColor = MaterialTheme.colorScheme.onSurface, - tonalElevation = 0.dp, - shadowElevation = 0.dp, - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), - ) { - Column( - modifier = Modifier.padding(horizontal = 12.dp, vertical = 16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.smaller), + val backdrop = LocalLiquidBackdrop.current + + if (backdrop != null) { + LiquidGlassSurface( + backdrop = backdrop, + target = LiquidTarget.BUTTON, // Connects to the Buttons Settings tab! + shape = RoundedCornerShape(20.dp), + modifier = modifier ) { - val boostVolume = mpvVolume - 100 - Text( - getVolumeSliderText(volume, mpvVolume, boostVolume, percentage, displayAsPercentage), - style = MaterialTheme.typography.bodySmall, - textAlign = TextAlign.Center, - ) - VerticalSlider( - if (displayAsPercentage) percentage else volume, - if (displayAsPercentage) 0..100 else range, - overflowValue = boostVolume, - overflowRange = boostRange, - ) - Icon( - when (percentage) { - 0 -> Icons.AutoMirrored.Default.VolumeOff - in 0..30 -> Icons.AutoMirrored.Default.VolumeMute - in 30..60 -> Icons.AutoMirrored.Default.VolumeDown - in 60..100 -> Icons.AutoMirrored.Default.VolumeUp - else -> Icons.AutoMirrored.Default.VolumeOff - }, - contentDescription = null, - ) + Column( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.smaller), + ) { + val boostVolume = mpvVolume - 100 + Text( + getVolumeSliderText(volume, mpvVolume, boostVolume, percentage, displayAsPercentage), + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center, + color = Color.White + ) + VerticalSlider( + if (displayAsPercentage) percentage else volume, + if (displayAsPercentage) 0..100 else range, + overflowValue = boostVolume, + overflowRange = boostRange, + ) + Icon( + when (percentage) { + 0 -> Icons.AutoMirrored.Default.VolumeOff + in 0..30 -> Icons.AutoMirrored.Default.VolumeMute + in 30..60 -> Icons.AutoMirrored.Default.VolumeDown + in 60..100 -> Icons.AutoMirrored.Default.VolumeUp + else -> Icons.AutoMirrored.Default.VolumeOff + }, + contentDescription = null, + tint = Color.White + ) + } + } + } else { + Surface( + modifier = modifier, + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.55f), + contentColor = MaterialTheme.colorScheme.onSurface, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), + ) { + Column( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.smaller), + ) { + val boostVolume = mpvVolume - 100 + Text( + getVolumeSliderText(volume, mpvVolume, boostVolume, percentage, displayAsPercentage), + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center, + ) + VerticalSlider( + if (displayAsPercentage) percentage else volume, + if (displayAsPercentage) 0..100 else range, + overflowValue = boostVolume, + overflowRange = boostRange, + ) + Icon( + when (percentage) { + 0 -> Icons.AutoMirrored.Default.VolumeOff + in 0..30 -> Icons.AutoMirrored.Default.VolumeMute + in 30..60 -> Icons.AutoMirrored.Default.VolumeDown + in 60..100 -> Icons.AutoMirrored.Default.VolumeUp + else -> Icons.AutoMirrored.Default.VolumeOff + }, + contentDescription = null, + ) + } } } } diff --git a/app/src/main/java/app/marlboroadvance/mpvex/ui/player/controls/components/panels/DraggablePanel.kt b/app/src/main/java/app/marlboroadvance/mpvex/ui/player/controls/components/panels/DraggablePanel.kt index 3751ed677..89bee2d7d 100644 --- a/app/src/main/java/app/marlboroadvance/mpvex/ui/player/controls/components/panels/DraggablePanel.kt +++ b/app/src/main/java/app/marlboroadvance/mpvex/ui/player/controls/components/panels/DraggablePanel.kt @@ -30,22 +30,20 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp - import kotlin.math.roundToInt -/** - * A draggable panel with an optional fixed header and scrollable content. - * - * @param modifier Modifier for the panel - * @param header Optional composable for the fixed header that stays constant during scroll - * @param content The scrollable content of the panel - */ +// --- NEW LIQUID IMPORTS --- +import app.marlboroadvance.mpvex.ui.components.liquid.LocalLiquidBackdrop +import app.marlboroadvance.mpvex.ui.components.liquid.LiquidGlassSurface +import app.marlboroadvance.mpvex.preferences.LiquidTarget + @Composable fun DraggablePanel( modifier: Modifier = Modifier, @@ -65,33 +63,26 @@ fun DraggablePanel( val density = LocalDensity.current val parentWidthPx = with(density) { maxWidth.toPx() } - // Calculate bounds for horizontal drag - // Panel is aligned to CenterEnd (Right), so offset 0 is the default rightmost position. val freeSpace = (parentWidthPx - panelWidth).coerceAtLeast(0f) val maxOffset = 0f val minOffset = -freeSpace - - // In portrait, cap panel height to 50% of available height val panelMaxHeight = if (isPortrait) maxHeight * 0.5f else maxHeight val colors = panelCardsColors() - Surface( - modifier = Modifier - .offset { IntOffset(offsetX.roundToInt(), 0) } - .onSizeChanged { panelWidth = it.width } - .widthIn(max = 380.dp) - .heightIn(max = panelMaxHeight), - shape = MaterialTheme.shapes.extraLarge, - color = colors.containerColor, - contentColor = colors.contentColor, - tonalElevation = 0.dp, - ) { + val backdrop = LocalLiquidBackdrop.current + + val panelModifier = Modifier + .offset { IntOffset(offsetX.roundToInt(), 0) } + .onSizeChanged { panelWidth = it.width } + .widthIn(max = 380.dp) + .heightIn(max = panelMaxHeight) + + val panelContent: @Composable () -> Unit = { Column { - // Drag Handle & Indicator Box( modifier = Modifier .fillMaxWidth() - .height(18.dp) // Good touch target size + .height(18.dp) .pointerInput(maxOffset, minOffset) { detectDragGestures { change, dragAmount -> change.consume() @@ -106,24 +97,41 @@ fun DraggablePanel( .width(32.dp) .height(4.dp) .background( - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + color = if (backdrop != null) Color.White.copy(alpha = 0.5f) else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), shape = RoundedCornerShape(2.dp) ) ) } - // Fixed header (if provided) - stays constant if (header != null) { header() } - // Scrollable content - Column( - modifier = Modifier.verticalScroll(rememberScrollState()) - ) { + Column(modifier = Modifier.verticalScroll(rememberScrollState())) { content() } } } + + if (backdrop != null) { + LiquidGlassSurface( + backdrop = backdrop, + target = LiquidTarget.DIALOG, + shape = MaterialTheme.shapes.extraLarge, + modifier = panelModifier + ) { + panelContent() + } + } else { + Surface( + modifier = panelModifier, + shape = MaterialTheme.shapes.extraLarge, + color = colors.containerColor, + contentColor = colors.contentColor, + tonalElevation = 0.dp, + ) { + panelContent() + } + } } } diff --git a/app/src/main/java/app/marlboroadvance/mpvex/ui/player/controls/components/panels/SubtitleSettingsMiscellaneousCard.kt b/app/src/main/java/app/marlboroadvance/mpvex/ui/player/controls/components/panels/SubtitleSettingsMiscellaneousCard.kt index 87950b5c7..92eab7f81 100644 --- a/app/src/main/java/app/marlboroadvance/mpvex/ui/player/controls/components/panels/SubtitleSettingsMiscellaneousCard.kt +++ b/app/src/main/java/app/marlboroadvance/mpvex/ui/player/controls/components/panels/SubtitleSettingsMiscellaneousCard.kt @@ -34,7 +34,7 @@ import app.marlboroadvance.mpvex.ui.player.controls.panelCardsColors import app.marlboroadvance.mpvex.ui.theme.spacing import `is`.xyz.mpv.MPVLib import me.zhanghai.compose.preference.ProvidePreferenceLocals -import me.zhanghai.compose.preference.SwitchPreference +import app.marlboroadvance.mpvex.ui.components.liquid.LiquidSwitchPreference as SwitchPreference import org.koin.compose.koinInject @Composable diff --git a/app/src/main/java/app/marlboroadvance/mpvex/ui/player/controls/components/sheets/AmbientSheet.kt b/app/src/main/java/app/marlboroadvance/mpvex/ui/player/controls/components/sheets/AmbientSheet.kt deleted file mode 100644 index c0fdb53eb..000000000 --- a/app/src/main/java/app/marlboroadvance/mpvex/ui/player/controls/components/sheets/AmbientSheet.kt +++ /dev/null @@ -1,372 +0,0 @@ -package app.marlboroadvance.mpvex.ui.player.controls.components.sheets - -import android.content.res.Configuration -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.BlurOn -import androidx.compose.material.icons.outlined.Brightness6 -import androidx.compose.material.icons.outlined.Grain -import androidx.compose.material.icons.outlined.Gradient -import androidx.compose.material.icons.outlined.Opacity -import androidx.compose.material.icons.outlined.Palette -import androidx.compose.material.icons.outlined.RoundedCorner -import androidx.compose.material.icons.outlined.Thermostat -import androidx.compose.material.icons.outlined.Vignette -import androidx.compose.material.icons.outlined.WbSunny -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.FilledTonalButton -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import app.marlboroadvance.mpvex.presentation.components.PlayerSheet -import app.marlboroadvance.mpvex.presentation.components.SliderItem -import app.marlboroadvance.mpvex.ui.player.PlayerViewModel -import app.marlboroadvance.mpvex.ui.theme.spacing - -@Composable -fun AmbientSheet( - viewModel: PlayerViewModel, - onDismissRequest: () -> Unit -) { - // ── Collect all state flows ────────────────────────────────────────────── - val blurSamples by viewModel.ambientBlurSamples.collectAsState() - val maxRadius by viewModel.ambientMaxRadius.collectAsState() - val glowIntensity by viewModel.ambientGlowIntensity.collectAsState() - val satBoost by viewModel.ambientSatBoost.collectAsState() - val vignetteStrength by viewModel.ambientVignetteStrength.collectAsState() - val warmth by viewModel.ambientWarmth.collectAsState() - val fadeCurve by viewModel.ambientFadeCurve.collectAsState() - val opacity by viewModel.ambientOpacity.collectAsState() - val bezelDepth by viewModel.ambientBezelDepth.collectAsState() - val ditherNoise by viewModel.ambientDitherNoise.collectAsState() - - // Profile detection (all conditions must match for the button to highlight) - val isFast = blurSamples == 16 && maxRadius == 0.17f && - glowIntensity == 1.4f && satBoost == 1.2f && - fadeCurve == 1.6f && opacity == 1.0f - val isBalanced = blurSamples == 24 && maxRadius == 0.20f && - glowIntensity == 1.45f && satBoost == 1.25f && - fadeCurve == 1.7f && opacity == 1.0f - val isHQ = blurSamples == 48 && maxRadius == 0.25f && - glowIntensity == 1.5f && satBoost == 1.3f && - fadeCurve == 1.8f && opacity == 1.0f - - val configuration = LocalConfiguration.current - val customMaxHeight = if (configuration.orientation == Configuration.ORIENTATION_PORTRAIT) { - (configuration.screenHeightDp * 0.5f).dp - } else { - null - } - - PlayerSheet( - onDismissRequest = onDismissRequest, - customMaxHeight = customMaxHeight - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()) - .padding(vertical = MaterialTheme.spacing.medium), - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.small), - ) { - - // ── Title ──────────────────────────────────────────────────────── - Text( - text = "Ambience Mode", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Center, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 4.dp), - ) - - // ── Quality Presets ────────────────────────────────────────────── - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = MaterialTheme.spacing.medium), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - FilledTonalButton( - onClick = { viewModel.applyAmbientProfileFast() }, - modifier = Modifier.weight(1f), - colors = if (isFast) ButtonDefaults.filledTonalButtonColors( - containerColor = MaterialTheme.colorScheme.primary, - contentColor = MaterialTheme.colorScheme.onPrimary, - ) else ButtonDefaults.filledTonalButtonColors(), - ) { - Text("Fast", fontWeight = FontWeight.Bold) - } - FilledTonalButton( - onClick = { viewModel.applyAmbientProfileBalanced() }, - modifier = Modifier.weight(1f), - colors = if (isBalanced) ButtonDefaults.filledTonalButtonColors( - containerColor = MaterialTheme.colorScheme.primary, - contentColor = MaterialTheme.colorScheme.onPrimary, - ) else ButtonDefaults.filledTonalButtonColors(), - ) { - Text("Balanced", fontWeight = FontWeight.Bold) - } - FilledTonalButton( - onClick = { viewModel.applyAmbientProfileHighQuality() }, - modifier = Modifier.weight(1f), - colors = if (isHQ) ButtonDefaults.filledTonalButtonColors( - containerColor = MaterialTheme.colorScheme.primary, - contentColor = MaterialTheme.colorScheme.onPrimary, - ) else ButtonDefaults.filledTonalButtonColors(), - ) { - Text("HQ", fontWeight = FontWeight.Bold) - } - } - - HorizontalDivider( - modifier = Modifier.padding(horizontal = MaterialTheme.spacing.medium), - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f), - ) - - // ── Section: Glow ──────────────────────────────────────────────── - SectionLabel("Glow") - - SliderItem( - label = "Blur Samples", - valueText = "$blurSamples", - value = blurSamples, - onChange = { viewModel.updateAmbientParams(blurSamples = it) }, - min = 5, - max = 64, - icon = { - Icon( - imageVector = Icons.Outlined.BlurOn, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(20.dp), - ) - }, - ) - - SliderItem( - label = "Spread", - valueText = "%.2f".format(maxRadius), - value = maxRadius, - onChange = { viewModel.updateAmbientParams(maxRadius = it) }, - min = 0.05f, - max = 0.80f, - steps = 75, - icon = { - Icon( - imageVector = Icons.Outlined.Gradient, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(20.dp), - ) - }, - ) - - SliderItem( - label = "Glow Intensity", - valueText = "%.1f".format(glowIntensity), - value = glowIntensity, - onChange = { viewModel.updateAmbientParams(glowIntensity = it) }, - min = 0.5f, - max = 3.0f, - steps = 25, - icon = { - Icon( - imageVector = Icons.Outlined.Brightness6, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(20.dp), - ) - }, - ) - - SliderItem( - label = "Fade Curve", - valueText = "%.1f".format(fadeCurve), - value = fadeCurve, - onChange = { viewModel.updateAmbientParams(fadeCurve = it) }, - min = 0.5f, - max = 3.0f, - steps = 25, - icon = { - Icon( - imageVector = Icons.Outlined.WbSunny, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(20.dp), - ) - }, - ) - - HorizontalDivider( - modifier = Modifier.padding(horizontal = MaterialTheme.spacing.medium), - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f), - ) - - // ── Section: Color ─────────────────────────────────────────────── - SectionLabel("Color") - - SliderItem( - label = "Saturation", - valueText = "%.1f".format(satBoost), - value = satBoost, - onChange = { viewModel.updateAmbientParams(satBoost = it) }, - min = 0.0f, - max = 3.0f, - steps = 30, - icon = { - Icon( - imageVector = Icons.Outlined.Palette, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(20.dp), - ) - }, - ) - - SliderItem( - label = "Warmth", - valueText = if (warmth == 0f) "0" else "%.2f".format(warmth), - value = warmth, - onChange = { viewModel.updateAmbientParams(warmth = it) }, - min = -1.0f, - max = 1.0f, - steps = 40, - icon = { - Icon( - imageVector = Icons.Outlined.Thermostat, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(20.dp), - ) - }, - ) - - HorizontalDivider( - modifier = Modifier.padding(horizontal = MaterialTheme.spacing.medium), - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f), - ) - - // ── Section: Compositing ───────────────────────────────────────── - SectionLabel("Compositing") - - SliderItem( - label = "Opacity", - valueText = "%.2f".format(opacity), - value = opacity, - onChange = { viewModel.updateAmbientParams(opacity = it) }, - min = 0.0f, - max = 1.0f, - steps = 20, - icon = { - Icon( - imageVector = Icons.Outlined.Opacity, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(20.dp), - ) - }, - ) - - SliderItem( - label = "Vignette", - valueText = "%.1f".format(vignetteStrength), - value = vignetteStrength, - onChange = { viewModel.updateAmbientParams(vignetteStrength = it) }, - min = 0.0f, - max = 1.0f, - steps = 10, - icon = { - Icon( - imageVector = Icons.Outlined.Vignette, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(20.dp), - ) - }, - ) - - HorizontalDivider( - modifier = Modifier.padding(horizontal = MaterialTheme.spacing.medium), - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f), - ) - - // ── Section: Advanced ──────────────────────────────────────────── - SectionLabel("Advanced") - - SliderItem( - label = "Bezel", - valueText = "%.3f".format(bezelDepth), - value = bezelDepth, - onChange = { viewModel.updateAmbientParams(bezelDepth = it) }, - min = 0.0f, - max = 0.1f, - steps = 50, - icon = { - Icon( - imageVector = Icons.Outlined.RoundedCorner, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(20.dp), - ) - }, - ) - - SliderItem( - label = "Dither", - valueText = "%.3f".format(ditherNoise), - value = ditherNoise, - onChange = { viewModel.updateAmbientParams(ditherNoise = it) }, - min = 0.0f, - max = 0.05f, - steps = 50, - icon = { - Icon( - imageVector = Icons.Outlined.Grain, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(20.dp), - ) - }, - ) - - Spacer(modifier = Modifier.height(8.dp)) - } - } -} - -// ── Helper: section label ──────────────────────────────────────────────────── -@Composable -private fun SectionLabel(text: String) { - Text( - text = text, - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding( - horizontal = MaterialTheme.spacing.medium, - vertical = 2.dp, - ), - ) -} diff --git a/app/src/main/java/app/marlboroadvance/mpvex/ui/player/controls/components/sheets/AudioTracksSheet.kt b/app/src/main/java/app/marlboroadvance/mpvex/ui/player/controls/components/sheets/AudioTracksSheet.kt index bc445f597..85d50ba76 100644 --- a/app/src/main/java/app/marlboroadvance/mpvex/ui/player/controls/components/sheets/AudioTracksSheet.kt +++ b/app/src/main/java/app/marlboroadvance/mpvex/ui/player/controls/components/sheets/AudioTracksSheet.kt @@ -8,13 +8,10 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.MoreTime -import androidx.compose.material.icons.outlined.Info import androidx.compose.material3.FilterChip import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -23,12 +20,8 @@ import androidx.compose.material3.RadioButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight @@ -54,21 +47,6 @@ fun AudioTracksSheet( ) { val audioPreferences = koinInject() val audioChannels by audioPreferences.audioChannels.collectAsState() - val context = LocalContext.current - var infoDialogData by remember { mutableStateOf?>(null) } - - if (infoDialogData != null) { - androidx.compose.material3.AlertDialog( - onDismissRequest = { infoDialogData = null }, - title = { Text(infoDialogData!!.first) }, - text = { Text(infoDialogData!!.second) }, - confirmButton = { - androidx.compose.material3.TextButton(onClick = { infoDialogData = null }) { - Text(stringResource(R.string.generic_ok)) - } - } - ) - } GenericTracksSheet( tracks, @@ -98,33 +76,11 @@ fun AudioTracksSheet( .padding(MaterialTheme.spacing.medium) ) { Spacer(modifier = Modifier.height(MaterialTheme.spacing.medium)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Start, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = stringResource(id = R.string.pref_audio_channels), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.primary - ) - Spacer(modifier = Modifier.width(8.dp)) - IconButton(onClick = { - val descResName = "pref_audio_channels_${audioChannels.value.replace("-safe", "_safe").replace("-", "_")}_desc" - // Special handling for reversed stereo if value string doesn't match resource convention - val finalDescResName = if (audioChannels.name == "ReverseStereo") "pref_audio_channels_reverse_stereo_desc" else descResName - val resId = context.resources.getIdentifier(finalDescResName, "string", context.packageName) - val description = if (resId != 0) context.getString(resId) else "" - infoDialogData = Pair(context.getString(audioChannels.title), description) - }, modifier = Modifier.size(24.dp)) { - Icon( - imageVector = Icons.Outlined.Info, - contentDescription = "Info", - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(16.dp) - ) - } - } + Text( + text = stringResource(id = R.string.pref_audio_channels), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary + ) Spacer(modifier = Modifier.height(MaterialTheme.spacing.smaller)) LazyRow( horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.smaller), diff --git a/app/src/main/java/app/marlboroadvance/mpvex/ui/player/controls/components/sheets/MoreSheet.kt b/app/src/main/java/app/marlboroadvance/mpvex/ui/player/controls/components/sheets/MoreSheet.kt index 12c2eef9d..c3665d3ad 100644 --- a/app/src/main/java/app/marlboroadvance/mpvex/ui/player/controls/components/sheets/MoreSheet.kt +++ b/app/src/main/java/app/marlboroadvance/mpvex/ui/player/controls/components/sheets/MoreSheet.kt @@ -18,8 +18,8 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Tune -import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.outlined.Timer import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button @@ -83,21 +83,23 @@ fun MoreSheet( val context = LocalContext.current val scope = rememberCoroutineScope() + var infoDialogData by remember { mutableStateOf?>(null) } - - if (infoDialogData != null) { - AlertDialog( - onDismissRequest = { }, - title = { Text(infoDialogData!!.first) }, - text = { Text(infoDialogData!!.second) }, - confirmButton = { - TextButton(onClick = { infoDialogData = null }) { - Text(stringResource(R.string.generic_ok)) - } - } - ) + + infoDialogData?.let { (title, message) -> + AlertDialog( + onDismissRequest = { infoDialogData = null }, + title = { Text(title) }, + text = { Text(message) }, + confirmButton = { + TextButton(onClick = { infoDialogData = null }) { + Text(stringResource(id = R.string.generic_ok)) + } + } + ) } + PlayerSheet( onDismissRequest, modifier, @@ -127,7 +129,7 @@ fun MoreSheet( ) { Icon(imageVector = Icons.Outlined.Timer, contentDescription = null) Text( - text = if (remainingTime == 0) stringResource(R.string.timer_title) + text = if (remainingTime == 0) stringResource(R.string.timer_title) else stringResource(R.string.timer_remaining, DateUtils.formatElapsedTime(remainingTime.toLong())), ) if (isSleepTimerDialogShown) { @@ -150,7 +152,7 @@ fun MoreSheet( } } } - + SectionHeaderWithInfo( title = stringResource(R.string.player_sheets_stats_page_title), onInfoClick = { @@ -161,17 +163,17 @@ fun MoreSheet( val resId = context.resources.getIdentifier(descResName, "string", context.packageName) if (resId != 0) context.getString(resId) else "No description available." } - + val title = when (statisticsPage) { 0 -> context.getString(R.string.player_sheets_tracks_off) 6 -> "Page 6: Hardware HUD" else -> context.getString(R.string.player_sheets_stats_page_chip, statisticsPage) } - + infoDialogData = Pair(context.getString(R.string.player_sheets_stats_page_title), "$title\n\n$description") } ) - + LazyRow(horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.smaller)) { items(7) { page -> FilterChip( @@ -194,7 +196,7 @@ fun MoreSheet( if (page in 1..5) { MPVLib.command("script-binding", "stats/display-page-$page") } - + advancedPreferences.enabledStatisticsPage.set(page) }, selected = statisticsPage == page, @@ -202,7 +204,7 @@ fun MoreSheet( ) } } - + if (enableAnime4K && (!gpuNext || useVulkan)) { val width = MPVLib.getPropertyInt("video-params/w") ?: 0 val height = MPVLib.getPropertyInt("video-params/h") ?: 0 @@ -282,6 +284,36 @@ fun MoreSheet( } } +@Composable +private fun SectionHeaderWithInfo( + title: String, + onInfoClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, + ) + IconButton( + onClick = onInfoClick, + modifier = Modifier.size(24.dp), + ) { + Icon( + imageVector = Icons.Default.Info, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } + } +} + @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable fun TimePickerDialog( @@ -333,7 +365,7 @@ fun TimePickerDialog( ) TimeInput(state = state) - + Column( horizontalAlignment = Alignment.Start, modifier = Modifier.fillMaxWidth() @@ -393,31 +425,3 @@ fun TimePickerDialog( } } } - -@Composable -fun SectionHeaderWithInfo( - title: String, - onInfoClick: () -> Unit, - modifier: Modifier = Modifier -) { - Row( - modifier = modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Start, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = title, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.primary - ) - Spacer(modifier = Modifier.width(8.dp)) - IconButton(onClick = onInfoClick, modifier = Modifier.size(24.dp)) { - Icon( - imageVector = Icons.Outlined.Info, - contentDescription = "Info", - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(16.dp) - ) - } - } -} diff --git a/app/src/main/java/app/marlboroadvance/mpvex/ui/player/controls/components/sheets/PlaybackSpeedSheet.kt b/app/src/main/java/app/marlboroadvance/mpvex/ui/player/controls/components/sheets/PlaybackSpeedSheet.kt index 2292e567b..1c6634df5 100644 --- a/app/src/main/java/app/marlboroadvance/mpvex/ui/player/controls/components/sheets/PlaybackSpeedSheet.kt +++ b/app/src/main/java/app/marlboroadvance/mpvex/ui/player/controls/components/sheets/PlaybackSpeedSheet.kt @@ -58,7 +58,7 @@ import app.marlboroadvance.mpvex.presentation.components.SliderItem import app.marlboroadvance.mpvex.ui.theme.spacing import `is`.xyz.mpv.MPVLib import me.zhanghai.compose.preference.ProvidePreferenceLocals -import me.zhanghai.compose.preference.SwitchPreference +import app.marlboroadvance.mpvex.ui.components.liquid.LiquidSwitchPreference as SwitchPreference import org.koin.compose.koinInject import app.marlboroadvance.mpvex.presentation.components.RepeatingIconButton import kotlin.math.pow diff --git a/app/src/main/java/app/marlboroadvance/mpvex/ui/preferences/AdvancedPreferencesScreen.kt b/app/src/main/java/app/marlboroadvance/mpvex/ui/preferences/AdvancedPreferencesScreen.kt index 0b9d54c79..a0c614b54 100644 --- a/app/src/main/java/app/marlboroadvance/mpvex/ui/preferences/AdvancedPreferencesScreen.kt +++ b/app/src/main/java/app/marlboroadvance/mpvex/ui/preferences/AdvancedPreferencesScreen.kt @@ -64,7 +64,7 @@ import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable import me.zhanghai.compose.preference.Preference import me.zhanghai.compose.preference.ProvidePreferenceLocals -import me.zhanghai.compose.preference.SwitchPreference +import app.marlboroadvance.mpvex.ui.components.liquid.LiquidSwitchPreference as SwitchPreference import me.zhanghai.compose.preference.TwoTargetIconButtonPreference import org.koin.compose.koinInject import java.io.File diff --git a/app/src/main/java/app/marlboroadvance/mpvex/ui/preferences/AppearancePreferencesScreen.kt b/app/src/main/java/app/marlboroadvance/mpvex/ui/preferences/AppearancePreferencesScreen.kt index f2c5074f1..f86857f19 100644 --- a/app/src/main/java/app/marlboroadvance/mpvex/ui/preferences/AppearancePreferencesScreen.kt +++ b/app/src/main/java/app/marlboroadvance/mpvex/ui/preferences/AppearancePreferencesScreen.kt @@ -1,22 +1,42 @@ package app.marlboroadvance.mpvex.ui.preferences +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.ArrowBack import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -24,22 +44,27 @@ import app.marlboroadvance.mpvex.R import app.marlboroadvance.mpvex.preferences.AppearancePreferences import app.marlboroadvance.mpvex.preferences.BrowserPreferences import app.marlboroadvance.mpvex.preferences.GesturePreferences +import app.marlboroadvance.mpvex.preferences.LiquidUIPreferences +import app.marlboroadvance.mpvex.preferences.LiquidTarget import app.marlboroadvance.mpvex.preferences.MultiChoiceSegmentedButton import app.marlboroadvance.mpvex.preferences.preference.collectAsState import app.marlboroadvance.mpvex.presentation.Screen +import app.marlboroadvance.mpvex.ui.components.liquid.AdaptiveToggle +import app.marlboroadvance.mpvex.ui.components.liquid.LiquidSwitchPreference import app.marlboroadvance.mpvex.ui.preferences.components.ThemePicker import app.marlboroadvance.mpvex.ui.theme.DarkMode import app.marlboroadvance.mpvex.ui.utils.LocalBackStack import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.launch import kotlinx.serialization.Serializable import me.zhanghai.compose.preference.ProvidePreferenceLocals import me.zhanghai.compose.preference.SliderPreference -import me.zhanghai.compose.preference.SwitchPreference import org.koin.compose.koinInject import kotlin.math.roundToInt -import androidx.compose.runtime.getValue -import androidx.compose.runtime.setValue +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.rememberScrollState + @Serializable object AppearancePreferencesScreen : Screen { @@ -47,6 +72,9 @@ object AppearancePreferencesScreen : Screen { @Composable override fun Content() { val preferences = koinInject() + val context = LocalContext.current + val liquidPreferences = remember { LiquidUIPreferences(context) } + val scope = rememberCoroutineScope() val browserPreferences = koinInject() val gesturePreferences = koinInject() val backstack = LocalBackStack.current @@ -55,13 +83,28 @@ object AppearancePreferencesScreen : Screen { val darkMode by preferences.darkMode.collectAsState() val appTheme by preferences.appTheme.collectAsState() - // Determine if we're in dark mode for theme preview val isDarkMode = when (darkMode) { DarkMode.Dark -> true DarkMode.Light -> false DarkMode.System -> systemDarkTheme } + val isLiquidUIEnabled by liquidPreferences.liquidUIEnabledFlow.collectAsState(initial = false) + val toggleColor by liquidPreferences.liquidToggleColorFlow.collectAsState(initial = 0xFF4CAF50) + val sliderColor by liquidPreferences.liquidSliderColorFlow.collectAsState(initial = 0xFF2196F3) + + // --- THE ACTIVE TARGET STATE --- + var selectedTarget by remember { mutableStateOf(LiquidTarget.NAV) } + + // --- SAFE DYNAMIC DATA LOADING (Wrapped in remember blocks) --- + val blurRadius by remember(selectedTarget) { liquidPreferences.blurRadiusFlow(selectedTarget) }.collectAsState(initial = 0f) + val refractionHeight by remember(selectedTarget) { liquidPreferences.refractionHeightFlow(selectedTarget) }.collectAsState(initial = 40f) + val refractionAmount by remember(selectedTarget) { liquidPreferences.refractionAmountFlow(selectedTarget) }.collectAsState(initial = 23f) + val tintAlpha by remember(selectedTarget) { liquidPreferences.tintAlphaFlow(selectedTarget) }.collectAsState(initial = 0.15f) + val chromaticAberration by remember(selectedTarget) { liquidPreferences.chromaticAberrationFlow(selectedTarget) }.collectAsState(initial = false) + val depthEffect by remember(selectedTarget) { liquidPreferences.depthEffectFlow(selectedTarget) }.collectAsState(initial = true) + val vibrancyEnabled by remember(selectedTarget) { liquidPreferences.vibrancyEnabledFlow(selectedTarget) }.collectAsState(initial = true) + Scaffold( topBar = { TopAppBar( @@ -75,31 +118,19 @@ object AppearancePreferencesScreen : Screen { }, navigationIcon = { IconButton(onClick = backstack::removeLastOrNull) { - Icon( - Icons.AutoMirrored.Outlined.ArrowBack, - contentDescription = null, - tint = MaterialTheme.colorScheme.secondary, - ) + Icon(Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = null, tint = MaterialTheme.colorScheme.secondary) } }, ) }, ) { padding -> ProvidePreferenceLocals { - LazyColumn( - modifier = - Modifier - .fillMaxSize() - .padding(padding), - ) { - item { - PreferenceSectionHeader(title = stringResource(id = R.string.pref_appearance_category_theme)) - } + LazyColumn(modifier = Modifier.fillMaxSize().padding(padding)) { + item { PreferenceSectionHeader(title = stringResource(id = R.string.pref_appearance_category_theme)) } item { PreferenceCard { Column(modifier = Modifier.padding(vertical = 8.dp)) { - // Dark mode selector MultiChoiceSegmentedButton( choices = DarkMode.entries.map { stringResource(it.titleRes) }.toImmutableList(), selectedIndices = persistentListOf(DarkMode.entries.indexOf(darkMode)), @@ -109,10 +140,8 @@ object AppearancePreferencesScreen : Screen { PreferenceDivider() - // AMOLED mode state - need it before theme picker val amoledMode by preferences.amoledMode.collectAsState() - // Theme picker - Aniyomi style ThemePicker( currentTheme = appTheme, isDarkMode = isDarkMode, @@ -122,64 +151,199 @@ object AppearancePreferencesScreen : Screen { PreferenceDivider() - // AMOLED mode toggle - SwitchPreference( + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { scope.launch { liquidPreferences.setLiquidUIEnabled(!isLiquidUIEnabled) } } + .padding(horizontal = 16.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f).padding(end = 16.dp)) { + Text(text = "Enable Liquid Glass UI", fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface) + Text(text = "Transform controls with modern glass effects", color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodyMedium) + } + AdaptiveToggle( + checked = isLiquidUIEnabled, + onCheckedChange = { enabled -> scope.launch { liquidPreferences.setLiquidUIEnabled(enabled) } }, + preferences = liquidPreferences + ) + } + + AnimatedVisibility(visible = isLiquidUIEnabled) { + Column { + Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { + Text("Liquid UI Colors", style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary, modifier = Modifier.padding(bottom = 8.dp)) + + fun parseColorInput(input: String): Long? { + return try { + val formatted = if (input.matches(Regex("^[0-9A-Fa-f]{6,8}$"))) "#$input" else input + val colorInt = android.graphics.Color.parseColor(formatted) + colorInt.toLong() and 0xFFFFFFFFL + } catch (e: Exception) { null } + } + + var toggleInputText by remember(toggleColor) { mutableStateOf(String.format("#%06X", 0xFFFFFF and toggleColor.toInt())) } + OutlinedTextField( + value = toggleInputText, + onValueChange = { newValue -> + toggleInputText = newValue + parseColorInput(newValue)?.let { colorLong -> scope.launch { liquidPreferences.setToggleColor(colorLong) } } + }, + label = { Text("Toggle Color") }, + modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp), + singleLine = true, + leadingIcon = { Box(modifier = Modifier.size(24.dp).clip(CircleShape).background(Color(toggleColor))) } + ) + + var sliderInputText by remember(sliderColor) { mutableStateOf(String.format("#%06X", 0xFFFFFF and sliderColor.toInt())) } + OutlinedTextField( + value = sliderInputText, + onValueChange = { newValue -> + sliderInputText = newValue + parseColorInput(newValue)?.let { colorLong -> scope.launch { liquidPreferences.setSliderColor(colorLong) } } + }, + label = { Text("Slider Color") }, + modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), + singleLine = true, + leadingIcon = { Box(modifier = Modifier.size(24.dp).clip(CircleShape).background(Color(sliderColor))) } + ) + } + + PreferenceDivider() + + // --- THE NEW PREMIUM TARGET SELECTOR --- + Column(modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp), horizontalAlignment = Alignment.CenterHorizontally) { + Text("Select UI Layer to Tune:", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.outline, modifier = Modifier.padding(bottom = 8.dp)) + + // The Row is now safely scrollable so it won't break its layout! + Row( + modifier = Modifier + .horizontalScroll(rememberScrollState()) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), CircleShape) + .padding(4.dp), + horizontalArrangement = Arrangement.Center + ) { + LiquidTarget.values().forEach { target -> + val isSelected = selectedTarget == target + Surface( + modifier = Modifier.clickable { selectedTarget = target }.padding(horizontal = 4.dp), + shape = CircleShape, + color = if (isSelected) MaterialTheme.colorScheme.primary else Color.Transparent, + contentColor = if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant + ) { + // maxLines = 1 completely prevents the "wrapping" bug + Text( + text = target.title, + maxLines = 1, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal + ) + } + } + } + } + + + // --- THE SLIDERS --- + Text("${selectedTarget.title} Tuning", style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary, modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) + + SliderPreference( + value = blurRadius, + onValueChange = { v -> scope.launch { liquidPreferences.setBlurRadius(selectedTarget, v) } }, + sliderValue = blurRadius, + onSliderValueChange = { v -> scope.launch { liquidPreferences.setBlurRadius(selectedTarget, v) } }, + title = { Text("Blur Radius") }, + valueRange = 0f..64f, + summary = { Text("${blurRadius.roundToInt()} px", color = MaterialTheme.colorScheme.outline) } + ) + + SliderPreference( + value = refractionHeight, + onValueChange = { v -> scope.launch { liquidPreferences.setRefractionHeight(selectedTarget, v) } }, + sliderValue = refractionHeight, + onSliderValueChange = { v -> scope.launch { liquidPreferences.setRefractionHeight(selectedTarget, v) } }, + title = { Text("Refraction Height") }, + valueRange = 0f..100f, + summary = { Text("${refractionHeight.roundToInt()} dp", color = MaterialTheme.colorScheme.outline) } + ) + + SliderPreference( + value = refractionAmount, + onValueChange = { v -> scope.launch { liquidPreferences.setRefractionAmount(selectedTarget, v) } }, + sliderValue = refractionAmount, + onSliderValueChange = { v -> scope.launch { liquidPreferences.setRefractionAmount(selectedTarget, v) } }, + title = { Text("Refraction Amount") }, + valueRange = 0f..100f, + summary = { Text("${refractionAmount.roundToInt()} dp", color = MaterialTheme.colorScheme.outline) } + ) + + SliderPreference( + value = tintAlpha, + onValueChange = { v -> scope.launch { liquidPreferences.setTintAlpha(selectedTarget, v) } }, + sliderValue = tintAlpha, + onSliderValueChange = { v -> scope.launch { liquidPreferences.setTintAlpha(selectedTarget, v) } }, + title = { Text("Glass Opacity (Tint)") }, + valueRange = 0.0f..1.0f, + summary = { Text("${(tintAlpha * 100).roundToInt()}%", color = MaterialTheme.colorScheme.outline) } + ) + + PreferenceDivider() + + LiquidSwitchPreference( + value = depthEffect, + onValueChange = { v -> scope.launch { liquidPreferences.setDepthEffect(selectedTarget, v) } }, + title = { Text("Depth Effect") }, + summary = { Text("Enables 3D lens depth calculation", color = MaterialTheme.colorScheme.outline) } + ) + + LiquidSwitchPreference( + value = chromaticAberration, + onValueChange = { v -> scope.launch { liquidPreferences.setChromaticAberration(selectedTarget, v) } }, + title = { Text("Chromatic Aberration") }, + summary = { Text("Enables RGB color-split on glass edges", color = MaterialTheme.colorScheme.outline) } + ) + + LiquidSwitchPreference( + value = vibrancyEnabled, + onValueChange = { v -> scope.launch { liquidPreferences.setVibrancyEnabled(selectedTarget, v) } }, + title = { Text("Vibrancy") }, + summary = { Text("Multiplies background saturation by 1.5x", color = MaterialTheme.colorScheme.outline) } + ) + } + } + + PreferenceDivider() + + LiquidSwitchPreference( value = amoledMode, - onValueChange = { newValue -> - preferences.amoledMode.set(newValue) - }, + onValueChange = { newValue -> preferences.amoledMode.set(newValue) }, title = { Text(text = stringResource(id = R.string.pref_appearance_amoled_mode_title)) }, - summary = { - Text( - text = stringResource(id = R.string.pref_appearance_amoled_mode_summary), - color = MaterialTheme.colorScheme.outline, - ) - }, + summary = { Text(text = stringResource(id = R.string.pref_appearance_amoled_mode_summary), color = MaterialTheme.colorScheme.outline) }, enabled = darkMode != DarkMode.Light ) } } - item { - PreferenceSectionHeader(title = stringResource(id = R.string.pref_appearance_category_file_browser)) - } + item { PreferenceSectionHeader(title = stringResource(id = R.string.pref_appearance_category_file_browser)) } item { PreferenceCard { val unlimitedNameLines by preferences.unlimitedNameLines.collectAsState() - SwitchPreference( + LiquidSwitchPreference( value = unlimitedNameLines, onValueChange = { preferences.unlimitedNameLines.set(it) }, - title = { - Text( - text = stringResource(id = R.string.pref_appearance_unlimited_name_lines_title), - ) - }, - summary = { - Text( - text = stringResource(id = R.string.pref_appearance_unlimited_name_lines_summary), - color = MaterialTheme.colorScheme.outline, - ) - } + title = { Text(text = stringResource(id = R.string.pref_appearance_unlimited_name_lines_title)) }, + summary = { Text(text = stringResource(id = R.string.pref_appearance_unlimited_name_lines_summary), color = MaterialTheme.colorScheme.outline) } ) PreferenceDivider() val showUnplayedOldVideoLabel by preferences.showUnplayedOldVideoLabel.collectAsState() - SwitchPreference( + LiquidSwitchPreference( value = showUnplayedOldVideoLabel, onValueChange = { preferences.showUnplayedOldVideoLabel.set(it) }, - title = { - Text( - text = stringResource(id = R.string.pref_appearance_show_unplayed_old_video_label_title), - ) - }, - summary = { - Text( - text = stringResource(id = R.string.pref_appearance_show_unplayed_old_video_label_summary), - color = MaterialTheme.colorScheme.outline, - ) - } + title = { Text(text = stringResource(id = R.string.pref_appearance_show_unplayed_old_video_label_title)) }, + summary = { Text(text = stringResource(id = R.string.pref_appearance_show_unplayed_old_video_label_summary), color = MaterialTheme.colorScheme.outline) } ) PreferenceDivider() @@ -188,37 +352,22 @@ object AppearancePreferencesScreen : Screen { SliderPreference( value = unplayedOldVideoDays.toFloat(), onValueChange = { preferences.unplayedOldVideoDays.set(it.roundToInt()) }, + sliderValue = unplayedOldVideoDays.toFloat(), + onSliderValueChange = { preferences.unplayedOldVideoDays.set(it.roundToInt()) }, title = { Text(text = stringResource(id = R.string.pref_appearance_unplayed_old_video_days_title)) }, valueRange = 1f..30f, - summary = { - Text( - text = stringResource( - id = R.string.pref_appearance_unplayed_old_video_days_summary, - unplayedOldVideoDays, - ), - color = MaterialTheme.colorScheme.outline, - ) - }, - onSliderValueChange = { preferences.unplayedOldVideoDays.set(it.roundToInt()) }, - sliderValue = unplayedOldVideoDays.toFloat(), - enabled = showUnplayedOldVideoLabel + enabled = showUnplayedOldVideoLabel, + summary = { Text(text = stringResource(id = R.string.pref_appearance_unplayed_old_video_days_summary, unplayedOldVideoDays), color = MaterialTheme.colorScheme.outline) } ) PreferenceDivider() val autoScrollToLastPlayed by browserPreferences.autoScrollToLastPlayed.collectAsState() - SwitchPreference( + LiquidSwitchPreference( value = autoScrollToLastPlayed, onValueChange = { browserPreferences.autoScrollToLastPlayed.set(it) }, - title = { - Text(text = stringResource(R.string.pref_appearance_auto_scroll_title)) - }, - summary = { - Text( - text = stringResource(R.string.pref_appearance_auto_scroll_summary), - color = MaterialTheme.colorScheme.outline, - ) - } + title = { Text(text = stringResource(R.string.pref_appearance_auto_scroll_title)) }, + summary = { Text(text = stringResource(R.string.pref_appearance_auto_scroll_summary), color = MaterialTheme.colorScheme.outline) } ) PreferenceDivider() @@ -232,57 +381,30 @@ object AppearancePreferencesScreen : Screen { title = { Text(text = stringResource(id = R.string.pref_appearance_watched_threshold_title)) }, valueRange = 50f..100f, valueSteps = 9, - summary = { - Text( - text = stringResource( - id = R.string.pref_appearance_watched_threshold_summary, - watchedThreshold, - ), - color = MaterialTheme.colorScheme.outline, - ) - }, + summary = { Text(text = stringResource(id = R.string.pref_appearance_watched_threshold_summary, watchedThreshold), color = MaterialTheme.colorScheme.outline) } ) PreferenceDivider() val tapThumbnailToSelect by gesturePreferences.tapThumbnailToSelect.collectAsState() - SwitchPreference( + LiquidSwitchPreference( value = tapThumbnailToSelect, onValueChange = { gesturePreferences.tapThumbnailToSelect.set(it) }, - title = { - Text( - text = stringResource(id = R.string.pref_gesture_tap_thumbnail_to_select_title), - ) - }, - summary = { - Text( - text = stringResource(id = R.string.pref_gesture_tap_thumbnail_to_select_summary), - color = MaterialTheme.colorScheme.outline, - ) - } + title = { Text(text = stringResource(id = R.string.pref_gesture_tap_thumbnail_to_select_title)) }, + summary = { Text(text = stringResource(id = R.string.pref_gesture_tap_thumbnail_to_select_summary), color = MaterialTheme.colorScheme.outline) } ) PreferenceDivider() val showNetworkThumbnails by preferences.showNetworkThumbnails.collectAsState() - SwitchPreference( + LiquidSwitchPreference( value = showNetworkThumbnails, onValueChange = { preferences.showNetworkThumbnails.set(it) }, - title = { - Text( - text = stringResource(id = R.string.pref_appearance_show_network_thumbnails_title), - ) - }, - summary = { - Text( - text = stringResource(id = R.string.pref_appearance_show_network_thumbnails_summary), - color = MaterialTheme.colorScheme.outline, - ) - } + title = { Text(text = stringResource(id = R.string.pref_appearance_show_network_thumbnails_title)) }, + summary = { Text(text = stringResource(id = R.string.pref_appearance_show_network_thumbnails_summary), color = MaterialTheme.colorScheme.outline) } ) } } - } } } diff --git a/app/src/main/java/app/marlboroadvance/mpvex/ui/preferences/AudioPreferencesScreen.kt b/app/src/main/java/app/marlboroadvance/mpvex/ui/preferences/AudioPreferencesScreen.kt index 2bbd11ee5..c3e406609 100644 --- a/app/src/main/java/app/marlboroadvance/mpvex/ui/preferences/AudioPreferencesScreen.kt +++ b/app/src/main/java/app/marlboroadvance/mpvex/ui/preferences/AudioPreferencesScreen.kt @@ -36,7 +36,7 @@ import kotlinx.serialization.Serializable import me.zhanghai.compose.preference.ListPreference import me.zhanghai.compose.preference.ProvidePreferenceLocals import me.zhanghai.compose.preference.SliderPreference -import me.zhanghai.compose.preference.SwitchPreference +import app.marlboroadvance.mpvex.ui.components.liquid.LiquidSwitchPreference as SwitchPreference import me.zhanghai.compose.preference.TextFieldPreference import org.koin.compose.koinInject diff --git a/app/src/main/java/app/marlboroadvance/mpvex/ui/preferences/ControlLayoutEditorScreen.kt b/app/src/main/java/app/marlboroadvance/mpvex/ui/preferences/ControlLayoutEditorScreen.kt index 14464e037..048ced515 100644 --- a/app/src/main/java/app/marlboroadvance/mpvex/ui/preferences/ControlLayoutEditorScreen.kt +++ b/app/src/main/java/app/marlboroadvance/mpvex/ui/preferences/ControlLayoutEditorScreen.kt @@ -406,19 +406,12 @@ private fun IconsLegend() { verticalArrangement = Arrangement.spacedBy(16.dp) ) { // Header - androidx.compose.foundation.layout.Column { - Text( - text = "Icons Legend", - style = MaterialTheme.typography.titleMedium, - fontWeight = androidx.compose.ui.text.font.FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface, - ) - Text( - text = "What is each icon for?", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + Text( + text = "Icons Legend", + style = MaterialTheme.typography.titleMedium, + fontWeight = androidx.compose.ui.text.font.FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + ) // FlowRow grid of icons FlowRow( @@ -433,20 +426,36 @@ private fun IconsLegend() { horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.wrapContentWidth() ) { - val modifier = if (button == PlayerButton.VERTICAL_FLIP) { - Modifier.rotate(90f) + if (button == PlayerButton.AB_LOOP) { + // Show "AB" text instead of icon for AB_LOOP + Box( + modifier = Modifier.size(20.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = "AB", + style = MaterialTheme.typography.labelMedium, + fontWeight = androidx.compose.ui.text.font.FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = androidx.compose.ui.text.style.TextAlign.Center + ) + } } else { - Modifier - } + val modifier = if (button == PlayerButton.VERTICAL_FLIP) { + Modifier.rotate(90f) + } else { + Modifier + } - Icon( - imageVector = button.icon, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier - .size(20.dp) - .then(modifier) - ) + Icon( + imageVector = button.icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .size(20.dp) + .then(modifier) + ) + } Text( text = app.marlboroadvance.mpvex.preferences.getPlayerButtonLabel(button), diff --git a/app/src/main/java/app/marlboroadvance/mpvex/ui/preferences/DecoderPreferencesScreen.kt b/app/src/main/java/app/marlboroadvance/mpvex/ui/preferences/DecoderPreferencesScreen.kt index 10ee8e202..580172712 100644 --- a/app/src/main/java/app/marlboroadvance/mpvex/ui/preferences/DecoderPreferencesScreen.kt +++ b/app/src/main/java/app/marlboroadvance/mpvex/ui/preferences/DecoderPreferencesScreen.kt @@ -46,7 +46,7 @@ import app.marlboroadvance.mpvex.ui.utils.LocalBackStack import kotlinx.serialization.Serializable import me.zhanghai.compose.preference.ListPreference import me.zhanghai.compose.preference.ProvidePreferenceLocals -import me.zhanghai.compose.preference.SwitchPreference +import app.marlboroadvance.mpvex.ui.components.liquid.LiquidSwitchPreference as SwitchPreference import org.koin.compose.koinInject @Serializable diff --git a/app/src/main/java/app/marlboroadvance/mpvex/ui/preferences/GesturePreferencesScreen.kt b/app/src/main/java/app/marlboroadvance/mpvex/ui/preferences/GesturePreferencesScreen.kt index 5146880b8..0d20ecdc2 100644 --- a/app/src/main/java/app/marlboroadvance/mpvex/ui/preferences/GesturePreferencesScreen.kt +++ b/app/src/main/java/app/marlboroadvance/mpvex/ui/preferences/GesturePreferencesScreen.kt @@ -44,7 +44,7 @@ import kotlinx.serialization.Serializable import me.zhanghai.compose.preference.FooterPreference import me.zhanghai.compose.preference.ListPreference import me.zhanghai.compose.preference.ProvidePreferenceLocals -import me.zhanghai.compose.preference.SwitchPreference +import app.marlboroadvance.mpvex.ui.components.liquid.LiquidSwitchPreference as SwitchPreference import org.koin.compose.koinInject @Serializable diff --git a/app/src/main/java/app/marlboroadvance/mpvex/ui/preferences/PlayerControlsPreferencesScreen.kt b/app/src/main/java/app/marlboroadvance/mpvex/ui/preferences/PlayerControlsPreferencesScreen.kt index 1f4f3d4e3..d15cb1e08 100644 --- a/app/src/main/java/app/marlboroadvance/mpvex/ui/preferences/PlayerControlsPreferencesScreen.kt +++ b/app/src/main/java/app/marlboroadvance/mpvex/ui/preferences/PlayerControlsPreferencesScreen.kt @@ -54,7 +54,7 @@ import app.marlboroadvance.mpvex.ui.utils.LocalBackStack import kotlinx.serialization.Serializable import me.zhanghai.compose.preference.ListPreference import me.zhanghai.compose.preference.ProvidePreferenceLocals -import me.zhanghai.compose.preference.SwitchPreference +import app.marlboroadvance.mpvex.ui.components.liquid.LiquidSwitchPreference as SwitchPreference import app.marlboroadvance.mpvex.ui.preferences.components.PlayerButtonChip import org.koin.compose.koinInject diff --git a/app/src/main/java/app/marlboroadvance/mpvex/ui/preferences/PlayerPreferencesScreen.kt b/app/src/main/java/app/marlboroadvance/mpvex/ui/preferences/PlayerPreferencesScreen.kt index 5f9d433c0..a4bfd3338 100644 --- a/app/src/main/java/app/marlboroadvance/mpvex/ui/preferences/PlayerPreferencesScreen.kt +++ b/app/src/main/java/app/marlboroadvance/mpvex/ui/preferences/PlayerPreferencesScreen.kt @@ -30,7 +30,7 @@ import kotlinx.serialization.Serializable import me.zhanghai.compose.preference.ListPreference import me.zhanghai.compose.preference.ProvidePreferenceLocals import me.zhanghai.compose.preference.SliderPreference -import me.zhanghai.compose.preference.SwitchPreference +import app.marlboroadvance.mpvex.ui.components.liquid.LiquidSwitchPreference as SwitchPreference import org.koin.compose.koinInject import kotlin.math.roundToInt diff --git a/app/src/main/java/app/marlboroadvance/mpvex/ui/preferences/SubtitlesPreferencesScreen.kt b/app/src/main/java/app/marlboroadvance/mpvex/ui/preferences/SubtitlesPreferencesScreen.kt index fc839b1d7..544ce98cb 100644 --- a/app/src/main/java/app/marlboroadvance/mpvex/ui/preferences/SubtitlesPreferencesScreen.kt +++ b/app/src/main/java/app/marlboroadvance/mpvex/ui/preferences/SubtitlesPreferencesScreen.kt @@ -63,7 +63,7 @@ import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable import me.zhanghai.compose.preference.Preference import me.zhanghai.compose.preference.ProvidePreferenceLocals -import me.zhanghai.compose.preference.SwitchPreference +import app.marlboroadvance.mpvex.ui.components.liquid.LiquidSwitchPreference as SwitchPreference import me.zhanghai.compose.preference.TextFieldPreference import androidx.compose.material3.AlertDialog import androidx.compose.material3.Checkbox diff --git a/app/src/main/java/app/marlboroadvance/mpvex/ui/screens/player/LiquidPlayerControls.kt b/app/src/main/java/app/marlboroadvance/mpvex/ui/screens/player/LiquidPlayerControls.kt new file mode 100644 index 000000000..e022a719f --- /dev/null +++ b/app/src/main/java/app/marlboroadvance/mpvex/ui/screens/player/LiquidPlayerControls.kt @@ -0,0 +1,111 @@ +package app.marlboroadvance.mpvex.ui.screens.player + +import android.os.Build +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.kyant.backdrop.backdrops.rememberLayerBackdrop +import com.kyant.backdrop.drawBackdrop +import com.kyant.backdrop.Backdrop +import app.marlboroadvance.mpvex.preferences.LiquidUIPreferences +import app.marlboroadvance.mpvex.ui.theme.LiquidUIEffects + +@Composable +fun PlayerScreenWithLiquidUI( + liquidUIPreferences: LiquidUIPreferences +) { + // Collect the user's preference to toggle the UI on/off + val liquidUIEnabled = liquidUIPreferences.liquidUIEnabledFlow.collectAsState(initial = false).value + val backgroundColor = Color.Black + + // 1. Create the Backdrop State (CRITICAL) + val backdrop = rememberLayerBackdrop { + drawRect(backgroundColor) + drawContent() + } + + Box( + modifier = Modifier + .fillMaxSize() + .background(backgroundColor) + ) { + // Video content (Placeholder for the actual mpvEx video surface) + Box( + modifier = Modifier + .fillMaxSize() + .align(Alignment.Center), + contentAlignment = Alignment.Center + ) { + Text("Video Player Surface", color = Color.White) + } + + // 2. Apply the Controls + if (liquidUIEnabled) { + LiquidPlayerControls( + backdrop = backdrop, + modifier = Modifier.align(Alignment.BottomCenter) + ) + } else { + StandardPlayerControls( + modifier = Modifier.align(Alignment.BottomCenter) + ) + } + } +} + +@Composable +fun LiquidPlayerControls( + backdrop: Backdrop?, + modifier: Modifier = Modifier +) { + if (backdrop == null) { + StandardPlayerControls(modifier) + return + } + + Column(modifier = modifier.fillMaxWidth()) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(80.dp) + .padding(16.dp) + // 3. The Critical drawBackdrop Pattern + .drawBackdrop( + backdrop = backdrop, + shape = { RoundedCornerShape(24.dp) }, + effects = LiquidUIEffects.playerOverlayEffects( + enableBlur = true, + enableLens = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU, + enableVibrancy = true + ), + onDrawSurface = { drawRect(LiquidUIEffects.glassSurfaceColor) } + ), + contentAlignment = Alignment.Center + ) { + // Add your playback icons (Play/Pause/Seek) here + Text("Liquid Controls Active", color = Color.White) + } + } +} + +@Composable +fun StandardPlayerControls(modifier: Modifier = Modifier) { + // Fallback standard controls + Box( + modifier = modifier + .fillMaxWidth() + .height(80.dp) + .padding(16.dp) + .background(Color.DarkGray, RoundedCornerShape(24.dp)), + contentAlignment = Alignment.Center + ) { + Text("Standard Controls", color = Color.White) + } +} diff --git a/app/src/main/java/app/marlboroadvance/mpvex/ui/screens/settings/LiquidUISettingsScreen.kt b/app/src/main/java/app/marlboroadvance/mpvex/ui/screens/settings/LiquidUISettingsScreen.kt new file mode 100644 index 000000000..211548b4f --- /dev/null +++ b/app/src/main/java/app/marlboroadvance/mpvex/ui/screens/settings/LiquidUISettingsScreen.kt @@ -0,0 +1,315 @@ +package app.marlboroadvance.mpvex.ui.screens.settings + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Card +import androidx.compose.material3.Divider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import app.marlboroadvance.mpvex.preferences.LiquidUIPreferences +import app.marlboroadvance.mpvex.ui.components.liquid.AdaptiveToggle +import kotlinx.coroutines.launch + +@Composable +fun LiquidUISettingsScreen( + preferences: LiquidUIPreferences, + modifier: Modifier = Modifier +) { + val scope = rememberCoroutineScope() + + val liquidUIEnabled = preferences.liquidUIEnabledFlow.collectAsState(false).value + val blurEnabled = preferences.liquidBlurEnabledFlow.collectAsState(true).value + val lensEnabled = preferences.liquidLensEnabledFlow.collectAsState(true).value + val vibrancyEnabled = preferences.liquidVibrancyEnabledFlow.collectAsState(true).value + + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp) + ) { + + Text( + text = "Liquid Glass UI", + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.padding(bottom = 8.dp) + ) + + Text( + text = "Enable beautiful liquid glass effects throughout the app", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 16.dp) + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Enable Liquid UI", + style = MaterialTheme.typography.bodyLarge + ) + + AdaptiveToggle( + checked = liquidUIEnabled, + onCheckedChange = { enabled -> + scope.launch { preferences.setLiquidUIEnabled(enabled) } + }, + preferences = preferences + ) + } + + AnimatedVisibility(visible = liquidUIEnabled) { + Column { + Divider(modifier = Modifier.padding(vertical = 16.dp)) + + Text( + text = "Custom Colors", + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.padding(bottom = 8.dp) + ) + + Text( + text = "Type a hex code (e.g. #FF5733) or a basic color name (e.g. red, blue, cyan)", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 16.dp) + ) + + val toggleColor = preferences.liquidToggleColorFlow.collectAsState(0xFF4CAF50).value + val sliderColor = preferences.liquidSliderColorFlow.collectAsState(0xFF2196F3).value + + fun parseColorInput(input: String): Long? { + return try { + val formatted = if (input.matches(Regex("^[0-9A-Fa-f]{6,8}$"))) "#$input" else input + val colorInt = android.graphics.Color.parseColor(formatted) + colorInt.toLong() and 0xFFFFFFFFL + } catch (e: Exception) { null } + } + + var toggleInputText by remember(toggleColor) { + mutableStateOf(String.format("#%06X", 0xFFFFFF and toggleColor.toInt())) + } + + OutlinedTextField( + value = toggleInputText, + onValueChange = { newValue -> + toggleInputText = newValue + parseColorInput(newValue)?.let { colorLong -> + scope.launch { preferences.setToggleColor(colorLong) } + } + }, + label = { Text("Toggle Color") }, + placeholder = { Text("e.g. #FF5733 or blue") }, + modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp), + singleLine = true, + leadingIcon = { + Box( + modifier = Modifier + .size(24.dp) + .clip(CircleShape) + .background(Color(toggleColor)) + ) + } + ) + + var sliderInputText by remember(sliderColor) { + mutableStateOf(String.format("#%06X", 0xFFFFFF and sliderColor.toInt())) + } + + OutlinedTextField( + value = sliderInputText, + onValueChange = { newValue -> + sliderInputText = newValue + parseColorInput(newValue)?.let { colorLong -> + scope.launch { preferences.setSliderColor(colorLong) } + } + }, + label = { Text("Slider Color") }, + placeholder = { Text("e.g. #00FF00 or cyan") }, + modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), + singleLine = true, + leadingIcon = { + Box( + modifier = Modifier + .size(24.dp) + .clip(CircleShape) + .background(Color(sliderColor)) + ) + } + ) + } + } + + Divider(modifier = Modifier.padding(vertical = 16.dp)) + + Text( + text = "Effect Settings", + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.padding(bottom = 8.dp) + ) + + Text( + text = "Customize which effects are applied", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 16.dp) + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Blur Effect", + style = MaterialTheme.typography.bodyLarge + ) + Text( + text = "Blurs the background behind UI elements", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + AdaptiveToggle( + checked = blurEnabled, + onCheckedChange = { enabled -> + scope.launch { preferences.setBlurEnabled(enabled) } + }, + preferences = preferences, + enabled = liquidUIEnabled + ) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Lens Effect", + style = MaterialTheme.typography.bodyLarge + ) + Text( + text = "Adds refraction lens effect (Android 13+)", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + AdaptiveToggle( + checked = lensEnabled, + onCheckedChange = { enabled -> + scope.launch { preferences.setLensEnabled(enabled) } + }, + preferences = preferences, + enabled = liquidUIEnabled && android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU + ) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Vibrancy Effect", + style = MaterialTheme.typography.bodyLarge + ) + Text( + text = "Enhances color vibrancy of background content", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + AdaptiveToggle( + checked = vibrancyEnabled, + onCheckedChange = { enabled -> + scope.launch { preferences.setVibrancyEnabled(enabled) } + }, + preferences = preferences, + enabled = liquidUIEnabled + ) + } + + Divider(modifier = Modifier.padding(vertical = 16.dp)) + + Text( + text = "Information", + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.padding(bottom = 8.dp) + ) + + InfoCard( + title = "Performance Note", + description = "Liquid glass effects work best on Android 12+. " + + "Lens effects require Android 13+. Effects are optimized " + + "to have minimal performance impact." + ) + } +} + +@Composable +private fun InfoCard( + title: String, + description: String, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + ) { + Column(modifier = Modifier.padding(12.dp)) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall + ) + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp) + ) + } + } +} diff --git a/app/src/main/java/app/marlboroadvance/mpvex/ui/theme/LiquidUIEffects.kt b/app/src/main/java/app/marlboroadvance/mpvex/ui/theme/LiquidUIEffects.kt new file mode 100644 index 000000000..3023aab3c --- /dev/null +++ b/app/src/main/java/app/marlboroadvance/mpvex/ui/theme/LiquidUIEffects.kt @@ -0,0 +1,107 @@ +package app.marlboroadvance.mpvex.ui.theme + +import android.os.Build +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.kyant.backdrop.BackdropEffectScope +import com.kyant.backdrop.effects.blur +import com.kyant.backdrop.effects.lens +import com.kyant.backdrop.effects.vibrancy + +/** + * CORRECTED: Effects configuration for liquid glass UI + * + * IMPORTANT: These are lambdas to be used in the effects = { } block + * within drawBackdrop modifier, NOT to be used with buildList() + * + * Usage example: + * + * .drawBackdrop( + * backdrop = backdrop, + * shape = { RoundedCornerShape(16.dp) }, + * effects = LiquidUIEffects.glassBottomBarEffects( + * enableBlur = true, + * enableLens = true, + * enableVibrancy = true + * ) + * ) + */ +object LiquidUIEffects { + + /** + * Glass Bottom Bar Effect - Used for media player controls + * Combines blur, lens, and vibrancy for a classic liquid glass look + * + * Returns a lambda to be used in effects = { } block + */ + fun glassBottomBarEffects( + enableBlur: Boolean = true, + enableLens: Boolean = true, + enableVibrancy: Boolean = true + ): BackdropEffectScope.() -> Unit = { + if (enableVibrancy) vibrancy() + if (enableBlur) blur(4f.dp.toPx()) + if (enableLens && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + lens(16f.dp.toPx(), 32f.dp.toPx()) + } + } + + /** + * Interactive Glass Button Effect - For playback control buttons + * Lighter blur with enhanced color + */ + fun glassButtonEffects( + enableBlur: Boolean = true, + enableLens: Boolean = true + ): BackdropEffectScope.() -> Unit = { + if (enableBlur) blur(3f.dp.toPx()) + if (enableLens && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + lens(12f.dp.toPx(), 24f.dp.toPx()) + } + } + + /** + * Glass Dialog Effect - For modal dialogs and bottom sheets + * Stronger blur for better readability + */ + fun glassDialogEffects( + enableBlur: Boolean = true, + enableLens: Boolean = true + ): BackdropEffectScope.() -> Unit = { + vibrancy() + if (enableBlur) blur(8f.dp.toPx()) + if (enableLens && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + lens(20f.dp.toPx(), 40f.dp.toPx()) + } + } + + /** + * Glass Card Effect - For video cards, playlist items + * Subtle effect to avoid overwhelming content + */ + fun glassCardEffects( + enableBlur: Boolean = true + ): BackdropEffectScope.() -> Unit = { + if (enableBlur) blur(2.5f.dp.toPx()) + } + + /** + * Video Player Overlay Effect - For player controls overlay + * Balanced blur and lens for interactive controls + */ + fun playerOverlayEffects( + enableBlur: Boolean = true, + enableLens: Boolean = true, + enableVibrancy: Boolean = true + ): BackdropEffectScope.() -> Unit = { + if (enableVibrancy) vibrancy() + if (enableBlur) blur(5f.dp.toPx()) + if (enableLens && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + lens(18f.dp.toPx(), 36f.dp.toPx()) + } + } + + // Surface color for readability (semi-transparent white) + val glassSurfaceColor = Color.White.copy(alpha = 0.5f) + val glassDarkSurfaceColor = Color.Black.copy(alpha = 0.3f) +}