Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
199 changes: 148 additions & 51 deletions src/ui/Features/Shared/PickMatroskaTrack/PickMatroskaTrackViewModel.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -212,100 +226,183 @@ internal void OnKeyDownHandler(object? sender, KeyEventArgs e)

internal void DataGridTracksSelectionChanged(object? sender, SelectionChangedEventArgs e)
{
TrackChanged();
_ = TrackChangedAsync();
}

private bool TrackChanged()
/// <summary>
/// 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).
/// </summary>
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<PleaseWaitWindow, PleaseWaitViewModel>(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)
}

/// <summary>
/// Parses the selected track into plain preview data. Runs on a background thread, so it must
/// not touch any UI controls - the Avalonia <see cref="Bitmap"/> objects it creates are decoded
/// here, but the <see cref="Image"/> controls that host them are built on the UI thread.
/// </summary>
private static PreviewResult BuildPreview(MatroskaTrackInfo trackInfo, MatroskaFile matroskaFile, PleaseWaitViewModel? pleaseWaitVm)
{
var cues = new List<PreviewCueData>();
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<MatroskaSubtitle> 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<PreviewCueData> 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)
Expand All @@ -319,7 +416,7 @@ internal void SelectAndScrollToRow(int index)
{
TracksGrid.SelectedIndex = index;
TracksGrid.ScrollIntoView(TracksGrid.SelectedItem, null);
TrackChanged();
_ = TrackChangedAsync();
}, DispatcherPriority.Background);
}
}