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:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user