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
|
|
@ -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);
|
||||
|
|
|
|||
7
VideoDownloader.Mobile/AppOfflineStore.cs
Normal file
7
VideoDownloader.Mobile/AppOfflineStore.cs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
namespace VideoDownloader.Mobile;
|
||||
|
||||
/// <summary>앱 데이터 디렉터리를 주입하는 오프라인 저장소 기본 인스턴스(Core 는 MAUI 비의존).</summary>
|
||||
public static class AppOfflineStore
|
||||
{
|
||||
public static OfflineStore Default { get; } = new(FileSystem.AppDataDirectory);
|
||||
}
|
||||
|
|
@ -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 { }
|
||||
}}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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" />
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue