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 내