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;
}
}
}
}