using System; using System.Linq; using System.IO; using ytLive.Helpers; using ytLive.Models; using ytLive.Services; namespace ytLive.ViewModels; /// /// TASK 65, slice 4: the creator-facing half of replay clips — the setting, the ring's lifetime, and F10. /// /// Clips are a **static feature**: 10 or 15 seconds, keep 3/4/5, no editor and no gallery. Off by /// default, and when off nothing runs — no ring, no reader thread, no encoder output — so the feature /// costs nothing until someone asks for it. /// public partial class MainViewModel { private ClipRing? _clipRing; private ClipLibrary? _clipLibrary; public bool ClipsEnabled { get => _clipsEnabled; set { if (!SetProperty(ref _clipsEnabled, value)) return; _layoutStore.SaveClipsEnabled(value); ApplyClipSettings(); } } private bool _clipsEnabled; /// 10 or 15 seconds; anything else in the store is coerced by the loader. public int ClipSeconds { get => _clipSeconds; set { var clamped = value == 15 ? 15 : 10; if (!SetProperty(ref _clipSeconds, clamped)) return; _layoutStore.SaveClipSeconds(clamped); ApplyClipSettings(); } } private int _clipSeconds = 10; public int ClipKeep { get => _clipKeep; set { var clamped = Math.Clamp(value, 3, 5); if (!SetProperty(ref _clipKeep, clamped)) return; _layoutStore.SaveClipKeep(clamped); } } private int _clipKeep = 3; /// String-faced pickers, because the drawer binds a ComboBox and the model keeps ints. public string[] ClipLengthOptions { get; } = { "10 seconds", "15 seconds" }; public string ClipLengthText { get => ClipSeconds == 15 ? "15 seconds" : "10 seconds"; set { ClipSeconds = value == "15 seconds" ? 15 : 10; OnPropertyChanged(nameof(ClipLengthText)); } } public string[] ClipKeepOptions { get; } = { "3 clips", "4 clips", "5 clips" }; public string ClipKeepText { get => $"{ClipKeep} clips"; set { if (int.TryParse(value.Split(' ')[0], out var keep)) ClipKeep = keep; OnPropertyChanged(nameof(ClipKeepText)); } } private void LoadClipSettings() { _clipsEnabled = _layoutStore.LoadClipsEnabled(); _clipSeconds = _layoutStore.LoadClipSeconds(); _clipKeep = _layoutStore.LoadClipKeep(); } /// The ring exists only while a session runs and clips are on. Sized from the chosen length /// and the tier's bitrate, because memory is the cost and bytes are the unit. private void ApplyClipSettings() { if (!_clipsEnabled || !(IsLive || IsRecording)) { _clipRing = null; return; } var quality = EffectiveQuality(Models.OutputProfile.Resolve(SelectedQuality, QualityOptions, HasProOutput)); var bytes = ClipLibrary.RingBytesFor(_clipSeconds, (int)Math.Round(quality.Bitrate * 1000)); if (_clipRing is null || _clipRingCapacity != bytes) { _clipRing = new ClipRing(bytes); _clipRingCapacity = bytes; } } private long _clipRingCapacity; private void StartClipRing() { _clipLibrary = new ClipLibrary(() => _recordFolder ?? DefaultRecordFolder()); ClipSaved -= OnClipSaved; ClipSaved += OnClipSaved; _clipRing = null; _clipRingCapacity = 0; ApplyClipSettings(); OnPropertyChanged(nameof(ClipsEnabled)); OnPropertyChanged(nameof(ClipSeconds)); OnPropertyChanged(nameof(ClipKeep)); } private void StopClipRing() { ClipSaved -= OnClipSaved; _clipRing = null; _clipRingCapacity = 0; _liveReplayPlaying = false; } /// F10. Snapshots the ring, writes a file, and prunes to the retention count. When nothing /// has been buffered yet it says so instead of writing a file that will not open. public void SaveReplayClip() { if (_liveReplayPlaying) { _notifications.Info("Replay in progress", "Wait for the replay to finish before saving another clip."); return; } if (!_clipsEnabled) { _notifications.Info("Replay clips are off", "Turn them on in Settings to save a clip."); return; } var bytes = _clipRing?.Snapshot() ?? Array.Empty(); if (bytes.Length == 0) { _notifications.Warning("Nothing to clip yet", "The replay buffer fills while you stream — wait a few seconds, then press again."); return; } var library = _clipLibrary ??= new ClipLibrary(() => _recordFolder ?? DefaultRecordFolder()); var path = library.Save(bytes, DateTime.Now, _clipKeep); if (path == null) { _notifications.Error("Couldn't save the clip", "The file could not be written. Check free space."); return; } AppLog.Write($"Replay clip saved: {path}"); _notifications.Success("Clip saved", Path.GetFileName(path)); ClipSaved?.Invoke(path); } /// Raised with the saved path so the replay layer can play it immediately. public event Action? ClipSaved; /// True while the Live scene's replay layer is playing a fresh clip. F10 is inert during /// that window: the clip is a second mux of the frames viewers already got, so clipping a replay /// would record a video of a video — the guard replaces the impossible "clean feed". public bool IsLiveReplayPlaying => _liveReplayPlaying; private bool _liveReplayPlaying; /// Adds the replay layer to the staged scene. One per scene; it is an ordinary media layer /// whose path the app owns, so the compositor needs no special case. internal void AddReplayLayer() { var scene = StagedScene; if (scene is null || scene.Elements.OfType().Any(s => s.IsReplayLayer)) return; AddSource(Models.SourceType.MediaSource, NewestClipPath()); if (StagedScene?.Elements.OfType().LastOrDefault() is not { } layer) return; layer.Name = "Replay"; layer.IsReplayLayer = true; layer.MediaMuted = true; // silent by default layer.MediaIsLooping = !Models.SceneCatalog.Is(scene.Name, Models.SceneCatalog.Live); ScheduleSave(); } private string? NewestClipPath() => (_clipLibrary ??= new ClipLibrary(() => _recordFolder ?? DefaultRecordFolder())).Recent().FirstOrDefault(); /// A new clip landed: Live plays it once, every other scene keeps its loop of the kept set. private void OnClipSaved(string path) { foreach (var scene in Scenes) { var layer = scene.Elements.OfType().FirstOrDefault(s => s.IsReplayLayer); if (layer is null) continue; if (Models.SceneCatalog.Is(scene.Name, Models.SceneCatalog.Live)) { layer.MediaPath = path; layer.MediaIsLooping = false; layer.MediaMuted = true; layer.IsVisible = true; layer.MediaPlaybackState = Models.MediaPlaybackState.Playing; _liveReplayPlaying = true; OnPropertyChanged(nameof(IsLiveReplayPlaying)); EnsureMediaSession(MediaSessionKey(layer)); } else if (!layer.IsMediaRunning) { layer.MediaPath = NewestClipPath(); layer.MediaIsLooping = true; layer.IsVisible = true; if (!string.IsNullOrWhiteSpace(layer.MediaPath)) EnsureMediaSession(MediaSessionKey(layer)); } } } /// A replay layer's clip finished: Live hides (you were there to see it), everything else /// advances through the kept set, looped. private void OnReplayLayerCompleted(Source layer) { if (Models.SceneCatalog.Is(StagedScene?.Name ?? string.Empty, Models.SceneCatalog.Live) && layer == Scenes.SelectMany(s => s.Elements).OfType().FirstOrDefault(s => s.IsReplayLayer)) { layer.IsVisible = false; layer.MediaPlaybackState = Models.MediaPlaybackState.Stopped; _liveReplayPlaying = false; OnPropertyChanged(nameof(IsLiveReplayPlaying)); return; } var playlist = (_clipLibrary ??= new ClipLibrary(() => _recordFolder ?? DefaultRecordFolder())).Recent(); if (playlist.Count == 0) return; var index = playlist.ToList().IndexOf(layer.MediaPath ?? string.Empty); var next = playlist[(index + 1 + playlist.Count) % playlist.Count]; layer.MediaPath = next; layer.MediaIsLooping = true; EnsureMediaSession(MediaSessionKey(layer)); } }