Baseline: WPF/Avalonia/Cli/Core + plans (BROWSER_FEATURES_PLAN v2, MOBILE_PLAN v1)
This commit is contained in:
commit
21b7c4d8bd
60 changed files with 6581 additions and 0 deletions
10
VideoDownloader.Avalonia/App.axaml
Normal file
10
VideoDownloader.Avalonia/App.axaml
Normal 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>
|
||||
23
VideoDownloader.Avalonia/App.axaml.cs
Normal file
23
VideoDownloader.Avalonia/App.axaml.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
114
VideoDownloader.Avalonia/MainViewModel.cs
Normal file
114
VideoDownloader.Avalonia/MainViewModel.cs
Normal 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));
|
||||
}
|
||||
63
VideoDownloader.Avalonia/MainWindow.axaml
Normal file
63
VideoDownloader.Avalonia/MainWindow.axaml
Normal 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>
|
||||
19
VideoDownloader.Avalonia/MainWindow.axaml.cs
Normal file
19
VideoDownloader.Avalonia/MainWindow.axaml.cs
Normal 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);
|
||||
};
|
||||
}
|
||||
}
|
||||
24
VideoDownloader.Avalonia/Program.cs
Normal file
24
VideoDownloader.Avalonia/Program.cs
Normal 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();
|
||||
}
|
||||
16
VideoDownloader.Avalonia/RelayCommand.cs
Normal file
16
VideoDownloader.Avalonia/RelayCommand.cs
Normal 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);
|
||||
}
|
||||
28
VideoDownloader.Avalonia/VideoDownloader.Avalonia.csproj
Normal file
28
VideoDownloader.Avalonia/VideoDownloader.Avalonia.csproj
Normal 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>
|
||||
18
VideoDownloader.Avalonia/app.manifest
Normal file
18
VideoDownloader.Avalonia/app.manifest
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue