releases/VideoDownloader.Mobile/Pages.cs

213 lines
9.2 KiB
C#

using Microsoft.Maui.Controls;
using System.Net.Http;
namespace VideoDownloader.Mobile;
/// <summary>M1 연결 페이지: QR 스캔(ZXing) + 수동 입력 폴백.</summary>
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 }
}
};
}
}
/// <summary>M1 큐 페이지: 목록(2초 폴링) + URL 추가 + 제어.</summary>
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();
}
}
/// <summary>M2 라이브러리 페이지: 목록 → 탭하여 스트리밍 재생.</summary>
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();
}
}
/// <summary>M2 재생 페이지: Range 스트리밍 재생(MediaElement).</summary>
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();
}
}