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:
Yun Chan 2026-08-15 15:25:10 +09:00
parent 406957088f
commit 40c1b39b70
8 changed files with 491 additions and 0 deletions

File diff suppressed because one or more lines are too long

2
VideoDownloader.App/Assets/hls.min.js vendored Normal file

File diff suppressed because one or more lines are too long

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

View 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("&", "&amp;").Replace("\"", "&quot;").Replace("<", "&lt;");
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();
}
}

View file

@ -191,6 +191,7 @@
<Button Name="HistoryBtn" Style="{StaticResource IconButton}" Content="&#xE823;" ToolTip="방문 기록" Margin="0,0,2,0" Click="ToggleHistory"/>
<Button Name="TabSearchBtn" Style="{StaticResource IconButton}" Content="&#xE721;" ToolTip="탭 검색 (Ctrl+Shift+A)" Margin="0,0,2,0" Click="ToggleTabSearch"/>
<Button Name="DownloadsBtn" Style="{StaticResource IconButton}" Content="&#xE896;" ToolTip="다운로드 (Ctrl+J)" Margin="0,0,2,0" Click="ToggleDownloads"/>
<Button Name="PlaylistBtn" Style="{StaticResource IconButton}" Content="&#xE8D6;" ToolTip="미디어 재생목록" Margin="0,0,2,0" Click="TogglePlaylistPanel"/>
<Button Style="{StaticResource IconButton}" Content="&#xE8B7;" ToolTip="저장 폴더 열기" Margin="0,0,2,0" Click="OpenFolder_Click"/>
<Button Name="LinkBtn" Style="{StaticResource IconButton}" Content="&#xE71B;" ToolTip="링크로 받기 (YouTube·Instagram·X)" Margin="0,0,2,0" Click="ToggleLink"/>
<Button x:Name="PrivateBtn" Style="{StaticResource IconButton}" Content="&#xE72E;" ToolTip="개인정보 보호 모드(InPrivate)" Margin="0,0,2,0" Click="TogglePrivate_Click"/>
@ -633,6 +634,50 @@
</Border>
</Popup>
<!-- ===== 미디어 재생목록 플라이아웃 (T2-10) ===== -->
<Popup Name="PlaylistPopup" PlacementTarget="{Binding ElementName=PlaylistBtn}"
Placement="Bottom" HorizontalOffset="-10" VerticalOffset="6"
StaysOpen="False" AllowsTransparency="True" PopupAnimation="Fade">
<Border Background="{StaticResource CardBg}" BorderBrush="{StaticResource BorderBrush}"
BorderThickness="1" CornerRadius="12" Padding="10" Width="440" MaxHeight="470">
<StackPanel>
<DockPanel Margin="6,2,6,8">
<TextBlock Text="미디어 재생목록" Foreground="{StaticResource TextPrimary}" FontWeight="SemiBold" VerticalAlignment="Center"/>
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal" HorizontalAlignment="Right">
<Button Style="{StaticResource CardActionButton}" Content="▶ 재생" Click="PlaylistPlayAll_Click" Margin="0,0,5,0"/>
<Button Style="{StaticResource CardActionButton}" Content="비우기" Click="PlaylistClear_Click"/>
</StackPanel>
</DockPanel>
<ScrollViewer VerticalScrollBarVisibility="Auto" MaxHeight="380">
<ItemsControl Name="PlaylistList">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Background="{StaticResource GlassBg}" BorderBrush="{StaticResource BorderSubtle}"
BorderThickness="1" CornerRadius="8" Padding="10" Margin="0,0,0,7">
<DockPanel>
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal">
<Button Style="{StaticResource CardActionButton}" Content="▶" Click="PlaylistPlay_Click" Margin="0,0,5,0"/>
<Button Style="{StaticResource CardActionButton}" Content="✕" Click="PlaylistRemove_Click"/>
</StackPanel>
<StackPanel>
<TextBlock Text="{Binding Title}" Foreground="{StaticResource TextPrimary}" FontSize="12.5"
FontWeight="Medium" TextTrimming="CharacterEllipsis"/>
<TextBlock Foreground="{StaticResource TextMuted}" FontSize="10.5" TextTrimming="CharacterEllipsis">
<Run Text="{Binding Kind}"/><Run Text=" · "/><Run Text="{Binding AddedAt, StringFormat=MM-dd HH:mm}"/>
</TextBlock>
</StackPanel>
</DockPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<TextBlock x:Name="PlaylistEmptyHint" Text="영상을 재생하면 자동으로 쌓입니다" Foreground="{StaticResource TextMuted}"
FontSize="11" HorizontalAlignment="Center" Margin="0,4,0,0"/>
</StackPanel>
</Border>
</Popup>
<!-- ===== 링크로 받기 플라이아웃 (YouTube/Instagram/X URL 붙여넣기) ===== -->
<Popup Name="LinkPopup" PlacementTarget="{Binding ElementName=LinkBtn}"
Placement="Bottom" HorizontalOffset="-10" VerticalOffset="6"

View file

