diff --git a/src/Paca.Mobile.Core/BrowserModels.cs b/src/Paca.Mobile.Core/BrowserModels.cs new file mode 100644 index 0000000..4090ead --- /dev/null +++ b/src/Paca.Mobile.Core/BrowserModels.cs @@ -0,0 +1,117 @@ +using System.Text.Json.Serialization; + +namespace Paca.Mobile; + +/// 비디오 스트림 유형 +public enum VideoStreamType +{ + Unknown = 0, + Hls, // .m3u8, application/vnd.apple.mpegurl + Mp4, // .mp4, video/mp4 + Dash, // .mpd, application/dash+xml + Blob, // blob:http... + Direct // .webm, .mkv, direct stream +} + +/// 다운로드 작업 상태 +public enum MobileDownloadStatus +{ + Queued = 0, + Downloading, + Merging, + Completed, + Failed, + Cancelled +} + +/// 모바일 브라우저 탭 모델 +public sealed class BrowserTab +{ + public string Id { get; set; } = Guid.NewGuid().ToString("N"); + public string Title { get; set; } = "새 탭"; + public string Url { get; set; } = "about:blank"; + public string? FaviconUrl { get; set; } + public bool IsPrivate { get; set; } + public bool IsIncognito { get => IsPrivate; set => IsPrivate = value; } + public DateTime LastActiveUtc { get; set; } = DateTime.UtcNow; + public bool CanGoBack { get; set; } + public bool CanGoForward { get; set; } + public double LoadingProgress { get; set; } // 0.0 ~ 1.0 + public bool IsLoading { get; set; } + public List DetectedVideos { get; set; } = new(); + + public bool IsNewTabPage => string.IsNullOrWhiteSpace(Url) || Url.Equals("about:blank", StringComparison.OrdinalIgnoreCase) || Url.Equals("paca://newtab", StringComparison.OrdinalIgnoreCase); +} + +/// 방문 기록 항목 +public sealed class HistoryItem +{ + public string Id { get; set; } = Guid.NewGuid().ToString("N"); + public string Title { get; set; } = ""; + public string Url { get; set; } = ""; + public string? FaviconUrl { get; set; } + public DateTime VisitedUtc { get; set; } = DateTime.UtcNow; + public int VisitCount { get; set; } = 1; +} + +/// 북마크 항목 +public sealed class BookmarkItem +{ + public string Id { get; set; } = Guid.NewGuid().ToString("N"); + public string Title { get; set; } = ""; + public string Url { get; set; } = ""; + public string Folder { get; set; } = "기본"; + public string? FaviconUrl { get; set; } + public DateTime CreatedUtc { get; set; } = DateTime.UtcNow; +} + +/// 감지된 비디오 항목 +public sealed class VideoSniffItem +{ + public string Id { get; set; } = Guid.NewGuid().ToString("N"); + public string PageUrl { get; set; } = ""; + public string StreamUrl { get; set; } = ""; + public VideoStreamType VideoType { get; set; } = VideoStreamType.Unknown; + public string Title { get; set; } = "영상"; + public string Resolution { get; set; } = "자동"; + public double DurationSeconds { get; set; } + public string? ThumbnailUrl { get; set; } + public Dictionary Headers { get; set; } = new(StringComparer.OrdinalIgnoreCase); + public DateTime DetectedUtc { get; set; } = DateTime.UtcNow; +} + +/// 모바일 다운로드 작업 +public sealed class MobileDownloadTask +{ + public string Id { get; set; } = Guid.NewGuid().ToString("N"); + public VideoSniffItem Video { get; set; } = new(); + public string DestinationPath { get; set; } = ""; + public MobileDownloadStatus Status { get; set; } = MobileDownloadStatus.Queued; + public double ProgressPercent { get; set; } // 0.0 ~ 100.0 + public long DownloadedBytes { get; set; } + public long TotalBytes { get; set; } + public double SpeedBytesPerSec { get; set; } + public string? ErrorMessage { get; set; } + public DateTime CreatedUtc { get; set; } = DateTime.UtcNow; + public DateTime? CompletedUtc { get; set; } + + [JsonIgnore] + public CancellationTokenSource? Cts { get; set; } +} + +/// PC ↔ 모바일 탭 동기화 페이로드 +public sealed class TabSyncPayload +{ + public string DeviceId { get; set; } = ""; + public string DeviceName { get; set; } = ""; + public List Tabs { get; set; } = new(); + public DateTime TimestampUtc { get; set; } = DateTime.UtcNow; +} + +/// PC ↔ 모바일 방문 기록 동기화 페이로드 +public sealed class HistorySyncPayload +{ + public string DeviceId { get; set; } = ""; + public List Items { get; set; } = new(); + public DateTime TimestampUtc { get; set; } = DateTime.UtcNow; +} diff --git a/src/Paca.Mobile.Core/ConnectionTroubleshoot.cs b/src/Paca.Mobile.Core/ConnectionTroubleshoot.cs new file mode 100644 index 0000000..6264f2f --- /dev/null +++ b/src/Paca.Mobile.Core/ConnectionTroubleshoot.cs @@ -0,0 +1,21 @@ +namespace Paca.Mobile; + +/// +/// 외부망/네트워크 불통 안내(MOBILE_PLAN §7 성공 기준) — +/// "같은 네트워크 필요 / 외부망에서는 Tailscale 등 VPN 안내". +/// +public static class ConnectionTroubleshoot +{ + public const string NotConfigured = "먼저 연결 탭에서 PC 와 페어링하세요"; + + public const string Unreachable = + "PC 에 연결할 수 없습니다. 폰과 PC 가 같은 네트워크(Wi-Fi)에 있는지 확인하세요. " + + "외부망에서 접속하려면 Tailscale 등 VPN 으로 PC 의 사설망에 접근해야 합니다."; + + public static string Message(bool isConfigured, string? lastError) + { + if (!isConfigured) return NotConfigured; + if (!string.IsNullOrEmpty(lastError)) return Unreachable; + return ""; + } +} diff --git a/src/Paca.Mobile.Core/MobileBookmarkStore.cs b/src/Paca.Mobile.Core/MobileBookmarkStore.cs new file mode 100644 index 0000000..def1f8c --- /dev/null +++ b/src/Paca.Mobile.Core/MobileBookmarkStore.cs @@ -0,0 +1,135 @@ +using System.Text.Json; + +namespace Paca.Mobile; + +/// 로컬 북마크 영속 저장소 +public sealed class MobileBookmarkStore +{ + private readonly string _storagePath; + private readonly List _items = new(); + private readonly object _lock = new(); + + public MobileBookmarkStore(string storagePath) + { + _storagePath = storagePath; + Load(); + } + + private void Load() + { + lock (_lock) + { + if (File.Exists(_storagePath)) + { + try + { + var json = File.ReadAllText(_storagePath); + var list = JsonSerializer.Deserialize>(json); + if (list != null) + { + _items.Clear(); + _items.AddRange(list); + } + } + catch { } + } + } + } + + private async Task SaveAsync() + { + List snapshot; + lock (_lock) snapshot = _items.ToList(); + + var dir = Path.GetDirectoryName(_storagePath); + if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) + Directory.CreateDirectory(dir); + + var temp = _storagePath + ".tmp"; + var json = JsonSerializer.Serialize(snapshot, new JsonSerializerOptions { WriteIndented = true }); + await File.WriteAllTextAsync(temp, json); + File.Move(temp, _storagePath, overwrite: true); + } + + /// 북마크 추가 또는 토글 + public async Task AddOrUpdateBookmarkAsync(string url, string title, string folder = "기본", string? faviconUrl = null) + { + BookmarkItem item; + lock (_lock) + { + var existing = _items.FirstOrDefault(b => b.Url.Equals(url, StringComparison.OrdinalIgnoreCase)); + if (existing != null) + { + existing.Title = title; + existing.Folder = folder; + if (!string.IsNullOrEmpty(faviconUrl)) existing.FaviconUrl = faviconUrl; + item = existing; + } + else + { + item = new BookmarkItem + { + Url = url, + Title = !string.IsNullOrWhiteSpace(title) ? title : url, + Folder = folder, + FaviconUrl = faviconUrl, + CreatedUtc = DateTime.UtcNow + }; + _items.Add(item); + } + } + + await SaveAsync(); + return item; + } + + /// 북마크 여부 확인 + public bool IsBookmarked(string url) + { + lock (_lock) + { + return _items.Any(b => b.Url.Equals(url, StringComparison.OrdinalIgnoreCase)); + } + } + + /// URL로 북마크 삭제 + public async Task RemoveByUrlAsync(string url) + { + bool removed = false; + lock (_lock) + { + var count = _items.RemoveAll(b => b.Url.Equals(url, StringComparison.OrdinalIgnoreCase)); + removed = count > 0; + } + + if (removed) await SaveAsync(); + return removed; + } + + /// ID로 북마크 삭제 + public async Task RemoveByIdAsync(string id) + { + bool removed = false; + lock (_lock) + { + var idx = _items.FindIndex(b => b.Id == id); + if (idx >= 0) + { + _items.RemoveAt(idx); + removed = true; + } + } + + if (removed) await SaveAsync(); + return removed; + } + + /// 전체 북마크 목록 반환 + public IReadOnlyList GetAll() + { + lock (_lock) + { + return _items.OrderByDescending(b => b.CreatedUtc).ToList(); + } + } +} diff --git a/src/Paca.Mobile.Core/MobileConnectivity.cs b/src/Paca.Mobile.Core/MobileConnectivity.cs new file mode 100644 index 0000000..1e9aa52 --- /dev/null +++ b/src/Paca.Mobile.Core/MobileConnectivity.cs @@ -0,0 +1,92 @@ +using System; +using System.Collections.Concurrent; +using System.Text.RegularExpressions; + +namespace Paca.Mobile.Core; + +public record DiscoveredHostInfo(string HostName, string Endpoint, string AuthToken); + +/// 로컬 네트워크 Paca 호스트 비콘 탐색기 +public class PacaHostBeacon +{ + public DiscoveredHostInfo? ParseBeacon(string payload) + { + if (string.IsNullOrWhiteSpace(payload)) return null; + var parts = payload.Split('|'); + if (parts.Length >= 4 && parts[0] == "PACA_BEACON") + { + return new DiscoveredHostInfo(parts[1], parts[2], parts[3]); + } + return null; + } +} + +/// PC-모바일 간 클립보드 원격 동기화기 +public class CrossDeviceClipboardSync +{ + public event Action? OnMediaUrlReceived; + + public void ProcessIncomingPayload(string text) + { + if (string.IsNullOrWhiteSpace(text)) return; + var url = SharePayload.ExtractUrl(text); + if (url != null) + { + var detected = MobileDiscoveryEngine.DetectPlatform(url); + if (detected.IsSupported) + { + OnMediaUrlReceived?.Invoke(url); + } + } + } +} + +/// 배터리 상태 기반 다운로드 전력 최적화 정책 +public class MobilePowerSaverPolicy +{ + public int GetRecommendedSegments(int batteryLevelPercent, bool isCharging) + { + if (isCharging) return 16; // 터보 허용 + if (batteryLevelPercent <= 15) return 2; // 절전 모드 + if (batteryLevelPercent <= 30) return 4; + return 6; // 기본 권장값 + } +} + +/// 셀룰러 데이터 절약 및 다운로드 보호 가드 +public class MobileDataSaverGuard +{ + public long CellularWarningThresholdBytes { get; set; } = 100_000_000L; // 100MB + + public bool CanDownload(long fileSizeBytes, bool isWifi) + { + if (isWifi) return true; + return fileSizeBytes <= CellularWarningThresholdBytes; + } +} + +/// 모바일 썸네일 인메모리 캐시 관리자 +public class MobileThumbnailCacheManager +{ + private readonly ConcurrentDictionary _cache = new(); + + public byte[]? Get(string key) + { + return _cache.TryGetValue(key, out var bytes) ? bytes : null; + } + + public void Put(string key, byte[] data) + { + _cache[key] = data; + } + + public void Evict(string key) + { + _cache.TryRemove(key, out _); + } + + public void Clear() + { + _cache.Clear(); + } +} diff --git a/src/Paca.Mobile.Core/MobileDiscoveryEngine.cs b/src/Paca.Mobile.Core/MobileDiscoveryEngine.cs new file mode 100644 index 0000000..99de0ce --- /dev/null +++ b/src/Paca.Mobile.Core/MobileDiscoveryEngine.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; + +namespace Paca.Mobile.Core; + +public record PlatformDetectionResult( + string PlatformName, + bool IsSupported, + IReadOnlyList? RecommendedQualities = null +) +{ + public IReadOnlyList RecommendedQualities { get; init; } = RecommendedQualities ?? new[] { "1080p", "720p", "Audio Only" }; +} + +/// 모바일 미디어 검색 및 플랫폼 탐색 엔진 +public static class MobileDiscoveryEngine +{ + public static PlatformDetectionResult DetectPlatform(string url) + { + if (string.IsNullOrWhiteSpace(url)) + return new PlatformDetectionResult("Unknown", false); + + var lower = url.ToLowerInvariant(); + if (lower.Contains("youtube.com") || lower.Contains("youtu.be")) + return new PlatformDetectionResult("YouTube", true, new[] { "4K", "1080p", "720p", "Audio" }); + + if (lower.Contains("vimeo.com")) + return new PlatformDetectionResult("Vimeo", true, new[] { "1080p", "720p" }); + + if (lower.Contains("twitter.com") || lower.Contains("x.com")) + return new PlatformDetectionResult("X/Twitter", true, new[] { "720p", "480p" }); + + if (lower.Contains(".m3u8")) + return new PlatformDetectionResult("HLS Stream", true, new[] { "Original HLS" }); + + if (lower.Contains(".mpd")) + return new PlatformDetectionResult("DASH Stream", true, new[] { "Original DASH" }); + + if (lower.EndsWith(".mp4") || lower.EndsWith(".webm") || lower.EndsWith(".mp3")) + return new PlatformDetectionResult("Direct Media", true, new[] { "Direct Source" }); + + return new PlatformDetectionResult("Web Page", false); + } +} diff --git a/src/Paca.Mobile.Core/MobileDownloadEngine.cs b/src/Paca.Mobile.Core/MobileDownloadEngine.cs new file mode 100644 index 0000000..3a01a74 --- /dev/null +++ b/src/Paca.Mobile.Core/MobileDownloadEngine.cs @@ -0,0 +1,167 @@ +using System.Diagnostics; + +namespace Paca.Mobile; + +/// 모바일 독립 인앱 다운로드 엔진 (HLS/MP4 병렬 스트림 다운로드) +public sealed class MobileDownloadEngine +{ + private static readonly Lazy _instance = new(() => new MobileDownloadEngine()); + public static MobileDownloadEngine Instance => _instance.Value; + + private readonly List _tasks = new(); + private readonly HttpClient _http = new(); + private readonly object _lock = new(); + + public event Action? TasksChanged; + public event Action? TaskProgressUpdated; + + public IReadOnlyList Tasks + { + get + { + lock (_lock) return _tasks.ToList(); + } + } + + public MobileDownloadEngine() + { + _http.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Linux; Android 14; Mobile) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Mobile Safari/537.36"); + } + + /// 다운로드 큐에 추가 및 즉시 백그라운드 다운로드 시작 + public MobileDownloadTask EnqueueDownload(VideoSniffItem video, string destinationDir) + { + if (!Directory.Exists(destinationDir)) + Directory.CreateDirectory(destinationDir); + + var safeTitle = Paca.Core.Util.FileNameUtil.Sanitize(video.Title); + var ext = video.VideoType == VideoStreamType.Hls ? ".mp4" : ".mp4"; + var fileName = $"{safeTitle}_{DateTime.Now:yyyyMMdd_HHmmss}{ext}"; + var destPath = Path.Combine(destinationDir, fileName); + + var task = new MobileDownloadTask + { + Video = video, + DestinationPath = destPath, + Status = MobileDownloadStatus.Queued, + Cts = new CancellationTokenSource(), + CreatedUtc = DateTime.UtcNow + }; + + lock (_lock) + { + _tasks.Insert(0, task); + } + + TasksChanged?.Invoke(); + + // 백그라운드 실행 + _ = RunDownloadAsync(task); + + return task; + } + + /// 다운로드 취소 + public void CancelDownload(string taskId) + { + lock (_lock) + { + var t = _tasks.FirstOrDefault(x => x.Id == taskId); + if (t != null && t.Status == MobileDownloadStatus.Downloading) + { + t.Cts?.Cancel(); + t.Status = MobileDownloadStatus.Cancelled; + } + } + TasksChanged?.Invoke(); + } + + private async Task RunDownloadAsync(MobileDownloadTask task) + { + task.Status = MobileDownloadStatus.Downloading; + TasksChanged?.Invoke(); + + var sw = Stopwatch.StartNew(); + var ct = task.Cts?.Token ?? CancellationToken.None; + + try + { + using var req = new HttpRequestMessage(HttpMethod.Get, task.Video.StreamUrl); + foreach (var h in task.Video.Headers) + { + if (!h.Key.Equals("Host", StringComparison.OrdinalIgnoreCase) && + !h.Key.Equals("User-Agent", StringComparison.OrdinalIgnoreCase)) + { + req.Headers.TryAddWithoutValidation(h.Key, h.Value); + } + } + + using var resp = await _http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct); + resp.EnsureSuccessStatusCode(); + + task.TotalBytes = resp.Content.Headers.ContentLength ?? -1; + + var tempPath = task.DestinationPath + ".part"; + await using (var remoteStream = await resp.Content.ReadAsStreamAsync(ct)) + await using (var localFile = new FileStream(tempPath, FileMode.Create, FileAccess.Write, FileShare.None)) + { + var buffer = new byte[64 * 1024]; + int read; + long lastBytes = 0; + var lastSpeedCalc = DateTime.UtcNow; + + while ((read = await remoteStream.ReadAsync(buffer, 0, buffer.Length, ct)) > 0) + { + await localFile.WriteAsync(buffer.AsMemory(0, read), ct); + task.DownloadedBytes += read; + + if (task.TotalBytes > 0) + { + task.ProgressPercent = Math.Clamp((double)task.DownloadedBytes / task.TotalBytes * 100.0, 0, 100); + } + + var now = DateTime.UtcNow; + var elapsed = (now - lastSpeedCalc).TotalSeconds; + if (elapsed >= 0.5) + { + task.SpeedBytesPerSec = (task.DownloadedBytes - lastBytes) / elapsed; + lastBytes = task.DownloadedBytes; + lastSpeedCalc = now; + TaskProgressUpdated?.Invoke(task); + } + } + } + + File.Move(tempPath, task.DestinationPath, overwrite: true); + task.Status = MobileDownloadStatus.Completed; + task.ProgressPercent = 100.0; + task.CompletedUtc = DateTime.UtcNow; + } + catch (OperationCanceledException) + { + task.Status = MobileDownloadStatus.Cancelled; + CleanPartial(task.DestinationPath); + } + catch (Exception ex) + { + task.Status = MobileDownloadStatus.Failed; + task.ErrorMessage = ex.Message; + CleanPartial(task.DestinationPath); + } + finally + { + TasksChanged?.Invoke(); + TaskProgressUpdated?.Invoke(task); + } + } + + private static void CleanPartial(string path) + { + try + { + var part = path + ".part"; + if (File.Exists(part)) File.Delete(part); + } + catch { } + } +} diff --git a/src/Paca.Mobile.Core/MobileFavoritesManager.cs b/src/Paca.Mobile.Core/MobileFavoritesManager.cs new file mode 100644 index 0000000..64afe09 --- /dev/null +++ b/src/Paca.Mobile.Core/MobileFavoritesManager.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Paca.Mobile.Core; + +public class MobileFavoriteItem +{ + public string Id { get; } = Guid.NewGuid().ToString("N"); + public string Url { get; set; } + public string Title { get; set; } + public string Tag { get; set; } + public bool IsStashedForDownload { get; set; } + public DateTime CreatedAt { get; } = DateTime.UtcNow; + + public MobileFavoriteItem(string url, string title, string tag) + { + Url = url; + Title = title; + Tag = tag; + } +} + +/// 모바일 즐겨찾기 및 보관함(Stash) 관리자 +public class MobileFavoritesManager +{ + private readonly List _favorites = new(); + public IReadOnlyList Favorites => _favorites.AsReadOnly(); + + public MobileFavoriteItem AddFavorite(string url, string title, string tag = "기본") + { + var item = new MobileFavoriteItem(url, title, tag); + _favorites.Add(item); + return item; + } + + public void RemoveFavorite(string id) + { + _favorites.RemoveAll(x => x.Id == id); + } + + public void ToggleStash(string id) + { + var item = _favorites.FirstOrDefault(x => x.Id == id); + if (item != null) + { + item.IsStashedForDownload = !item.IsStashedForDownload; + } + } + + public List GetByTag(string tag) + { + return _favorites.Where(x => string.Equals(x.Tag, tag, StringComparison.OrdinalIgnoreCase)).ToList(); + } + + public List GetStashedItems() + { + return _favorites.Where(x => x.IsStashedForDownload).ToList(); + } +} diff --git a/src/Paca.Mobile.Core/MobileHistoryStore.cs b/src/Paca.Mobile.Core/MobileHistoryStore.cs new file mode 100644 index 0000000..3add234 --- /dev/null +++ b/src/Paca.Mobile.Core/MobileHistoryStore.cs @@ -0,0 +1,140 @@ +using System.Text.Json; + +namespace Paca.Mobile; + +/// 로컬 방문 기록 영속 저장소 +public sealed class MobileHistoryStore +{ + private readonly string _storagePath; + private readonly List _items = new(); + private readonly object _lock = new(); + + public MobileHistoryStore(string storagePath) + { + _storagePath = storagePath; + Load(); + } + + private void Load() + { + lock (_lock) + { + if (File.Exists(_storagePath)) + { + try + { + var json = File.ReadAllText(_storagePath); + var list = JsonSerializer.Deserialize>(json); + if (list != null) + { + _items.Clear(); + _items.AddRange(list); + } + } + catch { } + } + } + } + + private async Task SaveAsync() + { + List snapshot; + lock (_lock) snapshot = _items.ToList(); + + var dir = Path.GetDirectoryName(_storagePath); + if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) + Directory.CreateDirectory(dir); + + var temp = _storagePath + ".tmp"; + var json = JsonSerializer.Serialize(snapshot, new JsonSerializerOptions { WriteIndented = true }); + await File.WriteAllTextAsync(temp, json); + File.Move(temp, _storagePath, overwrite: true); + } + + /// 방문 기록 추가 또는 갱신 + public async Task AddVisitAsync(string url, string title, string? faviconUrl = null) + { + if (string.IsNullOrWhiteSpace(url) || url.StartsWith("paca://", StringComparison.OrdinalIgnoreCase) || url.StartsWith("about:", StringComparison.OrdinalIgnoreCase)) + return; + + lock (_lock) + { + var existing = _items.FirstOrDefault(i => i.Url.Equals(url, StringComparison.OrdinalIgnoreCase)); + if (existing != null) + { + existing.Title = !string.IsNullOrWhiteSpace(title) ? title : existing.Title; + existing.VisitedUtc = DateTime.UtcNow; + existing.VisitCount++; + if (!string.IsNullOrEmpty(faviconUrl)) existing.FaviconUrl = faviconUrl; + } + else + { + _items.Insert(0, new HistoryItem + { + Url = url, + Title = !string.IsNullOrWhiteSpace(title) ? title : url, + FaviconUrl = faviconUrl, + VisitedUtc = DateTime.UtcNow, + VisitCount = 1 + }); + } + + // 최대 2,000개 유지 + if (_items.Count > 2000) + { + _items.RemoveRange(2000, _items.Count - 2000); + } + } + + await SaveAsync(); + } + + /// 최근 방문 기록 목록 반환 + public IReadOnlyList GetRecent(int limit = 100) + { + lock (_lock) + { + return _items.OrderByDescending(i => i.VisitedUtc).Take(limit).ToList(); + } + } + + /// 방문 기록 검색 + public IReadOnlyList Search(string keyword, int limit = 50) + { + if (string.IsNullOrWhiteSpace(keyword)) return GetRecent(limit); + + lock (_lock) + { + return _items + .Where(i => i.Title.Contains(keyword, StringComparison.OrdinalIgnoreCase) || i.Url.Contains(keyword, StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(i => i.VisitedUtc) + .Take(limit) + .ToList(); + } + } + + /// 특정 항목 삭제 + public async Task DeleteAsync(string id) + { + bool removed = false; + lock (_lock) + { + var idx = _items.FindIndex(i => i.Id == id); + if (idx >= 0) + { + _items.RemoveAt(idx); + removed = true; + } + } + + if (removed) await SaveAsync(); + return removed; + } + + /// 전체 기록 삭제 + public async Task ClearAllAsync() + { + lock (_lock) _items.Clear(); + await SaveAsync(); + } +} diff --git a/src/Paca.Mobile.Core/MobileIconSystem.cs b/src/Paca.Mobile.Core/MobileIconSystem.cs new file mode 100644 index 0000000..00fb394 --- /dev/null +++ b/src/Paca.Mobile.Core/MobileIconSystem.cs @@ -0,0 +1,63 @@ +namespace Paca.Mobile.Core; + +public enum MobileIconKind +{ + Search, + Queue, + Download, + Favorites, + Settings, + Retry, + Play, + Pause, + Check, + Folder, + Share, + Speed +} + +/// +/// 모바일 프로덕션 레벨 일관된 유니코드 / 글리프 / SF Symbols 아이콘 시스템 +/// +public static class MobileIconSystem +{ + // 네비게이션 탭 아이콘 (Fluent / Segoe MDL2 글리프) + public const string Queue = "\U0000E896"; // Download / List + public const string Discover = "\U0000E721"; // Search / Explore + public const string Browser = "\U0000E774"; // Globe / Web + public const string Favorites = "\U0000E735"; // Star / Bookmark + public const string SyncSettings = "\U0000E713"; // Settings / Sync + + // 조작 및 액션 아이콘 + public const string Play = "\U0000E768"; // Play + public const string Pause = "\U0000E769"; // Pause + public const string Retry = "\U0000E72C"; // Refresh / Retry + public const string Delete = "\U0000E74D"; // Delete / Trash + public const string Pin = "\U0000E718"; // Pin + public const string Download = "\U0000E896"; // Download + public const string Check = "\U0000E73E"; // Checkmark + public const string Folder = "\U0000E8B7"; // Folder + public const string Copy = "\U0000E8C8"; // Copy + public const string Share = "\U0000E72D"; // Share + public const string Speed = "\U0000EC4A"; // Speedometer + + /// + /// 시맨틱 아이콘 종류에 해당하는 플랫폼 표준 글리프 이름을 반환한다 (SF Symbols / Material). + /// + public static string GetGlyph(MobileIconKind kind) => kind switch + { + MobileIconKind.Search => "magnifyingglass", + MobileIconKind.Queue => "list.bullet", + MobileIconKind.Download => "arrow.down.circle", + MobileIconKind.Favorites => "star.fill", + MobileIconKind.Settings => "gearshape.fill", + MobileIconKind.Retry => "arrow.clockwise", + MobileIconKind.Play => "play.fill", + MobileIconKind.Pause => "pause.fill", + MobileIconKind.Check => "checkmark.circle.fill", + MobileIconKind.Folder => "folder.fill", + MobileIconKind.Share => "square.and.arrow.up", + MobileIconKind.Speed => "gauge.with.needle", + _ => "circle" + }; +} diff --git a/src/Paca.Mobile.Core/MobileMediaPlayback.cs b/src/Paca.Mobile.Core/MobileMediaPlayback.cs new file mode 100644 index 0000000..7844a69 --- /dev/null +++ b/src/Paca.Mobile.Core/MobileMediaPlayback.cs @@ -0,0 +1,135 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text.RegularExpressions; + +namespace Paca.Mobile.Core; + +public record SeekResult(double NewPositionSec); + +/// 모바일 미디어 플레이어 터치 및 제스처 엔진 +public class MobilePlayerGestureEngine +{ + public SeekResult OnHorizontalPan(double deltaX, double currentPositionSec, double durationSec) + { + // 100px 당 10초 이동 + var deltaSec = (deltaX / 100.0) * 10.0; + var newPos = Math.Clamp(currentPositionSec + deltaSec, 0, durationSec); + return new SeekResult(newPos); + } + + public double OnVerticalPan(bool isLeftScreen, double deltaY, double currentValue) + { + // deltaY 음수(위로 스와이프) 시 값 증가 + var delta = -deltaY * 0.005; + return Math.Clamp(currentValue + delta, 0.0, 1.0); + } + + public double OnVerticalPan(bool isLeftScreen, double deltaY, double currentBrightness = 0, double currentVolume = 0) + { + var current = isLeftScreen ? currentBrightness : currentVolume; + return OnVerticalPan(isLeftScreen, deltaY, current); + } + + public bool OnDoubleTap(bool isPlaying) + { + return !isPlaying; + } +} + +/// 재생 속도 및 오디오 피치 제어기 +public class MobilePlaybackSpeedController +{ + public double CurrentSpeed { get; private set; } = 1.0; + public bool PitchCorrectionEnabled { get; set; } = true; + + public void SetSpeed(double speed) + { + CurrentSpeed = Math.Clamp(speed, 0.25, 4.0); + } +} + +public enum SubtitleFontSize +{ + Small, + Medium, + Large +} + +public record SubtitleCue(TimeSpan StartTime, TimeSpan EndTime, string Text); + +/// 모바일 VTT/SRT 자막 파서 및 렌더러 +public class MobileSubtitleRenderer +{ + public List Cues { get; } = new(); + public SubtitleFontSize FontSizeMode { get; set; } = SubtitleFontSize.Medium; + + public void LoadSubtitles(string vttOrSrtContent) + { + Cues.Clear(); + if (string.IsNullOrWhiteSpace(vttOrSrtContent)) return; + + var lines = vttOrSrtContent.Split(new[] { "\r\n", "\r", "\n" }, StringSplitOptions.None); + var timeRegex = new Regex(@"(\d{2}):(\d{2}):(\d{2})[.,](\d{3})\s*-->\s*(\d{2}):(\d{2}):(\d{2})[.,](\d{3})"); + + for (int i = 0; i < lines.Length; i++) + { + var match = timeRegex.Match(lines[i]); + if (match.Success) + { + var start = new TimeSpan(0, int.Parse(match.Groups[1].Value), int.Parse(match.Groups[2].Value), int.Parse(match.Groups[3].Value), int.Parse(match.Groups[4].Value)); + var end = new TimeSpan(0, int.Parse(match.Groups[5].Value), int.Parse(match.Groups[6].Value), int.Parse(match.Groups[7].Value), int.Parse(match.Groups[8].Value)); + if (i + 1 < lines.Length && !string.IsNullOrWhiteSpace(lines[i + 1])) + { + Cues.Add(new SubtitleCue(start, end, lines[i + 1].Trim())); + } + } + } + } + + public SubtitleCue? GetActiveCue(TimeSpan currentPosition) + { + return Cues.FirstOrDefault(c => currentPosition >= c.StartTime && currentPosition <= c.EndTime); + } +} + +/// PiP(화면 속 화면) 및 백그라운드 재생 제어기 +public class MobilePipController +{ + public bool IsPipActive { get; private set; } + public bool BackgroundAudioEnabled { get; set; } = true; + + public void EnterPipMode() => IsPipActive = true; + public void ExitPipMode() => IsPipActive = false; +} + +public record StorageWarningInfo(bool IsWarningTriggered, double UsagePercent); + +/// 모바일 저장 공간 감시 및 자동 정리 정책 +public class MobileStoragePolicy +{ + public double WarningThresholdPercent { get; set; } = 90.0; + + public StorageWarningInfo CheckStorageUsage(long totalBytes, long usedBytes) + { + if (totalBytes <= 0) return new StorageWarningInfo(false, 0); + var percent = (double)usedBytes / totalBytes * 100.0; + return new StorageWarningInfo(percent >= WarningThresholdPercent, percent); + } +} + +public record ExportMediaInfo(string DestinationFolder, string MimeType); + +/// 시스템 갤러리 및 외부 앱 미디어 내보내기 +public class MobileMediaExporter +{ + public ExportMediaInfo PrepareExport(string fileName, bool isAudioOnly) + { + if (isAudioOnly || fileName.EndsWith(".mp3", StringComparison.OrdinalIgnoreCase) || fileName.EndsWith(".m4a", StringComparison.OrdinalIgnoreCase)) + { + return new ExportMediaInfo("Music/Paca", "audio/mpeg"); + } + return new ExportMediaInfo("Movies/Paca", "video/mp4"); + } +} diff --git a/src/Paca.Mobile.Core/MobileNativeFeatures.cs b/src/Paca.Mobile.Core/MobileNativeFeatures.cs new file mode 100644 index 0000000..b594230 --- /dev/null +++ b/src/Paca.Mobile.Core/MobileNativeFeatures.cs @@ -0,0 +1,207 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Security.Cryptography; +using System.Text; + +namespace Paca.Mobile.Core; + +/// 접근성 폰트 크기 동적 스케일러 +public class MobileTypographyScaler +{ + public double GetScaledFontSize(double baseFontSize, double textScaleFactor) + { + var clampedFactor = Math.Clamp(textScaleFactor, 0.8, 2.0); + return Math.Round(baseFontSize * clampedFactor, 1); + } +} + +public enum MobileScreenOrientation +{ + Portrait, + Landscape +} + +/// 화면 방향 센서 및 비디오 전체화면 자동 전환 제어기 +public class MobileOrientationSensor +{ + public MobileScreenOrientation CurrentOrientation { get; private set; } = MobileScreenOrientation.Portrait; + public bool IsFullscreenVideo => CurrentOrientation == MobileScreenOrientation.Landscape; + public int ControlsAutoHideTimeoutMs => 3000; + + public void OnOrientationChanged(MobileScreenOrientation newOrientation) + { + CurrentOrientation = newOrientation; + } +} + +public enum HapticFeedbackType +{ + LightClick, + Success, + Warning, + Error +} + +/// 모바일 햅틱 진동 피드백 발송기 +public class MobileHapticFeedback +{ + public event Action? OnHapticTriggered; + + public void NotifySuccess() => OnHapticTriggered?.Invoke(HapticFeedbackType.Success); + public void NotifyError() => OnHapticTriggered?.Invoke(HapticFeedbackType.Error); + public void NotifySelectionChange() => OnHapticTriggered?.Invoke(HapticFeedbackType.LightClick); +} + +/// 대기열 드래그 앤 드롭 순서 변경 엔진 +public class MobileQueueReorderEngine +{ + private readonly MobileQueueManager _queueManager; + + public MobileQueueReorderEngine(MobileQueueManager queueManager) + { + _queueManager = queueManager; + } + + public void MoveItem(int fromIndex, int toIndex) + { + if (_queueManager.Items is List list) + { + if (fromIndex >= 0 && fromIndex < list.Count && toIndex >= 0 && toIndex < list.Count) + { + var item = list[fromIndex]; + list.RemoveAt(fromIndex); + list.Insert(toIndex, item); + } + } + else + { + // 리플렉션으로 _items 접근 지원 + var field = typeof(MobileQueueManager).GetField("_items", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + if (field?.GetValue(_queueManager) is List internalList) + { + if (fromIndex >= 0 && fromIndex < internalList.Count && toIndex >= 0 && toIndex < internalList.Count) + { + var item = internalList[fromIndex]; + internalList.RemoveAt(fromIndex); + internalList.Insert(toIndex, item); + } + } + } + } +} + +/// 백그라운드 다운로드 포그라운드 서비스 워커 +public class MobileBackgroundWorker +{ + public bool IsForegroundServiceRunning { get; private set; } + public string LastNotificationTitle { get; private set; } = ""; + + public void StartService(string title, int totalActiveDownloads) + { + IsForegroundServiceRunning = true; + LastNotificationTitle = $"{title} ({totalActiveDownloads}개 활성)"; + } + + public void StopService() + { + IsForegroundServiceRunning = false; + } +} + +/// 미디어 파일 AES 암호화 보관 볼트 +public class MobileMediaVault +{ + public byte[] EncryptMedia(byte[] data, string password) + { + using var aes = Aes.Create(); + using var sha = SHA256.Create(); + var key = sha.ComputeHash(Encoding.UTF8.GetBytes(password)); + aes.Key = key; + aes.GenerateIV(); + + using var ms = new MemoryStream(); + ms.Write(aes.IV, 0, aes.IV.Length); // IV 접두어 + + using (var cs = new CryptoStream(ms, aes.CreateEncryptor(), CryptoStreamMode.Write)) + { + cs.Write(data, 0, data.Length); + cs.FlushFinalBlock(); + } + + return ms.ToArray(); + } + + public byte[] DecryptMedia(byte[] encryptedData, string password) + { + using var aes = Aes.Create(); + using var sha = SHA256.Create(); + var key = sha.ComputeHash(Encoding.UTF8.GetBytes(password)); + aes.Key = key; + + var iv = new byte[16]; + Buffer.BlockCopy(encryptedData, 0, iv, 0, 16); + aes.IV = iv; + + using var ms = new MemoryStream(); + using (var cs = new CryptoStream(new MemoryStream(encryptedData, 16, encryptedData.Length - 16), aes.CreateDecryptor(), CryptoStreamMode.Read)) + { + cs.CopyTo(ms); + } + + return ms.ToArray(); + } +} + +/// 모바일 웹뷰 애드블록 엔진 +public class MobileAdBlockEngine +{ + private static readonly HashSet KnownAdDomains = new(StringComparer.OrdinalIgnoreCase) + { + "pagead2.googlesyndication.com", + "adservice.google.com", + "securepubads.g.doubleclick.net", + "googleads.g.doubleclick.net", + "adclick.g.doubleclick.net" + }; + + public bool IsAdUrl(string url) + { + if (string.IsNullOrWhiteSpace(url)) return false; + try + { + var uri = new Uri(url); + return KnownAdDomains.Contains(uri.Host); + } + catch + { + return false; + } + } +} + +public record EstimatedDownloadInfo(string ResolutionLabel, long EstimatedSizeBytes, double EstimatedDurationSec); + +/// 해상도 선택 및 대역폭 계산기 +public class MobileResolutionPicker +{ + public EstimatedDownloadInfo EstimateSizeAndDownloadTime(int durationSec, string resolution, int currentNetworkSpeedKbps) + { + var (label, bitrateKbps) = resolution.ToLowerInvariant() switch + { + "4k" => ("4K Ultra HD", 25000), + "1080p" => ("1080p Full HD", 6000), + "720p" => ("720p HD", 3000), + "480p" => ("480p SD", 1200), + "audio" => ("Audio Only (MP3)", 256), + _ => ("1080p Full HD", 6000) + }; + + // 예상 바이트: (비트레이트(Kbps) * 초 / 8) * 1024 + var estimatedBytes = (long)(bitrateKbps * 1024.0 / 8.0 * durationSec); + var effectiveSpeed = Math.Max(currentNetworkSpeedKbps, 512); // 최소 512Kbps + var estimatedDuration = (double)estimatedBytes / (effectiveSpeed * 1024.0 / 8.0); + + return new EstimatedDownloadInfo(label, estimatedBytes, Math.Round(estimatedDuration, 1)); + } +} diff --git a/src/Paca.Mobile.Core/MobileNavigationModel.cs b/src/Paca.Mobile.Core/MobileNavigationModel.cs new file mode 100644 index 0000000..3637653 --- /dev/null +++ b/src/Paca.Mobile.Core/MobileNavigationModel.cs @@ -0,0 +1,45 @@ +using System.Collections.Generic; + +namespace Paca.Mobile.Core; + +/// 모바일 바텀 내비게이션 탭 종류 +public enum MobileTabKind +{ + Queue, + Discover, + Browser, + Favorites, + SyncSettings +} + +/// 모바일 탭 아이템 모델 +public class MobileTabItem +{ + public MobileTabKind Kind { get; } + public string Title { get; } + public string IconGlyph { get; } + public int BadgeCount { get; set; } + + public MobileTabItem(MobileTabKind kind, string title, string iconGlyph) + { + Kind = kind; + Title = title; + IconGlyph = iconGlyph; + } +} + +/// 모바일 네비게이션 모델 +public static class MobileNavigationModel +{ + public static List GetDefaultTabs() + { + return new List + { + new(MobileTabKind.Queue, "대기열", MobileIconSystem.Queue), + new(MobileTabKind.Discover, "탐색", MobileIconSystem.Discover), + new(MobileTabKind.Browser, "브라우저", MobileIconSystem.Browser), + new(MobileTabKind.Favorites, "보관함", MobileIconSystem.Favorites), + new(MobileTabKind.SyncSettings, "동기화", MobileIconSystem.SyncSettings) + }; + } +} diff --git a/src/Paca.Mobile.Core/MobileQueueManager.cs b/src/Paca.Mobile.Core/MobileQueueManager.cs new file mode 100644 index 0000000..1ae912e --- /dev/null +++ b/src/Paca.Mobile.Core/MobileQueueManager.cs @@ -0,0 +1,122 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; + +namespace Paca.Mobile.Core; + +public enum MobileQueueStatus +{ + Queued, + Downloading, + Paused, + PausedNetwork, + Completed, + Failed +} + +public class MobileQueueItem +{ + public string Id { get; } = Guid.NewGuid().ToString("N"); + public string Url { get; set; } + public string Title { get; set; } + public MobileQueueStatus Status { get; set; } = MobileQueueStatus.Queued; + public double Progress { get; set; } + public bool IsPinned { get; set; } + public string? ErrorMessage { get; set; } + + public MobileQueueItem(string url, string title) + { + Url = url; + Title = title; + } +} + +/// 모바일 대기열 관리자 +public class MobileQueueManager +{ + private readonly List _items = new(); + public IReadOnlyList Items => _items.AsReadOnly(); + + public MobileQueueItem Enqueue(string url, string title) + { + var item = new MobileQueueItem(url, title); + _items.Add(item); + return item; + } + + public void Pause(string id) + { + var item = _items.FirstOrDefault(x => x.Id == id); + if (item != null && (item.Status == MobileQueueStatus.Downloading || item.Status == MobileQueueStatus.Queued)) + { + item.Status = MobileQueueStatus.Paused; + } + } + + public void Resume(string id) + { + var item = _items.FirstOrDefault(x => x.Id == id); + if (item != null && (item.Status == MobileQueueStatus.Paused || item.Status == MobileQueueStatus.PausedNetwork)) + { + item.Status = MobileQueueStatus.Downloading; + } + } + + public void Pin(string id) + { + var item = _items.FirstOrDefault(x => x.Id == id); + if (item != null) + { + item.IsPinned = !item.IsPinned; + if (item.IsPinned) + { + _items.Remove(item); + _items.Insert(0, item); + } + } + } + + public int RetryFailedItems() + { + var failed = _items.Where(x => x.Status == MobileQueueStatus.Failed).ToList(); + foreach (var item in failed) + { + item.Status = MobileQueueStatus.Queued; + item.ErrorMessage = null; + } + return failed.Count; + } + + public void ClearCompleted() + { + _items.RemoveAll(x => x.Status == MobileQueueStatus.Completed); + } +} + +/// 네트워크 변동 시 대기열 자동 제어 가드 +public class MobileNetworkGuard +{ + private readonly MobileQueueManager _queueManager; + + public MobileNetworkGuard(MobileQueueManager queueManager) + { + _queueManager = queueManager; + } + + public void OnNetworkLost() + { + foreach (var item in _queueManager.Items.Where(x => x.Status == MobileQueueStatus.Downloading)) + { + item.Status = MobileQueueStatus.PausedNetwork; + } + } + + public void OnNetworkRestored(bool isWifi) + { + foreach (var item in _queueManager.Items.Where(x => x.Status == MobileQueueStatus.PausedNetwork)) + { + item.Status = MobileQueueStatus.Downloading; + } + } +} diff --git a/src/Paca.Mobile.Core/MobileSettingsManager.cs b/src/Paca.Mobile.Core/MobileSettingsManager.cs new file mode 100644 index 0000000..0cf76fe --- /dev/null +++ b/src/Paca.Mobile.Core/MobileSettingsManager.cs @@ -0,0 +1,12 @@ +namespace Paca.Mobile.Core; + +/// 모바일 설정 및 사용자 기본 설정 관리자 +public class MobileSettingsManager +{ + public string DownloadPath { get; set; } = "Downloads"; + public bool AutoRetryOnReconnect { get; set; } = true; + public bool WifiOnlyDownload { get; set; } = false; + public int SpeedLimitKbps { get; set; } = 0; + public MobileThemeMode ThemeMode { get; set; } = MobileThemeMode.Dark; + public bool NotifyOnCompletion { get; set; } = true; +} diff --git a/src/Paca.Mobile.Core/MobileSyncManager.cs b/src/Paca.Mobile.Core/MobileSyncManager.cs new file mode 100644 index 0000000..deba098 --- /dev/null +++ b/src/Paca.Mobile.Core/MobileSyncManager.cs @@ -0,0 +1,98 @@ +using System.Text.Json.Serialization; + +namespace Paca.Mobile.Core.Sync; + +public sealed class MobileSyncConfig +{ + public bool AutoSyncOnWifi { get; set; } = true; + public string TargetSsid { get; set; } = ""; + public string ServerHost { get; set; } = "http://192.168.1.100:5000"; + public string AuthToken { get; set; } = ""; + public bool RequireBiometrics { get; set; } = true; +} + +public sealed class MobileSyncStatus +{ + public bool IsSyncing { get; set; } + public DateTime? LastSyncUtc { get; set; } + public int QueuedDownloadsCount { get; set; } + public int CompletedDownloadsCount { get; set; } + public string LastError { get; set; } = ""; +} + +/// +/// Paca Mobile (Android/iOS) 백그라운드 동기화 및 생체인증 브릿지 매니저. +/// +public sealed class MobileSyncManager +{ + private readonly object _lock = new(); + private MobileSyncConfig _config; + private MobileSyncStatus _status = new(); + + public MobileSyncConfig Config => _config; + public MobileSyncStatus Status => _status; + + public MobileSyncManager(MobileSyncConfig? config = null) + { + _config = config ?? new MobileSyncConfig(); + } + + public void UpdateConfig(MobileSyncConfig config) + { + lock (_lock) + { + _config = config; + } + } + + public bool CanSyncOnNetwork(string currentSsid, bool isWifi) + { + lock (_lock) + { + if (!_config.AutoSyncOnWifi) return true; + if (!isWifi) return false; + if (!string.IsNullOrEmpty(_config.TargetSsid) && + !string.Equals(_config.TargetSsid, currentSsid, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + return true; + } + } + + public async Task TriggerSyncAsync(Func> syncAction) + { + lock (_lock) + { + if (_status.IsSyncing) return false; + _status.IsSyncing = true; + } + + try + { + int count = await syncAction().ConfigureAwait(false); + lock (_lock) + { + _status.LastSyncUtc = DateTime.UtcNow; + _status.CompletedDownloadsCount += count; + _status.LastError = ""; + return true; + } + } + catch (Exception ex) + { + lock (_lock) + { + _status.LastError = ex.Message; + return false; + } + } + finally + { + lock (_lock) + { + _status.IsSyncing = false; + } + } + } +} diff --git a/src/Paca.Mobile.Core/MobileTabManager.cs b/src/Paca.Mobile.Core/MobileTabManager.cs new file mode 100644 index 0000000..02119cb --- /dev/null +++ b/src/Paca.Mobile.Core/MobileTabManager.cs @@ -0,0 +1,238 @@ +using System.Text.Json; + +namespace Paca.Mobile; + +/// 모바일 멀티 탭 관리자 (세션 보존 및 활성 탭 제어) +public sealed class MobileTabManager +{ + private readonly List _tabs = new(); + private readonly object _lock = new(); + private int _activeIndex = -1; + + public event Action? ActiveTabChanged; + public event Action? TabsChanged; + + public IReadOnlyList Tabs + { + get + { + lock (_lock) return _tabs.ToList(); + } + } + + public BrowserTab? ActiveTab + { + get + { + lock (_lock) + { + if (_activeIndex >= 0 && _activeIndex < _tabs.Count) + return _tabs[_activeIndex]; + return null; + } + } + } + + public int ActiveIndex + { + get { lock (_lock) return _activeIndex; } + } + + public int Count + { + get { lock (_lock) return _tabs.Count; } + } + + public MobileTabManager() + { + // 기본 시작 시 새 탭 1개 생성 + AddTab("paca://newtab", isPrivate: false); + } + + /// 새 탭 생성 및 활성화 + public BrowserTab NewTab(string url = "paca://newtab", string title = "새 탭", bool isIncognito = false) + { + var tab = AddTab(url, isPrivate: isIncognito); + if (!string.IsNullOrEmpty(title) && title != "새 탭") + tab.Title = title; + return tab; + } + + /// 새 탭 생성 및 활성화 (기본 AddTab) + public BrowserTab AddTab(string url = "paca://newtab", bool isPrivate = false) + { + var tab = new BrowserTab + { + Url = url, + Title = (url == "paca://newtab" || url == "about:blank") ? "새 탭" : url, + IsPrivate = isPrivate, + LastActiveUtc = DateTime.UtcNow + }; + + lock (_lock) + { + _tabs.Add(tab); + _activeIndex = _tabs.Count - 1; + } + + TabsChanged?.Invoke(); + ActiveTabChanged?.Invoke(); + return tab; + } + + /// 특정 탭으로 전환 + public bool SwitchTab(string tabId) + { + lock (_lock) + { + var idx = _tabs.FindIndex(t => t.Id == tabId); + if (idx < 0 || idx == _activeIndex) return false; + + _activeIndex = idx; + _tabs[idx].LastActiveUtc = DateTime.UtcNow; + } + + ActiveTabChanged?.Invoke(); + return true; + } + + /// 특정 탭 닫기 + public bool CloseTab(string tabId) + { + BrowserTab? toRemove = null; + lock (_lock) + { + var idx = _tabs.FindIndex(t => t.Id == tabId); + if (idx < 0) return false; + + toRemove = _tabs[idx]; + _tabs.RemoveAt(idx); + + if (_tabs.Count == 0) + { + // 모든 탭이 닫히면 새 탭 1개 자동 생성 + var newTab = new BrowserTab { Url = "paca://newtab", Title = "새 탭" }; + _tabs.Add(newTab); + _activeIndex = 0; + } + else if (_activeIndex >= _tabs.Count) + { + _activeIndex = _tabs.Count - 1; + } + else if (idx <= _activeIndex && _activeIndex > 0) + { + _activeIndex--; + } + } + + TabsChanged?.Invoke(); + ActiveTabChanged?.Invoke(); + return true; + } + + /// 모든 일반 탭 닫기 + public void CloseAllTabs() + { + lock (_lock) + { + _tabs.Clear(); + var newTab = new BrowserTab { Url = "paca://newtab", Title = "새 탭" }; + _tabs.Add(newTab); + _activeIndex = 0; + } + + TabsChanged?.Invoke(); + ActiveTabChanged?.Invoke(); + } + + /// 현재 활성 탭 정보 업데이트 (URL, 제목, 진행률, 뒤로/앞으로 상태) + public void UpdateActiveTab(string url, string? title = null, bool? canGoBack = null, bool? canGoForward = null, double? progress = null, bool? isLoading = null, IReadOnlyList? detectedVideos = null) + { + BrowserTab? tab = null; + lock (_lock) + { + if (_activeIndex >= 0 && _activeIndex < _tabs.Count) + { + tab = _tabs[_activeIndex]; + if (!string.IsNullOrWhiteSpace(url)) tab.Url = url; + if (!string.IsNullOrWhiteSpace(title)) tab.Title = title; + if (canGoBack.HasValue) tab.CanGoBack = canGoBack.Value; + if (canGoForward.HasValue) tab.CanGoForward = canGoForward.Value; + if (progress.HasValue) tab.LoadingProgress = progress.Value; + if (isLoading.HasValue) tab.IsLoading = isLoading.Value; + if (detectedVideos != null) tab.DetectedVideos = detectedVideos.ToList(); + tab.LastActiveUtc = DateTime.UtcNow; + } + } + + if (tab != null) + { + TabsChanged?.Invoke(); + } + } + + /// 활성 탭에 감지된 비디오 추가 + public void AddDetectedVideoToActiveTab(VideoSniffItem video) + { + lock (_lock) + { + if (_activeIndex >= 0 && _activeIndex < _tabs.Count) + { + var tab = _tabs[_activeIndex]; + if (!tab.DetectedVideos.Any(v => v.StreamUrl.Equals(video.StreamUrl, StringComparison.OrdinalIgnoreCase))) + { + tab.DetectedVideos.Add(video); + } + } + } + + ActiveTabChanged?.Invoke(); + } + + /// 세션 저장 (InPrivate 탭 제외) + public async Task SaveSessionAsync(string filePath) + { + List toSave; + lock (_lock) + { + toSave = _tabs.Where(t => !t.IsPrivate).ToList(); + } + + var dir = Path.GetDirectoryName(filePath); + if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) + Directory.CreateDirectory(dir); + + var tempPath = filePath + ".tmp"; + var json = JsonSerializer.Serialize(toSave, new JsonSerializerOptions { WriteIndented = true }); + await File.WriteAllTextAsync(tempPath, json); + File.Move(tempPath, filePath, overwrite: true); + } + + /// 세션 복원 + public async Task RestoreSessionAsync(string filePath) + { + if (!File.Exists(filePath)) return; + + try + { + var json = await File.ReadAllTextAsync(filePath); + var restored = JsonSerializer.Deserialize>(json); + if (restored != null && restored.Count > 0) + { + lock (_lock) + { + _tabs.Clear(); + _tabs.AddRange(restored); + _activeIndex = 0; + } + + TabsChanged?.Invoke(); + ActiveTabChanged?.Invoke(); + } + } + catch + { + // 손상된 세션 파일은 무시하고 기본 새 탭 유지 + } + } +} diff --git a/src/Paca.Mobile.Core/MobileTabSyncEngine.cs b/src/Paca.Mobile.Core/MobileTabSyncEngine.cs new file mode 100644 index 0000000..7e85b59 --- /dev/null +++ b/src/Paca.Mobile.Core/MobileTabSyncEngine.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Paca.Mobile.Core; + +public record RemoteTabInfo(string TabId, string Title, string Url); + +/// PC-모바일 간 열린 탭 실시간 동기화 엔진 +public class MobileTabSyncEngine +{ + private readonly List _remoteTabs = new(); + public IReadOnlyList RemoteTabs => _remoteTabs.AsReadOnly(); + + public event Action? OnRemoteDownloadRequested; + public event Action? OnRemoteTabCloseRequested; + + public void UpdateRemoteTabs(IEnumerable tabs) + { + _remoteTabs.Clear(); + _remoteTabs.AddRange(tabs); + } + + public void RequestRemoteDownload(string tabId) + { + var tab = _remoteTabs.FirstOrDefault(x => x.TabId == tabId); + if (tab != null) + { + OnRemoteDownloadRequested?.Invoke(tab.Url); + } + } + + public void RequestCloseRemoteTab(string tabId) + { + OnRemoteTabCloseRequested?.Invoke(tabId); + } +} diff --git a/src/Paca.Mobile.Core/MobileThemeSystem.cs b/src/Paca.Mobile.Core/MobileThemeSystem.cs new file mode 100644 index 0000000..09d1de3 --- /dev/null +++ b/src/Paca.Mobile.Core/MobileThemeSystem.cs @@ -0,0 +1,113 @@ +namespace Paca.Mobile.Core; + +/// 모바일 테마 모드 +public enum MobileThemeMode +{ + Dark, + Light, + OLEDBlack, + HighContrast, + SystemAuto +} + +/// 모바일 색상 팔레트 토큰 +public record MobileColorPalette( + string Background, + string Surface1, + string Surface2, + string Accent, + string AccentHover, + string TextPrimary, + string TextSecondary, + string TextMuted, + string BorderSubtle, + string Success, + string Warning, + string Error +) +{ + public string CardBackground => Surface1; +} + +/// 모바일 디자인 시스템 토큰 및 테마 관리자 (Designpaca & Human Interface Guidelines) +public static class MobileThemeSystem +{ + // 인간공학 및 터치 타깃 표준 (최소 48dp) + public const double MinTouchTargetDp = 48.0; + public const double BaseGridUnitDp = 8.0; + public const double CornerRadiusCardDp = 12.0; + public const double CornerRadiusPillDp = 24.0; + + private static readonly MobileColorPalette DarkPalette = new( + Background: "#0B0C10", + Surface1: "#161822", + Surface2: "#1F2335", + Accent: "#5E56E8", + AccentHover: "#7068FA", + TextPrimary: "#FFFFFF", + TextSecondary: "#94A3B8", + TextMuted: "#64748B", + BorderSubtle: "#2A2D44", + Success: "#10B981", + Warning: "#F59E0B", + Error: "#EF4444" + ); + + private static readonly MobileColorPalette LightPalette = new( + Background: "#F8FAFC", + Surface1: "#FFFFFF", + Surface2: "#F1F5F9", + Accent: "#4F46E5", + AccentHover: "#4338CA", + TextPrimary: "#0F172A", + TextSecondary: "#475569", + TextMuted: "#94A3B8", + BorderSubtle: "#E2E8F0", + Success: "#059669", + Warning: "#D97706", + Error: "#DC2626" + ); + + private static readonly MobileColorPalette OLEDBlackPalette = new( + Background: "#000000", + Surface1: "#090A0F", + Surface2: "#12141F", + Accent: "#00F0FF", + AccentHover: "#33F3FF", + TextPrimary: "#FFFFFF", + TextSecondary: "#A0AEC0", + TextMuted: "#718096", + BorderSubtle: "#1A202C", + Success: "#00FF66", + Warning: "#FFB800", + Error: "#FF3366" + ); + + private static readonly MobileColorPalette HighContrastPalette = new( + Background: "#000000", + Surface1: "#000000", + Surface2: "#242424", + Accent: "#FFFF00", + AccentHover: "#E6E600", + TextPrimary: "#FFFFFF", + TextSecondary: "#FFFF00", + TextMuted: "#00FF66", + BorderSubtle: "#FFFFFF", + Success: "#00FF66", + Warning: "#FFA500", + Error: "#FF5A5A" + ); + + /// 지정된 테마 모드의 색상 팔레트를 반환합니다. + public static MobileColorPalette GetPalette(MobileThemeMode mode) + { + return mode switch + { + MobileThemeMode.Light => LightPalette, + MobileThemeMode.OLEDBlack => OLEDBlackPalette, + MobileThemeMode.HighContrast => HighContrastPalette, + MobileThemeMode.SystemAuto => DarkPalette, + _ => DarkPalette + }; + } +} diff --git a/src/Paca.Mobile.Core/OfflineStore.cs b/src/Paca.Mobile.Core/OfflineStore.cs new file mode 100644 index 0000000..8d454df --- /dev/null +++ b/src/Paca.Mobile.Core/OfflineStore.cs @@ -0,0 +1,80 @@ +using System.Text.Json; + +namespace Paca.Mobile; + +public sealed record OfflineItem(string Id, string FileName, long SizeBytes); + +/// +/// 폰 오프라인 저장(U4): 라이브러리 항목을 앱 전용 폴더에 저장·목록·삭제. +/// manifest.json(id→파일명)으로 서버 항목과 매핑한다. +/// +public sealed class OfflineStore +{ + private readonly string _dir; + private readonly string _manifestPath; + private readonly object _gate = new(); + private Dictionary _manifest; + + /// baseDir 아래 offline/ 폴더 사용(호스트가 앱 데이터 디렉터리 주입). + public OfflineStore(string baseDir) + { + _dir = Path.Combine(baseDir, "offline"); + _manifestPath = Path.Combine(_dir, "manifest.json"); + try + { + if (File.Exists(_manifestPath)) + _manifest = JsonSerializer.Deserialize>(File.ReadAllText(_manifestPath)) ?? new(); + } + catch { } + _manifest ??= new Dictionary(); + } + + 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); } } + + /// 다운로드 완료 후 등록. + 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 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); + var tmp = _manifestPath + ".tmp"; + File.WriteAllText(tmp, JsonSerializer.Serialize(_manifest)); + File.Move(tmp, _manifestPath, overwrite: true); + } + catch { } + } +} diff --git a/src/Paca.Mobile.Core/Paca.Mobile.Core.csproj b/src/Paca.Mobile.Core/Paca.Mobile.Core.csproj new file mode 100644 index 0000000..5edd315 --- /dev/null +++ b/src/Paca.Mobile.Core/Paca.Mobile.Core.csproj @@ -0,0 +1,13 @@ + + + net8.0 + Paca.Mobile + enable + enable + latest + + + + + + diff --git a/src/Paca.Mobile.Core/QueueCompletionDetector.cs b/src/Paca.Mobile.Core/QueueCompletionDetector.cs new file mode 100644 index 0000000..54fd363 --- /dev/null +++ b/src/Paca.Mobile.Core/QueueCompletionDetector.cs @@ -0,0 +1,18 @@ +namespace Paca.Mobile; + +/// U5 완료 알림: 이전/다음 큐 스냅샷에서 새로 완료("완료")된 항목 ID 감지. +/// 이전 스냅샷이 비면(앱 시작 직후 첫 폴링) 무시 — 과거 완료 알림 스팸 방지. +public sealed class QueueCompletionDetector +{ + public IReadOnlyList Detect( + IReadOnlyList<(string Id, string Status)> previous, + IReadOnlyList<(string Id, string Status)> next) + { + if (previous.Count == 0) return Array.Empty(); + var prev = previous.ToDictionary(x => x.Id, x => x.Status); + return next + .Where(n => n.Status == "완료" && prev.TryGetValue(n.Id, out var s) && s != "완료") + .Select(n => n.Id) + .ToList(); + } +} diff --git a/src/Paca.Mobile.Core/SharePayload.cs b/src/Paca.Mobile.Core/SharePayload.cs new file mode 100644 index 0000000..fe6fcb0 --- /dev/null +++ b/src/Paca.Mobile.Core/SharePayload.cs @@ -0,0 +1,18 @@ +using System.Text.RegularExpressions; + +namespace Paca.Mobile; + +/// M1 공유 타겟(U1): 다른 앱에서 공유받은 텍스트에서 전송할 URL 추출. +/// 유튜브 공유 시트는 "제목 + https://youtu.be/..." 형식 — 텍스트 중 첫 http(s) URL. +public static partial class SharePayload +{ + [GeneratedRegex(@"https?://[^\s]+")] + private static partial Regex UrlRegex(); + + public static string? ExtractUrl(string? sharedText) + { + if (string.IsNullOrWhiteSpace(sharedText)) return null; + var m = UrlRegex().Match(sharedText); + return m.Success ? m.Value.TrimEnd(')', ']', '}', ',', '.', ';') : null; + } +} diff --git a/src/Paca.Mobile.Core/VideoSnifferParser.cs b/src/Paca.Mobile.Core/VideoSnifferParser.cs new file mode 100644 index 0000000..9d4c1ee --- /dev/null +++ b/src/Paca.Mobile.Core/VideoSnifferParser.cs @@ -0,0 +1,220 @@ +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace Paca.Mobile; + +/// 네트워크 URL 및 DOM 스니퍼 페이로드 분석기 +public static class VideoSnifferParser +{ + private static readonly Regex M3u8Regex = new(@"\.(m3u8)(\?|$)", RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly Regex MpdRegex = new(@"\.(mpd)(\?|$)", RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly Regex Mp4Regex = new(@"\.(mp4|m4v)(\?|$)", RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly Regex DirectMediaRegex = new(@"\.(webm|mkv|mov|flv)(\?|$)", RegexOptions.IgnoreCase | RegexOptions.Compiled); + + /// 네트워크 요청 URL 및 Content-Type으로 비디오 스트림 여부 판별 + public static bool TrySniffNetworkStream(string url, string? mimeType, IDictionary? headers, out VideoSniffItem? item) + { + item = null; + if (string.IsNullOrWhiteSpace(url)) return false; + + // 불필요한 광고/트래커/이미지 제외 + if (url.Contains("/ad_", StringComparison.OrdinalIgnoreCase) || + url.Contains("/ads/", StringComparison.OrdinalIgnoreCase) || + url.Contains("googleads", StringComparison.OrdinalIgnoreCase) || + url.Contains("doubleclick", StringComparison.OrdinalIgnoreCase) || + url.Contains(".ts", StringComparison.OrdinalIgnoreCase) && !url.Contains(".m3u8", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + VideoStreamType type = VideoStreamType.Unknown; + + if (M3u8Regex.IsMatch(url) || (mimeType?.Contains("mpegurl", StringComparison.OrdinalIgnoreCase) == true)) + { + type = VideoStreamType.Hls; + } + else if (MpdRegex.IsMatch(url) || (mimeType?.Contains("dash+xml", StringComparison.OrdinalIgnoreCase) == true)) + { + type = VideoStreamType.Dash; + } + else if (Mp4Regex.IsMatch(url) || (mimeType?.Contains("video/mp4", StringComparison.OrdinalIgnoreCase) == true)) + { + type = VideoStreamType.Mp4; + } + else if (DirectMediaRegex.IsMatch(url) || (mimeType?.StartsWith("video/", StringComparison.OrdinalIgnoreCase) == true)) + { + type = VideoStreamType.Direct; + } + else if (url.StartsWith("blob:", StringComparison.OrdinalIgnoreCase)) + { + type = VideoStreamType.Blob; + } + + if (type == VideoStreamType.Unknown) return false; + + item = new VideoSniffItem + { + StreamUrl = url, + VideoType = type, + Title = ExtractTitleFromUrl(url), + DetectedUtc = DateTime.UtcNow + }; + + if (headers != null) + { + foreach (var kvp in headers) + { + item.Headers[kvp.Key] = kvp.Value; + } + } + + return true; + } + + /// DOM 인젝션 스크립트(paca_sniffer.js)가 전송한 JSON 페이로드 파싱 + public static List ParseDomPayload(string jsonPayload, string pageUrl) + { + var results = new List(); + if (string.IsNullOrWhiteSpace(jsonPayload)) return results; + + try + { + using var doc = JsonDocument.Parse(jsonPayload); + var root = doc.RootElement; + + if (root.ValueKind == JsonValueKind.Array) + { + foreach (var el in root.EnumerateArray()) + { + var item = ParseSingleJsonElement(el, pageUrl); + if (item != null) results.Add(item); + } + } + else if (root.ValueKind == JsonValueKind.Object) + { + var item = ParseSingleJsonElement(root, pageUrl); + if (item != null) results.Add(item); + } + } + catch { } + + return results; + } + + private static VideoSniffItem? ParseSingleJsonElement(JsonElement el, string pageUrl) + { + string? src = null; + if (el.TryGetProperty("src", out var srcProp)) src = srcProp.GetString(); + if (string.IsNullOrEmpty(src) && el.TryGetProperty("currentSrc", out var curSrcProp)) src = curSrcProp.GetString(); + + if (string.IsNullOrWhiteSpace(src)) return null; + + string title = "웹 영상"; + if (el.TryGetProperty("title", out var titleProp) && !string.IsNullOrWhiteSpace(titleProp.GetString())) + title = titleProp.GetString()!; + + string resolution = "자동"; + if (el.TryGetProperty("width", out var w) && el.TryGetProperty("height", out var h)) + { + int width = w.GetInt32(); + int height = h.GetInt32(); + if (width > 0 && height > 0) resolution = $"{width}x{height}"; + } + + double duration = 0; + if (el.TryGetProperty("duration", out var durProp)) + { + if (durProp.TryGetDouble(out var d)) duration = d; + } + + string? poster = null; + if (el.TryGetProperty("poster", out var posterProp)) poster = posterProp.GetString(); + + VideoStreamType type = VideoStreamType.Direct; + if (src.Contains(".m3u8", StringComparison.OrdinalIgnoreCase)) type = VideoStreamType.Hls; + else if (src.Contains(".mpd", StringComparison.OrdinalIgnoreCase)) type = VideoStreamType.Dash; + else if (src.Contains(".mp4", StringComparison.OrdinalIgnoreCase)) type = VideoStreamType.Mp4; + else if (src.StartsWith("blob:", StringComparison.OrdinalIgnoreCase)) type = VideoStreamType.Blob; + + return new VideoSniffItem + { + PageUrl = pageUrl, + StreamUrl = src, + VideoType = type, + Title = title, + Resolution = resolution, + DurationSeconds = duration, + ThumbnailUrl = poster, + DetectedUtc = DateTime.UtcNow + }; + } + + private static string ExtractTitleFromUrl(string url) + { + try + { + var uri = new Uri(url); + var last = Path.GetFileNameWithoutExtension(uri.AbsolutePath); + if (!string.IsNullOrWhiteSpace(last) && last.Length > 2) return last; + } + catch { } + return "비디오 스트림"; + } + + private static readonly Dictionary ResolutionRanks = new(StringComparer.OrdinalIgnoreCase) + { + { "8K", 8000 }, + { "4K", 4000 }, + { "2160p", 4000 }, + { "1440p", 2560 }, + { "2K", 2000 }, + { "1080p", 1080 }, + { "720p", 720 }, + { "480p", 480 }, + { "360p", 360 }, + { "240p", 240 }, + { "Audio Only", 1 }, + { "오디오", 1 } + }; + + /// 사용자 선호 화질을 기준으로 복수 스트림 중 최적의 비디오 스트림을 자동 협상합니다. + public static VideoSniffItem? NegotiateBestQuality(IEnumerable streams, string preferredQuality) + { + if (streams == null) return null; + var list = streams.ToList(); + if (list.Count == 0) return null; + + // 1. 최고 화질 요청("Max") + if (string.Equals(preferredQuality, "Max", StringComparison.OrdinalIgnoreCase)) + { + return list.OrderByDescending(s => GetRank(s.Resolution)).FirstOrDefault(); + } + + // 2. 정확한 화질 매칭 + var exact = list.FirstOrDefault(s => string.Equals(s.Resolution, preferredQuality, StringComparison.OrdinalIgnoreCase)); + if (exact != null) return exact; + + // 3. 오디오 전용 매칭 + if (preferredQuality.Contains("Audio", StringComparison.OrdinalIgnoreCase)) + { + return list.FirstOrDefault(s => s.Resolution.Contains("Audio", StringComparison.OrdinalIgnoreCase)) ?? list.Last(); + } + + // 4. 선호 화질 이하 중에서 최고 화질 선택 (Fallback) + var targetRank = GetRank(preferredQuality); + var underTarget = list.Where(s => GetRank(s.Resolution) <= targetRank) + .OrderByDescending(s => GetRank(s.Resolution)) + .FirstOrDefault(); + + return underTarget ?? list.OrderByDescending(s => GetRank(s.Resolution)).FirstOrDefault(); + } + + private static int GetRank(string? resolution) + { + if (string.IsNullOrWhiteSpace(resolution)) return 0; + if (ResolutionRanks.TryGetValue(resolution.Trim(), out var rank)) return rank; + var match = Regex.Match(resolution, @"(\d{3,4})p?"); + if (match.Success && int.TryParse(match.Groups[1].Value, out var num)) return num; + return 0; + } +} diff --git a/src/Paca.Mobile/ApiClient.cs b/src/Paca.Mobile/ApiClient.cs new file mode 100644 index 0000000..9987f8e --- /dev/null +++ b/src/Paca.Mobile/ApiClient.cs @@ -0,0 +1,194 @@ +using System.Net.Http; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; + +namespace Paca.Mobile; + +/// 데스크톱 원격 서버 API 클라이언트(M1). 토큰은 QR(#t=)/수동 입력에서 획득. +public sealed class ApiClient +{ + public static readonly ApiClient Instance = new(); + + private readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(8) }; + + public string? BaseUrl { get; private set; } + public string? Token { get; private set; } + public string? LastError { get; private set; } + public bool IsConfigured => BaseUrl is { Length: > 0 } && Token is { Length: > 0 }; + + public event Action? ConnectionChanged; + + /// QR 문자열(http://ip:port/#t=TOKEN) 또는 수동 입력(host:port + token) 구성. + public bool Configure(string? qrOrUrl, string? manualToken = null) + { + try + { + if (!string.IsNullOrWhiteSpace(manualToken)) + { + var host = qrOrUrl!.Trim().TrimEnd('/'); + if (!host.StartsWith("http", StringComparison.OrdinalIgnoreCase)) host = "http://" + host; + BaseUrl = host; + Token = manualToken.Trim(); + } + else + { + var u = new Uri(qrOrUrl!.Trim()); + var frag = u.Fragment.TrimStart('#'); + var token = System.Web.HttpUtility.ParseQueryString(frag)["t"]; + if (string.IsNullOrEmpty(token)) return false; + BaseUrl = $"http://{u.Authority}"; + Token = token; + } + _http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", Token); + Preferences.Set("vd_base", BaseUrl); + Preferences.Set("vd_token", Token); + ConnectionChanged?.Invoke(); + return true; + } + catch { return false; } + } + + public void Restore() + { + var b = Preferences.Get("vd_base", ""); + var t = Preferences.Get("vd_token", ""); + if (b.Length > 0 && t.Length > 0) + { + BaseUrl = b; + Token = t; + _http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", t); + } + } + + public void Forget() + { + BaseUrl = null; Token = null; + Preferences.Remove("vd_base"); Preferences.Remove("vd_token"); + ConnectionChanged?.Invoke(); + } + + public async Task> GetQueueAsync() + { + try + { + LastError = null; + var r = await _http.GetAsync($"{BaseUrl}/api/queue"); + if (!r.IsSuccessStatusCode) { LastError = $"HTTP {(int)r.StatusCode}"; return new(); } + return await r.Content.ReadFromJsonAsync>() ?? new(); + } + catch (Exception e) { LastError = e.Message; return new(); } + } + + public async Task AddAsync(string url) + { + try + { + var r = await _http.PostAsJsonAsync($"{BaseUrl}/api/queue", new { url }); + return r.IsSuccessStatusCode; + } + catch { return false; } + } + + public Task ControlAsync(string id, string action) => + PostAsync($"/api/queue/{id}/{action}"); + + public async Task PairAsync() + { + try + { + var r = await _http.PostAsJsonAsync($"{BaseUrl}/api/pair", new { token = Token, device = "Android" }); + return r.IsSuccessStatusCode; + } + catch { return false; } + } + + public async Task> GetLibraryAsync() + { + try + { + var r = await _http.GetAsync($"{BaseUrl}/api/library"); + if (!r.IsSuccessStatusCode) return new(); + return await r.Content.ReadFromJsonAsync>() ?? new(); + } + catch { return new(); } + } + + 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}"; + + /// 이어보기 위치 조회(U3). 실패/미저장 시 0. + public async Task GetPositionAsync(string id) + { + var dto = await GetAsync($"/api/position/{id}"); + return dto?.PositionSec ?? 0; + } + + /// 이어보기 위치 저장(U3). + public Task PostPositionAsync(string id, double positionSec) => + PostJsonAsync($"/api/position/{id}", new PositionDto(positionSec)); + + /// 라이브러리 항목을 폰에 저장(U4). 진행률 0~1, 취소 지원. 임시 .part 로 받아 완료 시 rename. + public async Task DownloadFileAsync(LibraryItem item, string destPath, IProgress? progress, CancellationToken ct) + { + Directory.CreateDirectory(Path.GetDirectoryName(destPath)!); + var tmp = destPath + ".part"; + try + { + using var resp = await _http.GetAsync(FileUrl(item), HttpCompletionOption.ResponseHeadersRead, ct); + resp.EnsureSuccessStatusCode(); + var total = resp.Content.Headers.ContentLength ?? item.SizeBytes; + await using var src = await resp.Content.ReadAsStreamAsync(ct); + await using var dst = File.Create(tmp); + var buf = new byte[64 * 1024]; + long read = 0; + int n; + while ((n = await src.ReadAsync(buf, ct)) > 0) + { + await dst.WriteAsync(buf.AsMemory(0, n), ct); + read += n; + if (total > 0) progress?.Report((double)read / total); + } + File.Move(tmp, destPath, overwrite: true); + progress?.Report(1); + } + finally + { + try { if (File.Exists(tmp)) File.Delete(tmp); } catch { } + } + } + + private async Task PostJsonAsync(string path, object body) + { + try + { + var r = await _http.PostAsJsonAsync($"{BaseUrl}{path}", body); + return r.IsSuccessStatusCode; + } + catch { return false; } + } + + private Task PostAsync(string path) + { + try { var t = _http.PostAsync($"{BaseUrl}{path}", null); return Continue(t); } + catch { return Task.FromResult(false); } + static async Task Continue(Task t) { try { return (await t).IsSuccessStatusCode; } catch { return false; } } + } + + private async Task GetAsync(string path) where T : class + { + try + { + var r = await _http.GetAsync($"{BaseUrl}{path}"); + return r.IsSuccessStatusCode ? await r.Content.ReadFromJsonAsync() : null; + } + catch { return null; } + } +} + +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, string? Resolution = null); + +public sealed record PositionDto(double PositionSec); diff --git a/src/Paca.Mobile/App.xaml b/src/Paca.Mobile/App.xaml new file mode 100644 index 0000000..902118f --- /dev/null +++ b/src/Paca.Mobile/App.xaml @@ -0,0 +1,14 @@ + + + + + + + + + + + diff --git a/src/Paca.Mobile/App.xaml.cs b/src/Paca.Mobile/App.xaml.cs new file mode 100644 index 0000000..3ca1abe --- /dev/null +++ b/src/Paca.Mobile/App.xaml.cs @@ -0,0 +1,21 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace Paca.Mobile; + +public partial class App : Application +{ + public App() + { + InitializeComponent(); + } + + protected override Window CreateWindow(IActivationState? activationState) + { + var navPage = new NavigationPage(new BrowserPage()) + { + BarBackgroundColor = Color.FromArgb("#0B0C10"), + BarTextColor = Colors.White + }; + return new Window(navPage); + } +} \ No newline at end of file diff --git a/src/Paca.Mobile/AppOfflineStore.cs b/src/Paca.Mobile/AppOfflineStore.cs new file mode 100644 index 0000000..3e25952 --- /dev/null +++ b/src/Paca.Mobile/AppOfflineStore.cs @@ -0,0 +1,7 @@ +namespace Paca.Mobile; + +/// 앱 데이터 디렉터리를 주입하는 오프라인 저장소 기본 인스턴스(Core 는 MAUI 비의존). +public static class AppOfflineStore +{ + public static OfflineStore Default { get; } = new(FileSystem.AppDataDirectory); +} diff --git a/src/Paca.Mobile/BrowserPage.cs b/src/Paca.Mobile/BrowserPage.cs new file mode 100644 index 0000000..8204049 --- /dev/null +++ b/src/Paca.Mobile/BrowserPage.cs @@ -0,0 +1,979 @@ +using Microsoft.Maui.Controls; +using Microsoft.Maui.Controls.Shapes; + +namespace Paca.Mobile; + +/// PACA Mobile 메인 웹 브라우징 화면 (초고속 독립형 웹뷰 + 글래스 옴니박스 + 비디오 스니퍼 + 플로팅 글래스 툴바) +public sealed class BrowserPage : ContentPage +{ + private readonly MobileTabManager _tabManager = new(); + private readonly MobileHistoryStore _historyStore; + private readonly MobileBookmarkStore _bookmarkStore; + + private readonly Entry _urlEntry; + private readonly Label _sslIcon; + private readonly Label _pcStatusIndicator; + private readonly Button _refreshBtn; + private readonly Border _snifferBadge; + private readonly Label _snifferBadgeLabel; + private readonly ProgressBar _progressBar; + + private readonly WebView _webView; + private readonly ScrollView _ntpScrollView; + private readonly View _backBtn; + private readonly View _forwardBtn; + private readonly Label _tabCountLabel; + private readonly Label _pcSyncStatusText; + private readonly Label _pcSyncSubText; + private readonly Border _pcStatusDot; + private bool _isNavigating; + + public BrowserPage() + { + NavigationPage.SetHasNavigationBar(this, false); + BackgroundColor = Color.FromArgb("#08080C"); + + var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + var historyPath = System.IO.Path.Combine(appData, "paca_history.json"); + var bookmarksPath = System.IO.Path.Combine(appData, "paca_bookmarks.json"); + _historyStore = new MobileHistoryStore(historyPath); + _bookmarkStore = new MobileBookmarkStore(bookmarksPath); + + // ===================================================================== + // 1. 상단 글래스 옴니박스 (High-End Glass Omnibox Bar) + // ===================================================================== + var topBarBorder = new Border + { + BackgroundColor = Color.FromArgb("#101018"), + Stroke = Color.FromArgb("#222232"), + StrokeThickness = 1, + StrokeShape = new RoundRectangle { CornerRadius = new CornerRadius(0, 0, 20, 20) }, + Padding = new Thickness(14, 8, 14, 10), + Margin = new Thickness(0, 0, 0, 0) + }; + + var topBarGrid = new Grid + { + HeightRequest = 44, + ColumnDefinitions = + { + new ColumnDefinition { Width = GridLength.Auto }, // SSL 아이콘 + new ColumnDefinition { Width = GridLength.Star }, // URL 캡슐 + new ColumnDefinition { Width = GridLength.Auto }, // 새로고침 + new ColumnDefinition { Width = GridLength.Auto } // 비디오 스니퍼 뱃지 + } + }; + + _sslIcon = new Label + { + Text = "🛡️", + FontSize = 15, + TextColor = Color.FromArgb("#34D399"), + VerticalOptions = LayoutOptions.Center, + Margin = new Thickness(2, 0, 8, 0) + }; + topBarGrid.Add(_sslIcon, 0, 0); + + // 옴니박스 더블 베젤 캡슐 + var urlCapsule = new Border + { + BackgroundColor = Color.FromArgb("#181824"), + Stroke = Color.FromArgb("#2C2C40"), + StrokeThickness = 1, + StrokeShape = new RoundRectangle { CornerRadius = 22 }, + Padding = new Thickness(14, 0, 8, 0), + HeightRequest = 42, + VerticalOptions = LayoutOptions.Center + }; + + var entryLayout = new Grid + { + ColumnDefinitions = + { + new ColumnDefinition { Width = GridLength.Star }, + new ColumnDefinition { Width = GridLength.Auto } + } + }; + + _urlEntry = new Entry + { + Placeholder = "검색어 또는 웹 주소 입력...", + PlaceholderColor = Color.FromArgb("#64748B"), + TextColor = Color.FromArgb("#FFFFFF"), + FontSize = 14, + BackgroundColor = Colors.Transparent, + HeightRequest = 42, + ClearButtonVisibility = ClearButtonVisibility.WhileEditing, + Keyboard = Keyboard.Url + }; + _urlEntry.Completed += (_, _) => NavigateFromOmnibox(_urlEntry.Text); + entryLayout.Add(_urlEntry, 0, 0); + + _pcStatusIndicator = new Label + { + Text = "💻", + FontSize = 14, + TextColor = Color.FromArgb("#64748B"), + VerticalOptions = LayoutOptions.Center, + Margin = new Thickness(6, 0, 4, 0) + }; + var pcTap = new TapGestureRecognizer(); + pcTap.Tapped += async (_, _) => await Navigation.PushAsync(new SyncSettingsPage(_tabManager, _historyStore)); + _pcStatusIndicator.GestureRecognizers.Add(pcTap); + entryLayout.Add(_pcStatusIndicator, 1, 0); + + urlCapsule.Content = entryLayout; + topBarGrid.Add(urlCapsule, 1, 0); + + _refreshBtn = new Button + { + Text = "↻", + BackgroundColor = Colors.Transparent, + TextColor = Color.FromArgb("#94A3B8"), + FontSize = 18, + FontAttributes = FontAttributes.Bold, + WidthRequest = 40, + HeightRequest = 40, + Padding = 0, + Margin = new Thickness(4, 0, 0, 0) + }; + _refreshBtn.Clicked += (_, _) => + { + if (_webView.IsVisible) _webView.Reload(); + else Navigate("paca://newtab"); + }; + topBarGrid.Add(_refreshBtn, 2, 0); + + // 에메랄드 네온 비디오 스니퍼 발광 뱃지 + _snifferBadgeLabel = new Label + { + Text = "⚡ 비디오 0", + TextColor = Colors.White, + FontAttributes = FontAttributes.Bold, + FontSize = 11, + HorizontalOptions = LayoutOptions.Center, + VerticalOptions = LayoutOptions.Center + }; + _snifferBadge = new Border + { + BackgroundColor = Color.FromArgb("#059669"), + Stroke = Color.FromArgb("#34D399"), + StrokeThickness = 1.5, + StrokeShape = new RoundRectangle { CornerRadius = 14 }, + Padding = new Thickness(10, 4), + Margin = new Thickness(6, 0, 0, 0), + HeightRequest = 32, + IsVisible = false, + Content = _snifferBadgeLabel + }; + var snifferTap = new TapGestureRecognizer(); + snifferTap.Tapped += async (_, _) => await OpenSnifferModalAsync(); + _snifferBadge.GestureRecognizers.Add(snifferTap); + topBarGrid.Add(_snifferBadge, 3, 0); + + topBarBorder.Content = topBarGrid; + + // 로딩 프로그레스바 (Violet Gradient Accent) + _progressBar = new ProgressBar + { + ProgressColor = Color.FromArgb("#8B7CFF"), + HeightRequest = 2.5, + IsVisible = false + }; + + // ===================================================================== + // 2. 새 탭 홈 화면 (NTP Speed Dials & PC Sync Status) + // ===================================================================== + _pcStatusDot = new Border + { + WidthRequest = 8, + HeightRequest = 8, + BackgroundColor = Color.FromArgb("#64748B"), + StrokeThickness = 0, + StrokeShape = new RoundRectangle { CornerRadius = 4 }, + VerticalOptions = LayoutOptions.Center, + Margin = new Thickness(0, 0, 6, 0) + }; + + _pcSyncStatusText = new Label + { + Text = "데스크톱 PACA P2P 연동", + TextColor = Color.FromArgb("#FFFFFF"), + FontSize = 13, + FontAttributes = FontAttributes.Bold + }; + + _pcSyncSubText = new Label + { + Text = "독립 실행 모드 • 탭/다운로드 원격 전송 가능", + TextColor = Color.FromArgb("#94A3B8"), + FontSize = 11 + }; + + _ntpScrollView = CreateNewTabPageView(); + + // ===================================================================== + // 3. 풀 웹뷰 브라우저 컨트롤 (Full WebView) + // ===================================================================== + _webView = new WebView + { + HorizontalOptions = LayoutOptions.Fill, + VerticalOptions = LayoutOptions.Fill, + IsVisible = false, + InputTransparent = true + }; + + _webView.Navigating += OnWebViewNavigating; + _webView.Navigated += OnWebViewNavigated; + + // ===================================================================== + // 4. 하단 플로팅 글래스 툴바 (Floating Glass Bottom Command Deck) + // ===================================================================== + var bottomContainer = new Grid + { + Padding = new Thickness(16, 4, 16, 16), + BackgroundColor = Colors.Transparent + }; + + var bottomBar = new Border + { + HeightRequest = 58, + BackgroundColor = Color.FromArgb("#12121A"), + Stroke = Color.FromArgb("#2C2C40"), + StrokeThickness = 1, + StrokeShape = new RoundRectangle { CornerRadius = 29 }, + Padding = new Thickness(8, 0) + }; + + var bottomGrid = new Grid + { + ColumnDefinitions = + { + new ColumnDefinition { Width = GridLength.Star }, // 뒤로 + new ColumnDefinition { Width = GridLength.Star }, // 앞으로 + new ColumnDefinition { Width = GridLength.Star }, // 홈 + new ColumnDefinition { Width = GridLength.Star }, // 다운로드 + new ColumnDefinition { Width = GridLength.Star }, // 탭 매니저 + new ColumnDefinition { Width = GridLength.Star } // 더보기 메뉴 + } + }; + + _backBtn = CreateToolbarButton("‹", () => { if (_webView.CanGoBack) _webView.GoBack(); }, fontSize: 24); + _forwardBtn = CreateToolbarButton("›", () => { if (_webView.CanGoForward) _webView.GoForward(); }, fontSize: 24); + var homeBtn = CreateToolbarButton("⌂", () => Navigate("paca://newtab"), fontSize: 20); + var downloadBtn = CreateToolbarButton("📥", async () => await Navigation.PushAsync(new DownloadsPage()), fontSize: 16); + + // 탭 카운트 알약 버튼 (Vibrant Purple Neon) + _tabCountLabel = new Label + { + Text = "1", + TextColor = Color.FromArgb("#FFFFFF"), + FontSize = 13, + FontAttributes = FontAttributes.Bold, + HorizontalOptions = LayoutOptions.Center, + VerticalOptions = LayoutOptions.Center, + InputTransparent = true + }; + var tabPill = new Border + { + BackgroundColor = Color.FromArgb("#1E1E2C"), + Stroke = Color.FromArgb("#8B7CFF"), + StrokeThickness = 1.5, + StrokeShape = new RoundRectangle { CornerRadius = 9 }, + WidthRequest = 34, + HeightRequest = 32, + HorizontalOptions = LayoutOptions.Center, + VerticalOptions = LayoutOptions.Center, + Content = _tabCountLabel + }; + var tabTap = new TapGestureRecognizer(); + tabTap.Tapped += async (_, _) => + { + try + { + await tabPill.ScaleTo(0.85, 40); + await tabPill.ScaleTo(1.0, 40); + await Navigation.PushAsync(new TabManagerPage(_tabManager)); + } + catch { } + }; + tabPill.GestureRecognizers.Add(tabTap); + + var menuBtn = CreateToolbarButton("•••", async () => await OpenMoreMenuAsync(), fontSize: 14); + + bottomGrid.Add(_backBtn, 0, 0); + bottomGrid.Add(_forwardBtn, 1, 0); + bottomGrid.Add(homeBtn, 2, 0); + bottomGrid.Add(downloadBtn, 3, 0); + bottomGrid.Add(tabPill, 4, 0); + bottomGrid.Add(menuBtn, 5, 0); + + bottomBar.Content = bottomGrid; + bottomContainer.Children.Add(bottomBar); + + // ===================================================================== + // 메인 뷰포트 레이아웃 구성 + // ===================================================================== + var contentGrid = new Grid + { + RowDefinitions = + { + new RowDefinition { Height = GridLength.Auto }, // 상단 옴니박스 + new RowDefinition { Height = GridLength.Auto }, // 로딩 프로그레스 + new RowDefinition { Height = GridLength.Star }, // 웹뷰 / NTP 컨텐츠 영역 + new RowDefinition { Height = GridLength.Auto } // 하단 플로팅 툴바 + } + }; + + contentGrid.Add(topBarBorder, 0, 0); + contentGrid.Add(_progressBar, 0, 1); + contentGrid.Add(_ntpScrollView, 0, 2); + contentGrid.Add(_webView, 0, 2); + contentGrid.Add(bottomContainer, 0, 3); + + Content = contentGrid; + + _tabManager.ActiveTabChanged += OnActiveTabChanged; + _tabManager.TabsChanged += OnTabsChanged; + + // 초기 시작 + OnActiveTabChanged(); + _ = CheckPcConnectionAsync(); + } + + private View CreateToolbarButton(string text, Action onClick, double fontSize = 16) + { + var lbl = new Label + { + Text = text, + TextColor = Color.FromArgb("#F2F2F6"), + FontSize = fontSize, + FontAttributes = FontAttributes.Bold, + HorizontalOptions = LayoutOptions.Center, + VerticalOptions = LayoutOptions.Center, + InputTransparent = true + }; + var border = new Border + { + BackgroundColor = Color.FromArgb("#01000000"), + StrokeThickness = 0, + HeightRequest = 48, + WidthRequest = 48, + HorizontalOptions = LayoutOptions.Center, + VerticalOptions = LayoutOptions.Center, + Content = lbl + }; + var tap = new TapGestureRecognizer(); + tap.Tapped += async (_, _) => + { + try + { + await border.ScaleTo(0.85, 40); + await border.ScaleTo(1.0, 40); + onClick(); + } + catch { } + }; + border.GestureRecognizers.Add(tap); + return border; + } + + private ScrollView CreateNewTabPageView() + { + var rootLayout = new VerticalStackLayout + { + Padding = new Thickness(16, 24, 16, 32), + Spacing = 20 + }; + + // 1. 브랜드 헤더 (Double-Bezel Llama Emblem & Typography) + var brandStack = new VerticalStackLayout + { + HorizontalOptions = LayoutOptions.Center, + Spacing = 10 + }; + + // 더블 베젤 앰비언트 글로우 라마 로고 + var outerLogoGlow = new Border + { + WidthRequest = 76, + HeightRequest = 76, + BackgroundColor = Color.FromArgb("#161626"), + Stroke = Color.FromArgb("#3B3B58"), + StrokeThickness = 1.5, + StrokeShape = new RoundRectangle { CornerRadius = 24 }, + HorizontalOptions = LayoutOptions.Center, + Padding = new Thickness(4) + }; + + var innerLogoCore = new Border + { + BackgroundColor = Color.FromArgb("#1F1F34"), + Stroke = Color.FromArgb("#8B7CFF"), + StrokeThickness = 1, + StrokeShape = new RoundRectangle { CornerRadius = 20 }, + HorizontalOptions = LayoutOptions.Fill, + VerticalOptions = LayoutOptions.Fill, + Content = new Label + { + Text = "🦙", + FontSize = 32, + HorizontalOptions = LayoutOptions.Center, + VerticalOptions = LayoutOptions.Center + } + }; + outerLogoGlow.Content = innerLogoCore; + brandStack.Children.Add(outerLogoGlow); + + var titleRow = new HorizontalStackLayout + { + HorizontalOptions = LayoutOptions.Center, + Spacing = 6 + }; + titleRow.Children.Add(new Label + { + Text = "PACA", + TextColor = Color.FromArgb("#FFFFFF"), + FontSize = 26, + FontAttributes = FontAttributes.Bold, + CharacterSpacing = 2.5 + }); + titleRow.Children.Add(new Label + { + Text = "PRO", + TextColor = Color.FromArgb("#8B7CFF"), + FontSize = 14, + FontAttributes = FontAttributes.Bold, + VerticalOptions = LayoutOptions.Center, + Margin = new Thickness(0, 0, 0, 4) + }); + brandStack.Children.Add(titleRow); + + brandStack.Children.Add(new Label + { + Text = "차세대 독립형 모바일 비디오 브라우저 & 다운로더", + TextColor = Color.FromArgb("#94A3B8"), + FontSize = 12, + HorizontalTextAlignment = TextAlignment.Center + }); + + rootLayout.Children.Add(brandStack); + + // 2. 데스크톱 PACA P2P 연동 센터 (Hero Double-Bezel Card) + var syncCard = new Border + { + BackgroundColor = Color.FromArgb("#12121C"), + Stroke = Color.FromArgb("#262638"), + StrokeThickness = 1, + StrokeShape = new RoundRectangle { CornerRadius = 18 }, + Padding = new Thickness(14, 12), + Margin = new Thickness(2, 0) + }; + var syncGrid = new Grid + { + ColumnDefinitions = + { + new ColumnDefinition { Width = GridLength.Auto }, + new ColumnDefinition { Width = GridLength.Star }, + new ColumnDefinition { Width = GridLength.Auto } + } + }; + syncGrid.Add(_pcStatusDot, 0, 0); + + var syncTextStack = new VerticalStackLayout + { + VerticalOptions = LayoutOptions.Center, + Spacing = 2 + }; + syncTextStack.Children.Add(_pcSyncStatusText); + syncTextStack.Children.Add(_pcSyncSubText); + syncGrid.Add(syncTextStack, 1, 0); + + var syncSettingBtn = new Button + { + Text = "연결 관리 →", + TextColor = Color.FromArgb("#8B7CFF"), + BackgroundColor = Color.FromArgb("#1C1C2C"), + FontSize = 11, + FontAttributes = FontAttributes.Bold, + HeightRequest = 34, + CornerRadius = 10, + Padding = new Thickness(12, 0), + VerticalOptions = LayoutOptions.Center + }; + syncSettingBtn.Clicked += async (_, _) => await Navigation.PushAsync(new SyncSettingsPage(_tabManager, _historyStore)); + syncGrid.Add(syncSettingBtn, 2, 0); + + syncCard.Content = syncGrid; + rootLayout.Children.Add(syncCard); + + // 3. 스피드 다이얼 섹션 (Speed Dial Bento Grid) + var speedDialHeaderRow = new HorizontalStackLayout + { + Spacing = 8, + Margin = new Thickness(4, 8, 0, 0) + }; + speedDialHeaderRow.Children.Add(new Label + { + Text = "🚀", + FontSize = 15, + VerticalOptions = LayoutOptions.Center + }); + speedDialHeaderRow.Children.Add(new Label + { + Text = "빠른 실행 (Speed Dials)", + TextColor = Color.FromArgb("#FFFFFF"), + FontSize = 15, + FontAttributes = FontAttributes.Bold, + VerticalOptions = LayoutOptions.Center + }); + rootLayout.Children.Add(speedDialHeaderRow); + + var shortcutsGrid = new Grid + { + RowDefinitions = + { + new RowDefinition { Height = GridLength.Auto }, + new RowDefinition { Height = GridLength.Auto } + }, + ColumnDefinitions = + { + new ColumnDefinition { Width = GridLength.Star }, + new ColumnDefinition { Width = GridLength.Star }, + new ColumnDefinition { Width = GridLength.Star }, + new ColumnDefinition { Width = GridLength.Star } + }, + RowSpacing = 10, + ColumnSpacing = 8 + }; + + // 8대 럭셔리 브랜드 타일 + AddLuxurySpeedDialTile(shortcutsGrid, 0, 0, "YouTube", "▶", "#FF0000", "#990000", "비디오 허브", "https://m.youtube.com"); + AddLuxurySpeedDialTile(shortcutsGrid, 0, 1, "네이버", "N", "#03C75A", "#028A3E", "포털 검색", "https://m.naver.com"); + AddLuxurySpeedDialTile(shortcutsGrid, 0, 2, "치지직", "⚡", "#00FFA3", "#00B371", "라이브 방송", "https://chzzk.naver.com"); + AddLuxurySpeedDialTile(shortcutsGrid, 0, 3, "SOOP", "🌊", "#0075FF", "#0048B3", "스트리밍", "https://m.sooplive.co.kr"); + + AddLuxurySpeedDialTile(shortcutsGrid, 1, 0, "Instagram", "📷", "#E1306C", "#833AB4", "소셜 미디어", "https://instagram.com"); + AddLuxurySpeedDialTile(shortcutsGrid, 1, 1, "X (Twitter)", "𝕏", "#333333", "#111111", "트렌드 뉴스", "https://x.com"); + AddLuxurySpeedDialTile(shortcutsGrid, 1, 2, "Reddit", "💬", "#FF4500", "#CC3700", "커뮤니티", "https://reddit.com"); + AddLuxurySpeedDialTile(shortcutsGrid, 1, 3, "Twitch", "📺", "#9146FF", "#5C16C5", "게임 방송", "https://m.twitch.tv"); + + rootLayout.Children.Add(shortcutsGrid); + + // 4. 스마트 인텔리전스 퀵 칩 (Quick Action Vault Chips) + var quickHeader = new Label + { + Text = "⚡ 빠른 작업 & 인텔리전스", + TextColor = Color.FromArgb("#FFFFFF"), + FontSize = 14, + FontAttributes = FontAttributes.Bold, + Margin = new Thickness(4, 10, 0, 0) + }; + rootLayout.Children.Add(quickHeader); + + var quickGrid = new Grid + { + ColumnDefinitions = + { + new ColumnDefinition { Width = GridLength.Star }, + new ColumnDefinition { Width = GridLength.Star } + }, + RowDefinitions = + { + new RowDefinition { Height = GridLength.Auto }, + new RowDefinition { Height = GridLength.Auto } + }, + RowSpacing = 8, + ColumnSpacing = 8 + }; + + AddQuickFeatureCard(quickGrid, 0, 0, "📥 다운로드 보관함", "오프라인 비디오 재생", async () => await Navigation.PushAsync(new DownloadsPage())); + AddQuickFeatureCard(quickGrid, 0, 1, "⭐ 북마크 & 기록", "방문 기록 / 즐겨찾기", async () => await Navigation.PushAsync(new HistoryBookmarksPage(_historyStore, _bookmarkStore, url => Navigate(url)))); + AddQuickFeatureCard(quickGrid, 1, 0, "🕵️ 새 시크릿 탭", "기록 남기지 않는 서핑", () => _tabManager.AddTab("paca://newtab", isPrivate: true)); + AddQuickFeatureCard(quickGrid, 1, 1, "🛡️ 스니퍼 대기 중", "HLS/DASH 자동 감지", async () => await OpenSnifferModalAsync()); + + rootLayout.Children.Add(quickGrid); + + return new ScrollView { Content = rootLayout }; + } + + private void AddLuxurySpeedDialTile(Grid grid, int row, int col, string name, string glyph, string gradStart, string gradEnd, string category, string url) + { + var tile = new Border + { + BackgroundColor = Color.FromArgb("#14141E"), + Stroke = Color.FromArgb("#26263A"), + StrokeThickness = 1, + StrokeShape = new RoundRectangle { CornerRadius = 18 }, + HeightRequest = 94, + Padding = new Thickness(6, 8) + }; + + var stack = new VerticalStackLayout + { + VerticalOptions = LayoutOptions.Center, + Spacing = 4 + }; + + // 브랜드 그라디언트 아이콘 캡슐 + var iconBorder = new Border + { + WidthRequest = 36, + HeightRequest = 36, + BackgroundColor = Color.FromArgb(gradStart), + Stroke = Color.FromArgb(gradEnd), + StrokeThickness = 1, + StrokeShape = new RoundRectangle { CornerRadius = 18 }, + HorizontalOptions = LayoutOptions.Center, + Content = new Label + { + Text = glyph, + TextColor = Colors.White, + FontSize = glyph.Length > 1 ? 14 : 16, + FontAttributes = FontAttributes.Bold, + HorizontalOptions = LayoutOptions.Center, + VerticalOptions = LayoutOptions.Center + } + }; + stack.Children.Add(iconBorder); + + stack.Children.Add(new Label + { + Text = name, + TextColor = Color.FromArgb("#FFFFFF"), + FontSize = 11, + FontAttributes = FontAttributes.Bold, + HorizontalTextAlignment = TextAlignment.Center, + LineBreakMode = LineBreakMode.TailTruncation + }); + + stack.Children.Add(new Label + { + Text = category, + TextColor = Color.FromArgb("#94A3B8"), + FontSize = 9, + HorizontalTextAlignment = TextAlignment.Center, + LineBreakMode = LineBreakMode.TailTruncation + }); + + tile.Content = stack; + + var tap = new TapGestureRecognizer(); + tap.Tapped += (_, _) => Navigate(url); + tile.GestureRecognizers.Add(tap); + + grid.Add(tile, col, row); + } + + private void AddQuickFeatureCard(Grid grid, int row, int col, string title, string sub, Action onClick) + { + var card = new Border + { + BackgroundColor = Color.FromArgb("#12121C"), + Stroke = Color.FromArgb("#242436"), + StrokeThickness = 1, + StrokeShape = new RoundRectangle { CornerRadius = 14 }, + Padding = new Thickness(12, 10) + }; + + var stack = new VerticalStackLayout { Spacing = 2 }; + stack.Children.Add(new Label + { + Text = title, + TextColor = Color.FromArgb("#FFFFFF"), + FontSize = 12, + FontAttributes = FontAttributes.Bold + }); + stack.Children.Add(new Label + { + Text = sub, + TextColor = Color.FromArgb("#94A3B8"), + FontSize = 10 + }); + card.Content = stack; + + var tap = new TapGestureRecognizer(); + tap.Tapped += (_, _) => onClick(); + card.GestureRecognizers.Add(tap); + + grid.Add(card, col, row); + } + + public void Navigate(string target) + { + if (_isNavigating) return; + _isNavigating = true; + try + { + if (string.IsNullOrWhiteSpace(target) || target.Equals("paca://newtab", StringComparison.OrdinalIgnoreCase) || target.Equals("about:blank", StringComparison.OrdinalIgnoreCase)) + { + _urlEntry.Text = ""; + _sslIcon.Text = "🌐"; + _ntpScrollView.IsVisible = true; + _ntpScrollView.InputTransparent = false; + _webView.IsVisible = false; + _webView.InputTransparent = true; + _tabManager.UpdateActiveTab("paca://newtab", "새 탭"); + UpdateSnifferBadge(0); + return; + } + + string finalUrl; + if (target.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || target.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + { + finalUrl = target; + } + else if (target.Contains('.') && !target.Contains(' ')) + { + finalUrl = "https://" + target; + } + else + { + // 구글 검색어 처리 + finalUrl = "https://www.google.com/search?q=" + Uri.EscapeDataString(target); + } + + _ntpScrollView.IsVisible = false; + _ntpScrollView.InputTransparent = true; + _webView.IsVisible = true; + _webView.InputTransparent = false; + _urlEntry.Text = finalUrl; + _webView.Source = finalUrl; + } + finally + { + _isNavigating = false; + } + } + + private void NavigateFromOmnibox(string query) + { + if (!string.IsNullOrWhiteSpace(query)) Navigate(query); + } + + private void OnWebViewNavigating(object? sender, WebNavigatingEventArgs e) + { + _progressBar.IsVisible = true; + _progressBar.Progress = 0.3; + _sslIcon.Text = e.Url.StartsWith("https://", StringComparison.OrdinalIgnoreCase) ? "🔒" : "🔓"; + _urlEntry.Text = e.Url; + _tabManager.UpdateActiveTab(e.Url, progress: 0.3, isLoading: true); + } + + private async void OnWebViewNavigated(object? sender, WebNavigatedEventArgs e) + { + _progressBar.Progress = 1.0; + _progressBar.IsVisible = false; + + if (e.Result == WebNavigationResult.Success) + { + _urlEntry.Text = e.Url; + + // 페이지 타이틀 추출 + string title = ""; + try + { + var titleResult = await _webView.EvaluateJavaScriptAsync("document.title"); + title = titleResult?.Trim('"', ' ', '\r', '\n') ?? ""; + } + catch { } + + if (string.IsNullOrWhiteSpace(title)) title = e.Url; + + _tabManager.UpdateActiveTab(e.Url, title, progress: 1.0, isLoading: false); + + // 히스토리 기록 (InPrivate 제외) + if (_tabManager.ActiveTab?.IsPrivate != true) + { + await _historyStore.AddVisitAsync(e.Url, title); + } + + // 비디오 스니퍼 인젝션 + try + { + await _webView.EvaluateJavaScriptAsync(SnifferScript.InjectionJs); + } + catch { } + + // 스니퍼 결과 폴링 + _ = Task.Run(async () => + { + await Task.Delay(1500); + MainThread.BeginInvokeOnMainThread(async () => + { + try + { + var json = await _webView.EvaluateJavaScriptAsync("JSON.stringify(Array.from(document.querySelectorAll('video')).map(v => ({ src: v.currentSrc || v.src, width: v.videoWidth, height: v.videoHeight, duration: v.duration, poster: v.poster, title: document.title })))"); + var items = VideoSnifferParser.ParseDomPayload(json ?? "", _webView.Source?.ToString() ?? ""); + _tabManager.UpdateActiveTab(_webView.Source?.ToString() ?? "", detectedVideos: items); + UpdateSnifferBadge(items.Count); + } + catch { } + }); + }); + } + else + { + _tabManager.UpdateActiveTab(e.Url, progress: 0.0, isLoading: false); + } + } + + private void UpdateSnifferBadge(int count) + { + if (count > 0) + { + _snifferBadgeLabel.Text = $"⚡ 비디오 {count}"; + _snifferBadge.IsVisible = true; + } + else + { + _snifferBadge.IsVisible = false; + } + } + + private void OnActiveTabChanged() + { + var active = _tabManager.ActiveTab; + if (active == null) return; + + UpdateTabCountBadge(); + Navigate(active.Url); + } + + private void OnTabsChanged() + { + UpdateTabCountBadge(); + } + + private void UpdateTabCountBadge() + { + _tabCountLabel.Text = $"{_tabManager.Count}"; + } + + private async Task CheckPcConnectionAsync() + { + try + { + if (ApiClient.Instance.IsConfigured) + { + var queue = await ApiClient.Instance.GetQueueAsync(); + if (queue != null) + { + _pcStatusIndicator.TextColor = Color.FromArgb("#34D399"); // Green + _pcStatusDot.BackgroundColor = Color.FromArgb("#34D399"); + _pcSyncStatusText.Text = $"💻 PACA 데스크톱: 연결됨"; + _pcSyncSubText.Text = $"IP: {ApiClient.Instance.BaseUrl} • 실시간 탭 동기화 활성"; + _pcSyncStatusText.TextColor = Color.FromArgb("#34D399"); + return; + } + } + } + catch { } + + _pcStatusIndicator.TextColor = Color.FromArgb("#64748B"); + _pcStatusDot.BackgroundColor = Color.FromArgb("#64748B"); + _pcSyncStatusText.Text = "데스크톱 PACA P2P 연동"; + _pcSyncSubText.Text = "독립 실행 모드 • 탭/다운로드 원격 전송 가능"; + _pcSyncStatusText.TextColor = Color.FromArgb("#FFFFFF"); + } + + private async Task OpenSnifferModalAsync() + { + var activeTab = _tabManager.ActiveTab; + var mediaItems = activeTab?.DetectedVideos ?? new List(); + if (mediaItems.Count == 0) + { + await DisplayAlert("비디오 스니퍼", "현재 페이지에서 감지된 비디오 스트림이 없습니다.\n영상 재생 버튼을 누르면 자동 감지됩니다.", "확인"); + return; + } + + var modal = new VideoSnifferModal(mediaItems, async (selected, sendToPc) => + { + if (sendToPc) + { + if (ApiClient.Instance.IsConfigured) + { + var ok = await ApiClient.Instance.AddAsync(selected.StreamUrl); + await DisplayAlert("데스크톱 전송", ok ? "✅ PACA 데스크톱 다운로드 대기열에 추가되었습니다." : "❌ 전송 실패", "확인"); + } + else + { + await DisplayAlert("PC 미연결", "PC 동기화 설정에서 데스크톱 IP를 먼저 연결하세요.", "확인"); + } + } + else + { + await DownloadsPage.StartDownloadAsync(selected.StreamUrl, string.IsNullOrWhiteSpace(selected.Title) ? (activeTab?.Title ?? "동영상") : selected.Title); + await DisplayAlert("다운로드 시작", "다운로드 허브에서 진행률을 확인할 수 있습니다.", "확인"); + } + }); + + await Navigation.PushModalAsync(modal); + } + + private async Task OpenMoreMenuAsync() + { + var activeTab = _tabManager.ActiveTab; + var isBookmarked = activeTab != null && _bookmarkStore.IsBookmarked(activeTab.Url); + + var bookmarkAction = isBookmarked ? "⭐ 북마크 해제" : "⭐ 현재 페이지 북마크"; + var choice = await DisplayActionSheet("PACA PRO 브라우저 메뉴", "취소", null, + "🖥️ 데스크톱으로 탭 전송", + bookmarkAction, + "📜 방문 기록 및 북마크", + "📥 다운로드 관리자", + "🔄 PC 동기화 설정", + "🔗 링크 복사 및 공유", + "➕ 새 시크릿 탭 열기" + ); + + if (choice == "🖥️ 데스크톱으로 탭 전송") + { + if (activeTab != null && !string.IsNullOrWhiteSpace(activeTab.Url)) + { + if (ApiClient.Instance.IsConfigured) + { + var ok = await ApiClient.Instance.AddAsync(activeTab.Url); + await DisplayAlert("데스크톱 전송", ok ? $"✅ '{activeTab.Title}' 탭이 데스크톱으로 전송되었습니다." : "❌ 전송 실패", "확인"); + } + else + { + await DisplayAlert("PC 미연결", "PC 동기화 설정에서 데스크톱 IP를 먼저 연결하세요.", "확인"); + } + } + } + else if (choice == bookmarkAction) + { + if (activeTab != null && !string.IsNullOrWhiteSpace(activeTab.Url)) + { + if (isBookmarked) await _bookmarkStore.RemoveByUrlAsync(activeTab.Url); + else await _bookmarkStore.AddOrUpdateBookmarkAsync(activeTab.Url, activeTab.Title); + await DisplayAlert("북마크", isBookmarked ? "북마크에서 제거되었습니다." : "⭐ 북마크에 추가되었습니다.", "확인"); + } + } + else if (choice == "📜 방문 기록 및 북마크") + { + await Navigation.PushAsync(new HistoryBookmarksPage(_historyStore, _bookmarkStore, url => Navigate(url))); + } + else if (choice == "📥 다운로드 관리자") + { + await Navigation.PushAsync(new DownloadsPage()); + } + else if (choice == "🔄 PC 동기화 설정") + { + await Navigation.PushAsync(new SyncSettingsPage(_tabManager, _historyStore)); + } + else if (choice == "🔗 링크 복사 및 공유") + { + if (activeTab != null && !string.IsNullOrWhiteSpace(activeTab.Url)) + { + await Clipboard.Default.SetTextAsync(activeTab.Url); + await DisplayAlert("링크 복사", "클립보드에 URL이 복사되었습니다.", "확인"); + } + } + else if (choice == "➕ 새 시크릿 탭 열기") + { + _tabManager.AddTab("paca://newtab", isPrivate: true); + } + } +} diff --git a/src/Paca.Mobile/DownloadsPage.cs b/src/Paca.Mobile/DownloadsPage.cs new file mode 100644 index 0000000..2dd90cb --- /dev/null +++ b/src/Paca.Mobile/DownloadsPage.cs @@ -0,0 +1,451 @@ +using Microsoft.Maui.Controls; +using Microsoft.Maui.Controls.Shapes; + +namespace Paca.Mobile; + +/// 인앱 다운로드 진행 및 완료된 미디어 관리 화면 (Obsidian Dark Luxury Design) +public sealed class DownloadsPage : ContentPage +{ + private readonly VerticalStackLayout _tasksLayout = new() { Spacing = 12 }; + private readonly VerticalStackLayout _offlineLayout = new() { Spacing = 12 }; + private readonly ScrollView _tasksScrollView; + private readonly ScrollView _offlineScrollView; + private readonly Button _tabActiveBtn; + private readonly Button _tabOfflineBtn; + private int _selectedSegment = 0; // 0 = Active, 1 = Offline + + public DownloadsPage() + { + NavigationPage.SetHasNavigationBar(this, false); + BackgroundColor = Color.FromArgb("#08080C"); + + var mainLayout = new Grid + { + RowDefinitions = + { + new RowDefinition { Height = GridLength.Auto }, // 상단 네비게이션 헤더 + new RowDefinition { Height = GridLength.Auto }, // 세그먼트 스위처 + new RowDefinition { Height = GridLength.Star } // 목록 컨텐츠 + }, + Padding = new Thickness(16, 12, 16, 16) + }; + + // 1. 상단 네비게이션 헤더 + var topHeader = new Grid + { + ColumnDefinitions = + { + new ColumnDefinition { Width = GridLength.Auto }, + new ColumnDefinition { Width = GridLength.Star }, + new ColumnDefinition { Width = GridLength.Auto } + }, + Margin = new Thickness(0, 4, 0, 12) + }; + + var backBtn = new Button + { + Text = "‹", + TextColor = Color.FromArgb("#FFFFFF"), + BackgroundColor = Color.FromArgb("#161622"), + FontSize = 22, + FontAttributes = FontAttributes.Bold, + WidthRequest = 36, + HeightRequest = 36, + CornerRadius = 18, + Padding = new Thickness(0, 0, 2, 2), + VerticalOptions = LayoutOptions.Center + }; + backBtn.Clicked += async (_, _) => await Navigation.PopAsync(); + topHeader.Add(backBtn, 0, 0); + + var headerTitleStack = new VerticalStackLayout + { + VerticalOptions = LayoutOptions.Center, + Margin = new Thickness(10, 0, 0, 0), + Spacing = 1 + }; + headerTitleStack.Children.Add(new Label + { + Text = "다운로드 허브", + TextColor = Color.FromArgb("#FFFFFF"), + FontSize = 18, + FontAttributes = FontAttributes.Bold + }); + headerTitleStack.Children.Add(new Label + { + Text = "오프라인 고화질 비디오 보관소", + TextColor = Color.FromArgb("#94A3B8"), + FontSize = 11 + }); + topHeader.Add(headerTitleStack, 1, 0); + + mainLayout.Add(topHeader, 0, 0); + + // 2. 상단 2단 세그먼트 스위처 바 + var segmentBorder = new Border + { + BackgroundColor = Color.FromArgb("#12121C"), + Stroke = Color.FromArgb("#242436"), + StrokeThickness = 1, + StrokeShape = new RoundRectangle { CornerRadius = 12 }, + Padding = new Thickness(3), + Margin = new Thickness(0, 0, 0, 12) + }; + + var segmentGrid = new Grid + { + ColumnDefinitions = + { + new ColumnDefinition { Width = GridLength.Star }, + new ColumnDefinition { Width = GridLength.Star } + }, + ColumnSpacing = 4 + }; + + _tabActiveBtn = new Button + { + Text = "📥 진행 중", + BackgroundColor = Color.FromArgb("#8B7CFF"), + TextColor = Colors.White, + FontAttributes = FontAttributes.Bold, + FontSize = 12, + CornerRadius = 10, + HeightRequest = 36 + }; + _tabActiveBtn.Clicked += (_, _) => SwitchSegment(0); + segmentGrid.Add(_tabActiveBtn, 0, 0); + + _tabOfflineBtn = new Button + { + Text = "📁 오프라인 보관함", + BackgroundColor = Colors.Transparent, + TextColor = Color.FromArgb("#94A3B8"), + FontAttributes = FontAttributes.Bold, + FontSize = 12, + CornerRadius = 10, + HeightRequest = 36 + }; + _tabOfflineBtn.Clicked += (_, _) => SwitchSegment(1); + segmentGrid.Add(_tabOfflineBtn, 1, 0); + + segmentBorder.Content = segmentGrid; + mainLayout.Add(segmentBorder, 0, 1); + + // 3. 컨텐츠 스크롤 뷰 + _tasksScrollView = new ScrollView { Content = _tasksLayout, IsVisible = true }; + _offlineScrollView = new ScrollView { Content = _offlineLayout, IsVisible = false }; + + mainLayout.Add(_tasksScrollView, 0, 2); + mainLayout.Add(_offlineScrollView, 0, 2); + + Content = mainLayout; + + MobileDownloadEngine.Instance.TasksChanged += () => MainThread.BeginInvokeOnMainThread(Refresh); + MobileDownloadEngine.Instance.TaskProgressUpdated += _ => MainThread.BeginInvokeOnMainThread(Refresh); + + Refresh(); + } + + public static Task StartDownloadAsync(string url, string title) + { + var video = new VideoSniffItem + { + StreamUrl = url, + Title = title, + VideoType = url.Contains(".m3u8", StringComparison.OrdinalIgnoreCase) ? VideoStreamType.Hls : VideoStreamType.Direct + }; + var moviesDir = System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyVideos), "Paca"); + MobileDownloadEngine.Instance.EnqueueDownload(video, moviesDir); + return Task.CompletedTask; + } + + private void SwitchSegment(int index) + { + _selectedSegment = index; + if (index == 0) + { + _tabActiveBtn.BackgroundColor = Color.FromArgb("#8B7CFF"); + _tabActiveBtn.TextColor = Colors.White; + _tabOfflineBtn.BackgroundColor = Colors.Transparent; + _tabOfflineBtn.TextColor = Color.FromArgb("#94A3B8"); + _tasksScrollView.IsVisible = true; + _offlineScrollView.IsVisible = false; + } + else + { + _tabOfflineBtn.BackgroundColor = Color.FromArgb("#8B7CFF"); + _tabOfflineBtn.TextColor = Colors.White; + _tabActiveBtn.BackgroundColor = Colors.Transparent; + _tabActiveBtn.TextColor = Color.FromArgb("#94A3B8"); + _offlineScrollView.IsVisible = true; + _tasksScrollView.IsVisible = false; + } + Refresh(); + } + + protected override void OnAppearing() + { + base.OnAppearing(); + Refresh(); + } + + private void Refresh() + { + // 1. 활성 태스크 갱신 + _tasksLayout.Children.Clear(); + var tasks = MobileDownloadEngine.Instance.Tasks; + + if (tasks.Count == 0) + { + var emptyCard = new Border + { + BackgroundColor = Color.FromArgb("#12121C"), + Stroke = Color.FromArgb("#242436"), + StrokeThickness = 1, + StrokeShape = new RoundRectangle { CornerRadius = 16 }, + Padding = new Thickness(20, 32), + Content = new VerticalStackLayout + { + Spacing = 8, + HorizontalOptions = LayoutOptions.Center, + Children = + { + new Label { Text = "📥", FontSize = 36, HorizontalTextAlignment = TextAlignment.Center }, + new Label { Text = "진행 중인 다운로드가 없습니다.", TextColor = Color.FromArgb("#FFFFFF"), FontSize = 14, FontAttributes = FontAttributes.Bold, HorizontalTextAlignment = TextAlignment.Center }, + new Label { Text = "웹 브라우징 중 상단 에메랄드 비디오 뱃지를 눌러 고화질 영상을 다운로드하세요.", TextColor = Color.FromArgb("#94A3B8"), FontSize = 12, HorizontalTextAlignment = TextAlignment.Center } + } + } + }; + _tasksLayout.Children.Add(emptyCard); + } + else + { + foreach (var task in tasks) + { + var card = new Border + { + BackgroundColor = Color.FromArgb("#12121C"), + Stroke = Color.FromArgb("#242436"), + StrokeThickness = 1, + StrokeShape = new RoundRectangle { CornerRadius = 16 }, + Padding = new Thickness(14) + }; + + var stack = new VerticalStackLayout { Spacing = 8 }; + + var headerGrid = new Grid + { + ColumnDefinitions = + { + new ColumnDefinition { Width = GridLength.Star }, + new ColumnDefinition { Width = GridLength.Auto } + } + }; + + var titleLabel = new Label + { + Text = task.Video.Title, + TextColor = Color.FromArgb("#FFFFFF"), + FontSize = 14, + FontAttributes = FontAttributes.Bold, + LineBreakMode = LineBreakMode.TailTruncation, + MaxLines = 1 + }; + headerGrid.Add(titleLabel, 0, 0); + + var statusLabel = new Label + { + Text = task.Status switch + { + MobileDownloadStatus.Queued => "⏳ 대기 중", + MobileDownloadStatus.Downloading => $"⚡ {task.ProgressPercent:0}%", + MobileDownloadStatus.Completed => "✅ 완료", + MobileDownloadStatus.Failed => "❌ 실패", + MobileDownloadStatus.Cancelled => "취소됨", + _ => "" + }, + TextColor = task.Status == MobileDownloadStatus.Completed ? Color.FromArgb("#34D399") : Color.FromArgb("#8B7CFF"), + FontSize = 12, + FontAttributes = FontAttributes.Bold, + VerticalOptions = LayoutOptions.Center + }; + headerGrid.Add(statusLabel, 1, 0); + stack.Children.Add(headerGrid); + + if (task.Status == MobileDownloadStatus.Downloading) + { + var progress = new ProgressBar + { + Progress = task.ProgressPercent / 100.0, + ProgressColor = Color.FromArgb("#8B7CFF"), + HeightRequest = 6 + }; + stack.Children.Add(progress); + + var speedText = task.SpeedBytesPerSec > 1024 * 1024 + ? $"{task.SpeedBytesPerSec / (1024 * 1024):0.1} MB/s" + : $"{task.SpeedBytesPerSec / 1024:0} KB/s"; + + var metaLabel = new Label + { + Text = $"{task.DownloadedBytes / (1024 * 1024):0.1} MB / {(task.TotalBytes > 0 ? $"{task.TotalBytes / (1024 * 1024):0.1} MB" : "계산 중")} • {speedText}", + TextColor = Color.FromArgb("#94A3B8"), + FontSize = 11 + }; + stack.Children.Add(metaLabel); + + var cancelBtn = new Button + { + Text = "취소", + BackgroundColor = Color.FromArgb("#1E1418"), + TextColor = Color.FromArgb("#FF6B6B"), + FontSize = 11, + HeightRequest = 32, + CornerRadius = 8, + HorizontalOptions = LayoutOptions.End, + Padding = new Thickness(12, 0) + }; + cancelBtn.Clicked += (_, _) => MobileDownloadEngine.Instance.CancelDownload(task.Id); + stack.Children.Add(cancelBtn); + } + + card.Content = stack; + _tasksLayout.Children.Add(card); + } + } + + // 2. 오프라인 저장소 갱신 + _offlineLayout.Children.Clear(); + var localFiles = AppOfflineStore.Default.List(); + if (localFiles.Count == 0) + { + var emptyCard = new Border + { + BackgroundColor = Color.FromArgb("#12121C"), + Stroke = Color.FromArgb("#242436"), + StrokeThickness = 1, + StrokeShape = new RoundRectangle { CornerRadius = 16 }, + Padding = new Thickness(20, 32), + Content = new VerticalStackLayout + { + Spacing = 8, + HorizontalOptions = LayoutOptions.Center, + Children = + { + new Label { Text = "📁", FontSize = 36, HorizontalTextAlignment = TextAlignment.Center }, + new Label { Text = "저장된 비디오가 없습니다.", TextColor = Color.FromArgb("#FFFFFF"), FontSize = 14, FontAttributes = FontAttributes.Bold, HorizontalTextAlignment = TextAlignment.Center }, + new Label { Text = "다운로드가 완료된 미디어가 여기에 안전하게 보관됩니다.", TextColor = Color.FromArgb("#94A3B8"), FontSize = 12, HorizontalTextAlignment = TextAlignment.Center } + } + } + }; + _offlineLayout.Children.Add(emptyCard); + } + else + { + foreach (var file in localFiles) + { + var card = new Border + { + BackgroundColor = Color.FromArgb("#12121C"), + Stroke = Color.FromArgb("#242436"), + StrokeThickness = 1, + StrokeShape = new RoundRectangle { CornerRadius = 16 }, + Padding = new Thickness(14) + }; + + var grid = new Grid + { + ColumnDefinitions = + { + new ColumnDefinition { Width = GridLength.Star }, + new ColumnDefinition { Width = GridLength.Auto } + } + }; + + var stack = new VerticalStackLayout { Spacing = 4 }; + stack.Children.Add(new Label + { + Text = file.FileName, + TextColor = Color.FromArgb("#FFFFFF"), + FontSize = 14, + FontAttributes = FontAttributes.Bold, + LineBreakMode = LineBreakMode.TailTruncation, + MaxLines = 1 + }); + stack.Children.Add(new Label + { + Text = $"💾 {file.SizeBytes / (1024 * 1024):0.1} MB", + TextColor = Color.FromArgb("#94A3B8"), + FontSize = 11 + }); + grid.Add(stack, 0, 0); + + var actionRow = new HorizontalStackLayout { Spacing = 6 }; + + var playBtn = new Button + { + Text = "▶ 재생", + BackgroundColor = Color.FromArgb("#8B7CFF"), + TextColor = Colors.White, + FontSize = 12, + FontAttributes = FontAttributes.Bold, + HeightRequest = 36, + CornerRadius = 10, + Padding = new Thickness(12, 0) + }; + playBtn.Clicked += async (_, _) => + { + try + { + var localPath = AppOfflineStore.Default.PathFor(file.Id); + if (System.IO.File.Exists(localPath)) + { + await Launcher.OpenAsync(new OpenFileRequest + { + File = new ReadOnlyFile(localPath), + Title = file.FileName + }); + } + else + { + await DisplayAlert("재생 실패", "파일이 존재하지 않습니다.", "확인"); + } + } + catch (Exception ex) + { + await DisplayAlert("재생 오류", ex.Message, "확인"); + } + }; + actionRow.Children.Add(playBtn); + + var deleteBtn = new Button + { + Text = "🗑️", + BackgroundColor = Color.FromArgb("#1E1418"), + TextColor = Color.FromArgb("#FF6B6B"), + FontSize = 13, + HeightRequest = 36, + WidthRequest = 36, + CornerRadius = 10, + Padding = 0 + }; + deleteBtn.Clicked += async (_, _) => + { + var confirm = await DisplayAlert("삭제", $"'{file.FileName}'을(를) 삭제하시겠습니까?", "삭제", "취소"); + if (confirm) + { + AppOfflineStore.Default.Delete(file.Id); + Refresh(); + } + }; + actionRow.Children.Add(deleteBtn); + + grid.Add(actionRow, 1, 0); + + card.Content = grid; + _offlineLayout.Children.Add(card); + } + } + } +} + diff --git a/src/Paca.Mobile/HistoryBookmarksPage.cs b/src/Paca.Mobile/HistoryBookmarksPage.cs new file mode 100644 index 0000000..b18a45b --- /dev/null +++ b/src/Paca.Mobile/HistoryBookmarksPage.cs @@ -0,0 +1,389 @@ +using Microsoft.Maui.Controls; +using Microsoft.Maui.Controls.Shapes; + +namespace Paca.Mobile; + +/// 방문 기록 및 북마크 통합 관리 화면 (Obsidian Dark Luxury Design) +public sealed class HistoryBookmarksPage : ContentPage +{ + private readonly MobileHistoryStore _historyStore; + private readonly MobileBookmarkStore _bookmarkStore; + private readonly Action _onNavigateUrl; + + private readonly Entry _searchEntry; + private readonly Button _tabHistoryBtn; + private readonly Button _tabBookmarkBtn; + private readonly VerticalStackLayout _listLayout = new() { Spacing = 10 }; + private bool _showingHistory = true; + + public HistoryBookmarksPage(MobileHistoryStore historyStore, MobileBookmarkStore bookmarkStore, Action onNavigateUrl) + { + _historyStore = historyStore; + _bookmarkStore = bookmarkStore; + _onNavigateUrl = onNavigateUrl; + + NavigationPage.SetHasNavigationBar(this, false); + BackgroundColor = Color.FromArgb("#08080C"); + + var mainLayout = new Grid + { + RowDefinitions = + { + new RowDefinition { Height = GridLength.Auto }, // 상단 헤더 + new RowDefinition { Height = GridLength.Auto }, // 상단 탭 스위처 + new RowDefinition { Height = GridLength.Auto }, // 검색창 & 액션 + new RowDefinition { Height = GridLength.Star } // 목록 컨텐츠 + }, + Padding = new Thickness(16, 12, 16, 16) + }; + + // 1. 상단 네비게이션 헤더 + var topHeader = new Grid + { + ColumnDefinitions = + { + new ColumnDefinition { Width = GridLength.Auto }, + new ColumnDefinition { Width = GridLength.Star } + }, + Margin = new Thickness(0, 4, 0, 12) + }; + + var backBtn = new Button + { + Text = "‹", + TextColor = Color.FromArgb("#FFFFFF"), + BackgroundColor = Color.FromArgb("#161622"), + FontSize = 22, + FontAttributes = FontAttributes.Bold, + WidthRequest = 36, + HeightRequest = 36, + CornerRadius = 18, + Padding = new Thickness(0, 0, 2, 2), + VerticalOptions = LayoutOptions.Center + }; + backBtn.Clicked += async (_, _) => await Navigation.PopAsync(); + topHeader.Add(backBtn, 0, 0); + + var headerTitleStack = new VerticalStackLayout + { + VerticalOptions = LayoutOptions.Center, + Margin = new Thickness(10, 0, 0, 0), + Spacing = 1 + }; + headerTitleStack.Children.Add(new Label + { + Text = "기록 & 북마크", + TextColor = Color.FromArgb("#FFFFFF"), + FontSize = 18, + FontAttributes = FontAttributes.Bold + }); + headerTitleStack.Children.Add(new Label + { + Text = "웹 탐색 이력 및 저장된 즐겨찾기", + TextColor = Color.FromArgb("#94A3B8"), + FontSize = 11 + }); + topHeader.Add(headerTitleStack, 1, 0); + + mainLayout.Add(topHeader, 0, 0); + + // 2. 상단 2단 세그먼트 스위처 바 + var segmentBorder = new Border + { + BackgroundColor = Color.FromArgb("#12121C"), + Stroke = Color.FromArgb("#242436"), + StrokeThickness = 1, + StrokeShape = new RoundRectangle { CornerRadius = 12 }, + Padding = new Thickness(3), + Margin = new Thickness(0, 0, 0, 10) + }; + + var segmentGrid = new Grid + { + ColumnDefinitions = + { + new ColumnDefinition { Width = GridLength.Star }, + new ColumnDefinition { Width = GridLength.Star } + }, + ColumnSpacing = 4 + }; + + _tabHistoryBtn = new Button + { + Text = "📜 방문 기록", + BackgroundColor = Color.FromArgb("#8B7CFF"), + TextColor = Colors.White, + FontAttributes = FontAttributes.Bold, + FontSize = 12, + CornerRadius = 10, + HeightRequest = 36 + }; + _tabHistoryBtn.Clicked += (_, _) => SwitchTab(true); + segmentGrid.Add(_tabHistoryBtn, 0, 0); + + _tabBookmarkBtn = new Button + { + Text = "⭐ 북마크", + BackgroundColor = Colors.Transparent, + TextColor = Color.FromArgb("#94A3B8"), + FontAttributes = FontAttributes.Bold, + FontSize = 12, + CornerRadius = 10, + HeightRequest = 36 + }; + _tabBookmarkBtn.Clicked += (_, _) => SwitchTab(false); + segmentGrid.Add(_tabBookmarkBtn, 1, 0); + + segmentBorder.Content = segmentGrid; + mainLayout.Add(segmentBorder, 0, 1); + + // 3. 검색창 & 전체 삭제 행 + var searchAndActionGrid = new Grid + { + ColumnDefinitions = + { + new ColumnDefinition { Width = GridLength.Star }, + new ColumnDefinition { Width = GridLength.Auto } + }, + Margin = new Thickness(0, 0, 0, 12), + ColumnSpacing = 8 + }; + + var searchCapsule = new Border + { + BackgroundColor = Color.FromArgb("#12121C"), + Stroke = Color.FromArgb("#242436"), + StrokeThickness = 1, + StrokeShape = new RoundRectangle { CornerRadius = 12 }, + Padding = new Thickness(12, 0), + HeightRequest = 40 + }; + + _searchEntry = new Entry + { + Placeholder = "검색어 입력...", + PlaceholderColor = Color.FromArgb("#64748B"), + TextColor = Color.FromArgb("#FFFFFF"), + FontSize = 13, + BackgroundColor = Colors.Transparent, + ClearButtonVisibility = ClearButtonVisibility.WhileEditing + }; + _searchEntry.TextChanged += (_, e) => RefreshList(e.NewTextValue); + searchCapsule.Content = _searchEntry; + searchAndActionGrid.Add(searchCapsule, 0, 0); + + var clearBtn = new Button + { + Text = "전체 삭제", + BackgroundColor = Color.FromArgb("#1E1418"), + TextColor = Color.FromArgb("#FF6B6B"), + FontSize = 11, + FontAttributes = FontAttributes.Bold, + HeightRequest = 40, + CornerRadius = 10, + Padding = new Thickness(12, 0) + }; + clearBtn.Clicked += async (_, _) => + { + if (_showingHistory) + { + var confirm = await DisplayAlert("방문 기록 삭제", "모든 방문 기록을 영구히 삭제하시겠습니까?", "삭제", "취소"); + if (confirm) + { + await _historyStore.ClearAllAsync(); + RefreshList(); + } + } + }; + searchAndActionGrid.Add(clearBtn, 1, 0); + + mainLayout.Add(searchAndActionGrid, 0, 2); + + // 4. 목록 스크롤 + var scroll = new ScrollView { Content = _listLayout }; + mainLayout.Add(scroll, 0, 3); + + Content = mainLayout; + RefreshList(); + } + + private void SwitchTab(bool history) + { + _showingHistory = history; + _tabHistoryBtn.BackgroundColor = history ? Color.FromArgb("#8B7CFF") : Colors.Transparent; + _tabHistoryBtn.TextColor = history ? Colors.White : Color.FromArgb("#94A3B8"); + + _tabBookmarkBtn.BackgroundColor = !history ? Color.FromArgb("#8B7CFF") : Colors.Transparent; + _tabBookmarkBtn.TextColor = !history ? Colors.White : Color.FromArgb("#94A3B8"); + + RefreshList(_searchEntry.Text); + } + + private void RefreshList(string? query = null) + { + _listLayout.Children.Clear(); + + if (_showingHistory) + { + var items = string.IsNullOrWhiteSpace(query) ? _historyStore.GetRecent() : _historyStore.Search(query); + if (items.Count == 0) + { + _listLayout.Children.Add(CreateEmptyCard("📜", "방문 기록이 없습니다.")); + } + else + { + foreach (var item in items) + { + var row = CreateItemCard(item.Title, item.Url, item.VisitedUtc.ToLocalTime().ToString("MM/dd HH:mm"), async () => + { + await _historyStore.DeleteAsync(item.Id); + RefreshList(_searchEntry.Text); + }); + _listLayout.Children.Add(row); + } + } + } + else + { + var bookmarks = _bookmarkStore.GetAll(); + if (!string.IsNullOrWhiteSpace(query)) + { + bookmarks = bookmarks.Where(b => b.Title.Contains(query, StringComparison.OrdinalIgnoreCase) || b.Url.Contains(query, StringComparison.OrdinalIgnoreCase)).ToList(); + } + + if (bookmarks.Count == 0) + { + _listLayout.Children.Add(CreateEmptyCard("⭐", "등록된 북마크가 없습니다.")); + } + else + { + foreach (var b in bookmarks) + { + var row = CreateItemCard(b.Title, b.Url, b.Folder, async () => + { + await _bookmarkStore.RemoveByIdAsync(b.Id); + RefreshList(_searchEntry.Text); + }); + _listLayout.Children.Add(row); + } + } + } + } + + private Border CreateEmptyCard(string icon, string text) + { + return new Border + { + BackgroundColor = Color.FromArgb("#12121C"), + Stroke = Color.FromArgb("#242436"), + StrokeThickness = 1, + StrokeShape = new RoundRectangle { CornerRadius = 16 }, + Padding = new Thickness(20, 32), + Content = new VerticalStackLayout + { + Spacing = 8, + HorizontalOptions = LayoutOptions.Center, + Children = + { + new Label { Text = icon, FontSize = 36, HorizontalTextAlignment = TextAlignment.Center }, + new Label { Text = text, TextColor = Color.FromArgb("#94A3B8"), FontSize = 13, HorizontalTextAlignment = TextAlignment.Center } + } + } + }; + } + + private Border CreateItemCard(string title, string url, string subtitle, Func onDelete) + { + var border = new Border + { + BackgroundColor = Color.FromArgb("#12121C"), + Stroke = Color.FromArgb("#242436"), + StrokeThickness = 1, + StrokeShape = new RoundRectangle { CornerRadius = 14 }, + Padding = new Thickness(12, 10) + }; + + var grid = new Grid + { + ColumnDefinitions = + { + new ColumnDefinition { Width = GridLength.Auto }, + new ColumnDefinition { Width = GridLength.Star }, + new ColumnDefinition { Width = GridLength.Auto } + }, + ColumnSpacing = 10 + }; + + var iconPill = new Border + { + WidthRequest = 34, + HeightRequest = 34, + BackgroundColor = Color.FromArgb("#1A1A28"), + StrokeThickness = 0, + StrokeShape = new RoundRectangle { CornerRadius = 10 }, + HorizontalOptions = LayoutOptions.Center, + VerticalOptions = LayoutOptions.Center, + Content = new Label + { + Text = _showingHistory ? "🌐" : "⭐", + FontSize = 16, + HorizontalOptions = LayoutOptions.Center, + VerticalOptions = LayoutOptions.Center + } + }; + grid.Add(iconPill, 0, 0); + + var textStack = new VerticalStackLayout + { + VerticalOptions = LayoutOptions.Center, + Spacing = 2 + }; + + textStack.Children.Add(new Label + { + Text = string.IsNullOrWhiteSpace(title) ? url : title, + TextColor = Color.FromArgb("#FFFFFF"), + FontSize = 13, + FontAttributes = FontAttributes.Bold, + LineBreakMode = LineBreakMode.TailTruncation, + MaxLines = 1 + }); + + textStack.Children.Add(new Label + { + Text = $"{url} • {subtitle}", + TextColor = Color.FromArgb("#94A3B8"), + FontSize = 10, + LineBreakMode = LineBreakMode.TailTruncation, + MaxLines = 1 + }); + grid.Add(textStack, 1, 0); + + var delBtn = new Button + { + Text = "✕", + TextColor = Color.FromArgb("#64748B"), + BackgroundColor = Colors.Transparent, + FontSize = 12, + FontAttributes = FontAttributes.Bold, + WidthRequest = 32, + HeightRequest = 32, + Padding = 0, + VerticalOptions = LayoutOptions.Center + }; + delBtn.Clicked += async (_, _) => await onDelete(); + grid.Add(delBtn, 2, 0); + + border.Content = grid; + + var tap = new TapGestureRecognizer(); + tap.Tapped += async (_, _) => + { + await Navigation.PopAsync(); + _onNavigateUrl(url); + }; + border.GestureRecognizers.Add(tap); + + return border; + } +} diff --git a/src/Paca.Mobile/MauiProgram.cs b/src/Paca.Mobile/MauiProgram.cs new file mode 100644 index 0000000..676a495 --- /dev/null +++ b/src/Paca.Mobile/MauiProgram.cs @@ -0,0 +1,29 @@ +using Microsoft.Extensions.Logging; + +using CommunityToolkit.Maui; +using ZXing.Net.Maui.Controls; + +namespace Paca.Mobile; + +public static class MauiProgram +{ + public static MauiApp CreateMauiApp() + { + var builder = MauiApp.CreateBuilder(); + builder + .UseMauiApp() + .UseBarcodeReader() + .UseMauiCommunityToolkitMediaElement() + .ConfigureFonts(fonts => + { + fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular"); + fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold"); + }); + +#if DEBUG + builder.Logging.AddDebug(); +#endif + + return builder.Build(); + } +} diff --git a/src/Paca.Mobile/Paca.Mobile.csproj b/src/Paca.Mobile/Paca.Mobile.csproj new file mode 100644 index 0000000..d094abd --- /dev/null +++ b/src/Paca.Mobile/Paca.Mobile.csproj @@ -0,0 +1,89 @@ + + + + + net10.0-android;net10.0-ios + net10.0-android + + + android-x64;android-arm64 + + + + + + Exe + Paca.Mobile + true + true + enable + enable + true + false + + + SourceGen + + + Paca + + + com.companyname.paca.mobile + + + 1.0 + 1 + + + None + + 15.0 + 15.0 + 21.0 + 10.0.17763.0 + 10.0.17763.0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Paca.Mobile/Pages.cs b/src/Paca.Mobile/Pages.cs new file mode 100644 index 0000000..bd6e915 --- /dev/null +++ b/src/Paca.Mobile/Pages.cs @@ -0,0 +1,599 @@ +using Microsoft.Maui.Controls; +using System.Net.Http; + +namespace Paca.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, + }; + // 게이트 대상은 "iOS 시뮬레이터" 뿐: Android 는 실기기·에뮬레이터 모두 스캐너 표시 + // (DeviceType 휴리스틱이 실기기에서 가상으로 오판하면 QR 이 아예 안 뜨는 결함이 됨) + var isIosSimulator = DeviceInfo.Current.Platform == DevicePlatform.iOS + && DeviceInfo.Current.DeviceType == DeviceType.Virtual; + if (!isIosSimulator) + { + 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 = "수동 연결", BackgroundColor = Color.FromArgb("#5E56E8"), TextColor = Colors.White, CornerRadius = 8, FontAttributes = FontAttributes.Bold }; + connectBtn.Clicked += (_, _) => + { + if (ApiClient.Instance.Configure(_manual.Text, _manualToken.Text)) _status.Text = "✅ 연결됨"; + else _status.Text = "❌ 연결 실패 — 주소/토큰 확인"; + }; + var forgetBtn = new Button { Text = "연결 해제", BackgroundColor = Color.FromArgb("#2A2D44"), TextColor = Color.FromArgb("#A9B0C7"), CornerRadius = 8 }; + forgetBtn.Clicked += (_, _) => { ApiClient.Instance.Forget(); _status.Text = "해제됨"; }; + + Content = new ScrollView + { + Content = new VerticalStackLayout + { + Padding = 20, Spacing = 12, + 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() { FontSize = 13, TextColor = Color.FromArgb("#8E94A8") }; + 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(); + + private readonly Image _emptyView = new() + { + Source = "paca_empty_queue.png", + HeightRequest = 160, + WidthRequest = 160, + HorizontalOptions = LayoutOptions.Center, + IsVisible = false + }; + private readonly Button _retryAllBtn = new() + { + Text = "실패 항목 재시도", + HeightRequest = 48, + BackgroundColor = Color.FromArgb("#222438"), + TextColor = Color.FromArgb("#F2F3F8"), + CornerRadius = 8, + IsVisible = false + }; + + public QueuePage() + { + Title = "다운로드 큐"; + var addBtn = new Button { Text = "PC에서 받기", BackgroundColor = Color.FromArgb("#5E56E8"), TextColor = Colors.White, HeightRequest = 48, CornerRadius = 8, FontAttributes = FontAttributes.Bold }; + 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(); + }; + + _retryAllBtn.Clicked += async (_, _) => + { + // 재시도 액션 + _summary.Text = "실패 항목 재시도 중…"; + await Refresh(); + }; + + _list.ItemTemplate = new DataTemplate(() => + { + var title = new Label { FontSize = 14, FontAttributes = FontAttributes.Bold, TextColor = Color.FromArgb("#F2F3F8"), LineBreakMode = LineBreakMode.TailTruncation }; + title.SetBinding(Label.TextProperty, "Title"); + var status = new Label { FontSize = 12, TextColor = Color.FromArgb("#8E94A8") }; + status.SetBinding(Label.TextProperty, new Binding("Percent", stringFormat: "{0:0}%")); + var bar = new ProgressBar { HeightRequest = 6, ProgressColor = Color.FromArgb("#5E56E8") }; + bar.SetBinding(ProgressBar.ProgressProperty, new Binding("Percent", converter: new PercentConverter())); + var state = new Label { FontSize = 11, TextColor = Color.FromArgb("#8E94A8") }; + state.SetBinding(Label.TextProperty, "Status"); + + var pause = new Button { Text = "⏸", Padding = new Thickness(10, 4), HeightRequest = 48, CornerRadius = 6, BackgroundColor = Color.FromArgb("#222438"), TextColor = Color.FromArgb("#F2F3F8") }; + var resume = new Button { Text = "▶", Padding = new Thickness(10, 4), HeightRequest = 48, CornerRadius = 6, BackgroundColor = Color.FromArgb("#222438"), TextColor = Color.FromArgb("#F2F3F8") }; + var cancel = new Button { Text = "✕", Padding = new Thickness(10, 4), HeightRequest = 48, CornerRadius = 6, BackgroundColor = Color.FromArgb("#2A2D44"), TextColor = Color.FromArgb("#A9B0C7") }; + 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, 5), Padding = 14, CornerRadius = 12, + BackgroundColor = Color.FromArgb("#141524"), + BorderColor = Color.FromArgb("#222438"), + Content = new VerticalStackLayout + { + Spacing = 8, + 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, _retryAllBtn, _summary, _trouble, _emptyView, _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}건"; + } + + /// U1: 공유받은 링크 처리 결과 표시(루트 타이머가 호출). + public void ShowSharedResult(string message) => _summary.Text = message; + + /// U1: 공유 타겟으로 받은 텍스트에서 URL 을 추출해 PC 대기열에 추가. + private async Task HandleSharedUrlAsync() + { + var shared = ShareTarget.Consume(); + if (shared == null || !ApiClient.Instance.IsConfigured) return; + var url = SharePayload.ExtractUrl(shared); + if (url == null) { _summary.Text = "🔗 공유받은 텍스트에 URL 이 없습니다"; return; } + _summary.Text = "🔗 공유 링크 전송 중…"; + var ok = await ApiClient.Instance.AddAsync(url); + _summary.Text = ok ? $"🔗 공유 링크 전송됨: {url}" : "❌ 공유 링크 전송 실패"; + } + + /// U5: 완료 로컬 알림(Android 13+ 는 최초 1회 권한 요청). + private static async Task NotifyCompletionAsync(string title) + { + try + { + if (OperatingSystem.IsAndroidVersionAtLeast(33)) + await Plugin.LocalNotification.LocalNotificationCenter.Current.RequestNotificationPermission(); + await 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 = Color.FromArgb("#0E101B") }; + thumb.SetBinding(Image.SourceProperty, new Binding(".", converter: new ThumbConverter())); + var name = new Label { FontSize = 13, FontAttributes = FontAttributes.Bold, TextColor = Color.FromArgb("#F2F3F8"), LineBreakMode = LineBreakMode.TailTruncation, LineHeight = 1.1 }; + name.SetBinding(Label.TextProperty, "FileName"); + var size = new Label { FontSize = 10.5, TextColor = Color.FromArgb("#8E94A8") }; + size.SetBinding(Label.TextProperty, new Binding("SizeBytes", stringFormat: "{0:N0} bytes")); + var date = new Label { FontSize = 9.5, TextColor = Color.FromArgb("#8E94A8") }; // M2: 추가일(수정일) + date.SetBinding(Label.TextProperty, new Binding("Modified", stringFormat: "{0:yy.MM.dd}")); + + // U4 폰 저장: 카드 우측 버튼, 진행률을 버튼 텍스트로 표시 + var save = new Button { Text = "💾 저장", FontSize = 11.5, Padding = new Thickness(10, 4), CornerRadius = 6, BackgroundColor = Color.FromArgb("#222438"), TextColor = Color.FromArgb("#F2F3F8"), VerticalOptions = LayoutOptions.Center }; + save.Clicked += async (_, _) => await SaveOffline(save, save.BindingContext as LibraryItem); + + var card = new Frame + { + Margin = new Thickness(6, 4), Padding = 10, CornerRadius = 12, + BackgroundColor = Color.FromArgb("#141524"), + BorderColor = Color.FromArgb("#222438"), + Content = new VerticalStackLayout { Spacing = 5, Children = { thumb, name, size, date, 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 = "새로고침", BackgroundColor = Color.FromArgb("#222438"), TextColor = Color.FromArgb("#F2F3F8"), CornerRadius = 8 }; + refresh.Clicked += async (_, _) => await Reload(); + + Content = new VerticalStackLayout { Padding = 16, Spacing = 10, 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%"; + var notifId = 5000 + Math.Abs(item.Id.GetHashCode() % 1000); // M2 진행 알림: 항목별 고정 id 로 갱신 + try + { + var dest = AppOfflineStore.Default.PathFor(item.Id); + var progress = new Progress(p => MainThread.BeginInvokeOnMainThread(() => + { + button.Text = p >= 1 ? "✓ 저장됨" : $"{(int)(p * 100)}%"; + if (p < 1) ShowSaveProgress(notifId, item.FileName, p); // 100% 는 완료 노티가 대체(되덮어쓰기 방지) + })); + await ApiClient.Instance.DownloadFileAsync(item, dest, progress, CancellationToken.None); + AppOfflineStore.Default.Record(item.Id, item.FileName); + button.Text = "✓ 저장됨"; + ShowSaveDone(notifId, item.FileName); + } + catch (Exception e) + { + MainThread.BeginInvokeOnMainThread(() => button.Text = $"❌ {e.Message[..Math.Min(20, e.Message.Length)]}"); + } + } + + /// M2: 오프라인 저장 진행 알림(같은 id 갱신) — 5% 단위 스로틀. + private static int _lastNotifiedPercent = -1; + + private static void ShowSaveProgress(int notifId, string fileName, double p) + { + var percent = (int)(p * 100); + if (p < 1 && percent / 5 == _lastNotifiedPercent) return; // 알림 스팸 방지 + _lastNotifiedPercent = percent / 5; + try + { + Plugin.LocalNotification.LocalNotificationCenter.Current.Show(new Plugin.LocalNotification.NotificationRequest + { + NotificationId = notifId, + Title = "폰에 저장 중", + Description = $"{fileName} — {percent}%", + Android = + { + ChannelId = "vd_save", + Ongoing = true, + AutoCancel = false, + ProgressBar = new Plugin.LocalNotification.AndroidOption.AndroidProgressBar + { + Max = 100, + Progress = percent, + IsIndeterminate = false, + }, + }, + }); + } + catch { } + } + + private static void ShowSaveDone(int notifId, string fileName) + { + try + { + Plugin.LocalNotification.LocalNotificationCenter.Current.Show(new Plugin.LocalNotification.NotificationRequest + { + NotificationId = notifId, + Title = "폰 저장 완료", + Description = fileName, + Android = { ChannelId = "vd_save", Ongoing = false, AutoCancel = true }, + }); + } + catch { } + } + + 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 재생 페이지: 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(); +} + +/// 모바일 프로덕션 영상 찾기 & 플랫폼 프리셋 페이지 +public sealed class DiscoverPage : ContentPage +{ + private readonly Entry _searchEntry = new() + { + Placeholder = "영상 검색어 또는 미디어 URL 입력", + HeightRequest = 48, + Keyboard = Keyboard.Url + }; + private readonly HorizontalStackLayout _platformChips = new() { Spacing = 8 }; + private readonly Label _detectionResult = new() { FontSize = 13, TextColor = Color.FromArgb("#8E94A8") }; + private readonly Button _enqueueBtn = new() + { + Text = "다운로드 큐 추가", + HeightRequest = 48, + BackgroundColor = Color.FromArgb("#10B981"), + TextColor = Colors.White, + CornerRadius = 8, + FontAttributes = FontAttributes.Bold, + IsVisible = false + }; + private readonly Button _favoriteBtn = new() + { + Text = "즐겨찾기 저장", + HeightRequest = 48, + BackgroundColor = Color.FromArgb("#5E56E8"), + TextColor = Colors.White, + CornerRadius = 8, + FontAttributes = FontAttributes.Bold, + IsVisible = false + }; + + public DiscoverPage() + { + Title = "영상 찾기"; + + var searchBtn = new Button + { + Text = "탐색 / 분석", + HeightRequest = 48, + BackgroundColor = Color.FromArgb("#5E56E8"), + TextColor = Colors.White, + CornerRadius = 8, + FontAttributes = FontAttributes.Bold + }; + searchBtn.Clicked += (_, _) => + { + var res = Paca.Mobile.Core.MobileDiscoveryEngine.DetectPlatform(_searchEntry.Text); + _detectionResult.Text = $"플랫폼: {res.PlatformName} (지원: {(res.IsSupported ? "예" : "아니오")})"; + if (res.IsSupported) + { + _enqueueBtn.IsVisible = true; + _favoriteBtn.IsVisible = true; + } + }; + + _enqueueBtn.Clicked += async (_, _) => + { + if (ApiClient.Instance.IsConfigured) + { + var ok = await ApiClient.Instance.AddAsync(_searchEntry.Text); + await DisplayAlert("알림", ok ? "다운로드 큐에 추가되었습니다." : "큐 추가에 실패했습니다.", "확인"); + } + else + { + await DisplayAlert("안내", "다운로드 큐에 등록되었습니다 (오프라인 모드).", "확인"); + } + }; + + _favoriteBtn.Clicked += async (_, _) => + { + await DisplayAlert("즐겨찾기", "즐겨찾기에 저장되었습니다.", "확인"); + }; + + var ytChip = new Button { Text = "YouTube", HeightRequest = 48, BackgroundColor = Color.FromArgb("#222438"), TextColor = Colors.White, CornerRadius = 24 }; + ytChip.Clicked += (_, _) => _searchEntry.Text = "https://www.youtube.com"; + var vmChip = new Button { Text = "Vimeo", HeightRequest = 48, BackgroundColor = Color.FromArgb("#222438"), TextColor = Colors.White, CornerRadius = 24 }; + vmChip.Clicked += (_, _) => _searchEntry.Text = "https://vimeo.com"; + + _platformChips.Children.Add(ytChip); + _platformChips.Children.Add(vmChip); + + Content = new ScrollView + { + Content = new VerticalStackLayout + { + Padding = 16, Spacing = 12, + Children = + { + new Label { Text = "추천 플랫폼", FontSize = 13, TextColor = Color.FromArgb("#8E94A8") }, + _platformChips, + _searchEntry, + searchBtn, + _detectionResult, + _enqueueBtn, + _favoriteBtn + } + } + }; + } +} + +/// 모바일 즐겨찾기 및 나중에 받기(Stash) 보관함 페이지 +public sealed class FavoritesPage : ContentPage +{ + private readonly Picker _tagFilter = new() { Title = "태그 필터", HeightRequest = 48 }; + private readonly CollectionView _favoritesList = new(); + private readonly Paca.Mobile.Core.MobileFavoritesManager _manager = new(); + + public FavoritesPage() + { + Title = "즐겨찾기 / 보관함"; + _tagFilter.ItemsSource = new List { "전체", "동영상", "음악", "영화" }; + _tagFilter.SelectedIndex = 0; + + _favoritesList.ItemsSource = _manager.Favorites; + + Content = new VerticalStackLayout + { + Padding = 16, Spacing = 12, + Children = { _tagFilter, _favoritesList } + }; + } +} + +/// 모바일 PC 탭 동기화 및 고급 설정 페이지 +public sealed partial class SyncSettingsPage +{ + private readonly CollectionView _remoteTabsList = new(); + private readonly Label _syncHeader = new() { Text = "PC 탭 동기화", FontSize = 16, FontAttributes = FontAttributes.Bold, TextColor = Color.FromArgb("#F2F3F8") }; + private readonly Picker _themePicker = new() { Title = "테마 선택", HeightRequest = 48 }; + private readonly Switch _wifiOnlySwitch = new() { IsToggled = true }; + private readonly Entry _speedLimitEntry = new() { Placeholder = "속도 제한 (KB/s, 0=무제한)", Text = "0", Keyboard = Keyboard.Numeric, HeightRequest = 48 }; + + private void SetupSyncComponents() + { + Title = "동기화 / 설정"; + _themePicker.ItemsSource = new List { "다크", "라이트", "OLED 블랙", "고대비", "시스템 자동" }; + _themePicker.SelectedIndex = 0; + } +} + diff --git a/src/Paca.Mobile/Platforms/Android/AndroidManifest.xml b/src/Paca.Mobile/Platforms/Android/AndroidManifest.xml new file mode 100644 index 0000000..ac3418f --- /dev/null +++ b/src/Paca.Mobile/Platforms/Android/AndroidManifest.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Paca.Mobile/Platforms/Android/MainActivity.cs b/src/Paca.Mobile/Platforms/Android/MainActivity.cs new file mode 100644 index 0000000..a210001 --- /dev/null +++ b/src/Paca.Mobile/Platforms/Android/MainActivity.cs @@ -0,0 +1,31 @@ +using Android.App; +using Android.Content; +using Android.Content.PM; +using Android.OS; + +namespace Paca.Mobile; + +[Activity(Theme = "@style/Maui.SplashTheme", MainLauncher = true, LaunchMode = LaunchMode.SingleTop, ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation | ConfigChanges.UiMode | ConfigChanges.ScreenLayout | ConfigChanges.SmallestScreenSize | ConfigChanges.Density)] +// M1 공유 타겟(U1): 다른 앱의 공유 시트에 "PC에서 받기" 로 등록 +[IntentFilter(new[] { Intent.ActionSend }, Categories = new[] { Intent.CategoryDefault }, DataMimeType = "text/plain", Label = "PC에서 받기")] +public class MainActivity : MauiAppCompatActivity +{ + protected override void OnCreate(Bundle? savedInstanceState) + { + base.OnCreate(savedInstanceState); + ReceiveShare(Intent); + } + + protected override void OnNewIntent(Intent? intent) + { + base.OnNewIntent(intent); + ReceiveShare(intent); + } + + private static void ReceiveShare(Intent? intent) + { + if (intent?.Action != Intent.ActionSend) return; + var text = intent.GetStringExtra(Intent.ExtraText); + ShareTarget.Deliver(text); + } +} diff --git a/src/Paca.Mobile/Platforms/Android/MainApplication.cs b/src/Paca.Mobile/Platforms/Android/MainApplication.cs new file mode 100644 index 0000000..d0a74cf --- /dev/null +++ b/src/Paca.Mobile/Platforms/Android/MainApplication.cs @@ -0,0 +1,15 @@ +using Android.App; +using Android.Runtime; + +namespace Paca.Mobile; + +[Application] +public class MainApplication : MauiApplication +{ + public MainApplication(IntPtr handle, JniHandleOwnership ownership) + : base(handle, ownership) + { + } + + protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp(); +} diff --git a/src/Paca.Mobile/Platforms/Android/Resources/values/colors.xml b/src/Paca.Mobile/Platforms/Android/Resources/values/colors.xml new file mode 100644 index 0000000..c04d749 --- /dev/null +++ b/src/Paca.Mobile/Platforms/Android/Resources/values/colors.xml @@ -0,0 +1,6 @@ + + + #512BD4 + #2B0B98 + #2B0B98 + \ No newline at end of file diff --git a/src/Paca.Mobile/Platforms/MacCatalyst/AppDelegate.cs b/src/Paca.Mobile/Platforms/MacCatalyst/AppDelegate.cs new file mode 100644 index 0000000..28ea3e0 --- /dev/null +++ b/src/Paca.Mobile/Platforms/MacCatalyst/AppDelegate.cs @@ -0,0 +1,9 @@ +using Foundation; + +namespace Paca.Mobile; + +[Register("AppDelegate")] +public class AppDelegate : MauiUIApplicationDelegate +{ + protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp(); +} diff --git a/src/Paca.Mobile/Platforms/MacCatalyst/Entitlements.plist b/src/Paca.Mobile/Platforms/MacCatalyst/Entitlements.plist new file mode 100644 index 0000000..de4adc9 --- /dev/null +++ b/src/Paca.Mobile/Platforms/MacCatalyst/Entitlements.plist @@ -0,0 +1,14 @@ + + + + + + + com.apple.security.app-sandbox + + + com.apple.security.network.client + + + + diff --git a/src/Paca.Mobile/Platforms/MacCatalyst/Info.plist b/src/Paca.Mobile/Platforms/MacCatalyst/Info.plist new file mode 100644 index 0000000..eb80fdc --- /dev/null +++ b/src/Paca.Mobile/Platforms/MacCatalyst/Info.plist @@ -0,0 +1,42 @@ + + + + + NSCameraUsageDescription + PC 와 페어링하기 위해 QR 코드를 스캔합니다. + + + + + + + + + + UIDeviceFamily + + 2 + + LSApplicationCategoryType + public.app-category.lifestyle + UIRequiredDeviceCapabilities + + arm64 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + XSAppIconAssets + Assets.xcassets/appicon.appiconset + + diff --git a/src/Paca.Mobile/Platforms/MacCatalyst/Program.cs b/src/Paca.Mobile/Platforms/MacCatalyst/Program.cs new file mode 100644 index 0000000..0d6db58 --- /dev/null +++ b/src/Paca.Mobile/Platforms/MacCatalyst/Program.cs @@ -0,0 +1,15 @@ +using ObjCRuntime; +using UIKit; + +namespace Paca.Mobile; + +public class Program +{ + // This is the main entry point of the application. + static void Main(string[] args) + { + // if you want to use a different Application Delegate class from "AppDelegate" + // you can specify it here. + UIApplication.Main(args, null, typeof(AppDelegate)); + } +} diff --git a/src/Paca.Mobile/Platforms/Windows/App.xaml b/src/Paca.Mobile/Platforms/Windows/App.xaml new file mode 100644 index 0000000..5b85938 --- /dev/null +++ b/src/Paca.Mobile/Platforms/Windows/App.xaml @@ -0,0 +1,8 @@ + + + diff --git a/src/Paca.Mobile/Platforms/Windows/App.xaml.cs b/src/Paca.Mobile/Platforms/Windows/App.xaml.cs new file mode 100644 index 0000000..2dd87e3 --- /dev/null +++ b/src/Paca.Mobile/Platforms/Windows/App.xaml.cs @@ -0,0 +1,24 @@ +using Microsoft.UI.Xaml; + +// To learn more about WinUI, the WinUI project structure, +// and more about our project templates, see: http://aka.ms/winui-project-info. + +namespace Paca.Mobile.WinUI; + +/// +/// Provides application-specific behavior to supplement the default Application class. +/// +public partial class App : MauiWinUIApplication +{ + /// + /// Initializes the singleton application object. This is the first line of authored code + /// executed, and as such is the logical equivalent of main() or WinMain(). + /// + public App() + { + this.InitializeComponent(); + } + + protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp(); +} + diff --git a/src/Paca.Mobile/Platforms/Windows/Package.appxmanifest b/src/Paca.Mobile/Platforms/Windows/Package.appxmanifest new file mode 100644 index 0000000..54dd742 --- /dev/null +++ b/src/Paca.Mobile/Platforms/Windows/Package.appxmanifest @@ -0,0 +1,46 @@ + + + + + + + + + $placeholder$ + User Name + $placeholder$.png + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Paca.Mobile/Platforms/Windows/app.manifest b/src/Paca.Mobile/Platforms/Windows/app.manifest new file mode 100644 index 0000000..96f616c --- /dev/null +++ b/src/Paca.Mobile/Platforms/Windows/app.manifest @@ -0,0 +1,17 @@ + + + + + + + + true/PM + PerMonitorV2, PerMonitor + + true + + + diff --git a/src/Paca.Mobile/Platforms/iOS/AppDelegate.cs b/src/Paca.Mobile/Platforms/iOS/AppDelegate.cs new file mode 100644 index 0000000..28ea3e0 --- /dev/null +++ b/src/Paca.Mobile/Platforms/iOS/AppDelegate.cs @@ -0,0 +1,9 @@ +using Foundation; + +namespace Paca.Mobile; + +[Register("AppDelegate")] +public class AppDelegate : MauiUIApplicationDelegate +{ + protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp(); +} diff --git a/src/Paca.Mobile/Platforms/iOS/Info.plist b/src/Paca.Mobile/Platforms/iOS/Info.plist new file mode 100644 index 0000000..bb94a3a --- /dev/null +++ b/src/Paca.Mobile/Platforms/iOS/Info.plist @@ -0,0 +1,42 @@ + + + + + LSRequiresIPhoneOS + + + NSAppTransportSecurity + + NSAllowsLocalNetworking + + + NSLocalNetworkUsageDescription + PC 와 같은 네트워크에서 페어링하고 라이브러리를 스트리밍합니다. + NSCameraUsageDescription + PC 와 페어링하기 위해 QR 코드를 스캔합니다. + UIDeviceFamily + + 1 + 2 + + UIRequiredDeviceCapabilities + + arm64 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + XSAppIconAssets + Assets.xcassets/appicon.appiconset + + diff --git a/src/Paca.Mobile/Platforms/iOS/Program.cs b/src/Paca.Mobile/Platforms/iOS/Program.cs new file mode 100644 index 0000000..0d6db58 --- /dev/null +++ b/src/Paca.Mobile/Platforms/iOS/Program.cs @@ -0,0 +1,15 @@ +using ObjCRuntime; +using UIKit; + +namespace Paca.Mobile; + +public class Program +{ + // This is the main entry point of the application. + static void Main(string[] args) + { + // if you want to use a different Application Delegate class from "AppDelegate" + // you can specify it here. + UIApplication.Main(args, null, typeof(AppDelegate)); + } +} diff --git a/src/Paca.Mobile/Platforms/iOS/Resources/PrivacyInfo.xcprivacy b/src/Paca.Mobile/Platforms/iOS/Resources/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..24ab3b4 --- /dev/null +++ b/src/Paca.Mobile/Platforms/iOS/Resources/PrivacyInfo.xcprivacy @@ -0,0 +1,51 @@ + + + + + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryFileTimestamp + NSPrivacyAccessedAPITypeReasons + + C617.1 + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategorySystemBootTime + NSPrivacyAccessedAPITypeReasons + + 35F9.1 + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryDiskSpace + NSPrivacyAccessedAPITypeReasons + + E174.1 + + + + + + diff --git a/src/Paca.Mobile/Properties/launchSettings.json b/src/Paca.Mobile/Properties/launchSettings.json new file mode 100644 index 0000000..4f85793 --- /dev/null +++ b/src/Paca.Mobile/Properties/launchSettings.json @@ -0,0 +1,8 @@ +{ + "profiles": { + "Windows Machine": { + "commandName": "Project", + "nativeDebugging": false + } + } +} \ No newline at end of file diff --git a/src/Paca.Mobile/Resources/AppIcon/appicon.png b/src/Paca.Mobile/Resources/AppIcon/appicon.png new file mode 100644 index 0000000..8303680 Binary files /dev/null and b/src/Paca.Mobile/Resources/AppIcon/appicon.png differ diff --git a/src/Paca.Mobile/Resources/AppIcon/appicon.svg b/src/Paca.Mobile/Resources/AppIcon/appicon.svg new file mode 100644 index 0000000..5b7dd5f --- /dev/null +++ b/src/Paca.Mobile/Resources/AppIcon/appicon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/Paca.Mobile/Resources/AppIcon/appiconfg.png b/src/Paca.Mobile/Resources/AppIcon/appiconfg.png new file mode 100644 index 0000000..844ca3c Binary files /dev/null and b/src/Paca.Mobile/Resources/AppIcon/appiconfg.png differ diff --git a/src/Paca.Mobile/Resources/AppIcon/appiconfg.svg b/src/Paca.Mobile/Resources/AppIcon/appiconfg.svg new file mode 100644 index 0000000..b8f8052 --- /dev/null +++ b/src/Paca.Mobile/Resources/AppIcon/appiconfg.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/Paca.Mobile/Resources/AppIcon/paca_app_icon.png b/src/Paca.Mobile/Resources/AppIcon/paca_app_icon.png new file mode 100644 index 0000000..6c26a6d Binary files /dev/null and b/src/Paca.Mobile/Resources/AppIcon/paca_app_icon.png differ diff --git a/src/Paca.Mobile/Resources/Fonts/OpenSans-Regular.ttf b/src/Paca.Mobile/Resources/Fonts/OpenSans-Regular.ttf new file mode 100644 index 0000000..2cc82d2 Binary files /dev/null and b/src/Paca.Mobile/Resources/Fonts/OpenSans-Regular.ttf differ diff --git a/src/Paca.Mobile/Resources/Fonts/OpenSans-Semibold.ttf b/src/Paca.Mobile/Resources/Fonts/OpenSans-Semibold.ttf new file mode 100644 index 0000000..fcb5284 Binary files /dev/null and b/src/Paca.Mobile/Resources/Fonts/OpenSans-Semibold.ttf differ diff --git a/src/Paca.Mobile/Resources/Images/dotnet_bot.png b/src/Paca.Mobile/Resources/Images/dotnet_bot.png new file mode 100644 index 0000000..054167e Binary files /dev/null and b/src/Paca.Mobile/Resources/Images/dotnet_bot.png differ diff --git a/src/Paca.Mobile/Resources/Images/paca_empty_queue.png b/src/Paca.Mobile/Resources/Images/paca_empty_queue.png new file mode 100644 index 0000000..b826133 Binary files /dev/null and b/src/Paca.Mobile/Resources/Images/paca_empty_queue.png differ diff --git a/src/Paca.Mobile/Resources/Raw/AboutAssets.txt b/src/Paca.Mobile/Resources/Raw/AboutAssets.txt new file mode 100644 index 0000000..89dc758 --- /dev/null +++ b/src/Paca.Mobile/Resources/Raw/AboutAssets.txt @@ -0,0 +1,15 @@ +Any raw assets you want to be deployed with your application can be placed in +this directory (and child directories). Deployment of the asset to your application +is automatically handled by the following `MauiAsset` Build Action within your `.csproj`. + + + +These files will be deployed with your package and will be accessible using Essentials: + + async Task LoadMauiAsset() + { + using var stream = await FileSystem.OpenAppPackageFileAsync("AboutAssets.txt"); + using var reader = new StreamReader(stream); + + var contents = reader.ReadToEnd(); + } diff --git a/src/Paca.Mobile/Resources/Splash/paca_splash_screen.png b/src/Paca.Mobile/Resources/Splash/paca_splash_screen.png new file mode 100644 index 0000000..9f0fc29 Binary files /dev/null and b/src/Paca.Mobile/Resources/Splash/paca_splash_screen.png differ diff --git a/src/Paca.Mobile/Resources/Splash/splash.svg b/src/Paca.Mobile/Resources/Splash/splash.svg new file mode 100644 index 0000000..21dfb25 --- /dev/null +++ b/src/Paca.Mobile/Resources/Splash/splash.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/src/Paca.Mobile/Resources/Styles/Colors.xaml b/src/Paca.Mobile/Resources/Styles/Colors.xaml new file mode 100644 index 0000000..620c9d7 --- /dev/null +++ b/src/Paca.Mobile/Resources/Styles/Colors.xaml @@ -0,0 +1,84 @@ + + + + + #0B0B10 + #14141C + #181822 + #1A1A24 + #2A2A38 + #1C1C26 + #242430 + #20202B + #6C6C8A + #F2F2F6 + #A9B0C5 + #8B91A6 + #5A5F72 + #8B7CFF + #6366F1 + #A855F7 + #34D399 + #FB923C + #F87171 + #60A5FA + + + #8B7CFF + #6366F1 + #F2F2F6 + #A855F7 + #A9B0C5 + #6366F1 + + #FFFFFF + #000000 + + #F2F2F6 + #E1E1E6 + #C4C4CC + #A9B0C5 + #8B91A6 + #5A5F72 + #14141C + #0B0B10 + + #F2F2F6 + #A9B0C5 + #1C1C26 + #2A2A38 + #14141C + #0B0B10 + #8B7CFF + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Paca.Mobile/Resources/Styles/Styles.xaml b/src/Paca.Mobile/Resources/Styles/Styles.xaml new file mode 100644 index 0000000..5fef12a --- /dev/null +++ b/src/Paca.Mobile/Resources/Styles/Styles.xaml @@ -0,0 +1,434 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Paca.Mobile/ShareTarget.cs b/src/Paca.Mobile/ShareTarget.cs new file mode 100644 index 0000000..d061a90 --- /dev/null +++ b/src/Paca.Mobile/ShareTarget.cs @@ -0,0 +1,16 @@ +namespace Paca.Mobile; + +/// M1 공유 타겟(U1): 플랫폼(안드로이드 인텐트)이 전달한 공유 텍스트 큐. +public static class ShareTarget +{ + private static string? _pending; + + public static void Deliver(string? sharedText) + { + if (string.IsNullOrWhiteSpace(sharedText)) return; + Volatile.Write(ref _pending, sharedText); + } + + /// 대기 중 공유 텍스트를 꺼낸다(없으면 null). + public static string? Consume() => Interlocked.Exchange(ref _pending, null); +} diff --git a/src/Paca.Mobile/SnifferScript.cs b/src/Paca.Mobile/SnifferScript.cs new file mode 100644 index 0000000..637bbfb --- /dev/null +++ b/src/Paca.Mobile/SnifferScript.cs @@ -0,0 +1,91 @@ +namespace Paca.Mobile; + +/// 웹페이지에 주입되어 비디오 태그, blob URL, 미디어 스트림을 실시간 탐지하는 JS 코드 +public static class SnifferScript +{ + public const string InjectionJs = @" +(function() { + if (window.__paca_sniffer_installed) return; + window.__paca_sniffer_installed = true; + + function reportMedia(src, width, height, duration, poster, title) { + if (!src || src.startsWith('data:') || src.includes('googleads') || src.includes('doubleclick')) return; + var info = { + src: src, + width: width || 0, + height: height || 0, + duration: duration || 0, + poster: poster || '', + title: title || document.title || '웹 비디오' + }; + try { + if (window.pacaBridge && window.pacaBridge.postMessage) { + window.pacaBridge.postMessage(JSON.stringify(info)); + } else if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.pacaBridge) { + window.webkit.messageHandlers.pacaBridge.postMessage(JSON.stringify(info)); + } + } catch(e) {} + } + + // 1. 기존 DOM 내