ow
This commit is contained in:
1 parent
bcf82d0f45
commit
8260c417e2
7 files changed
+1148
-44
No files matched your search
@@ -0,0 +1,341 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user