UI 복잡 버전 234 RED→GREEN 완료(docs/UI_USECASES.md §A~§J): 라이브 테마 전환(DynamicResource 전환+ThemeResolver+HighContrast 테마, 재시작 없음), 디자인 토큰 체계·WCAG 대비 결함 5건 수정, 설정창 카테고리 내비/검색 재구성+AppConfig 40여 속성, 다운로드 관리자(속도/ETA/세그먼트맵/전체 일시정지-이어받기/CSV 내보내기/예약 게이트/중복 확인), 라이브러리·아카이브·작업공간·재생목록·즐겨찾기 편집 뷰, 접근성(자동화 속성 30여 개·커스텀 피어·docs/ACCESSIBILITY.md), 인터랙션(휠 클릭 새 탭·XButton 탐색·F12·영역 캡처·프로필 전환), 상태/알림(토스트·알림센터·온보딩·CrashReportDialog). 보강 테스트 35건+실창 E2E 4건(StaDispatcher 기반 — 프록시 설정이 기시작 핸들러를 건드리는 크래시 실결함 수정 포함) — 453/453 GREEN. 이전 미커밋 작업(CI 워크플로·Mdns·QueueHub·UpdateChecker·AGENTS/CLAUDE·빌드 스크립트) 일괄 포함

This commit is contained in:
Yun Chan 2026-08-17 15:54:53 +09:00
parent 5a0ce730b2
commit 17f2f91152
88 changed files with 9463 additions and 1127 deletions

View file

