PlaylistView() => _playlist.Tracks.ToList();
private void PlaylistSearchBox_TextChanged(object sender, TextChangedEventArgs e)
{
var q = PlaylistSearchBox?.Text?.Trim() ?? "";
var view = CollectionViewSource.GetDefaultView(_playlist.Tracks);
view.Filter = q.Length == 0 ? null
: o => o is MediaTrack t && (t.Title.Contains(q, StringComparison.OrdinalIgnoreCase) ||
t.Url.Contains(q, StringComparison.OrdinalIgnoreCase));
}
/// UC-M11 셔플 재생.
private void PlaylistShuffle_Click(object sender, RoutedEventArgs e)
{
var list = PlaylistView();
if (list.Count == 0) return;
var rng = new Random();
var shuffled = list.OrderBy(_ => rng.Next()).ToList();
_playlist.Clear();
foreach (var t in shuffled) _playlist.Add(t);
SavePlaylist();
OpenPlayer();
}
/// UC-M12 반복 모드 순환(끔 → 전체 → 한곡).
private void PlaylistLoop_Click(object sender, RoutedEventArgs e)
{
_config.PlaylistLoopMode = _config.PlaylistLoopMode switch
{
"none" => "all",
"all" => "one",
_ => "none",
};
ConfigStore.Save(_config);
PlaylistLoop.Content = _config.PlaylistLoopMode switch
{
"all" => "전체 반복",
"one" => "한곡 반복",
_ => "반복 끔",
};
}
private void SavePlaylist()
{
try { _playlist.Save(Path.Combine(AppDataDir, "playlist.json")); } catch { }
}
private void PlaylistImport_Click(object sender, RoutedEventArgs e)
{
var dlg = new OpenFileDialog { Filter = "M3U 재생목록|*.m3u" };
if (dlg.ShowDialog() != true) return;
try
{
foreach (var line in File.ReadAllLines(dlg.FileName))
{
var url = line.Trim();
if (url.Length == 0 || url.StartsWith('#')) continue;
AddToPlaylist(url, GuessTitle(url), "file", null);
}
}
catch (Exception ex) { Log($"재생목록 불러오기 실패: {ex.Message}"); }
}
/// UC-M14 순서 변경.
private void PlaylistMoveUp_Click(object sender, RoutedEventArgs e) => PlaylistMove(sender, -1);
private void PlaylistMoveDown_Click(object sender, RoutedEventArgs e) => PlaylistMove(sender, +1);
private void PlaylistMove(object sender, int delta)
{
if ((sender as FrameworkElement)?.DataContext is not MediaTrack t) return;
var list = PlaylistView();
var idx = list.IndexOf(t);
var next = idx + delta;
if (idx < 0 || next < 0 || next >= list.Count) return;
_playlist.Clear();
list.RemoveAt(idx);
list.Insert(next, t);
foreach (var x in list) _playlist.Add(x);
SavePlaylist();
}
// ---- 즐겨찾기·기록·노트 확장(M16~M22) ----
private void BookmarkSearchBox_TextChanged(object sender, TextChangedEventArgs e)
{
var q = BookmarkSearchBox?.Text?.Trim() ?? "";
var view = CollectionViewSource.GetDefaultView(_bookmarks);
view.Filter = q.Length == 0 ? null
: o => o is Bookmark b && (b.Title.Contains(q, StringComparison.OrdinalIgnoreCase) ||
b.Url.Contains(q, StringComparison.OrdinalIgnoreCase));
}
/// UC-K16 즐겨찾기 관리 창.
private void BookmarkManager_Click(object sender, RoutedEventArgs e)
{
BookmarksPopup.IsOpen = false;
new BookmarkManagerWindow(_bookmarks, () => { BrowserStore.SaveBookmarks(_bookmarks.ToList()); RefreshBookmarkBar(); })
{ Owner = this }.ShowDialog();
}
/// UC-M19 브라우저 호환 HTML 즐겨찾기 내보내기.
public string? ExportBookmarks()
{
var dlg = new SaveFileDialog { Filter = "북마크 HTML|*.html", FileName = "bookmarks.html" };
if (dlg.ShowDialog() != true) return null;
var sb = new System.Text.StringBuilder();
sb.AppendLine("");
sb.AppendLine("BookmarksBookmarks
");
foreach (var b in _bookmarks)
sb.AppendLine($"
- {System.Net.WebUtility.HtmlEncode(b.Title)}");
sb.AppendLine("
");
File.WriteAllText(dlg.FileName, sb.ToString());
_toast?.Show("즐겨찾기를 내보냈습니다", "success");
return dlg.FileName;
}
private string? Prompt(string title, string label, string initial)
{
var box = new TextBox
{
Text = initial,
Style = (Style)FindResource("DarkTextBox"),
Margin = new Thickness(0, 6, 0, 12),
};
var win = new Window
{
Title = title,
Width = 440,
SizeToContent = SizeToContent.Height,
WindowStartupLocation = WindowStartupLocation.CenterOwner,
Owner = this,
Background = (Brush)FindResource("WindowBg"),
};
var cancel = new Button { Content = "취소", Style = (Style)FindResource("GhostButton"), Width = 80, Margin = new Thickness(0, 0, 8, 0) };
var ok = new Button { Content = "확인", Style = (Style)FindResource("PrimaryButton"), Width = 80, IsDefault = true };
cancel.Click += (_, _) => win.DialogResult = false;
ok.Click += (_, _) => win.DialogResult = true;
win.Content = new StackPanel { Margin = new Thickness(16) }.Also(p =>
{
p.Children.Add(new TextBlock { Text = label, Foreground = (Brush)FindResource("TextSecondary") });
p.Children.Add(box);
p.Children.Add(new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Right, Children = { cancel, ok } });
});
return win.ShowDialog() == true ? box.Text : null;
}
/// UC-M20 방문 기록 날짜별 묶음 뷰 — 같은 날 첫 항목에만 헤더 표시.
private List GroupHistoryByDate(IEnumerable history)
{
var rows = new List();
foreach (var g in history.GroupBy(h => h.VisitedAt.Date))
{
var key = g.Key == DateTime.Today ? "오늘"
: g.Key == DateTime.Today.AddDays(-1) ? "어제"
: g.Key.ToString("yyyy-MM-dd ddd");
var first = true;
foreach (var h in g)
{
rows.Add(new HistoryRow(h.Title, h.Url, h.VisitedAt, key, first));
first = false;
}
}
return rows;
}
/// 기록 행 뷰(날짜 묶음 헤더 표시용).
public sealed record HistoryRow(string Title, string Url, DateTime VisitedAt, string GroupKey, bool ShowGroupKey);
private void HistorySearchBox_TextChanged(object sender, TextChangedEventArgs e)
{
var q = HistorySearchBox?.Text?.Trim() ?? "";
var view = CollectionViewSource.GetDefaultView(HistoryList.ItemsSource);
if (view == null) return;
view.Filter = q.Length == 0 ? null
: o => o is HistoryRow r && (r.Title.Contains(q, StringComparison.OrdinalIgnoreCase) ||
r.Url.Contains(q, StringComparison.OrdinalIgnoreCase));
}
/// UC-K17 이 항목만 기록에서 삭제.
private void DeleteHistory_Click(object sender, RoutedEventArgs e)
{
if ((sender as FrameworkElement)?.DataContext is not HistoryRow row) return;
var src = _history.FirstOrDefault(h => h.Url == row.Url);
if (src != null) _history.Remove(src);
BrowserStore.SaveHistory(_history.ToList());
HistoryList.ItemsSource = GroupHistoryByDate(_history);
}
private void NotesSearchBox_TextChanged(object sender, TextChangedEventArgs e)
{
var q = NotesSearchBox?.Text?.Trim() ?? "";
var view = CollectionViewSource.GetDefaultView(NotesList.ItemsSource);
if (view == null) return;
view.Filter = q.Length == 0 ? null
: o => o is string s && s.Contains(q, StringComparison.OrdinalIgnoreCase);
}
// ==================== §H 인터랙션 ====================
/// UC-K10 Esc 로 열린 모든 팝업 일괄 닫기.
private void CloseAllPopups()
{
foreach (var p in new Popup[]
{
BookmarksPopup, HistoryPopup, AddressPopup, NotesPopup, ShieldPopup, DownloadsPopup,
TabSearchPopup, PalettePopup, PlaylistPopup, LinkPopup, LibraryPopup, ArchivePopup,
ShortcutHelpPopup, NotificationCenterPopup, TabVolumePopup,
})
{
try { p.IsOpen = false; } catch { }
}
if (FindBar.Visibility == Visibility.Visible) CloseFind();
if (ErrorBanner.Visibility == Visibility.Visible) ErrorBanner.Visibility = Visibility.Collapsed;
}
/// UC-K12 F12 개발자 도구.
private void OpenDevTools()
{
try { Cv?.OpenDevToolsWindow(); } catch { Log("개발자 도구를 열 수 없습니다"); }
}
/// UC-K13 프로필(세션) 전환 — 별도 WebView2 프로필 폴더로 재시작.
private void SwitchProfile()
{
var next = Environment.GetEnvironmentVariable("VD_PROFILE") == "2" ? null : "2";
var msg = next == "2"
? "프로필 2(새 세션)로 전환합니다.\n앱이 재시작되며 현재 탭은 닫힙니다."
: "기본 프로필로 전환합니다.\n앱이 재시작되며 현재 탭은 닫힙니다.";
if (MessageBox.Show(this, msg + "\n\n계속할까요?", "프로필 전환", MessageBoxButton.OKCancel, MessageBoxImage.Question) != MessageBoxResult.OK)
return;
Environment.SetEnvironmentVariable("VD_PROFILE", next, EnvironmentVariableTarget.User);
RestartApp();
}
/// 프로필 전환용 폴더(ProfileFolder 확장).
internal static string ProfileFolderFor(string? profile) =>
profile == "2" ? Path.Combine(AppDataDir, "webview2-profile2") : ProfileFolder;
/// UC-K18 화면 영역 지정 캡처 — 전체 화면 캡처 후 사용자가 드래그한 영역을 잘라 저장.
private async Task RegionCapture()
{
try
{
var sw = System.Diagnostics.Stopwatch.StartNew();
// 1) 현재 모니터 캡처(TakeScreenshot 와 동일 경로 — DataStream 캡처)
var tab = _tabs.ActiveTab;
var cv = tab?.Core;
if (cv == null) { Log("캡처할 페이지가 없습니다"); return; }
using var ms = new MemoryStream();
await cv.CapturePreviewAsync(Microsoft.Web.WebView2.Core.CoreWebView2CapturePreviewImageFormat.Png, ms);
ms.Position = 0;
var full = System.Windows.Media.Imaging.BitmapFrame.Create(ms,
System.Windows.Media.Imaging.BitmapCreateOptions.None, System.Windows.Media.Imaging.BitmapCacheOption.OnLoad);
// 2) 반투명 오버레이로 영역 선택
var overlay = new Window
{
WindowStyle = WindowStyle.None,
AllowsTransparency = true,
Background = new SolidColorBrush(Color.FromArgb(0x40, 0, 0, 0)),
WindowState = WindowState.Maximized,
Topmost = true,
ShowInTaskbar = false,
};
var canvas = new Canvas();
var rect = new System.Windows.Shapes.Rectangle
{
Stroke = (Brush)FindResource("AccentBrush"),
StrokeThickness = 2,
StrokeDashArray = new DoubleCollection { 4, 2 },
};
canvas.Children.Add(rect);
overlay.Content = canvas;
System.Windows.Point? start = null;
canvas.MouseLeftButtonDown += (_, me) => { start = me.GetPosition(overlay); rect.StrokeDashArray = null; };
canvas.MouseMove += (_, me) =>
{
if (start is { } s)
{
var p = me.GetPosition(overlay);
Canvas.SetLeft(rect, Math.Min(s.X, p.X));
Canvas.SetTop(rect, Math.Min(s.Y, p.Y));
rect.Width = Math.Abs(p.X - s.X);
rect.Height = Math.Abs(p.Y - s.Y);
}
};
var tcs = new TaskCompletionSource();
canvas.MouseLeftButtonUp += (_, me) =>
{
if (start is { } s)
{
var p = me.GetPosition(overlay);
var r = new Rect(s, p);
tcs.TrySetResult(r.Width > 4 && r.Height > 4 ? r : null);
}
else tcs.TrySetResult(null);
};
canvas.MouseRightButtonDown += (_, _) => tcs.TrySetResult(null);
overlay.KeyDown += (_, ke) => { if (ke.Key == Key.Escape) tcs.TrySetResult(null); };
overlay.Show();
var area = await tcs.Task;
overlay.Close();
if (area == null) { Log("영역 캡처 취소"); return; }
// 3) 영역 크롭(DPI 고려) 저장
var dpi = System.Windows.Media.VisualTreeHelper.GetDpi(this);
var src = new System.Windows.Media.Imaging.CroppedBitmap(full,
new System.Windows.Int32Rect(
(int)(area.Value.X * dpi.DpiScaleX), (int)(area.Value.Y * dpi.DpiScaleY),
Math.Max(1, (int)(area.Value.Width * dpi.DpiScaleX)), Math.Max(1, (int)(area.Value.Height * dpi.DpiScaleY))));
var file = Path.Combine(_config.OutputFolder, $"capture-{DateTime.Now:yyyyMMdd-HHmmss}.png");
using var fs = File.Create(file);
System.Windows.Media.Imaging.BitmapEncoder enc = new System.Windows.Media.Imaging.PngBitmapEncoder();
enc.Frames.Add(System.Windows.Media.Imaging.BitmapFrame.Create(src));
enc.Save(fs);
Log($"영역 캡처 저장: {file} ({sw.ElapsedMilliseconds}ms)");
}
catch (Exception ex) { Log($"영역 캡처 실패: {ex.Message}"); }
}
/// UC-K07 주소창 컨텍스트 "붙여넣고 이동".
private void PasteAndGo_Click(object sender, RoutedEventArgs e)
{
try
{
var text = Clipboard.GetText().Trim();
if (text.Length > 0) NavigateTo(text);
}
catch { }
}
/// UC-K05 마우스 제스처 — XButton1(측면 뒤로)/XButton2(앞으로) 로 탐색.
private void Window_PreviewMouseDown(object sender, MouseButtonEventArgs e)
{
var cv = Cv;
if (cv == null) return;
switch (e.ChangedButton)
{
case MouseButton.XButton1:
try { if (cv.CanGoBack) cv.GoBack(); } catch { }
e.Handled = true;
break;
case MouseButton.XButton2:
try { if (cv.CanGoForward) cv.GoForward(); } catch { }
e.Handled = true;
break;
}
}
/// UC-K06 휠 클릭(가운데 버튼) 링크 → 새 탭. 페이지에 스크립트를 심어
/// auxclick(middle) 이벤트를 window.chrome.webmessage 로 전달받는다.
internal void WireTabMouseGestures(Microsoft.Web.WebView2.Core.CoreWebView2 cv)
{
const string script = """
(function(){
if (window.__vdGesture) return; window.__vdGesture = true;
document.addEventListener('auxclick', function(ev){
if (ev.button !== 1) return;
var a = ev.target && ev.target.closest ? ev.target.closest('a[href]') : null;
if (!a) return;
ev.preventDefault();
window.chrome.webview.postMessage({ __vd:'middleclick', url: a.href });
}, true);
})();
""";
try { _ = cv.AddScriptToExecuteOnDocumentCreatedAsync(script); } catch { }
cv.WebMessageReceived += (s, we) =>
{
try
{
var json = System.Text.Json.JsonDocument.Parse(we.WebMessageAsJson).RootElement;
if (json.TryGetProperty("__vd", out var kind) && kind.GetString() == "middleclick" &&
json.TryGetProperty("url", out var url) && url.GetString() is { Length: > 0 } link)
{
Dispatcher.BeginInvoke(new Action(() =>
{
var tab = _tabs.NewTab(activate: false);
Cv2Navigate(tab, link);
Log($"휠 클릭으로 새 탭: {Trunc(link, 60)}");
}));
}
}
catch { }
};
}
// ---- 탭 드래그 재정렬 시작(템플릿 TabBg.MouseMove) ----
private void TabBg_MouseMove(object sender, MouseEventArgs e)
{
if (sender is not FrameworkElement fe || _dragTab != null) return;
if (e.LeftButton != MouseButtonState.Pressed || fe.DataContext is not TabItem tab) return;
_dragTab = tab;
try { DragDrop.DoDragDrop(fe, new DataObject(typeof(TabItem), tab), DragDropEffects.Move); }
catch { _dragTab = null; }
}
/// UC-K08 주소창 포커스 시 전체 선택(설정 켜져 있을 때).
private void AddressBox_GotKeyboardFocus(object sender, KeyboardFocusChangedEventArgs e)
{
if (_config.FocusSelectsAddress) AddressBox.SelectAll();
}
private void ZoomIndicator_Click(object sender, RoutedEventArgs e)
{
ZoomSet(1.0);
ZoomIndicator.Content = "100%";
}
// ==================== §J 상태·알림 ====================
/// 상태바 집계 갱신 — 통합 진행률·요약·전체 속도·스파크라인·배지.
private void UpdateStatusAggregate()
{
try
{
var items = _manager.Items.ToList();
var active = items.Where(i => i.Status is "다운로드 중" or "병합 중" or "리먹스 중").ToList();
var pending = items.Count(i => i.Status == "대기");
var failed = items.Count(i => i.Status == "오류");
var done = items.Count(i => i.Status == "완료");
QueueSummaryText.Text = items.Count == 0 ? "" : $"{pending} 대기 · {active.Count} 진행 · {done} 완료{(failed > 0 ? $" · {failed} 실패" : "")}";
DownloadsBtnBadge.Visibility = active.Count > 0 ? Visibility.Visible : Visibility.Collapsed;
DownloadsBtnBadgeText.Text = active.Count.ToString();
var avg = active.Count == 0 ? 0 : (int)active.Average(i => i.Progress);
AggregateProgress.Value = active.Count > 0 ? avg : (done > 0 ? 100 : 0);
var bps = _pageDownloads.Items.Sum(i => i.BytesPerSecond);
GlobalSpeedText.Text = bps <= 0 ? "" : bps < 1024 * 1024 ? $"{bps / 1024:F0} KB/s" : $"{bps / 1024.0 / 1024:F1} MB/s";
_speedHistory.Enqueue(bps);
while (_speedHistory.Count > 10) _speedHistory.Dequeue();
var max = Math.Max(1024, _speedHistory.Max());
SpeedSparkline.Points = new PointCollection(
_speedHistory.Select((v, i) => new Point(i * 12, 12 - Math.Clamp(v * 12.0 / max, 0, 12))));
RefreshTabBadges(); // UC-B07 탭 번호 배지
// UC-D08 완료 항목 자동 치움(완료 후 5초)
if (_config.AutoClearCompleted)
foreach (var gone in items.Where(i => i.CompletedAt is { } at && (DateTime.UtcNow - at).TotalSeconds > 5).ToList())
_manager.Items.Remove(gone);
// UC-L15 상태바 자동 숨김 — 마우스가 올라와 있지 않고 활성 작업도 없으면 접는다
if (_config.StatusBarAutoHide && StatusBar != null)
StatusBar.Visibility = StatusBar.IsMouseOver || active.Count > 0
? Visibility.Visible : Visibility.Collapsed;
}
catch { }
}
/// 완료/오류를 토스트로 알림(DownloadManager 이벤트에서 호출).
internal void NotifyDownloadCompleted(DownloadItemViewModel it)
{
_toast?.Show($"완료: {Trunc(it.Title, 40)}", "success");
if (_config.NotifySound && !_config.DoNotDisturb)
try { System.Media.SystemSounds.Asterisk.Play(); } catch { }
if (_config.OpenFolderOnComplete) OpenInExplorer(it.OutputPath);
}
internal void NotifyDownloadFailed(DownloadItemViewModel it)
=> _toast?.Show($"실패: {Trunc(it.Title, 40)}", "error");
private void ErrorBanner_Close(object sender, RoutedEventArgs e) => ErrorBanner.Visibility = Visibility.Collapsed;
/// 치명 오류 배너 표시(스택 포함).
internal void ShowErrorBanner(string message, Exception? ex = null)
{
ErrorDetailText.Text = ex == null ? message : $"{message}\r\n{ex}";
ErrorBanner.Visibility = Visibility.Visible;
}
private void Onboarding_Close(object sender, RoutedEventArgs e) => Onboarding.Visibility = Visibility.Collapsed;
/// UC-N06 클립보드에 미디어/소셜 URL 이 있으면 받기 제안 배지.
private void CheckClipboardHint()
{
try
{
if (Keyboard.FocusedElement is TextBox || LinkPopup.IsOpen) return;
var text = Clipboard.ContainsText() ? Clipboard.GetText().Trim() : "";
bool isUrl = text.StartsWith("http", StringComparison.OrdinalIgnoreCase) && Uri.TryCreate(text, UriKind.Absolute, out _);
var hintUrl = isUrl ? text : null;
if (hintUrl == _lastClipboardHintUrl) return;
_lastClipboardHintUrl = hintUrl;
ClipboardHint.Visibility = hintUrl != null && text.Length < 2048 ? Visibility.Visible : Visibility.Collapsed;
}
catch { }
}
private void ClipboardHint_Click(object sender, RoutedEventArgs e)
{
ClipboardHint.Visibility = Visibility.Collapsed;
if (_lastClipboardHintUrl == null) return;
AddressBox.Text = _lastClipboardHintUrl;
NavigateTo(_lastClipboardHintUrl);
}
private void OnNetworkAvailabilityChanged(object? sender, System.Net.NetworkInformation.NetworkAvailabilityEventArgs e)
{
Dispatcher.BeginInvoke(new Action(() =>
{
ServerStatusIcon.ToolTip = e.IsAvailable ? "네트워크 연결됨" : "네트워크 끊김 — 오프라인";
ServerStatusIcon.Foreground = e.IsAvailable
? (Brush)FindResource("SuccessBrush")
: (Brush)FindResource("ErrorBrush");
if (!e.IsAvailable) Log("네트워크 연결이 끊겼습니다");
}));
}
private void PairingBtn_Click(object sender, RoutedEventArgs e) => ShowPairing();
/// 서버 상태 아이콘 갱신(원격 접근 토글 후 호출).
private void UpdateServerStatusIcon()
{
Dispatcher.BeginInvoke(new Action(() =>
{
ServerStatusIcon.Foreground = _remoteServer != null
? (Brush)FindResource("SuccessBrush")
: (Brush)FindResource("TextMuted");
ServerStatusIcon.ToolTip = _remoteServer != null
? $"원격 접속 켜짐 — 포트 {_remoteServer.Port}"
: "원격 접속 꺼짐";
}));
}
private void NotificationCenter_Click(object sender, RoutedEventArgs e)
{
NotificationCenterList.ItemsSource = _toast?.History.ToList() ?? new List();
NotificationCenterPopup.IsOpen = true;
}
private void ShortcutHelpBtn_Click(object sender, RoutedEventArgs e) => ShortcutHelpPopup.IsOpen = true;
/// 단축키 도움말 목록 갱신(사용자 재배치 반영).
private void UpdateShortcutHelp()
{
var labels = new Dictionary
{
["newtab"] = "새 탭", ["closetab"] = "탭 닫기", ["reopentab"] = "닫은 탭 복원",
["find"] = "페이지 내 검색", ["address"] = "주소창 포커스", ["downloads"] = "다운로드 패널",
["palette"] = "명령 팔레트", ["devtools"] = "개발자 도구", ["fullscreen"] = "전체화면",
["refresh"] = "새로고침", ["print"] = "인쇄", ["pauseall"] = "모두 일시정지",
};
ShortcutHelpList.ItemsSource = _shortcuts.All()
.Where(b => labels.ContainsKey(b.Command))
.Select(b => new { Gesture = b.Gesture, Label = labels.GetValueOrDefault(b.Command, b.Command) })
.ToList();
}
}