diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index d369e1e2e..4f6fcffb6 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -31,8 +31,8 @@ jobs: - name: Set up JDK uses: actions/setup-java@v5 with: - distribution: 'microsoft' - java-version: 21 + distribution: 'jetbrains' + java-version: 25 - name: Setup Android SDK uses: android-actions/setup-android@v2 diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml index d205ec1a4..946704354 100644 --- a/.github/workflows/desktop.yml +++ b/.github/workflows/desktop.yml @@ -32,8 +32,8 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: - distribution: "jetbrains" - java-version: 21 + distribution: 'jetbrains' + java-version: 25 - name: import certs if: startsWith(github.ref, 'refs/tags/') uses: apple-actions/import-codesign-certs@v5 @@ -91,8 +91,8 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: - distribution: "jetbrains" - java-version: 21 + distribution: 'jetbrains' + java-version: 25 # Run tests - name: Run Tests run: ./gradlew jvmTest diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index 3f5fea9f1..f808faa2b 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -27,8 +27,8 @@ jobs: - name: Set up JDK uses: actions/setup-java@v3 with: - distribution: 'zulu' - java-version: 21 + distribution: 'jetbrains' + java-version: 25 # Run tests - name: Run Tests run: ./gradlew allTests diff --git a/.github/workflows/server.yml b/.github/workflows/server.yml index 5eed98995..74a7f619a 100644 --- a/.github/workflows/server.yml +++ b/.github/workflows/server.yml @@ -32,10 +32,10 @@ jobs: submodules: true - name: Set up JDK - uses: actions/setup-java@v3 + uses: actions/setup-java@v5 with: - distribution: "zulu" - java-version: 21 + distribution: 'jetbrains' + java-version: 25 - name: Build env: diff --git a/build.gradle.kts b/build.gradle.kts index 60e754cf6..60ad20c9b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,6 +1,7 @@ import com.android.build.api.dsl.LibraryExtension import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.dsl.KotlinAndroidProjectExtension +import org.jetbrains.kotlin.gradle.dsl.KotlinJvmExtension import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension plugins { @@ -38,7 +39,10 @@ subprojects { freeCompilerArgs.addAll(freeArgs) optIn.addAll(commonOptIn) } - jvmToolchain(libs.versions.java.get().toInt()) + jvmToolchain { + languageVersion = JavaLanguageVersion.of(libs.versions.java.get().toInt()) + vendor = JvmVendorSpec.JETBRAINS + } } } plugins.withId("org.jetbrains.kotlin.android") { @@ -49,7 +53,23 @@ subprojects { optIn.addAll(commonOptIn) jvmTarget.set(JvmTarget.fromTarget(libs.versions.java.get())) } - jvmToolchain(libs.versions.java.get().toInt()) + jvmToolchain { + languageVersion = JavaLanguageVersion.of(libs.versions.java.get().toInt()) + vendor = JvmVendorSpec.JETBRAINS + } + } + } + plugins.withId("org.jetbrains.kotlin.jvm") { + extensions.configure { + compilerOptions { + allWarningsAsErrors.set(true) + freeCompilerArgs.addAll(freeArgs) + optIn.addAll(commonOptIn) + } + jvmToolchain { + languageVersion = JavaLanguageVersion.of(libs.versions.java.get().toInt()) + vendor = JvmVendorSpec.JETBRAINS + } } } diff --git a/compose-ui/build.gradle.kts b/compose-ui/build.gradle.kts index d912160ec..699e63dfe 100644 --- a/compose-ui/build.gradle.kts +++ b/compose-ui/build.gradle.kts @@ -84,6 +84,7 @@ kotlin { implementation(libs.fluent.ui) implementation(libs.koin.compose) implementation(libs.androidx.collection) + implementation("io.github.kdroidfilter:composemediaplayer:0.8.7") } } val iosMain by getting { diff --git a/compose-ui/src/jvmMain/kotlin/dev/dimension/flare/ui/component/VideoPlayer.kt b/compose-ui/src/jvmMain/kotlin/dev/dimension/flare/ui/component/VideoPlayer.kt new file mode 100644 index 000000000..fef9f0705 --- /dev/null +++ b/compose-ui/src/jvmMain/kotlin/dev/dimension/flare/ui/component/VideoPlayer.kt @@ -0,0 +1,361 @@ +package dev.dimension.flare.ui.component + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onLayoutRectChanged +import androidx.compose.ui.layout.onVisibilityChanged +import androidx.compose.ui.unit.dp +import compose.icons.FontAwesomeIcons +import compose.icons.fontawesomeicons.Solid +import compose.icons.fontawesomeicons.solid.CirclePlay +import dev.dimension.flare.ui.component.status.LocalIsScrollingInProgress +import dev.dimension.flare.ui.theme.PlatformTheme +import io.github.composefluent.component.ProgressRing +import io.github.kdroidfilter.composemediaplayer.VideoPlayerState +import io.github.kdroidfilter.composemediaplayer.VideoPlayerSurface +import kotlin.math.roundToLong + +@OptIn(ExperimentalFoundationApi::class) +@Composable +public fun VideoPlayer( + uri: String, + previewUri: String?, + contentDescription: String?, + modifier: Modifier = Modifier, + muted: Boolean = false, + showControls: Boolean = false, + keepScreenOn: Boolean = false, + aspectRatio: Float? = null, + contentScale: ContentScale = ContentScale.Crop, + onClick: (() -> Unit)? = null, + onLongClick: (() -> Unit)? = null, + autoPlay: Boolean = true, + remainingTimeContent: @Composable (BoxScope.(Long) -> Unit)? = null, + loadingPlaceholder: @Composable BoxScope.() -> Unit = { + if (previewUri != null) { + Box( + modifier = + Modifier + .fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + NetworkImage( + model = previewUri, + contentScale = contentScale, + contentDescription = contentDescription, + modifier = + Modifier + .let { + if (aspectRatio != null) { + it.aspectRatio( + aspectRatio, + matchHeightConstraintsFirst = aspectRatio > 1f, + ) + } else { + it + } + }.fillMaxSize(), + ) + } + ProgressRing( + modifier = + Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter), + ) + } + }, + idlePlaceholder: @Composable BoxScope.() -> Unit = { + if (previewUri != null) { + Box( + modifier = + Modifier + .fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + NetworkImage( + model = previewUri, + contentScale = contentScale, + contentDescription = contentDescription, + modifier = + Modifier + .let { + if (aspectRatio != null) { + it.aspectRatio( + aspectRatio, + matchHeightConstraintsFirst = aspectRatio > 1f, + ) + } else { + it + } + }.fillMaxSize(), + ) + Box( + modifier = + Modifier + .padding(16.dp) + .background( + Color.Black.copy(alpha = 0.5f), + shape = PlatformTheme.shapes.medium, + ).padding(horizontal = 8.dp, vertical = 4.dp) + .align(Alignment.BottomStart), + contentAlignment = Alignment.Center, + ) { + FAIcon( + FontAwesomeIcons.Solid.CirclePlay, + contentDescription = null, + modifier = + Modifier + .size(16.dp), + tint = Color.White, + ) + } + } + } + }, +) { + var isLoaded by remember { mutableStateOf(false) } + var visible by remember { mutableStateOf(false) } + var currentRect by remember { mutableStateOf(androidx.compose.ui.unit.IntRect.Zero) } + val binding = rememberSurfaceBinding(uri) + val playerState = binding.first + val windowInfo = androidx.compose.ui.platform.LocalWindowInfo.current + + LaunchedEffect(binding.second, currentRect, visible, windowInfo.containerSize.height) { + binding.second.update(currentRect, visible, windowInfo.containerSize.height / 2f) + } + + LaunchedEffect(muted, playerState) { + playerState?.volume = if (muted) 0f else 1f + } + + LaunchedEffect(visible, autoPlay, playerState) { + if (visible && autoPlay) { + playerState?.play() + } else { + playerState?.pause() + } + } + + LaunchedEffect(playerState?.isPlaying, playerState?.sliderPos) { + isLoaded = playerState?.isPlaying == true && playerState.sliderPos > 0f + } + + Box( + modifier = + modifier + .onLayoutRectChanged(debounceMillis = 300) { + currentRect = it.boundsInWindow + }.onVisibilityChanged(300, 0.66f) { + visible = it + }, + ) { + if (!isLoaded && LocalIsScrollingInProgress.current || !visible || playerState == null) { + idlePlaceholder() + } else { + AnimatedContent( + isLoaded, + transitionSpec = { + fadeIn() togetherWith fadeOut() + }, + ) { loaded -> + if (loaded) { + val playerModifier = + Modifier + .clipToBounds() + .let { + if (onClick != null) { + it.combinedClickable( + onClick = onClick, + onLongClick = onLongClick, + ) + } else { + it + } + }.let { + if (aspectRatio != null) { + it.aspectRatio(aspectRatio) + } else { + it + } + } + + DisposableEffect(Unit) { + onDispose { + playerState.stop() + } + } + Box { + VideoPlayerSurface( + playerState = playerState, + modifier = playerModifier, + contentScale = contentScale, + ) + val remainingTime by remember { + derivedStateOf { + if (playerState.sliderPos > 0f) { + (((playerState.currentTime / (playerState.sliderPos / 1000)) - playerState.currentTime) * 1000) + .roundToLong() + } else { + 0L + } + } + } + remainingTimeContent?.invoke(this, remainingTime) + } + } else { + Box { + loadingPlaceholder() + } + } + } + } + } +} + +@Composable +private fun rememberSurfaceBinding(uri: String): Pair { + val manager: SurfaceBindingManager = org.koin.compose.koinInject() + var player by remember { mutableStateOf(null) } + val binding = + remember(uri, manager) { + manager.register(uri) { videoPlayerState -> + player = videoPlayerState + } + } + + androidx.compose.runtime.DisposableEffect(binding) { + onDispose { + binding.dispose() + } + } + + return player to binding +} + +@androidx.compose.runtime.Stable +public class SurfaceBindingManager { + public val player: VideoPlayerState by lazy { + io.github.kdroidfilter.composemediaplayer.createVideoPlayerState().apply { + loop = true + volume = 0f + } + } + + public interface Binding { + public fun update( + rect: androidx.compose.ui.unit.IntRect, + isVisible: Boolean, + windowCenterY: Float, + ) + + public fun dispose() + } + + private data class Candidate( + val binding: Binding, + val uri: String, + val rect: androidx.compose.ui.unit.IntRect, + val isVisible: Boolean, + val windowCenterY: Float, + val callback: (VideoPlayerState?) -> Unit, + ) + + private val candidates = mutableMapOf() + private var activeBinding: Binding? = null + private var currentUri: String? = null + + internal fun register( + uri: String, + onActiveChanged: (VideoPlayerState?) -> Unit, + ): Binding = + object : Binding { + override fun update( + rect: androidx.compose.ui.unit.IntRect, + isVisible: Boolean, + windowCenterY: Float, + ) { + candidates[this] = + Candidate(this, uri, rect, isVisible, windowCenterY, onActiveChanged) + recalculateActiveItem() + } + + override fun dispose() { + candidates.remove(this) + if (activeBinding == this) { + activeBinding = null + player.pause() // Stop playback if the active one is removed + recalculateActiveItem() + } + } + } + + private fun recalculateActiveItem() { + // Find best candidate + val best = + candidates.values + .filter { it.isVisible } + .minByOrNull { kotlin.math.abs(it.rect.center.y - it.windowCenterY) } + + if (best?.binding != activeBinding) { + val oldBinding = activeBinding + val newBinding = best?.binding + + activeBinding = newBinding + + if (best != null) { + // Check if we are switching to a candidate with the SAME URI + val oldCandidate = candidates[oldBinding] + val sameUri = oldCandidate?.uri == best.uri + + if (!sameUri) { + // Different URI: Prepare player + if (currentUri != best.uri) { + currentUri = best.uri + player.openUri(best.uri) + } else { + if (!player.isPlaying) { + player.play() + } + } + // Notify bindings + } else { + // Same URI: Seamless handover + // Do nothing to the player state (it keeps playing) + } + + oldCandidate?.callback?.invoke(null) + best.callback.invoke(player) + } else { + // No candidate + candidates[oldBinding]?.callback?.invoke(null) + player.pause() + } + } + } +} diff --git a/compose-ui/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformVideoPlayer.jvm.kt b/compose-ui/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformVideoPlayer.jvm.kt index 78c868b92..a55d28421 100644 --- a/compose-ui/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformVideoPlayer.jvm.kt +++ b/compose-ui/src/jvmMain/kotlin/dev/dimension/flare/ui/component/platform/PlatformVideoPlayer.jvm.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.BoxScope import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale +import dev.dimension.flare.ui.component.VideoPlayer @OptIn(ExperimentalFoundationApi::class) @Composable @@ -25,4 +26,20 @@ internal actual fun PlatformVideoPlayer( errorContent: @Composable BoxScope.() -> Unit, loadingPlaceholder: @Composable BoxScope.() -> Unit, ) { + VideoPlayer( + uri = uri, + previewUri = previewUri, + contentDescription = contentDescription, + modifier = modifier, + muted = muted, + showControls = showControls, + keepScreenOn = keepScreenOn, + aspectRatio = aspectRatio, + contentScale = contentScale, + onClick = onClick, + onLongClick = onLongClick, + autoPlay = autoPlay, + remainingTimeContent = remainingTimeContent, + loadingPlaceholder = loadingPlaceholder, + ) } diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 2081dced2..cb257ce17 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -1,6 +1,5 @@ import org.jetbrains.compose.compose -import org.jetbrains.compose.desktop.application.dsl.TargetFormat plugins { alias(libs.plugins.kotlin.jvm) @@ -9,6 +8,7 @@ plugins { alias(libs.plugins.ktlint) alias(libs.plugins.kotlin.serialization) alias(libs.plugins.stability.analyzer) + id("io.github.kdroidfilter.nucleus") version "1.1.6" } dependencies { @@ -40,74 +40,146 @@ dependencies { implementation(libs.zoomable) implementation(libs.datastore) implementation(libs.reorderable) - implementation(libs.platformtools.darkmodedetector) implementation(libs.jna) - implementation(libs.junique) + implementation("io.github.kdroidfilter:composemediaplayer:0.8.7") + implementation("io.github.kdroidfilter:nucleus.darkmode-detector:1.1.6") + implementation("io.github.kdroidfilter:nucleus.aot-runtime:1.1.6") + implementation("io.github.kdroidfilter:nucleus.decorated-window:1.1.6") + implementation("io.github.kdroidfilter:composewebview:1.0.0-alpha-10") } -compose.desktop { - application { - mainClass = "dev.dimension.flare.MainKt" - - nativeDistributions { - targetFormats(TargetFormat.Pkg, TargetFormat.Exe, TargetFormat.Deb) - packageName = "Flare" - val buildVersion = System.getenv("BUILD_VERSION")?.toString()?.takeIf { - // match semantic versioning - Regex("""\d+\.\d+\.\d+(-\S+)?""").matches(it) - } ?: "1.0.0" - packageVersion = buildVersion - macOS { - val hasSigningProps = project.file("embedded.provisionprofile").exists() && project.file("runtime.provisionprofile").exists() - packageBuildVersion = System.getenv("BUILD_NUMBER") ?: "1" - bundleID = "dev.dimension.flare" - minimumSystemVersion = "14.0" - appStore = hasSigningProps - - jvmArgs( - "-Dapple.awt.application.appearance=system", - ) +nucleus.application { + jvmArgs += "--enable-native-access=ALL-UNNAMED" + mainClass = "dev.dimension.flare.MainKt" + nativeDistributions { + cleanupNativeLibs = true + enableAotCache = true + homepage = "https://github.com/DimensionDev/Flare" + appResourcesRootDir.set(file("resources")) + targetFormats( + io.github.kdroidfilter.nucleus.desktop.application.dsl.TargetFormat.Pkg, + io.github.kdroidfilter.nucleus.desktop.application.dsl.TargetFormat.Flatpak, + io.github.kdroidfilter.nucleus.desktop.application.dsl.TargetFormat.AppX, + ) + packageName = "Flare" + val buildVersion = System.getenv("BUILD_VERSION")?.toString()?.takeIf { + // match semantic versioning + Regex("""\d+\.\d+\.\d+(-\S+)?""").matches(it) + } ?: "1.0.0" + packageVersion = buildVersion - infoPlist { - extraKeysRawXml = macExtraPlistKeys - } + protocol("Flare", "flare") - if (hasSigningProps) { - signing { - sign.set(true) - identity.set("SUJITEKU LIMITED LIABILITY CO.") - } + macOS { + val hasSigningProps = project.file("embedded.provisionprofile").exists() && project.file("runtime.provisionprofile").exists() + packageBuildVersion = System.getenv("BUILD_NUMBER") ?: "1" + bundleID = "dev.dimension.flare" + minimumSystemVersion = "14.0" + appStore = hasSigningProps - entitlementsFile.set(project.file("entitlements.plist")) - runtimeEntitlementsFile.set(project.file("runtime-entitlements.plist")) - provisioningProfile.set(project.file("embedded.provisionprofile")) - runtimeProvisioningProfile.set(project.file("runtime.provisionprofile")) - } + jvmArgs( + "-Dapple.awt.application.appearance=system", + ) - iconFile.set(project.file("resources/ic_launcher.icns")) + infoPlist { + extraKeysRawXml = macExtraPlistKeys } - windows { - iconFile.set(project.file("resources/ic_launcher.ico")) + + if (hasSigningProps) { + signing { + sign.set(true) + identity.set("SUJITEKU LIMITED LIABILITY CO.") + } + + entitlementsFile.set(project.file("entitlements.plist")) + runtimeEntitlementsFile.set(project.file("runtime-entitlements.plist")) + provisioningProfile.set(project.file("embedded.provisionprofile")) + runtimeProvisioningProfile.set(project.file("runtime.provisionprofile")) } - linux { - iconFile.set(project.file("resources/ic_launcher.png")) + + iconFile.set(project.file("resources/ic_launcher.icns")) + } + windows { + iconFile.set(project.file("resources/ic_launcher.ico")) + appx { } - appResourcesRootDir.set(file("resources")) } - buildTypes { - release { - proguard { - this.isEnabled.set(false) - // version.set("7.7.0") - // this.configurationFiles.from( - // file("proguard-rules.pro") - // ) - } + linux { + iconFile.set(project.file("resources/ic_launcher.png")) + flatpak { + runtime = "org.freedesktop.Platform" + runtimeVersion = "24.08" + sdk = "org.freedesktop.Sdk" + branch = "master" + finishArgs = listOf("--share=ipc", "--socket=x11", "--socket=wayland") } } } } +//compose.desktop { +// application { +// mainClass = "dev.dimension.flare.MainKt" +// +// nativeDistributions { +// targetFormats(TargetFormat.Pkg, TargetFormat.Exe, TargetFormat.Deb) +// packageName = "Flare" +// val buildVersion = System.getenv("BUILD_VERSION")?.toString()?.takeIf { +// // match semantic versioning +// Regex("""\d+\.\d+\.\d+(-\S+)?""").matches(it) +// } ?: "1.0.0" +// packageVersion = buildVersion +// macOS { +// val hasSigningProps = project.file("embedded.provisionprofile").exists() && project.file("runtime.provisionprofile").exists() +// packageBuildVersion = System.getenv("BUILD_NUMBER") ?: "1" +// bundleID = "dev.dimension.flare" +// minimumSystemVersion = "14.0" +// appStore = hasSigningProps +// +// jvmArgs( +// "-Dapple.awt.application.appearance=system", +// ) +// +// infoPlist { +// extraKeysRawXml = macExtraPlistKeys +// } +// +// if (hasSigningProps) { +// signing { +// sign.set(true) +// identity.set("SUJITEKU LIMITED LIABILITY CO.") +// } +// +// entitlementsFile.set(project.file("entitlements.plist")) +// runtimeEntitlementsFile.set(project.file("runtime-entitlements.plist")) +// provisioningProfile.set(project.file("embedded.provisionprofile")) +// runtimeProvisioningProfile.set(project.file("runtime.provisionprofile")) +// } +// +// iconFile.set(project.file("resources/ic_launcher.icns")) +// } +// windows { +// iconFile.set(project.file("resources/ic_launcher.ico")) +// } +// linux { +// iconFile.set(project.file("resources/ic_launcher.png")) +// } +// appResourcesRootDir.set(file("resources")) +// } +// buildTypes { +// release { +// proguard { +// this.isEnabled.set(false) +// // version.set("7.7.0") +// // this.configurationFiles.from( +// // file("proguard-rules.pro") +// // ) +// } +// } +// } +// } +//} + compose.resources { packageOfResClass = "dev.dimension.flare" } @@ -146,8 +218,8 @@ extra["sqliteOsArch"] = "osx_arm64" extra["jnaVersion"] = libs.versions.jna.get() extra["nativeDestDir"] = "resources/macos-arm64" -apply(from = File(projectDir, "install-native-libs.gradle.kts")) -apply(from = File(projectDir, "build-swift.gradle.kts")) +//apply(from = File(projectDir, "install-native-libs.gradle.kts")) +//apply(from = File(projectDir, "build-swift.gradle.kts")) diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt index 0a33048ea..72327d7fb 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt @@ -24,6 +24,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.movableContentOf import androidx.compose.runtime.remember @@ -41,7 +42,6 @@ import compose.icons.fontawesomeicons.Solid import compose.icons.fontawesomeicons.solid.Gear import compose.icons.fontawesomeicons.solid.Pen import compose.icons.fontawesomeicons.solid.UserPlus -import dev.dimension.flare.common.NativeWindowBridge import dev.dimension.flare.data.model.AllListTabItem import dev.dimension.flare.data.model.AllNotificationTabItem import dev.dimension.flare.data.model.Bluesky @@ -77,21 +77,16 @@ import io.github.composefluent.component.Badge import io.github.composefluent.component.BadgeStatus import io.github.composefluent.component.Button import io.github.composefluent.component.Icon -import io.github.composefluent.component.NavigationDefaults import io.github.composefluent.component.SubtleButton import io.github.composefluent.component.Text import kotlinx.coroutines.launch import moe.tlaster.precompose.molecule.producePresenter -import org.apache.commons.lang3.SystemUtils import org.jetbrains.compose.resources.stringResource -import org.koin.compose.koinInject @Composable -internal fun WindowScope.FlareApp() { +internal fun WindowScope.FlareApp(backButtonState: NavigationBackButtonState) { val state by producePresenter { presenter() } val uriHandler = LocalUriHandler.current - val nativeWindowBridge = koinInject() - state.tabs.onSuccess { tabs -> val topLevelBackStack = retain( @@ -107,14 +102,6 @@ internal fun WindowScope.FlareApp() { fun navigate(route: Route) { when (route) { - is Route.RawImage if (SystemUtils.IS_OS_MAC || SystemUtils.IS_OS_WINDOWS) -> { - nativeWindowBridge.openImageImageViewer(route.rawImage) - } - - is Route.StatusMedia if (SystemUtils.IS_OS_MAC || SystemUtils.IS_OS_WINDOWS) -> { - nativeWindowBridge.openStatusImageViewer(route) - } - is Route.UrlRoute -> { uriHandler.openUri(route.url) } @@ -129,6 +116,16 @@ internal fun WindowScope.FlareApp() { topLevelBackStack.pop() } + LaunchedEffect(topLevelBackStack) { + backButtonState.attach { + goBack() + } + } + + LaunchedEffect(topLevelBackStack.canGoBack) { + backButtonState.update(topLevelBackStack.canGoBack) + } + val deeplinkPresenter by producePresenter("deeplink_presenter") { DeepLinkPresenter( onRoute = { @@ -152,14 +149,7 @@ internal fun WindowScope.FlareApp() { ).fillMaxHeight() .width(72.dp) .verticalScroll(rememberScrollState()) - .padding(top = 16.dp) - .let { - if (SystemUtils.IS_OS_MAC) { - it.padding(top = 24.dp) - } else { - it - } - }, + .padding(top = LocalWindowPadding.current.calculateTopPadding()), ) { state.user .onSuccess { user -> @@ -347,15 +337,15 @@ internal fun WindowScope.FlareApp() { navigate = { route -> navigate(route) }, onBack = { goBack() }, ) - if (topLevelBackStack.canGoBack) { - NavigationDefaults.BackButton( - onClick = { - goBack() - }, - disabled = !topLevelBackStack.canGoBack, - modifier = Modifier.align(Alignment.TopStart), - ) - } +// if (topLevelBackStack.canGoBack) { +// NavigationDefaults.BackButton( +// onClick = { +// goBack() +// }, +// disabled = !topLevelBackStack.canGoBack, +// modifier = Modifier.align(Alignment.TopStart), +// ) +// } InAppNotificationComponent( modifier = Modifier diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/Main.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/Main.kt index d4aea6326..03909a21e 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/Main.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/Main.kt @@ -1,9 +1,18 @@ package dev.dimension.flare +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Window import androidx.compose.ui.window.WindowPosition import androidx.compose.ui.window.application import androidx.compose.ui.window.rememberWindowState @@ -12,65 +21,62 @@ import coil3.compose.setSingletonImageLoaderFactory import coil3.network.ktor3.KtorNetworkFetcherFactory import coil3.request.crossfade import dev.dimension.flare.common.DeeplinkHandler -import dev.dimension.flare.common.NoopIPC import dev.dimension.flare.common.SandboxHelper -import dev.dimension.flare.common.windows.WindowsIPC import dev.dimension.flare.data.network.ktorClient import dev.dimension.flare.di.KoinHelper import dev.dimension.flare.di.composeUiModule import dev.dimension.flare.di.desktopModule -import dev.dimension.flare.ui.route.APPSCHEMA import dev.dimension.flare.ui.theme.FlareTheme import dev.dimension.flare.ui.theme.ProvideComposeWindow import dev.dimension.flare.ui.theme.ProvideThemeSettings -import io.github.kdroidfilter.platformtools.darkmodedetector.windows.setWindowsAdaptiveTitleBar -import it.sauronsoftware.junique.AlreadyLockedException -import it.sauronsoftware.junique.JUnique +import io.github.composefluent.FluentTheme +import io.github.composefluent.component.NavigationDefaults +import io.github.kdroidfilter.nucleus.aot.runtime.AotRuntime +import io.github.kdroidfilter.nucleus.core.runtime.DeepLinkHandler +import io.github.kdroidfilter.nucleus.core.runtime.SingleInstanceManager +import io.github.kdroidfilter.nucleus.window.DecoratedWindow +import io.github.kdroidfilter.nucleus.window.TitleBar +import io.github.kdroidfilter.nucleus.window.styling.LocalTitleBarStyle +import kotlinx.coroutines.flow.MutableStateFlow import org.apache.commons.lang3.SystemUtils import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource import org.koin.core.context.startKoin -import org.koin.dsl.module -import java.awt.Desktop -import java.io.File -import java.nio.file.Files -import java.nio.file.Paths -import kotlin.io.path.absolutePathString +import kotlin.system.exitProcess +import kotlin.time.Duration.Companion.seconds +import kotlin.time.toJavaDuration fun main(args: Array) { - if (SystemUtils.IS_OS_LINUX && isRunning(args)) { - return + if (AotRuntime.isTraining()) { + Thread({ + Thread.sleep(15.seconds.toJavaDuration()) + exitProcess(0) + }, "aot-timer").apply { + isDaemon = false + start() + } + } + val restoreRequestFlow = MutableStateFlow(0) + DeepLinkHandler.register(args) { uri -> + DeeplinkHandler.handleDeeplink(uri.toString()) } - if (SystemUtils.IS_OS_LINUX) { - ensureMimeInfo() - ensureDesktopEntry() + val isFirstInstance = + SingleInstanceManager.isSingleInstance( + onRestoreFileCreated = { DeepLinkHandler.writeUriTo(this) }, + onRestoreRequest = { + DeepLinkHandler.readUriFrom(this) + restoreRequestFlow.value++ + }, + ) + if (!isFirstInstance) { + return } SandboxHelper.configureSandboxArgs() - val ports = WindowsIPC.parsePorts(args) - val platformIPC = - if (ports != null) { - WindowsIPC( - ports, - onDeeplink = { - DeeplinkHandler.handleDeeplink(it) - }, - ) - } else { - NoopIPC - } startKoin { modules( - desktopModule + KoinHelper.modules() + composeUiModule + - module { - single { platformIPC } - }, + desktopModule + KoinHelper.modules() + composeUiModule, ) } - if (SystemUtils.IS_OS_MAC_OSX) { - Desktop.getDesktop().setOpenURIHandler { - DeeplinkHandler.handleDeeplink(it.uri.toString()) - } - } application { setSingletonImageLoaderFactory { context -> ImageLoader @@ -88,10 +94,9 @@ fun main(args: Array) { .build() } ProvideThemeSettings { - Window( + DecoratedWindow( onCloseRequest = { exitApplication() - platformIPC.sendShutdown() }, title = stringResource(Res.string.app_name), icon = painterResource(Res.drawable.flare_logo), @@ -101,12 +106,54 @@ fun main(args: Array) { size = DpSize(520.dp, 840.dp), ), ) { - if (SystemUtils.IS_OS_WINDOWS) { - window.setWindowsAdaptiveTitleBar() + val restoreRequest by restoreRequestFlow.collectAsState() + LaunchedEffect(restoreRequest) { + if (restoreRequest > 0) { + window.toFront() + window.requestFocus() + } } + val backButtonState = + remember { + NavigationBackButtonState() + } FlareTheme { ProvideComposeWindow { - FlareApp() + FlareApp( + backButtonState = backButtonState, + ) + } + TitleBar( + style = + LocalTitleBarStyle.current.copy( + colors = + LocalTitleBarStyle.current.colors.copy( + background = + FluentTheme.colors.background.mica.base + .copy(alpha = 0f), + inactiveBackground = Color.Transparent, + ), + ), + ) { + Row( + modifier = Modifier.align(Alignment.Start), + ) { + NavigationDefaults.BackButton( + onClick = { + backButtonState.onClick.invoke() + }, + modifier = + Modifier + .let { + if (SystemUtils.IS_OS_WINDOWS) { + it.width(70.dp) + } else { + it + } + }, + disabled = !backButtonState.canGoBack, + ) + } } } } @@ -114,57 +161,17 @@ fun main(args: Array) { } } -private const val ENTRY_FILE_NAME = "flare.desktop" -private const val LOCK_ID = "dev.dimensiondev.flare" +internal class NavigationBackButtonState { + var canGoBack by mutableStateOf(false) + private set + var onClick: () -> Unit = {} + private set -private fun ensureDesktopEntry() { - val entryFile = - File("${System.getProperty("user.home")}/.local/share/applications/$ENTRY_FILE_NAME") - if (!entryFile.exists()) { - entryFile.createNewFile() + fun attach(onClick: () -> Unit) { + this.onClick = onClick } - val path = Files.readSymbolicLink(Paths.get("/proc/self/exe")) - entryFile.writeText( - "[Desktop Entry]${System.lineSeparator()}" + - "Type=Application${System.lineSeparator()}" + - "Name=Flare${System.lineSeparator()}" + - "Icon=\"${path.parent.parent.absolutePathString() + "/lib/Flare.png" + "\""}${System.lineSeparator()}" + - "Exec=\"${path.absolutePathString() + "\" %u"}${System.lineSeparator()}" + - "Terminal=false${System.lineSeparator()}" + - "Categories=Network;Internet;${System.lineSeparator()}" + - "MimeType=application/x-$APPSCHEMA;x-scheme-handler/$APPSCHEMA;", - ) -} -private fun ensureMimeInfo() { - val file = File("${System.getProperty("user.home")}/.local/share/applications/mimeinfo.cache") - if (!file.exists()) { - file.createNewFile() - } - val text = file.readText() - if (text.isEmpty() || text.isBlank()) { - file.writeText("[MIME Cache]${System.lineSeparator()}") - } - if (!file.readText().contains("x-scheme-handler/$APPSCHEMA=$ENTRY_FILE_NAME;")) { - file.appendText("${System.lineSeparator()}x-scheme-handler/$APPSCHEMA=$ENTRY_FILE_NAME;") - } -} - -private fun isRunning(args: Array): Boolean { - val running = - try { - JUnique.acquireLock(LOCK_ID) { - DeeplinkHandler.handleDeeplink(it) - null - } - false - } catch (e: AlreadyLockedException) { - true - } - if (running) { - args.forEach { - JUnique.sendMessage(LOCK_ID, it) - } + fun update(canGoBack: Boolean) { + this.canGoBack = canGoBack } - return running } diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/di/DesktopModule.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/di/DesktopModule.kt index 9ae51a439..34797853e 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/di/DesktopModule.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/di/DesktopModule.kt @@ -5,6 +5,7 @@ import dev.dimension.flare.common.NativeWindowBridge import dev.dimension.flare.common.WebViewBridge import dev.dimension.flare.common.windows.WindowsBridge import dev.dimension.flare.ui.component.ComposeInAppNotification +import dev.dimension.flare.ui.component.SurfaceBindingManager import org.koin.core.module.dsl.singleOf import org.koin.dsl.binds import org.koin.dsl.module @@ -15,4 +16,5 @@ val desktopModule = singleOf(::NativeWindowBridge) singleOf(::WindowsBridge) singleOf(::WebViewBridge) + singleOf(::SurfaceBindingManager) } diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/component/AccountItem.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/component/AccountItem.kt index 05855f3e6..1ab396fcf 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/component/AccountItem.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/component/AccountItem.kt @@ -15,7 +15,6 @@ import dev.dimension.flare.data.repository.LoginExpiredException import dev.dimension.flare.login_expired import dev.dimension.flare.login_expired_relogin import dev.dimension.flare.model.MicroBlogKey -import dev.dimension.flare.ui.component.placeholder import dev.dimension.flare.ui.model.UiProfile import dev.dimension.flare.ui.model.UiState import dev.dimension.flare.ui.model.onError @@ -27,8 +26,8 @@ import io.github.composefluent.component.Text import org.jetbrains.compose.resources.stringResource @Composable -fun AccountItem( - userState: UiState, +fun AccountItem( + userState: UiState, onClick: (MicroBlogKey) -> Unit, toLogin: () -> Unit, modifier: Modifier = Modifier, diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/FloatingWindowState.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/FloatingWindowState.kt deleted file mode 100644 index 8d1a6e38f..000000000 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/FloatingWindowState.kt +++ /dev/null @@ -1,7 +0,0 @@ -package dev.dimension.flare.ui.route - -internal data class FloatingWindowState( - val route: Route.WindowRoute, -) { - var bringToFront: () -> Unit = {} -} diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Route.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Route.kt index ca866a9ed..50637eeea 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Route.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Route.kt @@ -229,8 +229,8 @@ internal sealed interface Route : NavKey { data class WebViewLogin( val url: String, - val cookieCallback: ((cookies: String?) -> Unit)?, - ) : WindowRoute + val callback: (cookies: String?) -> Boolean, + ) : ScreenRoute data class DeepLinkAccountPicker( val originalUrl: String, diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt index 6368db6c3..519c25087 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt @@ -74,10 +74,10 @@ import dev.dimension.flare.ui.screen.rss.EditRssSourceScreen import dev.dimension.flare.ui.screen.rss.ImportOPMLScreen import dev.dimension.flare.ui.screen.rss.RssListScreen import dev.dimension.flare.ui.screen.serviceselect.ServiceSelectScreen +import dev.dimension.flare.ui.screen.serviceselect.WebViewLoginScreen import dev.dimension.flare.ui.screen.settings.AppLoggingScreen import dev.dimension.flare.ui.screen.settings.LocalCacheScreen import dev.dimension.flare.ui.screen.settings.SettingsScreen -import dev.dimension.flare.ui.screen.settings.WebViewLoginScreen import dev.dimension.flare.ui.screen.status.StatusScreen import dev.dimension.flare.ui.screen.status.VVOCommentScreen import dev.dimension.flare.ui.screen.status.VVOStatusScreen @@ -502,6 +502,14 @@ internal fun WindowScope.Router( entry { ServiceSelectScreen( onBack = onBack, + onWebViewLogin = { url, callback -> + navigate( + Route.WebViewLogin( + url = url, + callback = callback, + ), + ) + }, ) } @@ -662,12 +670,12 @@ internal fun WindowScope.Router( } entry( - metadata = window(), + metadata = window(isDarkTheme = true), ) { args -> RawMediaScreen(url = args.rawImage) } entry( - metadata = window(), + metadata = window(isDarkTheme = true), ) { args -> StatusMediaScreen( accountType = args.accountType, @@ -800,10 +808,12 @@ internal fun WindowScope.Router( ) } - entry( - metadata = window(), - ) { args -> - WebViewLoginScreen(route = args) + entry { args -> + WebViewLoginScreen( + url = args.url, + callback = args.callback, + onBack = onBack, + ) } entry( diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/WindowSceneStrategy.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/WindowSceneStrategy.kt index 82057830b..09543edea 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/WindowSceneStrategy.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/WindowSceneStrategy.kt @@ -1,6 +1,7 @@ package dev.dimension.flare.ui.route import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.key import androidx.compose.ui.window.Window @@ -15,6 +16,11 @@ import dev.dimension.flare.flare_logo import dev.dimension.flare.ui.route.WindowSceneStrategy.Companion.window import dev.dimension.flare.ui.theme.FlareTheme import dev.dimension.flare.ui.theme.ProvideComposeWindow +import io.github.composefluent.FluentTheme +import io.github.kdroidfilter.nucleus.window.DecoratedWindow +import io.github.kdroidfilter.nucleus.window.NucleusDecoratedWindowTheme +import io.github.kdroidfilter.nucleus.window.TitleBar +import io.github.kdroidfilter.nucleus.window.styling.LocalTitleBarStyle import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource @@ -24,27 +30,47 @@ internal class WindowScene( private val entry: NavEntry, override val previousEntries: List>, override val overlaidEntries: List>, + private val isDarkTheme: Boolean, private val onBack: () -> Unit, ) : OverlayScene { override val entries: List> = listOf(entry) override val content: @Composable (() -> Unit) = { - Window( - onCloseRequest = onBack, - title = stringResource(Res.string.app_name), - icon = painterResource(Res.drawable.flare_logo), - onKeyEvent = { - if (it.key == Key.Escape) { - onBack.invoke() - true - } else { - false - } - }, + NucleusDecoratedWindowTheme( + isDark = isDarkTheme, ) { - FlareTheme { - ProvideComposeWindow { - entry.Content() + DecoratedWindow( + onCloseRequest = onBack, + title = stringResource(Res.string.app_name), + icon = painterResource(Res.drawable.flare_logo), + onKeyEvent = { + if (it.key == Key.Escape) { + onBack.invoke() + true + } else { + false + } + }, + ) { + FlareTheme( + isDarkTheme = isDarkTheme, + ) { + ProvideComposeWindow { + entry.Content() + } + TitleBar( + style = + LocalTitleBarStyle.current.copy( + colors = + LocalTitleBarStyle.current.colors.copy( + background = + FluentTheme.colors.background.mica.base + .copy(alpha = 0f), + inactiveBackground = Color.Transparent, + ), + ), + ) { + } } } } @@ -81,14 +107,15 @@ internal class WindowScene( public class WindowSceneStrategy : SceneStrategy { public override fun SceneStrategyScope.calculateScene(entries: List>): Scene? { val lastEntry = entries.lastOrNull() - val isWindow = lastEntry?.metadata?.get(WINDOW_KEY) as? Boolean - return if (isWindow == true) { + val isDarkTheme = lastEntry?.metadata?.get(WINDOW_KEY) as? Boolean + return if (isDarkTheme != null) { WindowScene( key = lastEntry.contentKey, entry = lastEntry, previousEntries = entries.dropLast(1), overlaidEntries = entries.dropLast(1), onBack = onBack, + isDarkTheme = isDarkTheme, ) } else { null @@ -100,7 +127,7 @@ public class WindowSceneStrategy : SceneStrategy { * Function to be called on the [NavEntry.metadata] to mark this entry as something that * should be displayed within a [Window]. */ - public fun window(): Map = mapOf(WINDOW_KEY to true) + public fun window(isDarkTheme: Boolean): Map = mapOf(WINDOW_KEY to isDarkTheme) const val WINDOW_KEY = "compose_window" } diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/media/StatusMediaScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/media/StatusMediaScreen.kt index 78d341c47..2bc720c04 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/media/StatusMediaScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/media/StatusMediaScreen.kt @@ -19,6 +19,7 @@ import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -42,6 +43,7 @@ import compose.icons.FontAwesomeIcons import compose.icons.fontawesomeicons.Solid import compose.icons.fontawesomeicons.solid.FloppyDisk import compose.icons.fontawesomeicons.solid.UpRightAndDownLeftFromCenter +import dev.dimension.flare.LocalWindowPadding import dev.dimension.flare.Res import dev.dimension.flare.media_fullscreen import dev.dimension.flare.media_save @@ -59,12 +61,13 @@ import dev.dimension.flare.ui.model.onSuccess import dev.dimension.flare.ui.presenter.invoke import dev.dimension.flare.ui.presenter.status.StatusPresenter import dev.dimension.flare.ui.presenter.status.StatusState -import dev.dimension.flare.ui.theme.FlareTheme import dev.dimension.flare.ui.theme.LocalComposeWindow import io.github.composefluent.FluentTheme import io.github.composefluent.component.GridViewItem import io.github.composefluent.component.HorizontalFlipView import io.github.composefluent.component.SubtleButton +import io.github.kdroidfilter.composemediaplayer.VideoPlayerSurface +import io.github.kdroidfilter.composemediaplayer.rememberVideoPlayerState import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.launch import me.saket.telephoto.ExperimentalTelephotoApi @@ -94,158 +97,180 @@ internal fun StatusMediaScreen( window = window, ) } - FlareTheme( - isDarkTheme = true, + Box( + modifier = + Modifier + .fillMaxSize(), ) { - Box( - modifier = - Modifier - .fillMaxSize(), - ) { - state.medias.onSuccess { medias -> - val pagerState = - rememberPagerState( - initialPage = index, - ) { - medias.size - } - HorizontalFlipView( - state = pagerState, - enabled = state.lockPager, - modifier = - Modifier.fillMaxSize(), + state.medias.onSuccess { medias -> + val pagerState = + rememberPagerState( + initialPage = index, ) { - val media = medias[it] - when (media) { - is UiMedia.Image -> - ImageItem( - modifier = Modifier.fillMaxSize(), - url = media.url, - previewUrl = media.previewUrl, - description = media.description, - isFocused = pagerState.currentPage == it, - setLockPager = state::setLockPager, - onClick = { - state.setShowThumbnailList(!state.showThumbnailList) - }, - ) + medias.size + } + HorizontalFlipView( + state = pagerState, + enabled = state.lockPager, + modifier = + Modifier.fillMaxSize(), + ) { + val media = medias[it] + when (media) { + is UiMedia.Image -> + ImageItem( + modifier = Modifier.fillMaxSize(), + url = media.url, + previewUrl = media.previewUrl, + description = media.description, + isFocused = pagerState.currentPage == it, + setLockPager = state::setLockPager, + onClick = { + state.setShowThumbnailList(!state.showThumbnailList) + }, + ) - else -> - CompositionLocalProvider( - LocalComponentAppearance provides - LocalComponentAppearance - .current - .copy( - videoAutoplay = ComponentAppearance.VideoAutoplay.ALWAYS, - ), - ) { - MediaItem( - media = media, - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Fit, - ) + is UiMedia.Video -> { + if (pagerState.currentPage == it) { + val playerState = rememberVideoPlayerState() + DisposableEffect(Unit) { + playerState.openUri(media.url) + onDispose { + playerState.dispose() + } } + VideoPlayerSurface( + playerState = playerState, + modifier = Modifier.fillMaxSize(), + ) + } else { + MediaItem( + media = media, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Fit, + ) + } } + + else -> + CompositionLocalProvider( + LocalComponentAppearance provides + LocalComponentAppearance + .current + .copy( + videoAutoplay = ComponentAppearance.VideoAutoplay.ALWAYS, + ), + ) { + MediaItem( + media = media, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Fit, + ) + } } - AnimatedVisibility( - state.showThumbnailList, + } + AnimatedVisibility( + state.showThumbnailList, + modifier = + Modifier + .fillMaxWidth() + .align(Alignment.TopCenter), + enter = slideInVertically { -it } + fadeIn(), + exit = slideOutVertically { -it } + fadeOut(), + ) { + Row( modifier = Modifier - .fillMaxWidth() - .align(Alignment.TopCenter), - enter = slideInVertically { -it } + fadeIn(), - exit = slideOutVertically { -it } + fadeOut(), + .background(FluentTheme.colors.background.layer.default) + .padding( + top = LocalWindowPadding.current.calculateTopPadding(), + bottom = 8.dp, + start = 8.dp, + end = 8.dp, + ), + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - Row( - modifier = - Modifier - .background(FluentTheme.colors.background.layer.default) - .height(48.dp) - .padding(8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Spacer(modifier = Modifier.weight(1f)) - SubtleButton( - onClick = { - val current = medias[pagerState.currentPage] - state.save(current) - }, - content = { - FAIcon( - FontAwesomeIcons.Solid.FloppyDisk, - contentDescription = stringResource(Res.string.media_save), - ) - }, - ) - SubtleButton( - onClick = { - if (window != null) { - val current = window.placement - if (current == WindowPlacement.Fullscreen) { - window.placement = WindowPlacement.Floating - } else { - window.placement = WindowPlacement.Fullscreen - } + Spacer(modifier = Modifier.weight(1f)) + SubtleButton( + onClick = { + val current = medias[pagerState.currentPage] + state.save(current) + }, + content = { + FAIcon( + FontAwesomeIcons.Solid.FloppyDisk, + contentDescription = stringResource(Res.string.media_save), + ) + }, + ) + SubtleButton( + onClick = { + if (window != null) { + val current = window.placement + if (current == WindowPlacement.Fullscreen) { + window.placement = WindowPlacement.Floating + } else { + window.placement = WindowPlacement.Fullscreen } - }, - content = { - FAIcon( - FontAwesomeIcons.Solid.UpRightAndDownLeftFromCenter, - contentDescription = stringResource(Res.string.media_fullscreen), - ) - }, - ) - } + } + }, + content = { + FAIcon( + FontAwesomeIcons.Solid.UpRightAndDownLeftFromCenter, + contentDescription = stringResource(Res.string.media_fullscreen), + ) + }, + ) } + } - AnimatedVisibility( - state.showThumbnailList, + AnimatedVisibility( + state.showThumbnailList, + modifier = + Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter), + enter = slideInVertically { it } + fadeIn(), + exit = slideOutVertically { it } + fadeOut(), + ) { + LazyRow( modifier = Modifier - .fillMaxWidth() - .align(Alignment.BottomCenter), - enter = slideInVertically { it } + fadeIn(), - exit = slideOutVertically { it } + fadeOut(), + .background(FluentTheme.colors.background.layer.default) + .height(96.dp) + .padding(8.dp), + horizontalArrangement = + Arrangement.spacedBy( + 8.dp, + Alignment.CenterHorizontally, + ), + verticalAlignment = Alignment.CenterVertically, ) { - LazyRow( - modifier = - Modifier - .background(FluentTheme.colors.background.layer.default) - .height(96.dp) - .padding(8.dp), - horizontalArrangement = - Arrangement.spacedBy( - 8.dp, - Alignment.CenterHorizontally, - ), - verticalAlignment = Alignment.CenterVertically, - ) { - items(medias.size) { index -> - val media = medias[index] - GridViewItem( - selected = pagerState.currentPage == index, - onSelectedChange = { - if (it) { - scope.launch { - pagerState.scrollToPage(index) - } + items(medias.size) { index -> + val media = medias[index] + GridViewItem( + selected = pagerState.currentPage == index, + onSelectedChange = { + if (it) { + scope.launch { + pagerState.scrollToPage(index) } - }, - ) { - NetworkImage( - model = - when (media) { - is UiMedia.Audio -> media.previewUrl - is UiMedia.Gif -> media.previewUrl - is UiMedia.Image -> media.previewUrl - is UiMedia.Video -> media.thumbnailUrl - }, - contentDescription = null, - modifier = - Modifier - .aspectRatio(1f), - ) - } + } + }, + ) { + NetworkImage( + model = + when (media) { + is UiMedia.Audio -> media.previewUrl + is UiMedia.Gif -> media.previewUrl + is UiMedia.Image -> media.previewUrl + is UiMedia.Video -> media.thumbnailUrl + }, + contentDescription = null, + modifier = + Modifier + .aspectRatio(1f), + ) } } } diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/serviceselect/ServiceSelectScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/serviceselect/ServiceSelectScreen.kt index dccf36626..96fc2d171 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/serviceselect/ServiceSelectScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/serviceselect/ServiceSelectScreen.kt @@ -6,18 +6,19 @@ import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalUriHandler import dev.dimension.flare.LocalWindowPadding import dev.dimension.flare.common.OnDeepLink -import dev.dimension.flare.common.WebViewBridge import dev.dimension.flare.ui.model.UiApplication import dev.dimension.flare.ui.presenter.login.VVOLoginPresenter import dev.dimension.flare.ui.presenter.login.XQTLoginPresenter import dev.dimension.flare.ui.screen.login.ServiceSelectionScreenContent import moe.tlaster.precompose.molecule.producePresenter -import org.koin.compose.koinInject @Composable -internal fun ServiceSelectScreen(onBack: () -> Unit) { +internal fun ServiceSelectScreen( + onBack: () -> Unit, + onWebViewLogin: (url: String, cookieCallback: (cookies: String?) -> Boolean) -> Unit, +) { val uriHandler = LocalUriHandler.current - val webviewBridge = koinInject() +// val webviewBridge = koinInject() val xqtLoginState by producePresenter("xqt_login_state") { remember { XQTLoginPresenter(toHome = onBack) @@ -31,9 +32,9 @@ internal fun ServiceSelectScreen(onBack: () -> Unit) { ServiceSelectionScreenContent( contentPadding = LocalWindowPadding.current, onXQT = { - webviewBridge.openAndWaitCookies( + onWebViewLogin.invoke( "https://${UiApplication.XQT.host}", - callback = { cookies -> + { cookies -> if (cookies.isNullOrEmpty()) { false } else { @@ -45,11 +46,25 @@ internal fun ServiceSelectScreen(onBack: () -> Unit) { } }, ) +// webviewBridge.openAndWaitCookies( +// "https://${UiApplication.XQT.host}", +// callback = { cookies -> +// if (cookies.isNullOrEmpty()) { +// false +// } else { +// xqtLoginState.checkChocolate(cookies).also { +// if (it) { +// xqtLoginState.login(cookies) +// } +// } +// } +// }, +// ) }, onVVO = { - webviewBridge.openAndWaitCookies( + onWebViewLogin.invoke( UiApplication.VVo.loginUrl, - callback = { cookies -> + { cookies -> if (cookies.isNullOrEmpty()) { false } else { @@ -61,6 +76,20 @@ internal fun ServiceSelectScreen(onBack: () -> Unit) { } }, ) +// webviewBridge.openAndWaitCookies( +// UiApplication.VVo.loginUrl, +// callback = { cookies -> +// if (cookies.isNullOrEmpty()) { +// false +// } else { +// vvoLoginState.checkChocolate(cookies).also { +// if (it) { +// vvoLoginState.login(cookies) +// } +// } +// } +// }, +// ) }, openUri = uriHandler::openUri, registerDeeplinkCallback = { callback -> diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/serviceselect/WebViewLoginScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/serviceselect/WebViewLoginScreen.kt new file mode 100644 index 000000000..a63864c40 --- /dev/null +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/serviceselect/WebViewLoginScreen.kt @@ -0,0 +1,40 @@ +package dev.dimension.flare.ui.screen.serviceselect + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import io.github.kdroidfilter.webview.web.WebView +import io.github.kdroidfilter.webview.web.rememberWebViewState +import io.ktor.http.Url +import kotlinx.coroutines.delay +import kotlin.time.Duration.Companion.seconds + +@Composable +internal fun WebViewLoginScreen( + url: String, + callback: (String) -> Boolean, + onBack: () -> Unit, +) { + val state = rememberWebViewState(url) + LaunchedEffect(Unit) { + val urlData = Url(url) + val actualUrl = + urlData.protocol.name + .plus("://") + .plus(urlData.host) + .plus("/") + while (true) { + delay(2.seconds) + val cookies = state.nativeWebView.getCookiesForUrl(actualUrl) + if (callback.invoke(cookies.joinToString(";") { "${it.name}=${it.value}" })) { + onBack.invoke() + break + } + } + } + WebView( + state, + modifier = Modifier.fillMaxSize(), + ) +} diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/WebViewLoginScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/WebViewLoginScreen.kt deleted file mode 100644 index f5d854735..000000000 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/WebViewLoginScreen.kt +++ /dev/null @@ -1,8 +0,0 @@ -package dev.dimension.flare.ui.screen.settings - -import androidx.compose.runtime.Composable -import dev.dimension.flare.ui.route.Route - -@Composable -internal fun WebViewLoginScreen(route: Route.WebViewLogin) { -} diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/theme/FlareTheme.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/theme/FlareTheme.kt index 4beba9e7b..55b4b8e71 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/theme/FlareTheme.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/theme/FlareTheme.kt @@ -35,6 +35,7 @@ import dev.dimension.flare.data.model.Theme import dev.dimension.flare.data.repository.SettingsRepository import dev.dimension.flare.ui.component.ComponentAppearance import dev.dimension.flare.ui.component.LocalComponentAppearance +import dev.dimension.flare.ui.component.platform.LocalWifiState import dev.dimension.flare.ui.humanizer.updateTimeFormatterLocale import dev.dimension.flare.ui.model.collectAsUiState import dev.dimension.flare.ui.model.onSuccess @@ -42,7 +43,10 @@ import io.github.composefluent.ExperimentalFluentApi import io.github.composefluent.FluentTheme import io.github.composefluent.darkColors import io.github.composefluent.lightColors -import io.github.kdroidfilter.platformtools.darkmodedetector.isSystemInDarkMode +import io.github.kdroidfilter.nucleus.darkmodedetector.isSystemInDarkMode +import io.github.kdroidfilter.nucleus.window.DecoratedWindowDefaults +import io.github.kdroidfilter.nucleus.window.NucleusDecoratedWindowTheme +import io.github.kdroidfilter.nucleus.window.styling.LocalTitleBarStyle import kotlinx.coroutines.launch import org.apache.commons.lang3.SystemUtils import org.koin.compose.koinInject @@ -109,16 +113,12 @@ internal fun WindowScope.ProvideComposeWindow(content: @Composable () -> Unit) { CompositionLocalProvider( LocalComposeWindow provides composeWindow, LocalWindowPadding provides - if (SystemUtils.IS_OS_MAC) { - PaddingValues( - start = 0.dp, - top = 24.dp + 8.dp, - end = 0.dp, - bottom = 8.dp, - ) - } else { - PaddingValues(vertical = 8.dp) - }, + PaddingValues( + start = 0.dp, + top = LocalTitleBarStyle.current.metrics.height + 8.dp, + end = 0.dp, + bottom = 8.dp, + ), ) { content.invoke() } @@ -231,7 +231,7 @@ internal fun ProvideThemeSettings(content: @Composable () -> Unit) { showLinkPreview = appearanceSettings.showLinkPreview, showMedia = appearanceSettings.showMedia, showSensitiveContent = appearanceSettings.showSensitiveContent, - videoAutoplay = ComponentAppearance.VideoAutoplay.NEVER, + videoAutoplay = ComponentAppearance.VideoAutoplay.ALWAYS, expandMediaSize = appearanceSettings.expandMediaSize, compatLinkPreview = appearanceSettings.compatLinkPreview, aiConfig = @@ -245,9 +245,29 @@ internal fun ProvideThemeSettings(content: @Composable () -> Unit) { showPlatformLogo = appearanceSettings.showPlatformLogo, ) }, + LocalWifiState provides true, content = { + val isDark = isDarkTheme() + val titleBarStyle = + if (isDark) { + DecoratedWindowDefaults.darkTitleBarStyle() + } else { + DecoratedWindowDefaults.lightTitleBarStyle() + }.let { + it.copy( + metrics = + it.metrics.copy( + height = 40.dp, + ), + ) + } key(appSettings.language) { - content.invoke() + NucleusDecoratedWindowTheme( + isDark = isDark, + titleBarStyle = titleBarStyle, + ) { + content.invoke() + } } }, ) diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties new file mode 100644 index 000000000..233787b3c --- /dev/null +++ b/gradle/gradle-daemon-jvm.properties @@ -0,0 +1,2 @@ +toolchainVersion=25 +toolchainVendor=Jetbrains diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 772f42156..4e9c62e61 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,7 +8,7 @@ jna = "5.18.1" junique = "1.0.4" lifecycleViewmodelComposeVersion = "2.10.0-alpha06" minSdk = "23" -java = "21" +java = "25" agp = "8.13.2" kotlin = "2.3.10" core-ktx = "1.17.0" diff --git a/settings.gradle.kts b/settings.gradle.kts index 127fdbd2d..89311dafb 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -5,6 +5,9 @@ pluginManagement { gradlePluginPortal() } } +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" +} dependencyResolutionManagement { // repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories {