feat(media): add a video file layer that decodes into the scene
TASK 21's picker slice: the decoder, manager, pacing and loop mechanism were
already shipped but nothing ever acquired a session, so a media Source rendered
nothing. Adds "Video File" to the (+) menu (video-filtered OpenFileDialog),
one decode session per distinct path with a HasSession guard, release when the
last scene reference goes, and a post-load reconcile. Loop is a manager-level
per-path answer ("any live reference loops") applied before StartAsync and
updated live via SetLooping. Properties panel shows the file with Change… and
Loop. Volume stays unwired: media audio rides the desktop loopback, TRAX-style.
Reference: OBS's media source (file → decode → composite into the scene) is the
established answer for this feature; the lifecycle shape mirrors this repo's
WebView2/ScreenCapture registration rather than inventing a new one.
Test: ytLive.Tests/MediaSourceAcquisitionTests.cs — add starts one decoder and
frames reach the layer, loop flip reaches the decoder, remove stops it.
Docs: task-21 closed, TASKS row + Day-4 row + stale 3·17 row corrected,
frame-pipeline acquisition note, HANDOFF rewritten. verify.sh green.
This commit is contained in:
@@ -1,5 +1,9 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Linq;
|
||||
using System.Windows.Media.Imaging;
|
||||
using Microsoft.Win32;
|
||||
using ytLive.Models;
|
||||
using ytLive.Services;
|
||||
|
||||
@@ -17,8 +21,15 @@ public partial class MainViewModel
|
||||
private const int MediaDecodeWidth = 1920;
|
||||
private const int MediaDecodeHeight = 1080;
|
||||
|
||||
private const string VideoFileFilter =
|
||||
"Video files (*.mp4;*.mov;*.avi;*.mkv;*.webm)|*.mp4;*.mov;*.avi;*.mkv;*.webm|All files (*.*)|*.*";
|
||||
|
||||
private readonly MediaVideoSourceManager _mediaManager;
|
||||
|
||||
// One subscription per media Source, so removing a layer stops the listener as
|
||||
// well as the decoder (a scene element outlives its layer list row).
|
||||
private readonly Dictionary<string, PropertyChangedEventHandler> _mediaHandlers = new();
|
||||
|
||||
// MediaVideoSourceManager publishes one shared WriteableBitmap per file; every
|
||||
// media Source pointing at that path picks it up for the preview/canvas.
|
||||
private void OnMediaPreviewBitmapChanged(string path, WriteableBitmap bitmap)
|
||||
@@ -28,8 +39,135 @@ public partial class MainViewModel
|
||||
source.VideoImageSource = bitmap;
|
||||
}
|
||||
|
||||
// Called from the encoder error path is out of scope (TASK 21 sees the decoder
|
||||
// fail at startup); log to the debug console for now, surfaced via the UI later.
|
||||
private static void OnMediaFailed(string path, string message)
|
||||
=> System.Diagnostics.Debug.WriteLine($"MediaSource: '{path}' failed: {message}");
|
||||
private void OnMediaFailed(string path, string message)
|
||||
{
|
||||
Helpers.AppLog.Write($"MediaSource: '{path}' failed: {message}");
|
||||
_notifications.Warning("Couldn't play that video", System.IO.Path.GetFileName(path));
|
||||
}
|
||||
|
||||
// ---------- add / change the file ----------
|
||||
|
||||
private void AddMedia()
|
||||
{
|
||||
if (PickVideoFile() is { } path)
|
||||
AddMediaSource(path);
|
||||
}
|
||||
|
||||
private string? PickVideoFile(string title = "Choose a video file")
|
||||
{
|
||||
var dialog = new OpenFileDialog
|
||||
{
|
||||
Title = title,
|
||||
Filter = VideoFileFilter,
|
||||
};
|
||||
return dialog.ShowDialog() == true ? dialog.FileName : null;
|
||||
}
|
||||
|
||||
/// <summary>Adds a MediaSource layer for <paramref name="path"/> to the staged scene.
|
||||
/// Internal so the Good Dog test drives the real add path without a file dialog.</summary>
|
||||
internal void AddMediaSource(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path)) return;
|
||||
AddSource(SourceType.MediaSource, path);
|
||||
}
|
||||
|
||||
private void ChangeMediaFile(Source? source)
|
||||
{
|
||||
if (source is not { IsMediaSource: true }) return;
|
||||
if (PickVideoFile("Replace the video") is not { } path) return;
|
||||
PushPreviewUndo();
|
||||
source.MediaPath = path;
|
||||
}
|
||||
|
||||
// ---------- session lifecycle (one session per distinct path) ----------
|
||||
|
||||
/// <summary>Starts decoding the file if nothing else already is, and subscribes to the
|
||||
/// layer so a file swap or a loop flip reaches the session.</summary>
|
||||
private void RegisterMediaSource(Source source)
|
||||
{
|
||||
if (_mediaHandlers.ContainsKey(source.Id)) return;
|
||||
|
||||
string? lastPath = source.MediaPath;
|
||||
|
||||
void OnSourceChanged(object? _, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName == nameof(Source.MediaPath))
|
||||
{
|
||||
var previous = lastPath;
|
||||
lastPath = source.MediaPath;
|
||||
if (string.Equals(previous, source.MediaPath, StringComparison.OrdinalIgnoreCase)) return;
|
||||
// The layer now points elsewhere, so the old file may be unreferenced —
|
||||
// release it before acquiring the replacement.
|
||||
ReleaseMediaIfUnused(previous);
|
||||
EnsureMediaSession(source.MediaPath);
|
||||
}
|
||||
else if (e.PropertyName == nameof(Source.MediaIsLooping) && source.MediaPath is { } path)
|
||||
{
|
||||
_mediaManager.SetLooping(path, AnyMediaReferenceLoops(path));
|
||||
}
|
||||
}
|
||||
|
||||
_mediaHandlers[source.Id] = OnSourceChanged;
|
||||
source.PropertyChanged += OnSourceChanged;
|
||||
EnsureMediaSession(source.MediaPath);
|
||||
}
|
||||
|
||||
private void UnregisterMediaSource(Source source)
|
||||
{
|
||||
if (_mediaHandlers.Remove(source.Id, out var handler))
|
||||
source.PropertyChanged -= handler;
|
||||
}
|
||||
|
||||
private void EnsureMediaSession(string? path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path)) return;
|
||||
if (_mediaManager.HasSession(path)) return;
|
||||
_ = AcquireMediaAsync(path);
|
||||
}
|
||||
|
||||
private async System.Threading.Tasks.Task AcquireMediaAsync(string path)
|
||||
{
|
||||
if (!await _mediaManager.AcquireAsync(path)) return;
|
||||
|
||||
// A session can be running before this layer existed (shared path), in which
|
||||
// case the bitmap is already published and will not be raised again for us.
|
||||
if (_mediaManager.GetPreviewBitmap(path) is { } bitmap)
|
||||
OnMediaPreviewBitmapChanged(path, bitmap);
|
||||
}
|
||||
|
||||
/// <summary>Shared sessions or not, the loop state is the path's: any live media
|
||||
/// reference asking for a loop makes the one decoder loop (recorded rule, TASK 21).</summary>
|
||||
private bool AnyMediaReferenceLoops(string path)
|
||||
=> Scenes.SelectMany(s => s.Elements).OfType<Source>()
|
||||
.Any(s => s.IsMediaSource && s.MediaPath == path && s.MediaIsLooping);
|
||||
|
||||
private bool HasMediaReference(string path)
|
||||
=> Scenes.SelectMany(s => s.Elements).OfType<Source>()
|
||||
.Any(s => s.IsMediaSource && s.MediaPath == path);
|
||||
|
||||
private void ReleaseMediaIfUnused(string? path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path)) return;
|
||||
if (!_mediaManager.HasSession(path)) return;
|
||||
if (HasMediaReference(path)) return;
|
||||
_ = _mediaManager.ReleaseAsync(path);
|
||||
}
|
||||
|
||||
/// <summary>After a layout load: subscribe every persisted media layer and start one
|
||||
/// session per distinct file; release sessions for files the layout no longer uses.</summary>
|
||||
private void ReacquireMediaSources()
|
||||
{
|
||||
var sources = Scenes.SelectMany(s => s.Elements).OfType<Source>()
|
||||
.Where(s => s.IsMediaSource && !string.IsNullOrWhiteSpace(s.MediaPath))
|
||||
.ToList();
|
||||
|
||||
foreach (var source in sources)
|
||||
RegisterMediaSource(source);
|
||||
|
||||
var wanted = sources.Select(s => s.MediaPath!).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
||||
foreach (var stale in _mediaManager.GetActiveKeys()
|
||||
.Where(key => !wanted.Contains(key, StringComparer.OrdinalIgnoreCase))
|
||||
.ToList())
|
||||
_ = _mediaManager.ReleaseAllAsync(stale);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user