@ -40,6 +40,10 @@ public partial class MainWindow : Window
private readonly ObservableCollection<HistoryEntry> _history = new();
private bool _suggesting; // 주소창 인라인 자동완성 도중 (TextChanged 재귀 방지)
// Phase D: 미디어 재생목록 (T2-10)
private readonly MediaPlaylist _playlist = new();
private PlayerWindow? _player;
// Phase C: 팔레트/부스트/워크스페이스/레이아웃
private readonly CommandPalette _palette = new();
private BoostStore _boostStore = null!;
@ -101,6 +105,7 @@ public partial class MainWindow : Window
_workspaces = System.Text.Json.JsonSerializer.Deserialize<List<WorkspaceSnapshot>>(File.ReadAllText(wsPath)) ?? new();
}
catch { }
_playlist.Load(Path.Combine(AppDataDir, "playlist.json"));
BuildPalette();
Loaded += async (_, _) => await InitializeBrowserAsync();
@ -227,6 +232,8 @@ public partial class MainWindow : Window
// T1-5 다운로드 속도 갱신 타이머
DownloadsList.ItemsSource = _pageDownloads.Items;
PlaylistList.ItemsSource = _playlist.Tracks;
PlaylistEmptyHint.Visibility = _playlist.Tracks.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
_speedTimer = new System.Windows.Threading.DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
_speedTimer.Tick += (_, _) =>
{
@ -451,6 +458,7 @@ public partial class MainWindow : Window
else
{
_manager.LogPanel($"★ 미디어 감지 [{ct}] {FmtLen(len)}: {uri}");
AddToPlaylist(uri, GuessTitle(uri), MediaKind.Direct.ToString(), referer);
_manager.OnDetected(new DetectionContext(uri, referer, MediaKind.Direct, ct));
}
}
@ -870,6 +878,7 @@ public partial class MainWindow : Window
ConfigStore.Save(_config);
// T1-10 세션 저장: 탭 URL/활성/핀 보존
try { if (_tabs != null) TabSessionStore.Save(SessionPath, _tabs.CaptureSession()); } catch { }
try { _playlist.Save(Path.Combine(AppDataDir, "playlist.json")); } catch { } // T2-10
}
// ---- 개인정보 보호 모드 + 브라우저 데이터 관리 ----
@ -1141,6 +1150,8 @@ public partial class MainWindow : Window
_palette.Register("인쇄", "print pdf", () => { try { Cv?.ShowPrintUI(); } catch { } });
_palette.Register("저장 폴더 열기", "folder open output", () => OpenInExplorer(null));
_palette.Register("이 페이지 즐겨찾기", "bookmark add star", () => AddBookmark_Click(this, new RoutedEventArgs()));
_palette.Register("미디어 재생목록", "playlist play media 음악 영상", () => { PlaylistPopup.IsOpen = true; });
_palette.Register("재생목록 재생", "playlist play start", OpenPlayer);
_palette.RegisterSeparator();
_palette.RegisterDynamic(() => _tabs.Tabs
.Select(t => new PaletteCommand($"탭 전환: {Trunc(t.Title, 40)}", "tab go switch", () => _tabs.Activate(t)))
@ -1475,6 +1486,58 @@ public partial class MainWindow : Window
Log($"워크스페이스 '{ws.Name}' 전환 ({ws.Urls.Count} 탭)");
}
// ---- T2-10 미디어 재생목록 ----
private void AddToPlaylist(string url, string title, string kind, string? referer)
{
_playlist.Add(new MediaTrack(url, Trunc(title, 70), kind, DateTime.UtcNow, Referer: referer));
try { _playlist.Save(Path.Combine(AppDataDir, "playlist.json")); } catch { }
PlaylistEmptyHint.Visibility = _playlist.Tracks.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
}
private static string GuessTitle(string url)
{
try
{
var name = Path.GetFileName(new Uri(url).AbsolutePath);
return string.IsNullOrWhiteSpace(name) ? url : Uri.UnescapeDataString(name);
}
catch { return url; }
}
private void TogglePlaylistPanel(object sender, RoutedEventArgs e)
{
PlaylistPopup.IsOpen = !PlaylistPopup.IsOpen;
}
private void PlaylistPlay_Click(object sender, RoutedEventArgs e)
{
if ((sender as FrameworkElement)?.DataContext is MediaTrack t)
{
_playlist.CurrentIndex = _playlist.Tracks.IndexOf(t);
OpenPlayer();
}
}
private void PlaylistRemove_Click(object sender, RoutedEventArgs e)
{
if ((sender as FrameworkElement)?.DataContext is MediaTrack t) _playlist.Remove(t);
}
private void PlaylistClear_Click(object sender, RoutedEventArgs e) => _playlist.Clear();
private void PlaylistPlayAll_Click(object sender, RoutedEventArgs e) => OpenPlayer();
private void OpenPlayer()
{
if (_player == null || _player.IsLoaded == false)
{
_player = new PlayerWindow(_env!, _config, _playlist) { Owner = this };
_player.Closed += (_, _) => _player = null;
_player.Show();
}
else { _player.Activate(); _player.PlayCurrent(); }
}
// ---- 카드 액션 ----
private void CardDownload_Click(object sender, RoutedEventArgs e)
{

View file

@ -42,6 +42,8 @@
<!-- T2-4 리더 모드: Mozilla Readability.js 번들 (MIT) -->
<ItemGroup>
<EmbeddedResource Include="Assets/readability.js" />
<EmbeddedResource Include="Assets/hls.min.js" />
<EmbeddedResource Include="Assets/dash.all.min.js" />
</ItemGroup>
<!-- E2E 테스트(InternalsVisibleTo) -->