Baseline: WPF/Avalonia/Cli/Core + plans (BROWSER_FEATURES_PLAN v2, MOBILE_PLAN v1)

This commit is contained in:
Yun Chan 2026-08-15 11:59:25 +09:00
commit 21b7c4d8bd
60 changed files with 6581 additions and 0 deletions

View file

@ -0,0 +1,10 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="VideoDownloader.Avalonia.App"
RequestedThemeVariant="Default">
<!-- "Default" ThemeVariant follows system theme variant. "Dark" or "Light" are other available options. -->
<Application.Styles>
<FluentTheme />
</Application.Styles>
</Application>

View file

@ -0,0 +1,23 @@
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
namespace VideoDownloader.Avalonia;
public partial class App : Application
{
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
}
public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.MainWindow = new MainWindow();
}
base.OnFrameworkInitializationCompleted();
}
}

View file

@ -0,0 +1,114 @@
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Windows.Input;
using Avalonia.Threading;
using VideoDownloader.Core.Config;
using VideoDownloader.Core.Dash;
using VideoDownloader.Core.Hls;
using VideoDownloader.Core.Models;
using VideoDownloader.Core.Social;
using VideoDownloader.Core.Util;
namespace VideoDownloader.Avalonia;
/// <summary>Phase 2 Gate #0: 크로스플랫폼 셸의 최소 뷰모델.
/// URL 을 붙여넣으면 유형(HLS/DASH/소셜/직링크) 을 판별해 Core 다운로더를 직접 호출해 진행률을 표시.
/// 브라우저/감지/쿠키(Gate #1) 는 아직 없으므로 DownloadManager 가 아닌 Core 엔진을 직접 사용.</summary>
public class MainViewModel : INotifyPropertyChanged
{
private readonly AppConfig _config = new();
private CancellationTokenSource? _cts;
private string _url = "";
private string _status = "URL 을 붙여넣고 받기를 누르세요";
private double _progress;
private bool _isBusy;
private string _result = "";
public string Url { get => _url; set { _url = value; On(); _downloadCommand.RaiseCanExecuteChanged(); } }
public string Status { get => _status; set { _status = value; On(); } }
public double Progress { get => _progress; set { _progress = value; On(); } }
public bool IsBusy { get => _isBusy; set { _isBusy = value; On(); _downloadCommand.RaiseCanExecuteChanged(); _cancelCommand.RaiseCanExecuteChanged(); } }
public string Result { get => _result; set { _result = value; On(); } }
public ObservableCollection<string> Log { get; } = new();
private readonly RelayCommand _downloadCommand;
private readonly RelayCommand _cancelCommand;
public ICommand DownloadCommand => _downloadCommand;
public ICommand CancelCommand => _cancelCommand;
public MainViewModel()
{
_downloadCommand = new RelayCommand(_ => _ = DownloadAsync(), _ => !IsBusy && !string.IsNullOrWhiteSpace(Url));
_cancelCommand = new RelayCommand(_ => Cancel(), _ => IsBusy);
}
public void Cancel() { try { _cts?.Cancel(); } catch { } Status = "취소 중..."; }
private async Task DownloadAsync()
{
if (!Uri.TryCreate(Url.Trim(), UriKind.Absolute, out var uri))
{ Status = "올바른 URL 이 아닙니다"; return; }
// UI 스레드에서 미리 유형 판별 + 상태 표시(백그라운드에서 INPC 속성을 건드리지 않도록).
var kind = Classify(uri);
IsBusy = true; Result = ""; Progress = 0; Log.Clear();
Status = kind switch { Kind.Social => "yt-dlp 소셜 다운로드", Kind.Hls => "HLS 다운로드", Kind.Dash => "DASH 다운로드", _ => "직접 다운로드" };
_cts = new CancellationTokenSource();
var ct = _cts.Token;
var progress = new Progress<DownloadProgress>(p => Dispatcher.UIThread.Post(() =>
{
if (p.OverridePercent >= 0) Progress = p.OverridePercent;
Status = !string.IsNullOrEmpty(p.Detail) ? p.Detail
: (p.Stage == "downloading" && p.Total > 0 ? $"{p.Completed}/{p.Total}" : p.Stage);
if (p.OverridePercent < 0 && !string.IsNullOrEmpty(p.Detail)) Log.Insert(0, p.Detail);
}));
try
{
var name = FileNameUtil.Sanitize(FileNameUtil.DeriveName(uri));
var outputBase = Path.Combine(_config.OutputFolder, name);
Directory.CreateDirectory(_config.OutputFolder);
string final = await Task.Run(() => DownloadByKindAsync(uri, kind, outputBase, progress, ct), ct);
Progress = 100;
Result = $"완료: {final}";
Status = "완료";
}
catch (OperationCanceledException) { Status = "취소됨"; }
catch (Exception e) { Status = "오류"; Result = $"{e.GetType().Name}: {e.Message}"; }
finally { IsBusy = false; }
}
// 백그라운드 스레드에서 실행. UI 속성 미참조(진행률은 progress 콜백→UI 디스패처로 전달).
private async Task<string> DownloadByKindAsync(Uri uri, Kind kind, string outputBase, IProgress<DownloadProgress> progress, CancellationToken ct)
{
using var http = new HttpClient { Timeout = TimeSpan.FromMinutes(10) };
http.DefaultRequestHeaders.UserAgent.ParseAdd(_config.UserAgent);
return kind switch
{
Kind.Social => await new YtDlpDownloader(_config)
.DownloadAsync(uri, outputBase, new FormatOption("최고 화질 (MP4)", "bv*+ba/b"), null, progress, ct),
Kind.Hls => await new HlsDownloader(http, _config).DownloadAsync(uri, outputBase, progress, ct),
Kind.Dash => await new DashDownloader(http, _config).DownloadAsync(uri, outputBase, progress, ct),
_ => await new DirectDownloader(http, _config).DownloadAsync(uri, outputBase, progress, ct),
};
}
private enum Kind { Social, Hls, Dash, Direct }
private static Kind Classify(Uri uri)
{
string host = uri.Host.ToLowerInvariant(), path = uri.AbsolutePath.ToLowerInvariant();
bool social = host.Contains("youtube") || host.Contains("youtu.be") || host.Contains("instagram")
|| host.Contains("tiktok") || host.Contains("x.com") || host.Contains("twitter") || host.Contains("facebook");
if (social) return Kind.Social;
if (path.EndsWith(".m3u8")) return Kind.Hls;
if (path.EndsWith(".mpd")) return Kind.Dash;
return Kind.Direct;
}
public event PropertyChangedEventHandler? PropertyChanged;
private void On([CallerMemberName] string? p = null) => PropertyChanged?.Invoke(this, new(p));
}

