Files
Docker/uniapp/Google Chrome/VideoListForm.cs
T
2026-11-06 16:14:51 +08:00

342 lines
13 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Drawing.Drawing2D;
namespace WinFormsApp3
{
/// <summary>
/// 本页视频下载列表:每个候选视频用 ffmpeg 抽 3 张间隔 5 秒的截图拼成预览条,
/// 肉眼对比哪个是当前视频后勾选,选文件夹批量下载。
/// 当前正在播放的那条默认勾上。
/// </summary>
public partial class VideoListForm : Form
{
private const int RowHeight = 124;
private const int PreviewWidth = 496;
private const int PreviewHeight = 100;
private readonly Func<VideoCandidate, CancellationToken, Task<(Image? Image, string? Error)>> _previewFactory;
private readonly List<VideoRow> _rows = new();
private readonly CancellationTokenSource _loadCts = new();
private int _generatedCount;
private int _failedCount;
/// <summary>点“下载选中”后带回的勾选候选(按列表顺序)。</summary>
public List<VideoCandidate> SelectedCandidates { get; } = new();
public VideoListForm(IReadOnlyList<VideoCandidate> candidates, IReadOnlyCollection<string> currentUrls,
Func<VideoCandidate, CancellationToken, Task<(Image? Image, string? Error)>> previewFactory)
{
InitializeComponent();
_previewFactory = previewFactory;
BuildRows(candidates, currentUrls);
}
private void BuildRows(IReadOnlyList<VideoCandidate> candidates, IReadOnlyCollection<string> 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();
}
/// <summary>
/// 截图预览在后台生成(三条并发),生成一条刷新一条;
/// 失败时把原因直接写在预览条上,不让界面一直停在“生成中…”。
/// </summary>
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<string> tags = new List<string>();
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}";
}
/// <summary>
/// 把抽到的 JPEG 帧横向拼成一条预览图(约 3×160 宽),供列表行显示。
/// </summary>
public static Image? ComposeFrames(List<byte[]> frames)
{
if (frames == null || frames.Count == 0) return null;
List<Image> source = new List<Image>();
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;
}
/// <summary>
/// 一行候选:勾选框 + 截图预览条 + 说明文字。
/// </summary>
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);
}
}
/// <summary>截图还没生成好(或生成失败)时行内显示的占位条。</summary>
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;
}
}
}
}