diff --git a/src/ui/Features/Shared/PickMatroskaTrack/PickMatroskaTrackViewModel.cs b/src/ui/Features/Shared/PickMatroskaTrack/PickMatroskaTrackViewModel.cs index ea36463efa3..fd0417685e7 100644 --- a/src/ui/Features/Shared/PickMatroskaTrack/PickMatroskaTrackViewModel.cs +++ b/src/ui/Features/Shared/PickMatroskaTrack/PickMatroskaTrackViewModel.cs @@ -1,6 +1,7 @@ using Nikse.SubtitleEdit.UiLogic.Export; using Avalonia.Controls; using Avalonia.Input; +using Avalonia.Media.Imaging; using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; @@ -19,6 +20,7 @@ using System.Collections.ObjectModel; using System.IO; using System.Text; +using System.Threading; using System.Threading.Tasks; namespace Nikse.SubtitleEdit.Features.Shared.PickMatroskaTrack; @@ -43,6 +45,18 @@ public partial class PickMatroskaTrackViewModel : ObservableObject private MatroskaFile? _matroskaFile; private string _fileName; + // MatroskaFile is not thread-safe (single shared FileStream), so preview parsing must be + // serialized. The token lets a newer selection discard a stale preview still queued behind it. + private readonly SemaphoreSlim _previewLock = new(1, 1); + private int _trackChangeToken; + + // GetSubtitle reads (and caches) the whole cluster data on its first call; that is the only + // slow part, so the progress window is only shown until that initial read has completed. + private bool _clusterLoaded; + + // Only bother with the progress window for files large enough that the read is noticeable. + private const long MatroskaProgressWindowMinFileSize = 25 * 1024 * 1024; // 25 MB + public PickMatroskaTrackViewModel(IFileHelper fileHelper, IWindowService windowService) { _fileHelper = fileHelper; @@ -212,100 +226,183 @@ internal void OnKeyDownHandler(object? sender, KeyEventArgs e) internal void DataGridTracksSelectionChanged(object? sender, SelectionChangedEventArgs e) { - TrackChanged(); + _ = TrackChangedAsync(); } - private bool TrackChanged() + /// + /// Builds the preview for the selected track. The actual parsing (which, on the first call, + /// reads the whole file's cluster data) runs off the UI thread with a progress window so the + /// dialog no longer freezes when picking a track in a large multi-subtitle file (#12193). + /// + private async Task TrackChangedAsync() { - var selectedTrack = SelectedTrack; - if (selectedTrack == null || selectedTrack.MatroskaTrackInfo == null) + var trackInfo = SelectedTrack?.MatroskaTrackInfo; + var matroskaFile = _matroskaFile; + var token = ++_trackChangeToken; + + Rows.Clear(); + if (trackInfo == null || matroskaFile == null) { SubtitleCountText = string.Empty; - return false; + return; } - Rows.Clear(); - var count = 0; - var trackInfo = selectedTrack.MatroskaTrackInfo!; - var subtitles = _matroskaFile?.GetSubtitle(trackInfo.TrackNumber, null); - if (trackInfo.CodecId == MatroskaTrackType.SubRip && subtitles != null) + await _previewLock.WaitAsync(); + try { - count = AddTextContent(trackInfo, subtitles, new SubRip()); + // A newer selection arrived while we were waiting for the previous preview to finish; + // let that newer call build the preview instead. + if (token != _trackChangeToken) + { + return; + } + + PleaseWaitViewModel? pleaseWaitVm = null; + if (!_clusterLoaded) + { + long fileSize = 0; + try + { + fileSize = new FileInfo(matroskaFile.Path).Length; + } + catch + { + // ignore - just means no size-based gating + } + + if (fileSize >= MatroskaProgressWindowMinFileSize && Window != null) + { + pleaseWaitVm = _windowService.ShowWindow(Window); + pleaseWaitVm.StatusText = Se.Language.Main.ParsingMatroskaFile; + } + } + + try + { + var vm = pleaseWaitVm; + var preview = await Task.Run(() => BuildPreview(trackInfo, matroskaFile, vm)); + _clusterLoaded = true; + + // Discard the result if the user moved on to another track meanwhile. + if (token != _trackChangeToken) + { + return; + } + + foreach (var cue in preview.Cues) + { + Rows.Add(new MatroskaSubtitleCueDisplay + { + Number = cue.Number, + Show = cue.Show, + Duration = cue.Duration, + Text = cue.Text ?? string.Empty, + Image = cue.Image != null ? new Image { Source = cue.Image } : null, + }); + } + + SubtitleCountText = string.Format(Se.Language.File.Import.NumberOfSubtitlesX, preview.Count); + } + catch (Exception exception) + { + Se.LogError(exception, "Error building Matroska track preview"); + SubtitleCountText = string.Empty; + } + finally + { + pleaseWaitVm?.Close(); + } } - else if (trackInfo.CodecId is MatroskaTrackType.SubStationAlpha or MatroskaTrackType.SubStationAlpha2 && subtitles != null) + finally { - count = AddTextContent(trackInfo, subtitles, new SubStationAlpha()); + _previewLock.Release(); } - else if (trackInfo.CodecId is MatroskaTrackType.AdvancedSubStationAlpha or MatroskaTrackType.AdvancedSubStationAlpha2 && subtitles != null) + } + + /// + /// Parses the selected track into plain preview data. Runs on a background thread, so it must + /// not touch any UI controls - the Avalonia objects it creates are decoded + /// here, but the controls that host them are built on the UI thread. + /// + private static PreviewResult BuildPreview(MatroskaTrackInfo trackInfo, MatroskaFile matroskaFile, PleaseWaitViewModel? pleaseWaitVm) + { + var cues = new List(); + var count = 0; + + MatroskaFile.LoadMatroskaCallback? callback = + pleaseWaitVm != null ? (position, total) => pleaseWaitVm.ReportProgress(position, total) : null; + var subtitles = matroskaFile.GetSubtitle(trackInfo.TrackNumber, callback); + if (subtitles == null) { - count = AddTextContent(trackInfo, subtitles, new AdvancedSubStationAlpha()); + return new PreviewResult(0, cues); } - else if (trackInfo.CodecId is MatroskaTrackType.WebVTT or MatroskaTrackType.WebVTT2 && subtitles != null) + + if (trackInfo.CodecId is MatroskaTrackType.SubRip + or MatroskaTrackType.SubStationAlpha or MatroskaTrackType.SubStationAlpha2 + or MatroskaTrackType.AdvancedSubStationAlpha or MatroskaTrackType.AdvancedSubStationAlpha2 + or MatroskaTrackType.WebVTT or MatroskaTrackType.WebVTT2) { - count = AddTextContent(trackInfo, subtitles, new WebVTT()); + var sub = new Subtitle(); + Utilities.LoadMatroskaTextSubtitle(trackInfo, matroskaFile, subtitles, sub); + count = sub.Paragraphs.Count; + foreach (var p in sub.Paragraphs) + { + cues.Add(new PreviewCueData + { + Number = p.Number, + Text = p.Text, + Show = TimeSpan.FromMilliseconds(p.StartTime.TotalMilliseconds), + Duration = TimeSpan.FromMilliseconds(p.EndTime.TotalMilliseconds - p.StartTime.TotalMilliseconds), + }); + } } - else if (trackInfo.CodecId == MatroskaTrackType.BluRay && subtitles != null && _matroskaFile != null) + else if (trackInfo.CodecId == MatroskaTrackType.BluRay) { - var pcsData = BluRaySupParser.ParseBluRaySupFromMatroska(trackInfo, _matroskaFile); + var pcsData = BluRaySupParser.ParseBluRaySupFromMatroska(trackInfo, matroskaFile); count = pcsData.Count; for (var i = 0; i < 20 && i < pcsData.Count; i++) { var item = pcsData[i]; - var bitmap = item.GetBitmap(); - var cue = new MatroskaSubtitleCueDisplay() + cues.Add(new PreviewCueData { Number = i + 1, Show = TimeSpan.FromMilliseconds(item.StartTimeCode.TotalMilliseconds), Duration = TimeSpan.FromMilliseconds(item.EndTimeCode.TotalMilliseconds - item.StartTimeCode.TotalMilliseconds), - Image = new Image { Source = bitmap.ToAvaloniaBitmap() }, - }; - Rows.Add(cue); + Image = item.GetBitmap().ToAvaloniaBitmap(), + }); } } - else if (trackInfo.CodecId == MatroskaTrackType.TextSt && subtitles != null && _matroskaFile != null) + else if (trackInfo.CodecId == MatroskaTrackType.TextSt) { var subtitle = new Subtitle(); - Utilities.LoadMatroskaTextSubtitle(trackInfo, _matroskaFile, subtitles, subtitle); + Utilities.LoadMatroskaTextSubtitle(trackInfo, matroskaFile, subtitles, subtitle); Utilities.ParseMatroskaTextSt(trackInfo, subtitles, subtitle); - count = subtitle.Paragraphs.Count; for (var i = 0; i < 20 && i < subtitle.Paragraphs.Count; i++) { var item = subtitle.Paragraphs[i]; - var cue = new MatroskaSubtitleCueDisplay() + cues.Add(new PreviewCueData { Number = i + 1, Show = item.StartTime.TimeSpan, Duration = TimeSpan.FromMilliseconds(item.EndTime.TotalMilliseconds - item.StartTime.TotalMilliseconds), Text = item.Text, - }; - Rows.Add(cue); + }); } } - SubtitleCountText = string.Format(Se.Language.File.Import.NumberOfSubtitlesX, count); - return true; + return new PreviewResult(count, cues); } - private int AddTextContent(MatroskaTrackInfo trackInfo, List subtitles, SubtitleFormat format) - { - var sub = new Subtitle(); - Utilities.LoadMatroskaTextSubtitle(trackInfo, _matroskaFile, subtitles, sub); - var raw = format.ToText(sub, string.Empty); - for (var i = 0; i < sub.Paragraphs.Count; i++) - { - var p = sub.Paragraphs[i]; - var cue = new MatroskaSubtitleCueDisplay() - { - Number = p.Number, - Text = p.Text, - Show = TimeSpan.FromMilliseconds(p.StartTime.TotalMilliseconds), - Duration = TimeSpan.FromMilliseconds(p.EndTime.TotalMilliseconds - p.StartTime.TotalMilliseconds), - }; - Rows.Add(cue); - } + private sealed record PreviewResult(int Count, List Cues); - return sub.Paragraphs.Count; + private sealed class PreviewCueData + { + public int Number { get; init; } + public TimeSpan Show { get; init; } + public TimeSpan Duration { get; init; } + public string? Text { get; init; } + public Bitmap? Image { get; init; } } internal void SelectAndScrollToRow(int index) @@ -319,7 +416,7 @@ internal void SelectAndScrollToRow(int index) { TracksGrid.SelectedIndex = index; TracksGrid.ScrollIntoView(TracksGrid.SelectedItem, null); - TrackChanged(); + _ = TrackChangedAsync(); }, DispatcherPriority.Background); } }