@ -77,4 +77,177 @@ public class AppConfig
/// <summary>M0 원격 접근(모바일 연동) 서버 켜기. 토큰은 자동 생성/저장.</summary>
public bool RemoteAccessEnabled { get; set; } = false;
public string? RemoteAccessToken { get; set; }
/// <summary>업데이트 확인 대상 GitHub 저장소("owner/repo") — DEPLOYMENT_PLAN §6 v1. 비면 확인 안 함.</summary>
public string UpdateRepo { get; set; } = "";
// ==================== UI 복잡 버전 (docs/UI_USECASES.md) ====================
// ---- §A 테마 ----
/// <summary>사용자 지정 강조색(#RRGGBB). null/빈이면 테마 기본 보라.</summary>
public string? AccentColor { get; set; }
/// <summary>사용자 테마 파일(.xaml 리소스사전) 경로. 설정에 있으면 우선 적용.</summary>
public string? CustomThemePath { get; set; }
// ---- §B 타이포그래피 ----
/// <summary>UI 기본 폰트(빈 문자열이면 기본 스택 Segoe UI+맑은고딕).</summary>
public string UiFontFamily { get; set; } = "";
/// <summary>UI 기본 폰트 크기(pt 단위가 아닌 WPF Device Independent px).</summary>
public double UiFontSize { get; set; } = 13;
/// <summary>한국어 폴백 폰트(기본 '맑은 고딕').</summary>
public string UiFontKoreanFallback { get; set; } = "Malgun Gothic";
// ---- §C 레이아웃·밀도·창 상태 ----
/// <summary>정보 밀도: compact / normal / comfortable.</summary>
public string UiDensity { get; set; } = "normal";
/// <summary>T3-13 컴팩트 모드(크롬 숨김) 시작 시 복원.</summary>
public bool CompactMode { get; set; } = false;
/// <summary>세로 탭을 기본 레이아웃으로 시작.</summary>
public bool VerticalTabsDefault { get; set; } = false;
/// <summary>분할 화면(스플릿) 패널 비율(0~1). 재시작 후 재현.</summary>
public double SplitViewRatio { get; set; } = 0.5;
/// <summary>창 위치·크기 "Left,Top,Width,Height". 화면 밤새 좌표는 무시됨.</summary>
public string? WindowBounds { get; set; }
/// <summary>종료 시 최대화 상태였는지.</summary>
public bool WindowMaximized { get; set; } = false;
/// <summary>사용 중인 상태바는 자동으로 숨김.</summary>
public bool StatusBarAutoHide { get; set; } = false;
/// <summary>툴바에 남길 버튼 키 목록(빈 목록=전부 표시). 예: back,forward,bookmarks,history,downloads</summary>
public List<string> ToolbarButtons { get; set; } = new();
/// <summary>북마크바 표시 여부(세션 간 유지).</summary>
public bool BookmarkBarVisible { get; set; } = false;
/// <summary>탭 스트립 위치: top / bottom / left.</summary>
public string TabStripPosition { get; set; } = "top";
/// <summary>우측 패널 최소 폭(px).</summary>
public double RightPanelMinWidth { get; set; } = 280;
/// <summary>움직임 민감 사용자용 애니메이션 축소.</summary>
public bool ReduceMotion { get; set; } = false;
/// <summary>컴팩트 모드 엣지 호버 반응 지연(ms).</summary>
public int EdgeRevealDelayMs { get; set; } = 200;
/// <summary>UI 전체 배율(레이아웃 루트 ScaleTransform, 0.5~2.0).</summary>
public double UiScale { get; set; } = 1.0;
/// <summary>종료 시 전체화면이었는지.</summary>
public bool FullScreen { get; set; } = false;
// ---- §D 탭 ----
/// <summary>여러 탭을 한 번에 닫을 때 확인 받기.</summary>
public bool ConfirmCloseMultipleTabs { get; set; } = true;
/// <summary>탭 스트립에서 휠로 탭 전환.</summary>
public bool TabWheelSwitch { get; set; } = true;
/// <summary>탭 최대 개수(0=제한 없음).</summary>
public int MaxTabs { get; set; } = 0;
/// <summary>세션 복원 시 탭을 잠자기 상태로만 복원(빠른 시작).</summary>
public bool RestoreTabsSuspended { get; set; } = false;
/// <summary>작업공간 표시 이름(인덱스 순).</summary>
public List<string> WorkspaceNames { get; set; } = new() { "작업", "개인", "학습" };
/// <summary>탭 제목 두 줄 표시.</summary>
public bool TabTitleMultiline { get; set; } = false;
// ---- §E 다운로드 ----
/// <summary>다운로드 목록 정렬: newest / largest / progress / title.</summary>
public string DownloadSortOrder { get; set; } = "newest";
/// <summary>완료 항목을 n초 후 목록에서 자동 제거(0=자동 치움 안 함).</summary>
public bool AutoClearCompleted { get; set; } = false;
/// <summary>다운로드 완료 알림 소리.</summary>
public bool NotifySound { get; set; } = true;
/// <summary>다운로드 완료 시 저장 폴더 자동 열기.</summary>
public bool OpenFolderOnComplete { get; set; } = false;
/// <summary>자막 우선 언어(ko/en/…, 빈 문자열=자막 안 받음).</summary>
public string SubtitleLanguage { get; set; } = "";
/// <summary>UC-D21 다운로드 예약 창("HH:mm-HH:mm", 빈 값=항상 허용).</summary>
public string? DownloadScheduleWindow { get; set; }
/// <summary>다운로드 속도 상한(kbps, 0=제한 없음).</summary>
public int MaxDownloadSpeedKbps { get; set; } = 0;
// ---- §F 설정창 ----
/// <summary>설정창 위치·크기 "Left,Top,Width,Height".</summary>
public string? SettingsWindowBounds { get; set; }
/// <summary>UI 언어: ko / en.</summary>
public string Language { get; set; } = "ko";
/// <summary>닫기 버튼으로 트레이로 최소화.</summary>
public bool MinimizeToTray { get; set; } = false;
/// <summary>Windows 시작 시 자동 실행.</summary>
public bool AutoStart { get; set; } = false;
/// <summary>재시도 횟수(UI 노출용 — Retries 와 동일 값을 설정창에서 편집).</summary>
public int RetriesUi { get => Retries; set => Retries = value; }
/// <summary>HTTP 프록시 서버(http://host:port, 빈 문자열=직접 연결).</summary>
public string ProxyServer { get; set; } = "";
// ---- §G 접근성 ----
/// <summary>저시력용 폰트 확대 배율(1.0~2.0). UiFontSize 와 곱해 적용.</summary>
public double UiFontScale { get; set; } = 1.0;
/// <summary>영상 자막 표시(플레이어에 자막 렌더 시도).</summary>
public bool ShowCaptions { get; set; } = true;
// ---- §H 인터랙션 ----
/// <summary>명령 키 → 조합 문자열(예: "newtab": "Ctrl+T"). 없으면 기본값.</summary>
public Dictionary<string, string> Shortcuts { get; set; } = new();
/// <summary>주소창에 붙여넣으면 즉시 이동.</summary>
public bool PasteAndGo { get; set; } = true;
/// <summary>주소창 포커스 시 전체 선택.</summary>
public bool FocusSelectsAddress { get; set; } = true;
/// <summary>새 탭 기본 줌 배율(0.1~5.0).</summary>
public double DefaultZoomFactor { get; set; } = 1.0;
// ---- §I 라이브러리·재생목록 ----
/// <summary>재생목록 반복 모드: none / one / all.</summary>
public string PlaylistLoopMode { get; set; } = "none";
// ---- §J 상태·알림 ----
/// <summary>토스트 표시 시간(ms).</summary>
public int ToastDurationMs { get; set; } = 4000;
/// <summary>절전 모드(전원 절약) 진입 시 다운로드 일시정지.</summary>
public bool PauseOnPowerSaver { get; set; } = true;
/// <summary>방해 금지(알림 음소거·토스트 억제).</summary>
public bool DoNotDisturb { get; set; } = false;
/// <summary>UC-S04 모든 설정을 새 인스턴스 기본값으로 되돌린다(원격 접속 토큰은 유지 — 기기 페어링 유지).</summary>
public void ResetToDefaults()
{
var fresh = new AppConfig();
foreach (var prop in typeof(AppConfig).GetProperties().Where(p => p.CanRead && p.CanWrite))
{
if (prop.Name == nameof(RemoteAccessToken)) continue;
try { prop.SetValue(this, prop.GetValue(fresh)); } catch { }
}
}
}

