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).
This commit is contained in:
Kyle Bolen
2026-07-30 06:03:43 +00:00
parent 4171ff04d2
commit 42039bade1
3 changed files with 132 additions and 7 deletions

View File

@@ -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<String?>(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)

View File

@@ -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<MediaPlayer?>(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
}
}