From 42039bade140a222468f86ac0cfa4e540dccd138 Mon Sep 17 00:00:00 2001 From: Kyle Bolen Date: Thu, 30 Jul 2026 06:03:43 +0000 Subject: [PATCH] Live Photo inline video player using JavaFX MediaPlayer Add OpenJFX dependencies (javafx-media, javafx-swing, javafx-graphics, javafx-base 21.0.3 mac platform) to build.gradle.kts. LivePhotoPlayer.kt: embeds JavaFX MediaPlayer in a SwingPanel via JFXPanel Swing interop. Auto-plays, loops infinitely, muted. PhotoPreviewDialog: pulls MOV companion to temp file, shows still HEIC above and looping video below in a 16:9 panel. Falls back gracefully if JavaFX is not available (isJavaFxMediaAvailable() check). --- build.gradle.kts | 9 ++ .../bolenpad/photophetch/ui/BrowseScreen.kt | 34 +++++-- .../photophetch/ui/LivePhotoPlayer.kt | 96 +++++++++++++++++++ 3 files changed, 132 insertions(+), 7 deletions(-) create mode 100644 src/main/kotlin/com/bolenpad/photophetch/ui/LivePhotoPlayer.kt diff --git a/build.gradle.kts b/build.gradle.kts index 8f8b8e1..9f66622 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -31,6 +31,15 @@ dependencies { implementation(libs.serialization.json) implementation(libs.slf4j.api) implementation(libs.logback.classic) + + // JavaFX media for Live Photo video playback in preview dialog + // Using OpenJFX which works with both Corretto and other JDK distributions + val javafxVersion = "21.0.3" + val javafxPlatform = "mac" // mac, mac-aarch64, win, linux + implementation("org.openjfx:javafx-media:$javafxVersion:$javafxPlatform") + implementation("org.openjfx:javafx-swing:$javafxVersion:$javafxPlatform") + implementation("org.openjfx:javafx-graphics:$javafxVersion:$javafxPlatform") + implementation("org.openjfx:javafx-base:$javafxVersion:$javafxPlatform") } compose.desktop { diff --git a/src/main/kotlin/com/bolenpad/photophetch/ui/BrowseScreen.kt b/src/main/kotlin/com/bolenpad/photophetch/ui/BrowseScreen.kt index c3e6db6..89c5c32 100644 --- a/src/main/kotlin/com/bolenpad/photophetch/ui/BrowseScreen.kt +++ b/src/main/kotlin/com/bolenpad/photophetch/ui/BrowseScreen.kt @@ -363,6 +363,9 @@ private fun PhotoPreviewDialog( val hash = (cacheKey + photo.devicePath).hashCode().toString(16) val cachePath = cacheDir.resolve("$hash.jpg") + // For Live Photos: pull companion MOV to temp for inline playback + var movPath by remember(photo.devicePath) { mutableStateOf(null) } + LaunchedEffect(photo.devicePath) { val bytes = withContext(Dispatchers.IO) { // Always use the cached JPEG (sips-converted) — works for HEIC too @@ -390,17 +393,16 @@ private fun PhotoPreviewDialog( runCatching { bitmap = SkiaImage.makeFromEncoded(bytes).toComposeImageBitmap() } } - // For Live Photos: open the companion MOV in system viewer + // For Live Photos: pull companion MOV for inline video playback if (photo.isLivePhotoStill && photo.companionDevicePath != null) { val tmpDir = Paths.get(System.getProperty("java.io.tmpdir"), "photophetch-preview") + tmpDir.toFile().mkdirs() val movFilename = photo.companionDevicePath.substringAfterLast("/") val tmpMov = tmpDir.resolve(movFilename) - val ok = withContext(Dispatchers.IO) { pullFn(photo.companionDevicePath, tmpMov) } - if (ok && tmpMov.toFile().exists()) { - withContext(Dispatchers.IO) { - ProcessBuilder("open", tmpMov.toString()).start() - } + if (!tmpMov.toFile().exists()) { + withContext(Dispatchers.IO) { pullFn(photo.companionDevicePath, tmpMov) } } + if (tmpMov.toFile().exists()) movPath = tmpMov.toString() } } @@ -441,7 +443,7 @@ private fun PhotoPreviewDialog( CircularProgressIndicator() if (photo.isLivePhotoStill) { Spacer(Modifier.height(8.dp)) - Text("Loading Live Photo + opening video…", + Text("Loading…", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } @@ -449,6 +451,24 @@ private fun PhotoPreviewDialog( } } + // Live Photo motion companion — inline looping video player + val mov = movPath + if (photo.isLivePhotoStill && mov != null && isJavaFxMediaAvailable()) { + Spacer(Modifier.height(4.dp)) + Text("▶ Live Photo motion", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(Modifier.height(2.dp)) + LivePhotoPlayer( + videoPath = mov, + modifier = Modifier.fillMaxWidth().aspectRatio(16f / 9f), + ) + } else if (photo.isLivePhotoStill && mov == null && movPath == null) { + Text("⏳ Loading motion clip…", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Text("${photo.sizeBytes / 1024} KB · ${photo.sourceFolder}", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) diff --git a/src/main/kotlin/com/bolenpad/photophetch/ui/LivePhotoPlayer.kt b/src/main/kotlin/com/bolenpad/photophetch/ui/LivePhotoPlayer.kt new file mode 100644 index 0000000..e749e05 --- /dev/null +++ b/src/main/kotlin/com/bolenpad/photophetch/ui/LivePhotoPlayer.kt @@ -0,0 +1,96 @@ +package com.bolenpad.photophetch.ui + +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.awt.SwingPanel +import javafx.application.Platform +import javafx.embed.swing.JFXPanel +import javafx.scene.Scene +import javafx.scene.layout.StackPane +import javafx.scene.media.Media +import javafx.scene.media.MediaPlayer +import javafx.scene.media.MediaView +import javafx.util.Duration +import org.slf4j.LoggerFactory +import java.io.File +import javax.swing.JPanel +import java.awt.BorderLayout + +private val log = LoggerFactory.getLogger("LivePhotoPlayer") + +/** + * Embeds a looping JavaFX MediaPlayer inside a Compose Desktop SwingPanel. + * Used to play Live Photo companion MOV files inline in the preview dialog. + * + * JavaFX requires its toolkit to be initialized before use. We initialize it + * lazily on first use via a JFXPanel (which triggers the JavaFX runtime). + */ +@Composable +fun LivePhotoPlayer( + videoPath: String, + modifier: Modifier = Modifier, +) { + // Hold a reference to the MediaPlayer so we can dispose it when the composable leaves + var mediaPlayer by remember { mutableStateOf(null) } + + DisposableEffect(videoPath) { + onDispose { + mediaPlayer?.stop() + mediaPlayer?.dispose() + } + } + + SwingPanel( + modifier = modifier, + factory = { + val panel = JPanel(BorderLayout()) + + // JFXPanel initializes the JavaFX runtime on first instantiation + val jfxPanel = JFXPanel() + panel.add(jfxPanel, BorderLayout.CENTER) + + Platform.runLater { + try { + val media = Media(File(videoPath).toURI().toString()) + val player = MediaPlayer(media).apply { + isAutoPlay = true + cycleCount = MediaPlayer.INDEFINITE // loop forever + // Mute — Live Photos shouldn't have audio in preview + isMute = true + setOnEndOfMedia { seek(Duration.ZERO) } + } + mediaPlayer = player + + val mediaView = MediaView(player).apply { + isPreserveRatio = true + fitWidth = 600.0 // reasonable default; panel resizes via Compose + } + + val root = StackPane(mediaView) + root.style = "-fx-background-color: black;" + jfxPanel.scene = Scene(root) + + player.play() + } catch (e: Exception) { + log.warn("LivePhotoPlayer: could not initialize JavaFX player for $videoPath: ${e.message}") + } + } + + panel + }, + update = { /* no dynamic updates needed */ }, + ) +} + +/** + * Check if JavaFX media is available on the classpath. + * Returns true if LivePhotoPlayer can be used. + */ +fun isJavaFxMediaAvailable(): Boolean { + return try { + Class.forName("javafx.scene.media.MediaPlayer") + true + } catch (_: ClassNotFoundException) { + false + } +}