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) }; public ConnectPage() { Title = "연결"; // 시뮬레이터는 카메라 하드웨어가 없어 세션 시작이 첫 프레임을 막음(UIApplication hang) → 실기기에서만 스캐너 View scannerArea = new Label { Text = "📷 시뮬레이터 — 수동 입력을 이용하세요", HorizontalOptions = LayoutOptions.Fill, VerticalOptions = LayoutOptions.Center, HorizontalTextAlignment = TextAlignment.Center, HeightRequest = 120, BackgroundColor = Colors.Black, TextColor = Colors.LightGray, }; if (DeviceInfo.Current.DeviceType == DeviceType.Physical) { var 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; } }); }; scannerArea = scanner; } 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, scannerArea, _manual, _manualToken, connectBtn, forgetBtn } } }; } } /// M1 큐 페이지: 목록(2초 폴링) + URL 추가 + 제어 + U5 완료 알림 + 외부망 안내. public sealed class QueuePage : ContentPage { private readonly Entry _url = new() { Placeholder = "YouTube·영상 URL 붙여넣기", Keyboard = Keyboard.Url }; private readonly Label _summary = new(); private readonly Label _trouble = new() { FontSize = 12, TextColor = Colors.OrangeRed, IsVisible = false }; private readonly CollectionView _list = new(); private readonly IDispatcherTimer _timer; private readonly QueueCompletionDetector _completions = new(); private List _last = new(); 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, _trouble, _list } }; } private async Task Refresh() { var trouble = ConnectionTroubleshoot.Message(ApiClient.Instance.IsConfigured, ApiClient.Instance.LastError); _trouble.Text = trouble; _trouble.IsVisible = trouble.Length > 0; if (!ApiClient.Instance.IsConfigured) { _summary.Text = "⚠️ 먼저 연결하세요"; _last = new(); return; } var items = await ApiClient.Instance.GetQueueAsync(); _list.ItemsSource = items; // U5: 이전 스냅샷 대비 새로 완료된 항목 → 로컬 알림 var done = _completions.Detect( _last.Select(i => (i.Id, i.Status)).ToList(), items.Select(i => (i.Id, i.Status)).ToList()); foreach (var id in done) { var item = items.First(i => i.Id == id); _ = NotifyCompletionAsync(item.Title); _summary.Text = $"🔔 완료: {item.Title}"; } _last = items; var err = ApiClient.Instance.LastError; _summary.Text = err != null ? $"❌ {err}" : items.Count == 0 ? "대기 중인 항목이 없습니다" : $"{items.Count}건"; } /// U5: 완료 로컬 알림(Android 13+ 는 최초 1회 권한 요청). private static async Task NotifyCompletionAsync(string title) { try { if (OperatingSystem.IsAndroidVersionAtLeast(33)) await Plugin.LocalNotification.LocalNotificationCenter.Current.RequestNotificationPermission(); Plugin.LocalNotification.LocalNotificationCenter.Current.Show(new Plugin.LocalNotification.NotificationRequest { Title = "다운로드 완료", Description = title, }); } catch { } } 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.ItemsLayout = new GridItemsLayout(2, ItemsLayoutOrientation.Vertical); // M2 썸네일 그리드 _list.ItemTemplate = new DataTemplate(() => { var thumb = new Image { HeightRequest = 96, Aspect = Aspect.AspectFill, BackgroundColor = Colors.Black }; thumb.SetBinding(Image.SourceProperty, new Binding(".", converter: new ThumbConverter())); var name = new Label { FontSize = 13, FontAttributes = FontAttributes.Bold, LineBreakMode = LineBreakMode.TailTruncation, LineHeight = 1.1 }; name.SetBinding(Label.TextProperty, "FileName"); var size = new Label { FontSize = 10, TextColor = Colors.Gray }; size.SetBinding(Label.TextProperty, new Binding("SizeBytes", stringFormat: "{0:N0} bytes")); // U4 폰 저장: 카드 우측 버튼, 진행률을 버튼 텍스트로 표시 var save = new Button { Text = "💾 저장", FontSize = 12, Padding = new Thickness(10, 4), VerticalOptions = LayoutOptions.Center }; save.Clicked += async (_, _) => await SaveOffline(save, save.BindingContext as LibraryItem); var card = new Frame { Margin = new Thickness(6, 4), Padding = 8, CornerRadius = 10, Content = new VerticalStackLayout { Spacing = 4, Children = { thumb, name, size, save } } }; // 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(); } /// U4: 항목을 폰 저장 폴더로 스트리밍 다운로드(진행률 % → 완료 ✓). private static async Task SaveOffline(Button button, LibraryItem? item) { if (item == null) return; if (AppOfflineStore.Default.Exists(item.Id)) { button.Text = "✓ 저장됨"; return; } button.Text = "0%"; try { var dest = AppOfflineStore.Default.PathFor(item.Id); var progress = new Progress(p => MainThread.BeginInvokeOnMainThread( () => button.Text = p >= 1 ? "✓ 저장됨" : $"{(int)(p * 100)}%")); await ApiClient.Instance.DownloadFileAsync(item, dest, progress, CancellationToken.None); AppOfflineStore.Default.Record(item.Id, item.FileName); button.Text = "✓ 저장됨"; } catch (Exception e) { MainThread.BeginInvokeOnMainThread(() => button.Text = $"❌ {e.Message[..Math.Min(20, e.Message.Length)]}"); } } 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-U4 저장 탭: 폰에 저장된 항목 목록 — 오프라인 재생/삭제. public sealed class DownloadsPage : ContentPage { private readonly CollectionView _list = new(); private readonly Label _empty = new() { Text = "저장된 항목이 없습니다 — 라이브러리에서 💾 저장", TextColor = Colors.Gray, HorizontalOptions = LayoutOptions.Center, Margin = new Thickness(0, 24), }; public DownloadsPage() { 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 del = new Button { Text = "🗑", FontSize = 12, Padding = new Thickness(10, 4), VerticalOptions = LayoutOptions.Center }; del.Clicked += (_, _) => { if ((del.BindingContext as OfflineItem) is { } it) { AppOfflineStore.Default.Delete(it.Id); Reload(); } }; var card = new Frame { Margin = new Thickness(0, 4), Padding = 12, CornerRadius = 10, Content = new HorizontalStackLayout { Spacing = 8, Children = { new VerticalStackLayout { Children = { name, size } }, del } } }; var tap = new TapGestureRecognizer(); tap.Tapped += (_, _) => { if ((card.BindingContext as OfflineItem) is { } it) _ = Application.Current!.MainPage!.Navigation.PushModalAsync( new NavigationPage(new PlayerPage(new LibraryItem(it.Id, it.FileName, it.SizeBytes, DateTime.MinValue), AppOfflineStore.Default.PathFor(it.Id)))); }; card.GestureRecognizers.Add(tap); return card; }); _list.SelectionMode = SelectionMode.None; var refresh = new Button { Text = "새로고침" }; refresh.Clicked += (_, _) => Reload(); Content = new VerticalStackLayout { Padding = 16, Children = { refresh, _empty, _list } }; _empty.IsVisible = false; Appearing += (_, _) => Reload(); } private void Reload() { var items = AppOfflineStore.Default.List(); _list.ItemsSource = items.Count > 0 ? items : null; _empty.IsVisible = items.Count == 0; } } /// M2 재생 페이지: Range 스트리밍 재생(MediaElement) + 이어보기 위치 동기화(U3). localPath 있으면 오프라인 재생(U4). public sealed class PlayerPage : ContentPage { private readonly CommunityToolkit.Maui.Views.MediaElement _media = new() { Aspect = Aspect.AspectFit, ShouldAutoPlay = true, ShouldShowPlaybackControls = true, }; private readonly string? _itemId; // 스트리밍 재생 시 위치 동기화 대상 private bool _sync; // 위치 저장 활성(오프라인 재생은 서버 없이도 동작) public PlayerPage(LibraryItem item, string? localPath = null) { Title = item.FileName; if (localPath != null) { _media.Source = CommunityToolkit.Maui.Views.MediaSource.FromFile(localPath); } else { _itemId = item.Id; _media.Source = CommunityToolkit.Maui.Views.MediaSource.FromUri(ApiClient.Instance.StreamUrl(item)); // 열림 시 저장된 위치로 시크 → 5초 주기 동기화(U3) _media.MediaOpened += async (_, _) => { var saved = await ApiClient.Instance.GetPositionAsync(item.Id); if (saved > 1) await _media.SeekTo(TimeSpan.FromSeconds(saved)); _sync = true; }; Device.StartTimer(TimeSpan.FromSeconds(5), () => { if (!_sync || _media.Position <= TimeSpan.Zero) return true; _ = ApiClient.Instance.PostPositionAsync(item.Id, _media.Position.TotalSeconds); return true; // 페이지가 닫혀도 무해(타이머는 프로세스 수명) }); } Content = _media; // M2: 재생 중 미디어 알림(ongoing 노티) — 재생은 앱 내(포그라운드) 한정 StartPlaybackNotification(item.FileName); } protected override void OnDisappearing() { if (_sync && _itemId != null && _media.Position > TimeSpan.Zero) _ = ApiClient.Instance.PostPositionAsync(_itemId, _media.Position.TotalSeconds); StopPlaybackNotification(); _media.Stop(); _media.Handler?.DisconnectHandler(); // 재생 리소스 해제 base.OnDisappearing(); } private const int PlaybackNotificationId = 4711; /// M2: 재생 중 미디어 알림 — ongoing 노티(닫으면 자동 소멸). private static void StartPlaybackNotification(string title) { try { Plugin.LocalNotification.LocalNotificationCenter.Current.Show(new Plugin.LocalNotification.NotificationRequest { NotificationId = PlaybackNotificationId, Title = "재생 중", Description = title, Android = { ChannelId = "vd_playback", Ongoing = true, AutoCancel = false, }, }); } catch { } } private static void StopPlaybackNotification() { try { Plugin.LocalNotification.LocalNotificationCenter.Current.Cancel(PlaybackNotificationId); } catch { } } } /// M2: LibraryItem → 썸네일 URL 변환(바인딩 컨버터). public sealed class ThumbConverter : IValueConverter { public object? Convert(object? value, Type t, object? p, System.Globalization.CultureInfo c) => value is LibraryItem it && ApiClient.Instance.IsConfigured ? Microsoft.Maui.Controls.ImageSource.FromUri(new Uri(ApiClient.Instance.ThumbUrl(it))) : null; public object ConvertBack(object? v, Type t, object? p, System.Globalization.CultureInfo c) => throw new NotSupportedException(); }