View file

@ -0,0 +1,63 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
x:CompileBindings="False"
mc:Ignorable="d" d:DesignWidth="640" d:DesignHeight="520"
x:Class="VideoDownloader.Avalonia.MainWindow"
Title="VideoDownloader (Avalonia · 크로스플랫폼 셸)"
Width="640" Height="520"
WindowStartupLocation="CenterScreen">
<!-- DataContext 는 MainWindow 생성자에서 코드비하인드로 설정(MainViewModel). -->
<Border Background="#1B1C24" Padding="24">
<Grid RowDefinitions="Auto,Auto,Auto,Auto,Auto,*">
<!-- 헤더 -->
<StackPanel Grid.Row="0" Margin="0,0,0,16">
<TextBlock Text="VideoDownloader" FontSize="22" FontWeight="SemiBold" Foreground="#E6E6EC"/>
<TextBlock Text="Avalonia 크로스플랫폼 셸 — Core 엔진 재사용 (HLS · DASH · yt-dlp 소셜 · 직링크)"
FontSize="12" Foreground="#8A8B95" Margin="0,4,0,0"/>
</StackPanel>
<!-- URL 입력 -->
<TextBox Grid.Row="1" x:Name="UrlBox"
Text="{Binding Url, UpdateSourceTrigger=PropertyChanged}"
PlaceholderText="영상 URL 붙여넣기 (YouTube/Instagram/X · m3u8 · mpd · mp4)"
Background="#26272F" Foreground="#E6E6EC"
BorderBrush="#3A3B45" BorderThickness="1" Padding="10,8" CornerRadius="8"/>
<!-- 버튼 -->
<StackPanel Grid.Row="2" Orientation="Horizontal" Margin="0,12,0,12" Spacing="8">
<Button Content="받기" Command="{Binding DownloadCommand}"
Background="#5B8DEF" Foreground="White" Padding="24,8" CornerRadius="8" FontWeight="SemiBold"/>
<Button Content="취소" Command="{Binding CancelCommand}"
Background="#3A3B45" Foreground="#E6E6EC" Padding="18,8" CornerRadius="8"/>
</StackPanel>
<!-- 진행률 -->
<ProgressBar Grid.Row="3" Value="{Binding Progress}" Maximum="100"
MinHeight="6" CornerRadius="3" Foreground="#5B8DEF" Background="#26272F" Margin="0,0,0,8"/>
<!-- 상태/결과 -->
<StackPanel Grid.Row="4" Margin="0,0,0,12">
<TextBlock Text="{Binding Status}" Foreground="#B9BAC4" FontSize="13"/>
<TextBlock Text="{Binding Result}" Foreground="#7FD1A0" FontSize="13" TextWrapping="Wrap" Margin="0,6,0,0"/>
</StackPanel>
<!-- 로그 -->
<Border Grid.Row="5" Background="#202127" CornerRadius="8" Padding="12">
<ScrollViewer>
<ItemsControl ItemsSource="{Binding Log}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding}" Foreground="#8A8B95" FontSize="11" Margin="0,2"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Border>
</Grid>
</Border>
</Window>

