T3-11/14/15 + M2 잔여: 권한 정책 UI(PermissionGate/PermissionRequested 다이얼로그+SavesInProfile), 추적 방지(TrackerBlocklist/WebResourceRequested 차단+실드 배지·팝업), 주소창 진행 바+파라미터 제거 복사(UrlCleaner/NavProgressTracker), U5 완료 알림(Mobile.Core 감지기+Plugin.LocalNotification), 재생중 ongoing 미디어 알림, 썸네일/해상도 API(Thumbnailer 사이드카 캐시+/thumb)+라이브러리 그리드, 외부망 안내(ConnectionTroubleshoot). 147 tests GREEN
This commit is contained in:
parent
27c91c04dd
commit
46c6c321f2
26 changed files with 1058 additions and 30 deletions
|
|
@ -70,13 +70,16 @@ public sealed class ConnectPage : ContentPage
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>M1 큐 페이지: 목록(2초 폴링) + URL 추가 + 제어.</summary>
|
||||
/// <summary>M1 큐 페이지: 목록(2초 폴링) + URL 추가 + 제어 + U5 완료 알림 + 외부망 안내.</summary>
|
||||
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<QueueItem> _last = new();
|
||||
|
||||
public QueuePage()
|
||||
{
|
||||
|
|
@ -131,19 +134,51 @@ public sealed class QueuePage : ContentPage
|
|||
Content = new VerticalStackLayout
|
||||
{
|
||||
Padding = 16, Spacing = 10,
|
||||
Children = { _url, addBtn, _summary, _list }
|
||||
Children = { _url, addBtn, _summary, _trouble, _list }
|
||||
};
|
||||
}
|
||||
|
||||
private async Task Refresh()
|
||||
{
|
||||
if (!ApiClient.Instance.IsConfigured) { _summary.Text = "⚠️ 먼저 연결하세요"; return; }
|
||||
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}건";
|
||||
}
|
||||
|
||||
/// <summary>U5: 완료 로컬 알림(Android 13+ 는 최초 1회 권한 요청).</summary>
|
||||
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);
|
||||
|
|
@ -159,11 +194,14 @@ public sealed class LibraryPage : ContentPage
|
|||
public LibraryPage()
|
||||
{
|
||||
Title = "라이브러리";
|
||||
_list.ItemsLayout = new GridItemsLayout(2, ItemsLayoutOrientation.Vertical); // M2 썸네일 그리드
|
||||
_list.ItemTemplate = new DataTemplate(() =>
|
||||
{
|
||||
var name = new Label { FontSize = 14, FontAttributes = FontAttributes.Bold, LineBreakMode = LineBreakMode.TailTruncation };
|
||||
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 = 11, TextColor = Colors.Gray };
|
||||
var size = new Label { FontSize = 10, TextColor = Colors.Gray };
|
||||
size.SetBinding(Label.TextProperty, new Binding("SizeBytes", stringFormat: "{0:N0} bytes"));
|
||||
|
||||
// U4 폰 저장: 카드 우측 버튼, 진행률을 버튼 텍스트로 표시
|
||||
|
|
@ -172,8 +210,8 @@ public sealed class LibraryPage : ContentPage
|
|||
|
||||
var card = new Frame
|
||||
{
|
||||
Margin = new Thickness(0, 4), Padding = 12, CornerRadius = 10,
|
||||
Content = new HorizontalStackLayout { Spacing = 8, Children = { new VerticalStackLayout { Children = { name, size } }, save } }
|
||||
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();
|
||||
|
|
@ -194,15 +232,15 @@ public sealed class LibraryPage : ContentPage
|
|||
private static async Task SaveOffline(Button button, LibraryItem? item)
|
||||
{
|
||||
if (item == null) return;
|
||||
if (OfflineStore.Default.Exists(item.Id)) { button.Text = "✓ 저장됨"; return; }
|
||||
if (AppOfflineStore.Default.Exists(item.Id)) { button.Text = "✓ 저장됨"; return; }
|
||||
button.Text = "0%";
|
||||
try
|
||||
{
|
||||
var dest = OfflineStore.Default.PathFor(item.Id);
|
||||
var dest = AppOfflineStore.Default.PathFor(item.Id);
|
||||
var progress = new Progress<double>(p => MainThread.BeginInvokeOnMainThread(
|
||||
() => button.Text = p >= 1 ? "✓ 저장됨" : $"{(int)(p * 100)}%"));
|
||||
await ApiClient.Instance.DownloadFileAsync(item, dest, progress, CancellationToken.None);
|
||||
OfflineStore.Default.Record(item.Id, item.FileName);
|
||||
AppOfflineStore.Default.Record(item.Id, item.FileName);
|
||||
button.Text = "✓ 저장됨";
|
||||
}
|
||||
catch (Exception e)
|
||||
|
|
@ -247,7 +285,7 @@ public sealed class DownloadsPage : ContentPage
|
|||
var del = new Button { Text = "🗑", FontSize = 12, Padding = new Thickness(10, 4), VerticalOptions = LayoutOptions.Center };
|
||||
del.Clicked += (_, _) =>
|
||||
{
|
||||
if ((del.BindingContext as OfflineItem) is { } it) { OfflineStore.Default.Delete(it.Id); Reload(); }
|
||||
if ((del.BindingContext as OfflineItem) is { } it) { AppOfflineStore.Default.Delete(it.Id); Reload(); }
|
||||
};
|
||||
|
||||
var card = new Frame
|
||||
|
|
@ -260,7 +298,7 @@ public sealed class DownloadsPage : ContentPage
|
|||
{
|
||||
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), OfflineStore.Default.PathFor(it.Id))));
|
||||
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;
|
||||
|
|
@ -277,7 +315,7 @@ public sealed class DownloadsPage : ContentPage
|
|||
|
||||
private void Reload()
|
||||
{
|
||||
var items = OfflineStore.Default.List();
|
||||
var items = AppOfflineStore.Default.List();
|
||||
_list.ItemsSource = items.Count > 0 ? items : null;
|
||||
_empty.IsVisible = items.Count == 0;
|
||||
}
|
||||
|
|
@ -321,14 +359,55 @@ public sealed class PlayerPage : ContentPage
|
|||
});
|
||||
}
|
||||
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;
|
||||
|
||||
/// <summary>M2: 재생 중 미디어 알림 — ongoing 노티(닫으면 자동 소멸).</summary>
|
||||
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 { }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>M2: LibraryItem → 썸네일 URL 변환(바인딩 컨버터).</summary>
|
||||
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();
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue