diff --git a/VideoDownloader.Mobile/ApiClient.cs b/VideoDownloader.Mobile/ApiClient.cs
new file mode 100644
index 0000000..4138bc7
--- /dev/null
+++ b/VideoDownloader.Mobile/ApiClient.cs
@@ -0,0 +1,139 @@
+using System.Net.Http;
+using System.Net.Http.Headers;
+using System.Net.Http.Json;
+using System.Text.Json;
+
+namespace VideoDownloader.Mobile;
+
+/// 데스크톱 원격 서버 API 클라이언트(M1). 토큰은 QR(#t=)/수동 입력에서 획득.
+public sealed class ApiClient
+{
+ public static readonly ApiClient Instance = new();
+
+ private readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(8) };
+
+ public string? BaseUrl { get; private set; }
+ public string? Token { get; private set; }
+ public string? LastError { get; private set; }
+ public bool IsConfigured => BaseUrl is { Length: > 0 } && Token is { Length: > 0 };
+
+ public event Action? ConnectionChanged;
+
+ /// QR 문자열(http://ip:port/#t=TOKEN) 또는 수동 입력(host:port + token) 구성.
+ public bool Configure(string? qrOrUrl, string? manualToken = null)
+ {
+ try
+ {
+ if (!string.IsNullOrWhiteSpace(manualToken))
+ {
+ var host = qrOrUrl!.Trim().TrimEnd('/');
+ if (!host.StartsWith("http", StringComparison.OrdinalIgnoreCase)) host = "http://" + host;
+ BaseUrl = host;
+ Token = manualToken.Trim();
+ }
+ else
+ {
+ var u = new Uri(qrOrUrl!.Trim());
+ var frag = u.Fragment.TrimStart('#');
+ var token = System.Web.HttpUtility.ParseQueryString(frag)["t"];
+ if (string.IsNullOrEmpty(token)) return false;
+ BaseUrl = $"http://{u.Authority}";
+ Token = token;
+ }
+ _http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", Token);
+ Preferences.Set("vd_base", BaseUrl);
+ Preferences.Set("vd_token", Token);
+ ConnectionChanged?.Invoke();
+ return true;
+ }
+ catch { return false; }
+ }
+
+ public void Restore()
+ {
+ var b = Preferences.Get("vd_base", "");
+ var t = Preferences.Get("vd_token", "");
+ if (b.Length > 0 && t.Length > 0)
+ {
+ BaseUrl = b;
+ Token = t;
+ _http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", t);
+ }
+ }
+
+ public void Forget()
+ {
+ BaseUrl = null; Token = null;
+ Preferences.Remove("vd_base"); Preferences.Remove("vd_token");
+ ConnectionChanged?.Invoke();
+ }
+
+ public async Task> GetQueueAsync()
+ {
+ try
+ {
+ LastError = null;
+ var r = await _http.GetAsync($"{BaseUrl}/api/queue");
+ if (!r.IsSuccessStatusCode) { LastError = $"HTTP {(int)r.StatusCode}"; return new(); }
+ return await r.Content.ReadFromJsonAsync>() ?? new();
+ }
+ catch (Exception e) { LastError = e.Message; return new(); }
+ }
+
+ public async Task AddAsync(string url)
+ {
+ try
+ {
+ var r = await _http.PostAsJsonAsync($"{BaseUrl}/api/queue", new { url });
+ return r.IsSuccessStatusCode;
+ }
+ catch { return false; }
+ }
+
+ public Task ControlAsync(string id, string action) =>
+ PostAsync($"/api/queue/{id}/{action}");
+
+ public async Task PairAsync()
+ {
+ try
+ {
+ var r = await _http.PostAsJsonAsync($"{BaseUrl}/api/pair", new { token = Token, device = "Android" });
+ return r.IsSuccessStatusCode;
+ }
+ catch { return false; }
+ }
+
+ public async Task> GetLibraryAsync()
+ {
+ try
+ {
+ var r = await _http.GetAsync($"{BaseUrl}/api/library");
+ if (!r.IsSuccessStatusCode) return new();
+ return await r.Content.ReadFromJsonAsync>() ?? new();
+ }
+ catch { return new(); }
+ }
+
+ public string StreamUrl(LibraryItem item) => $"{BaseUrl}/stream/{item.Id}?t={Token}";
+
+ private Task PostAsync(string path)
+ {
+ try { var t = _http.PostAsync($"{BaseUrl}{path}", null); return Continue(t); }
+ catch { return Task.FromResult(false); }
+ static async Task Continue(Task t) { try { return (await t).IsSuccessStatusCode; } catch { return false; } }
+ }
+
+ private async Task GetAsync(string path) where T : class
+ {
+ try
+ {
+ var r = await _http.GetAsync($"{BaseUrl}{path}");
+ return r.IsSuccessStatusCode ? await r.Content.ReadFromJsonAsync() : null;
+ }
+ catch { return null; }
+ }
+}
+
+public sealed record QueueItem(string Id, string Title, string Url, string Status, double Percent);
+
+public sealed record LibraryItem(string Id, string FileName, long SizeBytes, DateTime Modified);
diff --git a/VideoDownloader.Mobile/App.xaml b/VideoDownloader.Mobile/App.xaml
new file mode 100644
index 0000000..6100cfa
--- /dev/null
+++ b/VideoDownloader.Mobile/App.xaml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/VideoDownloader.Mobile/App.xaml.cs b/VideoDownloader.Mobile/App.xaml.cs
new file mode 100644
index 0000000..26b5aa1
--- /dev/null
+++ b/VideoDownloader.Mobile/App.xaml.cs
@@ -0,0 +1,16 @@
+using Microsoft.Extensions.DependencyInjection;
+
+namespace VideoDownloader.Mobile;
+
+public partial class App : Application
+{
+ public App()
+ {
+ InitializeComponent();
+ }
+
+ protected override Window CreateWindow(IActivationState? activationState)
+ {
+ return new Window(new TabbedRootPage());
+ }
+}
\ No newline at end of file
diff --git a/VideoDownloader.Mobile/MauiProgram.cs b/VideoDownloader.Mobile/MauiProgram.cs
new file mode 100644
index 0000000..7861346
--- /dev/null
+++ b/VideoDownloader.Mobile/MauiProgram.cs
@@ -0,0 +1,29 @@
+using Microsoft.Extensions.Logging;
+
+using CommunityToolkit.Maui;
+using ZXing.Net.Maui.Controls;
+
+namespace VideoDownloader.Mobile;
+
+public static class MauiProgram
+{
+ public static MauiApp CreateMauiApp()
+ {
+ var builder = MauiApp.CreateBuilder();
+ builder
+ .UseMauiApp()
+ .UseBarcodeReader()
+ .UseMauiCommunityToolkitMediaElement()
+ .ConfigureFonts(fonts =>
+ {
+ fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
+ fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
+ });
+
+#if DEBUG
+ builder.Logging.AddDebug();
+#endif
+
+ return builder.Build();
+ }
+}
diff --git a/VideoDownloader.Mobile/Pages.cs b/VideoDownloader.Mobile/Pages.cs
new file mode 100644
index 0000000..03d968c
--- /dev/null
+++ b/VideoDownloader.Mobile/Pages.cs
@@ -0,0 +1,213 @@
+using Microsoft.Maui.Controls;
+using System.Net.Http;
+
+namespace VideoDownloader.Mobile;
+
+/// M1 연결 페이지: QR 스캔(ZXing) + 수동 입력 폴백.
+public sealed class ConnectPage : ContentPage
+{
+ private readonly Entry _manual = new() { Placeholder = "PC 주소 (예: 192.168.0.223:52100)", Keyboard = Keyboard.Url };
+ private readonly Entry _manualToken = new() { Placeholder = "토큰 (QR 못 쓸 때)", IsPassword = true };
+ private readonly Label _status = new() { Text = "PC 앱에서 '기기 연결'을 누르고 QR 을 스캔하세요", HorizontalOptions = LayoutOptions.Center, Margin = new Thickness(0, 12) };
+ private readonly ZXing.Net.Maui.Controls.CameraBarcodeReaderView _scanner;
+
+ public ConnectPage()
+ {
+ Title = "연결";
+
+ _scanner = new ZXing.Net.Maui.Controls.CameraBarcodeReaderView
+ {
+ HorizontalOptions = LayoutOptions.Fill,
+ HeightRequest = 320,
+ Options = new ZXing.Net.Maui.BarcodeReaderOptions
+ {
+ Formats = ZXing.Net.Maui.BarcodeFormats.TwoDimensional,
+ AutoRotate = true,
+ },
+ };
+ _scanner.BarcodesDetected += (s, e) =>
+ {
+ var text = e.Results?.FirstOrDefault()?.Value;
+ if (string.IsNullOrEmpty(text)) return;
+ MainThread.BeginInvokeOnMainThread(() =>
+ {
+ if (ApiClient.Instance.Configure(text))
+ {
+ _status.Text = "✅ 연결됨";
+ _scanner.IsDetecting = false;
+ }
+ });
+ };
+
+ var connectBtn = new Button { Text = "수동 연결" };
+ connectBtn.Clicked += (_, _) =>
+ {
+ if (ApiClient.Instance.Configure(_manual.Text, _manualToken.Text)) _status.Text = "✅ 연결됨";
+ else _status.Text = "❌ 연결 실패 — 주소/토큰 확인";
+ };
+ var forgetBtn = new Button { Text = "연결 해제", BackgroundColor = Colors.Gray };
+ forgetBtn.Clicked += (_, _) => { ApiClient.Instance.Forget(); _status.Text = "해제됨"; };
+
+ Content = new ScrollView
+ {
+ Content = new VerticalStackLayout
+ {
+ Padding = 20, Spacing = 10,
+ Children = { _status, _scanner, _manual, _manualToken, connectBtn, forgetBtn }
+ }
+ };
+ }
+}
+
+/// M1 큐 페이지: 목록(2초 폴링) + URL 추가 + 제어.
+public sealed class QueuePage : ContentPage
+{
+ private readonly Entry _url = new() { Placeholder = "YouTube·영상 URL 붙여넣기", Keyboard = Keyboard.Url };
+ private readonly Label _summary = new();
+ private readonly CollectionView _list = new();
+ private readonly IDispatcherTimer _timer;
+
+ public QueuePage()
+ {
+ Title = "다운로드 큐";
+ var addBtn = new Button { Text = "PC에서 받기" };
+ addBtn.Clicked += async (_, _) =>
+ {
+ if (string.IsNullOrWhiteSpace(_url.Text)) return;
+ _summary.Text = "전송 중…";
+ var ok = await ApiClient.Instance.AddAsync(_url.Text.Trim());
+ _summary.Text = ok ? "✅ PC 대기열에 추가됨" : "❌ 실패 (연결 확인)";
+ if (ok) _url.Text = "";
+ await Refresh();
+ };
+
+ _list.ItemTemplate = new DataTemplate(() =>
+ {
+ var title = new Label { FontSize = 14, FontAttributes = FontAttributes.Bold, LineBreakMode = LineBreakMode.TailTruncation };
+ title.SetBinding(Label.TextProperty, "Title");
+ var status = new Label { FontSize = 12, TextColor = Colors.Gray };
+ status.SetBinding(Label.TextProperty, new Binding("Percent", stringFormat: "{0:0}%"));
+ var bar = new ProgressBar { HeightRequest = 6 };
+ bar.SetBinding(ProgressBar.ProgressProperty, new Binding("Percent", converter: new PercentConverter()));
+ var state = new Label { FontSize = 11, TextColor = Colors.Gray };
+ state.SetBinding(Label.TextProperty, "Status");
+
+ var pause = new Button { Text = "⏸" };
+ var resume = new Button { Text = "▶" };
+ var cancel = new Button { Text = "✕", BackgroundColor = Colors.Gray };
+ pause.Clicked += async (_, _) => { if (pause.BindingContext is QueueItem qi) { await ApiClient.Instance.ControlAsync(qi.Id, "pause"); await Refresh(); } };
+ resume.Clicked += async (_, _) => { if (resume.BindingContext is QueueItem qi) { await ApiClient.Instance.ControlAsync(qi.Id, "resume"); await Refresh(); } };
+ cancel.Clicked += async (_, _) => { if (cancel.BindingContext is QueueItem qi) { await ApiClient.Instance.ControlAsync(qi.Id, "cancel"); await Refresh(); } };
+
+ return new Frame
+ {
+ Margin = new Thickness(0, 4), Padding = 12, CornerRadius = 10,
+ Content = new VerticalStackLayout
+ {
+ Spacing = 6,
+ Children = { title, bar, new HorizontalStackLayout { Children = { state, new Label { Text = " " } } },
+ new HorizontalStackLayout { Spacing = 8, Children = { pause, resume, cancel } } }
+ }
+ };
+ });
+
+ _timer = Dispatcher.CreateTimer();
+ _timer.Interval = TimeSpan.FromSeconds(2);
+ _timer.Tick += async (_, _) => await Refresh();
+ Appearing += (_, _) => _timer.Start();
+ Disappearing += (_, _) => _timer.Stop();
+
+ Content = new VerticalStackLayout
+ {
+ Padding = 16, Spacing = 10,
+ Children = { _url, addBtn, _summary, _list }
+ };
+ }
+
+ private async Task Refresh()
+ {
+ if (!ApiClient.Instance.IsConfigured) { _summary.Text = "⚠️ 먼저 연결하세요"; return; }
+ var items = await ApiClient.Instance.GetQueueAsync();
+ _list.ItemsSource = items;
+ var err = ApiClient.Instance.LastError;
+ _summary.Text = err != null ? $"❌ {err}" : items.Count == 0 ? "대기 중인 항목이 없습니다" : $"{items.Count}건";
+ }
+
+ private sealed class PercentConverter : IValueConverter
+ {
+ public object Convert(object? value, Type t, object? p, System.Globalization.CultureInfo c) => Math.Clamp(value is double d ? d / 100.0 : 0, 0, 1);
+ public object ConvertBack(object? v, Type t, object? p, System.Globalization.CultureInfo c) => throw new NotSupportedException();
+ }
+}
+
+/// M2 라이브러리 페이지: 목록 → 탭하여 스트리밍 재생.
+public sealed class LibraryPage : ContentPage
+{
+ private readonly CollectionView _list = new();
+
+ public LibraryPage()
+ {
+ Title = "라이브러리";
+ _list.ItemTemplate = new DataTemplate(() =>
+ {
+ var name = new Label { FontSize = 14, FontAttributes = FontAttributes.Bold, LineBreakMode = LineBreakMode.TailTruncation };
+ name.SetBinding(Label.TextProperty, "FileName");
+ var size = new Label { FontSize = 11, TextColor = Colors.Gray };
+ size.SetBinding(Label.TextProperty, new Binding("SizeBytes", stringFormat: "{0:N0} bytes"));
+ var card = new Frame
+ {
+ Margin = new Thickness(0, 4), Padding = 12, CornerRadius = 10,
+ Content = new VerticalStackLayout { Children = { name, size } }
+ };
+ // CollectionView.SelectionChanged 는 Android 에서 발화 불안정 → 템플릿 카드에 탭 제스처
+ var tap = new TapGestureRecognizer();
+ tap.Tapped += (_, _) => { if (card.BindingContext is LibraryItem item) OpenPlayer(item); };
+ card.GestureRecognizers.Add(tap);
+ return card;
+ });
+ _list.SelectionMode = SelectionMode.None;
+
+ var refresh = new Button { Text = "새로고침" };
+ refresh.Clicked += async (_, _) => await Reload();
+
+ Content = new VerticalStackLayout { Padding = 16, Children = { refresh, _list } };
+ Appearing += async (_, _) => await Reload();
+ }
+
+ private async void OpenPlayer(LibraryItem item)
+ {
+ // 탭 자식엔 NavigationPage 가 없어 PushAsync 불가 → 루트에서 모달 push (닫기는 상단바 뒤로)
+ await Application.Current!.MainPage!.Navigation.PushModalAsync(new NavigationPage(new PlayerPage(item)));
+ }
+
+ private async Task Reload()
+ {
+ if (!ApiClient.Instance.IsConfigured) { _list.ItemsSource = new[] { "먼저 연결하세요" }; return; }
+ _list.ItemsSource = await ApiClient.Instance.GetLibraryAsync();
+ }
+}
+
+/// M2 재생 페이지: Range 스트리밍 재생(MediaElement).
+public sealed class PlayerPage : ContentPage
+{
+ private readonly CommunityToolkit.Maui.Views.MediaElement _media = new()
+ {
+ Aspect = Aspect.AspectFit,
+ ShouldAutoPlay = true,
+ ShouldShowPlaybackControls = true,
+ };
+
+ public PlayerPage(LibraryItem item)
+ {
+ Title = item.FileName;
+ _media.Source = CommunityToolkit.Maui.Views.MediaSource.FromUri(ApiClient.Instance.StreamUrl(item));
+ Content = _media;
+ }
+
+ protected override void OnDisappearing()
+ {
+ _media.Stop();
+ _media.Handler?.DisconnectHandler(); // 재생 리소스 해제
+ base.OnDisappearing();
+ }
+}
diff --git a/VideoDownloader.Mobile/Platforms/Android/AndroidManifest.xml b/VideoDownloader.Mobile/Platforms/Android/AndroidManifest.xml
new file mode 100644
index 0000000..5cdb4fd
--- /dev/null
+++ b/VideoDownloader.Mobile/Platforms/Android/AndroidManifest.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/VideoDownloader.Mobile/Platforms/Android/MainActivity.cs b/VideoDownloader.Mobile/Platforms/Android/MainActivity.cs
new file mode 100644
index 0000000..471765c
--- /dev/null
+++ b/VideoDownloader.Mobile/Platforms/Android/MainActivity.cs
@@ -0,0 +1,10 @@
+using Android.App;
+using Android.Content.PM;
+using Android.OS;
+
+namespace VideoDownloader.Mobile;
+
+[Activity(Theme = "@style/Maui.SplashTheme", MainLauncher = true, LaunchMode = LaunchMode.SingleTop, ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation | ConfigChanges.UiMode | ConfigChanges.ScreenLayout | ConfigChanges.SmallestScreenSize | ConfigChanges.Density)]
+public class MainActivity : MauiAppCompatActivity
+{
+}
diff --git a/VideoDownloader.Mobile/Platforms/Android/MainApplication.cs b/VideoDownloader.Mobile/Platforms/Android/MainApplication.cs
new file mode 100644
index 0000000..22a2260
--- /dev/null
+++ b/VideoDownloader.Mobile/Platforms/Android/MainApplication.cs
@@ -0,0 +1,15 @@
+using Android.App;
+using Android.Runtime;
+
+namespace VideoDownloader.Mobile;
+
+[Application]
+public class MainApplication : MauiApplication
+{
+ public MainApplication(IntPtr handle, JniHandleOwnership ownership)
+ : base(handle, ownership)
+ {
+ }
+
+ protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
+}
diff --git a/VideoDownloader.Mobile/Platforms/Android/Resources/values/colors.xml b/VideoDownloader.Mobile/Platforms/Android/Resources/values/colors.xml
new file mode 100644
index 0000000..c04d749
--- /dev/null
+++ b/VideoDownloader.Mobile/Platforms/Android/Resources/values/colors.xml
@@ -0,0 +1,6 @@
+
+
+ #512BD4
+ #2B0B98
+ #2B0B98
+
\ No newline at end of file
diff --git a/VideoDownloader.Mobile/Platforms/MacCatalyst/AppDelegate.cs b/VideoDownloader.Mobile/Platforms/MacCatalyst/AppDelegate.cs
new file mode 100644
index 0000000..5da0075
--- /dev/null
+++ b/VideoDownloader.Mobile/Platforms/MacCatalyst/AppDelegate.cs
@@ -0,0 +1,9 @@
+using Foundation;
+
+namespace VideoDownloader.Mobile;
+
+[Register("AppDelegate")]
+public class AppDelegate : MauiUIApplicationDelegate
+{
+ protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
+}
diff --git a/VideoDownloader.Mobile/Platforms/MacCatalyst/Entitlements.plist b/VideoDownloader.Mobile/Platforms/MacCatalyst/Entitlements.plist
new file mode 100644
index 0000000..de4adc9
--- /dev/null
+++ b/VideoDownloader.Mobile/Platforms/MacCatalyst/Entitlements.plist
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+ com.apple.security.app-sandbox
+
+
+ com.apple.security.network.client
+
+
+
+
diff --git a/VideoDownloader.Mobile/Platforms/MacCatalyst/Info.plist b/VideoDownloader.Mobile/Platforms/MacCatalyst/Info.plist
new file mode 100644
index 0000000..f2e0987
--- /dev/null
+++ b/VideoDownloader.Mobile/Platforms/MacCatalyst/Info.plist
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ UIDeviceFamily
+
+ 2
+
+ LSApplicationCategoryType
+ public.app-category.lifestyle
+ UIRequiredDeviceCapabilities
+
+ arm64
+
+ UISupportedInterfaceOrientations
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ UISupportedInterfaceOrientations~ipad
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationPortraitUpsideDown
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ XSAppIconAssets
+ Assets.xcassets/appicon.appiconset
+
+
diff --git a/VideoDownloader.Mobile/Platforms/MacCatalyst/Program.cs b/VideoDownloader.Mobile/Platforms/MacCatalyst/Program.cs
new file mode 100644
index 0000000..3d9e6fd
--- /dev/null
+++ b/VideoDownloader.Mobile/Platforms/MacCatalyst/Program.cs
@@ -0,0 +1,15 @@
+using ObjCRuntime;
+using UIKit;
+
+namespace VideoDownloader.Mobile;
+
+public class Program
+{
+ // This is the main entry point of the application.
+ static void Main(string[] args)
+ {
+ // if you want to use a different Application Delegate class from "AppDelegate"
+ // you can specify it here.
+ UIApplication.Main(args, null, typeof(AppDelegate));
+ }
+}
diff --git a/VideoDownloader.Mobile/Platforms/Windows/App.xaml b/VideoDownloader.Mobile/Platforms/Windows/App.xaml
new file mode 100644
index 0000000..885b74e
--- /dev/null
+++ b/VideoDownloader.Mobile/Platforms/Windows/App.xaml
@@ -0,0 +1,8 @@
+
+
+
diff --git a/VideoDownloader.Mobile/Platforms/Windows/App.xaml.cs b/VideoDownloader.Mobile/Platforms/Windows/App.xaml.cs
new file mode 100644
index 0000000..f182b49
--- /dev/null
+++ b/VideoDownloader.Mobile/Platforms/Windows/App.xaml.cs
@@ -0,0 +1,24 @@
+using Microsoft.UI.Xaml;
+
+// To learn more about WinUI, the WinUI project structure,
+// and more about our project templates, see: http://aka.ms/winui-project-info.
+
+namespace VideoDownloader.Mobile.WinUI;
+
+///
+/// Provides application-specific behavior to supplement the default Application class.
+///
+public partial class App : MauiWinUIApplication
+{
+ ///
+ /// Initializes the singleton application object. This is the first line of authored code
+ /// executed, and as such is the logical equivalent of main() or WinMain().
+ ///
+ public App()
+ {
+ this.InitializeComponent();
+ }
+
+ protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
+}
+
diff --git a/VideoDownloader.Mobile/Platforms/Windows/Package.appxmanifest b/VideoDownloader.Mobile/Platforms/Windows/Package.appxmanifest
new file mode 100644
index 0000000..54dd742
--- /dev/null
+++ b/VideoDownloader.Mobile/Platforms/Windows/Package.appxmanifest
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
+ $placeholder$
+ User Name
+ $placeholder$.png
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/VideoDownloader.Mobile/Platforms/Windows/app.manifest b/VideoDownloader.Mobile/Platforms/Windows/app.manifest
new file mode 100644
index 0000000..ceb516e
--- /dev/null
+++ b/VideoDownloader.Mobile/Platforms/Windows/app.manifest
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+ true/PM
+ PerMonitorV2, PerMonitor
+
+ true
+
+
+
diff --git a/VideoDownloader.Mobile/Platforms/iOS/AppDelegate.cs b/VideoDownloader.Mobile/Platforms/iOS/AppDelegate.cs
new file mode 100644
index 0000000..5da0075
--- /dev/null
+++ b/VideoDownloader.Mobile/Platforms/iOS/AppDelegate.cs
@@ -0,0 +1,9 @@
+using Foundation;
+
+namespace VideoDownloader.Mobile;
+
+[Register("AppDelegate")]
+public class AppDelegate : MauiUIApplicationDelegate
+{
+ protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
+}
diff --git a/VideoDownloader.Mobile/Platforms/iOS/Info.plist b/VideoDownloader.Mobile/Platforms/iOS/Info.plist
new file mode 100644
index 0000000..0004a4f
--- /dev/null
+++ b/VideoDownloader.Mobile/Platforms/iOS/Info.plist
@@ -0,0 +1,32 @@
+
+
+
+
+ LSRequiresIPhoneOS
+
+ UIDeviceFamily
+
+ 1
+ 2
+
+ UIRequiredDeviceCapabilities
+
+ arm64
+
+ UISupportedInterfaceOrientations
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ UISupportedInterfaceOrientations~ipad
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationPortraitUpsideDown
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ XSAppIconAssets
+ Assets.xcassets/appicon.appiconset
+
+
diff --git a/VideoDownloader.Mobile/Platforms/iOS/Program.cs b/VideoDownloader.Mobile/Platforms/iOS/Program.cs
new file mode 100644
index 0000000..3d9e6fd
--- /dev/null
+++ b/VideoDownloader.Mobile/Platforms/iOS/Program.cs
@@ -0,0 +1,15 @@
+using ObjCRuntime;
+using UIKit;
+
+namespace VideoDownloader.Mobile;
+
+public class Program
+{
+ // This is the main entry point of the application.
+ static void Main(string[] args)
+ {
+ // if you want to use a different Application Delegate class from "AppDelegate"
+ // you can specify it here.
+ UIApplication.Main(args, null, typeof(AppDelegate));
+ }
+}
diff --git a/VideoDownloader.Mobile/Platforms/iOS/Resources/PrivacyInfo.xcprivacy b/VideoDownloader.Mobile/Platforms/iOS/Resources/PrivacyInfo.xcprivacy
new file mode 100644
index 0000000..24ab3b4
--- /dev/null
+++ b/VideoDownloader.Mobile/Platforms/iOS/Resources/PrivacyInfo.xcprivacy
@@ -0,0 +1,51 @@
+
+
+
+
+
+ NSPrivacyAccessedAPITypes
+
+
+ NSPrivacyAccessedAPIType
+ NSPrivacyAccessedAPICategoryFileTimestamp
+ NSPrivacyAccessedAPITypeReasons
+
+ C617.1
+
+
+
+ NSPrivacyAccessedAPIType
+ NSPrivacyAccessedAPICategorySystemBootTime
+ NSPrivacyAccessedAPITypeReasons
+
+ 35F9.1
+
+
+
+ NSPrivacyAccessedAPIType
+ NSPrivacyAccessedAPICategoryDiskSpace
+ NSPrivacyAccessedAPITypeReasons
+
+ E174.1
+
+
+
+
+
+
diff --git a/VideoDownloader.Mobile/Properties/launchSettings.json b/VideoDownloader.Mobile/Properties/launchSettings.json
new file mode 100644
index 0000000..4f85793
--- /dev/null
+++ b/VideoDownloader.Mobile/Properties/launchSettings.json
@@ -0,0 +1,8 @@
+{
+ "profiles": {
+ "Windows Machine": {
+ "commandName": "Project",
+ "nativeDebugging": false
+ }
+ }
+}
\ No newline at end of file
diff --git a/VideoDownloader.Mobile/Resources/AppIcon/appicon.svg b/VideoDownloader.Mobile/Resources/AppIcon/appicon.svg
new file mode 100644
index 0000000..9d63b65
--- /dev/null
+++ b/VideoDownloader.Mobile/Resources/AppIcon/appicon.svg
@@ -0,0 +1,4 @@
+
+
\ No newline at end of file
diff --git a/VideoDownloader.Mobile/Resources/AppIcon/appiconfg.svg b/VideoDownloader.Mobile/Resources/AppIcon/appiconfg.svg
new file mode 100644
index 0000000..21dfb25
--- /dev/null
+++ b/VideoDownloader.Mobile/Resources/AppIcon/appiconfg.svg
@@ -0,0 +1,8 @@
+
+
+
\ No newline at end of file
diff --git a/VideoDownloader.Mobile/Resources/Fonts/OpenSans-Regular.ttf b/VideoDownloader.Mobile/Resources/Fonts/OpenSans-Regular.ttf
new file mode 100644
index 0000000..2cc82d2
Binary files /dev/null and b/VideoDownloader.Mobile/Resources/Fonts/OpenSans-Regular.ttf differ
diff --git a/VideoDownloader.Mobile/Resources/Fonts/OpenSans-Semibold.ttf b/VideoDownloader.Mobile/Resources/Fonts/OpenSans-Semibold.ttf
new file mode 100644
index 0000000..fcb5284
Binary files /dev/null and b/VideoDownloader.Mobile/Resources/Fonts/OpenSans-Semibold.ttf differ
diff --git a/VideoDownloader.Mobile/Resources/Images/dotnet_bot.png b/VideoDownloader.Mobile/Resources/Images/dotnet_bot.png
new file mode 100644
index 0000000..054167e
Binary files /dev/null and b/VideoDownloader.Mobile/Resources/Images/dotnet_bot.png differ
diff --git a/VideoDownloader.Mobile/Resources/Raw/AboutAssets.txt b/VideoDownloader.Mobile/Resources/Raw/AboutAssets.txt
new file mode 100644
index 0000000..89dc758
--- /dev/null
+++ b/VideoDownloader.Mobile/Resources/Raw/AboutAssets.txt
@@ -0,0 +1,15 @@
+Any raw assets you want to be deployed with your application can be placed in
+this directory (and child directories). Deployment of the asset to your application
+is automatically handled by the following `MauiAsset` Build Action within your `.csproj`.
+
+
+
+These files will be deployed with your package and will be accessible using Essentials:
+
+ async Task LoadMauiAsset()
+ {
+ using var stream = await FileSystem.OpenAppPackageFileAsync("AboutAssets.txt");
+ using var reader = new StreamReader(stream);
+
+ var contents = reader.ReadToEnd();
+ }
diff --git a/VideoDownloader.Mobile/Resources/Splash/splash.svg b/VideoDownloader.Mobile/Resources/Splash/splash.svg
new file mode 100644
index 0000000..21dfb25
--- /dev/null
+++ b/VideoDownloader.Mobile/Resources/Splash/splash.svg
@@ -0,0 +1,8 @@
+
+
+
\ No newline at end of file
diff --git a/VideoDownloader.Mobile/Resources/Styles/Colors.xaml b/VideoDownloader.Mobile/Resources/Styles/Colors.xaml
new file mode 100644
index 0000000..d57fcc6
--- /dev/null
+++ b/VideoDownloader.Mobile/Resources/Styles/Colors.xaml
@@ -0,0 +1,44 @@
+
+
+
+
+
+ #512BD4
+ #ac99ea
+ #242424
+ #DFD8F7
+ #9880e5
+ #2B0B98
+
+ White
+ Black
+ #D600AA
+ #190649
+ #1f1f1f
+
+ #E1E1E1
+ #C8C8C8
+ #ACACAC
+ #919191
+ #6E6E6E
+ #404040
+ #212121
+ #141414
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/VideoDownloader.Mobile/Resources/Styles/Styles.xaml b/VideoDownloader.Mobile/Resources/Styles/Styles.xaml
new file mode 100644
index 0000000..5fef12a
--- /dev/null
+++ b/VideoDownloader.Mobile/Resources/Styles/Styles.xaml
@@ -0,0 +1,434 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/VideoDownloader.Mobile/TabbedRootPage.cs b/VideoDownloader.Mobile/TabbedRootPage.cs
new file mode 100644
index 0000000..23b925d
--- /dev/null
+++ b/VideoDownloader.Mobile/TabbedRootPage.cs
@@ -0,0 +1,14 @@
+namespace VideoDownloader.Mobile;
+
+/// 컴패니언 루트: 연결 / 큐 / 라이브러리 탭.
+public sealed class TabbedRootPage : TabbedPage
+{
+ public TabbedRootPage()
+ {
+ ApiClient.Instance.Restore();
+ Children.Add(new QueuePage());
+ Children.Add(new LibraryPage());
+ Children.Add(new ConnectPage());
+ Title = "VD 원격";
+ }
+}
diff --git a/VideoDownloader.Mobile/VideoDownloader.Mobile.csproj b/VideoDownloader.Mobile/VideoDownloader.Mobile.csproj
new file mode 100644
index 0000000..09f5f09
--- /dev/null
+++ b/VideoDownloader.Mobile/VideoDownloader.Mobile.csproj
@@ -0,0 +1,76 @@
+
+
+
+ net10.0-android
+
+
+
+
+
+ Exe
+ VideoDownloader.Mobile
+ true
+ true
+ enable
+ enable
+
+
+ SourceGen
+
+
+ VideoDownloader.Mobile
+
+
+ com.companyname.videodownloader.mobile
+
+
+ 1.0
+ 1
+
+
+ None
+
+ 15.0
+ 15.0
+ 21.0
+ 10.0.17763.0
+ 10.0.17763.0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+