View file

@ -83,7 +83,8 @@ public class DirectDownloader
string detail = total > 0
? $"{written / 1048576.0:F1}/{total / 1048576.0:F1} MB"
: $"{written / 1048576.0:F1} MB";
progress?.Report(new DownloadProgress(0, 0, "downloading", pct, detail));
progress?.Report(new DownloadProgress(0, 0, "downloading", pct, detail,
ReceivedBytes: written, TotalBytes: total));
}
await fs.FlushAsync(ct);
return Finalize(part, outputBase, ext);

View file

@ -1,7 +1,8 @@
namespace VideoDownloader.Core.Models;
/// <summary>다운로드 진행 상태. Stage: downloading / merging / remuxing / done</summary>
public record DownloadProgress(int Completed, int Total, string Stage, double OverridePercent = -1, string? Detail = null)
public record DownloadProgress(int Completed, int Total, string Stage, double OverridePercent = -1, string? Detail = null,
long ReceivedBytes = -1, long TotalBytes = -1)
{
// HLS: Completed/Total 로 계산 / 직링크: OverridePercent 사용
public double Percent => OverridePercent >= 0 ? OverridePercent : (Total > 0 ? Completed * 100.0 / Total : 0);

View file

@ -41,7 +41,7 @@ public class YtDlpDownloader
void ReportStatus(string msg) => progress?.Report(new DownloadProgress(0, 0, "downloading", -1, msg));
await DenoRunner.EnsureInstalledAsync(_config, ReportStatus, ct);
var args = BuildArgs(pageUrl, outputBase, fmt, cookiesPath, ffmpegDir, _config.YoutubePlayerClients);
var args = BuildArgs(pageUrl, outputBase, fmt, cookiesPath, ffmpegDir, _config.YoutubePlayerClients, _config.SubtitleLanguage);
// yt-dlp 실행 + 진행률 파싱. 갱신 후 재시도 시 동일 콜백을 재사용하므로 로컬 함수로 캡슐화.
// (remuxing 은 실행마다 리셋 — 갱신 후 재시도에선 병합 단계가 처음부터 다시 시작될 수 있음.)
@ -125,7 +125,7 @@ public class YtDlpDownloader
}
private static List<string> BuildArgs(Uri pageUrl, string outputBase, FormatOption fmt,
string? cookiesPath, string? ffmpegDir, string? playerClients)
string? cookiesPath, string? ffmpegDir, string? playerClients, string? subtitleLang = null)
{
var args = new List<string>
{
@ -152,6 +152,9 @@ public class YtDlpDownloader
// YouTube n 챌린지(플레이어 서명 스로틀 파라미터 난독화) 해석 활성화. 없으면 포맷이 통째로 드랍되어
// "Only images are available" 로 MP4 다운로드 실패하거나, 살아남아도 수백 KiB/s 로 심하게 스로틀됨.
AddRemoteComponentsArgs(args);
// UC-D20 자막 함께 받기 — 지정 언어가 있으면 다운로드+임베드
if (!string.IsNullOrWhiteSpace(subtitleLang))
args.AddRange(new[] { "--write-subs", "--sub-langs", subtitleLang, "--embed-subs" });
args.AddRange(new[] { "-o", outputBase + ".%(ext)s" });
args.Add(pageUrl.ToString());
return args;

View file

@ -0,0 +1,84 @@
using System.Text.Json;
namespace VideoDownloader.Core.Update;
/// <summary>GitHub 릴리스의 설치 자산 하나.</summary>
public sealed record UpdateAsset(string Name, string DownloadUrl);
/// <summary>업데이트 확인 결과. LatestVersion 은 태그의 v 접두를 뗀 정규형.</summary>
public sealed record UpdateCheckResult(bool IsNewer, string CurrentVersion, string LatestVersion, string ReleaseUrl, UpdateAsset? Asset);
/// <summary>
/// DEPLOYMENT_PLAN §6 자동 업데이트 v1 — GitHub Releases latest 태그 폴링(무의존·전 OS 공통).
/// 설치를 자동 적용하지 않고 릴리스/자산 URL 만 돌려준다(설치는 사람 관문).
/// </summary>
public sealed class UpdateChecker
{
private readonly HttpClient _http;
public UpdateChecker(HttpMessageHandler? handler = null)
{
_http = handler == null ? new HttpClient() : new HttpClient(handler);
_http.DefaultRequestHeaders.UserAgent.ParseAdd("VideoDownloader-UpdateCheck"); // GitHub API 는 UA 필수
_http.Timeout = TimeSpan.FromSeconds(15);
}
/// <summary>"v1.2.3"/"1.2.3.0" 을 관대하게 비교. 어느 쪽이든 파싱 불가면 false(갱신 아님).</summary>
public static bool IsNewer(string current, string latest) =>
TryParse(current, out var cur) && TryParse(latest, out var lat) && lat > cur;
private static bool TryParse(string s, out Version version)
{
s = s.Trim().TrimStart('v', 'V');
if (!s.Contains('.')) s += ".0"; // Version.TryParse 는 최소 2파트 요구
return Version.TryParse(s, out version!);
}
/// <summary>RID(win-x64 등)에 맞는 설치 자산 선택. "x64" 판정에서 arm64 를 먼저 배제한다.</summary>
public static UpdateAsset? SelectAsset(IEnumerable<UpdateAsset> assets, string rid)
{
var parts = rid.Split('-');
var os = parts[0];
var arch = parts[^1];
foreach (var asset in assets)
{
var name = asset.Name.ToLowerInvariant();
var isArm = name.Contains("arm64");
if (arch == "arm64" ? !isArm : isArm || !name.Contains(arch)) continue;
var osMatch = os switch
{
"win" => name.EndsWith(".exe") || name.EndsWith(".msi"),
"linux" => name.EndsWith(".tar.gz") || name.EndsWith(".appimage") || name.EndsWith(".deb"),
"osx" => name.EndsWith(".dmg"),
_ => false,
};
if (osMatch) return asset;
}
return null;
}
/// <summary>repo("owner/repo")의 latest 릴리스를 조회한다. API 실패는 HttpRequestException 으로 드러난다.</summary>
public async Task<UpdateCheckResult> CheckAsync(string repo, string currentVersion, string rid, CancellationToken ct = default)
{
using var res = await _http.GetAsync($"https://api.github.com/repos/{repo}/releases/latest", ct);
res.EnsureSuccessStatusCode();
using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync(ct));
var root = doc.RootElement;
var tag = root.GetProperty("tag_name").GetString() ?? "";
var releaseUrl = root.TryGetProperty("html_url", out var h) ? h.GetString() ?? "" : "";
var assets = new List<UpdateAsset>();
if (root.TryGetProperty("assets", out var arr) && arr.ValueKind == JsonValueKind.Array)
{
foreach (var a in arr.EnumerateArray())
{
assets.Add(new UpdateAsset(
a.GetProperty("name").GetString() ?? "",
a.GetProperty("browser_download_url").GetString() ?? ""));
}
}
return new UpdateCheckResult(
IsNewer(currentVersion, tag), currentVersion, tag.TrimStart('v', 'V'), releaseUrl, SelectAsset(assets, rid));
}
}