diff --git a/Build/Build Avalonia.Win32 arm64.ps1 b/Build/Build Avalonia.Win32 arm64.ps1
index 7830af26d..7094fb6e8 100644
--- a/Build/Build Avalonia.Win32 arm64.ps1
+++ b/Build/Build Avalonia.Win32 arm64.ps1
@@ -56,11 +56,12 @@ New-Item -Path $outputPath -ItemType Directory | Out-Null
# Copy the build output to the final destination
Copy-Item -Path "$tempPath\*" -Destination $outputPath -Recurse -Force
-# Remove the PDB file
-$pdbPath = Join-Path -Path $outputPath -ChildPath "PicView.Avalonia.pdb"
-if (Test-Path $pdbPath) {
- Remove-Item -Path $pdbPath -Force
-}
+# Trim libvlc down to what motion photo playback needs (sibling archs, *.lib, lua,
+# hrtfs and unused plugins; ~275 MB -> ~23 MB)
+& (Join-Path -Path $PSScriptRoot -ChildPath "Trim-LibVLCPlugins.ps1") -LibVlcRoot (Join-Path -Path $outputPath -ChildPath "libvlc") -TargetArch "win-$platform"
+
+# Remove debug symbols (native PicView.pdb alone is >150 MB)
+Remove-Item -Path "$outputPath\*.pdb" -Force -ErrorAction SilentlyContinue
#Remove uninstended space
Rename-Item -path $outputPath -NewName $outputPath.Replace(" ","")
diff --git a/Build/Build Avalonia.Win32 x64.ps1 b/Build/Build Avalonia.Win32 x64.ps1
index 4db7de467..d42f73f2a 100644
--- a/Build/Build Avalonia.Win32 x64.ps1
+++ b/Build/Build Avalonia.Win32 x64.ps1
@@ -28,17 +28,18 @@ New-Item -Path $outputPath -ItemType Directory | Out-Null
# Copy the build output to the final destination
Copy-Item -Path "$tempPath\*" -Destination $outputPath -Recurse -Force
+# Trim libvlc down to what motion photo playback needs (sibling archs, *.lib, lua,
+# hrtfs and unused plugins; ~275 MB -> ~23 MB)
+& (Join-Path -Path $PSScriptRoot -ChildPath "Trim-LibVLCPlugins.ps1") -LibVlcRoot (Join-Path -Path $outputPath -ChildPath "libvlc") -TargetArch "win-x64"
+
# Remove the license file
$licensePath = Join-Path -Path $outputPath -ChildPath "Licenses\XamlAnimatedGif LICENSE.txt"
if (Test-Path $licensePath) {
Remove-Item -Path $licensePath -Force
}
-# Remove the PDB file
-$pdbPath = Join-Path -Path $outputPath -ChildPath "PicView.Avalonia.pdb"
-if (Test-Path $pdbPath) {
- Remove-Item -Path $pdbPath -Force
-}
+# Remove debug symbols (native PicView.pdb alone is >150 MB)
+Remove-Item -Path "$outputPath\*.pdb" -Force -ErrorAction SilentlyContinue
#Remove uninstended space
Rename-Item -path $outputPath -NewName $outputPath.Replace(" ","")
diff --git a/Build/Build Avalonia.Win32.ps1 b/Build/Build Avalonia.Win32.ps1
index 4deddd873..1fc284ab6 100644
--- a/Build/Build Avalonia.Win32.ps1
+++ b/Build/Build Avalonia.Win32.ps1
@@ -37,11 +37,11 @@ $avaloniaProjectPath = Join-Path -Path $PSScriptRoot -ChildPath "..\src\PicView.
# Run dotnet publish for the Avalonia project
dotnet publish $avaloniaProjectPath --runtime "win-$Platform" --self-contained true --configuration Release --output $outputPath /p:PublishReadyToRun=true
+# Trim libvlc down to what motion photo playback needs (sibling archs, *.lib, lua,
+# hrtfs and unused plugins; ~275 MB -> ~23 MB)
+& (Join-Path -Path $PSScriptRoot -ChildPath "Trim-LibVLCPlugins.ps1") -LibVlcRoot (Join-Path -Path $outputPath -ChildPath "libvlc") -TargetArch "win-$Platform"
-# Remove the PDB file
-$pdbPath = Join-Path -Path $outputPath -ChildPath "PicView.Avalonia.pdb"
-if (Test-Path $pdbPath) {
- Remove-Item -Path $pdbPath -Force
-}
+# Remove debug symbols (native PicView.pdb alone is >150 MB)
+Remove-Item -Path "$outputPath\*.pdb" -Force -ErrorAction SilentlyContinue
diff --git a/Build/Trim-LibVLCPlugins.ps1 b/Build/Trim-LibVLCPlugins.ps1
new file mode 100644
index 000000000..767d8610c
--- /dev/null
+++ b/Build/Trim-LibVLCPlugins.ps1
@@ -0,0 +1,102 @@
+param (
+ [Parameter(Mandatory = $true)]
+ [string]$LibVlcRoot,
+
+ [Parameter(Mandatory = $true)]
+ [string]$TargetArch
+)
+
+# Trims the VideoLAN.LibVLC.Windows payload down to what motion photo playback needs:
+# 1. Sibling architecture folders (libvlc\win-*) other than the publish target arch —
+# the NuGet package copies all architectures even for a RID-specific publish.
+# 2. Link-time-only import libraries (*.lib) in the arch root.
+# 3. lua\ scripts (the lua plugin is not kept) and hrtfs\ data (the headphone
+# spatializer audio filter is not kept).
+# 4. Plugin DLLs not on the whitelist below.
+#
+# The whitelist was determined empirically via `libvlc -vv` module logs and verified by
+# playing h264/aac mp4 files through the software video callbacks (StreamMediaInput),
+# including the 1080p d3d11va/dxva2 hardware decode path:
+# imem access, mp4 demux, avcodec decoder (+ hw accel), swscale/yuvp/chain +
+# d3d11/d3d9 video converters, vmem video output, wasapi/mmdevice audio chain.
+# Missing whitelist entries are fine (e.g. win-arm64 has no d3d9/dxva2 plugins).
+$whitelist = @(
+ "access\libaccess_imem_plugin.dll",
+ "access\libimem_plugin.dll",
+ "audio_filter\libscaletempo_plugin.dll",
+ "audio_filter\libtrivial_channel_mixer_plugin.dll",
+ "audio_filter\libugly_resampler_plugin.dll",
+ "audio_mixer\libfloat_mixer_plugin.dll",
+ "audio_output\libmmdevice_plugin.dll",
+ "audio_output\libwasapi_plugin.dll",
+ "codec\libavcodec_plugin.dll",
+ "codec\libd3d11va_plugin.dll",
+ "codec\libdxva2_plugin.dll",
+ "d3d11\libdirect3d11_filters_plugin.dll",
+ "d3d9\libdirect3d9_filters_plugin.dll",
+ "demux\libmp4_plugin.dll",
+ "keystore\libmemory_keystore_plugin.dll",
+ "logger\libconsole_logger_plugin.dll",
+ "stream_filter\libprefetch_plugin.dll",
+ "stream_filter\librecord_plugin.dll",
+ "video_chroma\libchain_plugin.dll",
+ "video_chroma\libswscale_plugin.dll",
+ "video_chroma\libyuvp_plugin.dll",
+ "video_output\libvmem_plugin.dll"
+)
+
+if (-not (Test-Path $LibVlcRoot)) {
+ Write-Warning "libvlc directory not found: $LibVlcRoot"
+ return
+}
+
+# 1. Remove sibling architecture folders (e.g. win-x86, and win-arm64 in an x64 publish)
+Get-ChildItem -Path $LibVlcRoot -Directory -Filter "win-*" | Where-Object {
+ $_.Name -ne $TargetArch
+} | ForEach-Object {
+ $sizeMB = ((Get-ChildItem $_.FullName -Recurse -File) | Measure-Object Length -Sum).Sum / 1MB
+ Remove-Item -Path $_.FullName -Recurse -Force
+ Write-Host ("libvlc trim: removed sibling arch {0} ({1:N1} MB)" -f $_.Name, $sizeMB)
+}
+
+$archDir = Join-Path -Path $LibVlcRoot -ChildPath $TargetArch
+if (-not (Test-Path $archDir)) {
+ Write-Warning "libvlc arch directory not found: $archDir"
+ return
+}
+
+# 2. Remove link-time-only import libraries
+Get-ChildItem -Path $archDir -Filter *.lib | ForEach-Object {
+ Remove-Item -Path $_.FullName -Force
+}
+
+# 3. Remove lua scripts and HRTF data (their plugins/filters are not kept)
+foreach ($subDir in "lua", "hrtfs") {
+ $path = Join-Path -Path $archDir -ChildPath $subDir
+ if (Test-Path $path) {
+ Remove-Item -Path $path -Recurse -Force
+ }
+}
+
+# 4. Remove plugin DLLs not on the whitelist
+$pluginsDir = Join-Path -Path $archDir -ChildPath "plugins"
+if (-not (Test-Path $pluginsDir)) {
+ Write-Warning "libvlc plugins directory not found: $pluginsDir"
+ return
+}
+
+$removed = 0
+Get-ChildItem -Path $pluginsDir -Recurse -Filter *.dll | ForEach-Object {
+ $relativePath = $_.FullName.Substring($pluginsDir.Length + 1)
+ if ($whitelist -notcontains $relativePath) {
+ Remove-Item -Path $_.FullName -Force
+ $removed++
+ }
+}
+
+# Drop plugin directories left empty
+Get-ChildItem -Path $pluginsDir -Directory | Where-Object {
+ -not (Get-ChildItem -Path $_.FullName -Recurse -File)
+} | Remove-Item -Recurse -Force
+
+Write-Host "libvlc trim ($TargetArch): removed $removed plugin DLLs, kept $($whitelist.Count) (motion photo playback set)"
diff --git a/src/PicView.Avalonia.Win32/PicView.Avalonia.Win32.csproj b/src/PicView.Avalonia.Win32/PicView.Avalonia.Win32.csproj
index 2aeca500c..9260d16ed 100644
--- a/src/PicView.Avalonia.Win32/PicView.Avalonia.Win32.csproj
+++ b/src/PicView.Avalonia.Win32/PicView.Avalonia.Win32.csproj
@@ -120,4 +120,8 @@
PreserveNewest
+
+
+
+
diff --git a/src/PicView.Avalonia/CustomControls/ZoomPanControl.cs b/src/PicView.Avalonia/CustomControls/ZoomPanControl.cs
index 7c57c70ca..3919130b8 100644
--- a/src/PicView.Avalonia/CustomControls/ZoomPanControl.cs
+++ b/src/PicView.Avalonia/CustomControls/ZoomPanControl.cs
@@ -239,6 +239,13 @@ private void HandleResetZoomOrStartPanning(object? sender, PointerPressedEventAr
return;
}
+ // Interactive children (e.g. the motion photo play badge) handle their own clicks;
+ // starting a pan here would capture the pointer and swallow their click.
+ if (e.Source is Button)
+ {
+ return;
+ }
+
// Panning shouldn't happen when moving the window by holding shift
if (e.KeyModifiers == KeyModifiers.Shift)
{
diff --git a/src/PicView.Avalonia/FileSystem/FileSaverHelper.cs b/src/PicView.Avalonia/FileSystem/FileSaverHelper.cs
index b72340245..37c58ce0d 100644
--- a/src/PicView.Avalonia/FileSystem/FileSaverHelper.cs
+++ b/src/PicView.Avalonia/FileSystem/FileSaverHelper.cs
@@ -96,6 +96,7 @@ async ValueTask SaveBitmap()
{
case ImageType.AnimatedGif: // TODO: Add animated GIF support
case ImageType.AnimatedWebp: // TODO: Add animated WebP support
+ case ImageType.MotionPhoto: // Saves the still cover; the embedded video is only kept via the file-copy path
case ImageType.Bitmap:
{
if (tab.Image.CurrentValue is not Bitmap bitmap)
@@ -132,6 +133,7 @@ async ValueTask SaveProcessedMagickImage()
{
case ImageType.AnimatedGif: // TODO: Add animated GIF support
case ImageType.AnimatedWebp: // TODO: Add animated WebP support
+ case ImageType.MotionPhoto: // Saving a processed image drops the embedded video, keep the still only
case ImageType.Bitmap:
{
if (angle is not 0)
diff --git a/src/PicView.Avalonia/ImageHandling/GetImageModel.cs b/src/PicView.Avalonia/ImageHandling/GetImageModel.cs
index 9ff7ccc28..5d9b06eb4 100644
--- a/src/PicView.Avalonia/ImageHandling/GetImageModel.cs
+++ b/src/PicView.Avalonia/ImageHandling/GetImageModel.cs
@@ -1,3 +1,4 @@
+using System.Text;
using Avalonia.Media.Imaging;
using Avalonia.Svg.Skia;
using ImageMagick;
@@ -7,6 +8,7 @@
using PicView.Core.Exif;
using PicView.Core.ImageDecoding;
using PicView.Core.Models;
+using PicView.Core.MotionPhoto;
using PicView.Core.Navigation.Tiff;
namespace PicView.Avalonia.ImageHandling;
@@ -36,6 +38,14 @@ public static async ValueTask GetImageModelAsync(FileInfo fileInfo,
try
{
+ // .livp is a zip container that cannot be pinged by Magick, so it must be
+ // handled before the MagickImage is initialized.
+ if (fileInfo.Extension.Equals(".livp", StringComparison.InvariantCultureIgnoreCase))
+ {
+ await ProcessLivpAsync(fileInfo, imageModel).ConfigureAwait(false);
+ return imageModel;
+ }
+
// Initialize MagickImage if not provided
magickImage ??= GetImage.CreateAndPingMagickImage(fileInfo);
@@ -139,6 +149,8 @@ public static async ValueTask GetImageModelAsync(FileInfo fileInfo,
break;
}
+ TryDetectMotionPhoto(fileInfo, magickImage, imageModel);
+
return imageModel;
}
catch (Exception e)
@@ -226,6 +238,52 @@ internal static bool IsAnimatedGif(FileInfo fileInfo)
}
}
+ ///
+ /// Checks whether a successfully decoded bitmap is actually a motion photo (XMP embedded
+ /// video, Samsung trailer or sidecar file) and upgrades the model accordingly.
+ /// Only the video location metadata is recorded here; the video bytes are extracted
+ /// on demand when playback starts.
+ ///
+ private static void TryDetectMotionPhoto(FileInfo fileInfo, MagickImage magickImage, ImageModel imageModel)
+ {
+ if (imageModel.ImageType is not ImageType.Bitmap)
+ {
+ return;
+ }
+
+ var extension = fileInfo.Extension;
+ if (!extension.Equals(".jpg", StringComparison.OrdinalIgnoreCase) &&
+ !extension.Equals(".jpeg", StringComparison.OrdinalIgnoreCase) &&
+ !extension.Equals(".heic", StringComparison.OrdinalIgnoreCase) &&
+ !extension.Equals(".heif", StringComparison.OrdinalIgnoreCase))
+ {
+ return;
+ }
+
+ string? xmpPacket = null;
+ try
+ {
+ var xmpProfile = magickImage.GetXmpProfile();
+ if (xmpProfile is not null)
+ {
+ xmpPacket = Encoding.UTF8.GetString(xmpProfile.ToByteArray());
+ }
+ }
+ catch (Exception e)
+ {
+ DebugHelper.LogDebug(nameof(GetImageModel), nameof(TryDetectMotionPhoto), e);
+ }
+
+ var info = MotionPhotoDetector.TryDetect(fileInfo, xmpPacket);
+ if (info is null)
+ {
+ return;
+ }
+
+ imageModel.ImageType = ImageType.MotionPhoto;
+ imageModel.MotionPhoto = info;
+ }
+
#region Image Processing Methods
private static async ValueTask ProcessSkBitmapAsync(FileInfo fileInfo, MagickFormat format, ImageModel imageModel)
@@ -248,6 +306,39 @@ private static async ValueTask ProcessBase64Async(FileInfo fileInfo, MagickForma
var bitmap = await GetImage.GetBase64ImageAsync(fileInfo).ConfigureAwait(false);
SetBitmapProperties(bitmap, imageModel);
}
+
+ ///
+ /// Handles Apple .livp containers (a zip holding a still image plus a video).
+ /// The cover image is extracted to a temporary file and decoded through the regular
+ /// pipeline, while the model keeps pointing at the original .livp file.
+ ///
+ private static async ValueTask ProcessLivpAsync(FileInfo fileInfo, ImageModel imageModel)
+ {
+ var tempImagePath = await MotionPhotoExtractor.ExtractLivpCoverToTempFileAsync(fileInfo).ConfigureAwait(false);
+ if (tempImagePath is null)
+ {
+ imageModel.ImageType = ImageType.Invalid;
+ return;
+ }
+
+ var tempFileInfo = new FileInfo(tempImagePath);
+ using var tempMagickImage = GetImage.CreateAndPingMagickImage(tempFileInfo);
+ if (tempMagickImage.Format is MagickFormat.Jpe or MagickFormat.Jpeg or MagickFormat.Pjpeg)
+ {
+ await ProcessSkBitmapAsync(tempFileInfo, tempMagickImage.Format, imageModel).ConfigureAwait(false);
+ }
+ else
+ {
+ await ProcessNonStandardImageAsync(tempFileInfo, imageModel, tempMagickImage).ConfigureAwait(false);
+ }
+
+ imageModel.FileInfo = fileInfo;
+ if (imageModel.ImageType is ImageType.Bitmap)
+ {
+ imageModel.ImageType = ImageType.MotionPhoto;
+ imageModel.MotionPhoto = new MotionPhotoInfo { Source = MotionPhotoSource.LivpContainer };
+ }
+ }
private static async ValueTask ProcessRawImageAsync(FileInfo fileInfo, ImageModel imageModel, MagickImage magickImage)
{
diff --git a/src/PicView.Avalonia/Input/MainKeyboardShortcuts.cs b/src/PicView.Avalonia/Input/MainKeyboardShortcuts.cs
index aeecd4c32..6c2afde40 100644
--- a/src/PicView.Avalonia/Input/MainKeyboardShortcuts.cs
+++ b/src/PicView.Avalonia/Input/MainKeyboardShortcuts.cs
@@ -4,6 +4,7 @@
using Avalonia.Input;
using PicView.Avalonia.CustomControls;
using PicView.Avalonia.Navigation;
+using PicView.Avalonia.Views.UC;
using PicView.Core.ViewModels;
namespace PicView.Avalonia.Input;
@@ -186,6 +187,22 @@ private static async ValueTask HandleSpecialCases(KeyEventArgs e, MainWind
return true;
}
+ // Motion photo playback: Space plays/pauses, Escape stops an active playback
+ if (vm.WindowTabs.ActiveTab.CurrentValue.CurrentView.CurrentValue is ImageViewer imageViewer &&
+ imageViewer.IsMotionPhotoActive)
+ {
+ if (e.Key is Key.Space)
+ {
+ imageViewer.ToggleMotionPhotoPlayPause();
+ return true;
+ }
+
+ if (e.Key is Key.Escape && imageViewer.StopMotionPhotoIfPlaying())
+ {
+ return true;
+ }
+ }
+
// Handle open dialog
if (mainWindow.IsDialogOpen)
{
diff --git a/src/PicView.Avalonia/MotionPhoto/MotionPhotoService.cs b/src/PicView.Avalonia/MotionPhoto/MotionPhotoService.cs
new file mode 100644
index 000000000..85a0fe438
--- /dev/null
+++ b/src/PicView.Avalonia/MotionPhoto/MotionPhotoService.cs
@@ -0,0 +1,107 @@
+using System.Runtime.InteropServices;
+using LibVLCSharp.Shared;
+using PicView.Core.DebugTools;
+
+namespace PicView.Avalonia.MotionPhoto;
+
+///
+/// Owns the process-wide instance used for motion photo playback.
+/// Initialization is lazy and failure is remembered, so a missing or broken native libvlc
+/// simply degrades motion photos to regular still images instead of breaking the viewer.
+///
+public static class MotionPhotoService
+{
+ private static readonly object InitLock = new();
+ private static LibVLC? _libVlc;
+ private static bool _initFailed;
+
+ ///
+ /// Video playback is supported on all desktop platforms: frames are produced by libvlc
+ /// software video callbacks and rendered in the Avalonia compositor, so no native window
+ /// embedding is needed (which also means Wayland sessions are fully supported).
+ /// Playback still requires libvlc to be installed/available.
+ ///
+ public static bool IsPlaybackSupported =>
+ OperatingSystem.IsWindows() || OperatingSystem.IsLinux() || OperatingSystem.IsMacOS();
+
+ ///
+ /// Attempts to get (or lazily create) the shared instance.
+ ///
+ /// False when playback is unavailable; callers should fall back to the still image.
+ public static bool TryGetLibVlc(out LibVLC? libVlc)
+ {
+ if (_libVlc is not null)
+ {
+ libVlc = _libVlc;
+ return true;
+ }
+
+ if (!IsPlaybackSupported || _initFailed)
+ {
+ libVlc = null;
+ return false;
+ }
+
+ lock (InitLock)
+ {
+ if (_libVlc is not null)
+ {
+ libVlc = _libVlc;
+ return true;
+ }
+
+ if (_initFailed)
+ {
+ libVlc = null;
+ return false;
+ }
+
+ try
+ {
+ InitializeNativeLibrary();
+ _libVlc = new LibVLC(
+ "--no-video-title-show",
+ "--no-stats",
+ "--quiet",
+ // Prefer hardware decoding (dxva2/d3d11va/vaapi/videotoolbox); libvlc
+ // falls back to software decoding automatically when unavailable.
+ // Frames are still downloaded to CPU memory for the software video callbacks.
+ "--avcodec-hw=any");
+ libVlc = _libVlc;
+ return true;
+ }
+ catch (Exception e)
+ {
+ DebugHelper.LogDebug(nameof(MotionPhotoService), nameof(TryGetLibVlc), e);
+ _initFailed = true;
+ libVlc = null;
+ return false;
+ }
+ }
+ }
+
+ ///
+ /// On Windows, points libvlc at the native libraries deployed by the VideoLAN.LibVLC.Windows
+ /// package (copied to "libvlc\win-x64" or "libvlc\win-arm64" next to the application).
+ /// On Linux and macOS, libvlc comes from the system installation (VideoLAN does not ship
+ /// NuGet packages for these platforms) and LibVLCSharp's default search is used.
+ ///
+ private static void InitializeNativeLibrary()
+ {
+ if (OperatingSystem.IsWindows())
+ {
+ var architectureFolder = RuntimeInformation.ProcessArchitecture is Architecture.Arm64
+ ? "win-arm64"
+ : "win-x64";
+ var libVlcDirectory = Path.Combine(AppContext.BaseDirectory, "libvlc", architectureFolder);
+ if (Directory.Exists(libVlcDirectory))
+ {
+ LibVLCSharp.Shared.Core.Initialize(libVlcDirectory);
+ return;
+ }
+ }
+
+ // Default search: libvlc.so.5 (Linux, system-installed) or the libvlc framework (macOS)
+ LibVLCSharp.Shared.Core.Initialize();
+ }
+}
diff --git a/src/PicView.Avalonia/MotionPhoto/MotionPhotoVideoSurface.cs b/src/PicView.Avalonia/MotionPhoto/MotionPhotoVideoSurface.cs
new file mode 100644
index 000000000..41056d5fa
--- /dev/null
+++ b/src/PicView.Avalonia/MotionPhoto/MotionPhotoVideoSurface.cs
@@ -0,0 +1,129 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Media;
+using Avalonia.Media.Imaging;
+using Avalonia.Platform;
+using PicView.Core.DebugTools;
+
+namespace PicView.Avalonia.MotionPhoto;
+
+///
+/// Renders motion photo video frames supplied by libvlc software video callbacks
+/// (MediaPlayer.SetVideoCallbacks). Frames arrive as BGRA32 ("RV32") bytes and are drawn
+/// letterboxed into the control. This works on every display stack, including Wayland
+/// (where native child-window embedding is impossible with libvlc 3.x), and lets the
+/// video participate in the normal Avalonia compositor (zoom, rotation, overlays).
+///
+public sealed class MotionPhotoVideoSurface : Control
+{
+ private WriteableBitmap? _frameBitmap;
+
+ ///
+ /// Ensures the frame bitmap matches the given video size. Must be called on the UI thread.
+ ///
+ public void EnsureBitmap(int width, int height)
+ {
+ if (width <= 0 || height <= 0)
+ {
+ return;
+ }
+
+ if (_frameBitmap is { PixelSize.Width: var w, PixelSize.Height: var h } && w == width && h == height)
+ {
+ return;
+ }
+
+ _frameBitmap?.Dispose();
+ _frameBitmap = new WriteableBitmap(
+ new PixelSize(width, height),
+ new Vector(96, 96),
+ PixelFormat.Bgra8888,
+ AlphaFormat.Unpremul);
+ }
+
+ ///
+ /// Copies a BGRA32 frame from an unmanaged buffer into the bitmap and invalidates
+ /// the visual. UI thread only; the source must stay valid for the duration of the call.
+ ///
+ public unsafe void UpdateFrame(IntPtr bgra, int byteCount, int width, int height)
+ {
+ try
+ {
+ EnsureBitmap(width, height);
+ var bitmap = _frameBitmap;
+ if (bitmap is null || bgra == IntPtr.Zero)
+ {
+ return;
+ }
+
+ var srcRowBytes = width * 4;
+ if (byteCount < srcRowBytes * height)
+ {
+ return;
+ }
+
+ using var framebuffer = bitmap.Lock();
+ var dstRowBytes = framebuffer.RowBytes;
+ var src = (byte*)bgra;
+ var dst = (byte*)framebuffer.Address;
+ if (dstRowBytes == srcRowBytes)
+ {
+ Buffer.MemoryCopy(src, dst, (long)dstRowBytes * height, byteCount);
+ }
+ else
+ {
+ // Copy row by row to handle potential framebuffer padding
+ for (var y = 0; y < height; y++)
+ {
+ Buffer.MemoryCopy(src + (long)y * srcRowBytes, dst + (long)y * dstRowBytes,
+ dstRowBytes, srcRowBytes);
+ }
+ }
+
+ InvalidateVisual();
+ }
+ catch (Exception e)
+ {
+ DebugHelper.LogDebug(nameof(MotionPhotoVideoSurface), nameof(UpdateFrame), e);
+ }
+ }
+
+ ///
+ /// Drops the current frame so the surface renders nothing.
+ ///
+ public void Clear()
+ {
+ _frameBitmap?.Dispose();
+ _frameBitmap = null;
+ InvalidateVisual();
+ }
+
+ public sealed override void Render(DrawingContext context)
+ {
+ base.Render(context);
+
+ var bitmap = _frameBitmap;
+ if (bitmap is null)
+ {
+ return;
+ }
+
+ var viewPort = new Rect(Bounds.Size);
+ var sourceSize = bitmap.Size;
+ var scale = Stretch.Uniform.CalculateScaling(Bounds.Size, sourceSize);
+ var scaledSize = sourceSize * scale;
+ var destRect = viewPort
+ .CenterRect(new Rect(scaledSize))
+ .Intersect(viewPort);
+ var sourceRect = new Rect(sourceSize)
+ .CenterRect(new Rect(destRect.Size / scale));
+
+ context.DrawImage(bitmap, sourceRect, destRect);
+ }
+
+ protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ Clear();
+ base.OnDetachedFromVisualTree(e);
+ }
+}
diff --git a/src/PicView.Avalonia/MotionPhoto/MotionPhotoView.axaml b/src/PicView.Avalonia/MotionPhoto/MotionPhotoView.axaml
new file mode 100644
index 000000000..b1f64ce6d
--- /dev/null
+++ b/src/PicView.Avalonia/MotionPhoto/MotionPhotoView.axaml
@@ -0,0 +1,38 @@
+
+
+
+
+
+
diff --git a/src/PicView.Avalonia/MotionPhoto/MotionPhotoView.axaml.cs b/src/PicView.Avalonia/MotionPhoto/MotionPhotoView.axaml.cs
new file mode 100644
index 000000000..81fb7aaf2
--- /dev/null
+++ b/src/PicView.Avalonia/MotionPhoto/MotionPhotoView.axaml.cs
@@ -0,0 +1,451 @@
+using System.Runtime.InteropServices;
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Avalonia.Threading;
+using LibVLCSharp.Shared;
+using PicView.Core.DebugTools;
+using PicView.Core.ImageDecoding;
+using PicView.Core.Models;
+using PicView.Core.MotionPhoto;
+using PicView.Core.ViewModels;
+
+namespace PicView.Avalonia.MotionPhoto;
+
+///
+/// Overlay that plays the embedded video of a motion photo on top of the still cover image.
+/// Behavior: show the cover with a badge, play the video once when triggered, then freeze
+/// back onto the cover (the badge remains so it can be replayed). Any failure degrades to
+/// showing only the still image.
+///
+/// Video frames are produced by libvlc software video callbacks (BGRA32) and rendered by
+/// , which works on every display stack including
+/// Wayland (libvlc 3.x has no public wl_surface embedding API) and lets the video follow
+/// the normal Avalonia compositor.
+///
+///
+public partial class MotionPhotoView : UserControl, IDisposable
+{
+ private const int BytesPerPixel = 4;
+
+ /// Number of display buffers handed to libvlc in rotation (slack for the UI thread).
+ private const int FrameBufferCount = 3;
+
+ /// Extra slot decoded into when every display buffer is pending; its frame is dropped.
+ private const int OverflowBufferIndex = FrameBufferCount;
+
+ private static readonly byte[] Rv32Chroma = [(byte)'R', (byte)'V', (byte)'3', (byte)'2'];
+
+ private readonly MediaPlayer.LibVLCVideoFormatCb _videoFormatCb;
+ private readonly MediaPlayer.LibVLCVideoCleanupCb _videoCleanupCb;
+ private readonly MediaPlayer.LibVLCVideoLockCb _videoLockCb;
+ private readonly MediaPlayer.LibVLCVideoUnlockCb _videoUnlockCb;
+ private readonly MediaPlayer.LibVLCVideoDisplayCb _videoDisplayCb;
+
+ private readonly Lock _frameLock = new();
+ private IntPtr[] _frameBuffers = [];
+ private int[] _bufferInUse = [];
+ private int _frameBufferSize;
+ private int _videoWidth;
+ private int _videoHeight;
+
+ private Stream? _videoStream;
+ private StreamMediaInput? _mediaInput;
+ private Media? _media;
+ private MediaPlayer? _mediaPlayer;
+ private ImageModel? _model;
+ private bool _isSessionBusy;
+ private bool _isDisposed;
+
+ /// Raised on the UI thread when video playback starts (zoom/pan should be locked).
+ public event EventHandler? PlaybackStarted;
+
+ /// Raised on the UI thread when video playback stops (zoom/pan can be unlocked).
+ public event EventHandler? PlaybackStopped;
+
+ /// Whether video is currently playing or paused.
+ public bool IsPlaying { get; private set; }
+
+ /// Whether the current image is a playable motion photo (badge or video is shown).
+ public bool IsMotionPhotoActive => IsVisible;
+
+ ///
+ /// Whether the auto-play setting may trigger playback for this view. Disabled for the
+ /// secondary (side-by-side) view so two clips never start on their own at once.
+ ///
+ public bool AllowAutoPlay { get; set; } = true;
+
+ public MotionPhotoView()
+ {
+ InitializeComponent();
+ PlayBadge.Click += OnPlayBadgeClicked;
+
+ // Keep explicit references so the delegates are never garbage-collected while
+ // libvlc may still invoke them (the MediaPlayer also stores them, but be explicit).
+ _videoFormatCb = VideoFormatCallback;
+ _videoCleanupCb = VideoCleanupCallback;
+ _videoLockCb = LockVideoCallback;
+ _videoUnlockCb = UnlockVideoCallback;
+ _videoDisplayCb = DisplayVideoCallback;
+ }
+
+ ///
+ /// Called whenever a new image is displayed. Stops any running playback and prepares
+ /// (or hides) the motion photo overlay for the new model. Null hides the overlay.
+ ///
+ public void OnImageChanged(ImageModel? model)
+ {
+ Stop();
+ _model = model;
+
+ if (model?.ImageType is ImageType.MotionPhoto &&
+ model.MotionPhoto is not null &&
+ MotionPhotoService.IsPlaybackSupported &&
+ MotionPhotoService.TryGetLibVlc(out _))
+ {
+ IsVisible = true;
+ PlayBadge.IsVisible = true;
+ if (AllowAutoPlay && Settings.UIProperties.AutoPlayMotionPhotos)
+ {
+ _ = PlayAsync();
+ }
+ }
+ else
+ {
+ IsVisible = false;
+ }
+ }
+
+ ///
+ /// Toggles between play and pause when playback is running, otherwise starts playback.
+ /// Used by the Space keyboard shortcut.
+ ///
+ public void TogglePlayPause()
+ {
+ if (_mediaPlayer is not null && IsPlaying)
+ {
+ _mediaPlayer.Pause();
+ return;
+ }
+
+ if (IsVisible && !IsPlaying)
+ {
+ _ = PlayAsync();
+ }
+ }
+
+ ///
+ /// Stops playback and returns to the cover image. Returns true when playback was active.
+ /// Used by the Escape keyboard shortcut.
+ ///
+ public bool StopIfPlaying()
+ {
+ if (!IsPlaying)
+ {
+ return false;
+ }
+
+ Stop();
+ PlayBadge.IsVisible = true;
+ return true;
+ }
+
+ ///
+ /// Starts motion photo playback: extracts the video on demand, hands it to libvlc
+ /// as an in-memory stream and plays it once.
+ ///
+ public async Task PlayAsync()
+ {
+ if (_isSessionBusy || IsPlaying || _isDisposed)
+ {
+ return;
+ }
+
+ var model = _model;
+ if (model?.ImageType is not ImageType.MotionPhoto || model.MotionPhoto is null || model.FileInfo is null)
+ {
+ return;
+ }
+
+ if (!MotionPhotoService.TryGetLibVlc(out var libVlc))
+ {
+ IsVisible = false;
+ return;
+ }
+
+ _isSessionBusy = true;
+ var cancellationToken = (DataContext as TabViewModel)?.GetTabCancellation().Token ?? default;
+ Stream? stream = null;
+ try
+ {
+ stream = await MotionPhotoExtractor.ExtractAsync(
+ model.FileInfo, model.MotionPhoto, cancellationToken).ConfigureAwait(true);
+ }
+ catch (Exception e)
+ {
+ DebugHelper.LogDebug(nameof(MotionPhotoView), nameof(PlayAsync), e);
+ }
+
+ if (stream is null)
+ {
+ _isSessionBusy = false;
+ // Extraction failed: degrade to the still image
+ IsVisible = false;
+ return;
+ }
+
+ try
+ {
+ _videoStream = stream;
+ _mediaInput = new StreamMediaInput(stream);
+ _media = new Media(libVlc, _mediaInput);
+ _mediaPlayer = new MediaPlayer(_media)
+ {
+ Mute = Settings.UIProperties.MuteMotionPhotos,
+ };
+ _mediaPlayer.SetVideoFormatCallbacks(_videoFormatCb, _videoCleanupCb);
+ _mediaPlayer.SetVideoCallbacks(_videoLockCb, _videoUnlockCb, _videoDisplayCb);
+ _mediaPlayer.EndReached += OnEndReached;
+ _mediaPlayer.EncounteredError += OnEncounteredError;
+
+ // The surface stays hidden until the first decoded frame arrives, so the
+ // still image remains untouched while libvlc starts up (no visual pop).
+ if (!_mediaPlayer.Play())
+ {
+ CleanupSession();
+ _isSessionBusy = false;
+ return;
+ }
+
+ PlayBadge.IsVisible = false;
+ IsPlaying = true;
+ PlaybackStarted?.Invoke(this, EventArgs.Empty);
+ }
+ catch (Exception e)
+ {
+ DebugHelper.LogDebug(nameof(MotionPhotoView), nameof(PlayAsync), e);
+ CleanupSession();
+ IsVisible = false;
+ }
+ finally
+ {
+ _isSessionBusy = false;
+ }
+ }
+
+ ///
+ /// Stops playback and releases all playback resources, returning to the cover image.
+ /// The last decoded frame is cleared so it can never flash up when the surface is
+ /// shown again (e.g. when replaying or when the next motion photo has a different
+ /// video resolution).
+ ///
+ public void Stop()
+ {
+ var wasPlaying = IsPlaying;
+ CleanupSession();
+ VideoSurface.IsVisible = false;
+ VideoSurface.Clear();
+ IsPlaying = false;
+ if (wasPlaying)
+ {
+ PlaybackStopped?.Invoke(this, EventArgs.Empty);
+ }
+ }
+
+ private async void OnPlayBadgeClicked(object? sender, RoutedEventArgs e) => await PlayAsync();
+
+ private void OnEndReached(object? sender, EventArgs e) =>
+ Dispatcher.UIThread.Post(FreezeBackToCover);
+
+ private void OnEncounteredError(object? sender, EventArgs e) =>
+ Dispatcher.UIThread.Post(FreezeBackToCover);
+
+ private void FreezeBackToCover()
+ {
+ if (!IsPlaying)
+ {
+ return;
+ }
+
+ Stop();
+ // Keep the badge visible so the clip can be replayed
+ PlayBadge.IsVisible = true;
+ }
+
+ #region libvlc software video callbacks
+
+ private uint VideoFormatCallback(ref IntPtr opaque, IntPtr chroma,
+ ref uint width, ref uint height, ref uint pitches, ref uint lines)
+ {
+ Marshal.Copy(Rv32Chroma, 0, chroma, Rv32Chroma.Length);
+ pitches = width * BytesPerPixel;
+ lines = height;
+
+ lock (_frameLock)
+ {
+ FreeFrameBuffersLocked();
+ _frameBufferSize = (int)(width * height * BytesPerPixel);
+ _frameBuffers = new IntPtr[FrameBufferCount + 1];
+ _bufferInUse = new int[FrameBufferCount + 1];
+ for (var i = 0; i < _frameBuffers.Length; i++)
+ {
+ _frameBuffers[i] = Marshal.AllocHGlobal(_frameBufferSize);
+ }
+
+ _videoWidth = (int)width;
+ _videoHeight = (int)height;
+ }
+
+ Dispatcher.UIThread.Post(() => VideoSurface.EnsureBitmap(_videoWidth, _videoHeight));
+ return (uint)_frameBuffers.Length;
+ }
+
+ private void VideoCleanupCallback(ref IntPtr opaque)
+ {
+ lock (_frameLock)
+ {
+ FreeFrameBuffersLocked();
+ }
+ }
+
+ private IntPtr LockVideoCallback(IntPtr opaque, IntPtr planes)
+ {
+ var buffers = _frameBuffers;
+ var inUse = _bufferInUse;
+ if (buffers.Length == 0)
+ {
+ // Format callback has not run (or cleanup already happened); nothing decodable.
+ Marshal.WriteIntPtr(planes, IntPtr.Zero);
+ return (IntPtr)OverflowBufferIndex;
+ }
+
+ for (var i = 0; i < FrameBufferCount; i++)
+ {
+ if (Interlocked.CompareExchange(ref inUse[i], 1, 0) is 0)
+ {
+ // The buffer index is returned as the picture cookie, so the display
+ // callback knows which buffer holds the decoded frame.
+ Marshal.WriteIntPtr(planes, buffers[i]);
+ return (IntPtr)i;
+ }
+ }
+
+ // Every display buffer is waiting for the UI thread: decode into the overflow
+ // buffer instead and let the display callback drop the frame.
+ Marshal.WriteIntPtr(planes, buffers[OverflowBufferIndex]);
+ return (IntPtr)OverflowBufferIndex;
+ }
+
+ private void UnlockVideoCallback(IntPtr opaque, IntPtr picture, IntPtr planes)
+ {
+ // Nothing to unlock; buffers are released by the UI thread after the frame is copied.
+ }
+
+ private void DisplayVideoCallback(IntPtr opaque, IntPtr picture)
+ {
+ var index = (int)picture;
+ if (index is OverflowBufferIndex)
+ {
+ // Dropped frame; the overflow buffer is never marked in use.
+ return;
+ }
+
+ var inUse = _bufferInUse;
+ if (index < 0 || index >= inUse.Length)
+ {
+ return;
+ }
+
+ Dispatcher.UIThread.Post(() =>
+ {
+ if (_isDisposed)
+ {
+ Interlocked.Exchange(ref inUse[index], 0);
+ return;
+ }
+
+ lock (_frameLock)
+ {
+ try
+ {
+ if (index < _frameBuffers.Length && _frameBuffers[index] != IntPtr.Zero && _frameBufferSize > 0)
+ {
+ // Copy straight from the libvlc buffer into the frame bitmap (single copy).
+ VideoSurface.UpdateFrame(_frameBuffers[index], _frameBufferSize, _videoWidth, _videoHeight);
+ // Reveal the surface only now that it holds a current frame
+ if (!VideoSurface.IsVisible)
+ {
+ VideoSurface.IsVisible = true;
+ }
+ }
+ }
+ finally
+ {
+ Interlocked.Exchange(ref inUse[index], 0);
+ }
+ }
+ });
+ }
+
+ private void FreeFrameBuffersLocked()
+ {
+ foreach (var buffer in _frameBuffers)
+ {
+ if (buffer != IntPtr.Zero)
+ {
+ Marshal.FreeHGlobal(buffer);
+ }
+ }
+
+ _frameBuffers = [];
+ _bufferInUse = [];
+ _frameBufferSize = 0;
+ }
+
+ #endregion
+
+ private void CleanupSession()
+ {
+ if (_mediaPlayer is not null)
+ {
+ _mediaPlayer.EndReached -= OnEndReached;
+ _mediaPlayer.EncounteredError -= OnEncounteredError;
+ try
+ {
+ _mediaPlayer.Stop();
+ }
+ catch (Exception e)
+ {
+ DebugHelper.LogDebug(nameof(MotionPhotoView), nameof(CleanupSession), e);
+ }
+
+ _mediaPlayer.Dispose();
+ _mediaPlayer = null;
+ }
+
+ lock (_frameLock)
+ {
+ FreeFrameBuffersLocked();
+ }
+
+ _media?.Dispose();
+ _media = null;
+ _mediaInput?.Dispose();
+ _mediaInput = null;
+ _videoStream?.Dispose();
+ _videoStream = null;
+ }
+
+ public void Dispose()
+ {
+ if (_isDisposed)
+ {
+ return;
+ }
+
+ _isDisposed = true;
+ PlayBadge.Click -= OnPlayBadgeClicked;
+ Stop();
+ VideoSurface.Clear();
+ GC.SuppressFinalize(this);
+ }
+}
diff --git a/src/PicView.Avalonia/Navigation/UpdateImage.cs b/src/PicView.Avalonia/Navigation/UpdateImage.cs
index e8eee52fd..81508e473 100644
--- a/src/PicView.Avalonia/Navigation/UpdateImage.cs
+++ b/src/PicView.Avalonia/Navigation/UpdateImage.cs
@@ -111,6 +111,8 @@ public static void ChangeImage(MainWindow mainWindow, TabViewModel tabViewModel,
SetWindowAndImageSize(mainWindow, tabViewModel, vm);
+ imageViewer.UpdateMotionPhoto(tabViewModel);
+
if (tabViewModel.Gallery.IsDockedGalleryVisible.CurrentValue)
{
imageViewer.GalleryView.GalleryItemsControl.ScrollToCenterOfCurrentItem();
@@ -193,6 +195,8 @@ public static void SetSingleImage(MainWindowViewModel vm, MainWindow mainWindow,
tabViewModel.Model.PixelHeight = height;
tabViewModel.SingleImageType = type;
+
+ imageViewer.UpdateMotionPhoto(tabViewModel);
tabViewModel.DisposeImageIterator();
}
diff --git a/src/PicView.Avalonia/PicView.Avalonia.csproj b/src/PicView.Avalonia/PicView.Avalonia.csproj
index 9b5c60b7b..22ec8eb2c 100644
--- a/src/PicView.Avalonia/PicView.Avalonia.csproj
+++ b/src/PicView.Avalonia/PicView.Avalonia.csproj
@@ -257,6 +257,7 @@
+
diff --git a/src/PicView.Avalonia/Views/Gallery/GalleryItem.axaml b/src/PicView.Avalonia/Views/Gallery/GalleryItem.axaml
index 96e5262ec..838ddf378 100644
--- a/src/PicView.Avalonia/Views/Gallery/GalleryItem.axaml
+++ b/src/PicView.Avalonia/Views/Gallery/GalleryItem.axaml
@@ -20,10 +20,32 @@
ToolTip.HorizontalOffset="0"
ToolTip.Placement="TopEdgeAlignedLeft"
ToolTip.VerticalOffset="0">
-
+
+
+
+
+
+
+
@@ -40,19 +41,24 @@
HorizontalAlignment="Center"
VerticalAlignment="Center"
LastChildFill="False">
-
-
+
+
+
+
+ IsVisible="{CompiledBinding ParentWindowContext.IsSideBySide.Value}">
+
+
+
@@ -81,13 +87,13 @@
x:Name="HoverBar"
VerticalAlignment="Bottom"
ZIndex="2" />
-
+
\ No newline at end of file
diff --git a/src/PicView.Avalonia/Views/UC/ImageViewer.axaml.cs b/src/PicView.Avalonia/Views/UC/ImageViewer.axaml.cs
index b0177870c..cef0c300f 100644
--- a/src/PicView.Avalonia/Views/UC/ImageViewer.axaml.cs
+++ b/src/PicView.Avalonia/Views/UC/ImageViewer.axaml.cs
@@ -12,6 +12,7 @@
using PicView.Core.DebugTools;
using PicView.Core.Extensions;
using PicView.Core.Localization;
+using PicView.Core.Models;
using PicView.Core.ViewModels;
using R3;
@@ -37,6 +38,73 @@ private void OnLoaded(object? sender, RoutedEventArgs e)
AddHandler(PointerTouchPadGestureMagnifyEvent, TouchMagnifyEvent, RoutingStrategies.Bubble);
AddHandler(PinchEvent, TouchMagnifyEvent, RoutingStrategies.Bubble);
_disposables.Add(new HoverFadeButtonHandler(GalleryShortcut, GalleryShortcut.InnerButton));
+
+ // Zoom/pan is locked for the duration of motion photo playback
+ MotionPhotoView.PlaybackStarted += OnMotionPhotoPlaybackStarted;
+ MotionPhotoView.PlaybackStopped += OnMotionPhotoPlaybackStopped;
+ SecondaryMotionPhotoView.PlaybackStarted += OnMotionPhotoPlaybackStarted;
+ SecondaryMotionPhotoView.PlaybackStopped += OnMotionPhotoPlaybackStopped;
+ }
+
+ private void OnMotionPhotoPlaybackStarted(object? sender, EventArgs e)
+ {
+ ZoomPanControl.IsEnabled = false;
+
+ // Only one clip plays at a time
+ if (ReferenceEquals(sender, MotionPhotoView))
+ {
+ SecondaryMotionPhotoView.Stop();
+ }
+ else
+ {
+ MotionPhotoView.Stop();
+ }
+ }
+
+ private void OnMotionPhotoPlaybackStopped(object? sender, EventArgs e) =>
+ ZoomPanControl.IsEnabled = !MotionPhotoView.IsPlaying && !SecondaryMotionPhotoView.IsPlaying;
+
+ ///
+ /// Notifies the motion photo overlays that a new image is displayed,
+ /// stopping any running playback and preparing the badge when applicable.
+ /// May be called from any thread; UI work is marshalled to the UI thread.
+ ///
+ public void UpdateMotionPhoto(TabViewModel tabViewModel)
+ {
+ if (Dispatcher.UIThread.CheckAccess())
+ {
+ UpdateMotionPhotoOverlays(tabViewModel);
+ }
+ else
+ {
+ Dispatcher.UIThread.Post(() => UpdateMotionPhotoOverlays(tabViewModel));
+ }
+ }
+
+ private void UpdateMotionPhotoOverlays(TabViewModel tabViewModel)
+ {
+ var isSingleImage = tabViewModel.SingleImageType is not SingleImageType.None;
+ MotionPhotoView.OnImageChanged(isSingleImage ? null : tabViewModel.Model);
+ SecondaryMotionPhotoView.OnImageChanged(isSingleImage ? null : tabViewModel.SecondaryModel);
+ }
+
+ /// Whether the current image is a playable motion photo.
+ public bool IsMotionPhotoActive => MotionPhotoView.IsMotionPhotoActive || SecondaryMotionPhotoView.IsMotionPhotoActive;
+
+ /// Stops motion photo playback. Returns true when playback was active.
+ public bool StopMotionPhotoIfPlaying() => MotionPhotoView.StopIfPlaying() | SecondaryMotionPhotoView.StopIfPlaying();
+
+ /// Starts, pauses or resumes motion photo playback.
+ public void ToggleMotionPhotoPlayPause()
+ {
+ if (MotionPhotoView.IsPlaying || !SecondaryMotionPhotoView.IsMotionPhotoActive)
+ {
+ MotionPhotoView.TogglePlayPause();
+ }
+ else
+ {
+ SecondaryMotionPhotoView.TogglePlayPause();
+ }
}
public void TriggerScalingModeUpdate(bool invalidate) =>
@@ -186,6 +254,12 @@ public void Dispose()
RemoveHandler(PointerWheelChangedEvent, PreviewOnPointerWheelChanged);
RemoveHandler(PointerTouchPadGestureMagnifyEvent, TouchMagnifyEvent);
RemoveHandler(PinchEvent, TouchMagnifyEvent);
+ MotionPhotoView.PlaybackStarted -= OnMotionPhotoPlaybackStarted;
+ MotionPhotoView.PlaybackStopped -= OnMotionPhotoPlaybackStopped;
+ MotionPhotoView.Dispose();
+ SecondaryMotionPhotoView.PlaybackStarted -= OnMotionPhotoPlaybackStarted;
+ SecondaryMotionPhotoView.PlaybackStopped -= OnMotionPhotoPlaybackStopped;
+ SecondaryMotionPhotoView.Dispose();
_disposables.Dispose();
HoverBar.Dispose();
}
diff --git a/src/PicView.Core/Config/AppSettings.cs b/src/PicView.Core/Config/AppSettings.cs
index 3ab458d44..9eeb0e3e5 100644
--- a/src/PicView.Core/Config/AppSettings.cs
+++ b/src/PicView.Core/Config/AppSettings.cs
@@ -215,6 +215,17 @@ public class UIProperties
public int DoubleClickBehavior { get; set; } = 1;
public bool ShowFullPathInTitleBar { get; set; } = false;
+
+ ///
+ /// Determines whether the embedded video of a motion photo is played automatically
+ /// when the image is shown. When false, playback starts via the motion photo badge.
+ ///
+ public bool AutoPlayMotionPhotos { get; set; } = false;
+
+ ///
+ /// Determines whether motion photo playback is muted.
+ ///
+ public bool MuteMotionPhotos { get; set; } = true;
}
public class Theme
diff --git a/src/PicView.Core/Config/Languages/da.json b/src/PicView.Core/Config/Languages/da.json
index aa4fc3112..6d0b46c13 100644
--- a/src/PicView.Core/Config/Languages/da.json
+++ b/src/PicView.Core/Config/Languages/da.json
@@ -239,6 +239,7 @@
"MouseKeyForward": "Museknap fremad",
"MouseSideButtons": "Musens sideknapper",
"MouseWheel": "Musehjul",
+ "MotionPhoto": "Bevægelsesfoto",
"MoveToRecycleBin": "Flyt til papirkurv",
"MoveWindow": "Flyt vinduet",
"Navigate": "Naviger",
diff --git a/src/PicView.Core/Config/Languages/de.json b/src/PicView.Core/Config/Languages/de.json
index acb5abfb0..77ca42410 100644
--- a/src/PicView.Core/Config/Languages/de.json
+++ b/src/PicView.Core/Config/Languages/de.json
@@ -239,6 +239,7 @@
"MouseKeyForward": "Maustaste vorwärts",
"MouseSideButtons": "Seitentasten der Maus",
"MouseWheel": "Mausrad",
+ "MotionPhoto": "Motion Photo",
"MoveToRecycleBin": "In den Papierkorb verschieben",
"MoveWindow": "Fenster verschieben",
"Navigate": "Navigieren",
diff --git a/src/PicView.Core/Config/Languages/en.json b/src/PicView.Core/Config/Languages/en.json
index 971f47ef8..6daa48d37 100644
--- a/src/PicView.Core/Config/Languages/en.json
+++ b/src/PicView.Core/Config/Languages/en.json
@@ -239,6 +239,7 @@
"MouseKeyForward": "Mouse key forward",
"MouseSideButtons": "Mouse side buttons",
"MouseWheel": "Mouse wheel",
+ "MotionPhoto": "Motion Photo",
"MoveToRecycleBin": "Move to recycle bin",
"MoveWindow": "Move window",
"Navigate": "Navigate",
diff --git a/src/PicView.Core/Config/Languages/es.json b/src/PicView.Core/Config/Languages/es.json
index df38bf8c3..4c6fae796 100644
--- a/src/PicView.Core/Config/Languages/es.json
+++ b/src/PicView.Core/Config/Languages/es.json
@@ -239,6 +239,7 @@
"MouseKeyForward": "Rueda del ratón hacia atrás",
"MouseSideButtons": "Botones laterales del ratón",
"MouseWheel": "Rueda del ratón",
+ "MotionPhoto": "Foto en movimiento",
"MoveToRecycleBin": "Mover a la papelera de reciclaje",
"MoveWindow": "Mover ventana",
"Navigate": "Navegar",
diff --git a/src/PicView.Core/Config/Languages/fr.json b/src/PicView.Core/Config/Languages/fr.json
index cb5f59d75..c88e3f398 100644
--- a/src/PicView.Core/Config/Languages/fr.json
+++ b/src/PicView.Core/Config/Languages/fr.json
@@ -239,6 +239,7 @@
"MouseKeyForward": "Touche souris en avant",
"MouseSideButtons": "Boutons latéraux de la souris",
"MouseWheel": "Roulette de la souris",
+ "MotionPhoto": "Photo animée",
"MoveToRecycleBin": "Déplacer vers la corbeille",
"MoveWindow": "Déplacer la fenêtre",
"Navigate": "Naviguer",
diff --git a/src/PicView.Core/Config/Languages/he.json b/src/PicView.Core/Config/Languages/he.json
index 06ecb5f03..258a7fe2e 100644
--- a/src/PicView.Core/Config/Languages/he.json
+++ b/src/PicView.Core/Config/Languages/he.json
@@ -239,6 +239,7 @@
"MouseKeyForward": "לחצן עכבר קדמי",
"MouseSideButtons": "כפתורי צד של העכבר",
"MouseWheel": "גלגלת עכבר",
+ "MotionPhoto": "תמונה בתנועה",
"MoveToRecycleBin": "העבר לסל המיחזור",
"MoveWindow": "הזז חלון",
"Navigate": "ניווט",
diff --git a/src/PicView.Core/Config/Languages/hu.json b/src/PicView.Core/Config/Languages/hu.json
index 73e20acb1..6d05d3b56 100644
--- a/src/PicView.Core/Config/Languages/hu.json
+++ b/src/PicView.Core/Config/Languages/hu.json
@@ -239,6 +239,7 @@
"MouseKeyForward": "Egérgomb előre",
"MouseSideButtons": "Egér oldalsó gombjai",
"MouseWheel": "Egérgörgő",
+ "MotionPhoto": "Mozgófotó",
"MoveToRecycleBin": "Áthelyezés a szemetesbe",
"MoveWindow": "Ablak áthelyezése",
"Navigate": "Navigálás",
diff --git a/src/PicView.Core/Config/Languages/it.json b/src/PicView.Core/Config/Languages/it.json
index f15cf7ef2..8afd35058 100644
--- a/src/PicView.Core/Config/Languages/it.json
+++ b/src/PicView.Core/Config/Languages/it.json
@@ -239,6 +239,7 @@
"MouseKeyForward": "Tasto mouse in avanti",
"MouseSideButtons": "Pulsanti laterali del mouse",
"MouseWheel": "Rotellina del mouse",
+ "MotionPhoto": "Foto in movimento",
"MoveToRecycleBin": "Sposta nel cestino",
"MoveWindow": "Sposta la finestra",
"Navigate": "Gezin",
diff --git a/src/PicView.Core/Config/Languages/ja.json b/src/PicView.Core/Config/Languages/ja.json
index fc40cb510..078be2576 100644
--- a/src/PicView.Core/Config/Languages/ja.json
+++ b/src/PicView.Core/Config/Languages/ja.json
@@ -239,6 +239,7 @@
"MouseKeyForward": "マウスキー進む",
"MouseSideButtons": "マウスのサイドボタン",
"MouseWheel": "マウスホイール",
+ "MotionPhoto": "モーションフォト",
"MoveToRecycleBin": "ゴミ箱に移動",
"MoveWindow": "ウィンドウの移動",
"Navigate": "ナビゲート",
diff --git a/src/PicView.Core/Config/Languages/ko.json b/src/PicView.Core/Config/Languages/ko.json
index 589815460..8027c0816 100644
--- a/src/PicView.Core/Config/Languages/ko.json
+++ b/src/PicView.Core/Config/Languages/ko.json
@@ -239,6 +239,7 @@
"MouseKeyForward": "마우스 키 앞으로",
"MouseSideButtons": "마우스 측면 버튼",
"MouseWheel": "마우스 휠",
+ "MotionPhoto": "모션 포토",
"MoveToRecycleBin": "휴지통으로 이동",
"MoveWindow": "창 이동",
"Navigate": "탐색",
diff --git a/src/PicView.Core/Config/Languages/nl.json b/src/PicView.Core/Config/Languages/nl.json
index 397604b47..5c3ecc33d 100644
--- a/src/PicView.Core/Config/Languages/nl.json
+++ b/src/PicView.Core/Config/Languages/nl.json
@@ -239,6 +239,7 @@
"MouseKeyForward": "Muistoets vooruit",
"MouseSideButtons": "Zijknoppen van de muis",
"MouseWheel": "Muiswiel",
+ "MotionPhoto": "Bewegingsfoto",
"MoveToRecycleBin": "Verplaatsen naar prullenbak",
"MoveWindow": "Venster verplaatsen",
"Navigate": "Navigeren",
diff --git a/src/PicView.Core/Config/Languages/pl.json b/src/PicView.Core/Config/Languages/pl.json
index b43bf56a3..2886e70c5 100644
--- a/src/PicView.Core/Config/Languages/pl.json
+++ b/src/PicView.Core/Config/Languages/pl.json
@@ -239,6 +239,7 @@
"MouseKeyForward": "Przycisk myszy wprzód",
"MouseSideButtons": "Boczne przyciski myszy",
"MouseWheel": "Kółko myszy",
+ "MotionPhoto": "Ruchome zdjęcie",
"MoveToRecycleBin": "Przenieś do kosza",
"MoveWindow": "Przenieś okno",
"Navigate": "Nawigacja",
diff --git a/src/PicView.Core/Config/Languages/pt-br.json b/src/PicView.Core/Config/Languages/pt-br.json
index be63fb740..b210670f0 100644
--- a/src/PicView.Core/Config/Languages/pt-br.json
+++ b/src/PicView.Core/Config/Languages/pt-br.json
@@ -239,6 +239,7 @@
"MouseKeyForward": "Tecla do mouse para frente",
"MouseSideButtons": "Botões laterais do mouse",
"MouseWheel": "Roda do mouse",
+ "MotionPhoto": "Foto em movimento",
"MoveToRecycleBin": "Mover para a lixeira",
"MoveWindow": "Mover janela",
"Navigate": "Navegar",
diff --git a/src/PicView.Core/Config/Languages/ro.json b/src/PicView.Core/Config/Languages/ro.json
index 7cd5bbf17..24f4f0534 100644
--- a/src/PicView.Core/Config/Languages/ro.json
+++ b/src/PicView.Core/Config/Languages/ro.json
@@ -239,6 +239,7 @@
"MouseKeyForward": "Tastă maus înainte",
"MouseSideButtons": "Butoanele laterale ale mouse-ului",
"MouseWheel": "Rotiță maus",
+ "MotionPhoto": "Fotografie în mișcare",
"MoveToRecycleBin": "Mută în coșul de gunoi",
"MoveWindow": "Mută fereastra",
"Navigate": "Navigare",
diff --git a/src/PicView.Core/Config/Languages/ru.json b/src/PicView.Core/Config/Languages/ru.json
index 93ff41c05..db73a6169 100644
--- a/src/PicView.Core/Config/Languages/ru.json
+++ b/src/PicView.Core/Config/Languages/ru.json
@@ -239,6 +239,7 @@
"MouseKeyForward": "Клавиша мыши вперед",
"MouseSideButtons": "Боковые кнопки мыши",
"MouseWheel": "Колесико мыши",
+ "MotionPhoto": "Движущееся фото",
"MoveToRecycleBin": "Переместить в корзину",
"MoveWindow": "Переместить окно",
"Navigate": "Навигация",
diff --git a/src/PicView.Core/Config/Languages/sl.json b/src/PicView.Core/Config/Languages/sl.json
index 8e6ec7e9f..99c3f5cac 100644
--- a/src/PicView.Core/Config/Languages/sl.json
+++ b/src/PicView.Core/Config/Languages/sl.json
@@ -239,6 +239,7 @@
"MouseKeyForward": "Tipka miške naprej",
"MouseSideButtons": "Bokovne tipke miške",
"MouseWheel": "Kolešček miške",
+ "MotionPhoto": "Gibljiva fotografija",
"MoveToRecycleBin": "Premakni v koš",
"MoveWindow": "Premakni okno",
"Navigate": "Navigiraj",
diff --git a/src/PicView.Core/Config/Languages/sr-Cyrl.json b/src/PicView.Core/Config/Languages/sr-Cyrl.json
index ae238adba..a72c95307 100644
--- a/src/PicView.Core/Config/Languages/sr-Cyrl.json
+++ b/src/PicView.Core/Config/Languages/sr-Cyrl.json
@@ -239,6 +239,7 @@
"MouseKeyForward": "Тастер миша напред",
"MouseSideButtons": "Бочна дугмад миша",
"MouseWheel": "Точкић миша",
+ "MotionPhoto": "Фотографија у покрету",
"MoveToRecycleBin": "Премести у канту",
"MoveWindow": "Помери прозор",
"Navigate": "Навигирај",
diff --git a/src/PicView.Core/Config/Languages/sr-Latn.json b/src/PicView.Core/Config/Languages/sr-Latn.json
index 12009a3bd..70f0be1dd 100644
--- a/src/PicView.Core/Config/Languages/sr-Latn.json
+++ b/src/PicView.Core/Config/Languages/sr-Latn.json
@@ -239,6 +239,7 @@
"MouseKeyForward": "Taster miša napred",
"MouseSideButtons": "Bočna dugmad miša",
"MouseWheel": "Točkić miša",
+ "MotionPhoto": "Fotografija u pokretu",
"MoveToRecycleBin": "Premesti u kantu",
"MoveWindow": "Pomeri prozor",
"Navigate": "Navigiraj",
diff --git a/src/PicView.Core/Config/Languages/sv.json b/src/PicView.Core/Config/Languages/sv.json
index 7a2649585..c021ed401 100644
--- a/src/PicView.Core/Config/Languages/sv.json
+++ b/src/PicView.Core/Config/Languages/sv.json
@@ -239,6 +239,7 @@
"MouseKeyForward": "Musknapp framåt",
"MouseSideButtons": "Musens sidoknappar",
"MouseWheel": "Mushjul",
+ "MotionPhoto": "Rörligt foto",
"MoveToRecycleBin": "Flytta till papperskorgen",
"MoveWindow": "Flytta fönster",
"Navigate": "Navigera",
diff --git a/src/PicView.Core/Config/Languages/tr.json b/src/PicView.Core/Config/Languages/tr.json
index 0f708f314..463b3e766 100644
--- a/src/PicView.Core/Config/Languages/tr.json
+++ b/src/PicView.Core/Config/Languages/tr.json
@@ -239,6 +239,7 @@
"MouseKeyForward": "Fare tuşu ileri",
"MouseSideButtons": "Farenin yan düğmeleri",
"MouseWheel": "Fare tekerleği",
+ "MotionPhoto": "Hareketli fotoğraf",
"MoveToRecycleBin": "Geri dönüşüm kutusuna taşı",
"MoveWindow": "Pencereyi taşı",
"Navigate": "Gezin",
diff --git a/src/PicView.Core/Config/Languages/zh-CN.json b/src/PicView.Core/Config/Languages/zh-CN.json
index 830c89b50..3efa26c1e 100644
--- a/src/PicView.Core/Config/Languages/zh-CN.json
+++ b/src/PicView.Core/Config/Languages/zh-CN.json
@@ -239,6 +239,7 @@
"MouseKeyForward": "鼠标拓展键 向前键",
"MouseSideButtons": "鼠标侧键",
"MouseWheel": "鼠标滚轮",
+ "MotionPhoto": "动态照片",
"MoveToRecycleBin": "移动到回收站",
"MoveWindow": "移动窗口",
"Navigate": "导航",
diff --git a/src/PicView.Core/Config/Languages/zh-TW.json b/src/PicView.Core/Config/Languages/zh-TW.json
index 79594d50a..422f74a7b 100644
--- a/src/PicView.Core/Config/Languages/zh-TW.json
+++ b/src/PicView.Core/Config/Languages/zh-TW.json
@@ -239,6 +239,7 @@
"MouseKeyForward": "滑鼠拓展鍵 向前鍵",
"MouseSideButtons": "滑鼠側鍵",
"MouseWheel": "滑鼠滾輪",
+ "MotionPhoto": "動態照片",
"MoveToRecycleBin": "移至資源回收筒",
"MoveWindow": "移動視窗",
"Navigate": "導覽",
diff --git a/src/PicView.Core/FileHandling/SupportedFiles.cs b/src/PicView.Core/FileHandling/SupportedFiles.cs
index 21ddba0b5..f27b8174f 100644
--- a/src/PicView.Core/FileHandling/SupportedFiles.cs
+++ b/src/PicView.Core/FileHandling/SupportedFiles.cs
@@ -31,6 +31,7 @@ public static class SupportedFiles
".tga",
".heic",
".heif",
+ ".livp",
".hdr",
".xcf",
".jxl",
diff --git a/src/PicView.Core/Gallery/GalleryLoader.cs b/src/PicView.Core/Gallery/GalleryLoader.cs
index ca4083af1..a81f96dc5 100644
--- a/src/PicView.Core/Gallery/GalleryLoader.cs
+++ b/src/PicView.Core/Gallery/GalleryLoader.cs
@@ -1,4 +1,5 @@
using PicView.Core.DebugTools;
+using PicView.Core.MotionPhoto;
using PicView.Core.Navigation.Interfaces;
using PicView.Core.ViewModels;
@@ -138,8 +139,9 @@ async ValueTask CheckAndLoad(GalleryItemViewModel item)
thumbnailCache.Add(tab.Id, item.FileInfo.FullName, thumb);
}
item.Image.Value = thumb;
+ DetectMotionPhoto(item);
}
-
+
async ValueTask LoadItem(GalleryItemViewModel item)
{
if (item.FileInfo is null)
@@ -147,14 +149,20 @@ async ValueTask LoadItem(GalleryItemViewModel item)
DebugHelper.LogDebug(nameof(GalleryLoader), nameof(LoadGalleryAsync), "Invalid file");
return;
}
-
+
var thumb = await thumbnailLoader.GetThumbnailAsync(item.FileInfo, (uint)maxHeight).ConfigureAwait(false);
if (thumb is not null)
{
thumbnailCache.Add(tab.Id, item.FileInfo.FullName, thumb);
}
item.Image.Value = thumb;
+ DetectMotionPhoto(item);
}
+
+ // Runs inside the parallel thumbnail loop (thread-pool threads). The detector is
+ // stateless and thread-safe; cost overlaps with thumbnail I/O.
+ static void DetectMotionPhoto(GalleryItemViewModel item) =>
+ item.IsMotionPhoto.Value = MotionPhotoDetector.TryDetect(item.FileInfo, null) is not null;
}
public static async Task ReloadGallery(TabViewModel tab, IReadOnlyList files, IThumbnailLoader thumbnailLoader, IThumbnailCache thumbnailCache, CancellationToken ct)
diff --git a/src/PicView.Core/ImageDecoding/ImageType.cs b/src/PicView.Core/ImageDecoding/ImageType.cs
index 4ed5d571c..e97dc9c9e 100644
--- a/src/PicView.Core/ImageDecoding/ImageType.cs
+++ b/src/PicView.Core/ImageDecoding/ImageType.cs
@@ -7,4 +7,5 @@ public enum ImageType
AnimatedWebp,
Bitmap,
Svg,
+ MotionPhoto,
}
\ No newline at end of file
diff --git a/src/PicView.Core/Localization/LanguageModel.cs b/src/PicView.Core/Localization/LanguageModel.cs
index d79deb891..65f6f21dc 100644
--- a/src/PicView.Core/Localization/LanguageModel.cs
+++ b/src/PicView.Core/Localization/LanguageModel.cs
@@ -254,6 +254,7 @@ public class LanguageModel
public string? MouseKeyForward { get; set; }
public string? MouseSideButtons { get; set; }
public string? MouseWheel { get; set; }
+ public string? MotionPhoto { get; set; }
public string? MoveToRecycleBin { get; set; }
public string? MoveWindow { get; set; }
public string? Navigate { get; set; }
diff --git a/src/PicView.Core/Models/ImageModel.cs b/src/PicView.Core/Models/ImageModel.cs
index dc36d04bf..8bf8e1713 100644
--- a/src/PicView.Core/Models/ImageModel.cs
+++ b/src/PicView.Core/Models/ImageModel.cs
@@ -1,5 +1,6 @@
using System.Diagnostics;
using PicView.Core.ImageDecoding;
+using PicView.Core.MotionPhoto;
using PicView.Core.Navigation.Tiff;
namespace PicView.Core.Models;
@@ -13,6 +14,7 @@ public class ImageModel : IDisposable
public uint PixelHeight { get; set; }
public ImageType ImageType { get; set; }
public TiffNavigationInfo? TiffNavigation { get; set; }
+ public MotionPhotoInfo? MotionPhoto { get; set; }
public void Dispose()
{
diff --git a/src/PicView.Core/MotionPhoto/FileSliceStream.cs b/src/PicView.Core/MotionPhoto/FileSliceStream.cs
new file mode 100644
index 000000000..304698943
--- /dev/null
+++ b/src/PicView.Core/MotionPhoto/FileSliceStream.cs
@@ -0,0 +1,85 @@
+namespace PicView.Core.MotionPhoto;
+
+///
+/// A read-only, seekable window over a region of a file. Used to hand the embedded video
+/// portion of a motion photo to consumers (e.g. libvlc) without copying it into memory first.
+/// Reads and seeks are clamped to the slice; the underlying file is kept open until disposal.
+///
+public sealed class FileSliceStream : Stream
+{
+ private readonly FileStream _inner;
+ private readonly long _offset;
+ private readonly long _length;
+
+ public FileSliceStream(string path, long offset, long length)
+ {
+ _inner = new FileStream(path, FileMode.Open, FileAccess.Read,
+ FileShare.ReadWrite, 4096, FileOptions.Asynchronous | FileOptions.SequentialScan);
+ _offset = offset;
+ _length = Math.Min(length, Math.Max(0, _inner.Length - offset));
+ _inner.Position = offset;
+ }
+
+ public override bool CanRead => true;
+ public override bool CanSeek => true;
+ public override bool CanWrite => false;
+ public override long Length => _length;
+
+ public override long Position
+ {
+ get => _inner.Position - _offset;
+ set => Seek(value, SeekOrigin.Begin);
+ }
+
+ public override long Seek(long offset, SeekOrigin origin)
+ {
+ var absolute = origin switch
+ {
+ SeekOrigin.Begin => offset,
+ SeekOrigin.Current => Position + offset,
+ SeekOrigin.End => _length + offset,
+ _ => throw new ArgumentOutOfRangeException(nameof(origin)),
+ };
+
+ var clamped = Math.Clamp(absolute, 0, _length);
+ _inner.Position = _offset + clamped;
+ return clamped;
+ }
+
+ public override int Read(byte[] buffer, int offset, int count) =>
+ _inner.Read(buffer, offset, ClampToRemaining(count));
+
+ public override int Read(Span buffer) =>
+ _inner.Read(buffer.Slice(0, ClampToRemaining(buffer.Length)));
+
+ public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) =>
+ _inner.ReadAsync(buffer.Slice(0, ClampToRemaining(buffer.Length)), cancellationToken);
+
+ public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) =>
+ _inner.ReadAsync(buffer.AsMemory(offset, ClampToRemaining(count)), cancellationToken).AsTask();
+
+ public override void Flush()
+ {
+ // Read-only; nothing to flush.
+ }
+
+ public override void SetLength(long value) => throw new NotSupportedException();
+
+ public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
+
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing)
+ {
+ _inner.Dispose();
+ }
+
+ base.Dispose(disposing);
+ }
+
+ private int ClampToRemaining(int count)
+ {
+ var remaining = _length - Position;
+ return (int)Math.Max(0, Math.Min(count, remaining));
+ }
+}
diff --git a/src/PicView.Core/MotionPhoto/MotionPhotoDetector.cs b/src/PicView.Core/MotionPhoto/MotionPhotoDetector.cs
new file mode 100644
index 000000000..10e813e3c
--- /dev/null
+++ b/src/PicView.Core/MotionPhoto/MotionPhotoDetector.cs
@@ -0,0 +1,418 @@
+using System.Buffers;
+using System.Text;
+using PicView.Core.DebugTools;
+
+namespace PicView.Core.MotionPhoto;
+
+///
+/// Detects whether an image file is a motion photo (Google/Samsung/DJI/OPPO style embedded
+/// video, Apple/vivo style sidecar file, or .livp container).
+///
+/// XMP metadata is located with plain string searches instead of XML parsing, because vendor
+/// namespaces vary widely (GCamera, OpCamera, dji, Samsung...). This mirrors the approach
+/// proven in other viewers.
+///
+///
+public static class MotionPhotoDetector
+{
+ private static readonly byte[] SamsungMarkerBytes = Encoding.ASCII.GetBytes("MotionPhoto_Data");
+ private static readonly byte[] JpegXmpHeaderBytes = Encoding.ASCII.GetBytes("http://ns.adobe.com/xap/1.0/");
+ private static readonly byte[] XmpEndTagBytes = Encoding.ASCII.GetBytes("");
+
+ /// Scan up to 32 MB from the file tail when searching for the Samsung trailer marker.
+ private const int SamsungScanWindowBytes = 32 * 1024 * 1024;
+
+ /// Scan up to 1 MB from the file start when searching for a JPEG XMP packet.
+ private const int JpegXmpScanWindowBytes = 1024 * 1024;
+
+ ///
+ /// Attempts to detect motion photo data for the given file.
+ ///
+ /// The image file to inspect.
+ ///
+ /// Optional XMP packet text (e.g. from Magick.NET). When null and the file is a JPEG,
+ /// a lightweight APP1 byte scan is used as fallback.
+ ///
+ /// A describing the video location, or null if not a motion photo.
+ public static MotionPhotoInfo? TryDetect(FileInfo fileInfo, string? xmpPacket)
+ {
+ try
+ {
+ if (!fileInfo.Exists || fileInfo.Length is 0)
+ {
+ return null;
+ }
+
+ var extension = fileInfo.Extension;
+ if (extension.Equals(".livp", StringComparison.OrdinalIgnoreCase))
+ {
+ return new MotionPhotoInfo { Source = MotionPhotoSource.LivpContainer };
+ }
+
+ if (xmpPacket is null &&
+ (extension.Equals(".jpg", StringComparison.OrdinalIgnoreCase) ||
+ extension.Equals(".jpeg", StringComparison.OrdinalIgnoreCase)))
+ {
+ xmpPacket = ReadJpegXmpPacket(fileInfo);
+ }
+
+ if (xmpPacket is { Length: > 0 })
+ {
+ var fromXmp = TryDetectFromXmp(fileInfo.Length, xmpPacket);
+ if (fromXmp is not null)
+ {
+ return fromXmp;
+ }
+ }
+
+ // The Samsung trailer format only exists in JPEG files; scanning the tail of
+ // every HEIC image would just waste I/O.
+ if (extension.Equals(".jpg", StringComparison.OrdinalIgnoreCase) ||
+ extension.Equals(".jpeg", StringComparison.OrdinalIgnoreCase))
+ {
+ var samsung = TryDetectSamsungTrailer(fileInfo);
+ if (samsung is not null)
+ {
+ return samsung;
+ }
+ }
+
+ return TryDetectSidecar(fileInfo);
+ }
+ catch (Exception e)
+ {
+ DebugHelper.LogDebug(nameof(MotionPhotoDetector), nameof(TryDetect), e);
+ return null;
+ }
+ }
+
+ ///
+ /// Searches the XMP packet text for motion photo metadata.
+ /// Supports the new Container:Directory standard (Item:Semantic=MotionPhoto + Item:Length)
+ /// and the legacy MicroVideo standard (MicroVideoOffset).
+ ///
+ internal static MotionPhotoInfo? TryDetectFromXmp(long fileLength, string xmp)
+ {
+ // New standard: Item:Semantic = MotionPhoto, with Item:Length holding the video byte count.
+ // Handle both element form (MotionPhoto)
+ // and attribute form (Item:Semantic="MotionPhoto").
+ var semanticIndex = xmp.IndexOf(">MotionPhoto<", StringComparison.Ordinal);
+ if (semanticIndex < 0)
+ {
+ semanticIndex = xmp.IndexOf("\"MotionPhoto\"", StringComparison.Ordinal);
+ }
+
+ if (semanticIndex >= 0)
+ {
+ // Only accept an Item:Length that belongs to the same Directory item as the
+ // MotionPhoto semantic. The still-image item carries its own Item:Length,
+ // which must never be mistaken for the video length.
+ var itemEnd = FindItemEnd(xmp, semanticIndex);
+ var markerIndex = xmp.IndexOf("Item:Length", semanticIndex, itemEnd - semanticIndex, StringComparison.OrdinalIgnoreCase);
+ if (markerIndex < 0)
+ {
+ // Attribute-reordered form: Item:Length before Item:Semantic within the same tag.
+ var tagStart = xmp.LastIndexOf('<', semanticIndex);
+ if (tagStart >= 0)
+ {
+ markerIndex = xmp.IndexOf("Item:Length", tagStart, semanticIndex - tagStart, StringComparison.OrdinalIgnoreCase);
+ }
+ }
+
+ if (markerIndex >= 0)
+ {
+ var videoLength = ExtractNumberAfter(xmp, markerIndex + "Item:Length".Length);
+ if (videoLength is > 0 && videoLength <= fileLength)
+ {
+ return new MotionPhotoInfo
+ {
+ Source = MotionPhotoSource.EmbeddedXmp,
+ VideoOffset = fileLength - videoLength.Value,
+ VideoLength = videoLength.Value,
+ };
+ }
+ }
+ }
+
+ // Legacy standard: GCamera:MicroVideoOffset (bytes from end of file)
+ var microVideoIndex = xmp.IndexOf("MicroVideoOffset", StringComparison.OrdinalIgnoreCase);
+ if (microVideoIndex >= 0)
+ {
+ var offset = ExtractNumberAfter(xmp, microVideoIndex + "MicroVideoOffset".Length);
+ if (offset is > 0 && offset <= fileLength)
+ {
+ return new MotionPhotoInfo
+ {
+ Source = MotionPhotoSource.EmbeddedXmp,
+ VideoOffset = fileLength - offset.Value,
+ VideoLength = offset.Value,
+ };
+ }
+ }
+
+ return null;
+ }
+
+ ///
+ /// Finds the end of the Directory item containing the given position: the earliest of the
+ /// self-closing tag end (attribute form), the closing item tag (element form) or the start
+ /// of the next sibling item. Returns the packet length when no boundary is found.
+ ///
+ private static int FindItemEnd(string xmp, int startIndex)
+ {
+ var end = xmp.Length;
+ var selfClose = xmp.IndexOf("/>", startIndex, StringComparison.Ordinal);
+ if (selfClose >= 0)
+ {
+ end = Math.Min(end, selfClose);
+ }
+
+ var elementClose = xmp.IndexOf("", startIndex, StringComparison.OrdinalIgnoreCase);
+ if (elementClose >= 0)
+ {
+ end = Math.Min(end, elementClose);
+ }
+
+ var nextItem = xmp.IndexOf("= 0)
+ {
+ end = Math.Min(end, nextItem);
+ }
+
+ return end;
+ }
+
+ ///
+ /// Scans the tail of the file for the legacy Samsung "MotionPhoto_Data" trailer marker.
+ /// The video starts immediately after the marker.
+ ///
+ internal static MotionPhotoInfo? TryDetectSamsungTrailer(FileInfo fileInfo)
+ {
+ var fileLength = fileInfo.Length;
+ var minimumSize = SamsungMarkerBytes.Length + 16;
+ if (fileLength < minimumSize)
+ {
+ return null;
+ }
+
+ var windowLength = (int)Math.Min(fileLength, SamsungScanWindowBytes);
+ var buffer = ArrayPool.Shared.Rent(windowLength);
+ try
+ {
+ var bytesRead = ReadFileTail(fileInfo, buffer, windowLength);
+ if (bytesRead < SamsungMarkerBytes.Length)
+ {
+ return null;
+ }
+
+ var markerIndex = buffer.AsSpan(0, bytesRead).LastIndexOf(SamsungMarkerBytes);
+ if (markerIndex < 0)
+ {
+ return null;
+ }
+
+ var videoStart = fileLength - bytesRead + markerIndex + SamsungMarkerBytes.Length;
+ if (videoStart >= fileLength)
+ {
+ return null;
+ }
+
+ return new MotionPhotoInfo
+ {
+ Source = MotionPhotoSource.SamsungTrailer,
+ VideoOffset = videoStart,
+ VideoLength = fileLength - videoStart,
+ };
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(buffer);
+ }
+ }
+
+ ///
+ /// Looks for a same-named sidecar video file (.mov preferred, then .mp4) next to the image.
+ /// The candidate must start with a valid ISO BMFF "ftyp" box, so unrelated same-named
+ /// files are not mistaken for a motion photo video.
+ ///
+ internal static MotionPhotoInfo? TryDetectSidecar(FileInfo fileInfo)
+ {
+ var directory = fileInfo.DirectoryName;
+ if (string.IsNullOrEmpty(directory))
+ {
+ return null;
+ }
+
+ var baseName = Path.GetFileNameWithoutExtension(fileInfo.FullName);
+ foreach (var extension in new[] { ".mov", ".mp4" })
+ {
+ var sidecar = new FileInfo(Path.Combine(directory, baseName + extension));
+ if (sidecar.Exists && sidecar.Length > 0 && HasVideoFileHeader(sidecar))
+ {
+ return new MotionPhotoInfo
+ {
+ Source = MotionPhotoSource.Sidecar,
+ SidecarFile = sidecar,
+ };
+ }
+ }
+
+ return null;
+ }
+
+ ///
+ /// Checks whether the file starts with an ISO BMFF box whose type is "ftyp"
+ /// (4-byte box size followed by the "ftyp" signature).
+ ///
+ private static bool HasVideoFileHeader(FileInfo file)
+ {
+ Span header = stackalloc byte[8];
+ try
+ {
+ using var stream = new FileStream(file.FullName, FileMode.Open, FileAccess.Read,
+ FileShare.ReadWrite, header.Length, FileOptions.SequentialScan);
+ var totalRead = 0;
+ while (totalRead < header.Length)
+ {
+ var read = stream.Read(header.Slice(totalRead));
+ if (read is 0)
+ {
+ break;
+ }
+
+ totalRead += read;
+ }
+
+ return totalRead == header.Length &&
+ header[4] == (byte)'f' && header[5] == (byte)'t' &&
+ header[6] == (byte)'y' && header[7] == (byte)'p';
+ }
+ catch (Exception e)
+ {
+ DebugHelper.LogDebug(nameof(MotionPhotoDetector), nameof(HasVideoFileHeader), e);
+ return false;
+ }
+ }
+
+ ///
+ /// Reads the XMP packet from a JPEG file by locating the APP1 segment that starts with the
+ /// XMP namespace header. Only the head of the file is scanned.
+ ///
+ internal static string? ReadJpegXmpPacket(FileInfo fileInfo)
+ {
+ var fileLength = fileInfo.Length;
+ var minimumSize = JpegXmpHeaderBytes.Length + 4;
+ if (fileLength < minimumSize)
+ {
+ return null;
+ }
+
+ var windowLength = (int)Math.Min(fileLength, JpegXmpScanWindowBytes);
+ var buffer = ArrayPool.Shared.Rent(windowLength);
+ try
+ {
+ var bytesRead = ReadFileHead(fileInfo, buffer, windowLength);
+ var span = buffer.AsSpan(0, bytesRead);
+ var headerIndex = span.IndexOf(JpegXmpHeaderBytes);
+ if (headerIndex < 0)
+ {
+ return null;
+ }
+
+ var packetStart = span.Slice(headerIndex).IndexOf((byte)'<');
+ if (packetStart < 0)
+ {
+ return null;
+ }
+
+ // Stop at the end of the XMP packet instead of converting the rest of the
+ // scan window (mostly JPEG image data) into a string.
+ var packetSpan = span.Slice(headerIndex + packetStart);
+ var packetEnd = packetSpan.IndexOf(XmpEndTagBytes);
+ if (packetEnd >= 0)
+ {
+ packetSpan = packetSpan.Slice(0, packetEnd + XmpEndTagBytes.Length);
+ }
+
+ return Encoding.UTF8.GetString(packetSpan);
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(buffer);
+ }
+ }
+
+ private static int ReadFileTail(FileInfo fileInfo, byte[] buffer, int count)
+ {
+ using var stream = new FileStream(fileInfo.FullName, FileMode.Open, FileAccess.Read,
+ FileShare.ReadWrite, 4096, FileOptions.SequentialScan);
+ stream.Seek(Math.Max(0, stream.Length - count), SeekOrigin.Begin);
+ var totalRead = 0;
+ while (totalRead < count)
+ {
+ var read = stream.Read(buffer.AsSpan(totalRead, count - totalRead));
+ if (read is 0)
+ {
+ break;
+ }
+
+ totalRead += read;
+ }
+
+ return totalRead;
+ }
+
+ private static int ReadFileHead(FileInfo fileInfo, byte[] buffer, int count)
+ {
+ using var stream = new FileStream(fileInfo.FullName, FileMode.Open, FileAccess.Read,
+ FileShare.ReadWrite, 4096, FileOptions.SequentialScan);
+ var totalRead = 0;
+ while (totalRead < count)
+ {
+ var read = stream.Read(buffer.AsSpan(totalRead, count - totalRead));
+ if (read is 0)
+ {
+ break;
+ }
+
+ totalRead += read;
+ }
+
+ return totalRead;
+ }
+
+ ///
+ /// Extracts the first run of ASCII digits found at or after ,
+ /// which allows handling both attribute ("Item:Length="123"") and element
+ /// ("<Item:Length>123</Item:Length>") XMP forms without parsing XML.
+ ///
+ private static long? ExtractNumberAfter(string text, int startIndex)
+ {
+ var index = startIndex;
+ while (index < text.Length && !char.IsAsciiDigit(text[index]))
+ {
+ index++;
+ }
+
+ if (index >= text.Length)
+ {
+ return null;
+ }
+
+ long value = 0;
+ var digitCount = 0;
+ while (index < text.Length && char.IsAsciiDigit(text[index]))
+ {
+ if (value > long.MaxValue / 10)
+ {
+ return null;
+ }
+
+ value = value * 10 + (text[index] - '0');
+ digitCount++;
+ index++;
+ }
+
+ return digitCount is 0 ? null : value;
+ }
+}
diff --git a/src/PicView.Core/MotionPhoto/MotionPhotoExtractor.cs b/src/PicView.Core/MotionPhoto/MotionPhotoExtractor.cs
new file mode 100644
index 000000000..d40dfa9fd
--- /dev/null
+++ b/src/PicView.Core/MotionPhoto/MotionPhotoExtractor.cs
@@ -0,0 +1,278 @@
+using System.Buffers.Binary;
+using PicView.Core.DebugTools;
+using PicView.Core.FileHandling;
+using SharpCompress.Archives;
+using SharpCompress.Readers;
+
+namespace PicView.Core.MotionPhoto;
+
+///
+/// Extracts the video portion of a motion photo as a seekable stream, without writing
+/// temporary files. Embedded videos are sliced from the source file (with ftyp box
+/// validation to tolerate vendor-specific trailing blocks), sidecars are opened directly
+/// and .livp containers are decompressed in memory.
+///
+public static class MotionPhotoExtractor
+{
+ /// Safety cap to avoid allocating unreasonable amounts of memory on corrupt metadata.
+ private const long MaxVideoBytes = 256 * 1024 * 1024;
+
+ ///
+ /// The search window (in bytes, both directions) used to correct the expected video start
+ /// position. Some vendors (e.g. DJI) append trailing blocks after the video, which shifts
+ /// the naive "last N bytes" slice into the middle of the stream.
+ ///
+ private const int FtypSearchWindowBytes = 8 * 1024;
+
+ private const int BoxHeaderSize = 8;
+
+ ///
+ /// Extracts the motion photo video described by .
+ ///
+ /// A seekable stream positioned at zero, or null when extraction fails.
+ public static async ValueTask ExtractAsync(FileInfo fileInfo, MotionPhotoInfo info, CancellationToken ct = default)
+ {
+ try
+ {
+ return info.Source switch
+ {
+ MotionPhotoSource.EmbeddedXmp or MotionPhotoSource.SamsungTrailer =>
+ await ExtractEmbeddedAsync(fileInfo, info.VideoOffset, ct).ConfigureAwait(false),
+ MotionPhotoSource.Sidecar => OpenReadOnly(info.SidecarFile),
+ MotionPhotoSource.LivpContainer =>
+ await ExtractLivpEntryToMemoryAsync(fileInfo, IsVideoFileName, ct).ConfigureAwait(false),
+ _ => null,
+ };
+ }
+ catch (Exception e)
+ {
+ DebugHelper.LogDebug(nameof(MotionPhotoExtractor), nameof(ExtractAsync), e);
+ return null;
+ }
+ }
+
+ ///
+ /// Extracts the cover image of a .livp container to a temporary file so the regular
+ /// image decoding pipeline can process it.
+ ///
+ /// The path of the temporary image file, or null when the container holds no image.
+ public static async ValueTask ExtractLivpCoverToTempFileAsync(FileInfo fileInfo, CancellationToken ct = default)
+ {
+ try
+ {
+ var stream = await ExtractLivpEntryToMemoryAsync(fileInfo, IsImageFileName, ct).ConfigureAwait(false);
+ if (stream is null)
+ {
+ return null;
+ }
+
+ await using (stream.ConfigureAwait(false))
+ {
+ var tempPath = TempFileManager.GetNewTempFilePath("livp-cover.jpg");
+ var fileStream = new FileStream(tempPath, FileMode.Create, FileAccess.Write,
+ FileShare.Read, 4096, FileOptions.Asynchronous);
+ await using (fileStream.ConfigureAwait(false))
+ {
+ await stream.CopyToAsync(fileStream, ct).ConfigureAwait(false);
+ }
+
+ return tempPath;
+ }
+ }
+ catch (Exception e)
+ {
+ DebugHelper.LogDebug(nameof(MotionPhotoExtractor), nameof(ExtractLivpCoverToTempFileAsync), e);
+ return null;
+ }
+ }
+
+ private static async ValueTask ExtractEmbeddedAsync(FileInfo fileInfo, long expectedStart, CancellationToken ct)
+ {
+ var fileLength = fileInfo.Length;
+ if (expectedStart < 0 || expectedStart >= fileLength)
+ {
+ return null;
+ }
+
+ var stream = new FileStream(fileInfo.FullName, FileMode.Open, FileAccess.Read,
+ FileShare.ReadWrite, 4096, FileOptions.Asynchronous | FileOptions.SequentialScan);
+ await using (stream.ConfigureAwait(false))
+ {
+ var start = await LocateVideoStartAsync(stream, expectedStart, fileLength, ct).ConfigureAwait(false);
+ if (start is null)
+ {
+ return null;
+ }
+
+ var length = fileLength - start.Value;
+ if (length <= 0 || length > MaxVideoBytes)
+ {
+ return null;
+ }
+
+ // Hand out a live window over the source file instead of copying the whole
+ // video into memory; playback can start immediately and uses no extra RAM.
+ return new FileSliceStream(fileInfo.FullName, start.Value, length);
+ }
+ }
+
+ private static Stream? OpenReadOnly(FileInfo? file)
+ {
+ if (file is null || !file.Exists || file.Length is 0)
+ {
+ return null;
+ }
+
+ return new FileStream(file.FullName, FileMode.Open, FileAccess.Read,
+ FileShare.ReadWrite, 4096, FileOptions.Asynchronous | FileOptions.SequentialScan);
+ }
+
+ private static async ValueTask ExtractLivpEntryToMemoryAsync(
+ FileInfo fileInfo, Func entryPredicate, CancellationToken ct)
+ {
+ if (!fileInfo.Exists || fileInfo.Length is 0 || fileInfo.Length > MaxVideoBytes)
+ {
+ return null;
+ }
+
+ var stream = new FileStream(fileInfo.FullName, FileMode.Open, FileAccess.Read,
+ FileShare.ReadWrite, 4096, FileOptions.Asynchronous | FileOptions.SequentialScan);
+ await using (stream.ConfigureAwait(false))
+ {
+ using var archive = ArchiveFactory.OpenArchive(stream, new ReaderOptions());
+ foreach (var entry in archive.Entries)
+ {
+ if (entry.IsDirectory || entry.Key is null || !entryPredicate(entry.Key))
+ {
+ continue;
+ }
+
+ var size = entry.Size;
+ if (size <= 0 || size > MaxVideoBytes)
+ {
+ continue;
+ }
+
+ var memory = new MemoryStream((int)size);
+ var entryStream = entry.OpenEntryStream();
+ await using (entryStream.ConfigureAwait(false))
+ {
+ await entryStream.CopyToAsync(memory, ct).ConfigureAwait(false);
+ }
+
+ memory.Position = 0;
+ return memory;
+ }
+ }
+
+ return null;
+ }
+
+ ///
+ /// Verifies that the expected video start position is a valid MP4 "ftyp" box; otherwise
+ /// searches a ±8 KB window for the closest valid ftyp box.
+ ///
+ internal static async ValueTask LocateVideoStartAsync(Stream stream, long expectedStart, long fileLength, CancellationToken ct)
+ {
+ if (await IsFtypBoxAtAsync(stream, expectedStart, fileLength, ct).ConfigureAwait(false))
+ {
+ return expectedStart;
+ }
+
+ var windowStart = Math.Max(0, expectedStart - FtypSearchWindowBytes);
+ var windowEnd = Math.Min(fileLength, expectedStart + FtypSearchWindowBytes + BoxHeaderSize);
+ var windowLength = (int)(windowEnd - windowStart);
+ if (windowLength <= BoxHeaderSize)
+ {
+ return null;
+ }
+
+ var buffer = new byte[windowLength];
+ stream.Seek(windowStart, SeekOrigin.Begin);
+ var totalRead = 0;
+ while (totalRead < windowLength)
+ {
+ var read = await stream.ReadAsync(buffer.AsMemory(totalRead, windowLength - totalRead), ct).ConfigureAwait(false);
+ if (read is 0)
+ {
+ break;
+ }
+
+ totalRead += read;
+ }
+
+ return FindFtypStart(buffer.AsSpan(0, totalRead), windowStart, expectedStart, fileLength);
+ }
+
+ ///
+ /// Scans a byte window for the valid ftyp box closest to .
+ ///
+ internal static long? FindFtypStart(ReadOnlySpan window, long windowStart, long expectedStart, long fileLength)
+ {
+ long? best = null;
+ var bestDistance = long.MaxValue;
+ for (var offset = 0; offset + BoxHeaderSize <= window.Length; offset++)
+ {
+ var absolute = windowStart + offset;
+ var distance = Math.Abs(absolute - expectedStart);
+ if (distance >= bestDistance)
+ {
+ continue;
+ }
+
+ if (IsValidFtypBox(window.Slice(offset), absolute, fileLength))
+ {
+ best = absolute;
+ bestDistance = distance;
+ }
+ }
+
+ return best;
+ }
+
+ private static async ValueTask IsFtypBoxAtAsync(Stream stream, long offset, long fileLength, CancellationToken ct)
+ {
+ if (offset < 0 || offset + BoxHeaderSize > fileLength)
+ {
+ return false;
+ }
+
+ var header = new byte[BoxHeaderSize];
+ stream.Seek(offset, SeekOrigin.Begin);
+ var read = await stream.ReadAsync(header.AsMemory(0, BoxHeaderSize), ct).ConfigureAwait(false);
+ return read == BoxHeaderSize && IsValidFtypBox(header, offset, fileLength);
+ }
+
+ private static bool IsValidFtypBox(ReadOnlySpan bytes, long absoluteOffset, long fileLength)
+ {
+ if (bytes.Length < BoxHeaderSize)
+ {
+ return false;
+ }
+
+ if (bytes[4] != (byte)'f' || bytes[5] != (byte)'t' || bytes[6] != (byte)'y' || bytes[7] != (byte)'p')
+ {
+ return false;
+ }
+
+ var boxSize = BinaryPrimitives.ReadUInt32BigEndian(bytes);
+ return boxSize >= BoxHeaderSize && absoluteOffset + boxSize <= fileLength;
+ }
+
+ private static bool IsVideoFileName(string name)
+ {
+ var extension = Path.GetExtension(name);
+ return extension.Equals(".mov", StringComparison.OrdinalIgnoreCase) ||
+ extension.Equals(".mp4", StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static bool IsImageFileName(string name)
+ {
+ var extension = Path.GetExtension(name);
+ return extension.Equals(".jpg", StringComparison.OrdinalIgnoreCase) ||
+ extension.Equals(".jpeg", StringComparison.OrdinalIgnoreCase) ||
+ extension.Equals(".heic", StringComparison.OrdinalIgnoreCase) ||
+ extension.Equals(".heif", StringComparison.OrdinalIgnoreCase) ||
+ extension.Equals(".png", StringComparison.OrdinalIgnoreCase);
+ }
+}
diff --git a/src/PicView.Core/MotionPhoto/MotionPhotoInfo.cs b/src/PicView.Core/MotionPhoto/MotionPhotoInfo.cs
new file mode 100644
index 000000000..1cd6f3c77
--- /dev/null
+++ b/src/PicView.Core/MotionPhoto/MotionPhotoInfo.cs
@@ -0,0 +1,47 @@
+namespace PicView.Core.MotionPhoto;
+
+///
+/// Identifies how the motion photo video is stored.
+///
+public enum MotionPhotoSource
+{
+ /// Video is embedded at the end of the image file, located via XMP metadata.
+ EmbeddedXmp,
+
+ /// Video is embedded after a "MotionPhoto_Data" trailer marker (legacy Samsung format).
+ SamsungTrailer,
+
+ /// Video is stored in a same-named sidecar file (.mov/.mp4) next to the image.
+ Sidecar,
+
+ /// Image and video are stored inside a .livp zip container (Apple Live Photo export).
+ LivpContainer,
+}
+
+///
+/// Describes where the embedded or associated video of a motion photo can be found.
+/// The still image itself is decoded through the regular image pipeline; this record
+/// only holds the coordinates needed to extract the video on demand.
+///
+public sealed record MotionPhotoInfo
+{
+ public required MotionPhotoSource Source { get; init; }
+
+ ///
+ /// Byte offset of the video inside the source file. Only meaningful for
+ /// and .
+ ///
+ public long VideoOffset { get; init; }
+
+ ///
+ /// Length of the video in bytes as reported by the metadata, when known.
+ /// The extractor reads from to the end of file regardless,
+ /// as trailing junk is tolerated by the demuxer.
+ ///
+ public long VideoLength { get; init; }
+
+ ///
+ /// The sidecar video file. Only meaningful for .
+ ///
+ public FileInfo? SidecarFile { get; init; }
+}
diff --git a/src/PicView.Core/Navigation/FileWatcherService.cs b/src/PicView.Core/Navigation/FileWatcherService.cs
index 9e39e114b..b1ce0445d 100644
--- a/src/PicView.Core/Navigation/FileWatcherService.cs
+++ b/src/PicView.Core/Navigation/FileWatcherService.cs
@@ -5,6 +5,7 @@
using PicView.Core.FileSorting;
using PicView.Core.Gallery;
using PicView.Core.Models;
+using PicView.Core.MotionPhoto;
using PicView.Core.Navigation.Interfaces;
using PicView.Core.ViewModels;
using R3;
@@ -219,6 +220,7 @@ private async ValueTask OnFileCreatedAsync(TabViewModel tab, FileSystemEventArgs
thumbnailCache?.Add(tab.Id, newFile.FullName, thumb);
}
item.Image.Value = thumb;
+ item.IsMotionPhoto.Value = MotionPhotoDetector.TryDetect(newFile, null) is not null;
}
}
}
diff --git a/src/PicView.Core/ViewModels/GalleryItemViewModel.cs b/src/PicView.Core/ViewModels/GalleryItemViewModel.cs
index e7458276a..9944f8ca8 100644
--- a/src/PicView.Core/ViewModels/GalleryItemViewModel.cs
+++ b/src/PicView.Core/ViewModels/GalleryItemViewModel.cs
@@ -11,15 +11,19 @@ public void Dispose()
FileName,
FileLocation,
FileSize,
- FileDate);
+ FileDate,
+ IsMotionPhoto);
}
-
+
// Data Properties
public BindableReactiveProperty