using System.Drawing.Drawing2D; namespace WinFormsApp3 { /// /// 本页视频下载列表:每个候选视频用 ffmpeg 抽 3 张间隔 5 秒的截图拼成预览条, /// 肉眼对比哪个是当前视频后勾选,选文件夹批量下载。 /// 当前正在播放的那条默认勾上。 /// public partial class VideoListForm : Form { private const int RowHeight = 124; private const int PreviewWidth = 496; private const int PreviewHeight = 100; private readonly Func> _previewFactory; private readonly List _rows = new(); private readonly CancellationTokenSource _loadCts = new(); private int _generatedCount; private int _failedCount; /// 点“下载选中”后带回的勾选候选(按列表顺序)。 public List SelectedCandidates { get; } = new(); public VideoListForm(IReadOnlyList candidates, IReadOnlyCollection currentUrls, Func> previewFactory) { InitializeComponent(); _previewFactory = previewFactory; BuildRows(candidates, currentUrls); } private void BuildRows(IReadOnlyList candidates, IReadOnlyCollection currentUrls) { int y = 8; foreach (VideoCandidate candidate in candidates) { bool isCurrent = currentUrls.Contains(candidate.Url); VideoRow row = new VideoRow(candidate, Describe(candidate, isCurrent), isCurrent) { Location = new Point(8, y), Width = Math.Max(620, pnlVideos.ClientSize.Width - 24), }; row.CheckChanged += (_, _) => UpdateSelectionCount(); _rows.Add(row); pnlVideos.Controls.Add(row); y += RowHeight + 6; } pnlVideos.Resize += (_, _) => { foreach (VideoRow row in _rows) { row.Width = Math.Max(620, pnlVideos.ClientSize.Width - 24); } }; UpdateSelectionCount(); } /// /// 截图预览在后台生成(三条并发),生成一条刷新一条; /// 失败时把原因直接写在预览条上,不让界面一直停在“生成中…”。 /// protected override async void OnShown(EventArgs e) { base.OnShown(e); if (_rows.Count == 0) { lblHint.Text = "本页没有可下载的视频"; return; } SemaphoreSlim semaphore = new SemaphoreSlim(3); Task[] tasks = _rows.Select(async row => { await semaphore.WaitAsync(_loadCts.Token); try { Image? preview = null; string? error = null; try { (preview, error) = await _previewFactory(row.Candidate, _loadCts.Token); } catch (OperationCanceledException) { PreviewLog.Write("预览任务被取消(列表已关闭)"); return; } catch (Exception ex) { // 抽帧失败按“无预览”处理,勾选与下载不受影响 error = ex.Message; } PreviewLog.Write($"预览回传 UI img={(preview != null ? "ok" : "null")} error={(error ?? "-")}"); if (_loadCts.IsCancellationRequested || !IsHandleCreated || IsDisposed) { preview?.Dispose(); return; } BeginInvoke(() => { row.SetResult(preview, error); if (preview == null) _failedCount++; _generatedCount++; // 最后一行刷新时直接切到完成文案,不依赖 WhenAll 后续代码的时序 lblHint.Text = _generatedCount < _rows.Count ? $"截图生成中… {_generatedCount}/{_rows.Count}" + (_failedCount > 0 ? $"({_failedCount} 条失败,原因见预览条)" : string.Empty) : (_failedCount == 0 ? "截图已生成,对比后勾选要下载的视频" : $"截图生成完毕({_failedCount} 条失败,原因见预览条;不影响勾选下载)"); }); } finally { semaphore.Release(); } }).ToArray(); await Task.WhenAll(tasks); if (!IsDisposed && !_loadCts.IsCancellationRequested) { lblHint.Text = _failedCount == 0 ? "截图已生成,对比后勾选要下载的视频" : $"截图生成完毕({_failedCount} 条失败,原因见预览条;不影响勾选下载)"; } } protected override void OnFormClosed(FormClosedEventArgs e) { _loadCts.Cancel(); _loadCts.Dispose(); base.OnFormClosed(e); } private void UpdateSelectionCount() { btnDownload.Text = $"下载选中 ({_rows.Count(r => r.IsChecked)})"; } private void btnDownload_Click(object sender, EventArgs e) { SelectedCandidates.Clear(); SelectedCandidates.AddRange(_rows.Where(r => r.IsChecked).Select(r => r.Candidate)); if (SelectedCandidates.Count == 0) { MessageBox.Show(this, "先勾选要下载的视频(看截图对比哪个是当前视频)。", "下载列表", MessageBoxButtons.OK, MessageBoxIcon.Information); return; } DialogResult = DialogResult.OK; } private static string Describe(VideoCandidate candidate, bool isCurrent) { List tags = new List(); if (isCurrent) tags.Add("当前播放"); if (candidate.PlaylistText != null) tags.Add("内存播放列表"); else if (VideoDownloader.IsHlsUrl(candidate.Url)) tags.Add("m3u8"); if (!string.IsNullOrEmpty(candidate.AudioUrl)) tags.Add("音频轨已配对·自动合并"); if (tags.Count == 0) tags.Add("直链"); string hostLine; try { Uri u = new Uri(candidate.Url); string last = Path.GetFileName(u.AbsolutePath); hostLine = last.Length > 0 ? $"{u.Host} … {last}" : u.Host; } catch { hostLine = candidate.Url.Length > 90 ? candidate.Url[..90] + "…" : candidate.Url; } return $"【{string.Join("】【", tags)}】\n{hostLine}"; } /// /// 把抽到的 JPEG 帧横向拼成一条预览图(约 3×160 宽),供列表行显示。 /// public static Image? ComposeFrames(List frames) { if (frames == null || frames.Count == 0) return null; List source = new List(); foreach (byte[] frame in frames) { try { Image img = Image.FromStream(new MemoryStream(frame)); source.Add(img); } catch { // 坏帧直接跳过 } } if (source.Count == 0) return null; const int cellW = 160; const int gap = 8; int cellH = PreviewHeight - 4; var sizes = source.Select(img => { int w = cellW; int h = Math.Max(1, (int)Math.Round((double)cellW * img.Height / img.Width)); if (h > cellH) { h = cellH; w = Math.Max(1, (int)Math.Round((double)cellH * img.Width / img.Height)); } return (w, h); }).ToList(); int canvasW = source.Count * cellW + (source.Count - 1) * gap; int canvasH = sizes.Max(s => s.h); Bitmap strip = new Bitmap(canvasW, canvasH); using (Graphics g = Graphics.FromImage(strip)) { g.Clear(Color.Black); g.InterpolationMode = InterpolationMode.HighQualityBicubic; for (int i = 0; i < source.Count; i++) { int x = i * (cellW + gap) + (cellW - sizes[i].w) / 2; int y = (canvasH - sizes[i].h) / 2; g.DrawImage(source[i], new Rectangle(x, y, sizes[i].w, sizes[i].h)); } } foreach (Image img in source) img.Dispose(); return strip; } /// /// 一行候选:勾选框 + 截图预览条 + 说明文字。 /// private sealed class VideoRow : UserControl { private readonly CheckBox _check = new(); private readonly PictureBox _preview = new(); private readonly ToolTip _tip = new(); public VideoCandidate Candidate { get; } public bool IsChecked => _check.Checked; public event EventHandler? CheckChanged; public VideoRow(VideoCandidate candidate, string description, bool isChecked) { Candidate = candidate; Height = RowHeight; Margin = new Padding(0); TableLayoutPanel layout = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 3, RowCount = 1, }; layout.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 30F)); layout.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, PreviewWidth + 8F)); layout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100F)); _check.Dock = DockStyle.None; _check.Anchor = AnchorStyles.Top; _check.Location = new Point(4, 6); _check.Checked = isChecked; _check.CheckedChanged += (_, _) => CheckChanged?.Invoke(this, EventArgs.Empty); _preview.Size = new Size(PreviewWidth, PreviewHeight); _preview.SizeMode = PictureBoxSizeMode.Zoom; _preview.BackColor = Color.FromArgb(28, 28, 28); _preview.Image = Placeholder(); Label text = new Label { Dock = DockStyle.Fill, Padding = new Padding(8, 4, 4, 2), Text = description, TextAlign = ContentAlignment.TopLeft, }; layout.Controls.Add(_check, 0, 0); layout.Controls.Add(_preview, 1, 0); layout.Controls.Add(text, 2, 0); Controls.Add(layout); } public void SetResult(Image? image, string? error) { _preview.Image?.Dispose(); if (image != null) { _preview.Image = image; _tip.SetToolTip(_preview, string.Empty); } else { // 把失败原因直接画在预览条上,一眼能看出是超时还是防盗链 string reason = string.IsNullOrWhiteSpace(error) ? "截图不可用" : error!; _preview.Image = Placeholder(reason); _tip.SetToolTip(_preview, reason); } } /// 截图还没生成好(或生成失败)时行内显示的占位条。 private static Image Placeholder(string? message = null) { Bitmap bmp = new Bitmap(PreviewWidth, PreviewHeight); using Graphics g = Graphics.FromImage(bmp); g.Clear(Color.FromArgb(28, 28, 28)); using (var cellBrush = new SolidBrush(Color.FromArgb(56, 56, 56))) { for (int i = 0; i < 3; i++) { g.FillRectangle(cellBrush, i * 168, 0, 160, PreviewHeight); } } using Font font = new Font("Microsoft YaHei UI", 8F); string text = message ?? "截图生成中…"; Color color = message == null ? Color.Gray : Color.FromArgb(230, 140, 90); // 失败原因可能较长,在预览条区域内自动换行 var rect = new RectangleF(4, 4, PreviewWidth - 8, PreviewHeight - 8); using var format = new StringFormat { Trimming = StringTrimming.EllipsisCharacter, FormatFlags = StringFormatFlags.LineLimit }; g.DrawString(text, font, new SolidBrush(color), rect, format); return bmp; } } } }