342 lines
13 KiB
C#
342 lines
13 KiB
C#
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;
|
||
}
|
||
}
|
||
}
|
||
}
|