View file

@ -0,0 +1,19 @@
using Avalonia.Controls;
namespace VideoDownloader.Avalonia;
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
DataContext = new MainViewModel();
// URL 입력창에 포커스 + Enter 로 받기 실행.
UrlBox.AttachedToVisualTree += (_, _) => UrlBox.Focus();
UrlBox.KeyDown += (_, e) =>
{
if (e.Key == global::Avalonia.Input.Key.Enter && DataContext is MainViewModel vm && !vm.IsBusy && !string.IsNullOrWhiteSpace(vm.Url))
vm.DownloadCommand.Execute(null);
};
}
}

View file

@ -0,0 +1,24 @@
using Avalonia;
using System;
namespace VideoDownloader.Avalonia;
class Program
{
// Initialization code. Don't use any Avalonia, third-party APIs or any
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
// yet and stuff might break.
[STAThread]
public static void Main(string[] args) => BuildAvaloniaApp()
.StartWithClassicDesktopLifetime(args);
// Avalonia configuration, don't remove; also used by visual designer.
public static AppBuilder BuildAvaloniaApp()
=> AppBuilder.Configure<App>()
.UsePlatformDetect()
#if DEBUG
.WithDeveloperTools()
#endif
.WithInterFont()
.LogToTrace();
}

View file

@ -0,0 +1,16 @@
using System.Windows.Input;
namespace VideoDownloader.Avalonia;
/// <summary>간단한 ICommand 구현(CommunityToolkit 없이 MVVM 커맨드 바인딩용).</summary>
public sealed class RelayCommand : ICommand
{
private readonly Action<object?> _execute;
private readonly Func<object?, bool>? _canExecute;
public RelayCommand(Action<object?> execute, Func<object?, bool>? canExecute = null)
{ _execute = execute; _canExecute = canExecute; }
public bool CanExecute(object? parameter) => _canExecute?.Invoke(parameter) ?? true;
public void Execute(object? parameter) => _execute(parameter);
public event EventHandler? CanExecuteChanged;
public void RaiseCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty);
}

View file

@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<!-- Core(net8.0)와 정렬 + Mac/Linux LTS 가용성. 크로스플랫폼 Avalonia 셸. -->
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>VideoDownloader.Avalonia</RootNamespace>
<AssemblyName>VideoDownloader.Avalonia</AssemblyName>
<ApplicationManifest>app.manifest</ApplicationManifest>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="12.1.0" />
<PackageReference Include="Avalonia.Desktop" Version="12.1.0" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.1.0" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.1.0" />
<PackageReference Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.3">
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
</PackageReference>
</ItemGroup>
<!-- 공유 엔진(HLS/DASH/Social yt-dlp + 자동복구/자동설치). WPF 앱과 동일 Core 재사용. -->
<ItemGroup>
<ProjectReference Include="..\VideoDownloader.Core\VideoDownloader.Core.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<!-- This manifest is used on Windows only.
Don't remove it as it might cause problems with window transparency and embedded controls.
For more details visit https://learn.microsoft.com/en-us/windows/win32/sbscs/application-manifests -->
<assemblyIdentity version="1.0.0.0" name="VideoDownloader.Avalonia.Desktop"/>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- A list of the Windows versions that this application has been tested on
and is designed to work with. Uncomment the appropriate elements
and Windows will automatically select the most compatible environment. -->
<!-- Windows 10 -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
</assembly>