Phase D (T2-10): media playlist - auto-collect detected media, flyout management, player window (native+hls.js+dash.js bundles), persistence, auto-next (101 tests GREEN)
This commit is contained in:
parent
406957088f
commit
40c1b39b70
8 changed files with 491 additions and 0 deletions
118
VideoDownloader.App/Browser/Features/MediaPlaylist.cs
Normal file
118
VideoDownloader.App/Browser/Features/MediaPlaylist.cs
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
using System.Collections.ObjectModel;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace VideoDownloader.App.Browser.Features;
|
||||
|
||||
/// <summary>재생목록 트랙. Kind: Direct/Hls/Dash/Social.</summary>
|
||||
public sealed record MediaTrack(
|
||||
string Url,
|
||||
string Title,
|
||||
string Kind,
|
||||
DateTime AddedAt,
|
||||
bool Played = false,
|
||||
string? Referer = null,
|
||||
string? LocalFile = null);
|
||||
|
||||
/// <summary>
|
||||
/// T2-10 미디어 재생목록(Brave Playlist 패턴): 브라우저에서 감지된 미디어를
|
||||
/// 쌓아두고 앱 내에서 연속 재생. JSON 영속(playlist.json).
|
||||
/// </summary>
|
||||
public sealed class MediaPlaylist
|
||||
{
|
||||
public ObservableCollection<MediaTrack> Tracks { get; } = new();
|
||||
|
||||
public int CurrentIndex { get; set; } = -1;
|
||||
|
||||
[JsonIgnore]
|
||||
public MediaTrack? Current => CurrentIndex >= 0 && CurrentIndex < Tracks.Count ? Tracks[CurrentIndex] : null;
|
||||
|
||||
public event Action? Changed;
|
||||
|
||||
/// <summary>추가(중복 URL 무시). 새 항목이 첫 트랙이면 자동 선택.</summary>
|
||||
public void Add(MediaTrack track)
|
||||
{
|
||||
if (Tracks.Any(t => t.Url == track.Url)) return;
|
||||
Tracks.Add(track);
|
||||
if (CurrentIndex < 0) CurrentIndex = Tracks.Count - 1;
|
||||
Changed?.Invoke();
|
||||
}
|
||||
|
||||
public void Remove(MediaTrack track)
|
||||
{
|
||||
var idx = Tracks.IndexOf(track);
|
||||
if (idx < 0) return;
|
||||
Tracks.RemoveAt(idx);
|
||||
if (CurrentIndex >= Tracks.Count) CurrentIndex = Tracks.Count - 1;
|
||||
Changed?.Invoke();
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
Tracks.Clear();
|
||||
CurrentIndex = -1;
|
||||
Changed?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>다음 트랙 인덱스(순환). 반환 후 CurrentIndex 갱신.</summary>
|
||||
public int Next()
|
||||
{
|
||||
if (Tracks.Count == 0) return -1;
|
||||
CurrentIndex = (CurrentIndex + 1) % Tracks.Count;
|
||||
return CurrentIndex;
|
||||
}
|
||||
|
||||
/// <summary>이전 트랙 인덱스(순환).</summary>
|
||||
public int Previous()
|
||||
{
|
||||
if (Tracks.Count == 0) return -1;
|
||||
CurrentIndex = (CurrentIndex - 1 + Tracks.Count) % Tracks.Count;
|
||||
return CurrentIndex;
|
||||
}
|
||||
|
||||
public void MarkPlayed(MediaTrack track)
|
||||
{
|
||||
var idx = Tracks.IndexOf(track);
|
||||
if (idx < 0) return;
|
||||
Tracks[idx] = track with { Played = true };
|
||||
Changed?.Invoke();
|
||||
}
|
||||
|
||||
public void Save(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
var dto = new { tracks = Tracks.ToList(), currentIndex = CurrentIndex };
|
||||
var tmp = path + ".tmp";
|
||||
File.WriteAllText(tmp, JsonSerializer.Serialize(dto));
|
||||
File.Move(tmp, path, overwrite: true);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
public void Load(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(path)) return;
|
||||
using var doc = JsonDocument.Parse(File.ReadAllText(path));
|
||||
var root = doc.RootElement;
|
||||
Tracks.Clear();
|
||||
if (root.TryGetProperty("tracks", out var arr))
|
||||
foreach (var t in arr.EnumerateArray())
|
||||
{
|
||||
var track = t.Deserialize<MediaTrack>();
|
||||
if (track != null) Tracks.Add(track);
|
||||
}
|
||||
CurrentIndex = root.TryGetProperty("currentIndex", out var ci) ? ci.GetInt32() : -1;
|
||||
if (CurrentIndex >= Tracks.Count) CurrentIndex = Tracks.Count - 1;
|
||||
}
|
||||
catch
|
||||
{
|
||||
Tracks.Clear();
|
||||
CurrentIndex = -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
162
VideoDownloader.App/Browser/PlayerWindow.cs
Normal file
162
VideoDownloader.App/Browser/PlayerWindow.cs
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Interop;
|
||||
using Microsoft.Web.WebView2.Core;
|
||||
using VideoDownloader.App.Browser.Features;
|
||||
using VideoDownloader.Core.Config;
|
||||
using Wv2 = Microsoft.Web.WebView2.Wpf;
|
||||
|
||||
namespace VideoDownloader.App.Browser;
|
||||
|
||||
/// <summary>
|
||||
/// T2-10 미디어 재생목록 플레이어: Direct(MP4)/HLS(hls.js)/DASH(dash.js) 를
|
||||
/// 생성된 HTML 플레이어로 재생. 곡 종료 시 다음 트랙 자동 재생(연속 재생).
|
||||
/// </summary>
|
||||
public sealed class PlayerWindow : Window
|
||||
{
|
||||
private readonly CoreWebView2Environment _env;
|
||||
private readonly AppConfig _config;
|
||||
private readonly Wv2.WebView2 _web = new() { DefaultBackgroundColor = System.Drawing.Color.Black };
|
||||
private MediaPlaylist _playlist;
|
||||
private readonly TextBlock _title;
|
||||
|
||||
public PlayerWindow(CoreWebView2Environment env, AppConfig config, MediaPlaylist playlist)
|
||||
{
|
||||
_env = env;
|
||||
_config = config;
|
||||
_playlist = playlist;
|
||||
|
||||
Title = "재생목록 — Video Downloader";
|
||||
Width = 900; Height = 620;
|
||||
WindowStartupLocation = WindowStartupLocation.CenterOwner;
|
||||
|
||||
var bar = new Border { Background = System.Windows.Media.Brushes.Black, Padding = new Thickness(8, 6, 8, 6) };
|
||||
var dock = new DockPanel();
|
||||
var prev = MkBtn("⏮ 이전", () => { _playlist.Previous(); PlayCurrent(); });
|
||||
var next = MkBtn("⏭ 다음", () => { _playlist.Next(); PlayCurrent(); });
|
||||
var close = MkBtn("✕ 닫기", () => Close());
|
||||
_title = new TextBlock { Foreground = System.Windows.Media.Brushes.LightGray, FontSize = 12, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis, Margin = new Thickness(8, 0, 8, 0) };
|
||||
DockPanel.SetDock(close, Dock.Right);
|
||||
DockPanel.SetDock(next, Dock.Right);
|
||||
DockPanel.SetDock(prev, Dock.Left);
|
||||
dock.Children.Add(close);
|
||||
dock.Children.Add(next);
|
||||
dock.Children.Add(prev);
|
||||
dock.Children.Add(_title);
|
||||
bar.Child = dock;
|
||||
|
||||
var grid = new Grid { RowDefinitions = { new RowDefinition { Height = GridLength.Auto }, new RowDefinition { Height = new GridLength(1, GridUnitType.Star) } } };
|
||||
Grid.SetRow(bar, 0); Grid.SetRow(_web, 1);
|
||||
grid.Children.Add(bar);
|
||||
grid.Children.Add(_web);
|
||||
Content = grid;
|
||||
|
||||
Loaded += async (_, _) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var options = _env.CreateCoreWebView2ControllerOptions();
|
||||
if (_config.PrivateMode) options.IsInPrivateModeEnabled = true;
|
||||
await _web.EnsureCoreWebView2Async(_env, options);
|
||||
var cv = _web.CoreWebView2!;
|
||||
cv.WebMessageReceived += (_, we) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var msg = we.WebMessageAsJson;
|
||||
if (string.IsNullOrEmpty(msg)) return;
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(msg);
|
||||
if (doc.RootElement.TryGetProperty("vdEnded", out var e) && e.GetBoolean())
|
||||
{
|
||||
Dispatcher.BeginInvoke(new Action(() =>
|
||||
{
|
||||
var cur = _playlist.Current;
|
||||
if (cur != null) _playlist.MarkPlayed(cur);
|
||||
_playlist.Next();
|
||||
PlayCurrent();
|
||||
}));
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
};
|
||||
PlayCurrent();
|
||||
}
|
||||
catch { }
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
protected override void OnSourceInitialized(EventArgs e)
|
||||
{
|
||||
base.OnSourceInitialized(e);
|
||||
try
|
||||
{
|
||||
var hwnd = new WindowInteropHelper(this).Handle;
|
||||
int dark = 1;
|
||||
DwmSetWindowAttribute(hwnd, 20, ref dark, sizeof(int));
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
[System.Runtime.InteropServices.DllImport("dwmapi.dll")]
|
||||
private static extern int DwmSetWindowAttribute(IntPtr hwnd, int attribute, ref int pvAttribute, int cbAttribute);
|
||||
|
||||
private Button MkBtn(string text, Action onClick)
|
||||
{
|
||||
var b = new Button
|
||||
{
|
||||
Content = text,
|
||||
Padding = new Thickness(10, 3, 10, 3),
|
||||
Margin = new Thickness(4, 0, 4, 0),
|
||||
Background = System.Windows.Media.Brushes.Transparent,
|
||||
Foreground = System.Windows.Media.Brushes.White,
|
||||
BorderBrush = System.Windows.Media.Brushes.Gray
|
||||
};
|
||||
b.Click += (_, _) => onClick();
|
||||
return b;
|
||||
}
|
||||
|
||||
public void PlayCurrent()
|
||||
{
|
||||
var track = _playlist.Current;
|
||||
if (track == null) { _title.Text = "재생목록이 비었습니다"; return; }
|
||||
_title.Text = $"{_playlist.CurrentIndex + 1}/{_playlist.Tracks.Count} · {track.Title} [{track.Kind}]";
|
||||
try { _web.CoreWebView2?.NavigateToString(BuildPlayerHtml(track)); }
|
||||
catch { }
|
||||
}
|
||||
|
||||
/// <summary>트랙 재생 HTML — HLS 는 hls.js, DASH 는 dash.js 번들 인라인.</summary>
|
||||
public static string BuildPlayerHtml(MediaTrack track)
|
||||
{
|
||||
var url = track.Url.Replace("&", "&").Replace("\"", """).Replace("<", "<");
|
||||
var isHls = track.Kind == "Hls" || track.Url.Contains(".m3u8", StringComparison.OrdinalIgnoreCase);
|
||||
var isDash = track.Kind == "Dash" || track.Url.Contains(".mpd", StringComparison.OrdinalIgnoreCase);
|
||||
var hls = isHls ? ReadResource("VideoDownloader.App.Assets.hls.min.js") : "";
|
||||
var dash = isDash ? ReadResource("VideoDownloader.App.Assets.dash.all.min.js") : "";
|
||||
|
||||
var loader = "";
|
||||
if (isHls) loader = "if (Hls.isSupported()) { var h = new Hls(); h.loadSource(SRC); h.attachMedia(v); } else v.src = SRC;";
|
||||
else if (isDash) loader = "var p = dashjs.MediaPlayer().create(); p.initialize(v, SRC, true);";
|
||||
else loader = "v.src = SRC;";
|
||||
|
||||
var html = "<!DOCTYPE html><html><head><meta charset='utf-8'><style>" +
|
||||
"body{background:#000;margin:0;overflow:hidden}video{width:100vw;height:100vh}" +
|
||||
"</style></head><body><video id='v' controls autoplay playsinline></video>" +
|
||||
"<script>var SRC=" + System.Text.Json.JsonSerializer.Serialize(track.Url) + ";" +
|
||||
(hls.Length > 0 ? "<script>" + hls + "</script>" : "") +
|
||||
(dash.Length > 0 ? "<script>" + dash + "</script>" : "") +
|
||||
"<script>(function(){var v=document.getElementById('v');" + loader +
|
||||
"v.addEventListener('ended',function(){try{window.chrome.webview.postMessage({vdEnded:true});}catch(e){}});" +
|
||||
"v.addEventListener('error',function(){try{window.chrome.webview.postMessage({vdEnded:true});}catch(e){}});})();" +
|
||||
"</script></body></html>";
|
||||
return html;
|
||||
}
|
||||
|
||||
private static string ReadResource(string name)
|
||||
{
|
||||
var stream = Assembly.GetExecutingAssembly().GetManifestResourceStream(name);
|
||||
return stream == null ? "" : new StreamReader(stream).ReadToEnd();
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue