diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7e3836854..8f2a1a6ab 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,7 +1,7 @@ import com.android.build.api.variant.FilterConfiguration import org.jetbrains.kotlin.gradle.dsl.JvmTarget -val enableX86 = project.findProperty("enableX86") != "false" +val enableX86 = project.findProperty("enableX86") == "true" val x86Abis = if (enableX86) listOf("x86", "x86_64") else emptyList() plugins { @@ -109,6 +109,7 @@ android { viewBinding = true buildConfig = true resValues = true + prefab = true } packaging { @@ -197,8 +198,11 @@ dependencies { implementation(libs.androidx.compose.animation.graphics) implementation(libs.mediasession) implementation(libs.androidx.documentfile) + implementation(libs.androidx.datastore.core) + implementation(libs.androidx.datastore.preferences) implementation(libs.bundles.coil) + implementation(platform(libs.koin.bom)) implementation(libs.bundles.koin) @@ -212,11 +216,21 @@ dependencies { implementation(libs.room.ktx) implementation(libs.kotlinx.immutable.collections) + implementation(libs.kotlinx.coroutines.guava) implementation(libs.kotlinx.serialization.json) + implementation(libs.okhttp) implementation(libs.jsoup) implementation(libs.androidx.media3.common) + implementation(libs.androidx.media3.datasource.okhttp) implementation(libs.androidx.media3.effect) + implementation(libs.androidx.media3.exoplayer) + implementation(libs.androidx.media3.exoplayer.dash) + implementation(libs.androidx.media3.exoplayer.hls) + implementation(libs.androidx.media3.exoplayer.rtsp) + implementation(libs.androidx.media3.session) + implementation(libs.androidx.media3.ui) + implementation(libs.androidx.media3.ui.compose) implementation(libs.androidx.media3.transformer) implementation(platform(libs.sora.editor.bom)) implementation(libs.sora.editor) @@ -226,11 +240,18 @@ dependencies { coreLibraryDesugaring(libs.desugar.jdk.libs) implementation(libs.truetype.parser) + implementation(libs.juniversalchardet) + implementation(libs.ass.media) implementation(libs.fsaf) + implementation("com.bytedance:bytehook:1.1.1") + + implementation(libs.mediainfo.lib) implementation("com.llamatik:library:1.4.0") implementation(files("libs/mpvlib.aar")) + implementation(files("libs/media3ext-release.aar")) + // Network protocol libraries implementation(libs.smbj) @@ -241,6 +262,8 @@ dependencies { implementation(libs.nanohttpd) implementation(libs.lazycolumnscrollbar) implementation(libs.reorderable) + implementation(libs.kyant.backdrop) + implementation(libs.kyant.shapes) } /* ---------------- Git helpers ---------------- */ diff --git a/app/libs/media3ext-release.aar b/app/libs/media3ext-release.aar new file mode 100644 index 000000000..5dac47408 Binary files /dev/null and b/app/libs/media3ext-release.aar differ diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3959fcf22..90eef724a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -332,6 +332,24 @@ + + + + + + + + +#include +#include +#include +#include +#include +#include +#include +#include + +#define LOG_TAG "GpuDriverBridge" +#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__) +#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__) + +extern "C" { +#include +} + +#include + +namespace { +void* g_vulkan_handle = nullptr; +bytehook_stub_t g_dlopen_stub = nullptr; +bytehook_stub_t g_android_dlopen_ext_stub = nullptr; +bytehook_stub_t g_dlsym_stub = nullptr; + +bytehook_stub_t g_vkGetInstanceProcAddr_stub = nullptr; +bytehook_stub_t g_vkCreateInstance_stub = nullptr; +bytehook_stub_t g_vkEnumerateInstanceExtensionProperties_stub = nullptr; +bytehook_stub_t g_vkEnumerateInstanceVersion_stub = nullptr; + +bool my_caller_allow_filter(const char *caller_path_name, void *arg) { + if (caller_path_name) { + // Log all callers to help debug + // LOGI("Filter check caller: %s", caller_path_name); + + if (strstr(caller_path_name, "libmpv.so") || + strstr(caller_path_name, "libplayer.so") || + strstr(caller_path_name, "libplacebo.so") || + strstr(caller_path_name, "libavcodec.so") || + strstr(caller_path_name, "libavformat.so") || + strstr(caller_path_name, "libavutil.so") || + strstr(caller_path_name, "libavfilter.so") || + strstr(caller_path_name, "libavdevice.so") || + strstr(caller_path_name, "libswscale.so") || + strstr(caller_path_name, "libswresample.so")) { + return true; + } + } else { + // If caller is unknown, allow it for now to be safe (might be from libmpv via some wrapper) + return true; + } + return false; +} + +bool is_libvulkan(const char* filename) { + if (!filename) return false; + if (strcmp(filename, "libvulkan.so") == 0 || strcmp(filename, "libvulkan.so.1") == 0) return true; + + size_t len = strlen(filename); + if (len >= 13) { + if (strcmp(filename + len - 13, "/libvulkan.so") == 0) return true; + } + return false; +} + +typedef void* (*PFN_vkGetInstanceProcAddr)(void*, const char*); +typedef int (*PFN_vkCreateInstance)(const void*, const void*, void**); +typedef int (*PFN_vkEnumerateInstanceExtensionProperties)(const char*, uint32_t*, void*); +typedef int (*PFN_vkEnumerateInstanceVersion)(uint32_t*); + +void* my_vkGetInstanceProcAddr(void* instance, const char* name); +int my_vkCreateInstance(const void* pCreateInfo, const void* pAllocator, void** pInstance); +int my_vkEnumerateInstanceExtensionProperties(const char* pLayerName, uint32_t* pPropertyCount, void* pProperties); +int my_vkEnumerateInstanceVersion(uint32_t* pApiVersion); + +void* my_dlopen(const char* filename, int flags) { + if (filename && g_vulkan_handle && is_libvulkan(filename)) { + LOGI("Intercepted dlopen for %s -> redirecting to custom driver", filename); + return g_vulkan_handle; + } + BYTEHOOK_STACK_SCOPE(); + return BYTEHOOK_CALL_PREV(my_dlopen, filename, flags); +} + +void* my_android_dlopen_ext(const char* filename, int flags, const void* extinfo) { + if (filename && g_vulkan_handle && is_libvulkan(filename)) { + LOGI("Intercepted android_dlopen_ext for %s -> redirecting to custom driver", filename); + return g_vulkan_handle; + } + BYTEHOOK_STACK_SCOPE(); + return BYTEHOOK_CALL_PREV(my_android_dlopen_ext, filename, flags, extinfo); +} + +void* my_dlsym(void* handle, const char* symbol) { + if (g_vulkan_handle && symbol) { + if (strcmp(symbol, "vkGetInstanceProcAddr") == 0) return (void*)my_vkGetInstanceProcAddr; + if (strcmp(symbol, "vkCreateInstance") == 0) return (void*)my_vkCreateInstance; + if (strcmp(symbol, "vkEnumerateInstanceExtensionProperties") == 0) return (void*)my_vkEnumerateInstanceExtensionProperties; + if (strcmp(symbol, "vkEnumerateInstanceVersion") == 0) return (void*)my_vkEnumerateInstanceVersion; + } + BYTEHOOK_STACK_SCOPE(); + return BYTEHOOK_CALL_PREV(my_dlsym, handle, symbol); +} + +void* my_vkGetInstanceProcAddr(void* instance, const char* name) { + if (g_vulkan_handle) { + auto func = (PFN_vkGetInstanceProcAddr) dlsym(g_vulkan_handle, "vkGetInstanceProcAddr"); + if (func) return func(instance, name); + } + BYTEHOOK_STACK_SCOPE(); + return BYTEHOOK_CALL_PREV(my_vkGetInstanceProcAddr, instance, name); +} + +int my_vkCreateInstance(const void* pCreateInfo, const void* pAllocator, void** pInstance) { + if (g_vulkan_handle) { + LOGI("Intercepted vkCreateInstance"); + auto func = (PFN_vkCreateInstance) dlsym(g_vulkan_handle, "vkCreateInstance"); + if (func) return func(pCreateInfo, pAllocator, pInstance); + } + BYTEHOOK_STACK_SCOPE(); + return BYTEHOOK_CALL_PREV(my_vkCreateInstance, pCreateInfo, pAllocator, pInstance); +} + +int my_vkEnumerateInstanceExtensionProperties(const char* pLayerName, uint32_t* pPropertyCount, void* pProperties) { + if (g_vulkan_handle) { + auto func = (PFN_vkEnumerateInstanceExtensionProperties) dlsym(g_vulkan_handle, "vkEnumerateInstanceExtensionProperties"); + if (func) return func(pLayerName, pPropertyCount, pProperties); + } + BYTEHOOK_STACK_SCOPE(); + return BYTEHOOK_CALL_PREV(my_vkEnumerateInstanceExtensionProperties, pLayerName, pPropertyCount, pProperties); +} + +int my_vkEnumerateInstanceVersion(uint32_t* pApiVersion) { + if (g_vulkan_handle) { + auto func = (PFN_vkEnumerateInstanceVersion) dlsym(g_vulkan_handle, "vkEnumerateInstanceVersion"); + if (func) return func(pApiVersion); + } + BYTEHOOK_STACK_SCOPE(); + return BYTEHOOK_CALL_PREV(my_vkEnumerateInstanceVersion, pApiVersion); +} + +} // namespace + +extern "C" { + +JNIEXPORT jboolean JNICALL +Java_app_gyrolet_mpvrx_domain_gpu_GpuDriverBridge_setDriver( + JNIEnv* env, + jobject /* this */, + jstring hookLibDir, + jstring customDriverDir, + jstring customDriverName, + jstring fileRedirectDir, + jstring tmpDir) { + + const char* nativeHookLibDir = hookLibDir ? env->GetStringUTFChars(hookLibDir, nullptr) : nullptr; + const char* nativeDriverDirRaw = customDriverDir ? env->GetStringUTFChars(customDriverDir, nullptr) : nullptr; + const char* nativeDriverName = customDriverName ? env->GetStringUTFChars(customDriverName, nullptr) : nullptr; + const char* nativeFileRedirectDir = fileRedirectDir ? env->GetStringUTFChars(fileRedirectDir, nullptr) : nullptr; + const char* nativeTmpDir = tmpDir ? env->GetStringUTFChars(tmpDir, nullptr) : nullptr; + + std::string driverDirStr = nativeDriverDirRaw ? nativeDriverDirRaw : ""; + if (!driverDirStr.empty() && driverDirStr.back() != '/') { + driverDirStr += '/'; + } + const char* nativeDriverDir = driverDirStr.empty() ? nullptr : driverDirStr.c_str(); + + LOGI("setDriver: hookLibDir=%s, customDriverDir=%s, customDriverName=%s, tmpDir=%s", + nativeHookLibDir ? nativeHookLibDir : "null", + nativeDriverDir ? nativeDriverDir : "null", + nativeDriverName ? nativeDriverName : "null", + nativeTmpDir ? nativeTmpDir : "null"); + + void* handle = nullptr; + int featureFlags = 0; + + if (nativeFileRedirectDir && strlen(nativeFileRedirectDir) > 0) { + featureFlags |= ADRENOTOOLS_DRIVER_FILE_REDIRECT; + } + + if (nativeDriverName && strlen(nativeDriverName) > 0) { + handle = adrenotools_open_libvulkan( + 2 /* RTLD_NOW */, featureFlags | ADRENOTOOLS_DRIVER_CUSTOM, nativeTmpDir, nativeHookLibDir, + nativeDriverDir, nativeDriverName, nativeFileRedirectDir, nullptr); + if (handle) { + LOGI("Successfully loaded custom driver: %s", nativeDriverName); + } else { + LOGE("Failed to load custom driver: %s", nativeDriverName); + } + } + + if (!handle) { + handle = adrenotools_open_libvulkan( + 2 /* RTLD_NOW */, featureFlags, nativeTmpDir, nativeHookLibDir, + nullptr, nullptr, nativeFileRedirectDir, nullptr); + if (handle) { + LOGI("Successfully initialized system driver with adrenotools hooks"); + } + } + + if (handle) { + g_vulkan_handle = handle; + bytehook_init(BYTEHOOK_MODE_AUTOMATIC, false); + + // Register hooks with caller filtering + if (!g_dlopen_stub) g_dlopen_stub = bytehook_hook_partial(my_caller_allow_filter, nullptr, nullptr, "dlopen", (void*)my_dlopen, nullptr, nullptr); + if (!g_android_dlopen_ext_stub) g_android_dlopen_ext_stub = bytehook_hook_partial(my_caller_allow_filter, nullptr, nullptr, "android_dlopen_ext", (void*)my_android_dlopen_ext, nullptr, nullptr); + if (!g_dlsym_stub) g_dlsym_stub = bytehook_hook_partial(my_caller_allow_filter, nullptr, nullptr, "dlsym", (void*)my_dlsym, nullptr, nullptr); + if (!g_vkGetInstanceProcAddr_stub) g_vkGetInstanceProcAddr_stub = bytehook_hook_partial(my_caller_allow_filter, nullptr, nullptr, "vkGetInstanceProcAddr", (void*)my_vkGetInstanceProcAddr, nullptr, nullptr); + if (!g_vkCreateInstance_stub) g_vkCreateInstance_stub = bytehook_hook_partial(my_caller_allow_filter, nullptr, nullptr, "vkCreateInstance", (void*)my_vkCreateInstance, nullptr, nullptr); + if (!g_vkEnumerateInstanceExtensionProperties_stub) g_vkEnumerateInstanceExtensionProperties_stub = bytehook_hook_partial(my_caller_allow_filter, nullptr, nullptr, "vkEnumerateInstanceExtensionProperties", (void*)my_vkEnumerateInstanceExtensionProperties, nullptr, nullptr); + if (!g_vkEnumerateInstanceVersion_stub) g_vkEnumerateInstanceVersion_stub = bytehook_hook_partial(my_caller_allow_filter, nullptr, nullptr, "vkEnumerateInstanceVersion", (void*)my_vkEnumerateInstanceVersion, nullptr, nullptr); + + LOGI("ByteHook initialized for libmpv redirection"); + } + + if (nativeHookLibDir) env->ReleaseStringUTFChars(hookLibDir, nativeHookLibDir); + if (nativeDriverDirRaw) env->ReleaseStringUTFChars(customDriverDir, nativeDriverDirRaw); + if (nativeDriverName) env->ReleaseStringUTFChars(customDriverName, nativeDriverName); + if (nativeFileRedirectDir) env->ReleaseStringUTFChars(fileRedirectDir, nativeFileRedirectDir); + if (nativeTmpDir) env->ReleaseStringUTFChars(tmpDir, nativeTmpDir); + + return handle != nullptr ? JNI_TRUE : JNI_FALSE; +} + +JNIEXPORT jboolean JNICALL +Java_app_gyrolet_mpvrx_domain_gpu_GpuDriverBridge_isAdrenoDevice( + JNIEnv* env, + jobject /* this */) { + return access("/dev/kgsl-3d0", F_OK) == 0 ? JNI_TRUE : JNI_FALSE; +} + +JNIEXPORT jstring JNICALL +Java_app_gyrolet_mpvrx_domain_gpu_GpuDriverBridge_getGpuInfo( + JNIEnv* env, + jobject /* this */) { + if (access("/dev/kgsl-3d0", F_OK) == 0) { + return env->NewStringUTF("Qualcomm Adreno GPU Detected"); + } + return env->NewStringUTF("Generic GPU Driver Active"); +} + +} // extern "C" diff --git a/app/src/main/java/app/gyrolet/mpvrx/App.kt b/app/src/main/java/app/gyrolet/mpvrx/App.kt index bf7da7d4c..3e09f81f4 100644 --- a/app/src/main/java/app/gyrolet/mpvrx/App.kt +++ b/app/src/main/java/app/gyrolet/mpvrx/App.kt @@ -2,6 +2,7 @@ package app.gyrolet.mpvrx import android.app.Application import android.util.Log +import app.gyrolet.mpvrx.utils.GpuDriverHelper import app.gyrolet.mpvrx.database.repository.VideoMetadataCacheRepository import app.gyrolet.mpvrx.di.DatabaseModule import app.gyrolet.mpvrx.di.FileManagerModule @@ -38,15 +39,28 @@ class App : Application() { override fun onCreate() { super.onCreate() - // Initialize Koin - startKoin { - androidContext(this@App) - modules( - PreferencesModule, - DatabaseModule, - FileManagerModule, - app.gyrolet.mpvrx.di.domainModule, - ) + try { + android.util.Log.d("App", "Starting Koin...") + com.bytedance.android.bytehook.ByteHook.init() + startKoin { + androidContext(this@App) + modules( + PreferencesModule, + DatabaseModule, + FileManagerModule, + app.gyrolet.mpvrx.di.domainModule, + app.gyrolet.mpvrx.exoplayer.di.exoPlayerModule, + ) + } + android.util.Log.d("App", "Koin initialized successfully") + + try { + GpuDriverHelper.initialize(this) + } catch (t: Throwable) { + android.util.Log.e("App", "CRITICAL: Failed to initialize GPU driver helper", t) + } + } catch (t: Throwable) { + android.util.Log.e("App", "CRITICAL: App initialization failed!", t) } Thread.setDefaultUncaughtExceptionHandler(GlobalExceptionHandler(applicationContext, CrashActivity::class.java)) @@ -129,4 +143,3 @@ class App : Application() { } } } - diff --git a/app/src/main/java/app/gyrolet/mpvrx/database/entities/RecentlyPlayedEntity.kt b/app/src/main/java/app/gyrolet/mpvrx/database/entities/RecentlyPlayedEntity.kt index a7a43ff8a..70e992322 100644 --- a/app/src/main/java/app/gyrolet/mpvrx/database/entities/RecentlyPlayedEntity.kt +++ b/app/src/main/java/app/gyrolet/mpvrx/database/entities/RecentlyPlayedEntity.kt @@ -1,9 +1,11 @@ package app.gyrolet.mpvrx.database.entities +import androidx.compose.runtime.Immutable import androidx.room.Entity import androidx.room.PrimaryKey @Entity +@Immutable data class RecentlyPlayedEntity( @PrimaryKey(autoGenerate = true) val id: Int = 0, val filePath: String, diff --git a/app/src/main/java/app/gyrolet/mpvrx/di/DomainModule.kt b/app/src/main/java/app/gyrolet/mpvrx/di/DomainModule.kt index 78dcaaa4f..2366080be 100644 --- a/app/src/main/java/app/gyrolet/mpvrx/di/DomainModule.kt +++ b/app/src/main/java/app/gyrolet/mpvrx/di/DomainModule.kt @@ -1,5 +1,6 @@ package app.gyrolet.mpvrx.di +import app.gyrolet.mpvrx.domain.gpu.GpuDriverManager import app.gyrolet.mpvrx.domain.anime4k.Anime4KManager import app.gyrolet.mpvrx.domain.hdr.HdrToysManager import app.gyrolet.mpvrx.domain.thumbnail.CoilVideoThumbnailDecoder @@ -39,6 +40,7 @@ import okhttp3.OkHttpClient import okio.FileSystem import org.koin.android.ext.koin.androidContext import org.koin.core.qualifier.named +import org.koin.core.module.dsl.singleOf import org.koin.dsl.module import java.util.concurrent.TimeUnit @@ -51,6 +53,7 @@ val domainModule = module { .cookieJar(AndroidCookieJar()) .build() } + single { GpuDriverManager(get(), get()) } single { val context = androidContext() val browserPreferences = get() diff --git a/app/src/main/java/app/gyrolet/mpvrx/di/PreferencesModule.kt b/app/src/main/java/app/gyrolet/mpvrx/di/PreferencesModule.kt index 200408fa8..337f688d9 100644 --- a/app/src/main/java/app/gyrolet/mpvrx/di/PreferencesModule.kt +++ b/app/src/main/java/app/gyrolet/mpvrx/di/PreferencesModule.kt @@ -8,6 +8,7 @@ import app.gyrolet.mpvrx.preferences.AudioPreferences import app.gyrolet.mpvrx.preferences.BrowserPreferences import app.gyrolet.mpvrx.preferences.DecoderPreferences import app.gyrolet.mpvrx.preferences.FoldersPreferences +import app.gyrolet.mpvrx.preferences.GpuDriverPreferences import app.gyrolet.mpvrx.preferences.GesturePreferences import app.gyrolet.mpvrx.preferences.PlayerPreferences import app.gyrolet.mpvrx.preferences.SettingsManager @@ -36,5 +37,6 @@ val PreferencesModule = singleOf(::AiPreferences) singleOf(::YtdlPreferences) singleOf(::SettingsManager) + singleOf(::GpuDriverPreferences) } diff --git a/app/src/main/java/app/gyrolet/mpvrx/domain/gpu/GpuDriver.kt b/app/src/main/java/app/gyrolet/mpvrx/domain/gpu/GpuDriver.kt new file mode 100644 index 000000000..ef5bbbce8 --- /dev/null +++ b/app/src/main/java/app/gyrolet/mpvrx/domain/gpu/GpuDriver.kt @@ -0,0 +1,16 @@ +package app.gyrolet.mpvrx.domain.gpu + +import kotlinx.serialization.Serializable + +@Serializable +data class GpuDriver( + val id: String, + val name: String, + val description: String = "", + val author: String = "", + val version: String = "", + val vendor: String = "", + val driverPath: String, // Path to the extracted driver directory + val vulkanLibName: String, // Usually libvulkan_freedreno.so + val isSystem: Boolean = false +) diff --git a/app/src/main/java/app/gyrolet/mpvrx/domain/gpu/GpuDriverBridge.kt b/app/src/main/java/app/gyrolet/mpvrx/domain/gpu/GpuDriverBridge.kt new file mode 100644 index 000000000..80833be0e --- /dev/null +++ b/app/src/main/java/app/gyrolet/mpvrx/domain/gpu/GpuDriverBridge.kt @@ -0,0 +1,30 @@ +package app.gyrolet.mpvrx.domain.gpu + +object GpuDriverBridge { + private var isLibraryLoaded = false + + init { + try { + System.loadLibrary("adrenotools_bridge") + isLibraryLoaded = true + android.util.Log.i("GpuDriverBridge", "Successfully loaded adrenotools_bridge") + } catch (t: Throwable) { + isLibraryLoaded = false + android.util.Log.e("GpuDriverBridge", "Failed to load adrenotools_bridge", t) + } + } + + fun isAvailable(): Boolean = isLibraryLoaded + + external fun setDriver( + hookLibDir: String?, + customDriverDir: String?, + customDriverName: String?, + fileRedirectDir: String?, + tmpDir: String? + ): Boolean + + external fun isAdrenoDevice(): Boolean + + external fun getGpuInfo(): String +} diff --git a/app/src/main/java/app/gyrolet/mpvrx/domain/gpu/GpuDriverManager.kt b/app/src/main/java/app/gyrolet/mpvrx/domain/gpu/GpuDriverManager.kt new file mode 100644 index 000000000..736f0f0f7 --- /dev/null +++ b/app/src/main/java/app/gyrolet/mpvrx/domain/gpu/GpuDriverManager.kt @@ -0,0 +1,399 @@ +package app.gyrolet.mpvrx.domain.gpu + +import android.content.Context +import android.net.Uri +import android.os.Build +import android.util.Log +import okhttp3.OkHttpClient +import okhttp3.Request +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.* +import java.io.File +import java.io.FileOutputStream +import java.util.UUID +import java.util.zip.ZipInputStream + +class GpuDriverManager(private val context: Context, private val okHttpClient: OkHttpClient) { + private val driversDir: File + get() { + val dir = File(context.filesDir, "gpu_drivers") + if (!dir.exists()) dir.mkdirs() + return dir + } + + private fun getSafGpuDriverFolder(): androidx.documentfile.provider.DocumentFile? { + val prefs = androidx.preference.PreferenceManager.getDefaultSharedPreferences(context) + val baseStorageFolder = prefs.getString("base_storage_folder", "") ?: "" + if (baseStorageFolder.isBlank()) return null + + return try { + val tree = androidx.documentfile.provider.DocumentFile.fromTreeUri(context, Uri.parse(baseStorageFolder)) + val gpuFolder = tree?.findFile("gpudriver") + if (gpuFolder != null && gpuFolder.isDirectory) gpuFolder else null + } catch (e: Exception) { + null + } + } + + private val json = Json { ignoreUnknownKeys = true } + + private val repoList = listOf( + DriverRepo("Eden Adreno Tools", "eden-emulator/libadrenotools", 0), + DriverRepo("Mr. Purple Turnip", "MrPurple666/purple-turnip", 1), + DriverRepo("GameHub Adreno 8xx", "crueter/GameHub-8Elite-Drivers", 2), + DriverRepo("KIMCHI Turnip", "K11MCH1/AdrenoToolsDrivers", 3, true), + DriverRepo("Weab-Chan Freedreno", "Weab-chan/freedreno_turnip-CI", 4), + DriverRepo("Whitebelyash Turnip", "whitebelyash/freedreno_turnip-CI", 5), + ) + + private val driverMap = listOf( + IntRange(Int.MIN_VALUE, 9) to "Unsupported", + IntRange(10, 99) to "KIMCHI Latest", + IntRange(100, 599) to "Unsupported", + IntRange(600, 639) to "Mr. Purple EOL-24.3.4", + IntRange(640, 699) to "Mr. Purple T19", + IntRange(700, 710) to "KIMCHI 25.2.0_r5", + IntRange(711, 799) to "Mr. Purple T23", + IntRange(800, 899) to "GameHub Adreno 8xx", + IntRange(900, Int.MAX_VALUE) to "Unsupported" + ) + + fun getGpuModel(): String { + if (!isArchitectureSupported() || !GpuDriverBridge.isAvailable()) { + return "Architecture Not Supported (${Build.SUPPORTED_ABIS.firstOrNull()})" + } + return runCatching { GpuDriverBridge.getGpuInfo() }.getOrDefault("Unknown GPU (Bridge Error)") + } + + fun isAdrenoSupported(): Boolean { + if (!isArchitectureSupported() || !GpuDriverBridge.isAvailable()) { + return false + } + return runCatching { GpuDriverBridge.isAdrenoDevice() }.getOrDefault(false) + } + + private fun isArchitectureSupported(): Boolean { + return Build.SUPPORTED_ABIS.contains("arm64-v8a") + } + + fun parseAdrenoModel(gpuModel: String): Int { + if (gpuModel.isEmpty()) return 0 + val modelList = gpuModel.split(" ") + val adrenoIndex = modelList.indexOfFirst { it.contains("Adreno", ignoreCase = true) } + if (adrenoIndex == -1) return 0 + for (i in adrenoIndex + 1 until modelList.size) { + val part = modelList[i].removePrefix("(TM)").trim() + if (part.isEmpty()) continue + try { + if (part.startsWith("A", ignoreCase = true)) { + return part.substring(1).toInt() + } + val modelNum = part.filter { it.isDigit() }.toIntOrNull() + if (modelNum != null) return modelNum + } catch (e: Exception) { } + } + return 0 + } + + fun getRecommendedDriver(adrenoModel: Int): String { + return driverMap.firstOrNull { adrenoModel in it.first }?.second ?: "Unsupported" + } + + fun getInstalledDriversSync(): List { + val drivers = mutableListOf() + drivers.add(GpuDriver("system", "System Default", "Use the built-in system GPU driver", isSystem = true, driverPath = "", vulkanLibName = "")) + + driversDir.listFiles()?.filter { it.isDirectory }?.forEach { dir -> + val metaFile = File(dir, "meta.json") + if (metaFile.exists()) { + try { + val metaContent = metaFile.readText() + val metaJson = json.parseToJsonElement(metaContent).jsonObject + var expectedLibName = metaJson["vulkanLibName"]?.jsonPrimitive?.content ?: "libvulkan_freedreno.so" + var actualDriverDir = dir.absolutePath + var foundLibName = expectedLibName + val soFiles = dir.walkTopDown().filter { it.isFile && it.name.endsWith(".so") }.toList() + if (soFiles.isNotEmpty()) { + val match = soFiles.find { it.name == expectedLibName } + ?: soFiles.find { it.name.contains("vulkan", ignoreCase = true) } + ?: soFiles.first() + + actualDriverDir = match.parentFile?.absolutePath ?: dir.absolutePath + foundLibName = match.name + + // Ensure executable and read-only permissions for all found .so files to fix existing installations + soFiles.forEach { + it.setExecutable(true, false) + it.setReadOnly() + } + } + + drivers.add(GpuDriver( + id = dir.name, + name = metaJson["name"]?.jsonPrimitive?.content ?: dir.name, + description = metaJson["description"]?.jsonPrimitive?.content ?: "", + author = metaJson["author"]?.jsonPrimitive?.content ?: "", + version = metaJson["driverVersion"]?.jsonPrimitive?.content ?: "", + vendor = metaJson["vendor"]?.jsonPrimitive?.content ?: "", + driverPath = actualDriverDir, + vulkanLibName = foundLibName + )) + } catch (e: Exception) { + Log.e("GpuDriverManager", "Failed to parse meta.json in ${dir.name}", e) + } + } + } + return drivers + } + + suspend fun getInstalledDrivers(): List = withContext(Dispatchers.IO) { + getInstalledDriversSync() + } + + suspend fun fetchRemoteDriverGroups(): List = withContext(Dispatchers.IO) { + repoList.map { repo -> + async { + try { + val request = Request.Builder() + .url("https://api.github.com/repos/${repo.path}/releases") + .header("Accept", "application/vnd.github.v3+json") + .header("User-Agent", "MpvRx-GpuFetcher") + .build() + + val response = okHttpClient.newCall(request).execute() + if (!response.isSuccessful) throw Exception("HTTP ${response.code}") + + val content = response.body.string() + val releasesJson = json.parseToJsonElement(content).jsonArray + + val remoteReleases = releasesJson.mapIndexed { index, release -> + val releaseObj = release.jsonObject + val tagName = releaseObj["tag_name"]?.jsonPrimitive?.content ?: "" + val titleName = releaseObj["name"]?.jsonPrimitive?.content ?: "" + val title = if (repo.useTagName) tagName else titleName + val prerelease = releaseObj["prerelease"]?.jsonPrimitive?.boolean ?: false + + val assets = releaseObj["assets"]?.jsonArray + val remoteDrivers = assets?.mapNotNull { asset -> + val assetObj = asset.jsonObject + val assetName = assetObj["name"]?.jsonPrimitive?.content ?: "" + if (assetName.endsWith(".zip")) { + RemoteGpuDriver( + name = assetName, + downloadUrl = assetObj["browser_download_url"]?.jsonPrimitive?.content ?: "" + ) + } else null + } ?: emptyList() + + RemoteRelease( + title = title, + version = tagName, + isLatest = index == 0 && !prerelease, + drivers = remoteDrivers + ) + }.filter { it.drivers.isNotEmpty() } + + RemoteDriverGroup(repo.name, remoteReleases, repo.sort) + } catch (e: Exception) { + Log.e("GpuDriverManager", "Failed to fetch from ${repo.name}: ${e.message}") + RemoteDriverGroup(repo.name, emptyList(), repo.sort) + } + } + }.awaitAll().sortedBy { it.sort } + } + + suspend fun installDriver(zipUri: Uri): Result = withContext(Dispatchers.IO) { + try { + val tempId = UUID.randomUUID().toString() + val targetDir = File(driversDir, tempId) + targetDir.mkdirs() + + context.contentResolver.openInputStream(zipUri)?.use { inputStream -> + ZipInputStream(inputStream).use { zipStream -> + var entry = zipStream.nextEntry + while (entry != null) { + val file = File(targetDir, entry.name) + if (entry.isDirectory) { + file.mkdirs() + } else { + file.parentFile?.mkdirs() + FileOutputStream(file).use { output -> + zipStream.copyTo(output) + } + if (file.name.endsWith(".so")) { + file.setExecutable(true, false) + } + } + zipStream.closeEntry() + entry = zipStream.nextEntry + } + } + } + + val metaFile = File(targetDir, "meta.json") + if (!metaFile.exists()) { + targetDir.deleteRecursively() + return@withContext Result.failure(Exception("Missing meta.json in driver package")) + } + + val metaContent = metaFile.readText() + val metaJson = json.parseToJsonElement(metaContent).jsonObject + var expectedLibName = metaJson["vulkanLibName"]?.jsonPrimitive?.content ?: "libvulkan_freedreno.so" + + // Search for the actual driver file in case it's in a subdirectory or has a different name + var actualDriverDir = targetDir.absolutePath + var foundLibName = expectedLibName + val soFiles = targetDir.walkTopDown().filter { it.isFile && it.name.endsWith(".so") }.toList() + if (soFiles.isNotEmpty()) { + val match = soFiles.find { it.name == expectedLibName } + ?: soFiles.find { it.name.contains("vulkan", ignoreCase = true) } + ?: soFiles.first() + + actualDriverDir = match.parentFile?.absolutePath ?: targetDir.absolutePath + foundLibName = match.name + + // Ensure executable and read-only permissions for all found .so files + soFiles.forEach { + it.setExecutable(true, false) + it.setReadOnly() + } + } + + val driver = GpuDriver( + id = tempId, + name = metaJson["name"]?.jsonPrimitive?.content ?: tempId, + description = metaJson["description"]?.jsonPrimitive?.content ?: "", + author = metaJson["author"]?.jsonPrimitive?.content ?: "", + version = metaJson["driverVersion"]?.jsonPrimitive?.content ?: "", + vendor = metaJson["vendor"]?.jsonPrimitive?.content ?: "", + driverPath = actualDriverDir, + vulkanLibName = foundLibName + ) + + // Backup the zip to the SAF folder if possible + try { + val safFolder = getSafGpuDriverFolder() + if (safFolder != null) { + val zipFileName = driver.name.replace(Regex("[^a-zA-Z0-9.-]"), "_") + ".zip" + var destFile = safFolder.findFile(zipFileName) + if (destFile == null) { + destFile = safFolder.createFile("application/zip", zipFileName) + } + if (destFile != null) { + context.contentResolver.openInputStream(zipUri)?.use { input -> + context.contentResolver.openOutputStream(destFile.uri)?.use { output -> + input.copyTo(output) + } + } + } + } + } catch (e: Exception) { + Log.e("GpuDriverManager", "Failed to backup zip to SAF", e) + } + + Result.success(driver) + } catch (e: Exception) { + Log.e("GpuDriverManager", "Failed to install driver", e) + Result.failure(e) + } + } + + suspend fun downloadAndInstallDriver(remoteDriver: RemoteGpuDriver, onProgress: (Float) -> Unit): Result = withContext(Dispatchers.IO) { + try { + val tempFile = File(context.cacheDir, "driver_download.zip") + val request = Request.Builder() + .url(remoteDriver.downloadUrl) + .header("User-Agent", "MpvRx-GpuFetcher") + .build() + + val response = okHttpClient.newCall(request).execute() + if (!response.isSuccessful) throw Exception("HTTP ${response.code}") + + val body = response.body + val totalSize = body.contentLength() + + body.byteStream().use { input -> + FileOutputStream(tempFile).use { output -> + val buffer = ByteArray(8192) + var bytesRead: Int + var totalRead = 0L + while (input.read(buffer).also { bytesRead = it } != -1) { + output.write(buffer, 0, bytesRead) + totalRead += bytesRead + if (totalSize > 0) { + onProgress(totalRead.toFloat() / totalSize) + } + } + } + } + + val result = installDriver(Uri.fromFile(tempFile)) + tempFile.delete() + result + } catch (e: Exception) { + Log.e("GpuDriverManager", "Failed to download and install driver", e) + Result.failure(e) + } + } + + fun deleteDriver(id: String) { + val dir = File(driversDir, id) + if (dir.exists()) { + dir.deleteRecursively() + } + } + + suspend fun getSafDrivers(): List = withContext(Dispatchers.IO) { + val drivers = mutableListOf() + val safFolder = getSafGpuDriverFolder() ?: return@withContext drivers + + safFolder.listFiles().filter { it.name?.endsWith(".zip", ignoreCase = true) == true }.forEach { zipFile -> + try { + context.contentResolver.openInputStream(zipFile.uri)?.use { inputStream -> + java.util.zip.ZipInputStream(inputStream).use { zipStream -> + var entry = zipStream.nextEntry + while (entry != null) { + if (!entry.isDirectory && entry.name.lowercase().endsWith("meta.json")) { + val metaContent = String(zipStream.readBytes()) + val metaJson = json.parseToJsonElement(metaContent).jsonObject + drivers.add(SafGpuDriver( + uri = zipFile.uri, + name = metaJson["name"]?.jsonPrimitive?.content ?: zipFile.name ?: "Unknown", + description = metaJson["description"]?.jsonPrimitive?.content ?: "", + author = metaJson["author"]?.jsonPrimitive?.content ?: "", + version = metaJson["driverVersion"]?.jsonPrimitive?.content ?: "", + vendor = metaJson["vendor"]?.jsonPrimitive?.content ?: "", + fileSize = zipFile.length() + )) + break + } + zipStream.closeEntry() + entry = zipStream.nextEntry + } + } + } + } catch (e: Exception) { + Log.e("GpuDriverManager", "Failed to parse SAF zip: ${zipFile.name}", e) + } + } + drivers + } + + data class SafGpuDriver( + val uri: Uri, + val name: String, + val description: String, + val author: String, + val version: String, + val vendor: String, + val fileSize: Long + ) + + data class RemoteGpuDriver(val name: String, val downloadUrl: String) + data class RemoteRelease(val title: String, val version: String, val isLatest: Boolean, val drivers: List) + data class RemoteDriverGroup(val name: String, val releases: List, val sort: Int) + private data class DriverRepo(val name: String, val path: String, val sort: Int, val useTagName: Boolean = false) +} diff --git a/app/src/main/java/app/gyrolet/mpvrx/domain/network/NetworkFile.kt b/app/src/main/java/app/gyrolet/mpvrx/domain/network/NetworkFile.kt index 5924351ea..bb701a5c5 100644 --- a/app/src/main/java/app/gyrolet/mpvrx/domain/network/NetworkFile.kt +++ b/app/src/main/java/app/gyrolet/mpvrx/domain/network/NetworkFile.kt @@ -1,8 +1,11 @@ package app.gyrolet.mpvrx.domain.network +import androidx.compose.runtime.Immutable + /** * Represents a file or directory on a network share */ +@Immutable data class NetworkFile( val name: String, val path: String, diff --git a/app/src/main/java/app/gyrolet/mpvrx/preferences/AppearancePreferences.kt b/app/src/main/java/app/gyrolet/mpvrx/preferences/AppearancePreferences.kt index 683ff9fda..4e63c6eb3 100644 --- a/app/src/main/java/app/gyrolet/mpvrx/preferences/AppearancePreferences.kt +++ b/app/src/main/java/app/gyrolet/mpvrx/preferences/AppearancePreferences.kt @@ -35,6 +35,27 @@ class AppearancePreferences( val showPlaylistsTab = preferenceStore.getBoolean("show_playlists_tab", true) val showNetworkTab = preferenceStore.getBoolean("show_network_tab", false) + // Liquid Glass Effects + val enableLiquidGlass = preferenceStore.getBoolean("enable_liquid_glass", false) + val liquidToggleColor = preferenceStore.getInt("liquid_toggle_color", 0xFF000080.toInt()) + val liquidSeekbarColor = preferenceStore.getInt("liquid_seekbar_color", 0xFFFF4500.toInt()) + + // Liquid Dialog Parameters + val liquidDialogBlur = preferenceStore.getFloat("liquid_dialog_blur", 32f) + val liquidDialogSaturation = preferenceStore.getFloat("liquid_dialog_saturation", 1.3f) + val liquidDialogBrightness = preferenceStore.getFloat("liquid_dialog_brightness", 0.08f) + val liquidDialogLensRadius = preferenceStore.getFloat("liquid_dialog_lens_radius", 55f) + val liquidDialogLensDepth = preferenceStore.getFloat("liquid_dialog_lens_depth", 85f) + val liquidDialogContainerAlpha = preferenceStore.getFloat("liquid_dialog_container_alpha", 0.35f) + val liquidDialogDarkText = preferenceStore.getBoolean("liquid_dialog_dark_text", false) + + // Liquid Button Parameters + val liquidButtonBlur = preferenceStore.getFloat("liquid_button_blur", 26f) + val liquidButtonLensRadius = preferenceStore.getFloat("liquid_button_lens_radius", 42f) + val liquidButtonLensDepth = preferenceStore.getFloat("liquid_button_lens_depth", 72f) + val liquidButtonOpacity = preferenceStore.getFloat("liquid_button_opacity", 0.15f) + val liquidButtonTint = preferenceStore.getInt("liquid_button_tint", 0x26FFFFFF) + val topLeftControls = preferenceStore.getString( "top_left_controls", @@ -44,13 +65,13 @@ class AppearancePreferences( val topRightControls = preferenceStore.getString( "top_right_controls", - "CURRENT_CHAPTER,DECODER,AUDIO_TRACK,SUBTITLES,MORE_OPTIONS", + "CURRENT_CHAPTER,DECODER,AUDIO_TRACK,SUBTITLES,TIME_NETWORK,VIDEO_FILTERS,MORE_OPTIONS", ) val bottomRightControls = preferenceStore.getString( "bottom_right_controls", - "FRAME_NAVIGATION,VIDEO_ZOOM,PICTURE_IN_PICTURE,ASPECT_RATIO", + "FRAME_NAVIGATION,VIDEO_ZOOM,PICTURE_IN_PICTURE,ASPECT_RATIO,AMBIENT_MODE", ) val bottomLeftControls = @@ -62,7 +83,7 @@ class AppearancePreferences( val portraitBottomControls = preferenceStore.getString( "portrait_bottom_controls", - "SCREEN_ROTATION,DECODER,AUDIO_TRACK,SUBTITLES,BOOKMARKS_CHAPTERS,PLAYBACK_SPEED,BACKGROUND_PLAYBACK,REPEAT_MODE,SHUFFLE,VIDEO_ZOOM,FRAME_NAVIGATION,ASPECT_RATIO,PICTURE_IN_PICTURE,LOCK_CONTROLS,MORE_OPTIONS", + "SCREEN_ROTATION,DECODER,AUDIO_TRACK,SUBTITLES,BOOKMARKS_CHAPTERS,PLAYBACK_SPEED,BACKGROUND_PLAYBACK,REPEAT_MODE,SHUFFLE,VIDEO_ZOOM,FRAME_NAVIGATION,ASPECT_RATIO,PICTURE_IN_PICTURE,LOCK_CONTROLS,TIME_NETWORK,AMBIENT_MODE,VIDEO_FILTERS,MORE_OPTIONS", ) fun parseButtons( diff --git a/app/src/main/java/app/gyrolet/mpvrx/preferences/GpuDriverPreferences.kt b/app/src/main/java/app/gyrolet/mpvrx/preferences/GpuDriverPreferences.kt new file mode 100644 index 000000000..9db31ce06 --- /dev/null +++ b/app/src/main/java/app/gyrolet/mpvrx/preferences/GpuDriverPreferences.kt @@ -0,0 +1,11 @@ +package app.gyrolet.mpvrx.preferences + +import app.gyrolet.mpvrx.preferences.preference.PreferenceStore + +class GpuDriverPreferences( + preferenceStore: PreferenceStore +) { + val activeDriverId = preferenceStore.getString("gpu_driver_active_id", "system") + val showDriverHud = preferenceStore.getBoolean("gpu_driver_show_hud", false) + val hasAcceptedGpuWarning = preferenceStore.getBoolean("gpu_driver_warning_accepted", false) +} diff --git a/app/src/main/java/app/gyrolet/mpvrx/preferences/PlayerButton.kt b/app/src/main/java/app/gyrolet/mpvrx/preferences/PlayerButton.kt index 6b6865d1d..6b935dd1a 100644 --- a/app/src/main/java/app/gyrolet/mpvrx/preferences/PlayerButton.kt +++ b/app/src/main/java/app/gyrolet/mpvrx/preferences/PlayerButton.kt @@ -36,6 +36,7 @@ enum class PlayerButton( BACKGROUND_PLAYBACK(Icons.Outlined.Headset), AMBIENT_MODE(Icons.Outlined.BlurOff), TIME_NETWORK(Icons.Default.AccessTime), + VIDEO_FILTERS(Icons.Default.Tune), NONE(Icons.Outlined.Bookmarks), } @@ -82,5 +83,6 @@ fun getPlayerButtonLabel(button: PlayerButton): String = PlayerButton.BACKGROUND_PLAYBACK -> "Background Playback" PlayerButton.AMBIENT_MODE -> "Ambience Mode" PlayerButton.TIME_NETWORK -> "Time + Network" + PlayerButton.VIDEO_FILTERS -> "Video Filters" PlayerButton.NONE -> "None" } diff --git a/app/src/main/java/app/gyrolet/mpvrx/preferences/PlayerPreferences.kt b/app/src/main/java/app/gyrolet/mpvrx/preferences/PlayerPreferences.kt index e0c880402..4186e2363 100644 --- a/app/src/main/java/app/gyrolet/mpvrx/preferences/PlayerPreferences.kt +++ b/app/src/main/java/app/gyrolet/mpvrx/preferences/PlayerPreferences.kt @@ -93,6 +93,8 @@ class PlayerPreferences( val keepScreenOnWhenPaused = preferenceStore.getBoolean("keep_screen_on_when_paused", false) val autoplayAfterScreenUnlock = preferenceStore.getBoolean("autoplay_after_screen_unlock", false) + val enableExoPlayer = preferenceStore.getBoolean("enable_exoplayer", false) + // Custom Buttons - JSON List val customButtons = preferenceStore.getString("custom_buttons_json", "[]") diff --git a/app/src/main/java/app/gyrolet/mpvrx/preferences/SeekbarStyle.kt b/app/src/main/java/app/gyrolet/mpvrx/preferences/SeekbarStyle.kt index f7829e4e9..60320bf0b 100644 --- a/app/src/main/java/app/gyrolet/mpvrx/preferences/SeekbarStyle.kt +++ b/app/src/main/java/app/gyrolet/mpvrx/preferences/SeekbarStyle.kt @@ -5,5 +5,5 @@ enum class SeekbarStyle { Wavy, Thick, Slim, + Liquid, } - diff --git a/app/src/main/java/app/gyrolet/mpvrx/presentation/components/LiquidDialog.kt b/app/src/main/java/app/gyrolet/mpvrx/presentation/components/LiquidDialog.kt new file mode 100644 index 000000000..41a234200 --- /dev/null +++ b/app/src/main/java/app/gyrolet/mpvrx/presentation/components/LiquidDialog.kt @@ -0,0 +1,164 @@ +package app.gyrolet.mpvrx.presentation.components + +import androidx.compose.foundation.layout.Arrangement +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.material3.AlertDialogDefaults +import androidx.compose.material3.BasicAlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.ProvideTextStyle +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp +import app.gyrolet.mpvrx.preferences.AppearancePreferences +import app.gyrolet.mpvrx.preferences.preference.collectAsState +import app.gyrolet.mpvrx.ui.player.controls.components.PlayerLiquidTokens +import com.kyant.backdrop.backdrops.rememberLayerBackdrop +import com.kyant.backdrop.drawBackdrop +import com.kyant.backdrop.effects.blur +import com.kyant.backdrop.effects.colorControls +import com.kyant.backdrop.effects.lens +import com.kyant.backdrop.highlight.Highlight +import org.koin.compose.koinInject + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LiquidDialog( + onDismissRequest: () -> Unit, + modifier: Modifier = Modifier, + shape: Shape = MaterialTheme.shapes.extraLarge, + containerColor: Color = AlertDialogDefaults.containerColor, + title: (@Composable () -> Unit)? = null, + text: (@Composable () -> Unit)? = null, + confirmButton: (@Composable () -> Unit)? = null, + dismissButton: (@Composable () -> Unit)? = null, + content: (@Composable () -> Unit)? = null +) { + val preferences = koinInject() + val enableLiquidGlass by preferences.enableLiquidGlass.collectAsState() + val liquidBlur by preferences.liquidDialogBlur.collectAsState() + val liquidSaturation by preferences.liquidDialogSaturation.collectAsState() + val liquidBrightness by preferences.liquidDialogBrightness.collectAsState() + val liquidLensRadius by preferences.liquidDialogLensRadius.collectAsState() + val liquidLensDepth by preferences.liquidDialogLensDepth.collectAsState() + val liquidAlpha by preferences.liquidDialogContainerAlpha.collectAsState() + val liquidDarkText by preferences.liquidDialogDarkText.collectAsState() + val density = LocalDensity.current + + BasicAlertDialog( + onDismissRequest = onDismissRequest, + modifier = modifier + ) { + Surface( + modifier = Modifier + .then( + if (enableLiquidGlass) { + Modifier.drawBackdrop( + backdrop = rememberLayerBackdrop(), + shape = { shape }, + effects = { + colorControls( + brightness = liquidBrightness, + saturation = liquidSaturation + ) + blur(with(density) { liquidBlur.dp.toPx() }) + lens( + with(density) { liquidLensRadius.dp.toPx() }, + with(density) { liquidLensDepth.dp.toPx() }, + depthEffect = true + ) + }, + highlight = { Highlight.Plain }, + onDrawSurface = { + drawRect(containerColor.copy(alpha = liquidAlpha)) + } + ) + } else { + Modifier + } + ), + shape = shape, + color = if (enableLiquidGlass) Color.Transparent else containerColor, + tonalElevation = if (enableLiquidGlass) 0.dp else AlertDialogDefaults.TonalElevation + ) { + val dialogContentColor = if (enableLiquidGlass) { + if (liquidDarkText) Color.Black else PlayerLiquidTokens.contentColor + } else { + AlertDialogDefaults.textContentColor + } + if (content != null) { + CompositionLocalProvider(LocalContentColor provides dialogContentColor) { + content() + } + } else { + CompositionLocalProvider(LocalContentColor provides dialogContentColor) { + Column( + modifier = Modifier.padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + if (title != null) { + CompositionLocalProvider( + LocalContentColor provides if (enableLiquidGlass) { + if (liquidDarkText) Color.Black else PlayerLiquidTokens.contentColor + } else { + AlertDialogDefaults.titleContentColor + } + ) { + ProvideTextStyle(MaterialTheme.typography.headlineSmall) { + Box(Modifier.fillMaxWidth()) { + title() + } + } + } + } + + if (text != null) { + CompositionLocalProvider( + LocalContentColor provides dialogContentColor + ) { + ProvideTextStyle(MaterialTheme.typography.bodyMedium) { + Box(Modifier.fillMaxWidth()) { + text() + } + } + } + } + + if (confirmButton != null || dismissButton != null) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + if (dismissButton != null) { + dismissButton() + } + if (confirmButton != null) { + confirmButton() + } + } + } + } + } + } + } + } +} + +@Composable +private fun Box(modifier: Modifier = Modifier, content: @Composable () -> Unit) { + androidx.compose.foundation.layout.Box(modifier) { + content() + } +} diff --git a/app/src/main/java/app/gyrolet/mpvrx/presentation/components/PlayerSheet.kt b/app/src/main/java/app/gyrolet/mpvrx/presentation/components/PlayerSheet.kt index ccaf2e661..ab69947ef 100644 --- a/app/src/main/java/app/gyrolet/mpvrx/presentation/components/PlayerSheet.kt +++ b/app/src/main/java/app/gyrolet/mpvrx/presentation/components/PlayerSheet.kt @@ -30,8 +30,10 @@ import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.shape.ZeroCornerSize import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.Surface import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf @@ -42,6 +44,7 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.nestedscroll.NestedScrollConnection @@ -54,10 +57,19 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.dp +import app.gyrolet.mpvrx.ui.player.controls.components.PlayerLiquidTokens +import app.gyrolet.mpvrx.preferences.AppearancePreferences +import app.gyrolet.mpvrx.preferences.preference.collectAsState +import com.kyant.backdrop.drawBackdrop +import com.kyant.backdrop.effects.blur +import com.kyant.backdrop.effects.colorControls +import com.kyant.backdrop.effects.lens +import com.kyant.backdrop.highlight.Highlight import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.filter import kotlinx.coroutines.launch +import org.koin.compose.koinInject import kotlin.math.roundToInt private val sheetAnimationSpec = tween(350) @@ -76,6 +88,20 @@ fun PlayerSheet( val scope = rememberCoroutineScope() val density = LocalDensity.current val latestOnDismissRequest by rememberUpdatedState(onDismissRequest) + + val preferences = koinInject() + val enableLiquidGlass by preferences.enableLiquidGlass.collectAsState() + val liquidBlur by preferences.liquidDialogBlur.collectAsState() + val liquidSaturation by preferences.liquidDialogSaturation.collectAsState() + val liquidBrightness by preferences.liquidDialogBrightness.collectAsState() + val liquidLensRadius by preferences.liquidDialogLensRadius.collectAsState() + val liquidLensDepth by preferences.liquidDialogLensDepth.collectAsState() + val liquidAlpha by preferences.liquidDialogContainerAlpha.collectAsState() + val liquidDarkText by preferences.liquidDialogDarkText.collectAsState() + + val sheetShape = MaterialTheme.shapes.extraLarge.copy(bottomEnd = ZeroCornerSize, bottomStart = ZeroCornerSize) + val themeSurfaceColor = MaterialTheme.colorScheme.surface + val maxWidth = customMaxWidth ?: if (LocalConfiguration.current.orientation == ORIENTATION_LANDSCAPE) { 640.dp @@ -147,7 +173,36 @@ fun PlayerSheet( remember(anchoredDraggableState) { anchoredDraggableState.preUpPostDownNestedScrollConnection() }, - ).then(modifier) + ) + .then( + if (enableLiquidGlass) { + Modifier.drawBackdrop( + backdrop = com.kyant.backdrop.backdrops.rememberLayerBackdrop(), + shape = { sheetShape }, + effects = { + colorControls( + brightness = liquidBrightness, + saturation = liquidSaturation + ) + blur(with(density) { liquidBlur.dp.toPx() }) + lens( + with(density) { liquidLensRadius.dp.toPx() }, + with(density) { liquidLensDepth.dp.toPx() }, + depthEffect = true + ) + }, + highlight = { Highlight.Plain }, + onDrawSurface = { + drawRect( + surfaceColor?.copy(alpha = liquidAlpha) ?: themeSurfaceColor.copy(alpha = liquidAlpha) + ) + } + ) + } else { + Modifier + } + ) + .then(modifier) .offset { IntOffset( 0, @@ -163,15 +218,23 @@ fun PlayerSheet( WindowInsets.systemBars .only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal), ).imePadding(), - shape = MaterialTheme.shapes.extraLarge.copy(bottomEnd = ZeroCornerSize, bottomStart = ZeroCornerSize), - color = surfaceColor ?: MaterialTheme.colorScheme.surface, - tonalElevation = tonalElevation, + shape = sheetShape, + color = if (enableLiquidGlass) Color.Transparent else (surfaceColor ?: themeSurfaceColor), + tonalElevation = if (enableLiquidGlass) 0.dp else tonalElevation, content = { BackHandler( enabled = anchoredDraggableState.targetValue == 0, onBack = internalOnDismissRequest, ) - content() + CompositionLocalProvider( + LocalContentColor provides if (enableLiquidGlass) { + if (liquidDarkText) Color.Black else PlayerLiquidTokens.contentColor + } else { + MaterialTheme.colorScheme.onSurface + }, + ) { + content() + } }, ) diff --git a/app/src/main/java/app/gyrolet/mpvrx/presentation/components/RepeatingIconButton.kt b/app/src/main/java/app/gyrolet/mpvrx/presentation/components/RepeatingIconButton.kt index 4eb2e138d..28a2286a6 100644 --- a/app/src/main/java/app/gyrolet/mpvrx/presentation/components/RepeatingIconButton.kt +++ b/app/src/main/java/app/gyrolet/mpvrx/presentation/components/RepeatingIconButton.kt @@ -13,7 +13,14 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.input.pointer.pointerInteropFilter +import androidx.compose.ui.unit.dp +import app.gyrolet.mpvrx.preferences.AppearancePreferences +import app.gyrolet.mpvrx.preferences.preference.collectAsState +import app.gyrolet.mpvrx.ui.components.LiquidButton + +import com.kyant.backdrop.backdrops.rememberLayerBackdrop import kotlinx.coroutines.delay +import org.koin.compose.koinInject @OptIn(ExperimentalComposeUiApi::class) @Composable @@ -30,34 +37,70 @@ fun RepeatingIconButton( val currentClickListener by rememberUpdatedState(onClick) var pressed by remember { mutableStateOf(false) } - FilledTonalIconButton( - modifier = - modifier.pointerInteropFilter { + val preferences = koinInject() + val enableLiquidGlass by preferences.enableLiquidGlass.collectAsState() + + if (enableLiquidGlass) { + val backdrop = rememberLayerBackdrop() + LiquidButton( + onClick = currentClickListener, + backdrop = backdrop, + modifier = modifier.pointerInteropFilter { pressed = when (it.action) { MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> true MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> false else -> pressed } - true }, - onClick = {}, - enabled = enabled, - interactionSource = interactionSource, - content = content, - ) + isInteractive = true, + height = 40.dp, + horizontalPadding = 0.dp, + content = { content() }, + ) + + LaunchedEffect(pressed, enabled) { + var currentDelayMillis = maxDelayMillis + while (enabled && pressed) { + currentClickListener() + delay(currentDelayMillis) + currentDelayMillis = + (currentDelayMillis - (currentDelayMillis * delayDecayFactor)) + .toLong() + .coerceAtLeast(minDelayMillis) + } + } + } else { + FilledTonalIconButton( + modifier = + modifier.pointerInteropFilter { + pressed = + when (it.action) { + MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> true + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> false + else -> pressed + } + + true + }, + onClick = {}, + enabled = enabled, + interactionSource = interactionSource, + content = content, + ) - LaunchedEffect(pressed, enabled) { - var currentDelayMillis = maxDelayMillis + LaunchedEffect(pressed, enabled) { + var currentDelayMillis = maxDelayMillis - while (enabled && pressed) { - currentClickListener() - delay(currentDelayMillis) - currentDelayMillis = - (currentDelayMillis - (currentDelayMillis * delayDecayFactor)) - .toLong() - .coerceAtLeast(minDelayMillis) + while (enabled && pressed) { + currentClickListener() + delay(currentDelayMillis) + currentDelayMillis = + (currentDelayMillis - (currentDelayMillis * delayDecayFactor)) + .toLong() + .coerceAtLeast(minDelayMillis) + } } } } diff --git a/app/src/main/java/app/gyrolet/mpvrx/presentation/crash/CrashActivity.kt b/app/src/main/java/app/gyrolet/mpvrx/presentation/crash/CrashActivity.kt index 1c5be52fd..cbc358a40 100644 --- a/app/src/main/java/app/gyrolet/mpvrx/presentation/crash/CrashActivity.kt +++ b/app/src/main/java/app/gyrolet/mpvrx/presentation/crash/CrashActivity.kt @@ -32,7 +32,6 @@ import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.FilledIconButton -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationBarDefaults import androidx.compose.material3.OutlinedButton @@ -70,6 +69,7 @@ import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.koin.android.ext.android.inject +import org.koin.core.context.GlobalContext import java.io.BufferedReader import java.io.File import java.io.InputStreamReader @@ -81,21 +81,36 @@ class CrashActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + val exception = intent.getStringExtra("exception") ?: "No exception provided" + android.util.Log.e("CrashActivity", "CRASH DETECTED: $exception") lifecycle.coroutineScope.launch { logcat = collectLogcat() } setContent { - val dark by appearancePreferences.darkMode.collectAsState() - val isSystemInDarkTheme = isSystemInDarkTheme() - val isDarkMode = dark == DarkMode.Dark || (dark == DarkMode.System && isSystemInDarkTheme) - enableEdgeToEdge( - SystemBarStyle.auto( - lightScrim = Color.White.toArgb(), - darkScrim = Color.Transparent.toArgb(), - ) { isDarkMode }, - ) - MpvrxTheme { - CrashScreen(intent.getStringExtra("exception") ?: "") + if (GlobalContext.getOrNull() != null) { + val dark by appearancePreferences.darkMode.collectAsState() + val isSystemInDarkTheme = isSystemInDarkTheme() + val isDarkMode = dark == DarkMode.Dark || (dark == DarkMode.System && isSystemInDarkTheme) + enableEdgeToEdge( + SystemBarStyle.auto( + lightScrim = Color.White.toArgb(), + darkScrim = Color.Transparent.toArgb(), + ) { isDarkMode }, + ) + MpvrxTheme { + CrashScreen(intent.getStringExtra("exception") ?: "") + } + } else { + val isDarkMode = isSystemInDarkTheme() + enableEdgeToEdge( + SystemBarStyle.auto( + lightScrim = Color.White.toArgb(), + darkScrim = Color.Transparent.toArgb(), + ) { isDarkMode }, + ) + MaterialTheme { + CrashScreen(intent.getStringExtra("exception") ?: "") + } } } } diff --git a/app/src/main/java/app/gyrolet/mpvrx/presentation/crash/GlobalExceptionHandler.kt b/app/src/main/java/app/gyrolet/mpvrx/presentation/crash/GlobalExceptionHandler.kt index d23e076b2..c79d583c3 100644 --- a/app/src/main/java/app/gyrolet/mpvrx/presentation/crash/GlobalExceptionHandler.kt +++ b/app/src/main/java/app/gyrolet/mpvrx/presentation/crash/GlobalExceptionHandler.kt @@ -15,7 +15,9 @@ class GlobalExceptionHandler( val intent = Intent(context, activity) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK) - intent.putExtra("exception", e.stackTraceToString()) + val stackTrace = e.stackTraceToString() + android.util.Log.e("GlobalExceptionHandler", "CRASH DETECTED: $stackTrace") + intent.putExtra("exception", stackTrace) context.startActivity(intent) exitProcess(0) } diff --git a/app/src/main/java/app/gyrolet/mpvrx/ui/browser/MainScreen.kt b/app/src/main/java/app/gyrolet/mpvrx/ui/browser/MainScreen.kt index 03fd9e0f9..66fbadbd7 100644 --- a/app/src/main/java/app/gyrolet/mpvrx/ui/browser/MainScreen.kt +++ b/app/src/main/java/app/gyrolet/mpvrx/ui/browser/MainScreen.kt @@ -25,10 +25,15 @@ import app.gyrolet.mpvrx.preferences.PlayerPreferences import app.gyrolet.mpvrx.preferences.preference.collectAsState import app.gyrolet.mpvrx.ui.player.NavigationAnimStyle import org.koin.compose.koinInject +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.Alignment import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.Scaffold @@ -43,6 +48,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp import app.gyrolet.mpvrx.presentation.Screen @@ -50,6 +56,23 @@ import app.gyrolet.mpvrx.ui.browser.folderlist.FolderListScreen import app.gyrolet.mpvrx.ui.browser.networkstreaming.NetworkStreamingScreen import app.gyrolet.mpvrx.ui.browser.playlist.PlaylistScreen import app.gyrolet.mpvrx.ui.browser.recentlyplayed.RecentlyPlayedScreen +import app.gyrolet.mpvrx.ui.components.LocalLiquidBottomTabScale +import app.gyrolet.mpvrx.ui.components.LocalScreenBackdrop +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.colorControls +import com.kyant.backdrop.effects.lens +import com.kyant.backdrop.highlight.Highlight +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.navigationBarsPadding import kotlinx.serialization.Serializable @@ -117,6 +140,15 @@ object MainScreen : Screen { val showNetworkTab by appearancePreferences.showNetworkTab.collectAsState() val hideNavigationBar = NavigationBarState.shouldHideNavigationBar val isPermissionDenied = NavigationBarState.isPermissionDenied + + val enableLiquidGlass by appearancePreferences.enableLiquidGlass.collectAsState() + val liquidBlur by appearancePreferences.liquidDialogBlur.collectAsState() + val liquidSaturation by appearancePreferences.liquidDialogSaturation.collectAsState() + val liquidBrightness by appearancePreferences.liquidDialogBrightness.collectAsState() + val liquidLensRadius by appearancePreferences.liquidDialogLensRadius.collectAsState() + val liquidLensDepth by appearancePreferences.liquidDialogLensDepth.collectAsState() + val liquidAlpha by appearancePreferences.liquidDialogContainerAlpha.collectAsState() + val surfaceColor = MaterialTheme.colorScheme.surfaceContainer val visibleTabs = remember( showHomeTab, @@ -145,6 +177,9 @@ object MainScreen : Screen { } } + // Backdrop for Liquid Glass effect + val screenBackdrop = rememberLayerBackdrop() + // Scaffold with bottom navigation bar Scaffold( modifier = Modifier.fillMaxSize(), @@ -167,41 +202,120 @@ object MainScreen : Screen { targetOffsetY = { fullHeight -> fullHeight } ) ) { - NavigationBar( - modifier = Modifier - .clip(AppShapeScale.extraLargeIncreased) - ) { - visibleTabs.forEach { tab -> - NavigationBarItem( - icon = { - when (tab) { - MainTab.HOME -> Icon(Icons.Filled.Home, contentDescription = "Home") - MainTab.RECENTS -> Icon(Icons.Filled.History, contentDescription = "Recents") - MainTab.PLAYLISTS -> Icon(Icons.Filled.PlaylistPlay, contentDescription = "Playlists") - MainTab.NETWORK -> Icon(Icons.Filled.BringYourOwnIp, contentDescription = "Network") + if (enableLiquidGlass) { + app.gyrolet.mpvrx.ui.components.LiquidBottomTabs( + selectedTabIndex = { visibleTabs.indexOf(selectedTab).coerceAtLeast(0) }, + onTabSelected = { index -> + if (index in visibleTabs.indices) { + selectedTab = visibleTabs[index] + } + }, + backdrop = screenBackdrop, + tabsCount = visibleTabs.size, + modifier = Modifier + .navigationBarsPadding() + .padding(start = 12.dp, end = 12.dp, top = 4.dp, bottom = 12.dp) + ) { + visibleTabs.forEach { tab -> + val isSelected = selectedTab == tab + val tabScale = LocalLiquidBottomTabScale.current + val interactionSource = remember { MutableInteractionSource() } + + Box( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .clickable( + interactionSource = interactionSource, + indication = null + ) { + selectedTab = tab + }, + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier.graphicsLayer { + val scale = tabScale() + scaleX = scale + scaleY = scale + } + ) { + val iconTint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f) + val textTint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f) + + when (tab) { + MainTab.HOME -> Icon(Icons.Filled.Home, contentDescription = "Home", tint = iconTint, modifier = Modifier.size(22.dp)) + MainTab.RECENTS -> Icon(Icons.Filled.History, contentDescription = "Recents", tint = iconTint, modifier = Modifier.size(22.dp)) + MainTab.PLAYLISTS -> Icon(Icons.Filled.PlaylistPlay, contentDescription = "Playlists", tint = iconTint, modifier = Modifier.size(22.dp)) + MainTab.NETWORK -> Icon(Icons.Filled.BringYourOwnIp, contentDescription = "Network", tint = iconTint, modifier = Modifier.size(22.dp)) + } + + Spacer(modifier = Modifier.height(3.dp)) + + Text( + text = when (tab) { + MainTab.HOME -> "Home" + MainTab.RECENTS -> "Recents" + MainTab.PLAYLISTS -> "Playlists" + MainTab.NETWORK -> "Network" + }, + style = MaterialTheme.typography.labelSmall, + color = textTint, + maxLines = 1 + ) } - }, - label = { - Text( + } + } + } + } else { + NavigationBar( + modifier = Modifier + .clip(AppShapeScale.extraLargeIncreased), + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ) { + visibleTabs.forEach { tab -> + NavigationBarItem( + icon = { when (tab) { - MainTab.HOME -> "Home" - MainTab.RECENTS -> "Recents" - MainTab.PLAYLISTS -> "Playlists" - MainTab.NETWORK -> "Network" + MainTab.HOME -> Icon(Icons.Filled.Home, contentDescription = "Home") + MainTab.RECENTS -> Icon(Icons.Filled.History, contentDescription = "Recents") + MainTab.PLAYLISTS -> Icon(Icons.Filled.PlaylistPlay, contentDescription = "Playlists") + MainTab.NETWORK -> Icon(Icons.Filled.BringYourOwnIp, contentDescription = "Network") } - ) - }, - selected = selectedTab == tab, - onClick = { selectedTab = tab }, - ) + }, + label = { + Text( + when (tab) { + MainTab.HOME -> "Home" + MainTab.RECENTS -> "Recents" + MainTab.PLAYLISTS -> "Playlists" + MainTab.NETWORK -> "Network" + } + ) + }, + selected = selectedTab == tab, + onClick = { selectedTab = tab }, + ) + } } } } } ) { paddingValues -> - Box(modifier = Modifier.fillMaxSize()) { - // Always use 80dp bottom padding regardless of navigation bar visibility - val fabBottomPadding = 80.dp + Box( + modifier = Modifier + .fillMaxSize() + .layerBackdrop(screenBackdrop) + ) { + val bottomNavVisible = !hideNavigationBar && visibleTabs.isNotEmpty() && !isPermissionDenied + val fabBottomPadding = + if (bottomNavVisible) { + if (enableLiquidGlass) 112.dp else 80.dp + } else { + 0.dp + } AnimatedContent( targetState = selectedTab, @@ -218,7 +332,8 @@ object MainScreen : Screen { label = "tab_animation" ) { targetTab -> CompositionLocalProvider( - LocalNavigationBarHeight provides fabBottomPadding + LocalNavigationBarHeight provides fabBottomPadding, + LocalScreenBackdrop provides screenBackdrop ) { val effectiveTab = if (visibleTabs.isEmpty()) MainTab.HOME else targetTab when (effectiveTab) { diff --git a/app/src/main/java/app/gyrolet/mpvrx/ui/browser/dialogs/FilePickerDialog.kt b/app/src/main/java/app/gyrolet/mpvrx/ui/browser/dialogs/FilePickerDialog.kt index 339d1d7f5..b31663e59 100644 --- a/app/src/main/java/app/gyrolet/mpvrx/ui/browser/dialogs/FilePickerDialog.kt +++ b/app/src/main/java/app/gyrolet/mpvrx/ui/browser/dialogs/FilePickerDialog.kt @@ -18,12 +18,8 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.FilledTonalIconButton -import androidx.compose.material3.Icon import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -43,8 +39,16 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import app.gyrolet.mpvrx.utils.storage.StorageVolumeUtils +import app.gyrolet.mpvrx.preferences.AppearancePreferences +import app.gyrolet.mpvrx.preferences.preference.collectAsState +import app.gyrolet.mpvrx.ui.components.LiquidButton +import app.gyrolet.mpvrx.presentation.components.LiquidDialog +import androidx.compose.foundation.layout.fillMaxHeight +import com.kyant.backdrop.backdrops.rememberLayerBackdrop +import org.koin.compose.koinInject import java.io.File @OptIn(ExperimentalMaterial3ExpressiveApi::class) @@ -88,59 +92,52 @@ fun FilePickerDialog( if (!isOpen) return val context = LocalContext.current - - // Get all available storage volumes + val preferences = koinInject() + val enableLiquidGlass by preferences.enableLiquidGlass.collectAsState() + val storageVolumes = remember(isOpen) { StorageVolumeUtils.getAllStorageVolumes(context) } - - // If there's only one storage volume, start there directly - // Otherwise, start at storage root view to show all volumes - // Respect currentPath if it's valid and exists + var selectedPath by remember(isOpen, currentPath, storageVolumes) { val initialPath = if (currentPath.isNotEmpty() && File(currentPath).exists()) { currentPath } else if (storageVolumes.size == 1) { StorageVolumeUtils.getVolumePath(storageVolumes.first()) } else { - null // Show storage root with all volumes + null } mutableStateOf(initialPath) } - // Notify parent when path changes for state persistence LaunchedEffect(selectedPath) { onPathChanged?.invoke(selectedPath) } - // Determine what to show based on selectedPath val showStorageRoot = selectedPath == null - - val currentDir = remember(selectedPath) { + + val currentDir = remember(selectedPath) { selectedPath?.let { File(it) } } - - // Get folders and allowed files + val (folders, files) = remember(selectedPath, matchToName) { if (showStorageRoot) { Pair(emptyList(), emptyList()) } else { val allFiles = currentDir?.listFiles { file -> !file.name.startsWith(".") } ?: emptyArray() - - // Use NaturalOrderComparator for better sorting (e.g., Ep 2 < Ep 10) - val dirs = allFiles.filter { it.isDirectory }.sortedWith { f1, f2 -> + + val dirs = allFiles.filter { it.isDirectory }.sortedWith { f1, f2 -> app.gyrolet.mpvrx.utils.sort.SortUtils.NaturalOrderComparator.DEFAULT.compare(f1.name, f2.name) } - - val filteredFiles = allFiles.filter { file -> - !file.isDirectory && allowedExtensions.any { ext -> file.name.endsWith(ext, ignoreCase = true) } + + val filteredFiles = allFiles.filter { file -> + !file.isDirectory && allowedExtensions.any { ext -> file.name.endsWith(ext, ignoreCase = true) } } - // Final sorted files: matches first (alphabetical), then others (alphabetical) val finalSortedFiles = filteredFiles.sortedWith { f1, f2 -> val m1 = matchToName != null && f1.name.contains(matchToName, ignoreCase = true) val m2 = matchToName != null && f2.name.contains(matchToName, ignoreCase = true) - + if (m1 && !m2) { -1 } else if (!m1 && m2) { @@ -149,7 +146,7 @@ fun FilePickerDialog( app.gyrolet.mpvrx.utils.sort.SortUtils.NaturalOrderComparator.DEFAULT.compare(f1.name, f2.name) } } - + Pair(dirs, finalSortedFiles) } } @@ -157,162 +154,163 @@ fun FilePickerDialog( val configuration = LocalConfiguration.current val isPortrait = configuration.orientation == Configuration.ORIENTATION_PORTRAIT - androidx.compose.ui.window.Dialog( - onDismissRequest = onDismiss, - properties = DialogProperties(usePlatformDefaultWidth = false), - ) { - Surface( - modifier = modifier.fillMaxWidth(if (isPortrait) 0.9f else 0.50f), - shape = MaterialTheme.shapes.extraLarge, - color = MaterialTheme.colorScheme.surface, - tonalElevation = 6.dp, - ) { - Column( - modifier = Modifier.padding(24.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) + val dialogContent = @Composable { + Column( + modifier = Modifier.padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + if (isPortrait) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = "Select Subtitle", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + ) + Text( + text = selectedPath ?: "Select a storage location", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 4.dp), + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally), + ) { + NavigationButtons( + selectedPath = selectedPath, + onBack = { selectedPath = currentDir?.parent }, + onHome = { selectedPath = Environment.getExternalStorageDirectory().absolutePath }, + onSystemPicker = onSystemPickerRequest, + buttonSize = 48.dp, + iconSize = 26.dp, + ) + } + } + } else { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, ) { - // Title Section - orientation-aware layout - if (isPortrait) { - // Portrait: title/path stacked on top, nav buttons centered below - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Column(modifier = Modifier.fillMaxWidth()) { - Text( - text = "Select Subtitle", - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold, - ) - Text( - text = selectedPath ?: "Select a storage location", - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(top = 4.dp), - ) - } - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally), - ) { - NavigationButtons( - selectedPath = selectedPath, - onBack = { selectedPath = currentDir?.parent }, - onHome = { selectedPath = Environment.getExternalStorageDirectory().absolutePath }, - onSystemPicker = onSystemPickerRequest, - buttonSize = 48.dp, - iconSize = 26.dp, - ) - } - } - } else { - // Landscape: title/path left, nav buttons right (same row) - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = "Select Subtitle", - style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.Bold, - ) - Text( - text = selectedPath ?: "Select a storage location", - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(top = 4.dp), - ) - } - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - NavigationButtons( - selectedPath = selectedPath, - onBack = { selectedPath = currentDir?.parent }, - onHome = { selectedPath = Environment.getExternalStorageDirectory().absolutePath }, - onSystemPicker = onSystemPickerRequest, - buttonSize = 40.dp, - iconSize = 24.dp, - ) - } - } - } - } - - // Content Section - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - // Folder/Volume/File list - LazyColumn( - modifier = - Modifier - .fillMaxWidth() - .height(400.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - if (showStorageRoot) { - // Show storage volumes - items(storageVolumes) { volume -> - val volumePath = StorageVolumeUtils.getVolumePath(volume) - if (volumePath != null) { - StorageVolumeItem( - context = context, - volume = volume, - volumePath = volumePath, - onClick = { selectedPath = volumePath }, - ) - } - } - if (storageVolumes.isEmpty()) { - item { - Text("No storage devices found", modifier = Modifier.padding(16.dp)) - } - } - } else { - // Show folders - items(folders) { folder -> - FolderItem( - folder = folder, - onClick = { selectedPath = folder.absolutePath }, - ) - } - // Show files - items(files) { file -> - FileItem( - file = file, - onClick = { onFileSelected(file.absolutePath) } - ) - } - if (folders.isEmpty() && files.isEmpty()) { - item { - Text("No folders or supported files", modifier = Modifier.padding(16.dp)) - } - } - } - } - } - - // Footer Section (Analyze padding here) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End - ) { - TextButton( - onClick = onDismiss, - shape = MaterialTheme.shapes.extraLarge, - // Reduced padding for the button itself if needed, or rely on Row padding - ) { - Text("Cancel", fontWeight = FontWeight.Medium) - } - } + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Select Subtitle", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + ) + Text( + text = selectedPath ?: "Select a storage location", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 4.dp), + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + NavigationButtons( + selectedPath = selectedPath, + onBack = { selectedPath = currentDir?.parent }, + onHome = { selectedPath = Environment.getExternalStorageDirectory().absolutePath }, + onSystemPicker = onSystemPickerRequest, + buttonSize = 40.dp, + iconSize = 24.dp, + ) + } + } + } + } + + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .height(400.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + if (showStorageRoot) { + items(storageVolumes, key = { it.hashCode() }) { volume -> + val volumePath = StorageVolumeUtils.getVolumePath(volume) + if (volumePath != null) { + StorageVolumeItem( + context = context, + volume = volume, + volumePath = volumePath, + onClick = { selectedPath = volumePath }, + ) + } + } + if (storageVolumes.isEmpty()) { + item { + Text("No storage devices found", modifier = Modifier.padding(16.dp)) + } + } + } else { + items(folders, key = { it.absolutePath }) { folder -> + FolderItem( + folder = folder, + onClick = { selectedPath = folder.absolutePath }, + ) } + items(files, key = { it.absolutePath }) { file -> + FileItem( + file = file, + onClick = { onFileSelected(file.absolutePath) } + ) + } + if (folders.isEmpty() && files.isEmpty()) { + item { + Text("No folders or supported files", modifier = Modifier.padding(16.dp)) + } + } + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + TextButton( + onClick = onDismiss, + shape = MaterialTheme.shapes.extraLarge, + ) { + Text("Cancel", fontWeight = FontWeight.Medium) + } + } + } + } + + if (enableLiquidGlass) { + LiquidDialog( + onDismissRequest = onDismiss, + modifier = if (isPortrait) { + modifier.fillMaxWidth().fillMaxHeight(0.5f) + } else { + modifier.fillMaxWidth(0.95f) + }, + ) { + dialogContent() + } + } else { + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Surface( + modifier = modifier.fillMaxWidth(if (isPortrait) 0.9f else 0.50f), + shape = MaterialTheme.shapes.extraLarge, + color = MaterialTheme.colorScheme.surface, + tonalElevation = 6.dp, + ) { + dialogContent() } + } } } @@ -327,14 +325,14 @@ private fun StorageVolumeItem( val description = volume.getDescription(context) val isPrimary = volume.isPrimary val isRemovable = volume.isRemovable - + val icon = when { isPrimary -> Icons.Default.Home isRemovable && volumePath.contains("usb", ignoreCase = true) -> Icons.Default.Usb isRemovable -> Icons.Default.SdCard else -> Icons.Default.Folder } - + Row( modifier = modifier @@ -452,42 +450,78 @@ private fun NavigationButtons( buttonSize: Dp, iconSize: Dp, ) { + val preferences = koinInject() + val enableLiquidGlass by preferences.enableLiquidGlass.collectAsState() + val backdrop = rememberLayerBackdrop() + if (selectedPath != null) { + if (enableLiquidGlass) { + LiquidButton( + onClick = onBack, + backdrop = backdrop, + modifier = Modifier.size(buttonSize), + height = buttonSize, + horizontalPadding = 0.dp, + ) { + Icon(Icons.Filled.ArrowBack, "Back", modifier = Modifier.size(iconSize)) + } + } else { + FilledTonalIconButton( + onClick = onBack, + modifier = Modifier.size(buttonSize), + colors = IconButtonDefaults.filledTonalIconButtonColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + ) + ) { + Icon(Icons.Filled.ArrowBack, "Back", modifier = Modifier.size(iconSize)) + } + } + } + + if (enableLiquidGlass) { + LiquidButton( + onClick = onHome, + backdrop = backdrop, + modifier = Modifier.size(buttonSize), + height = buttonSize, + horizontalPadding = 0.dp, + ) { + Icon(Icons.Default.Home, "Home", modifier = Modifier.size(iconSize)) + } + } else { FilledTonalIconButton( - onClick = onBack, + onClick = onHome, modifier = Modifier.size(buttonSize), colors = IconButtonDefaults.filledTonalIconButtonColors( containerColor = MaterialTheme.colorScheme.secondaryContainer, contentColor = MaterialTheme.colorScheme.onSecondaryContainer, ) ) { - Icon(Icons.Filled.ArrowBack, "Back", modifier = Modifier.size(iconSize)) + Icon(Icons.Default.Home, "Home", modifier = Modifier.size(iconSize)) } } - FilledTonalIconButton( - onClick = onHome, - modifier = Modifier.size(buttonSize), - colors = IconButtonDefaults.filledTonalIconButtonColors( - containerColor = MaterialTheme.colorScheme.secondaryContainer, - contentColor = MaterialTheme.colorScheme.onSecondaryContainer, - ) - ) { - Icon(Icons.Default.Home, "Home", modifier = Modifier.size(iconSize)) - } - - FilledTonalIconButton( - onClick = onSystemPicker, - modifier = Modifier.size(buttonSize), - colors = IconButtonDefaults.filledTonalIconButtonColors( - containerColor = MaterialTheme.colorScheme.tertiaryContainer, - contentColor = MaterialTheme.colorScheme.onTertiaryContainer, - ) - ) { - Icon(Icons.Default.DriveFolderUpload, "System Picker", modifier = Modifier.size(iconSize)) + if (enableLiquidGlass) { + LiquidButton( + onClick = onSystemPicker, + backdrop = backdrop, + modifier = Modifier.size(buttonSize), + height = buttonSize, + horizontalPadding = 0.dp, + ) { + Icon(Icons.Default.DriveFolderUpload, "System Picker", modifier = Modifier.size(iconSize)) + } + } else { + FilledTonalIconButton( + onClick = onSystemPicker, + modifier = Modifier.size(buttonSize), + colors = IconButtonDefaults.filledTonalIconButtonColors( + containerColor = MaterialTheme.colorScheme.tertiaryContainer, + contentColor = MaterialTheme.colorScheme.onTertiaryContainer, + ) + ) { + Icon(Icons.Default.DriveFolderUpload, "System Picker", modifier = Modifier.size(iconSize)) + } } } - - - - diff --git a/app/src/main/java/app/gyrolet/mpvrx/ui/browser/dialogs/VideoCompressorOverlay.kt b/app/src/main/java/app/gyrolet/mpvrx/ui/browser/dialogs/VideoCompressorOverlay.kt index e69104ac6..026bdf6df 100644 --- a/app/src/main/java/app/gyrolet/mpvrx/ui/browser/dialogs/VideoCompressorOverlay.kt +++ b/app/src/main/java/app/gyrolet/mpvrx/ui/browser/dialogs/VideoCompressorOverlay.kt @@ -70,7 +70,7 @@ import androidx.compose.material3.PrimaryTabRow import androidx.compose.material3.Scaffold import androidx.compose.material3.Slider import androidx.compose.material3.Surface -import androidx.compose.material3.Switch +import app.gyrolet.mpvrx.ui.components.AdaptiveSwitch import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -957,7 +957,7 @@ private fun CompressorAudioTab( horizontalArrangement = Arrangement.SpaceBetween, ) { Text("Remove audio", style = MaterialTheme.typography.bodyLarge) - Switch(checked = state.removeAudio, onCheckedChange = { onToggleRemoveAudio() }) + AdaptiveSwitch(checked = state.removeAudio, onCheckedChange = { onToggleRemoveAudio() }) } AnimatedVisibility(visible = !state.removeAudio) { @@ -1365,7 +1365,7 @@ private fun CompressorInfoDialog( horizontalArrangement = Arrangement.SpaceBetween, ) { Text("Show bitrate") - Switch(checked = state.showBitrate, onCheckedChange = { onToggleShowBitrate() }) + AdaptiveSwitch(checked = state.showBitrate, onCheckedChange = { onToggleShowBitrate() }) } if (state.showBitrate) { Row( @@ -1374,7 +1374,7 @@ private fun CompressorInfoDialog( horizontalArrangement = Arrangement.SpaceBetween, ) { Text("Use Mbps") - Switch(checked = state.useMbps, onCheckedChange = { onToggleBitrateUnit() }) + AdaptiveSwitch(checked = state.useMbps, onCheckedChange = { onToggleBitrateUnit() }) } } Row( @@ -1383,7 +1383,7 @@ private fun CompressorInfoDialog( horizontalArrangement = Arrangement.SpaceBetween, ) { Text("Preserve metadata") - Switch(checked = state.preserveMetadata, onCheckedChange = { onTogglePreserveMetadata() }) + AdaptiveSwitch(checked = state.preserveMetadata, onCheckedChange = { onTogglePreserveMetadata() }) } HorizontalDivider() Text("Supported Codecs", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary) diff --git a/app/src/main/java/app/gyrolet/mpvrx/ui/browser/filesystem/FileSystemBrowserScreen.kt b/app/src/main/java/app/gyrolet/mpvrx/ui/browser/filesystem/FileSystemBrowserScreen.kt index b0f441317..3589e3fef 100644 --- a/app/src/main/java/app/gyrolet/mpvrx/ui/browser/filesystem/FileSystemBrowserScreen.kt +++ b/app/src/main/java/app/gyrolet/mpvrx/ui/browser/filesystem/FileSystemBrowserScreen.kt @@ -213,9 +213,9 @@ fun FileSystemBrowserScreen(path: String? = null) { // Animation duration for responsive slide animations val animationDuration = 200 - // Selection managers - separate for folders and videos - val folders = items.filterIsInstance() - val videos = items.filterIsInstance().map { it.video } + val folders = remember(items) { items.filterIsInstance() } + val videoFiles = remember(items) { items.filterIsInstance() } + val videos = remember(videoFiles) { videoFiles.map { it.video } } val folderSelectionManager = rememberSelectionManager( items = folders, @@ -1195,8 +1195,9 @@ private fun FileSystemBrowserContent( val thumbWidthPx = with(density) { thumbWidthDp.roundToPx() } val thumbHeightPx = ((thumbWidthPx.toFloat() / aspect).toInt()) - val folders = items.filterIsInstance() - val videos = items.filterIsInstance().map { it.video } + val folders = remember(items) { items.filterIsInstance() } + val videoFiles = remember(items) { items.filterIsInstance() } + val videos = remember(videoFiles) { videoFiles.map { it.video } } // Create a unique folderId based on the current directories val folderId = remember(folders, isAtRoot, breadcrumbs) { @@ -1314,8 +1315,9 @@ private fun FileSystemBrowserContent( // Folders first items( - items = items.filterIsInstance(), + items = folders, key = { it.path }, + contentType = { "folder" }, ) { folder -> val folderModel = app.gyrolet.mpvrx.domain.media.model.VideoFolder( bucketId = folder.path, @@ -1345,8 +1347,9 @@ private fun FileSystemBrowserContent( // Videos second items( - items = items.filterIsInstance(), + items = videoFiles, key = { "${it.video.id}_${it.video.path}" }, + contentType = { "video" }, ) { videoFile -> VideoCard( video = videoFile.video, @@ -1514,6 +1517,8 @@ private fun FileSystemSearchContent( } else -> { + val folders = remember(searchResults) { searchResults.filterIsInstance().distinctBy { it.path } } + val videos = remember(searchResults) { searchResults.filterIsInstance().distinctBy { it.video.id } } Box( modifier = Modifier.fillMaxSize() ) { @@ -1529,13 +1534,11 @@ private fun FileSystemSearchContent( ), ) { // Separate folders and videos for proper ordering and deduplicate - val folders = searchResults.filterIsInstance().distinctBy { it.path } - val videos = searchResults.filterIsInstance().distinctBy { it.video.id } - // Folders first items( items = folders, key = { "search_folder_${it.path}_${it.hashCode()}" }, + contentType = { "folder" }, ) { folder -> val folderModel = app.gyrolet.mpvrx.domain.media.model.VideoFolder( bucketId = folder.path, @@ -1563,6 +1566,7 @@ private fun FileSystemSearchContent( items( items = videos, key = { "search_video_${it.video.id}_${it.video.path}_${it.hashCode()}" }, + contentType = { "video" }, ) { videoFile -> VideoCard( video = videoFile.video, diff --git a/app/src/main/java/app/gyrolet/mpvrx/ui/browser/folderlist/FolderListScreen.kt b/app/src/main/java/app/gyrolet/mpvrx/ui/browser/folderlist/FolderListScreen.kt index 5e7076d6a..f5b504eae 100644 --- a/app/src/main/java/app/gyrolet/mpvrx/ui/browser/folderlist/FolderListScreen.kt +++ b/app/src/main/java/app/gyrolet/mpvrx/ui/browser/folderlist/FolderListScreen.kt @@ -764,7 +764,11 @@ private fun GridContent( horizontalArrangement = Arrangement.spacedBy(2.dp), verticalArrangement = Arrangement.spacedBy(2.dp), ) { - items(count = folders.size, key = { index -> folders[index].bucketId }) { index -> + items( + count = folders.size, + key = { index -> folders[index].bucketId }, + contentType = { "folder" }, + ) { index -> val folder = folders[index] val isRecentlyPlayed = recentlyPlayedFilePath?.let { filePath -> val file = File(filePath) @@ -843,7 +847,11 @@ private fun ListContent( bottom = navigationBarHeight ), ) { - items(folders) { folder -> + items( + items = folders, + key = { it.bucketId }, + contentType = { "folder" }, + ) { folder -> val isRecentlyPlayed = recentlyPlayedFilePath?.let { filePath -> val file = File(filePath) file.parent == folder.path @@ -1146,7 +1154,11 @@ private fun SearchResultsContent( horizontalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp), ) { - items(count = folders.size, key = { index -> folders[index].bucketId }) { index -> + items( + count = folders.size, + key = { index -> folders[index].bucketId }, + contentType = { "folder" }, + ) { index -> val folder = folders[index] FolderCard( folder = folder, @@ -1160,7 +1172,11 @@ private fun SearchResultsContent( ) } - items(count = videos.size, key = { index -> videos[index].id }) { index -> + items( + count = videos.size, + key = { index -> videos[index].id }, + contentType = { "video" }, + ) { index -> val video = videos[index] VideoCard( video = video, @@ -1184,7 +1200,11 @@ private fun SearchResultsContent( bottom = navigationBarHeight + 8.dp ), ) { - items(count = folders.size, key = { index -> folders[index].bucketId }) { index -> + items( + count = folders.size, + key = { index -> folders[index].bucketId }, + contentType = { "folder" }, + ) { index -> val folder = folders[index] FolderCard( folder = folder, @@ -1198,7 +1218,11 @@ private fun SearchResultsContent( ) } - items(count = videos.size, key = { index -> videos[index].id }) { index -> + items( + count = videos.size, + key = { index -> videos[index].id }, + contentType = { "video" }, + ) { index -> val video = videos[index] VideoCard( video = video, diff --git a/app/src/main/java/app/gyrolet/mpvrx/ui/browser/playlist/PlaylistDetailScreen.kt b/app/src/main/java/app/gyrolet/mpvrx/ui/browser/playlist/PlaylistDetailScreen.kt index 022d41185..a476480d0 100644 --- a/app/src/main/java/app/gyrolet/mpvrx/ui/browser/playlist/PlaylistDetailScreen.kt +++ b/app/src/main/java/app/gyrolet/mpvrx/ui/browser/playlist/PlaylistDetailScreen.kt @@ -699,6 +699,7 @@ private fun PlaylistVideoListContent( items( count = videoItems.size, key = { index -> videoItems[index].playlistItem.id }, + contentType = { index -> if (isM3uPlaylist) "m3u" else "video" }, ) { index -> ReorderableItem(reorderableLazyListState, key = videoItems[index].playlistItem.id) { val item = videoItems[index] diff --git a/app/src/main/java/app/gyrolet/mpvrx/ui/browser/recentlyplayed/RecentlyPlayedItem.kt b/app/src/main/java/app/gyrolet/mpvrx/ui/browser/recentlyplayed/RecentlyPlayedItem.kt index c8e4552fc..c0f0a96c6 100644 --- a/app/src/main/java/app/gyrolet/mpvrx/ui/browser/recentlyplayed/RecentlyPlayedItem.kt +++ b/app/src/main/java/app/gyrolet/mpvrx/ui/browser/recentlyplayed/RecentlyPlayedItem.kt @@ -1,16 +1,20 @@ package app.gyrolet.mpvrx.ui.browser.recentlyplayed +import androidx.compose.runtime.Immutable import app.gyrolet.mpvrx.database.entities.PlaylistEntity import app.gyrolet.mpvrx.domain.media.model.Video +@Immutable sealed class RecentlyPlayedItem { abstract val timestamp: Long + @Immutable data class VideoItem( val video: Video, override val timestamp: Long, ) : RecentlyPlayedItem() + @Immutable data class PlaylistItem( val playlist: PlaylistEntity, val videoCount: Int, diff --git a/app/src/main/java/app/gyrolet/mpvrx/ui/browser/recentlyplayed/RecentlyPlayedScreen.kt b/app/src/main/java/app/gyrolet/mpvrx/ui/browser/recentlyplayed/RecentlyPlayedScreen.kt index 149b63a3f..52d299e6e 100644 --- a/app/src/main/java/app/gyrolet/mpvrx/ui/browser/recentlyplayed/RecentlyPlayedScreen.kt +++ b/app/src/main/java/app/gyrolet/mpvrx/ui/browser/recentlyplayed/RecentlyPlayedScreen.kt @@ -577,6 +577,7 @@ private fun RecentItemsContent( is RecentlyPlayedItem.PlaylistItem -> "playlist_${item.playlist.id}_${item.timestamp}" } }, + contentType = { index -> if (recentItems[index] is RecentlyPlayedItem.VideoItem) "video" else "playlist" }, ) { index -> when (val item = recentItems[index]) { is RecentlyPlayedItem.VideoItem -> { @@ -688,6 +689,7 @@ private fun RecentItemsContent( is RecentlyPlayedItem.PlaylistItem -> "playlist_${item.playlist.id}_${item.timestamp}" } }, + contentType = { index -> if (recentItems[index] is RecentlyPlayedItem.VideoItem) "video" else "playlist" }, ) { index -> when (val item = recentItems[index]) { is RecentlyPlayedItem.VideoItem -> { diff --git a/app/src/main/java/app/gyrolet/mpvrx/ui/browser/videolist/VideoListScreen.kt b/app/src/main/java/app/gyrolet/mpvrx/ui/browser/videolist/VideoListScreen.kt index 641f94a8f..92b3491fd 100644 --- a/app/src/main/java/app/gyrolet/mpvrx/ui/browser/videolist/VideoListScreen.kt +++ b/app/src/main/java/app/gyrolet/mpvrx/ui/browser/videolist/VideoListScreen.kt @@ -815,6 +815,7 @@ private fun VideoListContent( items( count = videosWithInfo.size, key = { index -> "${videosWithInfo[index].video.id}_${videosWithInfo[index].video.path}" }, + contentType = { "video" }, ) { index -> val videoWithInfo = videosWithInfo[index] val isRecentlyPlayed = recentlyPlayedFilePath?.let { videoWithInfo.video.path == it } ?: false @@ -868,6 +869,7 @@ private fun VideoListContent( items( count = videosWithInfo.size, key = { index -> "${videosWithInfo[index].video.id}_${videosWithInfo[index].video.path}" }, + contentType = { "video" }, ) { index -> val videoWithInfo = videosWithInfo[index] val isRecentlyPlayed = recentlyPlayedFilePath?.let { videoWithInfo.video.path == it } ?: false diff --git a/app/src/main/java/app/gyrolet/mpvrx/ui/components/AdaptiveSwitch.kt b/app/src/main/java/app/gyrolet/mpvrx/ui/components/AdaptiveSwitch.kt new file mode 100644 index 000000000..269d09e1e --- /dev/null +++ b/app/src/main/java/app/gyrolet/mpvrx/ui/components/AdaptiveSwitch.kt @@ -0,0 +1,41 @@ +package app.gyrolet.mpvrx.ui.components + +import androidx.compose.material3.Switch +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import app.gyrolet.mpvrx.preferences.AppearancePreferences +import app.gyrolet.mpvrx.preferences.preference.collectAsState +import com.kyant.backdrop.backdrops.rememberLayerBackdrop +import org.koin.compose.koinInject + +@Composable +fun AdaptiveSwitch( + checked: Boolean, + onCheckedChange: ((Boolean) -> Unit)?, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + val preferences = koinInject() + val enableLiquidGlass by preferences.enableLiquidGlass.collectAsState() + val liquidToggleColor by preferences.liquidToggleColor.collectAsState() + + if (enableLiquidGlass) { + val backdrop = rememberLayerBackdrop() + LiquidToggle( + selected = { checked }, + onSelect = { onCheckedChange?.invoke(it) }, + backdrop = backdrop, + modifier = modifier, + accentColor = Color(liquidToggleColor) + ) + } else { + Switch( + checked = checked, + onCheckedChange = onCheckedChange, + modifier = modifier, + enabled = enabled + ) + } +} diff --git a/app/src/main/java/app/gyrolet/mpvrx/ui/components/LiquidBottomTabs.kt b/app/src/main/java/app/gyrolet/mpvrx/ui/components/LiquidBottomTabs.kt new file mode 100644 index 000000000..e057adf34 --- /dev/null +++ b/app/src/main/java/app/gyrolet/mpvrx/ui/components/LiquidBottomTabs.kt @@ -0,0 +1,293 @@ +package app.gyrolet.mpvrx.ui.components + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.EaseOut +import androidx.compose.animation.core.spring +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastCoerceIn +import androidx.compose.ui.util.fastRoundToInt +import androidx.compose.ui.util.lerp +import com.kyant.backdrop.Backdrop +import com.kyant.backdrop.backdrops.layerBackdrop +import com.kyant.backdrop.backdrops.rememberCombinedBackdrop +import com.kyant.backdrop.backdrops.rememberLayerBackdrop +import app.gyrolet.mpvrx.ui.utils.DampedDragAnimation +import app.gyrolet.mpvrx.ui.utils.InteractiveHighlight +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.highlight.Highlight +import com.kyant.backdrop.shadow.InnerShadow +import com.kyant.backdrop.shadow.Shadow +import com.kyant.shapes.Capsule +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.launch +import kotlin.math.abs +import kotlin.math.sign + +val LocalLiquidBottomTabScale = compositionLocalOf { { 1f } } + +@Composable +fun LiquidBottomTabs( + selectedTabIndex: () -> Int, + onTabSelected: (index: Int) -> Unit, + backdrop: Backdrop, + tabsCount: Int, + modifier: Modifier = Modifier, + content: @Composable RowScope.() -> Unit +) { + val accentColor = MaterialTheme.colorScheme.primary + val containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.4f) + val indicatorColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f) + + val tabsBackdrop = rememberLayerBackdrop() + + BoxWithConstraints( + modifier, + contentAlignment = Alignment.CenterStart + ) { + val density = LocalDensity.current + val tabWidth = with(density) { + (constraints.maxWidth.toFloat() - 8f.dp.toPx()) / tabsCount + } + + val offsetAnimation = remember { Animatable(0f) } + val panelOffset by remember(density) { + derivedStateOf { + val fraction = (offsetAnimation.value / constraints.maxWidth).fastCoerceIn(-1f, 1f) + with(density) { + 4f.dp.toPx() * fraction.sign * EaseOut.transform(abs(fraction)) + } + } + } + + val isLtr = LocalLayoutDirection.current == LayoutDirection.Ltr + val animationScope = rememberCoroutineScope() + var currentIndex by remember(selectedTabIndex) { + mutableIntStateOf(selectedTabIndex()) + } + val dampedDragAnimation = remember(animationScope) { + DampedDragAnimation( + animationScope = animationScope, + initialValue = selectedTabIndex().toFloat(), + valueRange = 0f..(tabsCount - 1).toFloat(), + visibilityThreshold = 0.001f, + initialScale = 1f, + pressedScale = 68f / 56f, + onDragStarted = {}, + onDragStopped = { + val targetIndex = targetValue.fastRoundToInt().fastCoerceIn(0, tabsCount - 1) + currentIndex = targetIndex + animateToValue(targetIndex.toFloat()) + animationScope.launch { + offsetAnimation.animateTo( + 0f, + spring(1f, 300f, 0.5f) + ) + } + }, + onDrag = { _, dragAmount -> + updateValue( + (targetValue + dragAmount.x / tabWidth * if (isLtr) 1f else -1f) + .fastCoerceIn(0f, (tabsCount - 1).toFloat()) + ) + animationScope.launch { + offsetAnimation.snapTo(offsetAnimation.value + dragAmount.x) + } + } + ) + } + LaunchedEffect(selectedTabIndex) { + snapshotFlow { selectedTabIndex() } + .collectLatest { index -> + currentIndex = index + } + } + LaunchedEffect(dampedDragAnimation) { + snapshotFlow { currentIndex } + .drop(1) + .collectLatest { index -> + dampedDragAnimation.animateToValue(index.toFloat()) + onTabSelected(index) + } + } + + val interactiveHighlight = remember(animationScope) { + InteractiveHighlight( + animationScope = animationScope, + position = { size, _ -> + Offset( + if (isLtr) (dampedDragAnimation.value + 0.5f) * tabWidth + panelOffset + else size.width - (dampedDragAnimation.value + 0.5f) * tabWidth + panelOffset, + size.height / 2f + ) + } + ) + } + + Row( + Modifier + .graphicsLayer { + translationX = panelOffset + } + .drawBackdrop( + backdrop = backdrop, + shape = { Capsule() }, + effects = { + vibrancy() + blur(8f.dp.toPx()) + lens(6f.dp.toPx(), 6f.dp.toPx()) + }, + layerBlock = { + val progress = dampedDragAnimation.pressProgress + val scale = lerp(1f, 1f + 16f.dp.toPx() / size.width, progress) + scaleX = scale + scaleY = scale + }, + onDrawSurface = { drawRect(containerColor) } + ) + .then(interactiveHighlight.modifier) + .height(64f.dp) + .fillMaxWidth() + .padding(4f.dp), + verticalAlignment = Alignment.CenterVertically, + content = content + ) + + CompositionLocalProvider( + LocalLiquidBottomTabScale provides { + lerp(1f, 1.08f, dampedDragAnimation.pressProgress) + } + ) { + Row( + Modifier + .clearAndSetSemantics {} + .alpha(0f) + .layerBackdrop(tabsBackdrop) + .graphicsLayer { + translationX = panelOffset + } + .drawBackdrop( + backdrop = backdrop, + shape = { Capsule() }, + effects = { + val progress = dampedDragAnimation.pressProgress + vibrancy() + blur(8f.dp.toPx()) + lens( + 2f.dp.toPx() * progress, + 2f.dp.toPx() * progress + ) + }, + highlight = { + val progress = dampedDragAnimation.pressProgress + Highlight.Default.copy(alpha = progress) + }, + onDrawSurface = { drawRect(containerColor) } + ) + .then(interactiveHighlight.modifier) + .height(56.dp) + .fillMaxWidth() + .padding(horizontal = 4f.dp) + .graphicsLayer(colorFilter = ColorFilter.tint(accentColor)), + verticalAlignment = Alignment.CenterVertically, + content = content + ) + } + + Box( + Modifier + .padding(horizontal = 4f.dp) + .graphicsLayer { + translationX = + if (isLtr) dampedDragAnimation.value * tabWidth + panelOffset + else size.width - (dampedDragAnimation.value + 1f) * tabWidth + panelOffset + + scaleX = dampedDragAnimation.scaleX + scaleY = dampedDragAnimation.scaleY + val velocity = dampedDragAnimation.velocity / 10f + scaleX /= 1f - (velocity * 0.75f).fastCoerceIn(-0.2f, 0.2f) + scaleY *= 1f - (velocity * 0.25f).fastCoerceIn(-0.2f, 0.2f) + } + .then(interactiveHighlight.gestureModifier) + .then(dampedDragAnimation.modifier) + .drawBackdrop( + backdrop = rememberCombinedBackdrop(backdrop, tabsBackdrop), + shape = { Capsule() }, + effects = { + val progress = dampedDragAnimation.pressProgress + lens( + 10f.dp.toPx() * progress, + 14f.dp.toPx() * progress, + chromaticAberration = true + ) + }, + highlight = { + val progress = dampedDragAnimation.pressProgress + Highlight.Default.copy(alpha = progress) + }, + shadow = { + val progress = dampedDragAnimation.pressProgress + Shadow(alpha = progress) + }, + innerShadow = { + val progress = dampedDragAnimation.pressProgress + InnerShadow( + radius = 8f.dp * progress, + alpha = progress + ) + }, + layerBlock = { + scaleX = dampedDragAnimation.scaleX + scaleY = dampedDragAnimation.scaleY + val velocity = dampedDragAnimation.velocity / 10f + scaleX /= 1f - (velocity * 0.75f).fastCoerceIn(-0.2f, 0.2f) + scaleY *= 1f - (velocity * 0.25f).fastCoerceIn(-0.2f, 0.2f) + }, + onDrawSurface = { + val progress = dampedDragAnimation.pressProgress + drawRect( + indicatorColor, + alpha = 1f - progress + ) + drawRect(Color.Black.copy(alpha = 0.03f * progress)) + } + ) + .height(56f.dp) + .fillMaxWidth(1f / tabsCount) + ) + } +} diff --git a/app/src/main/java/app/gyrolet/mpvrx/ui/components/LiquidButton.kt b/app/src/main/java/app/gyrolet/mpvrx/ui/components/LiquidButton.kt new file mode 100644 index 000000000..d5cf373a8 --- /dev/null +++ b/app/src/main/java/app/gyrolet/mpvrx/ui/components/LiquidButton.kt @@ -0,0 +1,159 @@ +package app.gyrolet.mpvrx.ui.components + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.LocalContentColor +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.isSpecified +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastCoerceAtMost +import androidx.compose.ui.util.lerp +import com.kyant.backdrop.Backdrop +import app.gyrolet.mpvrx.ui.utils.InteractiveHighlight +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.shapes.Capsule +import org.koin.compose.koinInject +import app.gyrolet.mpvrx.preferences.AppearancePreferences +import app.gyrolet.mpvrx.preferences.preference.collectAsState +import androidx.compose.runtime.getValue +import kotlin.math.abs +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.sin +import kotlin.math.tanh +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.Spring +import androidx.compose.foundation.layout.size + +val LocalScreenBackdrop = compositionLocalOf { null } + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun LiquidButton( + onClick: () -> Unit, + backdrop: Backdrop, + modifier: Modifier = Modifier, + onLongClick: () -> Unit = {}, + isInteractive: Boolean = true, + useGlass: Boolean = true, + tint: Color = Color.Unspecified, + surfaceColor: Color = Color.Unspecified, + height: Dp = 48.dp, + horizontalPadding: Dp = 16.dp, + spacing: Dp = 8.dp, + content: @Composable RowScope.() -> Unit, +) { + val animationScope = rememberCoroutineScope() + val contentColor = LocalContentColor.current + val effectiveTint = if (tint.isSpecified) tint else contentColor + + val preferences = koinInject() + val blurRadius by preferences.liquidButtonBlur.collectAsState() + val lensRadius by preferences.liquidButtonLensRadius.collectAsState() + val lensDepth by preferences.liquidButtonLensDepth.collectAsState() + val liquidOpacity by preferences.liquidButtonOpacity.collectAsState() + val liquidTint by preferences.liquidButtonTint.collectAsState() + val density = androidx.compose.ui.platform.LocalDensity.current + + val interactiveHighlight = remember(animationScope) { + InteractiveHighlight( + animationScope = animationScope + ) + } + + Row( + modifier + .then( + if (useGlass) { + Modifier.drawBackdrop( + backdrop = backdrop, + shape = { Capsule() }, + effects = { + vibrancy() + blur(with(density) { blurRadius.dp.toPx() }) + lens(with(density) { lensRadius.dp.toPx() }, with(density) { lensDepth.dp.toPx() }, chromaticAberration = true) + }, + layerBlock = if (isInteractive) { + { + val width = size.width + val height = size.height + + val progress = interactiveHighlight.pressProgress + val scale = lerp(1f, 1f + (4f.dp.toPx() / size.height), progress) + + val maxOffset = size.minDimension + val initialDerivative = 0.05f + val offset = interactiveHighlight.offset + translationX = maxOffset * tanh((initialDerivative * offset.x) / maxOffset) + translationY = maxOffset * tanh((initialDerivative * offset.y) / maxOffset) + + val maxDragScale = 4f.dp.toPx() / size.height + val offsetAngle = atan2(offset.y, offset.x) + scaleX = + scale + + maxDragScale * abs(cos(offsetAngle) * offset.x / size.maxDimension) * + (width / height).fastCoerceAtMost(1f) + scaleY = + scale + + maxDragScale * abs(sin(offsetAngle) * offset.y / size.maxDimension) * + (height / width).fastCoerceAtMost(1f) + } + } else { + null + }, + onDrawSurface = { + val tintColor = if (tint.isSpecified) tint else Color(liquidTint) + drawRect(tintColor, blendMode = BlendMode.Screen, alpha = liquidOpacity) + drawRect(tintColor.copy(alpha = liquidOpacity * 0.2f)) + + if (surfaceColor.isSpecified) { + drawRect(surfaceColor) + } + } + ) + } else { + Modifier + } + ) + .combinedClickable( + interactionSource = null, + indication = if (isInteractive) null else LocalIndication.current, + role = Role.Button, + onClick = onClick, + onLongClick = onLongClick + ) + .then( + if (isInteractive) { + Modifier + .then(interactiveHighlight.modifier) + .then(interactiveHighlight.gestureModifier) + } else { + Modifier + } + ) + .height(height) + .padding(horizontal = horizontalPadding), + horizontalArrangement = Arrangement.spacedBy(spacing, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically, + content = content + ) +} diff --git a/app/src/main/java/app/gyrolet/mpvrx/ui/components/LiquidToggle.kt b/app/src/main/java/app/gyrolet/mpvrx/ui/components/LiquidToggle.kt new file mode 100644 index 000000000..bdce1b7a7 --- /dev/null +++ b/app/src/main/java/app/gyrolet/mpvrx/ui/components/LiquidToggle.kt @@ -0,0 +1,199 @@ +package app.gyrolet.mpvrx.ui.components + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.scale +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastCoerceIn +import androidx.compose.ui.util.lerp +import com.kyant.backdrop.Backdrop +import com.kyant.backdrop.backdrops.layerBackdrop +import com.kyant.backdrop.backdrops.rememberBackdrop +import com.kyant.backdrop.backdrops.rememberCombinedBackdrop +import com.kyant.backdrop.backdrops.rememberLayerBackdrop +import app.gyrolet.mpvrx.ui.utils.DampedDragAnimation +import com.kyant.backdrop.drawBackdrop +import com.kyant.backdrop.effects.blur +import com.kyant.backdrop.effects.lens +import com.kyant.backdrop.highlight.Highlight +import com.kyant.backdrop.shadow.InnerShadow +import com.kyant.backdrop.shadow.Shadow +import com.kyant.shapes.Capsule +import kotlinx.coroutines.flow.collectLatest + +@Composable +fun LiquidToggle( + selected: () -> Boolean, + onSelect: (Boolean) -> Unit, + backdrop: Backdrop, + modifier: Modifier = Modifier, + accentColor: Color = MaterialTheme.colorScheme.primary +) { + val trackColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f) + + val density = LocalDensity.current + val isLtr = LocalLayoutDirection.current == LayoutDirection.Ltr + val dragWidth = with(density) { 20f.dp.toPx() } + val animationScope = rememberCoroutineScope() + var didDrag by remember { mutableStateOf(false) } + var fraction by remember { mutableFloatStateOf(if (selected()) 1f else 0f) } + val dampedDragAnimation = remember(animationScope) { + DampedDragAnimation( + animationScope = animationScope, + initialValue = fraction, + valueRange = 0f..1f, + visibilityThreshold = 0.001f, + initialScale = 1f, + pressedScale = 1.5f, + onDragStarted = {}, + onDragStopped = { + if (didDrag) { + fraction = if (targetValue >= 0.5f) 1f else 0f + onSelect(fraction == 1f) + didDrag = false + } else { + fraction = if (selected()) 0f else 1f + onSelect(fraction == 1f) + } + }, + onDrag = { _, dragAmount -> + if (!didDrag) { + didDrag = dragAmount.x != 0f + } + val delta = dragAmount.x / dragWidth + fraction = + if (isLtr) (fraction + delta).fastCoerceIn(0f, 1f) + else (fraction - delta).fastCoerceIn(0f, 1f) + } + ) + } + LaunchedEffect(dampedDragAnimation) { + snapshotFlow { fraction } + .collectLatest { fraction -> + dampedDragAnimation.updateValue(fraction) + } + } + LaunchedEffect(selected) { + snapshotFlow { selected() } + .collectLatest { isSelected -> + val target = if (isSelected) 1f else 0f + if (target != fraction) { + fraction = target + dampedDragAnimation.animateToValue(target) + } + } + } + + val trackBackdrop = rememberLayerBackdrop() + + Box( + modifier, + contentAlignment = Alignment.CenterStart + ) { + Box( + Modifier + .layerBackdrop(trackBackdrop) + .clip(Capsule()) + .drawBehind { + val fraction = dampedDragAnimation.value + drawRect(lerp(trackColor, accentColor, fraction)) + } + .size(64f.dp, 28f.dp) + ) + + Box( + Modifier + .graphicsLayer { + val fraction = dampedDragAnimation.value + val padding = 2f.dp.toPx() + translationX = + if (isLtr) lerp(padding, padding + dragWidth, fraction) + else lerp(-padding, -(padding + dragWidth), fraction) + } + .semantics { + role = Role.Switch + } + .then(dampedDragAnimation.modifier) + .drawBackdrop( + backdrop = rememberCombinedBackdrop( + backdrop, + rememberBackdrop(trackBackdrop) { drawBackdrop -> + val progress = dampedDragAnimation.pressProgress + val scaleX = lerp(2f / 3f, 0.75f, progress) + val scaleY = lerp(0f, 0.75f, progress) + scale(scaleX, scaleY) { + drawBackdrop() + } + } + ), + shape = { Capsule() }, + effects = { + val progress = dampedDragAnimation.pressProgress + blur(8f.dp.toPx() * (1f - progress)) + lens( + 5f.dp.toPx() * progress, + 10f.dp.toPx() * progress, + chromaticAberration = true + ) + }, + highlight = { + val progress = dampedDragAnimation.pressProgress + Highlight.Ambient.copy( + width = Highlight.Ambient.width / 1.5f, + blurRadius = Highlight.Ambient.blurRadius / 1.5f, + alpha = progress + ) + }, + shadow = { + Shadow( + radius = 4f.dp, + color = Color.Black.copy(alpha = 0.05f) + ) + }, + innerShadow = { + val progress = dampedDragAnimation.pressProgress + InnerShadow( + radius = 4f.dp * progress, + alpha = progress + ) + }, + layerBlock = { + scaleX = dampedDragAnimation.scaleX + scaleY = dampedDragAnimation.scaleY + val velocity = dampedDragAnimation.velocity / 50f + scaleX /= 1f - (velocity * 0.75f).fastCoerceIn(-0.2f, 0.2f) + scaleY *= 1f - (velocity * 0.25f).fastCoerceIn(-0.2f, 0.2f) + }, + onDrawSurface = { + val progress = dampedDragAnimation.pressProgress + drawRect(Color.White.copy(alpha = 1f - progress)) + } + ) + .size(40f.dp, 24f.dp) + ) + } +} diff --git a/app/src/main/java/app/gyrolet/mpvrx/ui/icons/Icons.kt b/app/src/main/java/app/gyrolet/mpvrx/ui/icons/Icons.kt index e5aa95389..606b6d71c 100644 --- a/app/src/main/java/app/gyrolet/mpvrx/ui/icons/Icons.kt +++ b/app/src/main/java/app/gyrolet/mpvrx/ui/icons/Icons.kt @@ -297,12 +297,17 @@ object Icons { val Refresh = Shared.Refresh val Remove = Shared.Remove val RemoveCircle = Shared.RemoveCircle + val Repeat = Shared.Repeat + val RepeatOn = Shared.RepeatOn + val RepeatOne = Shared.RepeatOne val ResetIso = Shared.ResetIso + val Restore = Shared.Restore val RoundedCorner = Shared.RoundedCorner val ScreenRotation = Shared.ScreenRotation val Screenshot = Shared.Screenshot val SdCard = Shared.SdCard val Search = Shared.Search + val Settings = Shared.Settings val Shadow = Shared.Shadow val Share = Shared.Share val Shuffle = Shared.Shuffle @@ -385,6 +390,7 @@ object Icons { val Repeat = Shared.Repeat val RepeatOn = Shared.RepeatOn val RepeatOne = Shared.RepeatOne + val Restore = Shared.Restore val Search = Shared.Search val Settings = Shared.Settings val Share = Shared.Share @@ -422,6 +428,9 @@ object Icons { val PictureInPictureAlt = Shared.PictureInPictureAlt val PlaylistAdd = Shared.PlaylistAdd val Repeat = Shared.Repeat + val RepeatOn = Shared.RepeatOn + val RepeatOne = Shared.RepeatOne + val Restore = Shared.Restore val ScreenRotation = Shared.ScreenRotation val Search = Shared.Search val Settings = Shared.Settings diff --git a/app/src/main/java/app/gyrolet/mpvrx/ui/player/AmbientShaderBuilder.kt b/app/src/main/java/app/gyrolet/mpvrx/ui/player/AmbientShaderBuilder.kt index a189dcaca..8261ab387 100644 --- a/app/src/main/java/app/gyrolet/mpvrx/ui/player/AmbientShaderBuilder.kt +++ b/app/src/main/java/app/gyrolet/mpvrx/ui/player/AmbientShaderBuilder.kt @@ -22,6 +22,7 @@ enum class AmbientVisualMode( ) { GLOW("Glow"), FRAME_EXTEND("Frame Extend"), + YOUTUBE("YouTube") } sealed interface AmbientShaderSpec { @@ -52,6 +53,11 @@ data class AmbientFrameExtendShaderSpec( val ecoMode: Boolean = false, ) : AmbientShaderSpec +data class AmbientYouTubeShaderSpec( + override val context: AmbientRenderContext, + override val shared: AmbientSharedShaderConfig, +) : AmbientShaderSpec + data class AmbientGlowPreset( val blurSamples: Int, val maxRadius: Float, @@ -257,6 +263,7 @@ object AmbientShaderBuilder { when (spec) { is AmbientGlowShaderSpec -> buildGlow(spec) is AmbientFrameExtendShaderSpec -> buildFrameExtend(spec) + is AmbientYouTubeShaderSpec -> buildYouTube(spec) } private fun buildGlow(spec: AmbientGlowShaderSpec): String = @@ -711,5 +718,67 @@ vec4 hook() { } """.trimIndent() } + + private fun buildYouTube(spec: AmbientYouTubeShaderSpec): String = + """ +//!HOOK OUTPUT +//!BIND HOOKED +//!DESC YouTube-Style Ambient Mode + +#define SCALE_X ${spec.context.scaleX} +#define SCALE_Y ${spec.context.scaleY} + +// 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() +} diff --git a/app/src/main/java/app/gyrolet/mpvrx/ui/player/PlayerViewModel.kt b/app/src/main/java/app/gyrolet/mpvrx/ui/player/PlayerViewModel.kt index 8f04dab33..c30e5075e 100644 --- a/app/src/main/java/app/gyrolet/mpvrx/ui/player/PlayerViewModel.kt +++ b/app/src/main/java/app/gyrolet/mpvrx/ui/player/PlayerViewModel.kt @@ -4011,6 +4011,7 @@ class PlayerViewModel( ) } AmbientVisualMode.FRAME_EXTEND -> applyFrameExtendPreset(AmbientShaderPresets.frameExtendFast) + AmbientVisualMode.YOUTUBE -> applyAmbientProfileYouTube() } } @@ -4031,6 +4032,7 @@ class PlayerViewModel( ) } AmbientVisualMode.FRAME_EXTEND -> applyFrameExtendPreset(AmbientShaderPresets.frameExtendBalanced) + AmbientVisualMode.YOUTUBE -> applyAmbientProfileYouTube() } } @@ -4051,6 +4053,7 @@ class PlayerViewModel( ) } AmbientVisualMode.FRAME_EXTEND -> applyFrameExtendPreset(AmbientShaderPresets.frameExtendHighQuality) + AmbientVisualMode.YOUTUBE -> applyAmbientProfileYouTube() } } @@ -4071,9 +4074,16 @@ class PlayerViewModel( ) } AmbientVisualMode.FRAME_EXTEND -> applyFrameExtendPreset(AmbientShaderPresets.frameExtendEco) + AmbientVisualMode.YOUTUBE -> applyAmbientProfileYouTube() } } + fun applyAmbientProfileYouTube() { + updateAmbientVisualMode(AmbientVisualMode.YOUTUBE) + // No other parameters are needed as YouTube mode has baked-in settings. + scheduleAmbientUpdate() + } + fun updateAmbientBatterySaver(enabled: Boolean) { _isAmbientBatterySaver.value = enabled playerPreferences.ambientBatterySaver.set(enabled) @@ -4279,6 +4289,11 @@ class PlayerViewModel( ditherNoise = ditherNoise, ecoMode = ecoMode, ) + AmbientVisualMode.YOUTUBE -> + AmbientYouTubeShaderSpec( + context = context, + shared = shared, + ) } return AmbientShaderBuilder.build(spec) diff --git a/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/PlayerControls.kt b/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/PlayerControls.kt index 96a212c45..d7795f02d 100644 --- a/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/PlayerControls.kt +++ b/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/PlayerControls.kt @@ -3,6 +3,7 @@ package app.gyrolet.mpvrx.ui.player.controls import app.gyrolet.mpvrx.ui.icons.Icon import app.gyrolet.mpvrx.ui.icons.Icons import app.gyrolet.mpvrx.ui.player.controls.components.AnimatedPlayPauseIcon +import app.gyrolet.mpvrx.ui.player.controls.components.PlayerLiquidTokens import androidx.activity.compose.LocalActivity import androidx.compose.animation.AnimatedVisibility @@ -142,6 +143,10 @@ import app.gyrolet.mpvrx.ui.player.controls.components.SpeedControlSlider import app.gyrolet.mpvrx.ui.player.controls.components.TextPlayerUpdate import app.gyrolet.mpvrx.ui.player.controls.components.VolumeSlider import app.gyrolet.mpvrx.ui.player.controls.components.sheets.toFixed +import app.gyrolet.mpvrx.ui.player.controls.components.LiquidPillButton +import com.kyant.backdrop.backdrops.rememberLayerBackdrop +import com.kyant.backdrop.backdrops.layerBackdrop +import app.gyrolet.mpvrx.ui.player.controls.components.LocalPlayerBackdrop import app.gyrolet.mpvrx.ui.theme.controlColor import app.gyrolet.mpvrx.ui.theme.playerRippleConfiguration import app.gyrolet.mpvrx.ui.theme.spacing @@ -189,6 +194,7 @@ fun PlayerControls( val aiEnabled by aiPreferences.enabled.collectAsState() val realtimeSubsEnabled by aiPreferences.realtimeSubsEnabled.collectAsState() val hideBackground by appearancePreferences.hidePlayerButtonsBackground.collectAsState() + val enableLiquidGlass by appearancePreferences.enableLiquidGlass.collectAsState() val playerPreferences = koinInject() val audioPreferences = koinInject() val showSystemStatusBar by playerPreferences.showSystemStatusBar.collectAsState() @@ -329,6 +335,8 @@ fun PlayerControls( DoubleTapToSeekOvals(doubleTapSeekAmount, seekText, showDoubleTapOvals, showSeekTime, showSeekTime, interactionSource) + val playerBackdrop = rememberLayerBackdrop() + Box( modifier = modifier.fillMaxSize(), ) { @@ -337,6 +345,24 @@ fun PlayerControls( speedMultiplier = animSpeed, animationState = videoOpenAnimState, ) + + if (enableLiquidGlass) { + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + Pair(0f, Color.Black), + Pair(.4f, Color.Transparent), + Pair(.6f, Color.Transparent), + Pair(1f, Color.Black), + ), + alpha = transparentOverlay, + ) + .layerBackdrop(playerBackdrop) + ) + } + if (statisticsPage == 6) { CustomStatsPageSixOverlay( modifier = @@ -351,6 +377,7 @@ fun PlayerControls( LocalRippleConfiguration provides playerRippleConfiguration, LocalPlayerButtonsClickEvent provides { resetControlsTimestamp = System.currentTimeMillis() }, LocalContentColor provides Color.White, + LocalPlayerBackdrop provides playerBackdrop, ) { CompositionLocalProvider( LocalLayoutDirection provides LayoutDirection.Ltr, @@ -370,14 +397,20 @@ fun PlayerControls( Modifier .fillMaxSize() .onSizeChanged { controlsLayoutHeightPx = it.height } - .background( - Brush.verticalGradient( - Pair(0f, Color.Black), - Pair(.4f, Color.Transparent), - Pair(.6f, Color.Transparent), - Pair(1f, Color.Black), - ), - alpha = transparentOverlay, + .then( + if (!enableLiquidGlass) { + Modifier.background( + Brush.verticalGradient( + Pair(0f, Color.Black), + Pair(.4f, Color.Transparent), + Pair(.6f, Color.Transparent), + Pair(1f, Color.Black), + ), + alpha = transparentOverlay, + ) + } else { + Modifier + } ) .then(safeAreaInsetModifier) .then(navigationBarBottomInsetModifier), @@ -796,6 +829,24 @@ fun PlayerControls( ) { leftCustomButtons.forEach { button -> key(button.id) { + if (enableLiquidGlass) { + LiquidPillButton( + onClick = { + resetControlsTimestamp = System.currentTimeMillis() + viewModel.callCustomButton(button.id) + }, + onLongClick = { + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + resetControlsTimestamp = System.currentTimeMillis() + viewModel.callCustomButtonLongPress(button.id) + }, + useGlass = true, + horizontalPadding = 12.dp, + height = 36.dp, + ) { + Text(text = button.label, style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.Bold), maxLines = 1, softWrap = false) + } + } else { val buttonInteractionSource = remember { MutableInteractionSource() } Surface( shape = CircleShape, @@ -829,6 +880,7 @@ fun PlayerControls( ) } } + } } } } @@ -858,6 +910,24 @@ fun PlayerControls( ) { rightCustomButtons.forEach { button -> key(button.id) { + if (enableLiquidGlass) { + LiquidPillButton( + onClick = { + resetControlsTimestamp = System.currentTimeMillis() + viewModel.callCustomButton(button.id) + }, + onLongClick = { + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + resetControlsTimestamp = System.currentTimeMillis() + viewModel.callCustomButtonLongPress(button.id) + }, + useGlass = true, + horizontalPadding = 12.dp, + height = 36.dp, + ) { + Text(text = button.label, style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.Bold), maxLines = 1, softWrap = false) + } + } else { val buttonInteractionSource = remember { MutableInteractionSource() } Surface( shape = CircleShape, @@ -891,6 +961,7 @@ fun PlayerControls( ) } } + } } } } @@ -921,6 +992,24 @@ fun PlayerControls( ) { customButtons.forEach { button -> key(button.id) { + if (enableLiquidGlass) { + LiquidPillButton( + onClick = { + resetControlsTimestamp = System.currentTimeMillis() + viewModel.callCustomButton(button.id) + }, + onLongClick = { + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + resetControlsTimestamp = System.currentTimeMillis() + viewModel.callCustomButtonLongPress(button.id) + }, + useGlass = true, + horizontalPadding = 12.dp, + height = 36.dp, + ) { + Text(text = button.label, style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.Bold), maxLines = 1, softWrap = false) + } + } else { val buttonInteractionSource = remember { MutableInteractionSource() } Surface( shape = CircleShape, @@ -954,6 +1043,7 @@ fun PlayerControls( ) } } + } } } } @@ -989,38 +1079,56 @@ fun PlayerControls( ) { val segment = currentSkippableSegment ?: return@AnimatedVisibility val segmentColor = segment.type.accentColor - val segmentSurfaceColor = - Color( - red = segmentColor.red * 0.30f, - green = segmentColor.green * 0.30f, - blue = segmentColor.blue * 0.30f, - alpha = 0.88f, - ) - val segmentBorderColor = - Color( - red = segmentColor.red * 0.72f, - green = segmentColor.green * 0.72f, - blue = segmentColor.blue * 0.72f, - alpha = 0.96f, - ) - Surface( - shape = RoundedCornerShape(999.dp), - color = segmentSurfaceColor, - border = BorderStroke(1.5.dp, segmentBorderColor), - modifier = - Modifier - .clip(RoundedCornerShape(999.dp)) - .clickable { - resetControlsTimestamp = System.currentTimeMillis() - viewModel.skipActiveSegment() - }, - ) { - Text( - text = segment.label, - style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.Bold), - color = segmentColor.copy(alpha = 1f), - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), - ) + if (enableLiquidGlass) { + LiquidPillButton( + onClick = { + resetControlsTimestamp = System.currentTimeMillis() + viewModel.skipActiveSegment() + }, + useGlass = true, + tint = segmentColor, + height = 40.dp, + horizontalPadding = 16.dp, + ) { + Text( + text = segment.label, + style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.Bold), + ) + } + } else { + val segmentSurfaceColor = + Color( + red = segmentColor.red * 0.30f, + green = segmentColor.green * 0.30f, + blue = segmentColor.blue * 0.30f, + alpha = 0.88f, + ) + val segmentBorderColor = + Color( + red = segmentColor.red * 0.72f, + green = segmentColor.green * 0.72f, + blue = segmentColor.blue * 0.72f, + alpha = 0.96f, + ) + Surface( + shape = RoundedCornerShape(999.dp), + color = segmentSurfaceColor, + border = BorderStroke(1.5.dp, segmentBorderColor), + modifier = + Modifier + .clip(RoundedCornerShape(999.dp)) + .clickable { + resetControlsTimestamp = System.currentTimeMillis() + viewModel.skipActiveSegment() + }, + ) { + Text( + text = segment.label, + style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.Bold), + color = segmentColor.copy(alpha = 1f), + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + } } } @@ -1059,7 +1167,95 @@ fun PlayerControls( ) if (playlistMode && viewModel.hasPlaylistSupport()) { - androidx.compose.foundation.layout.Row( + if (enableLiquidGlass) { + val backdrop = playerBackdrop + androidx.compose.foundation.layout.Row( + horizontalArrangement = Arrangement.spacedBy(24.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + app.gyrolet.mpvrx.ui.components.LiquidButton( + onClick = { + resetControlsTimestamp = System.currentTimeMillis() + if (viewModel.hasPrevious()) viewModel.playPrevious() + }, + backdrop = backdrop, + modifier = Modifier.size(56.dp), + useGlass = true, + surfaceColor = PlayerLiquidTokens.surfaceColor, + height = 56.dp, + horizontalPadding = 0.dp, + ) { + 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), + ) + } + + app.gyrolet.mpvrx.ui.components.LiquidButton( + onClick = { + resetControlsTimestamp = System.currentTimeMillis() + viewModel.pauseUnpause() + }, + backdrop = backdrop, + modifier = Modifier.size(64.dp), + useGlass = true, + surfaceColor = PlayerLiquidTokens.surfaceColor, + height = 64.dp, + horizontalPadding = 0.dp, + ) { + AnimatedPlayPauseIcon( + isPlaying = paused == false, + modifier = Modifier + .fillMaxSize() + .padding(MaterialTheme.spacing.medium), + tint = LocalContentColor.current, + ) + } + + app.gyrolet.mpvrx.ui.components.LiquidButton( + onClick = { + resetControlsTimestamp = System.currentTimeMillis() + if (viewModel.hasNext()) viewModel.playNext() + }, + backdrop = backdrop, + modifier = Modifier.size(56.dp), + useGlass = true, + surfaceColor = PlayerLiquidTokens.surfaceColor, + height = 56.dp, + horizontalPadding = 0.dp, + ) { + Icon( + imageVector = Icons.Default.SkipNext, + contentDescription = "Next", + tint = if (viewModel.hasNext()) { + 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), + ) + } + } + } else { + androidx.compose.foundation.layout.Row( horizontalArrangement = Arrangement.spacedBy(24.dp), verticalAlignment = Alignment.CenterVertically, ) { @@ -1215,8 +1411,32 @@ fun PlayerControls( ) } } + } } else { - Surface( + if (enableLiquidGlass) { + val backdrop = playerBackdrop + app.gyrolet.mpvrx.ui.components.LiquidButton( + onClick = { + resetControlsTimestamp = System.currentTimeMillis() + viewModel.pauseUnpause() + }, + backdrop = backdrop, + modifier = Modifier.size(64.dp), + useGlass = true, + surfaceColor = PlayerLiquidTokens.surfaceColor, + height = 64.dp, + horizontalPadding = 0.dp, + ) { + AnimatedPlayPauseIcon( + isPlaying = paused == false, + modifier = Modifier + .fillMaxSize() + .padding(MaterialTheme.spacing.medium), + tint = LocalContentColor.current, + ) + } + } else { + Surface( modifier = Modifier .size(64.dp) @@ -1255,8 +1475,9 @@ fun PlayerControls( .fillMaxSize() .padding(MaterialTheme.spacing.medium), tint = LocalContentColor.current, - ) - } + ) + } + } } } } @@ -1737,9 +1958,8 @@ private fun CustomStatsPageSixOverlay( batteryWattsText = battery.wattsText, batteryTempText = battery.tempText, hdrActive = runCatching { - val transfer = MPVLib.getPropertyString("video-params/transfer") - val primaries = MPVLib.getPropertyString("video-params/primaries") - if (transfer == "pq" || transfer == "hlg" || primaries == "bt.2020") "HDR Active" else "SDR" + val hdrProp = MPVLib.getPropertyString("hdr-active") + if (hdrProp == "yes") "HDR Active" else "SDR" }.getOrDefault("SDR"), ) diff --git a/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/PlayerControlsLandscape.kt b/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/PlayerControlsLandscape.kt index 0a48d1fb6..4afd2cca5 100644 --- a/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/PlayerControlsLandscape.kt +++ b/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/PlayerControlsLandscape.kt @@ -21,7 +21,12 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.ripple import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import org.koin.compose.koinInject +import app.gyrolet.mpvrx.preferences.AppearancePreferences +import app.gyrolet.mpvrx.preferences.preference.collectAsState +import app.gyrolet.mpvrx.ui.player.controls.components.LiquidPillButton import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -54,6 +59,8 @@ fun TopLeftPlayerControlsLandscape( ) { val playlistModeEnabled = viewModel.hasPlaylistSupport() val clickEvent = LocalPlayerButtonsClickEvent.current + val appearancePreferences = koinInject() + val enableLiquidGlass by appearancePreferences.enableLiquidGlass.collectAsState() Column( modifier = Modifier.width(IntrinsicSize.Max), @@ -70,71 +77,99 @@ fun TopLeftPlayerControlsLandscape( ) Column { - val titleInteractionSource = remember { MutableInteractionSource() } - - Box( - modifier = - Modifier - .height(45.dp) - .clip(CircleShape) - .clickable( - interactionSource = titleInteractionSource, - indication = ripple(bounded = true), - enabled = playlistModeEnabled, - onClick = { - clickEvent() - onOpenSheet(Sheets.Playlist) - }, - ), - ) { - 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), - ) - }, + if (enableLiquidGlass) { + LiquidPillButton( + onClick = { + clickEvent() + onOpenSheet(Sheets.Playlist) + }, + useGlass = true, + height = 45.dp, + horizontalPadding = MaterialTheme.spacing.medium, ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = - Modifier - .padding( - start = MaterialTheme.spacing.medium, - end = MaterialTheme.spacing.medium, - top = MaterialTheme.spacing.small, - bottom = 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( - mediaTitle ?: "", + " • $playlistInfo", maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.weight(1f, fill = false), + overflow = TextOverflow.Visible, + style = MaterialTheme.typography.bodySmall, ) - viewModel.getPlaylistInfo()?.let { playlistInfo -> + } + } + } else { + val titleInteractionSource = remember { MutableInteractionSource() } + + Box( + modifier = + Modifier + .height(45.dp) + .clip(CircleShape) + .clickable( + interactionSource = titleInteractionSource, + indication = ripple(bounded = true), + enabled = playlistModeEnabled, + onClick = { + clickEvent() + onOpenSheet(Sheets.Playlist) + }, + ), + ) { + 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), + ) + }, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .padding( + start = MaterialTheme.spacing.medium, + end = MaterialTheme.spacing.medium, + top = MaterialTheme.spacing.small, + bottom = MaterialTheme.spacing.small, + ), + ) { Text( - " • $playlistInfo", + mediaTitle ?: "", maxLines = 1, - overflow = TextOverflow.Visible, - style = MaterialTheme.typography.bodySmall, + 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, + ) + } } } } diff --git a/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/PlayerControlsPortrait.kt b/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/PlayerControlsPortrait.kt index 934e033eb..122833bfa 100644 --- a/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/PlayerControlsPortrait.kt +++ b/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/PlayerControlsPortrait.kt @@ -23,7 +23,12 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.ripple import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import org.koin.compose.koinInject +import app.gyrolet.mpvrx.preferences.AppearancePreferences +import app.gyrolet.mpvrx.preferences.preference.collectAsState +import app.gyrolet.mpvrx.ui.player.controls.components.LiquidPillButton import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -57,6 +62,8 @@ fun TopPlayerControlsPortrait( ) { val playlistModeEnabled = viewModel.hasPlaylistSupport() val clickEvent = LocalPlayerButtonsClickEvent.current + val appearancePreferences = koinInject() + val enableLiquidGlass by appearancePreferences.enableLiquidGlass.collectAsState() Column( modifier = Modifier @@ -78,23 +85,17 @@ fun TopPlayerControlsPortrait( Column( modifier = Modifier.padding(start = 4.dp), ) { - val titleInteractionSource = remember { androidx.compose.foundation.interaction.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, - onClick = { - clickEvent() - onOpenSheet(Sheets.Playlist) - }, - enabled = playlistModeEnabled, - border = if (hideBackground) null else BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)), - modifier = Modifier.height(45.dp), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(horizontal = 14.dp), + if (enableLiquidGlass) { + LiquidPillButton( + onClick = { + if (playlistModeEnabled) { + clickEvent() + onOpenSheet(Sheets.Playlist) + } + }, + useGlass = true, + height = 45.dp, + horizontalPadding = 14.dp, ) { Text( mediaTitle ?: "", @@ -108,10 +109,45 @@ fun TopPlayerControlsPortrait( " • $playlistInfo", maxLines = 1, style = MaterialTheme.typography.bodySmall, - color = LocalContentColor.current.copy(alpha = 0.7f), ) } } + } else { + val titleInteractionSource = remember { androidx.compose.foundation.interaction.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, + onClick = { + clickEvent() + onOpenSheet(Sheets.Playlist) + }, + enabled = playlistModeEnabled, + border = if (hideBackground) null else BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)), + modifier = Modifier.height(45.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 14.dp), + ) { + 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, + style = MaterialTheme.typography.bodySmall, + color = LocalContentColor.current.copy(alpha = 0.7f), + ) + } + } + } } } } diff --git a/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/PlayerControlsShared.kt b/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/PlayerControlsShared.kt index 16c3befe2..b4d3b1b38 100644 --- a/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/PlayerControlsShared.kt +++ b/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/PlayerControlsShared.kt @@ -51,8 +51,11 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import app.gyrolet.mpvrx.preferences.AdvancedPreferences +import app.gyrolet.mpvrx.preferences.AppearancePreferences import app.gyrolet.mpvrx.preferences.PlayerButton import app.gyrolet.mpvrx.preferences.preference.collectAsState +import app.gyrolet.mpvrx.ui.player.controls.components.LiquidIconButton +import app.gyrolet.mpvrx.ui.player.controls.components.LiquidPillButton import app.gyrolet.mpvrx.ui.player.Panels import app.gyrolet.mpvrx.ui.player.PlayerActivity import app.gyrolet.mpvrx.ui.player.PlayerViewModel @@ -106,55 +109,20 @@ fun RenderPlayerButton( PlayerButton.VIDEO_TITLE -> { val playlistModeEnabled = viewModel.hasPlaylistSupport() + val appearancePreferences = koinInject() + val enableLiquidGlass by appearancePreferences.enableLiquidGlass.collectAsState() - 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), - ) + if (enableLiquidGlass) { + LiquidPillButton( + onClick = { + if (playlistModeEnabled) { + clickEvent() + onOpenSheet(Sheets.Playlist) + } }, - 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, - ), + modifier = Modifier.height(buttonSize), + useGlass = true, + horizontalPadding = MaterialTheme.spacing.extraSmall, ) { Text( mediaTitle ?: "", @@ -172,6 +140,73 @@ fun RenderPlayerButton( ) } } + } else { + 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, + ), + ) { + 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, + ) + } + } + } } } @@ -187,36 +222,20 @@ fun RenderPlayerButton( } PlayerButton.PLAYBACK_SPEED -> { + val appearancePreferences = koinInject() + val enableLiquidGlass by appearancePreferences.enableLiquidGlass.collectAsState() + 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 (enableLiquidGlass) { + LiquidPillButton( + onClick = { + clickEvent() + onOpenSheet(Sheets.PlaybackSpeed) + }, + modifier = Modifier.height(buttonSize), + useGlass = true, + tint = MaterialTheme.colorScheme.primary, + horizontalPadding = MaterialTheme.spacing.small, ) { AppSymbolIcon( imageVector = Icons.Default.Speed, @@ -230,6 +249,50 @@ fun RenderPlayerButton( 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, + ), + ) { + AppSymbolIcon( + 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, + ) + } + } } } else { ControlsButton( @@ -242,48 +305,18 @@ fun RenderPlayerButton( } 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), - ) + val appearancePreferences = koinInject() + val enableLiquidGlass by appearancePreferences.enableLiquidGlass.collectAsState() + + if (enableLiquidGlass) { + LiquidPillButton( + onClick = { + clickEvent() + onOpenSheet(Sheets.Decoders) }, - 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, - ), + modifier = Modifier.height(buttonSize), + useGlass = true, + horizontalPadding = MaterialTheme.spacing.medium, ) { Text( text = decoder.title, @@ -292,6 +325,58 @@ fun RenderPlayerButton( 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.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, + ) + } + } } } @@ -334,8 +419,66 @@ fun RenderPlayerButton( label = "FrameNavExpandCollapse", ) { expanded -> if (expanded) { - Surface( - shape = MaterialTheme.shapes.extraLarge, + val appearancePreferences = koinInject() + val enableLiquidGlass by appearancePreferences.enableLiquidGlass.collectAsState() + + if (enableLiquidGlass) { + LiquidPillButton( + onClick = {}, + modifier = Modifier.height(buttonSize), + useGlass = true, + horizontalPadding = 4.dp, + ) { + LiquidIconButton( + icon = Icons.Default.FastRewind, + onClick = { + viewModel.frameStepBackward() + viewModel.resetFrameNavigationTimer() + }, + tint = if (hideBackground) controlColor else MaterialTheme.colorScheme.onSurface, + size = buttonSize - 4.dp, + iconSize = 20.dp, + ) + if (isSnapshotLoading) { + Box( + modifier = Modifier + .size(buttonSize - 4.dp) + .padding(horizontal = 2.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = if (hideBackground) controlColor else MaterialTheme.colorScheme.primary, + ) + } + } else { + LiquidIconButton( + icon = Icons.Default.Aperture, + onClick = { + viewModel.takeSnapshot(context) + viewModel.resetFrameNavigationTimer() + }, + onLongClick = { onOpenSheet(Sheets.FrameNavigation) }, + tint = if (hideBackground) controlColor else MaterialTheme.colorScheme.onSurface, + size = buttonSize - 4.dp, + iconSize = 20.dp, + ) + } + LiquidIconButton( + icon = Icons.Default.FastForward, + onClick = { + viewModel.frameStepForward() + viewModel.resetFrameNavigationTimer() + }, + tint = if (hideBackground) controlColor else MaterialTheme.colorScheme.onSurface, + size = buttonSize - 4.dp, + iconSize = 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), @@ -434,6 +577,7 @@ fun RenderPlayerButton( } } } + } } else { // Collapsed: Show camera icon button ControlsButton( @@ -448,9 +592,40 @@ fun RenderPlayerButton( } PlayerButton.VIDEO_ZOOM -> { + val appearancePreferences = koinInject() + val enableLiquidGlass by appearancePreferences.enableLiquidGlass.collectAsState() + if (kotlin.math.abs(currentZoom) >= 0.005f) { - @OptIn(ExperimentalFoundationApi::class) - Surface( + if (enableLiquidGlass) { + LiquidPillButton( + onClick = { + clickEvent() + onOpenSheet(Sheets.VideoZoom) + }, + onLongClick = { + clickEvent() + viewModel.resetVideoZoom() + }, + modifier = Modifier.height(buttonSize), + useGlass = true, + tint = MaterialTheme.colorScheme.primary, + horizontalPadding = MaterialTheme.spacing.small, + ) { + AppSymbolIcon( + 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, + ) + } + } 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, @@ -497,6 +672,7 @@ fun RenderPlayerButton( ) } } + } } else { ControlsButton( Icons.Default.ZoomIn, @@ -672,7 +848,20 @@ fun RenderPlayerButton( } else { if (isVerticalFlipped) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface } - Surface( + val appearancePreferences = koinInject() + val enableLiquidGlass by appearancePreferences.enableLiquidGlass.collectAsState() + + if (enableLiquidGlass) { + LiquidIconButton( + icon = Icons.Default.Flip, + onClick = viewModel::toggleVerticalFlip, + tint = vFlipColor, + size = buttonSize, + iconSize = 20.dp, + modifier = Modifier.rotate(90f), + ) + } else { + Surface( shape = CircleShape, color = if (hideBackground) Color.Transparent else MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.55f), contentColor = vFlipColor, @@ -694,6 +883,7 @@ fun RenderPlayerButton( ) } } + } } PlayerButton.AB_LOOP -> { @@ -701,6 +891,8 @@ fun RenderPlayerButton( val isExpanded = abLoop.isExpanded val loopA = abLoop.a val loopB = abLoop.b + val appearancePreferences = koinInject() + val enableLiquidGlass by appearancePreferences.enableLiquidGlass.collectAsState() AnimatedContent( targetState = isExpanded, @@ -712,7 +904,51 @@ fun RenderPlayerButton( label = "ABLoopExpandCollapse", ) { expanded -> if (expanded) { - Surface( + if (enableLiquidGlass) { + LiquidPillButton( + onClick = {}, + modifier = Modifier.height(buttonSize), + useGlass = true, + horizontalPadding = 4.dp, + ) { + LiquidPillButton( + onClick = { viewModel.setLoopA() }, + useGlass = true, + tint = if (loopA != null) MaterialTheme.colorScheme.onTertiaryContainer else (if (hideBackground) controlColor else MaterialTheme.colorScheme.onSurface), + surfaceColor = if (loopA != null) MaterialTheme.colorScheme.tertiaryContainer else Color.Unspecified, + height = buttonSize - 4.dp, + horizontalPadding = if (loopA != null) 8.dp else 16.dp, + ) { + Text( + text = if (loopA != null) viewModel.formatTimestamp(loopA) else "A", + style = MaterialTheme.typography.labelLarge, + ) + } + LiquidIconButton( + icon = Icons.Default.Close, + onClick = { + viewModel.clearABLoop() + viewModel.toggleABLoopExpanded() + }, + size = buttonSize - 4.dp, + iconSize = 16.dp, + ) + LiquidPillButton( + onClick = { viewModel.setLoopB() }, + useGlass = true, + tint = if (loopB != null) MaterialTheme.colorScheme.onTertiaryContainer else (if (hideBackground) controlColor else MaterialTheme.colorScheme.onSurface), + surfaceColor = if (loopB != null) MaterialTheme.colorScheme.tertiaryContainer else Color.Unspecified, + height = buttonSize - 4.dp, + horizontalPadding = if (loopB != null) 8.dp else 16.dp, + ) { + Text( + text = if (loopB != null) viewModel.formatTimestamp(loopB) else "B", + style = MaterialTheme.typography.labelLarge, + ) + } + } + } 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)), @@ -795,9 +1031,18 @@ fun RenderPlayerButton( } } } + } } else { // Collapsed: Show the custom A-B loop icon - Surface( + if (enableLiquidGlass) { + LiquidIconButton( + icon = Icons.Default.Flip, + onClick = viewModel::toggleABLoopExpanded, + tint = if (loopA != null && loopB != null) MaterialTheme.colorScheme.primary else (if (hideBackground) controlColor else MaterialTheme.colorScheme.onSurface), + size = buttonSize, + ) + } 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)), @@ -815,6 +1060,7 @@ fun RenderPlayerButton( ) } } + } } } } @@ -830,8 +1076,31 @@ fun RenderPlayerButton( PlayerButton.AMBIENT_MODE -> { val isAmbientEnabled by viewModel.isAmbientEnabled.collectAsState() - @OptIn(ExperimentalFoundationApi::class) - Surface( + val appearancePreferences = koinInject() + val enableLiquidGlass by appearancePreferences.enableLiquidGlass.collectAsState() + + if (enableLiquidGlass) { + LiquidIconButton( + icon = if (isAmbientEnabled) Icons.Filled.BlurOn else Icons.Outlined.BlurOff, + onClick = { + clickEvent() + viewModel.toggleAmbientMode() + }, + onLongClick = { + clickEvent() + onOpenSheet(Sheets.AmbientConfig) + }, + tint = if (isAmbientEnabled) { + MaterialTheme.colorScheme.primary + } else { + if (hideBackground) controlColor else MaterialTheme.colorScheme.onSurface + }, + size = buttonSize, + iconSize = 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) { @@ -865,11 +1134,47 @@ fun RenderPlayerButton( ) } } + } } PlayerButton.TIME_NETWORK -> { val stat by rememberTimeAndNetworkStat() - Surface( + val appearancePreferences = koinInject() + val enableLiquidGlass by appearancePreferences.enableLiquidGlass.collectAsState() + + if (enableLiquidGlass) { + LiquidPillButton( + onClick = { + clickEvent() + if (statisticsPage == 6) { + advancedPreferences.enabledStatisticsPage.set(0) + } else { + if (statisticsPage in 1..5) { + MPVLib.command("script-binding", "stats/display-stats-toggle") + } + advancedPreferences.enabledStatisticsPage.set(6) + } + onOpenSheet(Sheets.None) + }, + modifier = Modifier.height(buttonSize), + useGlass = true, + horizontalPadding = MaterialTheme.spacing.small, + ) { + AppSymbolIcon( + imageVector = Icons.Default.AccessTime, + contentDescription = "Time and Network", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp), + ) + Text( + text = "${stat.time} • ${stat.network}", + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } 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, @@ -915,9 +1220,10 @@ fun RenderPlayerButton( ) } } + } } - PlayerButton.NONE -> { /* Do nothing */ + PlayerButton.NONE, PlayerButton.VIDEO_FILTERS -> { /* Do nothing */ } } } diff --git a/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/PlayerPanels.kt b/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/PlayerPanels.kt index 8b70ba407..d6abce034 100644 --- a/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/PlayerPanels.kt +++ b/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/PlayerPanels.kt @@ -23,6 +23,10 @@ import app.gyrolet.mpvrx.ui.player.controls.components.panels.LuaScriptsPanel import app.gyrolet.mpvrx.ui.player.controls.components.panels.SubtitleDelayPanel import app.gyrolet.mpvrx.ui.player.controls.components.panels.SubtitleSettingsPanel import app.gyrolet.mpvrx.ui.player.controls.components.panels.VideoSettingsPanel +import org.koin.compose.koinInject +import app.gyrolet.mpvrx.preferences.AppearancePreferences +import app.gyrolet.mpvrx.preferences.preference.collectAsState +import androidx.compose.runtime.getValue @Composable fun PlayerPanels( @@ -72,8 +76,11 @@ fun PlayerPanels( val CARDS_MAX_WIDTH = 420.dp val panelCardsColors: @Composable () -> CardColors = { + val preferences = koinInject() + val enableLiquidGlass by preferences.enableLiquidGlass.collectAsState() + // Higher alpha for better readability in panels (less transparent) - val alpha = 0.85f + val alpha = if (enableLiquidGlass) 0.1f else 0.85f CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surfaceContainer.copy(alpha = alpha), diff --git a/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/components/AdaptiveControlsButton.kt b/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/components/AdaptiveControlsButton.kt new file mode 100644 index 000000000..c92dd0685 --- /dev/null +++ b/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/components/AdaptiveControlsButton.kt @@ -0,0 +1,229 @@ +package app.gyrolet.mpvrx.ui.player.controls.components + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +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.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +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.unit.Dp +import androidx.compose.ui.unit.dp +import app.gyrolet.mpvrx.preferences.AppearancePreferences +import app.gyrolet.mpvrx.preferences.preference.collectAsState +import app.gyrolet.mpvrx.ui.components.LiquidButton +import app.gyrolet.mpvrx.ui.icons.AppIcon +import app.gyrolet.mpvrx.ui.theme.spacing +import com.kyant.backdrop.Backdrop +import com.kyant.backdrop.backdrops.rememberLayerBackdrop +import org.koin.compose.koinInject + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun AdaptiveControlsButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + icon: AppIcon? = null, + onLongClick: () -> Unit = {}, + text: String? = null, + title: String? = null, + color: Color? = null, + surfaceColor: Color = Color.Unspecified, + buttonSize: Dp = 40.dp, + useGlass: Boolean = true, + backdrop: Backdrop = rememberLayerBackdrop() +) { + val preferences = koinInject() + val enableLiquidGlass by preferences.enableLiquidGlass.collectAsState() + val clickEvent = app.gyrolet.mpvrx.ui.player.controls.LocalPlayerButtonsClickEvent.current + + if (enableLiquidGlass) { + val resolvedTint = color ?: PlayerLiquidTokens.contentColor + val resolvedSurface = if (surfaceColor != Color.Unspecified) surfaceColor else PlayerLiquidTokens.surfaceColor + + LiquidButton( + onClick = { + clickEvent() + onClick() + }, + onLongClick = onLongClick, + backdrop = backdrop, + modifier = modifier.height(buttonSize).widthIn(min = buttonSize), + tint = resolvedTint, + surfaceColor = resolvedSurface, + height = buttonSize, + horizontalPadding = if (text != null) 8.dp else 0.dp, + spacing = 4.dp, + useGlass = useGlass, + ) { + if (icon != null) { + app.gyrolet.mpvrx.ui.icons.Icon( + imageVector = icon, + contentDescription = title ?: text, + tint = resolvedTint, + modifier = Modifier.size(PlayerLiquidTokens.IconSize), + ) + } + if (text != null) { + Text( + text = text, + style = MaterialTheme.typography.labelLarge, + color = resolvedTint, + maxLines = 1, + ) + } + } + } else { + val hideBackground by preferences.hidePlayerButtonsBackground.collectAsState() + val interactionSource = remember { MutableInteractionSource() } + + Surface( + modifier = modifier + .clip(CircleShape) + .combinedClickable( + onClick = { + clickEvent() + onClick() + }, + onLongClick = onLongClick, + interactionSource = interactionSource, + indication = ripple(), + ), + shape = CircleShape, + color = if (hideBackground) Color.Transparent else if (surfaceColor != Color.Unspecified) surfaceColor else MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.55f), + contentColor = color ?: MaterialTheme.colorScheme.onSurface, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + border = if (hideBackground || !useGlass) null else BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.40f)), + ) { + Row( + modifier = Modifier + .padding(horizontal = if (text != null) 8.dp else 0.dp) + .height(buttonSize) + .widthIn(min = buttonSize), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + if (icon != null) { + app.gyrolet.mpvrx.ui.icons.Icon( + imageVector = icon, + contentDescription = title ?: text, + tint = color ?: MaterialTheme.colorScheme.onSurface, + modifier = Modifier + .padding(if (text == null) MaterialTheme.spacing.small else 0.dp) + .size(20.dp), + ) + } + if (text != null) { + if (icon != null) Spacer(Modifier.width(4.dp)) + Text( + text = text, + style = MaterialTheme.typography.labelLarge, + color = color ?: MaterialTheme.colorScheme.onSurface, + maxLines = 1, + ) + } + } + } + } +} + +@Composable +fun AdaptiveControlsContainer( + onClick: () -> Unit, + modifier: Modifier = Modifier, + onLongClick: () -> Unit = {}, + color: Color = Color.Unspecified, + surfaceColor: Color = Color.Unspecified, + isInteractive: Boolean = true, + useGlass: Boolean = true, + hideBackground: Boolean = false, + buttonSize: Dp = 40.dp, + spacing: Dp = 8.dp, + horizontalPadding: Dp? = null, + backdrop: Backdrop = rememberLayerBackdrop(), + content: @Composable RowScope.() -> Unit +) { + val preferences = koinInject() + val enableLiquidGlass by preferences.enableLiquidGlass.collectAsState() + val clickEvent = app.gyrolet.mpvrx.ui.player.controls.LocalPlayerButtonsClickEvent.current + + if (enableLiquidGlass) { + LiquidPillButton( + onClick = { + if (isInteractive) clickEvent() + onClick() + }, + onLongClick = onLongClick, + modifier = modifier, + isInteractive = isInteractive, + useGlass = useGlass, + tint = if (color != Color.Unspecified) color else PlayerLiquidTokens.contentColor, + surfaceColor = if (surfaceColor != Color.Unspecified) surfaceColor else PlayerLiquidTokens.surfaceColor, + height = buttonSize, + spacing = spacing, + horizontalPadding = horizontalPadding ?: 8.dp, + backdrop = backdrop, + content = content + ) + } else { + Surface( + shape = CircleShape, + color = if (hideBackground) Color.Transparent else if (surfaceColor != Color.Unspecified) surfaceColor else MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.55f), + contentColor = if (color != Color.Unspecified) color else MaterialTheme.colorScheme.onSurface, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + border = if (hideBackground || !useGlass) null else BorderStroke( + 1.dp, + MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.40f), + ), + modifier = modifier + .height(buttonSize) + .clip(CircleShape) + .then( + if (isInteractive) { + @OptIn(ExperimentalFoundationApi::class) + Modifier.combinedClickable( + interactionSource = remember { MutableInteractionSource() }, + indication = ripple(bounded = true), + onClick = { + clickEvent() + onClick() + }, + onLongClick = onLongClick + ) + } else { + Modifier + } + ), + ) { + Box(contentAlignment = Alignment.Center) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = horizontalPadding ?: MaterialTheme.spacing.small), + horizontalArrangement = Arrangement.spacedBy(spacing), + content = content + ) + } + } + } +} diff --git a/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/components/ControlsButton.kt b/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/components/ControlsButton.kt index e512ac9d5..b0fd62448 100644 --- a/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/components/ControlsButton.kt +++ b/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/components/ControlsButton.kt @@ -20,16 +20,22 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import app.gyrolet.mpvrx.preferences.AppearancePreferences import app.gyrolet.mpvrx.preferences.preference.collectAsState +import app.gyrolet.mpvrx.ui.components.LiquidButton import app.gyrolet.mpvrx.ui.icons.AppIcon import app.gyrolet.mpvrx.ui.icons.Icon import app.gyrolet.mpvrx.ui.icons.Icons import app.gyrolet.mpvrx.ui.player.controls.LocalPlayerButtonsClickEvent import app.gyrolet.mpvrx.ui.theme.spacing +import com.kyant.backdrop.backdrops.rememberLayerBackdrop +import com.kyant.backdrop.Backdrop import org.koin.compose.koinInject +val LocalPlayerBackdrop = androidx.compose.runtime.compositionLocalOf { null } + @Suppress("ModifierClickableOrder") @OptIn(ExperimentalFoundationApi::class) @Composable @@ -40,49 +46,76 @@ fun ControlsButton( onLongClick: () -> Unit = {}, title: String? = null, color: Color? = null, + buttonSize: Dp = 45.dp, + backdrop: Backdrop? = null, ) { - val interactionSource = remember { MutableInteractionSource() } val appearancePreferences = koinInject() + val enableLiquidGlass by appearancePreferences.enableLiquidGlass.collectAsState() val hideBackground by appearancePreferences.hidePlayerButtonsBackground.collectAsState() - val clickEvent = LocalPlayerButtonsClickEvent.current - 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), - ) + val resolvedBackdrop = backdrop ?: LocalPlayerBackdrop.current ?: rememberLayerBackdrop() + + if (enableLiquidGlass) { + LiquidButton( + onClick = { + clickEvent() + onClick() }, - ) { - Icon( - imageVector = icon, - contentDescription = title, - tint = color ?: MaterialTheme.colorScheme.onSurface, + onLongClick = onLongClick, + backdrop = resolvedBackdrop, + modifier = modifier.size(buttonSize), + tint = color ?: Color.White, + surfaceColor = PlayerLiquidTokens.surfaceColor, + height = buttonSize, + horizontalPadding = 0.dp, + ) { + Icon( + imageVector = icon, + contentDescription = title, + tint = color ?: Color.White, + modifier = Modifier.size(20.dp), + ) + } + } else { + val interactionSource = remember { MutableInteractionSource() } + Surface( modifier = - Modifier - .padding(MaterialTheme.spacing.small) - .size(20.dp), - ) + 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.40f), + ) + }, + ) { + Icon( + imageVector = icon, + contentDescription = title, + tint = color ?: MaterialTheme.colorScheme.onSurface, + modifier = + Modifier + .padding(MaterialTheme.spacing.small) + .size(20.dp), + ) + } } } diff --git a/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/components/CurrentChapter.kt b/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/components/CurrentChapter.kt index 4baaaefd2..7f4e21325 100644 --- a/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/components/CurrentChapter.kt +++ b/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/components/CurrentChapter.kt @@ -28,6 +28,7 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import app.gyrolet.mpvrx.ui.player.controls.components.LiquidPillButton import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -51,8 +52,67 @@ fun CurrentChapter( onClick: () -> Unit = {}, ) { val appearancePreferences = koinInject() + val enableLiquidGlass by appearancePreferences.enableLiquidGlass.collectAsState() - Surface( + if (enableLiquidGlass) { + LiquidPillButton( + onClick = onClick, + modifier = modifier.height(45.dp).widthIn(max = 220.dp), + useGlass = true, + horizontalPadding = MaterialTheme.spacing.medium, + ) { + 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 = 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(), + ) + } + } + } + } + } else { + Surface( modifier = modifier .height(45.dp) @@ -123,6 +183,7 @@ fun CurrentChapter( } } } + } } diff --git a/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/components/PlayerLiquidControls.kt b/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/components/PlayerLiquidControls.kt new file mode 100644 index 000000000..57ce95830 --- /dev/null +++ b/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/components/PlayerLiquidControls.kt @@ -0,0 +1,128 @@ +package app.gyrolet.mpvrx.ui.player.controls.components + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.size +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.isSpecified +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import app.gyrolet.mpvrx.ui.components.LiquidButton +import app.gyrolet.mpvrx.ui.icons.AppIcon +import app.gyrolet.mpvrx.ui.icons.Icon +import com.kyant.backdrop.Backdrop +import com.kyant.backdrop.backdrops.rememberLayerBackdrop + +object PlayerLiquidTokens { + val ButtonSize: Dp = 40.dp + val CenterButtonSize: Dp = 72.dp + val IconSize: Dp = 22.dp + val CenterIconSize: Dp = 34.dp + val PillHeight: Dp = 40.dp + + val contentColor: Color + @Composable get() = Color.White + + val disabledContentColor: Color + @Composable get() = Color.White.copy(alpha = 0.45f) + + val selectedContentColor: Color + @Composable get() = MaterialTheme.colorScheme.primary + + val surfaceColor: Color + @Composable get() = Color.Black.copy(alpha = 0.26f) + + val selectedSurfaceColor: Color + @Composable get() = MaterialTheme.colorScheme.primary.copy(alpha = 0.22f) +} + +@Composable +fun LiquidIconButton( + icon: AppIcon, + onClick: () -> Unit, + modifier: Modifier = Modifier, + onLongClick: () -> Unit = {}, + title: String? = null, + tint: Color = PlayerLiquidTokens.contentColor, + surfaceColor: Color = PlayerLiquidTokens.surfaceColor, + size: Dp = PlayerLiquidTokens.ButtonSize, + iconSize: Dp = PlayerLiquidTokens.IconSize, + spacing: Dp = 8.dp, + useGlass: Boolean = true, + backdrop: Backdrop? = null, +) { + val resolvedBackdrop = backdrop ?: LocalPlayerBackdrop.current ?: rememberLayerBackdrop() + CompositionLocalProvider(LocalContentColor provides tint) { + LiquidButton( + onClick = onClick, + onLongClick = onLongClick, + backdrop = resolvedBackdrop, + modifier = modifier.requiredSize(size), + tint = tint, + surfaceColor = surfaceColor, + height = size, + horizontalPadding = 0.dp, + spacing = spacing, + useGlass = useGlass, + ) { + Icon( + imageVector = icon, + contentDescription = title, + tint = tint, + modifier = Modifier.size(iconSize), + ) + } + } +} + +@Composable +fun LiquidPillButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + onLongClick: () -> Unit = {}, + isInteractive: Boolean = true, + tint: Color = PlayerLiquidTokens.contentColor, + surfaceColor: Color = PlayerLiquidTokens.surfaceColor, + height: Dp = PlayerLiquidTokens.PillHeight, + spacing: Dp = 8.dp, + horizontalPadding: Dp = 16.dp, + useGlass: Boolean = true, + backdrop: Backdrop? = null, + content: @Composable RowScope.() -> Unit, +) { + val resolvedTint = if (tint.isSpecified) tint else PlayerLiquidTokens.contentColor + val resolvedBackdrop = backdrop ?: LocalPlayerBackdrop.current ?: rememberLayerBackdrop() + CompositionLocalProvider(LocalContentColor provides resolvedTint) { + LiquidButton( + onClick = onClick, + onLongClick = onLongClick, + backdrop = resolvedBackdrop, + modifier = modifier, + tint = resolvedTint, + surfaceColor = surfaceColor, + isInteractive = isInteractive, + useGlass = useGlass, + height = height, + horizontalPadding = horizontalPadding, + spacing = spacing, + content = content, + ) + } +} + +@Composable +fun LiquidActionRow( + modifier: Modifier = Modifier, + contentColor: Color = PlayerLiquidTokens.contentColor, + content: @Composable RowScope.() -> Unit, +) { + CompositionLocalProvider(LocalContentColor provides contentColor) { + Row(modifier = modifier, content = content) + } +} diff --git a/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/components/PlayerUpdates.kt b/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/components/PlayerUpdates.kt index 9b4e31f80..acda127c8 100644 --- a/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/components/PlayerUpdates.kt +++ b/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/components/PlayerUpdates.kt @@ -18,48 +18,72 @@ 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 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.Shadow +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import app.gyrolet.mpvrx.R +import app.gyrolet.mpvrx.preferences.AppearancePreferences +import app.gyrolet.mpvrx.preferences.preference.collectAsState +import app.gyrolet.mpvrx.ui.components.LiquidButton import app.gyrolet.mpvrx.ui.theme.spacing +import com.kyant.backdrop.Backdrop +import com.kyant.backdrop.backdrops.rememberLayerBackdrop +import org.koin.compose.koinInject private val tabularFigures = "tnum" @Composable fun PlayerUpdate( modifier: Modifier = Modifier, + backdrop: Backdrop = rememberLayerBackdrop(), 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, + val preferences = koinInject() + val enableLiquidGlass by preferences.enableLiquidGlass.collectAsState() + + if (enableLiquidGlass) { + LiquidButton( + onClick = {}, + backdrop = backdrop, + isInteractive = false, + modifier = modifier.height(45.dp) ) { content() } + } else { + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.35f), + contentColor = MaterialTheme.colorScheme.onSurface, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + border = BorderStroke( + 1.dp, + MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.25f), + ), + modifier = modifier + .height(45.dp) + .animateContentSize(), + ) { + Box( + modifier = Modifier.padding( + vertical = MaterialTheme.spacing.small, + horizontal = MaterialTheme.spacing.medium, + ), + contentAlignment = Alignment.Center, + ) { + content() + } + } } } @@ -68,13 +92,20 @@ fun TextPlayerUpdate( text: String, modifier: Modifier = Modifier, ) { - val stableTextStyle = MaterialTheme.typography.bodyMedium.copy(fontFeatureSettings = tabularFigures) + val stableTextStyle = MaterialTheme.typography.bodyMedium.copy( + fontFeatureSettings = tabularFigures, + shadow = Shadow( + color = Color.Black.copy(alpha = 0.8f), + offset = Offset(2f, 2f), + blurRadius = 4f + ) + ) PlayerUpdate(modifier) { Text( text = text, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.onSurface, + color = Color.White, style = stableTextStyle, ) } @@ -99,7 +130,14 @@ fun SeekPlayerUpdate( seekDelta: String, modifier: Modifier = Modifier, ) { - val stableTextStyle = MaterialTheme.typography.bodyMedium.copy(fontFeatureSettings = tabularFigures) + val stableTextStyle = MaterialTheme.typography.bodyMedium.copy( + fontFeatureSettings = tabularFigures, + shadow = Shadow( + color = Color.Black.copy(alpha = 0.8f), + offset = Offset(2f, 2f), + blurRadius = 4f + ) + ) PlayerUpdate(modifier) { Row( verticalAlignment = Alignment.CenterVertically, @@ -108,7 +146,7 @@ fun SeekPlayerUpdate( text = currentTime, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.onSurface, + color = Color.White, style = stableTextStyle, ) @@ -117,7 +155,7 @@ fun SeekPlayerUpdate( fontWeight = FontWeight.Normal, textAlign = TextAlign.Center, style = stableTextStyle, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + color = Color.White.copy(alpha = 0.8f), ) } } diff --git a/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/components/Seekbar.kt b/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/components/Seekbar.kt index b64df6e91..9737a67cc 100644 --- a/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/components/Seekbar.kt +++ b/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/components/Seekbar.kt @@ -59,6 +59,31 @@ import app.gyrolet.mpvrx.ui.player.SkipSegment import app.gyrolet.mpvrx.ui.player.SkipSegmentType import app.gyrolet.mpvrx.ui.theme.AppMotion import app.gyrolet.mpvrx.ui.theme.spacing +import app.gyrolet.mpvrx.ui.utils.DampedDragAnimation +import com.kyant.backdrop.Backdrop +import com.kyant.backdrop.backdrops.rememberBackdrop +import com.kyant.backdrop.backdrops.rememberCombinedBackdrop +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.highlight.Highlight +import com.kyant.backdrop.shadow.InnerShadow +import com.kyant.backdrop.shadow.Shadow +import com.kyant.shapes.Capsule +import org.koin.compose.koinInject +import app.gyrolet.mpvrx.preferences.AppearancePreferences +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.drawscope.scale +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.util.fastCoerceIn +import com.kyant.backdrop.backdrops.layerBackdrop +import app.gyrolet.mpvrx.preferences.preference.collectAsState +import androidx.compose.ui.util.lerp +import androidx.compose.foundation.layout.size import dev.vivvvek.seeker.Segment import `is`.xyz.mpv.Utils import kotlinx.collections.immutable.ImmutableList @@ -244,6 +269,7 @@ private fun SeekbarContent( SeekbarStyle.Thick -> 16.dp SeekbarStyle.Standard -> 8.dp SeekbarStyle.Wavy -> 8.dp + SeekbarStyle.Liquid -> 8.dp } Box( @@ -382,6 +408,28 @@ private fun SeekbarContent( bufferEnd = bufferEnd, ) } + SeekbarStyle.Liquid -> { + LiquidSeekbar( + position = position, + duration = duration, + chapters = chapters, + isPaused = paused, + isScrubbing = isUserInteracting, + loopStart = loopStart, + loopEnd = loopEnd, + onSeek = { newPosition -> + onUserInteractionChange(true) + onUserPositionChange(newPosition) + onValueChange(newPosition) + }, + onSeekFinished = { + scope.launch { animatedPosition.snapTo(position) } + onUserInteractionChange(false) + onValueChangeFinished() + }, + modifier = Modifier.fillMaxWidth().height(touchAreaHeight) + ) + } } } @@ -1016,6 +1064,28 @@ fun SeekbarStylePreview( cornerRadius = CornerRadius(thumbW / 2f), ) } + SeekbarStyle.Liquid -> { + val height = 8.dp.toPx() + val radius = height / 2f + + // Unplayed + drawRoundRect( + color = primaryColor.copy(alpha = 0.3f), + topLeft = Offset(0f, centerY - radius), + size = Size(size.width, height), + cornerRadius = CornerRadius(radius), + ) + + // Played + if (playedPx > 0f) { + drawRoundRect( + color = primaryColor, + topLeft = Offset(0f, centerY - radius), + size = Size(playedPx, height), + cornerRadius = CornerRadius(radius), + ) + } + } } } } @@ -1285,6 +1355,171 @@ fun StandardSeekbar( ) } +@Composable +fun LiquidSeekbar( + position: Float, + duration: Float, + chapters: ImmutableList, + isPaused: Boolean, + isScrubbing: Boolean, + loopStart: Float? = null, + loopEnd: Float? = null, + onSeek: (Float) -> Unit, + onSeekFinished: () -> Unit, + modifier: Modifier = Modifier, + videoBackdrop: Backdrop = rememberLayerBackdrop(), +) { + val appearancePreferences = koinInject() + val liquidSeekbarColor by appearancePreferences.liquidSeekbarColor.collectAsState() + val accentColor = Color(liquidSeekbarColor) + val trackColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f) + + val trackBackdrop = rememberLayerBackdrop() + + BoxWithConstraints( + modifier, + contentAlignment = Alignment.CenterStart + ) { + val trackWidth = constraints.maxWidth.toFloat() + val isLtr = LocalLayoutDirection.current == LayoutDirection.Ltr + val animationScope = rememberCoroutineScope() + + val progress = if (duration > 0f) (position / duration).coerceIn(0f, 1f) else 0f + + val dampedDragAnimation = remember(animationScope) { + DampedDragAnimation( + animationScope = animationScope, + initialValue = progress, + valueRange = 0f..1f, + visibilityThreshold = 0.001f, + initialScale = 1f, + pressedScale = 1.5f, + onDragStarted = {}, + onDragStopped = { + onSeekFinished() + }, + onDrag = { _, dragAmount -> + val delta = dragAmount.x / trackWidth + val newProgress = if (isLtr) (value + delta).coerceIn(0f, 1f) + else (value - delta).coerceIn(0f, 1f) + onSeek(newProgress * duration) + } + ) + } + + LaunchedEffect(position) { + if (isScrubbing) { + dampedDragAnimation.snapToValue(progress) + } else { + dampedDragAnimation.updateValue(progress) + } + } + + Box( + modifier = Modifier + .fillMaxWidth() + .height(48.dp) + .pointerInput(Unit) { + detectTapGestures( + onTap = { offset -> + val newProgress = (offset.x / trackWidth).coerceIn(0f, 1f) + onSeek(newProgress * duration) + onSeekFinished() + } + ) + } + .layerBackdrop(trackBackdrop), + contentAlignment = Alignment.Center + ) { + // Track + Box( + Modifier + .clip(Capsule()) + .background(trackColor) + .height(8.dp) + .fillMaxWidth() + ) + + // Played part + Box( + Modifier + .clip(Capsule()) + .background(accentColor) + .height(8.dp) + .align(Alignment.CenterStart) + .fillMaxWidth(dampedDragAnimation.value) + ) + } + + // Thumb + Box( + Modifier + .graphicsLayer { + val thumbWidth = 40.dp.toPx() + translationX = ((trackWidth * dampedDragAnimation.value) - thumbWidth / 2f) + .coerceIn(0f, (trackWidth - thumbWidth).coerceAtLeast(0f)) + } + .then(dampedDragAnimation.modifier) + .drawBackdrop( + backdrop = rememberCombinedBackdrop( + videoBackdrop, + rememberBackdrop(trackBackdrop) { drawBackdrop -> + val pressProgress = dampedDragAnimation.pressProgress + val scaleX = lerp(2f / 3f, 1f, pressProgress) + val scaleY = lerp(0f, 1f, pressProgress) + scale(scaleX, scaleY) { + drawBackdrop() + } + } + ), + shape = { Capsule() }, + effects = { + val pressProgress = dampedDragAnimation.pressProgress + blur(8f.dp.toPx() * (1f - pressProgress)) + lens( + 10f.dp.toPx() * pressProgress, + 14f.dp.toPx() * pressProgress, + chromaticAberration = true + ) + }, + highlight = { + val pressProgress = dampedDragAnimation.pressProgress + Highlight.Ambient.copy( + width = Highlight.Ambient.width / 1.5f, + blurRadius = Highlight.Ambient.blurRadius / 1.5f, + alpha = pressProgress + ) + }, + shadow = { + Shadow( + radius = 4f.dp, + color = Color.Black.copy(alpha = 0.05f) + ) + }, + innerShadow = { + val pressProgress = dampedDragAnimation.pressProgress + InnerShadow( + radius = 4f.dp * pressProgress, + alpha = pressProgress + ) + }, + layerBlock = { + scaleX = dampedDragAnimation.scaleX + scaleY = dampedDragAnimation.scaleY + val velocity = dampedDragAnimation.velocity / 10f + scaleX /= 1f - (velocity * 0.75f).fastCoerceIn(-0.2f, 0.2f) + scaleY *= 1f - (velocity * 0.25f).fastCoerceIn(-0.2f, 0.2f) + }, + onDrawSurface = { + val pressProgress = dampedDragAnimation.pressProgress + drawRect(Color.White.copy(alpha = 1f - pressProgress)) + } + ) + .size(40.dp, 24.dp) + ) + } +} + @Preview(name = "Seekbar - Wavy (default)") @Composable private fun PreviewSeekBarWavy() { diff --git a/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/components/SlideToUnlock.kt b/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/components/SlideToUnlock.kt index ba611e628..a731ac7fb 100644 --- a/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/components/SlideToUnlock.kt +++ b/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/components/SlideToUnlock.kt @@ -41,6 +41,15 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import kotlinx.coroutines.launch import kotlin.math.roundToInt +import org.koin.compose.koinInject +import app.gyrolet.mpvrx.preferences.AppearancePreferences +import app.gyrolet.mpvrx.preferences.preference.collectAsState +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.graphics.BlendMode +import com.kyant.backdrop.drawBackdrop +import com.kyant.backdrop.effects.blur +import com.kyant.backdrop.effects.lens +import com.kyant.backdrop.backdrops.rememberLayerBackdrop @Composable fun SlideToUnlock( @@ -55,13 +64,40 @@ fun SlideToUnlock( val offsetX = remember { Animatable(0f) } var isDragging by remember { mutableStateOf(false) } + + val preferences = koinInject() + val enableLiquidGlass by preferences.enableLiquidGlass.collectAsState() + val blurRadius by preferences.liquidButtonBlur.collectAsState() + val lensRadius by preferences.liquidButtonLensRadius.collectAsState() + val lensDepth by preferences.liquidButtonLensDepth.collectAsState() + val liquidOpacity by preferences.liquidButtonOpacity.collectAsState() + val liquidTint by preferences.liquidButtonTint.collectAsState() + val density = LocalDensity.current Box( modifier = modifier .width(200.dp) .height(64.dp) .clip(RoundedCornerShape(32.dp)) - .background(Color.Black.copy(alpha = 0.6f)) + .then( + if (enableLiquidGlass) { + Modifier.drawBackdrop( + backdrop = rememberLayerBackdrop(), + shape = { RoundedCornerShape(32.dp) }, + effects = { + blur(with(density) { blurRadius.dp.toPx() }) + lens(with(density) { lensRadius.dp.toPx() }, with(density) { lensDepth.dp.toPx() }, chromaticAberration = true) + }, + onDrawSurface = { + val tintColor = Color(liquidTint) + drawRect(tintColor, blendMode = BlendMode.Screen, alpha = liquidOpacity) + drawRect(tintColor.copy(alpha = liquidOpacity * 0.2f)) + } + ) + } else { + Modifier.background(Color.Black.copy(alpha = 0.6f)) + } + ) .padding(4.dp) .onSizeChanged { size -> containerWidthPx = size.width.toFloat() diff --git a/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/components/SpeedControlSlider.kt b/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/components/SpeedControlSlider.kt index e868dfa3f..f2cc51aea 100644 --- a/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/components/SpeedControlSlider.kt +++ b/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/components/SpeedControlSlider.kt @@ -49,6 +49,15 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import app.gyrolet.mpvrx.ui.theme.spacing import kotlinx.coroutines.delay +import org.koin.compose.koinInject +import app.gyrolet.mpvrx.preferences.AppearancePreferences +import app.gyrolet.mpvrx.preferences.preference.collectAsState +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.graphics.BlendMode +import com.kyant.backdrop.drawBackdrop +import com.kyant.backdrop.effects.blur +import com.kyant.backdrop.effects.lens +import com.kyant.backdrop.backdrops.rememberLayerBackdrop /** * A compact speed control display that shows available speed options (0.25x to 4x) @@ -72,18 +81,44 @@ fun SpeedControlSlider( val primaryColor = MaterialTheme.colorScheme.primary val onSurfaceColor = MaterialTheme.colorScheme.onSurface + + val preferences = koinInject() + val enableLiquidGlass by preferences.enableLiquidGlass.collectAsState() + val blurRadius by preferences.liquidButtonBlur.collectAsState() + val lensRadius by preferences.liquidButtonLensRadius.collectAsState() + val lensDepth by preferences.liquidButtonLensDepth.collectAsState() + val liquidOpacity by preferences.liquidButtonOpacity.collectAsState() + val liquidTint by preferences.liquidButtonTint.collectAsState() + val density = LocalDensity.current + // Use a Surface with less rounded corners instead of CircleShape Surface( shape = AppShapeScale.medium, - color = MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.55f), + color = if (enableLiquidGlass) Color.Transparent else MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.55f), contentColor = MaterialTheme.colorScheme.onSurface, tonalElevation = 0.dp, shadowElevation = 0.dp, - border = BorderStroke( + border = if (enableLiquidGlass) null else BorderStroke( 1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f), ), - modifier = modifier.animateContentSize(), + modifier = modifier.animateContentSize().then( + if (enableLiquidGlass) { + Modifier.drawBackdrop( + backdrop = rememberLayerBackdrop(), + shape = { AppShapeScale.medium }, + effects = { + blur(with(density) { blurRadius.dp.toPx() }) + lens(with(density) { lensRadius.dp.toPx() }, with(density) { lensDepth.dp.toPx() }, chromaticAberration = true) + }, + onDrawSurface = { + val tintColor = Color(liquidTint) + drawRect(tintColor, blendMode = BlendMode.Screen, alpha = liquidOpacity) + drawRect(tintColor.copy(alpha = liquidOpacity * 0.2f)) + } + ) + } else Modifier + ), ) { Box( modifier = Modifier.padding( @@ -197,17 +232,43 @@ fun CompactSpeedIndicator( currentSpeed: Float, modifier: Modifier = Modifier, ) { + val preferences = koinInject() + val enableLiquidGlass by preferences.enableLiquidGlass.collectAsState() + val blurRadius by preferences.liquidButtonBlur.collectAsState() + val lensRadius by preferences.liquidButtonLensRadius.collectAsState() + val lensDepth by preferences.liquidButtonLensDepth.collectAsState() + val liquidOpacity by preferences.liquidButtonOpacity.collectAsState() + val liquidTint by preferences.liquidButtonTint.collectAsState() + val density = LocalDensity.current + Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, modifier = modifier - .background( - color = MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.55f), - shape = AppShapeScale.full - ) - .border( - BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), - shape = AppShapeScale.full + .then( + if (enableLiquidGlass) { + Modifier.drawBackdrop( + backdrop = rememberLayerBackdrop(), + shape = { AppShapeScale.full }, + effects = { + blur(with(density) { blurRadius.dp.toPx() }) + lens(with(density) { lensRadius.dp.toPx() }, with(density) { lensDepth.dp.toPx() }, chromaticAberration = true) + }, + onDrawSurface = { + val tintColor = Color(liquidTint) + drawRect(tintColor, blendMode = BlendMode.Screen, alpha = liquidOpacity) + drawRect(tintColor.copy(alpha = liquidOpacity * 0.2f)) + } + ) + } else { + Modifier.background( + color = MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.55f), + shape = AppShapeScale.full + ).border( + BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), + shape = AppShapeScale.full + ) + } ) .padding(horizontal = MaterialTheme.spacing.medium, vertical = MaterialTheme.spacing.small) ) { diff --git a/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/components/VerticalSliders.kt b/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/components/VerticalSliders.kt index d9094b7c5..6d3ac5495 100644 --- a/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/components/VerticalSliders.kt +++ b/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/components/VerticalSliders.kt @@ -28,6 +28,15 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import app.gyrolet.mpvrx.R import app.gyrolet.mpvrx.ui.theme.spacing +import org.koin.compose.koinInject +import app.gyrolet.mpvrx.preferences.AppearancePreferences +import app.gyrolet.mpvrx.preferences.preference.collectAsState +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.graphics.BlendMode +import com.kyant.backdrop.drawBackdrop +import com.kyant.backdrop.effects.blur +import com.kyant.backdrop.effects.lens +import com.kyant.backdrop.backdrops.rememberLayerBackdrop fun percentage( value: Float, @@ -50,13 +59,16 @@ fun VerticalSlider( colorEnd: Color = MaterialTheme.colorScheme.primary, ) { val coercedValue = value.coerceIn(range) + val preferences = koinInject() + val enableLiquidGlass by preferences.enableLiquidGlass.collectAsState() + Box( modifier = modifier .height(130.dp) .width(36.dp) .clip(AppShapeScale.largeIncreased) - .background(Color.Black.copy(alpha = 0.3f)), + .background(if (enableLiquidGlass) Color.Black.copy(alpha = 0.1f) else Color.Black.copy(alpha = 0.3f)), contentAlignment = Alignment.BottomCenter, ) { val targetHeight by animateFloatAsState( @@ -98,13 +110,17 @@ fun VerticalSlider( colorEnd: Color = MaterialTheme.colorScheme.primary, ) { val coercedValue = value.coerceIn(range) + + val preferences = koinInject() + val enableLiquidGlass by preferences.enableLiquidGlass.collectAsState() + Box( modifier = modifier .height(130.dp) .width(36.dp) .clip(AppShapeScale.largeIncreased) - .background(Color.Black.copy(alpha = 0.3f)), + .background(if (enableLiquidGlass) Color.Black.copy(alpha = 0.1f) else Color.Black.copy(alpha = 0.3f)), contentAlignment = Alignment.BottomCenter, ) { val targetHeight by animateFloatAsState( @@ -142,10 +158,35 @@ fun BrightnessSlider( modifier: Modifier = Modifier, ) { val coercedBrightness = brightness.coerceIn(range) + val preferences = koinInject() + val enableLiquidGlass by preferences.enableLiquidGlass.collectAsState() + val blurRadius by preferences.liquidButtonBlur.collectAsState() + val lensRadius by preferences.liquidButtonLensRadius.collectAsState() + val lensDepth by preferences.liquidButtonLensDepth.collectAsState() + val liquidOpacity by preferences.liquidButtonOpacity.collectAsState() + val liquidTint by preferences.liquidButtonTint.collectAsState() + val density = LocalDensity.current + Surface( - modifier = modifier, + modifier = modifier.then( + if (enableLiquidGlass) { + Modifier.drawBackdrop( + backdrop = rememberLayerBackdrop(), + shape = { AppShapeScale.extraLarge }, + effects = { + blur(with(density) { blurRadius.dp.toPx() }) + lens(with(density) { lensRadius.dp.toPx() }, with(density) { lensDepth.dp.toPx() }, chromaticAberration = true) + }, + onDrawSurface = { + val tintColor = Color(liquidTint) + drawRect(tintColor, blendMode = BlendMode.Screen, alpha = liquidOpacity) + drawRect(tintColor.copy(alpha = liquidOpacity * 0.2f)) + } + ) + } else Modifier + ), shape = AppShapeScale.extraLarge, - color = Color.Black.copy(alpha = 0.5f), + color = if (enableLiquidGlass) Color.Transparent else Color.Black.copy(alpha = 0.5f), contentColor = Color.White, ) { Column( @@ -191,10 +232,35 @@ fun VolumeSlider( displayAsPercentage: Boolean = false, ) { val percentage = volumePercentage.coerceIn(0, 100) + val preferences = koinInject() + val enableLiquidGlass by preferences.enableLiquidGlass.collectAsState() + val blurRadius by preferences.liquidButtonBlur.collectAsState() + val lensRadius by preferences.liquidButtonLensRadius.collectAsState() + val lensDepth by preferences.liquidButtonLensDepth.collectAsState() + val liquidOpacity by preferences.liquidButtonOpacity.collectAsState() + val liquidTint by preferences.liquidButtonTint.collectAsState() + val density = LocalDensity.current + Surface( - modifier = modifier, + modifier = modifier.then( + if (enableLiquidGlass) { + Modifier.drawBackdrop( + backdrop = rememberLayerBackdrop(), + shape = { AppShapeScale.extraLarge }, + effects = { + blur(with(density) { blurRadius.dp.toPx() }) + lens(with(density) { lensRadius.dp.toPx() }, with(density) { lensDepth.dp.toPx() }, chromaticAberration = true) + }, + onDrawSurface = { + val tintColor = Color(liquidTint) + drawRect(tintColor, blendMode = BlendMode.Screen, alpha = liquidOpacity) + drawRect(tintColor.copy(alpha = liquidOpacity * 0.2f)) + } + ) + } else Modifier + ), shape = AppShapeScale.extraLarge, - color = Color.Black.copy(alpha = 0.5f), + color = if (enableLiquidGlass) Color.Transparent else Color.Black.copy(alpha = 0.5f), contentColor = Color.White, ) { Column( diff --git a/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/components/panels/DraggablePanel.kt b/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/components/panels/DraggablePanel.kt index 74861ce66..29f252547 100644 --- a/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/components/panels/DraggablePanel.kt +++ b/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/components/panels/DraggablePanel.kt @@ -37,8 +37,16 @@ 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 +import org.koin.compose.koinInject +import app.gyrolet.mpvrx.preferences.AppearancePreferences +import app.gyrolet.mpvrx.preferences.preference.collectAsState +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Color +import com.kyant.backdrop.drawBackdrop +import com.kyant.backdrop.effects.blur +import com.kyant.backdrop.effects.lens +import com.kyant.backdrop.backdrops.rememberLayerBackdrop /** * A draggable panel with an optional fixed header and scrollable content. @@ -76,14 +84,43 @@ fun DraggablePanel( val panelMaxHeight = if (isPortrait) maxHeight * 0.5f else maxHeight val colors = panelCardsColors() + + val preferences = koinInject() + val enableLiquidGlass by preferences.enableLiquidGlass.collectAsState() + val blurRadius by preferences.liquidButtonBlur.collectAsState() + val lensRadius by preferences.liquidButtonLensRadius.collectAsState() + val lensDepth by preferences.liquidButtonLensDepth.collectAsState() + val liquidOpacity by preferences.liquidButtonOpacity.collectAsState() + val liquidTint by preferences.liquidButtonTint.collectAsState() + + val panelShape = MaterialTheme.shapes.extraLarge + val liquidBackdrop = rememberLayerBackdrop() + Surface( modifier = Modifier .offset { IntOffset(offsetX.roundToInt(), 0) } .onSizeChanged { panelWidth = it.width } .widthIn(max = 380.dp) - .heightIn(max = panelMaxHeight), + .heightIn(max = panelMaxHeight) + .then( + if (enableLiquidGlass) { + Modifier.drawBackdrop( + backdrop = liquidBackdrop, + shape = { panelShape }, + effects = { + blur(with(density) { blurRadius.dp.toPx() }) + lens(with(density) { lensRadius.dp.toPx() }, with(density) { lensDepth.dp.toPx() }, chromaticAberration = true) + }, + onDrawSurface = { + val tintColor = Color(liquidTint) + drawRect(tintColor, blendMode = BlendMode.Screen, alpha = liquidOpacity) + drawRect(tintColor.copy(alpha = liquidOpacity * 0.2f)) + } + ) + } else Modifier + ), shape = MaterialTheme.shapes.extraLarge, - color = colors.containerColor, + color = if (enableLiquidGlass) Color.Transparent else colors.containerColor, contentColor = colors.contentColor, tonalElevation = 0.dp, ) { diff --git a/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/components/panels/components/MultiCardPanel.kt b/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/components/panels/components/MultiCardPanel.kt index ef88e821a..08e798318 100644 --- a/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/components/panels/components/MultiCardPanel.kt +++ b/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/components/panels/components/MultiCardPanel.kt @@ -29,6 +29,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.runtime.movableContentOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -36,11 +37,20 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shadow import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.constraintlayout.compose.ConstraintLayout +import app.gyrolet.mpvrx.preferences.AppearancePreferences +import app.gyrolet.mpvrx.preferences.preference.collectAsState import app.gyrolet.mpvrx.ui.player.controls.CARDS_MAX_WIDTH import app.gyrolet.mpvrx.ui.theme.spacing +import com.kyant.backdrop.drawBackdrop +import com.kyant.backdrop.effects.blur +import com.kyant.backdrop.effects.colorControls +import com.kyant.backdrop.effects.lens +import com.kyant.backdrop.highlight.Highlight +import org.koin.compose.koinInject @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @Composable @@ -53,8 +63,18 @@ fun MultiCardPanel( ) { BackHandler(onBack = onDismissRequest) val orientation = LocalConfiguration.current.orientation + val density = LocalDensity.current val cards = remember { movableContentOf { p1: Int, p2: Modifier -> cards(p1, p2) } } + val preferences = koinInject() + val enableLiquidGlass by preferences.enableLiquidGlass.collectAsState() + val liquidBlur by preferences.liquidDialogBlur.collectAsState() + val liquidSaturation by preferences.liquidDialogSaturation.collectAsState() + val liquidBrightness by preferences.liquidDialogBrightness.collectAsState() + val liquidLensRadius by preferences.liquidDialogLensRadius.collectAsState() + val liquidLensDepth by preferences.liquidDialogLensDepth.collectAsState() + val liquidAlpha by preferences.liquidDialogContainerAlpha.collectAsState() + ConstraintLayout(modifier = modifier.fillMaxSize()) { val settingsCards = createRef() @@ -62,10 +82,37 @@ fun MultiCardPanel( if (orientation == ORIENTATION_PORTRAIT) { Column( modifier = - Modifier.constrainAs(settingsCards) { - top.linkTo(parent.top) - start.linkTo(parent.start) - }, + Modifier + .then( + if (enableLiquidGlass) { + Modifier.drawBackdrop( + backdrop = com.kyant.backdrop.backdrops.rememberLayerBackdrop(), + shape = { androidx.compose.ui.graphics.RectangleShape }, + effects = { + colorControls( + brightness = liquidBrightness, + saturation = liquidSaturation + ) + blur(with(density) { liquidBlur.dp.toPx() }) + lens( + with(density) { liquidLensRadius.dp.toPx() }, + with(density) { liquidLensDepth.dp.toPx() }, + depthEffect = true + ) + }, + highlight = { Highlight.Plain }, + onDrawSurface = { + drawRect(Color.Black.copy(alpha = liquidAlpha)) + } + ) + } else { + Modifier + } + ) + .constrainAs(settingsCards) { + top.linkTo(parent.top) + start.linkTo(parent.start) + }, verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.medium), ) { TopAppBar( diff --git a/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/components/sheets/AmbientSheet.kt b/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/components/sheets/AmbientSheet.kt index 6b1526912..9218b17ae 100644 --- a/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/components/sheets/AmbientSheet.kt +++ b/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/components/sheets/AmbientSheet.kt @@ -93,6 +93,7 @@ fun AmbientSheet( vignetteStrength = vignetteStrength, opacity = opacity, ) + AmbientVisualMode.YOUTUBE -> false } val isBalanced = when (ambientMode) { AmbientVisualMode.GLOW -> matchesGlowPreset( @@ -117,6 +118,7 @@ fun AmbientSheet( vignetteStrength = vignetteStrength, opacity = opacity, ) + AmbientVisualMode.YOUTUBE -> false } val isHQ = when (ambientMode) { AmbientVisualMode.GLOW -> matchesGlowPreset( @@ -141,6 +143,7 @@ fun AmbientSheet( vignetteStrength = vignetteStrength, opacity = opacity, ) + AmbientVisualMode.YOUTUBE -> false } val isEco = when (ambientMode) { AmbientVisualMode.GLOW -> matchesGlowPreset( @@ -165,7 +168,9 @@ fun AmbientSheet( vignetteStrength = vignetteStrength, opacity = opacity, ) + AmbientVisualMode.YOUTUBE -> false } + val isYouTube = ambientMode == AmbientVisualMode.YOUTUBE val configuration = LocalConfiguration.current val customMaxHeight = if (configuration.orientation == Configuration.ORIENTATION_PORTRAIT) { @@ -203,7 +208,7 @@ fun AmbientSheet( modifier = Modifier .fillMaxWidth() .padding(horizontal = MaterialTheme.spacing.medium), - horizontalArrangement = Arrangement.spacedBy(6.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically, ) { ExpressivePresetButton( @@ -223,9 +228,14 @@ fun AmbientSheet( ) ExpressivePresetButton( label = "HQ", - selected = isHQ, + selected = isHQ && !isYouTube, onClick = { viewModel.applyAmbientProfileHighQuality() }, ) + ExpressivePresetButton( + label = "YouTube", + selected = isYouTube, + onClick = { viewModel.applyAmbientProfileYouTube() }, + ) } HorizontalDivider( @@ -233,18 +243,19 @@ fun AmbientSheet( color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f), ) - // ── Section: Glow ──────────────────────────────────────────────── - var glowExpanded by remember { mutableStateOf(true) } - SectionHeader( - title = "Glow", - isExpanded = glowExpanded, - onClick = { glowExpanded = !glowExpanded }, - ) - AnimatedVisibility( - visible = glowExpanded, - enter = expandVertically(animationSpec = spring(dampingRatio = AppMotion.Spatial.Expressive.dampingRatio, stiffness = AppMotion.Spatial.Expressive.stiffness)) + fadeIn(animationSpec = spring(stiffness = AppMotion.Effect.Alpha.stiffness)), - exit = shrinkVertically(animationSpec = spring(dampingRatio = AppMotion.Spatial.Expressive.dampingRatio, stiffness = AppMotion.Spatial.Expressive.stiffness)) + fadeOut(animationSpec = spring(stiffness = AppMotion.Effect.Alpha.stiffness)), - ) { + if (ambientMode != AmbientVisualMode.YOUTUBE) { + // ── Section: Glow ──────────────────────────────────────────────── + var glowExpanded by remember { mutableStateOf(true) } + SectionHeader( + title = "Glow", + isExpanded = glowExpanded, + onClick = { glowExpanded = !glowExpanded }, + ) + AnimatedVisibility( + visible = glowExpanded, + enter = expandVertically(animationSpec = spring(dampingRatio = AppMotion.Spatial.Expressive.dampingRatio, stiffness = AppMotion.Spatial.Expressive.stiffness)) + fadeIn(animationSpec = spring(stiffness = AppMotion.Effect.Alpha.stiffness)), + exit = shrinkVertically(animationSpec = spring(dampingRatio = AppMotion.Spatial.Expressive.dampingRatio, stiffness = AppMotion.Spatial.Expressive.stiffness)) + fadeOut(animationSpec = spring(stiffness = AppMotion.Effect.Alpha.stiffness)), + ) { Column( verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.small), ) { @@ -437,6 +448,8 @@ fun AmbientSheet( } } + } // end of if (ambientMode != AmbientVisualMode.YOUTUBE) + HorizontalDivider( modifier = Modifier.padding(horizontal = MaterialTheme.spacing.medium), color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f), @@ -461,6 +474,11 @@ fun AmbientSheet( selected = ambientMode == AmbientVisualMode.FRAME_EXTEND, onClick = { viewModel.updateAmbientVisualMode(AmbientVisualMode.FRAME_EXTEND) }, ) + AmbientModeButton( + label = AmbientVisualMode.YOUTUBE.label, + selected = ambientMode == AmbientVisualMode.YOUTUBE, + onClick = { viewModel.updateAmbientVisualMode(AmbientVisualMode.YOUTUBE) }, + ) } if (ambientMode == AmbientVisualMode.FRAME_EXTEND) { diff --git a/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/components/sheets/FrameNavigationSheet.kt b/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/components/sheets/FrameNavigationSheet.kt index 81a8e028c..5f419497f 100644 --- a/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/components/sheets/FrameNavigationSheet.kt +++ b/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/components/sheets/FrameNavigationSheet.kt @@ -50,6 +50,7 @@ import androidx.compose.ui.unit.dp import app.gyrolet.mpvrx.R import app.gyrolet.mpvrx.preferences.PlayerPreferences import app.gyrolet.mpvrx.preferences.preference.collectAsState +import app.gyrolet.mpvrx.preferences.AppearancePreferences import app.gyrolet.mpvrx.presentation.components.PlayerSheet import app.gyrolet.mpvrx.ui.theme.spacing import `is`.xyz.mpv.MPVLib @@ -222,11 +223,17 @@ private fun FrameNavigationCard( title: @Composable () -> Unit, modifier: Modifier = Modifier, ) { + val preferences = koinInject() + val enableLiquidGlass by preferences.enableLiquidGlass.collectAsState() + val panelCardsColors: @Composable () -> CardColors = { val colors = CardDefaults.cardColors() + + val alpha = if (enableLiquidGlass) 0.1f else 0.6f + colors.copy( - containerColor = MaterialTheme.colorScheme.background.copy(alpha = 0.6f), - disabledContainerColor = MaterialTheme.colorScheme.background.copy(alpha = 0.6f), + containerColor = MaterialTheme.colorScheme.background.copy(alpha = alpha), + disabledContainerColor = MaterialTheme.colorScheme.background.copy(alpha = alpha), ) } diff --git a/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/components/sheets/MoreSheet.kt b/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/components/sheets/MoreSheet.kt index 5713d58fc..55c7818dc 100644 --- a/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/components/sheets/MoreSheet.kt +++ b/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/components/sheets/MoreSheet.kt @@ -202,7 +202,11 @@ fun MoreSheet( LazyRow( horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.smaller), ) { - items(7) { page -> + items( + count = 7, + key = { it }, + contentType = { "stats_page" } + ) { page -> FilterChip( label = { Text( @@ -262,7 +266,11 @@ fun MoreSheet( LazyRow( horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.smaller), ) { - items(Anime4KManager.Mode.entries) { mode -> + items( + items = Anime4KManager.Mode.entries, + key = { it.name }, + contentType = { "anime4k_mode" } + ) { mode -> FilterChip( label = { Text(stringResource(mode.titleRes)) }, selected = anime4kMode == mode.name, @@ -439,7 +447,7 @@ fun TimePickerDialog( } } } - } +} @Composable fun SectionHeaderWithInfo( diff --git a/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/components/sheets/SubtitleTracksSheet.kt b/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/components/sheets/SubtitleTracksSheet.kt index d4b4eb197..c51365910 100644 --- a/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/components/sheets/SubtitleTracksSheet.kt +++ b/app/src/main/java/app/gyrolet/mpvrx/ui/player/controls/components/sheets/SubtitleTracksSheet.kt @@ -145,7 +145,7 @@ fun SubtitlesSheet( if (langSearch.isBlank()) source else source.filter { it.contains(langSearch, ignoreCase = true) } } - androidx.compose.material3.AlertDialog( + app.gyrolet.mpvrx.presentation.components.LiquidDialog( onDismissRequest = { showLanguagePicker = null langSearch = "" diff --git a/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/AdvancedPreferencesScreen.kt b/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/AdvancedPreferencesScreen.kt index 6d9b0dc0b..dbed88ca4 100644 --- a/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/AdvancedPreferencesScreen.kt +++ b/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/AdvancedPreferencesScreen.kt @@ -68,7 +68,7 @@ import kotlinx.serialization.Serializable import me.zhanghai.compose.preference.ListPreference import me.zhanghai.compose.preference.Preference import me.zhanghai.compose.preference.ProvidePreferenceLocals -import me.zhanghai.compose.preference.SwitchPreference +import app.gyrolet.mpvrx.ui.preferences.components.AdaptiveSwitchPreference import me.zhanghai.compose.preference.TwoTargetIconButtonPreference import org.koin.compose.koinInject import java.io.File @@ -163,7 +163,7 @@ object AdvancedPreferencesScreen : Screen { subtitlesPreferences.subtitleSaveFolder.set(uriString) subtitlesPreferences.fontsFolder.set(uriString) val root = DocumentFile.fromTreeUri(context, uri) ?: return@rememberLauncherForActivityResult - listOf("fonts", "Subtitles", "scripts", "script-opts", "shaders").forEach { name -> + listOf("fonts", "Subtitles", "scripts", "script-opts", "shaders", "gpudriver").forEach { name -> if (root.findFile(name) == null) root.createDirectory(name) } } @@ -429,7 +429,7 @@ object AdvancedPreferencesScreen : Screen { val selectedScripts by preferences.selectedLuaScripts.collectAsState() val enableLuaScripts by preferences.enableLuaScripts.collectAsState() - SwitchPreference( + AdaptiveSwitchPreference( value = enableLuaScripts, onValueChange = preferences.enableLuaScripts::set, title = { Text(stringResource(R.string.pref_enable_lua_scripts_title)) }, @@ -532,7 +532,7 @@ object AdvancedPreferencesScreen : Screen { } } - SwitchPreference( + AdaptiveSwitchPreference( value = enableRecentlyPlayed, onValueChange = preferences.enableRecentlyPlayed::set, title = { Text(stringResource(R.string.pref_advanced_enable_recently_played_title)) }, @@ -822,7 +822,7 @@ object AdvancedPreferencesScreen : Screen { val clipboardManager = context.getSystemService(ClipboardManager::class.java) val verboseLogging by preferences.verboseLogging.collectAsState() - SwitchPreference( + AdaptiveSwitchPreference( value = verboseLogging, onValueChange = preferences.verboseLogging::set, title = { Text(stringResource(R.string.pref_advanced_verbose_logging_title)) }, diff --git a/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/AiIntegrationScreen.kt b/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/AiIntegrationScreen.kt index f25e23aae..9e2680160 100644 --- a/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/AiIntegrationScreen.kt +++ b/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/AiIntegrationScreen.kt @@ -83,7 +83,7 @@ import app.gyrolet.mpvrx.ui.preferences.PreferenceCard import app.gyrolet.mpvrx.ui.preferences.PreferenceDivider import app.gyrolet.mpvrx.ui.preferences.PreferenceSectionHeader import me.zhanghai.compose.preference.ProvidePreferenceLocals -import me.zhanghai.compose.preference.SwitchPreference +import app.gyrolet.mpvrx.ui.preferences.components.AdaptiveSwitchPreference import org.koin.compose.koinInject private val allLanguages = mapOf( @@ -223,7 +223,7 @@ object AiIntegrationScreen : Screen { item { PreferenceCard { - SwitchPreference( + AdaptiveSwitchPreference( value = enabled, onValueChange = { preferences.enabled.set(it) }, title = { Text("Enable AI Features") }, @@ -746,7 +746,7 @@ object AiIntegrationScreen : Screen { item { PreferenceCard { - SwitchPreference( + AdaptiveSwitchPreference( value = preferences.showThinking.collectAsState().value, onValueChange = { preferences.showThinking.set(it) }, title = { Text("Show AI Reasoning (Thinking)") }, @@ -760,7 +760,7 @@ object AiIntegrationScreen : Screen { item { PreferenceCard { - SwitchPreference( + AdaptiveSwitchPreference( value = renameWithAi, onValueChange = { preferences.renameWithAi.set(it) }, title = { Text("AI-Powered Rename") }, @@ -774,7 +774,7 @@ object AiIntegrationScreen : Screen { PreferenceDivider() - SwitchPreference( + AdaptiveSwitchPreference( value = subtitleFormatWithAi, onValueChange = { preferences.subtitleFormatWithAi.set(it) }, title = { Text("AI Search") }, @@ -797,7 +797,7 @@ object AiIntegrationScreen : Screen { val sttProvider by preferences.sttProvider.collectAsState() val sttModel by preferences.sttModel.collectAsState() - SwitchPreference( + AdaptiveSwitchPreference( value = realtimeSubsEnabled, onValueChange = { preferences.realtimeSubsEnabled.set(it) }, title = { Text("Real-time Subtitle Generation") }, @@ -889,7 +889,7 @@ object AiIntegrationScreen : Screen { item { PreferenceCard { - SwitchPreference( + AdaptiveSwitchPreference( value = subtitleTranslationEnabled, onValueChange = { enabled -> preferences.subtitleTranslationEnabled.set(enabled) @@ -920,7 +920,7 @@ object AiIntegrationScreen : Screen { item { PreferenceCard { - SwitchPreference( + AdaptiveSwitchPreference( value = customPromptEnabled, onValueChange = { preferences.customPromptEnabled.set(it) }, title = { Text("Override Default Instructions") }, diff --git a/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/AppearancePreferencesScreen.kt b/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/AppearancePreferencesScreen.kt index 90424866c..2daf65491 100644 --- a/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/AppearancePreferencesScreen.kt +++ b/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/AppearancePreferencesScreen.kt @@ -64,7 +64,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.gyrolet.mpvrx.ui.preferences.components.AdaptiveSwitchPreference import org.koin.compose.koinInject import kotlin.math.roundToInt @@ -230,7 +230,7 @@ object AppearancePreferencesScreen : Screen { PreferenceDivider() - SwitchPreference( + AdaptiveSwitchPreference( value = amoledMode, onValueChange = { newValue -> preferences.amoledMode.set(newValue) @@ -248,7 +248,7 @@ object AppearancePreferencesScreen : Screen { PreferenceDivider() val useSystemFont by preferences.useSystemFont.collectAsState() - SwitchPreference( + AdaptiveSwitchPreference( value = useSystemFont, onValueChange = preferences.useSystemFont::set, title = { Text(text = stringResource(id = R.string.pref_appearance_system_font_title)) }, @@ -271,7 +271,7 @@ object AppearancePreferencesScreen : Screen { item { PreferenceCard { val unlimitedNameLines by preferences.unlimitedNameLines.collectAsState() - SwitchPreference( + AdaptiveSwitchPreference( value = unlimitedNameLines, onValueChange = { preferences.unlimitedNameLines.set(it) }, title = { @@ -290,7 +290,7 @@ object AppearancePreferencesScreen : Screen { PreferenceDivider() val showUnplayedOldVideoLabel by preferences.showUnplayedOldVideoLabel.collectAsState() - SwitchPreference( + AdaptiveSwitchPreference( value = showUnplayedOldVideoLabel, onValueChange = { preferences.showUnplayedOldVideoLabel.set(it) }, title = { @@ -331,7 +331,7 @@ object AppearancePreferencesScreen : Screen { PreferenceDivider() val autoScrollToLastPlayed by browserPreferences.autoScrollToLastPlayed.collectAsState() - SwitchPreference( + AdaptiveSwitchPreference( value = autoScrollToLastPlayed, onValueChange = { browserPreferences.autoScrollToLastPlayed.set(it) }, title = { @@ -376,7 +376,7 @@ object AppearancePreferencesScreen : Screen { item { PreferenceCard { val showVideoThumbnails by browserPreferences.showVideoThumbnails.collectAsState() - SwitchPreference( + AdaptiveSwitchPreference( value = showVideoThumbnails, onValueChange = { browserPreferences.showVideoThumbnails.set(it) }, title = { @@ -444,7 +444,7 @@ object AppearancePreferencesScreen : Screen { PreferenceDivider() val tapThumbnailToSelect by gesturePreferences.tapThumbnailToSelect.collectAsState() - SwitchPreference( + AdaptiveSwitchPreference( value = tapThumbnailToSelect, onValueChange = { gesturePreferences.tapThumbnailToSelect.set(it) }, title = { @@ -464,7 +464,7 @@ object AppearancePreferencesScreen : Screen { PreferenceDivider() val showNetworkThumbnails by preferences.showNetworkThumbnails.collectAsState() - SwitchPreference( + AdaptiveSwitchPreference( value = showNetworkThumbnails, onValueChange = { preferences.showNetworkThumbnails.set(it) }, title = { @@ -494,7 +494,7 @@ object AppearancePreferencesScreen : Screen { val showPlaylistsTab by preferences.showPlaylistsTab.collectAsState() val showNetworkTab by preferences.showNetworkTab.collectAsState() - SwitchPreference( + AdaptiveSwitchPreference( value = showHomeTab, onValueChange = preferences.showHomeTab::set, title = { Text(text = stringResource(id = R.string.pref_nav_home_title)) }, @@ -508,7 +508,7 @@ object AppearancePreferencesScreen : Screen { PreferenceDivider() - SwitchPreference( + AdaptiveSwitchPreference( value = showRecentsTab, onValueChange = preferences.showRecentsTab::set, title = { Text(text = stringResource(id = R.string.pref_nav_recents_title)) }, @@ -522,7 +522,7 @@ object AppearancePreferencesScreen : Screen { PreferenceDivider() - SwitchPreference( + AdaptiveSwitchPreference( value = showPlaylistsTab, onValueChange = preferences.showPlaylistsTab::set, title = { Text(text = stringResource(id = R.string.pref_nav_playlists_title)) }, @@ -536,7 +536,7 @@ object AppearancePreferencesScreen : Screen { PreferenceDivider() - SwitchPreference( + AdaptiveSwitchPreference( value = showNetworkTab, onValueChange = preferences.showNetworkTab::set, title = { Text(text = stringResource(id = R.string.pref_nav_network_title)) }, diff --git a/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/AudioPreferencesScreen.kt b/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/AudioPreferencesScreen.kt index 4403f758a..4291ebbaa 100644 --- a/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/AudioPreferencesScreen.kt +++ b/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/AudioPreferencesScreen.kt @@ -34,7 +34,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.gyrolet.mpvrx.ui.preferences.components.AdaptiveSwitchPreference import me.zhanghai.compose.preference.TextFieldPreference import org.koin.compose.koinInject @@ -116,7 +116,7 @@ object AudioPreferencesScreen : Screen { PreferenceDivider() val audioPitchCorrection by preferences.audioPitchCorrection.collectAsState() - SwitchPreference( + AdaptiveSwitchPreference( value = audioPitchCorrection, onValueChange = { preferences.audioPitchCorrection.set(it) }, title = { Text(stringResource(R.string.pref_audio_pitch_correction_title)) }, @@ -130,7 +130,7 @@ object AudioPreferencesScreen : Screen { PreferenceDivider() val volumeNormalization by preferences.volumeNormalization.collectAsState() - SwitchPreference( + AdaptiveSwitchPreference( value = volumeNormalization, onValueChange = { preferences.volumeNormalization.set(it) }, title = { Text(stringResource(R.string.pref_audio_volume_normalization_title)) }, @@ -144,7 +144,7 @@ object AudioPreferencesScreen : Screen { PreferenceDivider() val automaticBackgroundPlayback by preferences.automaticBackgroundPlayback.collectAsState() - SwitchPreference( + AdaptiveSwitchPreference( value = automaticBackgroundPlayback, onValueChange = { preferences.automaticBackgroundPlayback.set(it) }, title = { Text(stringResource(R.string.background_playback_title)) }, diff --git a/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/CardPreferences.kt b/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/CardPreferences.kt index 95532ed3e..c68640ad9 100644 --- a/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/CardPreferences.kt +++ b/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/CardPreferences.kt @@ -12,8 +12,20 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp +import app.gyrolet.mpvrx.preferences.AppearancePreferences +import app.gyrolet.mpvrx.preferences.preference.collectAsState +import com.kyant.backdrop.backdrops.rememberLayerBackdrop +import com.kyant.backdrop.drawBackdrop +import com.kyant.backdrop.effects.blur +import com.kyant.backdrop.effects.colorControls +import com.kyant.backdrop.effects.lens +import com.kyant.backdrop.highlight.Highlight +import org.koin.compose.koinInject /** * A card container for grouping related preferences, mimicking modern Android settings UI. @@ -23,13 +35,51 @@ fun PreferenceCard( modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit, ) { + val preferences = koinInject() + val enableLiquidGlass by preferences.enableLiquidGlass.collectAsState() + val liquidBlur by preferences.liquidDialogBlur.collectAsState() + val liquidSaturation by preferences.liquidDialogSaturation.collectAsState() + val liquidBrightness by preferences.liquidDialogBrightness.collectAsState() + val liquidLensRadius by preferences.liquidDialogLensRadius.collectAsState() + val liquidLensDepth by preferences.liquidDialogLensDepth.collectAsState() + val liquidAlpha by preferences.liquidDialogContainerAlpha.collectAsState() + val density = LocalDensity.current + val shape = RoundedCornerShape(28.dp) + val surfaceColor = MaterialTheme.colorScheme.surfaceContainer + Card( modifier = modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - shape = RoundedCornerShape(28.dp), + .padding(horizontal = 16.dp, vertical = 8.dp) + .then( + if (enableLiquidGlass) { + Modifier.drawBackdrop( + backdrop = rememberLayerBackdrop(), + shape = { shape }, + effects = { + colorControls( + brightness = liquidBrightness * 0.5f, // Subtler for cards + saturation = liquidSaturation + ) + blur(with(density) { (liquidBlur * 0.8f).dp.toPx() }) + lens( + with(density) { (liquidLensRadius * 0.8f).dp.toPx() }, + with(density) { (liquidLensDepth * 0.8f).dp.toPx() }, + depthEffect = true + ) + }, + highlight = { Highlight.Plain }, + onDrawSurface = { + drawRect(surfaceColor.copy(alpha = liquidAlpha * 0.6f)) + } + ) + } else { + Modifier + } + ), + shape = shape, colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer, + containerColor = if (enableLiquidGlass) Color.Transparent else MaterialTheme.colorScheme.surfaceContainer, ), elevation = CardDefaults.cardElevation( defaultElevation = 0.dp, diff --git a/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/DecoderPreferencesScreen.kt b/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/DecoderPreferencesScreen.kt index b42e7fa04..04f87ebc5 100644 --- a/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/DecoderPreferencesScreen.kt +++ b/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/DecoderPreferencesScreen.kt @@ -48,12 +48,18 @@ import app.gyrolet.mpvrx.ui.player.MPVProfile import app.gyrolet.mpvrx.ui.utils.LocalBackStack import app.gyrolet.mpvrx.ui.utils.popSafely import app.gyrolet.mpvrx.ui.preferences.VulkanUtils +import app.gyrolet.mpvrx.ui.preferences.GpuDriverPreferencesScreen import kotlinx.serialization.Serializable import me.zhanghai.compose.preference.ListPreference +import me.zhanghai.compose.preference.Preference import me.zhanghai.compose.preference.ProvidePreferenceLocals -import me.zhanghai.compose.preference.SwitchPreference +import app.gyrolet.mpvrx.ui.preferences.components.AdaptiveSwitchPreference import org.koin.compose.koinInject +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import app.gyrolet.mpvrx.preferences.GpuDriverPreferences + @Serializable object DecoderPreferencesScreen : Screen { @OptIn(ExperimentalMaterial3Api::class) @@ -118,7 +124,7 @@ object DecoderPreferencesScreen : Screen { PreferenceDivider() val tryHWDecoding by preferences.tryHWDecoding.collectAsState() - SwitchPreference( + AdaptiveSwitchPreference( value = tryHWDecoding, onValueChange = { preferences.tryHWDecoding.set(it) @@ -130,7 +136,7 @@ object DecoderPreferencesScreen : Screen { val gpuNext by preferences.gpuNext.collectAsState() val useVulkan by preferences.useVulkan.collectAsState() // Added to check Vulkan state - SwitchPreference( + AdaptiveSwitchPreference( value = gpuNext, onValueChange = { enabled -> if (enabled && !gpuNext && !useVulkan) { // Only show warning if Vulkan is disabled @@ -198,7 +204,7 @@ object DecoderPreferencesScreen : Screen { PreferenceDivider() - SwitchPreference( + AdaptiveSwitchPreference( value = useVulkan, onValueChange = { enabled -> preferences.useVulkan.set(enabled) @@ -245,7 +251,7 @@ object DecoderPreferencesScreen : Screen { PreferenceDivider() val useYUV420p by preferences.useYUV420P.collectAsState() - SwitchPreference( + AdaptiveSwitchPreference( value = useYUV420p, onValueChange = { preferences.useYUV420P.set(it) @@ -262,7 +268,7 @@ object DecoderPreferencesScreen : Screen { PreferenceDivider() val enableAnime4K by preferences.enableAnime4K.collectAsState() - SwitchPreference( + AdaptiveSwitchPreference( value = enableAnime4K, onValueChange = { enabled -> preferences.enableAnime4K.set(enabled) @@ -293,6 +299,28 @@ object DecoderPreferencesScreen : Screen { PreferenceDivider() + Preference( + title = { Text(stringResource(R.string.pref_gpu_driver_title)) }, + summary = { + Text( + "Manage custom GPU drivers (Adreno/Turnip)", + color = MaterialTheme.colorScheme.outline + ) + }, + icon = { + Icon( + Icons.Default.Memory, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + }, + onClick = { + backstack.add(GpuDriverPreferencesScreen) + } + ) + + PreferenceDivider() + val anime4kQuality by preferences.anime4kQuality.collectAsState() ListPreference( value = anime4kQuality, @@ -322,8 +350,9 @@ object DecoderPreferencesScreen : Screen { } } -object VulkanUtils { +object VulkanUtils : KoinComponent { private const val TAG = "VulkanUtils" + private val gpuDriverPreferences: GpuDriverPreferences by inject() /** * Checks if the device supports Vulkan for MPV rendering @@ -337,6 +366,12 @@ object VulkanUtils { */ fun isVulkanSupported(context: Context): Boolean { try { + // Bypass all system checks if a custom driver is active + if (gpuDriverPreferences.activeDriverId.get() != "system") { + Log.d(TAG, "Custom GPU driver is active, bypassing system Vulkan checks.") + return true + } + // Vulkan 1.3 requires Android 13 (API 33) minimum if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { Log.d(TAG, "Vulkan not supported: Android version ${Build.VERSION.SDK_INT} < 33 (Tiramisu)") diff --git a/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/GesturePreferencesScreen.kt b/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/GesturePreferencesScreen.kt index 1dbf0cabd..107e89a93 100644 --- a/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/GesturePreferencesScreen.kt +++ b/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/GesturePreferencesScreen.kt @@ -48,7 +48,7 @@ import me.zhanghai.compose.preference.FooterPreference 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.gyrolet.mpvrx.ui.preferences.components.AdaptiveSwitchPreference import org.koin.compose.koinInject import app.gyrolet.mpvrx.ui.player.controls.components.sheets.toFixed @@ -101,7 +101,7 @@ object GesturePreferencesScreen : Screen { item { PreferenceCard { val brightnessGesture by playerPreferences.brightnessGesture.collectAsState() - SwitchPreference( + AdaptiveSwitchPreference( value = brightnessGesture, onValueChange = playerPreferences.brightnessGesture::set, title = { Text(stringResource(R.string.pref_player_gestures_brightness)) }, @@ -110,7 +110,7 @@ object GesturePreferencesScreen : Screen { PreferenceDivider() val volumeGesture by playerPreferences.volumeGesture.collectAsState() - SwitchPreference( + AdaptiveSwitchPreference( value = volumeGesture, onValueChange = playerPreferences.volumeGesture::set, title = { Text(stringResource(R.string.pref_player_gestures_volume)) }, @@ -119,7 +119,7 @@ object GesturePreferencesScreen : Screen { PreferenceDivider() val pinchToZoomGesture by playerPreferences.pinchToZoomGesture.collectAsState() - SwitchPreference( + AdaptiveSwitchPreference( value = pinchToZoomGesture, onValueChange = playerPreferences.pinchToZoomGesture::set, title = { Text(stringResource(R.string.pref_player_gestures_pinch_to_zoom)) }, @@ -128,7 +128,7 @@ object GesturePreferencesScreen : Screen { PreferenceDivider() val horizontalSwipeToSeek by playerPreferences.horizontalSwipeToSeek.collectAsState() - SwitchPreference( + AdaptiveSwitchPreference( value = horizontalSwipeToSeek, onValueChange = playerPreferences.horizontalSwipeToSeek::set, title = { Text(stringResource(R.string.pref_player_gestures_horizontal_swipe_to_seek)) }, @@ -180,7 +180,7 @@ object GesturePreferencesScreen : Screen { PreferenceDivider() val showDynamicSpeedOverlay by playerPreferences.showDynamicSpeedOverlay.collectAsState() - SwitchPreference( + AdaptiveSwitchPreference( value = showDynamicSpeedOverlay, onValueChange = playerPreferences.showDynamicSpeedOverlay::set, title = { Text(stringResource(R.string.pref_dynamic_speed_overlay_title)) }, @@ -356,7 +356,7 @@ object GesturePreferencesScreen : Screen { PreferenceDivider() val useSingleTapForCenter by preferences.useSingleTapForCenter.collectAsState() - SwitchPreference( + AdaptiveSwitchPreference( value = useSingleTapForCenter, onValueChange = { preferences.useSingleTapForCenter.set(it) }, title = { @@ -375,7 +375,7 @@ object GesturePreferencesScreen : Screen { PreferenceDivider() val centerVerticalSubtitlePositionGesture by preferences.centerVerticalSubtitlePositionGesture.collectAsState() - SwitchPreference( + AdaptiveSwitchPreference( value = centerVerticalSubtitlePositionGesture, onValueChange = { preferences.centerVerticalSubtitlePositionGesture.set(it) }, title = { Text(text = stringResource(R.string.pref_gesture_center_vertical_subtitle_position_title)) }, diff --git a/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/GpuDriverPreferencesScreen.kt b/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/GpuDriverPreferencesScreen.kt new file mode 100644 index 000000000..f4150baf9 --- /dev/null +++ b/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/GpuDriverPreferencesScreen.kt @@ -0,0 +1,967 @@ +package app.gyrolet.mpvrx.ui.preferences + +import android.annotation.SuppressLint +import android.content.ClipboardManager +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import app.gyrolet.mpvrx.ui.components.AdaptiveSwitch +import androidx.compose.animation.* +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.* +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.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import app.gyrolet.mpvrx.R +import app.gyrolet.mpvrx.domain.gpu.GpuDriver +import app.gyrolet.mpvrx.domain.gpu.GpuDriverManager +import app.gyrolet.mpvrx.preferences.GpuDriverPreferences +import app.gyrolet.mpvrx.preferences.preference.collectAsState +import app.gyrolet.mpvrx.presentation.Screen +import app.gyrolet.mpvrx.presentation.components.LiquidDialog +import app.gyrolet.mpvrx.ui.icons.Icon +import app.gyrolet.mpvrx.ui.icons.Icons as AppIcons +import app.gyrolet.mpvrx.ui.utils.LocalBackStack +import app.gyrolet.mpvrx.ui.utils.popSafely + +import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable +import org.koin.compose.koinInject + +@Serializable +object GpuDriverPreferencesScreen : Screen { + @OptIn(ExperimentalMaterial3Api::class) + @SuppressLint("LocalContextGetResourceValueCall") + @Composable + override fun Content() { + val context = LocalContext.current + val backstack = LocalBackStack.current + val preferences = koinInject() + val driverManager = koinInject() + val scope = rememberCoroutineScope() + + val drivers = remember { mutableStateListOf() } + val safDrivers = remember { mutableStateListOf() } + val remoteDriverGroups = remember { mutableStateListOf() } + var isFetching by remember { mutableStateOf(false) } + var showFetchSheet by remember { mutableStateOf(false) } + var downloadProgress by remember { mutableStateOf(null) } + var showInfoDialog by remember { mutableStateOf(false) } + + val expandedGroups = remember { mutableStateMapOf() } + val expandedReleases = remember { mutableStateMapOf() } + + val activeDriverId by preferences.activeDriverId.collectAsState() + val hasAcceptedWarning by preferences.hasAcceptedGpuWarning.collectAsState() + var showCompatibilityWarning by remember { mutableStateOf(!hasAcceptedWarning && !driverManager.isAdrenoSupported()) } + + val isArm64 = remember { Build.SUPPORTED_ABIS.contains("arm64-v8a") } + val isQualcomm = remember { driverManager.isAdrenoSupported() } + val isSupported = isArm64 && isQualcomm + + val gpuModel = remember { + val bridgeInfo = driverManager.getGpuModel() + if (bridgeInfo.contains("Architecture Not Supported") || bridgeInfo.contains("Generic GPU")) { + "Detected ${Build.HARDWARE} (${Build.MODEL})" + } else { + bridgeInfo + } + } + val adrenoModel = remember { driverManager.parseAdrenoModel(gpuModel) } + val recommendedDriver = remember { driverManager.getRecommendedDriver(adrenoModel) } + + LaunchedEffect(Unit) { + drivers.clear() + drivers.addAll(driverManager.getInstalledDrivers()) + safDrivers.clear() + safDrivers.addAll(driverManager.getSafDrivers()) + } + + val launcher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> + uri?.let { + scope.launch { + val result = driverManager.installDriver(it) + if (result.isSuccess) { + drivers.clear() + drivers.addAll(driverManager.getInstalledDrivers()) + safDrivers.clear() + safDrivers.addAll(driverManager.getSafDrivers()) + Toast.makeText(context, R.string.gpu_driver_install_success, Toast.LENGTH_SHORT).show() + } else { + Toast.makeText(context, context.getString(R.string.gpu_driver_install_failed, result.exceptionOrNull()?.message), Toast.LENGTH_LONG).show() + } + } + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + text = stringResource(R.string.pref_gpu_driver_title), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.ExtraBold, + color = MaterialTheme.colorScheme.primary, + ) + }, + navigationIcon = { + IconButton(onClick = { backstack.popSafely() }) { + Icon(AppIcons.Default.ArrowBack, contentDescription = null) + } + }, + actions = { + IconButton(onClick = { showInfoDialog = true }) { + Icon(AppIcons.Default.DeveloperBoard, contentDescription = "Technical Info", tint = MaterialTheme.colorScheme.primary) + } + } + ) + } + ) { padding -> + Box( + modifier = Modifier + .padding(padding) + .fillMaxSize() + ) { + if (showCompatibilityWarning) { + LiquidDialog( + onDismissRequest = { /* Must accept or go back */ }, + title = { + Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth()) { + Icon(AppIcons.Default.Aperture, contentDescription = null, tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(28.dp)) + Spacer(modifier = Modifier.height(12.dp)) + Text("Compatibility Warning") + } + }, + text = { + Text("Custom GPU drivers are primarily designed for Qualcomm Adreno GPUs. Your device does not appear to have one.\n\nThere is no guarantee these drivers will work, and they could cause crashes or graphical glitches. Proceed with caution.") + }, + confirmButton = { + Button( + onClick = { + preferences.hasAcceptedGpuWarning.set(true) + showCompatibilityWarning = false + } + ) { + Text("I Understand") + } + }, + dismissButton = { + TextButton(onClick = { backstack.popSafely() }) { + Text("Go Back") + } + } + ) + } + + if (showInfoDialog) { + TechnicalInfoDialog(onDismiss = { showInfoDialog = false }) + } + + Column(modifier = Modifier.fillMaxSize()) { + LazyColumn(modifier = Modifier.weight(1f)) { + item { + if (!isSupported) { + Card( + modifier = Modifier.padding(16.dp).fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.8f)) + ) { + Row(modifier = Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(AppIcons.Outlined.Info, contentDescription = null, tint = MaterialTheme.colorScheme.error) + Spacer(modifier = Modifier.width(16.dp)) + Text( + if (!isArm64) stringResource(R.string.gpu_driver_not_supported) + else "Custom drivers require a Qualcomm Adreno GPU.", + color = MaterialTheme.colorScheme.onErrorContainer + ) + } + } + } + } + + item { + DeviceInfoHub(gpuModel, recommendedDriver) + } + + item { + val showHud by preferences.showDriverHud.collectAsState() + PreferenceSectionHeader(title = "General Settings") + PreferenceCard { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.gpu_driver_show_hud), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.gpu_driver_show_hud_summary), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.outline) + } + AdaptiveSwitch(checked = showHud, onCheckedChange = { preferences.showDriverHud.set(it) }) + } + } + } + + item { + PreferenceSectionHeader(title = "Installed Drivers") + } + + items(drivers, key = { it.id }) { driver -> + DriverItem( + driver = driver, + isSelected = activeDriverId == driver.id, + onSelect = { + if (activeDriverId != driver.id) { + preferences.activeDriverId.set(driver.id) + Toast.makeText(context, R.string.gpu_driver_restart_required, Toast.LENGTH_LONG).show() + } + }, + onDelete = { + scope.launch { + driverManager.deleteDriver(driver.id) + drivers.clear() + drivers.addAll(driverManager.getInstalledDrivers()) + safDrivers.clear() + safDrivers.addAll(driverManager.getSafDrivers()) + if (activeDriverId == driver.id) { + preferences.activeDriverId.set("system") + } + } + } + ) + } + + if (safDrivers.isNotEmpty()) { + item { PreferenceSectionHeader("Drivers in Storage (gpudriver/)") } + items(safDrivers, key = { it.uri.toString() }) { safDriver -> + SafDriverItem( + driver = safDriver, + onInstall = { + scope.launch { + val result = driverManager.installDriver(safDriver.uri) + if (result.isSuccess) { + drivers.clear() + drivers.addAll(driverManager.getInstalledDrivers()) + safDrivers.clear() + safDrivers.addAll(driverManager.getSafDrivers()) + preferences.activeDriverId.set(result.getOrNull()?.id ?: "system") + Toast.makeText(context, "Driver installed and activated", Toast.LENGTH_SHORT).show() + } else { + Toast.makeText(context, "Failed to install driver", Toast.LENGTH_SHORT).show() + } + } + } + ) + } + } + + item { Spacer(modifier = Modifier.height(80.dp)) } + } + + Surface( + modifier = Modifier.fillMaxWidth(), + tonalElevation = 8.dp, + color = MaterialTheme.colorScheme.surface + ) { + Row( + modifier = Modifier.padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Button( + onClick = { launcher.launch("application/zip") }, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer + ) + ) { + Icon(AppIcons.Default.FileUpload, contentDescription = null, modifier = Modifier.size(20.dp)) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(R.string.gpu_driver_install_from_file), maxLines = 1, overflow = TextOverflow.Ellipsis) + } + Button( + onClick = { + showFetchSheet = true + if (remoteDriverGroups.isEmpty()) { + scope.launch { + isFetching = true + remoteDriverGroups.clear() + remoteDriverGroups.addAll(driverManager.fetchRemoteDriverGroups()) + isFetching = false + } + } + }, + modifier = Modifier.weight(1f) + ) { + Icon(AppIcons.Default.Download, contentDescription = null, modifier = Modifier.size(20.dp)) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(R.string.gpu_driver_fetch_drivers), maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } + } + + if (showFetchSheet) { + ModalBottomSheet( + onDismissRequest = { showFetchSheet = false }, + sheetState = rememberModalBottomSheetState(), + containerColor = MaterialTheme.colorScheme.surface, + dragHandle = { BottomSheetDefaults.DragHandle() } + ) { + Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + stringResource(R.string.gpu_driver_fetch_drivers), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + IconButton( + onClick = { + scope.launch { + isFetching = true + remoteDriverGroups.clear() + remoteDriverGroups.addAll(driverManager.fetchRemoteDriverGroups()) + isFetching = false + } + }, + enabled = !isFetching + ) { + Icon(AppIcons.Default.Refresh, contentDescription = "Refresh") + } + } + Spacer(modifier = Modifier.height(16.dp)) + + Box(modifier = Modifier.fillMaxHeight(0.8f)) { + if (isFetching) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + CircularProgressIndicator() + Spacer(modifier = Modifier.height(16.dp)) + Text(stringResource(R.string.gpu_driver_fetching), style = MaterialTheme.typography.bodyMedium) + } + } else if (remoteDriverGroups.isEmpty() || remoteDriverGroups.all { it.releases.isEmpty() }) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon(AppIcons.Filled.CloudDownload, contentDescription = null, modifier = Modifier.size(48.dp), tint = MaterialTheme.colorScheme.outline) + Spacer(modifier = Modifier.height(8.dp)) + Text("No drivers found or API error.", color = MaterialTheme.colorScheme.outline) + TextButton(onClick = { + scope.launch { + isFetching = true + remoteDriverGroups.clear() + remoteDriverGroups.addAll(driverManager.fetchRemoteDriverGroups()) + isFetching = false + } + }) { + Text("Retry") + } + } + } else { + LazyColumn(modifier = Modifier.fillMaxSize()) { + item { + Card( + modifier = Modifier.padding(bottom = 16.dp).fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.5f)) + ) { + Row(modifier = Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(AppIcons.Default.AutoAwesome, contentDescription = null, tint = MaterialTheme.colorScheme.secondary) + Spacer(modifier = Modifier.width(12.dp)) + Text( + "Recommend: $recommendedDriver", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSecondaryContainer + ) + } + } + } + + remoteDriverGroups.forEach { group -> + if (group.releases.isNotEmpty()) { + item { + RemoteGroupItem( + group = group, + isExpanded = expandedGroups[group.name] ?: false, + onToggle = { expandedGroups[group.name] = !(expandedGroups[group.name] ?: false) } + ) { + group.releases.forEach { release -> + val releaseKey = "${group.name}_${release.version}" + RemoteReleaseItem( + release = release, + isExpanded = expandedReleases[releaseKey] ?: false, + onToggle = { expandedReleases[releaseKey] = !(expandedReleases[releaseKey] ?: false) }, + downloadProgress = downloadProgress, + adrenoModel = adrenoModel, + onDownload = { remoteDriver -> + scope.launch { + downloadProgress = 0f + val result = driverManager.downloadAndInstallDriver(remoteDriver) { + downloadProgress = it + } + downloadProgress = null + if (result.isSuccess) { + drivers.clear() + drivers.addAll(driverManager.getInstalledDrivers()) + safDrivers.clear() + safDrivers.addAll(driverManager.getSafDrivers()) + showFetchSheet = false + Toast.makeText(context, R.string.gpu_driver_install_success, Toast.LENGTH_SHORT).show() + } else { + Toast.makeText(context, context.getString(R.string.gpu_driver_install_failed, result.exceptionOrNull()?.message), Toast.LENGTH_LONG).show() + } + } + } + ) + } + } + } + } + } + item { Spacer(modifier = Modifier.height(32.dp)) } + } + } + } + + AnimatedVisibility(visible = downloadProgress != null) { + Column(modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp)) { + LinearProgressIndicator( + progress = { downloadProgress ?: 0f }, + modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(4.dp)) + ) + Spacer(modifier = Modifier.height(8.dp)) + Text("Downloading: ${((downloadProgress ?: 0f) * 100).toInt()}%", style = MaterialTheme.typography.bodySmall) + } + } + } + } + } + } + } + } + + @Composable + private fun DeviceInfoHub(gpuModel: String, recommendedDriver: String) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = stringResource(R.string.gpu_driver_device_info), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 8.dp) + ) + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh), + shape = RoundedCornerShape(24.dp) + ) { + Row( + modifier = Modifier.padding(20.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(56.dp) + .clip(RoundedCornerShape(16.dp)) + .background(MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center + ) { + Icon(AppIcons.Default.Memory, contentDescription = null, tint = MaterialTheme.colorScheme.onPrimaryContainer, modifier = Modifier.size(28.dp)) + } + Spacer(modifier = Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = gpuModel, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.ExtraBold, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + Spacer(modifier = Modifier.height(4.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Surface( + color = MaterialTheme.colorScheme.secondary.copy(alpha = 0.1f), + shape = RoundedCornerShape(8.dp) + ) { + Row(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(AppIcons.Default.AutoAwesome, contentDescription = null, tint = MaterialTheme.colorScheme.secondary, modifier = Modifier.size(14.dp)) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = recommendedDriver, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.secondary, + fontWeight = FontWeight.Bold + ) + } + } + } + } + } + } + } + } + + @Composable + private fun DriverItem( + driver: GpuDriver, + isSelected: Boolean, + onSelect: () -> Unit, + onDelete: () -> Unit + ) { + var showDeleteDialog by remember { mutableStateOf(false) } + + Card( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp).fillMaxWidth(), + onClick = onSelect, + colors = CardDefaults.cardColors( + containerColor = if (isSelected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + ), + shape = RoundedCornerShape(20.dp), + border = if (isSelected) androidx.compose.foundation.BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton(selected = isSelected, onClick = onSelect) + Column(modifier = Modifier.weight(1f).padding(start = 12.dp)) { + Text( + driver.name, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = if (isSelected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface + ) + if (driver.version.isNotEmpty() || driver.author.isNotEmpty()) { + Text( + listOfNotNull( + if (driver.version.isNotEmpty()) "v${driver.version}" else null, + if (driver.author.isNotEmpty()) "by ${driver.author}" else null + ).joinToString(" • "), + style = MaterialTheme.typography.bodySmall, + color = if (isSelected) MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f) else MaterialTheme.colorScheme.onSurfaceVariant + ) + } + if (driver.isSystem) { + Text(driver.description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + if (!driver.isSystem) { + IconButton(onClick = { showDeleteDialog = true }) { + Icon(AppIcons.Default.Delete, contentDescription = null, tint = MaterialTheme.colorScheme.error) + } + } else if (isSelected) { + Icon(AppIcons.Default.Check, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.padding(end = 8.dp)) + } + } + } + + if (showDeleteDialog) { + LiquidDialog( + onDismissRequest = { showDeleteDialog = false }, + title = { Text(stringResource(R.string.gpu_driver_delete_confirm)) }, + confirmButton = { + TextButton(onClick = { + onDelete() + showDeleteDialog = false + }) { + Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = { showDeleteDialog = false }) { + Text(stringResource(R.string.generic_cancel)) + } + } + ) + } + } + + @Composable + private fun SafDriverItem( + driver: GpuDriverManager.SafGpuDriver, + onInstall: () -> Unit + ) { + Card( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp).fillMaxWidth(), + onClick = onInstall, + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)), + shape = RoundedCornerShape(20.dp) + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier.size(40.dp).clip(RoundedCornerShape(12.dp)).background(MaterialTheme.colorScheme.secondaryContainer), + contentAlignment = Alignment.Center + ) { + Icon(AppIcons.Default.Folder, contentDescription = null, tint = MaterialTheme.colorScheme.onSecondaryContainer) + } + Column(modifier = Modifier.weight(1f).padding(start = 16.dp)) { + Text( + driver.name, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + listOfNotNull( + if (driver.version.isNotEmpty()) "v${driver.version}" else null, + if (driver.author.isNotEmpty()) "by ${driver.author}" else null + ).joinToString(" • "), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Button( + onClick = onInstall, + contentPadding = PaddingValues(horizontal = 12.dp), + modifier = Modifier.height(32.dp) + ) { + Text("Install", style = MaterialTheme.typography.labelSmall) + } + } + } + } + + @Composable + private fun RemoteGroupItem( + group: GpuDriverManager.RemoteDriverGroup, + isExpanded: Boolean, + onToggle: () -> Unit, + content: @Composable ColumnScope.() -> Unit + ) { + Column(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) { + Surface( + onClick = onToggle, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f), + border = if (isExpanded) androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.primary) else null + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(36.dp) + .clip(RoundedCornerShape(10.dp)) + .background(MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center + ) { + Icon(AppIcons.Default.Folder, contentDescription = null, tint = MaterialTheme.colorScheme.onPrimaryContainer, modifier = Modifier.size(20.dp)) + } + Spacer(modifier = Modifier.width(16.dp)) + Text( + group.name, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f) + ) + Icon( + if (isExpanded) AppIcons.Default.ExpandLess else AppIcons.Default.ExpandMore, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + } + } + AnimatedVisibility( + visible = isExpanded, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut() + ) { + Column( + modifier = Modifier.padding(start = 8.dp, top = 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + content() + } + } + } + } + + @Composable + private fun RemoteReleaseItem( + release: GpuDriverManager.RemoteRelease, + isExpanded: Boolean, + onToggle: () -> Unit, + downloadProgress: Float?, + adrenoModel: Int, + onDownload: (GpuDriverManager.RemoteGpuDriver) -> Unit + ) { + Column(modifier = Modifier.fillMaxWidth()) { + Surface( + onClick = onToggle, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + color = if (release.isLatest) MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.2f) else MaterialTheme.colorScheme.surfaceContainerLow + ) { + Row( + modifier = Modifier.padding(14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(36.dp) + .clip(RoundedCornerShape(10.dp)) + .background(if (release.isLatest) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center + ) { + Icon( + if (release.isLatest) AppIcons.Default.NewReleases else AppIcons.Default.Aperture, + contentDescription = null, + tint = if (release.isLatest) MaterialTheme.colorScheme.onSecondaryContainer else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp) + ) + } + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + release.title, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + if (release.isLatest) { + Spacer(modifier = Modifier.width(8.dp)) + Surface( + color = MaterialTheme.colorScheme.primary, + shape = RoundedCornerShape(6.dp) + ) { + Text( + "LATEST", + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + style = MaterialTheme.typography.labelSmall.copy(fontSize = 9.sp), + fontWeight = FontWeight.Black, + color = MaterialTheme.colorScheme.onPrimary + ) + } + } + } + Text( + text = "Tag: ${release.version} • ${release.drivers.size} assets", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.outline + ) + } + Icon( + if (isExpanded) AppIcons.Default.ExpandLess else AppIcons.Default.ExpandMore, + contentDescription = null, + tint = MaterialTheme.colorScheme.outline + ) + } + } + AnimatedVisibility( + visible = isExpanded, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut() + ) { + Column( + modifier = Modifier.padding(start = 12.dp, top = 6.dp, bottom = 4.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + release.drivers.forEach { driver -> + val isRecommended = remember(driver.name, adrenoModel) { + if (adrenoModel >= 800) driver.name.contains("8xx", ignoreCase = true) + else if (adrenoModel >= 700) driver.name.contains("7xx", ignoreCase = true) + else if (adrenoModel >= 600) driver.name.contains("6xx", ignoreCase = true) + else false + } + RemoteGpuDriverItem( + driver = driver, + isRecommended = isRecommended, + isDownloading = downloadProgress != null, + onDownload = { onDownload(driver) } + ) + } + } + } + } + } + + @Composable + private fun TechnicalInfoDialog(onDismiss: () -> Unit) { + val context = LocalContext.current + val driverManager = koinInject() + + val systemInfo = remember { + buildString { + appendLine("=== General Information ===") + appendLine("Manufacturer: ${Build.MANUFACTURER}") + appendLine("Model: ${Build.MODEL}") + appendLine("Device: ${Build.DEVICE}") + appendLine("Hardware: ${Build.HARDWARE}") + appendLine("Supported ABIs: ${Build.SUPPORTED_ABIS.joinToString(", ")}") + appendLine("Android Version: ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})") + + appendLine() + appendLine("=== CPU Info ===") + appendLine("Processor/Board: ${Build.BOARD}") + + appendLine() + appendLine("=== GPU Information ===") + val gpuModel = driverManager.getGpuModel() + appendLine("GPU Model: $gpuModel") + + val vulkanFeature = context.packageManager.systemAvailableFeatures.find { + it.name == PackageManager.FEATURE_VULKAN_HARDWARE_VERSION + } + vulkanFeature?.let { + val version = it.version + val major = version shr 22 + val minor = (version shr 12) and 0x3FF + val patch = version and 0xFFF + appendLine("Vulkan Hardware Version: $major.$minor.$patch") + } + + appendLine() + appendLine("=== Memory Info ===") + val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as android.app.ActivityManager + val memInfo = android.app.ActivityManager.MemoryInfo() + activityManager.getMemoryInfo(memInfo) + val totalRam = memInfo.totalMem / (1024 * 1024) + val availableRam = memInfo.availMem / (1024 * 1024) + + appendLine("Total RAM: $totalRam MB") + appendLine("Available RAM: $availableRam MB") + if (memInfo.lowMemory) { + appendLine("Status: Low Memory Warning Active") + } + } + } + + LiquidDialog( + onDismissRequest = onDismiss, + title = { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(AppIcons.Default.DeveloperBoard, contentDescription = null, tint = MaterialTheme.colorScheme.primary) + Spacer(modifier = Modifier.width(12.dp)) + Text("Technical Info") + } + IconButton( + onClick = { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager + val clip = android.content.ClipData.newPlainText("MpvRx System Info", systemInfo) + clipboard.setPrimaryClip(clip) + Toast.makeText(context, "System info copied to clipboard", Toast.LENGTH_SHORT).show() + } + ) { + Icon(AppIcons.Default.ContentCopy, contentDescription = "Copy", tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp)) + } + } + }, + text = { + Box(modifier = Modifier.heightIn(max = 400.dp)) { + val scrollState = rememberScrollState() + Column(modifier = Modifier.verticalScroll(scrollState)) { + Text( + text = systemInfo, + style = MaterialTheme.typography.bodySmall.copy( + fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, + lineHeight = 16.sp + ), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text("Close") + } + }, + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = RoundedCornerShape(28.dp) + ) + } + + @Composable + private fun RemoteGpuDriverItem( + driver: GpuDriverManager.RemoteGpuDriver, + isRecommended: Boolean, + isDownloading: Boolean, + onDownload: () -> Unit + ) { + Surface( + onClick = onDownload, + enabled = !isDownloading, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + color = if (isRecommended) MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.3f) else MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.4f), + border = if (isRecommended) androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.secondary.copy(alpha = 0.5f)) else null + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + if (isRecommended) AppIcons.Default.AutoAwesome else AppIcons.Default.Download, + contentDescription = null, + tint = if (isRecommended) MaterialTheme.colorScheme.secondary else MaterialTheme.colorScheme.primary, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(10.dp)) + Text( + driver.name, + style = MaterialTheme.typography.labelLarge, + fontWeight = if (isRecommended) FontWeight.Bold else FontWeight.Normal, + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + if (isRecommended) { + Text( + "MATCH", + style = MaterialTheme.typography.labelSmall.copy(fontSize = 8.sp), + color = MaterialTheme.colorScheme.secondary, + fontWeight = FontWeight.Black, + modifier = Modifier.padding(end = 8.dp) + ) + } + Icon( + AppIcons.Default.ChevronRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.outline, + modifier = Modifier.size(16.dp) + ) + } + } + } +} diff --git a/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/LiquidSettingsScreen.kt b/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/LiquidSettingsScreen.kt new file mode 100644 index 000000000..313ba45f0 --- /dev/null +++ b/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/LiquidSettingsScreen.kt @@ -0,0 +1,1070 @@ +package app.gyrolet.mpvrx.ui.preferences + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +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 +import app.gyrolet.mpvrx.R +import app.gyrolet.mpvrx.preferences.AppearancePreferences +import app.gyrolet.mpvrx.preferences.preference.collectAsState +import app.gyrolet.mpvrx.presentation.Screen +import app.gyrolet.mpvrx.presentation.components.ConfirmDialog +import app.gyrolet.mpvrx.ui.components.LiquidToggle +import app.gyrolet.mpvrx.ui.icons.AppIcon +import app.gyrolet.mpvrx.ui.icons.Icon +import app.gyrolet.mpvrx.ui.icons.Icons +import me.zhanghai.compose.preference.ProvidePreferenceLocals +import me.zhanghai.compose.preference.ProvidePreferenceLocals +import app.gyrolet.mpvrx.ui.preferences.components.AdaptiveSwitchPreference +import me.zhanghai.compose.preference.ProvidePreferenceLocals +import app.gyrolet.mpvrx.ui.utils.LocalBackStack +import app.gyrolet.mpvrx.ui.utils.popSafely +import com.kyant.backdrop.backdrops.rememberLayerBackdrop +import kotlinx.serialization.Serializable +import org.koin.compose.koinInject +import kotlin.math.roundToInt + +@Serializable +object LiquidSettingsScreen : Screen { + @OptIn(ExperimentalMaterial3Api::class) + @Composable + override fun Content() { + val preferences = koinInject() + val backstack = LocalBackStack.current + val screenBackdrop = rememberLayerBackdrop() + val enableLiquidGlassCurrent by preferences.enableLiquidGlass.collectAsState() + + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + text = "Liquid Glass Effects", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.ExtraBold, + color = MaterialTheme.colorScheme.primary, + ) + }, + navigationIcon = { + IconButton(onClick = { backstack.popSafely() }) { + Icon( + Icons.Default.ArrowBack, + contentDescription = null, + tint = MaterialTheme.colorScheme.secondary, + ) + } + }, + ) + }, + ) { padding -> + ProvidePreferenceLocals { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(padding), + ) { + item { + PreferenceSectionHeader(title = "General") + } + + item { + PreferenceCard { + val enableLiquidGlass by preferences.enableLiquidGlass.collectAsState() + AdaptiveSwitchPreference( + value = enableLiquidGlass, + onValueChange = { enabled -> + if (enabled && + preferences.liquidButtonBlur.get() <= 0f && + preferences.liquidButtonLensRadius.get() <= 0f && + preferences.liquidButtonLensDepth.get() <= 0f + ) { + preferences.liquidButtonBlur.set(26f) + preferences.liquidButtonLensRadius.set(42f) + preferences.liquidButtonLensDepth.set(72f) + preferences.liquidDialogBlur.set(32f) + preferences.liquidDialogSaturation.set(1.3f) + preferences.liquidDialogBrightness.set(0.08f) + preferences.liquidDialogLensRadius.set(55f) + preferences.liquidDialogLensDepth.set(85f) + preferences.liquidDialogContainerAlpha.set(0.35f) + preferences.liquidButtonOpacity.set(0.15f) + preferences.liquidButtonTint.set(0x26FFFFFF) + } + preferences.enableLiquidGlass.set(enabled) + }, + title = { Text(text = stringResource(id = R.string.pref_anim_liquid_glass_title)) }, + summary = { + Text( + text = stringResource(id = R.string.pref_anim_liquid_glass_summary), + color = MaterialTheme.colorScheme.outline, + ) + } + ) + } + } + + if (enableLiquidGlassCurrent) { + item { + PreferenceSectionHeader(title = "Appearance & Accents") + } + + item { + val liquidToggleColor by preferences.liquidToggleColor.collectAsState() + val toggleColorIsPreset = TogglePresets.any { it.color == liquidToggleColor } + val isToggleCustomActive = !toggleColorIsPreset + + PreferenceCard { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 12.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Toggle Button Accent", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = "Accent glow for liquid toggle floating actions", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.outline, + ) + } + Box( + modifier = Modifier + .size(36.dp) + .background( + color = Color(liquidToggleColor), + shape = CircleShape + ) + .border(2.dp, Color.White.copy(alpha = 0.25f), CircleShape) + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + Box( + modifier = Modifier + .fillMaxWidth() + .background( + color = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.2f), + shape = RoundedCornerShape(16.dp) + ) + .padding(vertical = 16.dp), + contentAlignment = Alignment.Center + ) { + var previewSelected by remember { mutableStateOf(true) } + LiquidToggle( + selected = { previewSelected }, + onSelect = { previewSelected = it }, + backdrop = screenBackdrop, + accentColor = Color(liquidToggleColor) + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + TogglePresets.forEach { preset -> + PremiumColorChip( + name = preset.name, + presetColor = Color(preset.color), + selected = liquidToggleColor == preset.color, + onClick = { preferences.liquidToggleColor.set(preset.color) } + ) + } + CustomColorChip( + selected = isToggleCustomActive, + onClick = { + if (!isToggleCustomActive) { + preferences.liquidToggleColor.set(0xFF536DFE.toInt()) + } + } + ) + } + + AnimatedVisibility(visible = isToggleCustomActive) { + Column { + Spacer(modifier = Modifier.height(12.dp)) + PremiumColorPicker( + color = liquidToggleColor, + onColorChange = { preferences.liquidToggleColor.set(it) }, + badgeColor = Color(liquidToggleColor) + ) + } + } + } + } + } + + item { + val liquidSeekbarColor by preferences.liquidSeekbarColor.collectAsState() + val seekbarColorIsPreset = SeekbarPresets.any { it.color == liquidSeekbarColor } + val isSeekbarCustomActive = !seekbarColorIsPreset + + PreferenceCard { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 12.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Seekbar Accent Color", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = "Accent glow for premium player seek bar lines", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.outline, + ) + } + Box( + modifier = Modifier + .size(36.dp) + .background( + color = Color(liquidSeekbarColor), + shape = CircleShape + ) + .border(2.dp, Color.White.copy(alpha = 0.25f), CircleShape) + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + Box( + modifier = Modifier + .fillMaxWidth() + .background( + color = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.2f), + shape = RoundedCornerShape(16.dp) + ) + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier + .fillMaxWidth(0.9f) + .height(6.dp) + .background( + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f), + shape = RoundedCornerShape(3.dp) + ), + contentAlignment = Alignment.CenterStart + ) { + Box( + modifier = Modifier + .fillMaxWidth(0.65f) + .height(6.dp) + .background( + brush = Brush.horizontalGradient( + listOf(Color(liquidSeekbarColor), Color(liquidSeekbarColor).copy(alpha = 0.6f)) + ), + shape = RoundedCornerShape(3.dp) + ) + ) + Box( + modifier = Modifier + .size(16.dp) + .background( + color = Color(liquidSeekbarColor), + shape = CircleShape + ) + .border(2.dp, Color.White, CircleShape) + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + SeekbarPresets.forEach { preset -> + PremiumColorChip( + name = preset.name, + presetColor = Color(preset.color), + selected = liquidSeekbarColor == preset.color, + onClick = { preferences.liquidSeekbarColor.set(preset.color) } + ) + } + CustomColorChip( + selected = isSeekbarCustomActive, + onClick = { + if (!isSeekbarCustomActive) { + preferences.liquidSeekbarColor.set(0xFFFF4500.toInt()) + } + } + ) + } + + AnimatedVisibility(visible = isSeekbarCustomActive) { + Column { + Spacer(modifier = Modifier.height(12.dp)) + PremiumColorPicker( + color = liquidSeekbarColor, + onColorChange = { preferences.liquidSeekbarColor.set(it) }, + badgeColor = Color(liquidSeekbarColor) + ) + } + } + } + } + } + + item { + PreferenceSectionHeader(title = "Buttons Parameters") + } + + item { + val liquidBlur by preferences.liquidButtonBlur.collectAsState() + val liquidLensRadius by preferences.liquidButtonLensRadius.collectAsState() + val liquidLensDepth by preferences.liquidButtonLensDepth.collectAsState() + val liquidOpacity by preferences.liquidButtonOpacity.collectAsState() + val liquidTint by preferences.liquidButtonTint.collectAsState() + + PreferenceCard { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 12.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Liquid Button Parameters", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = "Customize physical refraction and lens distortion settings", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.outline, + ) + } + Icon( + imageVector = Icons.Default.Tune, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp) + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + PremiumParameterSlider( + value = liquidBlur, + onValueChange = { preferences.liquidButtonBlur.set(it) }, + valueRange = 0f..64f, + title = "Blur Intensity", + summary = "Softness and glow propagation of the backdrop glass", + icon = Icons.Default.BlurOn, + accentColor = MaterialTheme.colorScheme.primary + ) + + PremiumParameterSlider( + value = liquidLensRadius, + onValueChange = { preferences.liquidButtonLensRadius.set(it) }, + valueRange = 0f..100f, + title = "Lens Radius", + summary = "Horizontal scale of the physical chromatic lens", + icon = Icons.Default.AspectRatio, + accentColor = MaterialTheme.colorScheme.secondary + ) + + PremiumParameterSlider( + value = liquidLensDepth, + onValueChange = { preferences.liquidButtonLensDepth.set(it) }, + valueRange = 0f..200f, + title = "Lens Depth", + summary = "Chromatic aberration offset and optical refraction index", + icon = Icons.Default.BlurOff, + accentColor = MaterialTheme.colorScheme.tertiary + ) + + PremiumParameterSlider( + value = liquidOpacity, + onValueChange = { preferences.liquidButtonOpacity.set(it) }, + valueRange = 0f..1f, + title = "Button Opacity", + summary = "Base translucency of the liquid button", + icon = Icons.Default.Opacity, + accentColor = MaterialTheme.colorScheme.primary + ) + + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = "Button Tint", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(bottom = 8.dp) + ) + PremiumColorPicker( + color = liquidTint, + onColorChange = { preferences.liquidButtonTint.set(it) }, + badgeColor = Color(liquidTint) + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + TextButton( + onClick = { + preferences.liquidButtonBlur.set(26f) + preferences.liquidButtonLensRadius.set(42f) + preferences.liquidButtonLensDepth.set(72f) + preferences.liquidButtonOpacity.set(0.15f) + preferences.liquidButtonTint.set(0x26FFFFFF) + }, + colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error) + ) { + Icon( + imageVector = Icons.Default.Restore, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + Spacer(Modifier.width(6.dp)) + Text("Reset Parameters") + } + } + } + } + } + + item { + PreferenceSectionHeader(title = "Dialog & Sheet Parameters") + } + + item { + val liquidBlur by preferences.liquidDialogBlur.collectAsState() + val liquidSaturation by preferences.liquidDialogSaturation.collectAsState() + val liquidBrightness by preferences.liquidDialogBrightness.collectAsState() + val liquidLensRadius by preferences.liquidDialogLensRadius.collectAsState() + val liquidLensDepth by preferences.liquidDialogLensDepth.collectAsState() + val liquidAlpha by preferences.liquidDialogContainerAlpha.collectAsState() + val liquidDarkText by preferences.liquidDialogDarkText.collectAsState() + var showPreview by remember { mutableStateOf(false) } + + if (showPreview) { + ConfirmDialog( + title = "Liquid Dialog Preview", + subtitle = "This is a mockup of how your dialogs and sheets will look with the current liquid glass settings.", + onConfirm = { showPreview = false }, + onCancel = { showPreview = false } + ) + } + + PreferenceCard { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 12.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Liquid Dialog & Sheet Parameters", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = "Tweak optical effects, saturation, opacity, and chromatic aberration", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.outline, + ) + } + Icon( + imageVector = Icons.Default.Settings, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp) + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + Button( + onClick = { showPreview = true }, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer + ), + shape = RoundedCornerShape(16.dp) + ) { + Icon( + imageVector = Icons.Default.PlayCircle, + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + Spacer(Modifier.width(8.dp)) + Text("Show Live Preview") + } + + Spacer(modifier = Modifier.height(12.dp)) + + app.gyrolet.mpvrx.ui.preferences.components.AdaptiveSwitchPreference( + value = liquidDarkText, + onValueChange = { preferences.liquidDialogDarkText.set(it) }, + title = { Text("Use Dark Text", style = MaterialTheme.typography.titleMedium) }, + summary = { + Text( + text = "Use dark text for better readability on lighter video backgrounds", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.outline + ) + } + ) + + Spacer(modifier = Modifier.height(4.dp)) + + PremiumParameterSlider( + value = liquidBlur, + onValueChange = { preferences.liquidDialogBlur.set(it) }, + valueRange = 0f..64f, + title = "Blur Intensity", + summary = "Overall backdrop blur diffusion radius", + icon = Icons.Default.BlurOn, + accentColor = MaterialTheme.colorScheme.primary + ) + + PremiumParameterSlider( + value = liquidSaturation, + onValueChange = { preferences.liquidDialogSaturation.set(it) }, + valueRange = 0f..3f, + title = "Backdrop Saturation", + summary = "Vibrancy and depth multiplier of underlying content", + icon = Icons.Default.AutoAwesome, + accentColor = MaterialTheme.colorScheme.secondary + ) + + PremiumParameterSlider( + value = liquidBrightness, + onValueChange = { preferences.liquidDialogBrightness.set(it) }, + valueRange = -1f..1f, + title = "Brightness Offset", + summary = "Exposure boost or dimming overlay for readability", + icon = Icons.Default.BrightnessMedium, + accentColor = MaterialTheme.colorScheme.tertiary + ) + + PremiumParameterSlider( + value = liquidLensRadius, + onValueChange = { preferences.liquidDialogLensRadius.set(it) }, + valueRange = 0f..100f, + title = "Lens Radius", + summary = "Glass curvature diameter for the dialog pane", + icon = Icons.Default.AspectRatio, + accentColor = MaterialTheme.colorScheme.primary + ) + + PremiumParameterSlider( + value = liquidLensDepth, + onValueChange = { preferences.liquidDialogLensDepth.set(it) }, + valueRange = 0f..200f, + title = "Lens Depth", + summary = "Chromatic edge scattering and refraction power", + icon = Icons.Default.BlurOff, + accentColor = MaterialTheme.colorScheme.secondary + ) + + PremiumParameterSlider( + value = liquidAlpha, + onValueChange = { preferences.liquidDialogContainerAlpha.set(it) }, + valueRange = 0f..1f, + title = "Base Container Alpha", + summary = "Base translucency of the dialog backing plate", + icon = Icons.Default.Opacity, + accentColor = MaterialTheme.colorScheme.tertiary + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + TextButton( + onClick = { + preferences.liquidDialogBlur.set(32f) + preferences.liquidDialogSaturation.set(1.3f) + preferences.liquidDialogBrightness.set(0.08f) + preferences.liquidDialogLensRadius.set(55f) + preferences.liquidDialogLensDepth.set(85f) + preferences.liquidDialogContainerAlpha.set(0.35f) + }, + colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error) + ) { + Icon( + imageVector = Icons.Default.Restore, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + Spacer(Modifier.width(6.dp)) + Text("Reset Parameters") + } + } + } + } + } + } + } + } + } +} + +data class ColorPreset( + val name: String, + val color: Int +) + +val TogglePresets = listOf( + ColorPreset("Royal Indigo", 0xFF536DFE.toInt()), + ColorPreset("Teal Dream", 0xFF00BFA5.toInt()), + ColorPreset("Amber Flare", 0xFFFFAB00.toInt()), + ColorPreset("Sunset Rose", 0xFFFF4081.toInt()), + ColorPreset("Emerald Glow", 0xFF00E676.toInt()), + ColorPreset("Navy Blue", 0xFF000080.toInt()), +) + +val SeekbarPresets = listOf( + ColorPreset("Chili Orange", 0xFFFF4500.toInt()), + ColorPreset("Ocean Breeze", 0xFF00B0FF.toInt()), + ColorPreset("Teal Dream", 0xFF00BFA5.toInt()), + ColorPreset("Sunset Rose", 0xFFFF4081.toInt()), + ColorPreset("Emerald Glow", 0xFF00E676.toInt()), + ColorPreset("Deep Violet", 0xFF7C4DFF.toInt()), +) + +@Composable +fun PremiumColorChip( + name: String, + presetColor: Color, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val scale by animateFloatAsState( + targetValue = if (selected) 1.05f else 1f, + animationSpec = spring(stiffness = Spring.StiffnessLow), + label = "chipScale" + ) + + Card( + modifier = modifier + .graphicsLayer { + scaleX = scale + scaleY = scale + } + .border( + width = 2.dp, + brush = Brush.linearGradient( + colors = if (selected) { + listOf(presetColor, presetColor.copy(alpha = 0.5f)) + } else { + listOf(MaterialTheme.colorScheme.outline.copy(alpha = 0.1f), MaterialTheme.colorScheme.outline.copy(alpha = 0.05f)) + } + ), + shape = RoundedCornerShape(16.dp) + ) + .clickable(onClick = onClick), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = if (selected) 0.3f else 0.1f) + ) + ) { + Column( + modifier = Modifier + .width(100.dp) + .padding(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Box( + modifier = Modifier + .size(40.dp) + .background( + brush = Brush.radialGradient( + colors = listOf(presetColor, presetColor.copy(alpha = 0.6f)) + ), + shape = CircleShape + ) + .border(1.5.dp, Color.White.copy(alpha = 0.25f), CircleShape), + contentAlignment = Alignment.Center + ) { + if (selected) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = "Selected", + tint = Color.White, + modifier = Modifier.size(18.dp) + ) + } + } + Text( + text = name, + style = MaterialTheme.typography.labelMedium, + fontWeight = if (selected) FontWeight.Bold else FontWeight.Medium, + color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +@Composable +fun CustomColorChip( + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val scale by animateFloatAsState( + targetValue = if (selected) 1.05f else 1f, + animationSpec = spring(stiffness = Spring.StiffnessLow), + label = "chipScale" + ) + val rainbowColors = listOf( + Color(0xFFFF0000), Color(0xFFFF7F00), Color(0xFFFFFF00), + Color(0xFF00FF00), Color(0xFF0000FF), Color(0xFF4B0082), Color(0xFF9400D3) + ) + Card( + modifier = modifier + .graphicsLayer { + scaleX = scale + scaleY = scale + } + .border( + width = 2.dp, + brush = Brush.sweepGradient(rainbowColors), + shape = RoundedCornerShape(16.dp) + ) + .clickable(onClick = onClick), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = if (selected) 0.3f else 0.1f) + ) + ) { + Column( + modifier = Modifier + .width(100.dp) + .padding(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Box( + modifier = Modifier + .size(40.dp) + .background( + brush = Brush.sweepGradient(rainbowColors), + shape = CircleShape + ) + .border(1.5.dp, Color.White.copy(alpha = 0.25f), CircleShape), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Palette, + contentDescription = "Custom Color", + tint = Color.White, + modifier = Modifier.size(18.dp) + ) + } + Text( + text = "Custom", + style = MaterialTheme.typography.labelMedium, + fontWeight = if (selected) FontWeight.Bold else FontWeight.Medium, + color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +@Composable +fun PremiumGradientSlider( + value: Float, + onValueChange: (Float) -> Unit, + valueRange: ClosedFloatingPointRange, + title: String, + gradientColors: List, + badgeColor: Color, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(vertical = 6.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) + Box( + modifier = Modifier + .background( + color = badgeColor.copy(alpha = 0.2f), + shape = RoundedCornerShape(8.dp) + ) + .padding(horizontal = 8.dp, vertical = 2.dp) + ) { + Text( + text = value.roundToInt().toString(), + style = MaterialTheme.typography.labelSmall, + color = badgeColor, + fontWeight = FontWeight.Bold + ) + } + } + + Spacer(modifier = Modifier.height(6.dp)) + + Box( + modifier = Modifier + .fillMaxWidth() + .height(36.dp), + contentAlignment = Alignment.CenterStart + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(8.dp) + .background( + brush = Brush.horizontalGradient(gradientColors), + shape = RoundedCornerShape(4.dp) + ) + ) + + Slider( + value = value, + onValueChange = onValueChange, + valueRange = valueRange, + modifier = Modifier.fillMaxWidth(), + colors = SliderDefaults.colors( + activeTrackColor = Color.Transparent, + inactiveTrackColor = Color.Transparent, + thumbColor = badgeColor, + activeTickColor = Color.Transparent, + inactiveTickColor = Color.Transparent + ) + ) + } + } +} + +@Composable +fun PremiumParameterSlider( + value: Float, + onValueChange: (Float) -> Unit, + valueRange: ClosedFloatingPointRange, + title: String, + summary: String, + icon: AppIcon, + accentColor: Color, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier + .fillMaxWidth() + .padding(vertical = 6.dp), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.15f) + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(40.dp) + .background( + color = accentColor.copy(alpha = 0.15f), + shape = RoundedCornerShape(12.dp) + ), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = accentColor, + modifier = Modifier.size(22.dp) + ) + } + + Spacer(modifier = Modifier.width(12.dp)) + + Column(modifier = Modifier.weight(1f)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold + ) + Text( + text = summary, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.outline + ) + } + } + + Slider( + value = value, + onValueChange = onValueChange, + valueRange = valueRange, + modifier = Modifier.fillMaxWidth(), + colors = SliderDefaults.colors( + activeTrackColor = accentColor, + thumbColor = accentColor + ) + ) + } + } + } +} + +@Composable +fun PremiumColorPicker( + color: Int, + onColorChange: (Int) -> Unit, + badgeColor: Color +) { + val r = (color shr 16) and 0xFF + val g = (color shr 8) and 0xFF + val b = color and 0xFF + val a = (color shr 24) and 0xFF + + fun updateColor(nr: Int = r, ng: Int = g, nb: Int = b, na: Int = a) { + onColorChange((na shl 24) or (nr shl 16) or (ng shl 8) or nb) + } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + PremiumGradientSlider( + value = r.toFloat(), + onValueChange = { updateColor(nr = it.roundToInt()) }, + valueRange = 0f..255f, + title = "Red Channel", + gradientColors = listOf(Color(0xFF000000), Color(0xFFFF0000)), + badgeColor = Color(0xFFFF3B30) + ) + PremiumGradientSlider( + value = g.toFloat(), + onValueChange = { updateColor(ng = it.roundToInt()) }, + valueRange = 0f..255f, + title = "Green Channel", + gradientColors = listOf(Color(0xFF000000), Color(0xFF00FF00)), + badgeColor = Color(0xFF34C759) + ) + PremiumGradientSlider( + value = b.toFloat(), + onValueChange = { updateColor(nb = it.roundToInt()) }, + valueRange = 0f..255f, + title = "Blue Channel", + gradientColors = listOf(Color(0xFF000000), Color(0xFF0000FF)), + badgeColor = Color(0xFF007AFF) + ) + PremiumGradientSlider( + value = a.toFloat(), + onValueChange = { updateColor(na = it.roundToInt()) }, + valueRange = 0f..255f, + title = "Alpha Transparency", + gradientColors = listOf(Color(0x00FFFFFF), Color(0xFFFFFFFF)), + badgeColor = badgeColor + ) + } +} + +@Composable +fun PreferenceSectionHeader( + title: String, + modifier: Modifier = Modifier +) { + Text( + text = title.uppercase(), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + modifier = modifier + .padding(horizontal = 24.dp, vertical = 16.dp) + .padding(top = 8.dp) + ) +} + +@Composable +fun PreferenceCard( + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit +) { + Card( + modifier = modifier.padding(horizontal = 16.dp, vertical = 4.dp), + shape = RoundedCornerShape(24.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + content = content + ) +} +} diff --git a/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/LuaScriptsScreen.kt b/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/LuaScriptsScreen.kt index 4aa5fea9d..ab25bfb29 100644 --- a/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/LuaScriptsScreen.kt +++ b/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/LuaScriptsScreen.kt @@ -211,7 +211,7 @@ object LuaScriptsScreen : Screen { } } else -> { - items(catalog.availableScripts) { scriptName -> + items(catalog.availableScripts, key = { it }) { scriptName -> LuaScriptToggleCard( scriptName = scriptName, selected = selectedScripts.contains(scriptName), diff --git a/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/PlayerControlsPreferencesScreen.kt b/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/PlayerControlsPreferencesScreen.kt index 10cd4a25b..15ce25dbb 100644 --- a/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/PlayerControlsPreferencesScreen.kt +++ b/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/PlayerControlsPreferencesScreen.kt @@ -54,7 +54,7 @@ import app.gyrolet.mpvrx.ui.utils.popSafely import kotlinx.serialization.Serializable import me.zhanghai.compose.preference.ListPreference import me.zhanghai.compose.preference.ProvidePreferenceLocals -import me.zhanghai.compose.preference.SwitchPreference +import app.gyrolet.mpvrx.ui.preferences.components.AdaptiveSwitchPreference import app.gyrolet.mpvrx.ui.player.controls.components.SeekbarStylePreview import app.gyrolet.mpvrx.ui.preferences.components.PlayerButtonChip import org.koin.compose.koinInject @@ -241,7 +241,7 @@ object PlayerControlsPreferencesScreen : Screen { var customTimeValue by remember { mutableStateOf("") } PreferenceCard { - SwitchPreference( + AdaptiveSwitchPreference( value = hidePlayerButtonsBackground, onValueChange = { appearancePrefs.hidePlayerButtonsBackground.set(it) }, title = { diff --git a/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/PlayerPreferencesScreen.kt b/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/PlayerPreferencesScreen.kt index 5098e0ef2..c086720e2 100644 --- a/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/PlayerPreferencesScreen.kt +++ b/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/PlayerPreferencesScreen.kt @@ -32,7 +32,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.gyrolet.mpvrx.ui.preferences.components.AdaptiveSwitchPreference import org.koin.compose.koinInject import kotlin.math.roundToInt import androidx.compose.ui.text.AnnotatedString @@ -92,7 +92,7 @@ object PlayerPreferencesScreen : Screen { PreferenceDivider() val savePositionOnQuit by preferences.savePositionOnQuit.collectAsState() - SwitchPreference( + AdaptiveSwitchPreference( value = savePositionOnQuit, onValueChange = preferences.savePositionOnQuit::set, title = { Text(stringResource(R.string.pref_player_save_position_on_quit)) }, @@ -101,7 +101,7 @@ object PlayerPreferencesScreen : Screen { PreferenceDivider() val closeAfterEndOfVideo by preferences.closeAfterReachingEndOfVideo.collectAsState() - SwitchPreference( + AdaptiveSwitchPreference( value = closeAfterEndOfVideo, onValueChange = preferences.closeAfterReachingEndOfVideo::set, title = { Text(stringResource(R.string.pref_player_close_after_eof)) }, @@ -110,7 +110,7 @@ object PlayerPreferencesScreen : Screen { PreferenceDivider() val autoplayNextVideo by preferences.autoplayNextVideo.collectAsState() - SwitchPreference( + AdaptiveSwitchPreference( value = autoplayNextVideo, onValueChange = preferences.autoplayNextVideo::set, title = { Text(stringResource(R.string.pref_autoplay_next_video_title)) }, @@ -126,7 +126,7 @@ object PlayerPreferencesScreen : Screen { PreferenceDivider() val playlistMode by preferences.playlistMode.collectAsState() - SwitchPreference( + AdaptiveSwitchPreference( value = playlistMode, onValueChange = preferences.playlistMode::set, title = { Text(stringResource(R.string.pref_playlist_mode_title)) }, @@ -142,7 +142,7 @@ object PlayerPreferencesScreen : Screen { PreferenceDivider() val rememberBrightness by preferences.rememberBrightness.collectAsState() - SwitchPreference( + AdaptiveSwitchPreference( value = rememberBrightness, onValueChange = preferences.rememberBrightness::set, title = { Text(stringResource(R.string.pref_player_remember_brightness)) }, @@ -151,7 +151,7 @@ object PlayerPreferencesScreen : Screen { PreferenceDivider() val autoPiPOnNavigation by preferences.autoPiPOnNavigation.collectAsState() - SwitchPreference( + AdaptiveSwitchPreference( value = autoPiPOnNavigation, onValueChange = preferences.autoPiPOnNavigation::set, title = { Text(stringResource(R.string.pref_auto_pip_title)) }, @@ -166,7 +166,7 @@ object PlayerPreferencesScreen : Screen { PreferenceDivider() val keepScreenOnWhenPaused by preferences.keepScreenOnWhenPaused.collectAsState() - SwitchPreference( + AdaptiveSwitchPreference( value = keepScreenOnWhenPaused, onValueChange = preferences.keepScreenOnWhenPaused::set, title = { Text(stringResource(R.string.pref_player_keep_screen_on_when_paused_title)) }, @@ -182,7 +182,7 @@ object PlayerPreferencesScreen : Screen { PreferenceDivider() val autoplayAfterScreenUnlock by preferences.autoplayAfterScreenUnlock.collectAsState() - SwitchPreference( + AdaptiveSwitchPreference( value = autoplayAfterScreenUnlock, onValueChange = preferences.autoplayAfterScreenUnlock::set, title = { Text(stringResource(R.string.pref_player_autoplay_after_screen_unlock_title)) }, @@ -202,7 +202,7 @@ object PlayerPreferencesScreen : Screen { item { PreferenceCard { val showDoubleTapOvals by preferences.showDoubleTapOvals.collectAsState() - SwitchPreference( + AdaptiveSwitchPreference( value = showDoubleTapOvals, onValueChange = preferences.showDoubleTapOvals::set, title = { Text(stringResource(R.string.show_splash_ovals_on_double_tap_to_seek)) }, @@ -211,7 +211,7 @@ object PlayerPreferencesScreen : Screen { PreferenceDivider() val showSeekTimeWhileSeeking by preferences.showSeekTimeWhileSeeking.collectAsState() - SwitchPreference( + AdaptiveSwitchPreference( value = showSeekTimeWhileSeeking, onValueChange = preferences.showSeekTimeWhileSeeking::set, title = { Text(stringResource(R.string.show_time_on_double_tap_to_seek)) }, @@ -220,7 +220,7 @@ object PlayerPreferencesScreen : Screen { PreferenceDivider() val showBufferedRange by preferences.showBufferedRange.collectAsState() - SwitchPreference( + AdaptiveSwitchPreference( value = showBufferedRange, onValueChange = preferences.showBufferedRange::set, title = { Text(stringResource(R.string.pref_player_show_buffered_range_title)) }, @@ -235,7 +235,7 @@ object PlayerPreferencesScreen : Screen { PreferenceDivider() val usePreciseSeeking by preferences.usePreciseSeeking.collectAsState() - SwitchPreference( + AdaptiveSwitchPreference( value = usePreciseSeeking, onValueChange = preferences.usePreciseSeeking::set, title = { Text(stringResource(R.string.pref_player_use_precise_seeking)) }, @@ -262,7 +262,7 @@ object PlayerPreferencesScreen : Screen { PreferenceDivider() val enableIntroDb by preferences.enableIntroDb.collectAsState() - SwitchPreference( + AdaptiveSwitchPreference( value = enableIntroDb, onValueChange = preferences.enableIntroDb::set, title = { Text(stringResource(R.string.pref_online_skip_markers_title)) }, @@ -296,7 +296,7 @@ object PlayerPreferencesScreen : Screen { PreferenceDivider() val detectFromChapters by preferences.detectIntroOutroFromChapters.collectAsState() - SwitchPreference( + AdaptiveSwitchPreference( value = detectFromChapters, onValueChange = preferences.detectIntroOutroFromChapters::set, title = { Text(stringResource(R.string.pref_chapter_detect_title)) }, @@ -311,7 +311,7 @@ object PlayerPreferencesScreen : Screen { PreferenceDivider() val autoSkipIntro by preferences.autoSkipIntro.collectAsState() - SwitchPreference( + AdaptiveSwitchPreference( value = autoSkipIntro, onValueChange = preferences.autoSkipIntro::set, title = { Text(stringResource(R.string.pref_auto_skip_intro_title)) }, @@ -326,7 +326,7 @@ object PlayerPreferencesScreen : Screen { PreferenceDivider() val autoSkipOutro by preferences.autoSkipOutro.collectAsState() - SwitchPreference( + AdaptiveSwitchPreference( value = autoSkipOutro, onValueChange = preferences.autoSkipOutro::set, title = { Text(stringResource(R.string.pref_auto_skip_outro_title)) }, @@ -345,7 +345,7 @@ object PlayerPreferencesScreen : Screen { item { PreferenceCard { val showSystemStatusBar by preferences.showSystemStatusBar.collectAsState() - SwitchPreference( + AdaptiveSwitchPreference( value = showSystemStatusBar, onValueChange = preferences.showSystemStatusBar::set, title = { Text(stringResource(R.string.pref_player_display_show_status_bar)) }, @@ -354,7 +354,7 @@ object PlayerPreferencesScreen : Screen { PreferenceDivider() val showSystemNavigationBar by preferences.showSystemNavigationBar.collectAsState() - SwitchPreference( + AdaptiveSwitchPreference( value = showSystemNavigationBar, onValueChange = preferences.showSystemNavigationBar::set, title = { Text(stringResource(R.string.pref_nav_bar_title)) }, @@ -369,7 +369,7 @@ object PlayerPreferencesScreen : Screen { PreferenceDivider() val safeAreaWindow by preferences.safeAreaWindow.collectAsState() - SwitchPreference( + AdaptiveSwitchPreference( value = safeAreaWindow, onValueChange = preferences.safeAreaWindow::set, title = { Text(stringResource(R.string.pref_player_safe_area_window_title)) }, @@ -384,7 +384,7 @@ object PlayerPreferencesScreen : Screen { PreferenceDivider() val reduceMotion by preferences.reduceMotion.collectAsState() - SwitchPreference( + AdaptiveSwitchPreference( value = reduceMotion, onValueChange = preferences.reduceMotion::set, title = { Text(stringResource(R.string.pref_player_display_reduce_player_animation)) }, @@ -393,7 +393,7 @@ object PlayerPreferencesScreen : Screen { PreferenceDivider() val showLoadingCircle by preferences.showLoadingCircle.collectAsState() - SwitchPreference( + AdaptiveSwitchPreference( value = showLoadingCircle, onValueChange = preferences.showLoadingCircle::set, title = { Text(stringResource(R.string.pref_player_controls_show_loading_circle)) }, @@ -402,7 +402,7 @@ object PlayerPreferencesScreen : Screen { PreferenceDivider() val allowGesturesInPanels by preferences.allowGesturesInPanels.collectAsState() - SwitchPreference( + AdaptiveSwitchPreference( value = allowGesturesInPanels, onValueChange = preferences.allowGesturesInPanels::set, title = { Text(stringResource(R.string.pref_player_controls_allow_gestures_in_panels)) }, @@ -411,7 +411,7 @@ object PlayerPreferencesScreen : Screen { PreferenceDivider() val swapVolumeAndBrightness by preferences.swapVolumeAndBrightness.collectAsState() - SwitchPreference( + AdaptiveSwitchPreference( value = swapVolumeAndBrightness, onValueChange = preferences.swapVolumeAndBrightness::set, title = { Text(stringResource(R.string.swap_the_volume_and_brightness_slider)) }, @@ -424,7 +424,7 @@ object PlayerPreferencesScreen : Screen { item { PreferenceCard { val showVolumeGestureOverlay by preferences.showVolumeGestureOverlay.collectAsState() - SwitchPreference( + AdaptiveSwitchPreference( value = showVolumeGestureOverlay, onValueChange = preferences.showVolumeGestureOverlay::set, title = { Text(stringResource(R.string.pref_volume_overlay_title)) }, @@ -439,7 +439,7 @@ object PlayerPreferencesScreen : Screen { PreferenceDivider() val showBrightnessGestureOverlay by preferences.showBrightnessGestureOverlay.collectAsState() - SwitchPreference( + AdaptiveSwitchPreference( value = showBrightnessGestureOverlay, onValueChange = preferences.showBrightnessGestureOverlay::set, title = { Text(stringResource(R.string.pref_brightness_overlay_title)) }, @@ -454,7 +454,7 @@ object PlayerPreferencesScreen : Screen { PreferenceDivider() val showHoldSpeedOverlay by preferences.showHoldSpeedOverlay.collectAsState() - SwitchPreference( + AdaptiveSwitchPreference( value = showHoldSpeedOverlay, onValueChange = preferences.showHoldSpeedOverlay::set, title = { Text(stringResource(R.string.pref_hold_speed_overlay_pref_title)) }, @@ -469,7 +469,7 @@ object PlayerPreferencesScreen : Screen { PreferenceDivider() val showAspectRatioOverlay by preferences.showAspectRatioOverlay.collectAsState() - SwitchPreference( + AdaptiveSwitchPreference( value = showAspectRatioOverlay, onValueChange = preferences.showAspectRatioOverlay::set, title = { Text(stringResource(R.string.pref_aspect_ratio_overlay_title)) }, @@ -484,7 +484,7 @@ object PlayerPreferencesScreen : Screen { PreferenceDivider() val showZoomLevelOverlay by preferences.showZoomLevelOverlay.collectAsState() - SwitchPreference( + AdaptiveSwitchPreference( value = showZoomLevelOverlay, onValueChange = preferences.showZoomLevelOverlay::set, title = { Text(stringResource(R.string.pref_zoom_overlay_title)) }, @@ -499,7 +499,7 @@ object PlayerPreferencesScreen : Screen { PreferenceDivider() val showRepeatShuffleOverlay by preferences.showRepeatShuffleOverlay.collectAsState() - SwitchPreference( + AdaptiveSwitchPreference( value = showRepeatShuffleOverlay, onValueChange = preferences.showRepeatShuffleOverlay::set, title = { Text(stringResource(R.string.pref_repeat_shuffle_overlay_title)) }, @@ -514,7 +514,7 @@ object PlayerPreferencesScreen : Screen { PreferenceDivider() val showActionFeedbackOverlay by preferences.showActionFeedbackOverlay.collectAsState() - SwitchPreference( + AdaptiveSwitchPreference( value = showActionFeedbackOverlay, onValueChange = preferences.showActionFeedbackOverlay::set, title = { Text(stringResource(R.string.pref_action_feedback_overlay_title)) }, diff --git a/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/PreferencesScreen.kt b/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/PreferencesScreen.kt index b93c7be00..27cec63fd 100644 --- a/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/PreferencesScreen.kt +++ b/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/PreferencesScreen.kt @@ -30,9 +30,19 @@ import app.gyrolet.mpvrx.R import app.gyrolet.mpvrx.presentation.Screen import app.gyrolet.mpvrx.ui.utils.LocalBackStack import app.gyrolet.mpvrx.ui.utils.popSafely +import app.gyrolet.mpvrx.preferences.preference.collectAsState import kotlinx.serialization.Serializable import me.zhanghai.compose.preference.Preference import me.zhanghai.compose.preference.ProvidePreferenceLocals +import org.koin.compose.koinInject +import androidx.compose.runtime.getValue +import app.gyrolet.mpvrx.exoplayer.settings.screens.medialibrary.ExoMediaLibraryPreferencesScreen +import app.gyrolet.mpvrx.exoplayer.settings.screens.player.ExoPlayerPreferencesScreen +import app.gyrolet.mpvrx.exoplayer.settings.screens.gesture.ExoGesturePreferencesScreen +import app.gyrolet.mpvrx.exoplayer.settings.screens.decoder.ExoDecoderPreferencesScreen +import app.gyrolet.mpvrx.exoplayer.settings.screens.audio.ExoAudioPreferencesScreen +import app.gyrolet.mpvrx.exoplayer.settings.screens.subtitle.ExoSubtitlePreferencesScreen +import app.gyrolet.mpvrx.exoplayer.settings.screens.general.ExoGeneralPreferencesScreen @Serializable object PreferencesScreen : Screen { @@ -40,6 +50,11 @@ object PreferencesScreen : Screen { @Composable override fun Content() { val backstack = LocalBackStack.current + android.util.Log.d("PreferencesScreen", "Content() starting") + val playerPreferences = koinInject() + val enableExoPlayer by playerPreferences.enableExoPlayer.collectAsState() + android.util.Log.d("PreferencesScreen", "enableExoPlayer: $enableExoPlayer") + Scaffold( topBar = { TopAppBar( @@ -103,118 +118,314 @@ object PreferencesScreen : Screen { } // ── 1. Appearance ───────────────────────────────────────────────── - item { PreferenceSectionHeader(title = stringResource(R.string.pref_section_appearance)) } - item { - PreferenceCard { - Preference( - title = { Text(stringResource(R.string.pref_appearance_title)) }, - summary = { Text(stringResource(R.string.pref_appearance_summary), color = MaterialTheme.colorScheme.outline) }, - icon = { Icon(Icons.Outlined.Palette, contentDescription = null, tint = MaterialTheme.colorScheme.primary) }, - onClick = { backstack.add(AppearancePreferencesScreen) }, - ) + if (!enableExoPlayer) { + item { PreferenceSectionHeader(title = stringResource(R.string.pref_section_appearance)) } + item { + PreferenceCard { + Preference( + title = { Text(stringResource(R.string.pref_appearance_title)) }, + summary = { + Text( + stringResource(R.string.pref_appearance_summary), + color = MaterialTheme.colorScheme.outline + ) + }, + icon = { + Icon( + Icons.Outlined.Palette, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + }, + onClick = { backstack.add(AppearancePreferencesScreen) }, + ) + } } } // ── 2. Playback ─────────────────────────────────────────────────── - item { PreferenceSectionHeader(title = stringResource(R.string.pref_section_playback)) } - item { - PreferenceCard { - Preference( - title = { Text(stringResource(R.string.pref_player)) }, - summary = { Text(stringResource(R.string.pref_player_summary), color = MaterialTheme.colorScheme.outline) }, - icon = { Icon(Icons.Default.Slideshow, contentDescription = null, tint = MaterialTheme.colorScheme.primary) }, - onClick = { backstack.add(PlayerPreferencesScreen) }, - ) - PreferenceDivider() - Preference( - title = { Text(stringResource(R.string.pref_decoder)) }, - summary = { Text(stringResource(R.string.pref_decoder_summary), color = MaterialTheme.colorScheme.outline) }, - icon = { Icon(Icons.Default.DeveloperBoard, contentDescription = null, tint = MaterialTheme.colorScheme.primary) }, - onClick = { backstack.add(DecoderPreferencesScreen) }, - ) - PreferenceDivider() - Preference( - title = { Text(stringResource(R.string.pref_audio)) }, - summary = { Text(stringResource(R.string.pref_audio_summary), color = MaterialTheme.colorScheme.outline) }, - icon = { Icon(Icons.Outlined.Audiotrack, contentDescription = null, tint = MaterialTheme.colorScheme.primary) }, - onClick = { backstack.add(AudioPreferencesScreen) }, - ) + if (!enableExoPlayer) { + item { PreferenceSectionHeader(title = stringResource(R.string.pref_section_playback)) } + item { + PreferenceCard { + Preference( + title = { Text(stringResource(R.string.pref_player)) }, + summary = { + Text( + stringResource(R.string.pref_player_summary), + color = MaterialTheme.colorScheme.outline + ) + }, + icon = { + Icon( + Icons.Default.Slideshow, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + }, + onClick = { backstack.add(PlayerPreferencesScreen) }, + ) + PreferenceDivider() + Preference( + title = { Text(stringResource(R.string.pref_decoder)) }, + summary = { + Text( + stringResource(R.string.pref_decoder_summary), + color = MaterialTheme.colorScheme.outline + ) + }, + icon = { + Icon( + Icons.Default.DeveloperBoard, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + }, + onClick = { backstack.add(DecoderPreferencesScreen) }, + ) + PreferenceDivider() + Preference( + title = { Text(stringResource(R.string.pref_audio)) }, + summary = { + Text( + stringResource(R.string.pref_audio_summary), + color = MaterialTheme.colorScheme.outline + ) + }, + icon = { + Icon( + Icons.Outlined.Audiotrack, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + }, + onClick = { backstack.add(AudioPreferencesScreen) }, + ) + } } } // ── 3. Gestures & Controls ──────────────────────────────────────── - item { PreferenceSectionHeader(title = stringResource(R.string.pref_section_gestures_controls)) } - item { - PreferenceCard { - Preference( - title = { Text(stringResource(R.string.pref_gesture)) }, - summary = { Text(stringResource(R.string.pref_gesture_summary), color = MaterialTheme.colorScheme.outline) }, - icon = { Icon(Icons.Outlined.Gesture, contentDescription = null, tint = MaterialTheme.colorScheme.primary) }, - onClick = { backstack.add(GesturePreferencesScreen) }, - ) - PreferenceDivider() - Preference( - title = { Text(stringResource(R.string.pref_layout_title)) }, - summary = { Text(stringResource(R.string.pref_layout_summary), color = MaterialTheme.colorScheme.outline) }, - icon = { Icon(Icons.Default.GridView, contentDescription = null, tint = MaterialTheme.colorScheme.primary) }, - onClick = { backstack.add(PlayerControlsPreferencesScreen) }, - ) + if (!enableExoPlayer) { + item { PreferenceSectionHeader(title = stringResource(R.string.pref_section_gestures_controls)) } + item { + PreferenceCard { + Preference( + title = { Text(stringResource(R.string.pref_gesture)) }, + summary = { + Text( + stringResource(R.string.pref_gesture_summary), + color = MaterialTheme.colorScheme.outline + ) + }, + icon = { + Icon( + Icons.Outlined.Gesture, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + }, + onClick = { backstack.add(GesturePreferencesScreen) }, + ) + PreferenceDivider() + Preference( + title = { Text(stringResource(R.string.pref_layout_title)) }, + summary = { + Text( + stringResource(R.string.pref_layout_summary), + color = MaterialTheme.colorScheme.outline + ) + }, + icon = { + Icon( + Icons.Default.GridView, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + }, + onClick = { backstack.add(PlayerControlsPreferencesScreen) }, + ) + } } } // ── 4. Subtitles ────────────────────────────────────────────────── - item { PreferenceSectionHeader(title = stringResource(R.string.pref_section_subtitles)) } - item { - PreferenceCard { - Preference( - title = { Text(stringResource(R.string.pref_subtitles)) }, - summary = { Text(stringResource(R.string.pref_subtitles_summary), color = MaterialTheme.colorScheme.outline) }, - icon = { Icon(Icons.Outlined.Subtitles, contentDescription = null, tint = MaterialTheme.colorScheme.primary) }, - onClick = { backstack.add(SubtitlesPreferencesScreen) }, - ) + if (!enableExoPlayer) { + item { PreferenceSectionHeader(title = stringResource(R.string.pref_section_subtitles)) } + item { + PreferenceCard { + Preference( + title = { Text(stringResource(R.string.pref_subtitles)) }, + summary = { + Text( + stringResource(R.string.pref_subtitles_summary), + color = MaterialTheme.colorScheme.outline + ) + }, + icon = { + Icon( + Icons.Outlined.Subtitles, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + }, + onClick = { backstack.add(SubtitlesPreferencesScreen) }, + ) + } } } // ── 5. Storage ──────────────────────────────────────────────────── - item { PreferenceSectionHeader(title = stringResource(R.string.pref_section_storage)) } - item { - PreferenceCard { - Preference( - title = { Text(stringResource(R.string.pref_folders_title)) }, - summary = { Text(stringResource(R.string.pref_section_storage_summary), color = MaterialTheme.colorScheme.outline) }, - icon = { Icon(Icons.Outlined.Folder, contentDescription = null, tint = MaterialTheme.colorScheme.primary) }, - onClick = { backstack.add(FoldersPreferencesScreen) }, - ) + if (!enableExoPlayer) { + item { PreferenceSectionHeader(title = stringResource(R.string.pref_section_storage)) } + item { + PreferenceCard { + Preference( + title = { Text(stringResource(R.string.pref_folders_title)) }, + summary = { + Text( + stringResource(R.string.pref_section_storage_summary), + color = MaterialTheme.colorScheme.outline + ) + }, + icon = { + Icon( + Icons.Outlined.Folder, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + }, + onClick = { backstack.add(FoldersPreferencesScreen) }, + ) + } } } // ── 6. AI Integration ────────────────────────────────────────────── - item { PreferenceSectionHeader(title = stringResource(R.string.pref_section_ai)) } - item { - PreferenceCard { - Preference( - title = { Text(stringResource(R.string.pref_section_ai_title)) }, - summary = { Text(stringResource(R.string.pref_section_ai_summary), color = MaterialTheme.colorScheme.outline) }, - icon = { Icon(Icons.Default.AutoAwesome, contentDescription = null, tint = MaterialTheme.colorScheme.primary) }, - onClick = { backstack.add(AiIntegrationScreen) }, - ) + if (!enableExoPlayer) { + item { PreferenceSectionHeader(title = stringResource(R.string.pref_section_ai)) } + item { + PreferenceCard { + Preference( + title = { Text(stringResource(R.string.pref_section_ai_title)) }, + summary = { + Text( + stringResource(R.string.pref_section_ai_summary), + color = MaterialTheme.colorScheme.outline + ) + }, + icon = { + Icon( + Icons.Default.AutoAwesome, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + }, + onClick = { backstack.add(AiIntegrationScreen) }, + ) + } } } - // ── 7. Advanced ─────────────────────────────────────────────────── - item { PreferenceSectionHeader(title = stringResource(R.string.pref_section_advanced)) } + // ── 7. ExoPlayer ────────────────────────────────────────────────── + item { PreferenceSectionHeader(title = "ExoPlayer") } item { PreferenceCard { - Preference( - title = { Text(stringResource(R.string.pref_advanced)) }, - summary = { Text(stringResource(R.string.pref_advanced_summary), color = MaterialTheme.colorScheme.outline) }, - icon = { Icon(Icons.Alternatives.AdvancedSettings, contentDescription = null, tint = MaterialTheme.colorScheme.primary) }, - onClick = { backstack.add(AdvancedPreferencesScreen) }, + app.gyrolet.mpvrx.ui.preferences.components.AdaptiveSwitchPreference( + value = enableExoPlayer, + onValueChange = { playerPreferences.enableExoPlayer.set(it) }, + title = { Text("Enable ExoPlayer") }, + summary = { Text("Use ExoPlayer as the video engine instead of mpv") }, + icon = { + Icon( + Icons.Default.PlayCircle, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + }, ) + + if (enableExoPlayer) { + // Ported settings from One-Player + Preference( + title = { Text("Media Library") }, + summary = { Text("Ignore .nomedia, thumbnail generation", color = MaterialTheme.colorScheme.outline) }, + icon = { Icon(Icons.Default.Movie, contentDescription = null, tint = MaterialTheme.colorScheme.primary) }, + onClick = { backstack.add(ExoMediaLibraryPreferencesScreen) }, + ) + PreferenceDivider() + Preference( + title = { Text("Player") }, + summary = { Text("Resume, speed, loop mode, orientation", color = MaterialTheme.colorScheme.outline) }, + icon = { Icon(Icons.Default.PlayArrow, contentDescription = null, tint = MaterialTheme.colorScheme.primary) }, + onClick = { backstack.add(ExoPlayerPreferencesScreen) }, + ) + PreferenceDivider() + Preference( + title = { Text("Gestures") }, + summary = { Text("Double tap, seek, zoom, volume, brightness", color = MaterialTheme.colorScheme.outline) }, + icon = { Icon(Icons.Outlined.Gesture, contentDescription = null, tint = MaterialTheme.colorScheme.primary) }, + onClick = { backstack.add(ExoGesturePreferencesScreen) }, + ) + PreferenceDivider() + Preference( + title = { Text("Video Processing") }, + summary = { Text("Decoder priority, video filters", color = MaterialTheme.colorScheme.outline) }, + icon = { Icon(Icons.Default.DeveloperBoard, contentDescription = null, tint = MaterialTheme.colorScheme.primary) }, + onClick = { backstack.add(ExoDecoderPreferencesScreen) }, + ) + PreferenceDivider() + Preference( + title = { Text("Audio") }, + summary = { Text("Preferred languages, focus, volume memory", color = MaterialTheme.colorScheme.outline) }, + icon = { Icon(Icons.Default.Audiotrack, contentDescription = null, tint = MaterialTheme.colorScheme.primary) }, + onClick = { backstack.add(ExoAudioPreferencesScreen) }, + ) + PreferenceDivider() + Preference( + title = { Text("Subtitle") }, + summary = { Text("Font, auto-load, encoding, appearance", color = MaterialTheme.colorScheme.outline) }, + icon = { Icon(Icons.Default.Subtitles, contentDescription = null, tint = MaterialTheme.colorScheme.primary) }, + onClick = { backstack.add(ExoSubtitlePreferencesScreen) }, + ) + PreferenceDivider() + Preference( + title = { Text("General") }, + summary = { Text("Cache, backup, restore", color = MaterialTheme.colorScheme.outline) }, + icon = { Icon(Icons.Outlined.Settings, contentDescription = null, tint = MaterialTheme.colorScheme.primary) }, + onClick = { backstack.add(ExoGeneralPreferencesScreen) }, + ) + } } } - // ── 7. About ────────────────────────────────────────────────────── + // ── 8. Advanced ─────────────────────────────────────────────────── + if (!enableExoPlayer) { + item { PreferenceSectionHeader(title = stringResource(R.string.pref_section_advanced)) } + item { + PreferenceCard { + Preference( + title = { Text(stringResource(R.string.pref_advanced)) }, + summary = { + Text( + stringResource(R.string.pref_advanced_summary), + color = MaterialTheme.colorScheme.outline + ) + }, + icon = { + Icon( + Icons.Alternatives.AdvancedSettings, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + }, + onClick = { backstack.add(AdvancedPreferencesScreen) }, + ) + } + } + } + + // ── 9. About ────────────────────────────────────────────────────── item { PreferenceSectionHeader(title = stringResource(R.string.pref_section_about)) } item { PreferenceCard { @@ -226,6 +437,19 @@ object PreferencesScreen : Screen { ) } } + + // ── 10. Liquid Glass ────────────────────────────────────────────── + item { PreferenceSectionHeader(title = "Liquid Glass") } + item { + PreferenceCard { + Preference( + title = { Text("Liquid Glass Effects") }, + summary = { Text("Optical glass refraction and backdrop effects", color = MaterialTheme.colorScheme.outline) }, + icon = { Icon(Icons.Default.BlurOn, contentDescription = null, tint = MaterialTheme.colorScheme.primary) }, + onClick = { backstack.add(LiquidSettingsScreen) }, + ) + } + } } } } diff --git a/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/SearchablePreference.kt b/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/SearchablePreference.kt index 77da88661..36be3cdde 100644 --- a/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/SearchablePreference.kt +++ b/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/SearchablePreference.kt @@ -2,12 +2,14 @@ package app.gyrolet.mpvrx.ui.preferences import androidx.annotation.StringRes import app.gyrolet.mpvrx.R +import androidx.compose.runtime.Immutable import app.gyrolet.mpvrx.presentation.Screen /** * Represents a searchable preference item. * Used to index all preferences for the settings search feature. */ +@Immutable data class SearchablePreference( @StringRes val titleRes: Int? = null, val title: String? = null, diff --git a/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/SettingsSearchScreen.kt b/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/SettingsSearchScreen.kt index 3ea9aea60..ec1817a5b 100644 --- a/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/SettingsSearchScreen.kt +++ b/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/SettingsSearchScreen.kt @@ -211,7 +211,8 @@ object SettingsSearchScreen : Screen { ) { itemsIndexed( items = searchResults, - key = { index, pref -> "${pref.titleRes}_${pref.category}_${pref.screen}_$index".hashCode() } + key = { index, pref -> "${pref.titleRes}_${pref.category}_${pref.screen}_$index".hashCode() }, + contentType = { _, _ -> "searchResult" } ) { _, preference -> SearchResultItem( preference = preference, diff --git a/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/SubtitlesPreferencesScreen.kt b/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/SubtitlesPreferencesScreen.kt index b9ca916fd..6ed5a3411 100644 --- a/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/SubtitlesPreferencesScreen.kt +++ b/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/SubtitlesPreferencesScreen.kt @@ -52,7 +52,7 @@ import kotlinx.serialization.Serializable import me.zhanghai.compose.preference.Preference import me.zhanghai.compose.preference.ProvidePreferenceLocals import me.zhanghai.compose.preference.ListPreference -import me.zhanghai.compose.preference.SwitchPreference +import app.gyrolet.mpvrx.ui.preferences.components.AdaptiveSwitchPreference import me.zhanghai.compose.preference.TextFieldPreference import androidx.compose.material3.AlertDialog import androidx.compose.material3.Checkbox @@ -157,7 +157,7 @@ object SubtitlesPreferencesScreen : Screen { PreferenceDivider() val autoload by preferences.autoloadMatchingSubtitles.collectAsState() - SwitchPreference( + AdaptiveSwitchPreference( value = autoload, onValueChange = { preferences.autoloadMatchingSubtitles.set(it) }, title = { Text(stringResource(R.string.pref_subtitles_autoload_title)) }, @@ -172,7 +172,7 @@ object SubtitlesPreferencesScreen : Screen { PreferenceDivider() val overrideAss by preferences.overrideAssSubs.collectAsState() - SwitchPreference( + AdaptiveSwitchPreference( value = overrideAss, onValueChange = { preferences.overrideAssSubs.set(it) }, title = { Text(stringResource(R.string.player_sheets_sub_override_ass)) }, @@ -187,7 +187,7 @@ object SubtitlesPreferencesScreen : Screen { PreferenceDivider() val scaleByWindow by preferences.scaleByWindow.collectAsState() - SwitchPreference( + AdaptiveSwitchPreference( value = scaleByWindow, onValueChange = { preferences.scaleByWindow.set(it) }, title = { Text(stringResource(R.string.player_sheets_sub_scale_by_window)) }, @@ -339,7 +339,7 @@ object SubtitlesPreferencesScreen : Screen { if (showAdvanced) { Column(modifier = Modifier.padding(horizontal = 16.dp)) { - SwitchPreference( + AdaptiveSwitchPreference( value = wyzieHearingImpaired, onValueChange = { preferences.wyzieHearingImpaired.set(it) }, title = { Text(stringResource(R.string.pref_hearing_impaired_title)) }, @@ -509,7 +509,7 @@ fun MultiChoicePreference( onDismissRequest = { showDialog = false }, title = title, text = { - val valuesList = values.toList() + val valuesList = remember(values) { values.toList() } LazyColumn { items(count = valuesList.size, key = { index -> valuesList[index].first }) { index -> val entry = valuesList[index] diff --git a/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/YtdlpSettingsScreen.kt b/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/YtdlpSettingsScreen.kt index d276ac48f..983577432 100644 --- a/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/YtdlpSettingsScreen.kt +++ b/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/YtdlpSettingsScreen.kt @@ -36,7 +36,7 @@ import kotlinx.serialization.Serializable import org.koin.compose.koinInject import java.io.File import me.zhanghai.compose.preference.ProvidePreferenceLocals -import me.zhanghai.compose.preference.SwitchPreference +import app.gyrolet.mpvrx.ui.preferences.components.AdaptiveSwitchPreference @Serializable object YtdlpSettingsScreen : Screen { @@ -215,7 +215,7 @@ object YtdlpSettingsScreen : Screen { PreferenceDivider() - SwitchPreference( + AdaptiveSwitchPreference( value = preferH264, onValueChange = { newValue -> ytdlPreferences.preferH264.set(newValue) @@ -268,7 +268,7 @@ object YtdlpSettingsScreen : Screen { modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.medium) ) { - SwitchPreference( + AdaptiveSwitchPreference( value = writeSubs, onValueChange = { ytdlPreferences.writeSubs.set(it) }, title = { Text("Download Media Subtitles", fontWeight = FontWeight.Medium) }, @@ -277,7 +277,7 @@ object YtdlpSettingsScreen : Screen { PreferenceDivider() - SwitchPreference( + AdaptiveSwitchPreference( value = writeAutoSubs, onValueChange = { ytdlPreferences.writeAutoSubs.set(it) }, title = { Text("Include Auto-Generated Subtitles", fontWeight = FontWeight.Medium) }, diff --git a/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/components/AdaptiveSwitchPreference.kt b/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/components/AdaptiveSwitchPreference.kt new file mode 100644 index 000000000..4c42ad6d0 --- /dev/null +++ b/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/components/AdaptiveSwitchPreference.kt @@ -0,0 +1,48 @@ +package app.gyrolet.mpvrx.ui.preferences.components + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import app.gyrolet.mpvrx.preferences.AppearancePreferences +import app.gyrolet.mpvrx.preferences.preference.collectAsState +import me.zhanghai.compose.preference.SwitchPreference +import org.koin.compose.koinInject + +@Composable +fun AdaptiveSwitchPreference( + value: Boolean, + onValueChange: (Boolean) -> Unit, + title: @Composable () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + icon: (@Composable () -> Unit)? = null, + summary: (@Composable () -> Unit)? = null, +) { + val preferences = koinInject() + val enableLiquidGlass by preferences.enableLiquidGlass.collectAsState() + val liquidToggleColor by preferences.liquidToggleColor.collectAsState() + + if (enableLiquidGlass) { + LiquidAdaptiveSwitchPreference( + value = value, + onValueChange = onValueChange, + title = title, + modifier = modifier, + enabled = enabled, + icon = icon, + summary = summary, + accentColor = Color(liquidToggleColor) + ) + } else { + SwitchPreference( + value = value, + onValueChange = onValueChange, + title = title, + modifier = modifier, + enabled = enabled, + icon = icon, + summary = summary + ) + } +} diff --git a/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/components/LiquidSwitchPreference.kt b/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/components/LiquidSwitchPreference.kt new file mode 100644 index 000000000..d9d07767b --- /dev/null +++ b/app/src/main/java/app/gyrolet/mpvrx/ui/preferences/components/LiquidSwitchPreference.kt @@ -0,0 +1,52 @@ +package app.gyrolet.mpvrx.ui.preferences.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +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.gyrolet.mpvrx.ui.components.LiquidToggle +import com.kyant.backdrop.backdrops.rememberLayerBackdrop + +@Composable +fun LiquidAdaptiveSwitchPreference( + value: Boolean, + onValueChange: (Boolean) -> Unit, + title: @Composable () -> Unit, + modifier: Modifier = Modifier, + icon: (@Composable () -> Unit)? = null, + summary: (@Composable () -> Unit)? = null, + accentColor: Color = MaterialTheme.colorScheme.primary, + enabled: Boolean = true, +) { + val backdrop = rememberLayerBackdrop() + + ListItem( + headlineContent = title, + supportingContent = summary, + leadingContent = icon, + trailingContent = { + Box(contentAlignment = Alignment.Center) { + LiquidToggle( + selected = { value }, + onSelect = onValueChange, + backdrop = backdrop, + accentColor = accentColor + ) + } + }, + modifier = modifier + .fillMaxWidth() + .clickable(enabled = enabled) { onValueChange(!value) }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent) + ) +} diff --git a/app/src/main/java/app/gyrolet/mpvrx/ui/utils/LiquidGlassUtils.kt b/app/src/main/java/app/gyrolet/mpvrx/ui/utils/LiquidGlassUtils.kt new file mode 100644 index 000000000..02351d810 --- /dev/null +++ b/app/src/main/java/app/gyrolet/mpvrx/ui/utils/LiquidGlassUtils.kt @@ -0,0 +1,273 @@ +package app.gyrolet.mpvrx.ui.utils + +import android.graphics.RuntimeShader +import android.os.Build +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.VectorConverter +import androidx.compose.animation.core.VisibilityThreshold +import androidx.compose.animation.core.spring +import androidx.compose.foundation.MutatorMutex +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ShaderBrush +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.input.pointer.PointerInputChange +import androidx.compose.ui.input.pointer.PointerInputScope +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.util.VelocityTracker +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.util.fastCoerceIn +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.android.awaitFrame +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlin.math.abs + +suspend fun PointerInputScope.inspectDragGestures( + onDragStart: (PointerInputChange) -> Unit = {}, + onDragEnd: () -> Unit = {}, + onDragCancel: () -> Unit = {}, + onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit +) { + awaitEachGesture { + val down = awaitFirstDown() + onDragStart(down) + var pointer = down.id + while (true) { + val event = awaitPointerEvent() + val anyPressed = event.changes.any { it.pressed } + if (!anyPressed) { + onDragEnd() + break + } + val change = event.changes.firstOrNull { it.id == pointer } ?: event.changes.first() + pointer = change.id + onDrag(change, change.position - change.previousPosition) + } + } +} + +class DampedDragAnimation( + private val animationScope: CoroutineScope, + val initialValue: Float, + val valueRange: ClosedRange, + val visibilityThreshold: Float, + val initialScale: Float, + val pressedScale: Float, + val onDragStarted: DampedDragAnimation.(position: Offset) -> Unit, + val onDragStopped: DampedDragAnimation.() -> Unit, + val onDrag: DampedDragAnimation.(size: IntSize, dragAmount: Offset) -> Unit, +) { + private val valueAnimationSpec = spring(1f, 1000f, visibilityThreshold) + private val velocityAnimationSpec = spring(0.5f, 300f, visibilityThreshold * 10f) + private val pressProgressAnimationSpec = spring(1f, 1000f, 0.001f) + private val scaleXAnimationSpec = spring(0.6f, 250f, 0.001f) + private val scaleYAnimationSpec = spring(0.7f, 250f, 0.001f) + + private val valueAnimation = Animatable(initialValue, visibilityThreshold) + private val velocityAnimation = Animatable(0f, 5f) + private val pressProgressAnimation = Animatable(0f, 0.001f) + private val scaleXAnimation = Animatable(initialScale, 0.001f) + private val scaleYAnimation = Animatable(initialScale, 0.001f) + + private val mutatorMutex = MutatorMutex() + private val velocityTracker = VelocityTracker() + + val value: Float get() = valueAnimation.value + val progress: Float get() = (value - valueRange.start) / (valueRange.endInclusive - valueRange.start) + val targetValue: Float get() = valueAnimation.targetValue + val pressProgress: Float get() = pressProgressAnimation.value + val scaleX: Float get() = scaleXAnimation.value + val scaleY: Float get() = scaleYAnimation.value + val velocity: Float get() = velocityAnimation.value + + val modifier: Modifier = Modifier.pointerInput(Unit) { + inspectDragGestures( + onDragStart = { down -> + onDragStarted(down.position) + press() + }, + onDragEnd = { + onDragStopped() + release() + }, + onDragCancel = { + onDragStopped() + release() + } + ) { _, dragAmount -> + onDrag(size, dragAmount) + } + } + + fun press() { + velocityTracker.resetTracking() + animationScope.launch { + launch { pressProgressAnimation.animateTo(1f, pressProgressAnimationSpec) } + launch { scaleXAnimation.animateTo(pressedScale, scaleXAnimationSpec) } + launch { scaleYAnimation.animateTo(pressedScale, scaleYAnimationSpec) } + } + } + + fun release() { + animationScope.launch { + awaitFrame() + if (value != targetValue) { + val threshold = (valueRange.endInclusive - valueRange.start) * 0.025f + snapshotFlow { valueAnimation.value } + .filter { abs(it - valueAnimation.targetValue) < threshold } + .first() + } + launch { pressProgressAnimation.animateTo(0f, pressProgressAnimationSpec) } + launch { scaleXAnimation.animateTo(initialScale, scaleXAnimationSpec) } + launch { scaleYAnimation.animateTo(initialScale, scaleYAnimationSpec) } + } + } + + fun updateValue(value: Float) { + val targetValue = value.coerceIn(valueRange) + animationScope.launch { + launch { + valueAnimation.animateTo(targetValue, valueAnimationSpec) { + updateVelocity() + } + } + } + } + + fun animateToValue(value: Float) { + animationScope.launch { + mutatorMutex.mutate { + press() + val targetValue = value.coerceIn(valueRange) + launch { valueAnimation.animateTo(targetValue, valueAnimationSpec) } + if (velocity != 0f) { + launch { velocityAnimation.animateTo(0f, velocityAnimationSpec) } + } + release() + } + } + } + + fun snapToValue(value: Float) { + val targetValue = value.coerceIn(valueRange) + animationScope.launch { + valueAnimation.snapTo(targetValue) + updateVelocity() + } + } + + private fun updateVelocity() { + velocityTracker.addPosition( + System.currentTimeMillis(), + Offset(value, 0f) + ) + val targetVelocity = velocityTracker.calculateVelocity().x / (valueRange.endInclusive - valueRange.start) + animationScope.launch { + velocityAnimation.animateTo(targetVelocity, velocityAnimationSpec) + } + } +} + +class InteractiveHighlight( + val animationScope: CoroutineScope, + val position: (size: Size, offset: Offset) -> Offset = { _, offset -> offset } +) { + private val pressProgressAnimationSpec = spring(0.5f, 300f, 0.001f) + private val positionAnimationSpec = spring(0.5f, 300f, Offset.VisibilityThreshold) + private val pressProgressAnimation = Animatable(0f, 0.001f) + private val positionAnimation = + Animatable(Offset.Zero, Offset.VectorConverter, Offset.VisibilityThreshold) + private var startPosition = Offset.Zero + + val pressProgress: Float get() = pressProgressAnimation.value + val offset: Offset get() = positionAnimation.value - startPosition + + private val shader = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + RuntimeShader( + """ + uniform float2 size; + layout(color) uniform half4 color; + uniform float radius; + uniform float2 position; + + half4 main(float2 coord) { + float dist = distance(coord, position); + float intensity = smoothstep(radius, radius * 0.5, dist); + return color * intensity; + } + """.trimIndent() + ) + } else { + null + } + + val modifier: Modifier = Modifier.drawWithContent { + val progress = pressProgressAnimation.value + if (progress > 0f) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && shader != null) { + drawRect( + Color.White.copy(0.08f * progress), + blendMode = BlendMode.Plus + ) + shader.apply { + val position = position(size, positionAnimation.value) + setFloatUniform("size", size.width, size.height) + setColorUniform("color", Color.White.copy(0.15f * progress).toArgb()) + setFloatUniform("radius", size.minDimension * 1.5f) + setFloatUniform( + "position", + position.x.fastCoerceIn(0f, size.width), + position.y.fastCoerceIn(0f, size.height) + ) + } + drawRect( + ShaderBrush(shader), + blendMode = BlendMode.Plus + ) + } else { + drawRect( + Color.White.copy(0.25f * progress), + blendMode = BlendMode.Plus + ) + } + } + drawContent() + } + + val gestureModifier: Modifier = Modifier.pointerInput(animationScope) { + inspectDragGestures( + onDragStart = { down -> + startPosition = down.position + animationScope.launch { + launch { pressProgressAnimation.animateTo(1f, pressProgressAnimationSpec) } + launch { positionAnimation.snapTo(startPosition) } + } + }, + onDragEnd = { + animationScope.launch { + launch { pressProgressAnimation.animateTo(0f, pressProgressAnimationSpec) } + launch { positionAnimation.animateTo(startPosition, positionAnimationSpec) } + } + }, + onDragCancel = { + animationScope.launch { + launch { pressProgressAnimation.animateTo(0f, pressProgressAnimationSpec) } + launch { positionAnimation.animateTo(startPosition, positionAnimationSpec) } + } + } + ) { change, _ -> + animationScope.launch { + positionAnimation.snapTo(change.position) + } + } + } +} diff --git a/app/src/main/java/app/gyrolet/mpvrx/utils/GpuDriverHelper.kt b/app/src/main/java/app/gyrolet/mpvrx/utils/GpuDriverHelper.kt new file mode 100644 index 000000000..c7ade4cc1 --- /dev/null +++ b/app/src/main/java/app/gyrolet/mpvrx/utils/GpuDriverHelper.kt @@ -0,0 +1,127 @@ +package app.gyrolet.mpvrx.utils + +import android.content.Context +import android.os.Build +import android.util.Log +import app.gyrolet.mpvrx.domain.gpu.GpuDriverBridge +import app.gyrolet.mpvrx.domain.gpu.GpuDriverManager +import app.gyrolet.mpvrx.preferences.GpuDriverPreferences +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +object GpuDriverHelper : KoinComponent { + private const val TAG = "GpuDriverHelper" + private val preferences: GpuDriverPreferences by inject() + private val driverManager: GpuDriverManager by inject() + private var isInitialized = false + + /** + * Initializes the GPU driver on app startup. + * This should be called early in Application.onCreate(). + */ + @Synchronized + fun initialize(context: Context) { + if (isInitialized) return + + try { + if (!GpuDriverBridge.isAvailable()) { + Log.d(TAG, "Native library not available, skipping GPU driver initialization") + isInitialized = true + return + } + + if (!Build.SUPPORTED_ABIS.contains("arm64-v8a")) { + Log.d(TAG, "Custom GPU drivers only supported on arm64-v8a") + isInitialized = true + return + } + + val activeDriverId = preferences.activeDriverId.get() + + // File redirect dir if HUD or debug is enabled + var fileRedirectDir: String? = null + if (preferences.showDriverHud.get()) { + // In Azahar/Adrenotools this allows shader/config redirection + fileRedirectDir = context.cacheDir.absolutePath + } + + if (activeDriverId == "system") { + Log.d(TAG, "Using system default GPU driver") + // Load system driver with hooks (to allow file redirection if needed) + val success = GpuDriverBridge.setDriver( + hookLibDir = context.applicationInfo.nativeLibraryDir, + customDriverDir = null, + customDriverName = null, + fileRedirectDir = fileRedirectDir, + tmpDir = context.cacheDir.absolutePath + ) + if (success) { + Log.i(TAG, "Successfully initialized system GPU driver hooks") + } else { + Log.w(TAG, "Failed to initialize system GPU driver hooks") + } + isInitialized = true + return + } + + // Load drivers synchronously to avoid race condition with MPV initialization + val drivers = driverManager.getInstalledDriversSync() + val activeDriver = drivers.find { it.id == activeDriverId } + + if (activeDriver != null && !activeDriver.isSystem) { + Log.d(TAG, "Initializing custom GPU driver: ${activeDriver.name}") + + // Create an ICD json file to force Vulkan loaders to use this driver + val icdFile = java.io.File(activeDriver.driverPath, "icd.json") + val icdContent = """ + { + "file_format_version": "1.0.0", + "ICD": { + "library_path": "${activeDriver.driverPath}/${activeDriver.vulkanLibName}", + "api_version": "1.1.0" + } + } + """.trimIndent() + icdFile.writeText(icdContent) + + // Inject the VK_ICD_FILENAMES environment variable + try { + android.system.Os.setenv("VK_ICD_FILENAMES", icdFile.absolutePath, true) + Log.i(TAG, "Injected VK_ICD_FILENAMES=${icdFile.absolutePath}") + } catch (e: Exception) { + Log.e(TAG, "Failed to set VK_ICD_FILENAMES", e) + } + + val success = GpuDriverBridge.setDriver( + hookLibDir = context.applicationInfo.nativeLibraryDir, + customDriverDir = activeDriver.driverPath, + customDriverName = activeDriver.vulkanLibName, + fileRedirectDir = fileRedirectDir, + tmpDir = context.cacheDir.absolutePath + ) + + if (success) { + Log.i(TAG, "Successfully initialized custom GPU driver: ${activeDriver.name}") + } else { + Log.e(TAG, "Failed to initialize custom GPU driver: ${activeDriver.name}. Driver may be incompatible.") + // Don't automatically revert to system here to allow user to see the error/retry + // and because it might fail due to temporary conditions (though unlikely for local files) + } + } else { + // Fallback if the active driver is missing from installed list + GpuDriverBridge.setDriver( + hookLibDir = context.applicationInfo.nativeLibraryDir, + customDriverDir = null, + customDriverName = null, + fileRedirectDir = fileRedirectDir, + tmpDir = context.cacheDir.absolutePath + ) + } + + isInitialized = true + } catch (t: Throwable) { + Log.e(TAG, "CRITICAL: Native crash or error in GpuDriverHelper.initialize", t) + isInitialized = true + } + } +} diff --git a/app/src/main/java/app/gyrolet/mpvrx/utils/media/MediaInfoOps.kt b/app/src/main/java/app/gyrolet/mpvrx/utils/media/MediaInfoOps.kt index 721ac6515..a7ddc7ef7 100644 --- a/app/src/main/java/app/gyrolet/mpvrx/utils/media/MediaInfoOps.kt +++ b/app/src/main/java/app/gyrolet/mpvrx/utils/media/MediaInfoOps.kt @@ -8,6 +8,14 @@ import kotlinx.coroutines.withContext import net.mediaarea.mediainfo.lib.MediaInfo object MediaInfoOps { + init { + try { + Class.forName("net.mediaarea.mediainfo.lib.MediaInfo") + android.util.Log.d("MediaInfoOps", "MediaInfo library found") + } catch (e: Throwable) { + android.util.Log.e("MediaInfoOps", "MediaInfo library NOT found or failed to load!", e) + } + } /** * Extract detailed media information from a video file */ @@ -420,6 +428,7 @@ object MediaInfoOps { retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE) ?.toFloatOrNull() ?: 0f, hasEmbeddedSubtitles = false, + subtitleCodec = "", ) } finally { retriever.release() @@ -462,4 +471,15 @@ object MediaInfoOps { } }.getOrDefault(0) } + + /** + * Formats duration in milliseconds to a human-readable string (HH:MM:SS.mmm) + */ + fun formatDuration(durationMs: Long): String { + val hours = durationMs / 3600000 + val minutes = (durationMs % 3600000) / 60000 + val seconds = (durationMs % 60000) / 1000 + val millis = durationMs % 1000 + return "%02d:%02d:%02d.%03d".format(hours, minutes, seconds, millis) + } } diff --git a/app/src/main/java/app/gyrolet/mpvrx/utils/media/MediaUtils.kt b/app/src/main/java/app/gyrolet/mpvrx/utils/media/MediaUtils.kt index d11c1db89..492a68a80 100644 --- a/app/src/main/java/app/gyrolet/mpvrx/utils/media/MediaUtils.kt +++ b/app/src/main/java/app/gyrolet/mpvrx/utils/media/MediaUtils.kt @@ -6,7 +6,10 @@ import android.net.Uri import androidx.core.content.FileProvider import androidx.core.net.toUri import app.gyrolet.mpvrx.domain.media.model.Video +import app.gyrolet.mpvrx.exoplayer.ExoPlayerActivity import app.gyrolet.mpvrx.ui.player.PlayerActivity +import app.gyrolet.mpvrx.preferences.PlayerPreferences +import org.koin.java.KoinJavaComponent.inject import app.gyrolet.mpvrx.ui.player.PlayerLookupHints import app.gyrolet.mpvrx.utils.history.RecentlyPlayedOps import `is`.xyz.mpv.Utils @@ -42,6 +45,15 @@ data class PlaybackSubtitleTrack( * bypassing MediaUtils. */ object MediaUtils { + private val playerPreferences: PlayerPreferences by inject(PlayerPreferences::class.java) + + private fun getPlayerActivityClass(): Class<*> { + return if (playerPreferences.enableExoPlayer.get()) { + ExoPlayerActivity::class.java + } else { + PlayerActivity::class.java + } + } /** * Play video content from any source. * @@ -68,7 +80,7 @@ object MediaUtils { when (source) { is Video -> { val intent = Intent(Intent.ACTION_VIEW, source.uri) - intent.setClass(context, PlayerActivity::class.java) + intent.setClass(context, getPlayerActivityClass()) intent.putExtra("internal_launch", true) // Enables subtitle autoload applyPlaybackExtras( intent = intent, @@ -113,7 +125,7 @@ object MediaUtils { } val intent = Intent(Intent.ACTION_VIEW, uri) - intent.setClass(context, PlayerActivity::class.java) + intent.setClass(context, getPlayerActivityClass()) intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) applyPlaybackExtras( diff --git a/app/src/main/kotlin/app/gyrolet/mpvrx/exoplayer/ExoPlayerActivity.kt b/app/src/main/kotlin/app/gyrolet/mpvrx/exoplayer/ExoPlayerActivity.kt new file mode 100644 index 000000000..7c468806c --- /dev/null +++ b/app/src/main/kotlin/app/gyrolet/mpvrx/exoplayer/ExoPlayerActivity.kt @@ -0,0 +1,892 @@ +package app.gyrolet.mpvrx.exoplayer + +import android.annotation.SuppressLint +import android.content.ComponentName +import android.content.Intent +import android.content.pm.ActivityInfo +import android.graphics.Color +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.os.Handler +import android.provider.MediaStore +import android.view.KeyEvent +import android.view.PixelCopy +import android.view.SurfaceView +import android.view.View +import android.view.WindowManager +import android.widget.Toast +import androidx.activity.SystemBarStyle +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.result.contract.ActivityResultContracts.RequestPermission +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.runtime.getValue +import org.koin.androidx.viewmodel.ext.android.viewModel +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId +import androidx.core.content.ContextCompat +import androidx.core.content.IntentCompat +import androidx.core.graphics.createBitmap +import androidx.core.util.Consumer +import androidx.lifecycle.compose.LifecycleStartEffect +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.lifecycleScope +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata +import androidx.media3.common.Player +import androidx.media3.session.MediaController +import androidx.media3.session.SessionToken +import com.google.common.util.concurrent.ListenableFuture +import java.io.File +import java.io.IOException +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.concurrent.CopyOnWriteArrayList +import kotlin.coroutines.resume +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.guava.await +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import app.gyrolet.mpvrx.exoplayer.core.common.Logger +import app.gyrolet.mpvrx.exoplayer.core.common.extensions.applyNavigationBarStyle +import app.gyrolet.mpvrx.exoplayer.core.common.extensions.applyPrivacyProtection +import app.gyrolet.mpvrx.exoplayer.core.common.extensions.canonicalPathOrSelf +import app.gyrolet.mpvrx.exoplayer.core.common.extensions.getFilenameFromUri +import app.gyrolet.mpvrx.exoplayer.core.common.extensions.getMediaContentUri +import app.gyrolet.mpvrx.exoplayer.core.common.extensions.isSubtitleExtension +import app.gyrolet.mpvrx.exoplayer.core.common.extensions.resolvePrivacyPreviewScrim +import app.gyrolet.mpvrx.exoplayer.core.common.extensions.scanFileForContentUri +import app.gyrolet.mpvrx.exoplayer.core.model.ScreenOrientation +import app.gyrolet.mpvrx.exoplayer.core.model.Video +import app.gyrolet.mpvrx.ui.theme.MpvrxTheme +import app.gyrolet.mpvrx.exoplayer.feature.player.extensions.OpenDocumentWithInitialUri +import app.gyrolet.mpvrx.exoplayer.feature.player.extensions.copy +import app.gyrolet.mpvrx.exoplayer.feature.player.extensions.registerForSuspendActivityResult +import app.gyrolet.mpvrx.exoplayer.feature.player.extensions.setMetadataExtras +import app.gyrolet.mpvrx.exoplayer.core.data.repository.OnlineSubtitleRepository +import app.gyrolet.mpvrx.exoplayer.core.data.repository.InvalidOnlineSubtitleSchemeException +import app.gyrolet.mpvrx.exoplayer.core.data.repository.InvalidOnlineSubtitleUrlException +import app.gyrolet.mpvrx.exoplayer.core.data.repository.InvalidOnlineSubtitleExtensionException +import app.gyrolet.mpvrx.exoplayer.core.data.repository.OnlineSubtitleTooLargeException +import app.gyrolet.mpvrx.exoplayer.core.data.repository.EmptyOnlineSubtitleException +import app.gyrolet.mpvrx.exoplayer.core.data.repository.OnlineSubtitleDownloadFailedException +import app.gyrolet.mpvrx.exoplayer.feature.player.extensions.toActivityOrientation +import app.gyrolet.mpvrx.exoplayer.feature.player.extensions.uriToSubtitleConfiguration +import app.gyrolet.mpvrx.exoplayer.feature.player.service.addSubtitleTrack +import app.gyrolet.mpvrx.exoplayer.feature.player.service.stopPlayerSession +import app.gyrolet.mpvrx.exoplayer.feature.player.MediaPlayerScreen +import app.gyrolet.mpvrx.exoplayer.feature.player.PlayerViewModel +import app.gyrolet.mpvrx.R +import org.koin.android.ext.android.inject + +internal data class PlaybackPlaylist( + val items: List, + val currentIndex: Int, +) + +internal data class PlaybackTarget( + val sourceUriString: String, + val playbackUriString: String, + val currentPath: String?, +) + +internal fun buildPlaybackPlaylistFromItems( + playlistItems: List, + playbackTarget: PlaybackTarget, +): PlaybackPlaylist { + if (playlistItems.isEmpty()) { + return PlaybackPlaylist( + items = listOf(playbackTarget.playbackUriString), + currentIndex = 0, + ) + } + + val currentIndex = playlistItems.indexOfFirst { uriString -> + uriString == playbackTarget.playbackUriString || + uriString == playbackTarget.sourceUriString + } + if (currentIndex >= 0) { + return PlaybackPlaylist( + items = playlistItems, + currentIndex = currentIndex, + ) + } + + return PlaybackPlaylist( + items = listOf(playbackTarget.playbackUriString) + playlistItems, + currentIndex = 0, + ) +} + +internal fun buildPlaybackPlaylist( + playlistVideos: List