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:
Yun Chan 2026-08-15 18:11:49 +09:00
parent 27c91c04dd
commit 46c6c321f2
26 changed files with 1058 additions and 30 deletions

View file

@ -116,6 +116,7 @@ public sealed class ApiClient
public string StreamUrl(LibraryItem item) => $"{BaseUrl}/stream/{item.Id}?t={Token}";
public string FileUrl(LibraryItem item) => $"{BaseUrl}/file/{item.Id}?t={Token}";
public string ThumbUrl(LibraryItem item) => $"{BaseUrl}/api/library/{item.Id}/thumb?t={Token}";
/// <summary>이어보기 위치 조회(U3). 실패/미저장 시 0.</summary>
public async Task<double> GetPositionAsync(string id)
@ -188,6 +189,6 @@ public sealed class ApiClient
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);
public sealed record LibraryItem(string Id, string FileName, long SizeBytes, DateTime Modified, string? Resolution = null);
public sealed record PositionDto(double PositionSec);

View file

@ -0,0 +1,7 @@
namespace VideoDownloader.Mobile;
/// <summary>앱 데이터 디렉터리를 주입하는 오프라인 저장소 기본 인스턴스(Core 는 MAUI 비의존).</summary>
public static class AppOfflineStore
{
public static OfflineStore Default { get; } = new(FileSystem.AppDataDirectory);
}

View file

@ -1,73 +0,0 @@
using System.Text.Json;
namespace VideoDownloader.Mobile;
public sealed record OfflineItem(string Id, string FileName, long SizeBytes);
/// <summary>
/// 폰 오프라인 저장(U4): 라이브러리 항목을 앱 전용 폴더에 저장·목록·삭제.
/// manifest.json(id→파일명)으로 서버 항목과 매핑한다.
/// </summary>
public sealed class OfflineStore
{
public static OfflineStore Default { get; } = new();
private readonly string _dir;
private readonly string _manifestPath;
private readonly object _gate = new();
private Dictionary<string, string> _manifest;
public OfflineStore()
{
_dir = Path.Combine(FileSystem.AppDataDirectory, "offline");
_manifestPath = Path.Combine(_dir, "manifest.json");
try
{
if (File.Exists(_manifestPath))
_manifest = JsonSerializer.Deserialize<Dictionary<string, string>>(File.ReadAllText(_manifestPath)) ?? new();
}
catch { }
_manifest ??= new Dictionary<string, string>();
}
public string Directory => _dir;
public string PathFor(string id) => Path.Combine(_dir, id + Path.GetExtension(_manifest.GetValueOrDefault(id, ".mp4")));
public bool Exists(string id) { lock (_gate) { var p = PathFor(id); return File.Exists(p) && _manifest.ContainsKey(id); } }
/// <summary>다운로드 완료 후 등록.</summary>
public void Record(string id, string fileName)
{
lock (_gate)
{
System.IO.Directory.CreateDirectory(_dir);
_manifest[id] = fileName;
Save();
}
}
public void Delete(string id)
{
lock (_gate)
{
try { if (File.Exists(PathFor(id))) File.Delete(PathFor(id)); } catch { }
_manifest.Remove(id);
Save();
}
}
public List<OfflineItem> List()
{
lock (_gate)
return _manifest.Select(kv =>
{
var p = PathFor(kv.Key);
return new OfflineItem(kv.Key, kv.Value, File.Exists(p) ? new FileInfo(p).Length : 0);
}).Where(i => i.SizeBytes > 0).ToList();
}
private void Save()
{
try { System.IO.Directory.CreateDirectory(_dir); File.WriteAllText(_manifestPath, JsonSerializer.Serialize(_manifest)); } catch { }
}}

View file

@ -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();
}

View file

@ -4,5 +4,9 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.INTERNET" />
<!-- U5 완료 알림(Android 13+) -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- M2 재생중 미디어 알림(포그라운드 서비스 mediaPlayback) -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
</manifest>

View file

@ -66,8 +66,14 @@
<MauiAsset Include="Resources\Raw\**" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\VideoDownloader.Mobile.Core\VideoDownloader.Mobile.Core.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Maui.Controls" Version="$(MauiVersion)" />
<!-- U5 완료 알림 -->
<PackageReference Include="Plugin.LocalNotification" Version="11.1.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="10.0.0" />
<PackageReference Include="ZXing.Net.Maui.Controls" Version="0.4.0" />
<PackageReference Include="CommunityToolkit.Maui.MediaElement" Version="3.2.0" />