feat(mobile): advance Paca.Mobile to production excellence with rich design assets and core systems
- Generate high-resolution glassmorphism app icon and splash screen assets - Implement MobileIconSystem with semantic SF Symbols/Material glyph mappings - Add OLEDBlack theme and contrast tokens to MobileThemeSystem - Implement VideoSnifferParser.NegotiateBestQuality for multi-stream quality resolution - Enhance DiscoverPage, FavoritesPage, and SyncSettingsPage with production controls - Add Gate 103-114 test suites with 100% test pass rate
117
src/Paca.Mobile.Core/BrowserModels.cs
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace Paca.Mobile;
|
||||||
|
|
||||||
|
/// <summary>비디오 스트림 유형</summary>
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>다운로드 작업 상태</summary>
|
||||||
|
public enum MobileDownloadStatus
|
||||||
|
{
|
||||||
|
Queued = 0,
|
||||||
|
Downloading,
|
||||||
|
Merging,
|
||||||
|
Completed,
|
||||||
|
Failed,
|
||||||
|
Cancelled
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>모바일 브라우저 탭 모델</summary>
|
||||||
|
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<VideoSniffItem> DetectedVideos { get; set; } = new();
|
||||||
|
|
||||||
|
public bool IsNewTabPage => string.IsNullOrWhiteSpace(Url) || Url.Equals("about:blank", StringComparison.OrdinalIgnoreCase) || Url.Equals("paca://newtab", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>방문 기록 항목</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>북마크 항목</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>감지된 비디오 항목</summary>
|
||||||
|
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<string, string> Headers { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
public DateTime DetectedUtc { get; set; } = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>모바일 다운로드 작업</summary>
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>PC ↔ 모바일 탭 동기화 페이로드</summary>
|
||||||
|
public sealed class TabSyncPayload
|
||||||
|
{
|
||||||
|
public string DeviceId { get; set; } = "";
|
||||||
|
public string DeviceName { get; set; } = "";
|
||||||
|
public List<BrowserTab> Tabs { get; set; } = new();
|
||||||
|
public DateTime TimestampUtc { get; set; } = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>PC ↔ 모바일 방문 기록 동기화 페이로드</summary>
|
||||||
|
public sealed class HistorySyncPayload
|
||||||
|
{
|
||||||
|
public string DeviceId { get; set; } = "";
|
||||||
|
public List<HistoryItem> Items { get; set; } = new();
|
||||||
|
public DateTime TimestampUtc { get; set; } = DateTime.UtcNow;
|
||||||
|
}
|
||||||
21
src/Paca.Mobile.Core/ConnectionTroubleshoot.cs
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
namespace Paca.Mobile;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 외부망/네트워크 불통 안내(MOBILE_PLAN §7 성공 기준) —
|
||||||
|
/// "같은 네트워크 필요 / 외부망에서는 Tailscale 등 VPN 안내".
|
||||||
|
/// </summary>
|
||||||
|
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 "";
|
||||||
|
}
|
||||||
|
}
|
||||||
135
src/Paca.Mobile.Core/MobileBookmarkStore.cs
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace Paca.Mobile;
|
||||||
|
|
||||||
|
/// <summary>로컬 북마크 영속 저장소</summary>
|
||||||
|
public sealed class MobileBookmarkStore
|
||||||
|
{
|
||||||
|
private readonly string _storagePath;
|
||||||
|
private readonly List<BookmarkItem> _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<List<BookmarkItem>>(json);
|
||||||
|
if (list != null)
|
||||||
|
{
|
||||||
|
_items.Clear();
|
||||||
|
_items.AddRange(list);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveAsync()
|
||||||
|
{
|
||||||
|
List<BookmarkItem> 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>북마크 추가 또는 토글</summary>
|
||||||
|
public async Task<BookmarkItem> 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>북마크 여부 확인</summary>
|
||||||
|
public bool IsBookmarked(string url)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
return _items.Any(b => b.Url.Equals(url, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>URL로 북마크 삭제</summary>
|
||||||
|
public async Task<bool> 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>ID로 북마크 삭제</summary>
|
||||||
|
public async Task<bool> 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>전체 북마크 목록 반환</summary>
|
||||||
|
public IReadOnlyList<BookmarkItem> GetAll()
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
return _items.OrderByDescending(b => b.CreatedUtc).ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
92
src/Paca.Mobile.Core/MobileConnectivity.cs
Normal file
|
|
@ -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);
|
||||||
|
|
||||||
|
/// <summary>로컬 네트워크 Paca 호스트 비콘 탐색기</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>PC-모바일 간 클립보드 원격 동기화기</summary>
|
||||||
|
public class CrossDeviceClipboardSync
|
||||||
|
{
|
||||||
|
public event Action<string>? 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>배터리 상태 기반 다운로드 전력 최적화 정책</summary>
|
||||||
|
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; // 기본 권장값
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>셀룰러 데이터 절약 및 다운로드 보호 가드</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>모바일 썸네일 인메모리 캐시 관리자</summary>
|
||||||
|
public class MobileThumbnailCacheManager
|
||||||
|
{
|
||||||
|
private readonly ConcurrentDictionary<string, byte[]> _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();
|
||||||
|
}
|
||||||
|
}
|
||||||
44
src/Paca.Mobile.Core/MobileDiscoveryEngine.cs
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace Paca.Mobile.Core;
|
||||||
|
|
||||||
|
public record PlatformDetectionResult(
|
||||||
|
string PlatformName,
|
||||||
|
bool IsSupported,
|
||||||
|
IReadOnlyList<string>? RecommendedQualities = null
|
||||||
|
)
|
||||||
|
{
|
||||||
|
public IReadOnlyList<string> RecommendedQualities { get; init; } = RecommendedQualities ?? new[] { "1080p", "720p", "Audio Only" };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>모바일 미디어 검색 및 플랫폼 탐색 엔진</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
167
src/Paca.Mobile.Core/MobileDownloadEngine.cs
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
using System.Diagnostics;
|
||||||
|
|
||||||
|
namespace Paca.Mobile;
|
||||||
|
|
||||||
|
/// <summary>모바일 독립 인앱 다운로드 엔진 (HLS/MP4 병렬 스트림 다운로드)</summary>
|
||||||
|
public sealed class MobileDownloadEngine
|
||||||
|
{
|
||||||
|
private static readonly Lazy<MobileDownloadEngine> _instance = new(() => new MobileDownloadEngine());
|
||||||
|
public static MobileDownloadEngine Instance => _instance.Value;
|
||||||
|
|
||||||
|
private readonly List<MobileDownloadTask> _tasks = new();
|
||||||
|
private readonly HttpClient _http = new();
|
||||||
|
private readonly object _lock = new();
|
||||||
|
|
||||||
|
public event Action? TasksChanged;
|
||||||
|
public event Action<MobileDownloadTask>? TaskProgressUpdated;
|
||||||
|
|
||||||
|
public IReadOnlyList<MobileDownloadTask> 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");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>다운로드 큐에 추가 및 즉시 백그라운드 다운로드 시작</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>다운로드 취소</summary>
|
||||||
|
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 { }
|
||||||
|
}
|
||||||
|
}
|
||||||
60
src/Paca.Mobile.Core/MobileFavoritesManager.cs
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>모바일 즐겨찾기 및 보관함(Stash) 관리자</summary>
|
||||||
|
public class MobileFavoritesManager
|
||||||
|
{
|
||||||
|
private readonly List<MobileFavoriteItem> _favorites = new();
|
||||||
|
public IReadOnlyList<MobileFavoriteItem> 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<MobileFavoriteItem> GetByTag(string tag)
|
||||||
|
{
|
||||||
|
return _favorites.Where(x => string.Equals(x.Tag, tag, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<MobileFavoriteItem> GetStashedItems()
|
||||||
|
{
|
||||||
|
return _favorites.Where(x => x.IsStashedForDownload).ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
140
src/Paca.Mobile.Core/MobileHistoryStore.cs
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace Paca.Mobile;
|
||||||
|
|
||||||
|
/// <summary>로컬 방문 기록 영속 저장소</summary>
|
||||||
|
public sealed class MobileHistoryStore
|
||||||
|
{
|
||||||
|
private readonly string _storagePath;
|
||||||
|
private readonly List<HistoryItem> _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<List<HistoryItem>>(json);
|
||||||
|
if (list != null)
|
||||||
|
{
|
||||||
|
_items.Clear();
|
||||||
|
_items.AddRange(list);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveAsync()
|
||||||
|
{
|
||||||
|
List<HistoryItem> 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>방문 기록 추가 또는 갱신</summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>최근 방문 기록 목록 반환</summary>
|
||||||
|
public IReadOnlyList<HistoryItem> GetRecent(int limit = 100)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
return _items.OrderByDescending(i => i.VisitedUtc).Take(limit).ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>방문 기록 검색</summary>
|
||||||
|
public IReadOnlyList<HistoryItem> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>특정 항목 삭제</summary>
|
||||||
|
public async Task<bool> 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>전체 기록 삭제</summary>
|
||||||
|
public async Task ClearAllAsync()
|
||||||
|
{
|
||||||
|
lock (_lock) _items.Clear();
|
||||||
|
await SaveAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
63
src/Paca.Mobile.Core/MobileIconSystem.cs
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
namespace Paca.Mobile.Core;
|
||||||
|
|
||||||
|
public enum MobileIconKind
|
||||||
|
{
|
||||||
|
Search,
|
||||||
|
Queue,
|
||||||
|
Download,
|
||||||
|
Favorites,
|
||||||
|
Settings,
|
||||||
|
Retry,
|
||||||
|
Play,
|
||||||
|
Pause,
|
||||||
|
Check,
|
||||||
|
Folder,
|
||||||
|
Share,
|
||||||
|
Speed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 모바일 프로덕션 레벨 일관된 유니코드 / 글리프 / SF Symbols 아이콘 시스템
|
||||||
|
/// </summary>
|
||||||
|
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
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 시맨틱 아이콘 종류에 해당하는 플랫폼 표준 글리프 이름을 반환한다 (SF Symbols / Material).
|
||||||
|
/// </summary>
|
||||||
|
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"
|
||||||
|
};
|
||||||
|
}
|
||||||
135
src/Paca.Mobile.Core/MobileMediaPlayback.cs
Normal file
|
|
@ -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);
|
||||||
|
|
||||||
|
/// <summary>모바일 미디어 플레이어 터치 및 제스처 엔진</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>재생 속도 및 오디오 피치 제어기</summary>
|
||||||
|
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);
|
||||||
|
|
||||||
|
/// <summary>모바일 VTT/SRT 자막 파서 및 렌더러</summary>
|
||||||
|
public class MobileSubtitleRenderer
|
||||||
|
{
|
||||||
|
public List<SubtitleCue> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>PiP(화면 속 화면) 및 백그라운드 재생 제어기</summary>
|
||||||
|
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);
|
||||||
|
|
||||||
|
/// <summary>모바일 저장 공간 감시 및 자동 정리 정책</summary>
|
||||||
|
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);
|
||||||
|
|
||||||
|
/// <summary>시스템 갤러리 및 외부 앱 미디어 내보내기</summary>
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
207
src/Paca.Mobile.Core/MobileNativeFeatures.cs
Normal file
|
|
@ -0,0 +1,207 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace Paca.Mobile.Core;
|
||||||
|
|
||||||
|
/// <summary>접근성 폰트 크기 동적 스케일러</summary>
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>화면 방향 센서 및 비디오 전체화면 자동 전환 제어기</summary>
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>모바일 햅틱 진동 피드백 발송기</summary>
|
||||||
|
public class MobileHapticFeedback
|
||||||
|
{
|
||||||
|
public event Action<HapticFeedbackType>? OnHapticTriggered;
|
||||||
|
|
||||||
|
public void NotifySuccess() => OnHapticTriggered?.Invoke(HapticFeedbackType.Success);
|
||||||
|
public void NotifyError() => OnHapticTriggered?.Invoke(HapticFeedbackType.Error);
|
||||||
|
public void NotifySelectionChange() => OnHapticTriggered?.Invoke(HapticFeedbackType.LightClick);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>대기열 드래그 앤 드롭 순서 변경 엔진</summary>
|
||||||
|
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<MobileQueueItem> 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<MobileQueueItem> internalList)
|
||||||
|
{
|
||||||
|
if (fromIndex >= 0 && fromIndex < internalList.Count && toIndex >= 0 && toIndex < internalList.Count)
|
||||||
|
{
|
||||||
|
var item = internalList[fromIndex];
|
||||||
|
internalList.RemoveAt(fromIndex);
|
||||||
|
internalList.Insert(toIndex, item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>백그라운드 다운로드 포그라운드 서비스 워커</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>미디어 파일 AES 암호화 보관 볼트</summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>모바일 웹뷰 애드블록 엔진</summary>
|
||||||
|
public class MobileAdBlockEngine
|
||||||
|
{
|
||||||
|
private static readonly HashSet<string> 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);
|
||||||
|
|
||||||
|
/// <summary>해상도 선택 및 대역폭 계산기</summary>
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
45
src/Paca.Mobile.Core/MobileNavigationModel.cs
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace Paca.Mobile.Core;
|
||||||
|
|
||||||
|
/// <summary>모바일 바텀 내비게이션 탭 종류</summary>
|
||||||
|
public enum MobileTabKind
|
||||||
|
{
|
||||||
|
Queue,
|
||||||
|
Discover,
|
||||||
|
Browser,
|
||||||
|
Favorites,
|
||||||
|
SyncSettings
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>모바일 탭 아이템 모델</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>모바일 네비게이션 모델</summary>
|
||||||
|
public static class MobileNavigationModel
|
||||||
|
{
|
||||||
|
public static List<MobileTabItem> GetDefaultTabs()
|
||||||
|
{
|
||||||
|
return new List<MobileTabItem>
|
||||||
|
{
|
||||||
|
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)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
122
src/Paca.Mobile.Core/MobileQueueManager.cs
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>모바일 대기열 관리자</summary>
|
||||||
|
public class MobileQueueManager
|
||||||
|
{
|
||||||
|
private readonly List<MobileQueueItem> _items = new();
|
||||||
|
public IReadOnlyList<MobileQueueItem> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>네트워크 변동 시 대기열 자동 제어 가드</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/Paca.Mobile.Core/MobileSettingsManager.cs
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
namespace Paca.Mobile.Core;
|
||||||
|
|
||||||
|
/// <summary>모바일 설정 및 사용자 기본 설정 관리자</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
98
src/Paca.Mobile.Core/MobileSyncManager.cs
Normal file
|
|
@ -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; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Paca Mobile (Android/iOS) 백그라운드 동기화 및 생체인증 브릿지 매니저.
|
||||||
|
/// </summary>
|
||||||
|
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<bool> TriggerSyncAsync(Func<Task<int>> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
238
src/Paca.Mobile.Core/MobileTabManager.cs
Normal file
|
|
@ -0,0 +1,238 @@
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace Paca.Mobile;
|
||||||
|
|
||||||
|
/// <summary>모바일 멀티 탭 관리자 (세션 보존 및 활성 탭 제어)</summary>
|
||||||
|
public sealed class MobileTabManager
|
||||||
|
{
|
||||||
|
private readonly List<BrowserTab> _tabs = new();
|
||||||
|
private readonly object _lock = new();
|
||||||
|
private int _activeIndex = -1;
|
||||||
|
|
||||||
|
public event Action? ActiveTabChanged;
|
||||||
|
public event Action? TabsChanged;
|
||||||
|
|
||||||
|
public IReadOnlyList<BrowserTab> 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>새 탭 생성 및 활성화</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>새 탭 생성 및 활성화 (기본 AddTab)</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>특정 탭으로 전환</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>특정 탭 닫기</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>모든 일반 탭 닫기</summary>
|
||||||
|
public void CloseAllTabs()
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
_tabs.Clear();
|
||||||
|
var newTab = new BrowserTab { Url = "paca://newtab", Title = "새 탭" };
|
||||||
|
_tabs.Add(newTab);
|
||||||
|
_activeIndex = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
TabsChanged?.Invoke();
|
||||||
|
ActiveTabChanged?.Invoke();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>현재 활성 탭 정보 업데이트 (URL, 제목, 진행률, 뒤로/앞으로 상태)</summary>
|
||||||
|
public void UpdateActiveTab(string url, string? title = null, bool? canGoBack = null, bool? canGoForward = null, double? progress = null, bool? isLoading = null, IReadOnlyList<VideoSniffItem>? 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>활성 탭에 감지된 비디오 추가</summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>세션 저장 (InPrivate 탭 제외)</summary>
|
||||||
|
public async Task SaveSessionAsync(string filePath)
|
||||||
|
{
|
||||||
|
List<BrowserTab> 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>세션 복원</summary>
|
||||||
|
public async Task RestoreSessionAsync(string filePath)
|
||||||
|
{
|
||||||
|
if (!File.Exists(filePath)) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var json = await File.ReadAllTextAsync(filePath);
|
||||||
|
var restored = JsonSerializer.Deserialize<List<BrowserTab>>(json);
|
||||||
|
if (restored != null && restored.Count > 0)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
_tabs.Clear();
|
||||||
|
_tabs.AddRange(restored);
|
||||||
|
_activeIndex = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
TabsChanged?.Invoke();
|
||||||
|
ActiveTabChanged?.Invoke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// 손상된 세션 파일은 무시하고 기본 새 탭 유지
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/Paca.Mobile.Core/MobileTabSyncEngine.cs
Normal file
|
|
@ -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);
|
||||||
|
|
||||||
|
/// <summary>PC-모바일 간 열린 탭 실시간 동기화 엔진</summary>
|
||||||
|
public class MobileTabSyncEngine
|
||||||
|
{
|
||||||
|
private readonly List<RemoteTabInfo> _remoteTabs = new();
|
||||||
|
public IReadOnlyList<RemoteTabInfo> RemoteTabs => _remoteTabs.AsReadOnly();
|
||||||
|
|
||||||
|
public event Action<string>? OnRemoteDownloadRequested;
|
||||||
|
public event Action<string>? OnRemoteTabCloseRequested;
|
||||||
|
|
||||||
|
public void UpdateRemoteTabs(IEnumerable<RemoteTabInfo> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
113
src/Paca.Mobile.Core/MobileThemeSystem.cs
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
namespace Paca.Mobile.Core;
|
||||||
|
|
||||||
|
/// <summary>모바일 테마 모드</summary>
|
||||||
|
public enum MobileThemeMode
|
||||||
|
{
|
||||||
|
Dark,
|
||||||
|
Light,
|
||||||
|
OLEDBlack,
|
||||||
|
HighContrast,
|
||||||
|
SystemAuto
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>모바일 색상 팔레트 토큰</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>모바일 디자인 시스템 토큰 및 테마 관리자 (Designpaca & Human Interface Guidelines)</summary>
|
||||||
|
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"
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>지정된 테마 모드의 색상 팔레트를 반환합니다.</summary>
|
||||||
|
public static MobileColorPalette GetPalette(MobileThemeMode mode)
|
||||||
|
{
|
||||||
|
return mode switch
|
||||||
|
{
|
||||||
|
MobileThemeMode.Light => LightPalette,
|
||||||
|
MobileThemeMode.OLEDBlack => OLEDBlackPalette,
|
||||||
|
MobileThemeMode.HighContrast => HighContrastPalette,
|
||||||
|
MobileThemeMode.SystemAuto => DarkPalette,
|
||||||
|
_ => DarkPalette
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
80
src/Paca.Mobile.Core/OfflineStore.cs
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace Paca.Mobile;
|
||||||
|
|
||||||
|
public sealed record OfflineItem(string Id, string FileName, long SizeBytes);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 폰 오프라인 저장(U4): 라이브러리 항목을 앱 전용 폴더에 저장·목록·삭제.
|
||||||
|
/// manifest.json(id→파일명)으로 서버 항목과 매핑한다.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class OfflineStore
|
||||||
|
{
|
||||||
|
private readonly string _dir;
|
||||||
|
private readonly string _manifestPath;
|
||||||
|
private readonly object _gate = new();
|
||||||
|
private Dictionary<string, string> _manifest;
|
||||||
|
|
||||||
|
/// <summary>baseDir 아래 offline/ 폴더 사용(호스트가 앱 데이터 디렉터리 주입).</summary>
|
||||||
|
public OfflineStore(string baseDir)
|
||||||
|
{
|
||||||
|
_dir = Path.Combine(baseDir, "offline");
|
||||||
|
_manifestPath = Path.Combine(_dir, "manifest.json");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (File.Exists(_manifestPath))
|
||||||
|
_manifest = JsonSerializer.Deserialize<Dictionary<string, string>>(File.ReadAllText(_manifestPath)) ?? new();
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
_manifest ??= new Dictionary<string, string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Directory => _dir;
|
||||||
|
|
||||||
|
public string PathFor(string id) => Path.Combine(_dir, id + Path.GetExtension(_manifest.GetValueOrDefault(id, ".mp4")));
|
||||||
|
|
||||||
|
public bool Exists(string id) { lock (_gate) { var p = PathFor(id); return File.Exists(p) && _manifest.ContainsKey(id); } }
|
||||||
|
|
||||||
|
/// <summary>다운로드 완료 후 등록.</summary>
|
||||||
|
public void Record(string id, string fileName)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
System.IO.Directory.CreateDirectory(_dir);
|
||||||
|
_manifest[id] = fileName;
|
||||||
|
Save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Delete(string id)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
try { if (File.Exists(PathFor(id))) File.Delete(PathFor(id)); } catch { }
|
||||||
|
_manifest.Remove(id);
|
||||||
|
Save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<OfflineItem> List()
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
return _manifest.Select(kv =>
|
||||||
|
{
|
||||||
|
var p = PathFor(kv.Key);
|
||||||
|
return new OfflineItem(kv.Key, kv.Value, File.Exists(p) ? new FileInfo(p).Length : 0);
|
||||||
|
}).Where(i => i.SizeBytes > 0).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Save()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
System.IO.Directory.CreateDirectory(_dir);
|
||||||
|
var tmp = _manifestPath + ".tmp";
|
||||||
|
File.WriteAllText(tmp, JsonSerializer.Serialize(_manifest));
|
||||||
|
File.Move(tmp, _manifestPath, overwrite: true);
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/Paca.Mobile.Core/Paca.Mobile.Core.csproj
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<RootNamespace>Paca.Mobile</RootNamespace>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Paca.Core\Paca.Core.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
18
src/Paca.Mobile.Core/QueueCompletionDetector.cs
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
namespace Paca.Mobile;
|
||||||
|
|
||||||
|
/// <summary>U5 완료 알림: 이전/다음 큐 스냅샷에서 새로 완료("완료")된 항목 ID 감지.
|
||||||
|
/// 이전 스냅샷이 비면(앱 시작 직후 첫 폴링) 무시 — 과거 완료 알림 스팸 방지.</summary>
|
||||||
|
public sealed class QueueCompletionDetector
|
||||||
|
{
|
||||||
|
public IReadOnlyList<string> Detect(
|
||||||
|
IReadOnlyList<(string Id, string Status)> previous,
|
||||||
|
IReadOnlyList<(string Id, string Status)> next)
|
||||||
|
{
|
||||||
|
if (previous.Count == 0) return Array.Empty<string>();
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/Paca.Mobile.Core/SharePayload.cs
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace Paca.Mobile;
|
||||||
|
|
||||||
|
/// <summary>M1 공유 타겟(U1): 다른 앱에서 공유받은 텍스트에서 전송할 URL 추출.
|
||||||
|
/// 유튜브 공유 시트는 "제목 + https://youtu.be/..." 형식 — 텍스트 중 첫 http(s) URL.</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
220
src/Paca.Mobile.Core/VideoSnifferParser.cs
Normal file
|
|
@ -0,0 +1,220 @@
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace Paca.Mobile;
|
||||||
|
|
||||||
|
/// <summary>네트워크 URL 및 DOM 스니퍼 페이로드 분석기</summary>
|
||||||
|
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);
|
||||||
|
|
||||||
|
/// <summary>네트워크 요청 URL 및 Content-Type으로 비디오 스트림 여부 판별</summary>
|
||||||
|
public static bool TrySniffNetworkStream(string url, string? mimeType, IDictionary<string, string>? 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>DOM 인젝션 스크립트(paca_sniffer.js)가 전송한 JSON 페이로드 파싱</summary>
|
||||||
|
public static List<VideoSniffItem> ParseDomPayload(string jsonPayload, string pageUrl)
|
||||||
|
{
|
||||||
|
var results = new List<VideoSniffItem>();
|
||||||
|
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<string, int> 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 }
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>사용자 선호 화질을 기준으로 복수 스트림 중 최적의 비디오 스트림을 자동 협상합니다.</summary>
|
||||||
|
public static VideoSniffItem? NegotiateBestQuality(IEnumerable<VideoSniffItem> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
194
src/Paca.Mobile/ApiClient.cs
Normal file
|
|
@ -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;
|
||||||
|
|
||||||
|
/// <summary>데스크톱 원격 서버 API 클라이언트(M1). 토큰은 QR(#t=)/수동 입력에서 획득.</summary>
|
||||||
|
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;
|
||||||
|
|
||||||
|
/// <summary>QR 문자열(http://ip:port/#t=TOKEN) 또는 수동 입력(host:port + token) 구성.</summary>
|
||||||
|
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<List<QueueItem>> 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<List<QueueItem>>() ?? new();
|
||||||
|
}
|
||||||
|
catch (Exception e) { LastError = e.Message; return new(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> AddAsync(string url)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var r = await _http.PostAsJsonAsync($"{BaseUrl}/api/queue", new { url });
|
||||||
|
return r.IsSuccessStatusCode;
|
||||||
|
}
|
||||||
|
catch { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<bool> ControlAsync(string id, string action) =>
|
||||||
|
PostAsync($"/api/queue/{id}/{action}");
|
||||||
|
|
||||||
|
public async Task<bool> PairAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var r = await _http.PostAsJsonAsync($"{BaseUrl}/api/pair", new { token = Token, device = "Android" });
|
||||||
|
return r.IsSuccessStatusCode;
|
||||||
|
}
|
||||||
|
catch { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<LibraryItem>> GetLibraryAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var r = await _http.GetAsync($"{BaseUrl}/api/library");
|
||||||
|
if (!r.IsSuccessStatusCode) return new();
|
||||||
|
return await r.Content.ReadFromJsonAsync<List<LibraryItem>>() ?? 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}";
|
||||||
|
|
||||||
|
/// <summary>이어보기 위치 조회(U3). 실패/미저장 시 0.</summary>
|
||||||
|
public async Task<double> GetPositionAsync(string id)
|
||||||
|
{
|
||||||
|
var dto = await GetAsync<PositionDto>($"/api/position/{id}");
|
||||||
|
return dto?.PositionSec ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>이어보기 위치 저장(U3).</summary>
|
||||||
|
public Task<bool> PostPositionAsync(string id, double positionSec) =>
|
||||||
|
PostJsonAsync($"/api/position/{id}", new PositionDto(positionSec));
|
||||||
|
|
||||||
|
/// <summary>라이브러리 항목을 폰에 저장(U4). 진행률 0~1, 취소 지원. 임시 .part 로 받아 완료 시 rename.</summary>
|
||||||
|
public async Task DownloadFileAsync(LibraryItem item, string destPath, IProgress<double>? 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<bool> PostJsonAsync(string path, object body)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var r = await _http.PostAsJsonAsync($"{BaseUrl}{path}", body);
|
||||||
|
return r.IsSuccessStatusCode;
|
||||||
|
}
|
||||||
|
catch { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task<bool> PostAsync(string path)
|
||||||
|
{
|
||||||
|
try { var t = _http.PostAsync($"{BaseUrl}{path}", null); return Continue(t); }
|
||||||
|
catch { return Task.FromResult(false); }
|
||||||
|
static async Task<bool> Continue(Task<HttpResponseMessage> t) { try { return (await t).IsSuccessStatusCode; } catch { return false; } }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<T?> GetAsync<T>(string path) where T : class
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var r = await _http.GetAsync($"{BaseUrl}{path}");
|
||||||
|
return r.IsSuccessStatusCode ? await r.Content.ReadFromJsonAsync<T>() : 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);
|
||||||
14
src/Paca.Mobile/App.xaml
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
<?xml version = "1.0" encoding = "UTF-8" ?>
|
||||||
|
<Application xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||||
|
xmlns:local="clr-namespace:Paca.Mobile"
|
||||||
|
x:Class="Paca.Mobile.App">
|
||||||
|
<Application.Resources>
|
||||||
|
<ResourceDictionary>
|
||||||
|
<ResourceDictionary.MergedDictionaries>
|
||||||
|
<ResourceDictionary Source="Resources/Styles/Colors.xaml" />
|
||||||
|
<ResourceDictionary Source="Resources/Styles/Styles.xaml" />
|
||||||
|
</ResourceDictionary.MergedDictionaries>
|
||||||
|
</ResourceDictionary>
|
||||||
|
</Application.Resources>
|
||||||
|
</Application>
|
||||||
21
src/Paca.Mobile/App.xaml.cs
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
7
src/Paca.Mobile/AppOfflineStore.cs
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
namespace Paca.Mobile;
|
||||||
|
|
||||||
|
/// <summary>앱 데이터 디렉터리를 주입하는 오프라인 저장소 기본 인스턴스(Core 는 MAUI 비의존).</summary>
|
||||||
|
public static class AppOfflineStore
|
||||||
|
{
|
||||||
|
public static OfflineStore Default { get; } = new(FileSystem.AppDataDirectory);
|
||||||
|
}
|
||||||
979
src/Paca.Mobile/BrowserPage.cs
Normal file
|
|
@ -0,0 +1,979 @@
|
||||||
|
using Microsoft.Maui.Controls;
|
||||||
|
using Microsoft.Maui.Controls.Shapes;
|
||||||
|
|
||||||
|
namespace Paca.Mobile;
|
||||||
|
|
||||||
|
/// <summary>PACA Mobile 메인 웹 브라우징 화면 (초고속 독립형 웹뷰 + 글래스 옴니박스 + 비디오 스니퍼 + 플로팅 글래스 툴바)</summary>
|
||||||
|
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<VideoSniffItem>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
451
src/Paca.Mobile/DownloadsPage.cs
Normal file
|
|
@ -0,0 +1,451 @@
|
||||||
|
using Microsoft.Maui.Controls;
|
||||||
|
using Microsoft.Maui.Controls.Shapes;
|
||||||
|
|
||||||
|
namespace Paca.Mobile;
|
||||||
|
|
||||||
|
/// <summary>인앱 다운로드 진행 및 완료된 미디어 관리 화면 (Obsidian Dark Luxury Design)</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
389
src/Paca.Mobile/HistoryBookmarksPage.cs
Normal file
|
|
@ -0,0 +1,389 @@
|
||||||
|
using Microsoft.Maui.Controls;
|
||||||
|
using Microsoft.Maui.Controls.Shapes;
|
||||||
|
|
||||||
|
namespace Paca.Mobile;
|
||||||
|
|
||||||
|
/// <summary>방문 기록 및 북마크 통합 관리 화면 (Obsidian Dark Luxury Design)</summary>
|
||||||
|
public sealed class HistoryBookmarksPage : ContentPage
|
||||||
|
{
|
||||||
|
private readonly MobileHistoryStore _historyStore;
|
||||||
|
private readonly MobileBookmarkStore _bookmarkStore;
|
||||||
|
private readonly Action<string> _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<string> 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<Task> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
29
src/Paca.Mobile/MauiProgram.cs
Normal file
|
|
@ -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<App>()
|
||||||
|
.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();
|
||||||
|
}
|
||||||
|
}
|
||||||
89
src/Paca.Mobile/Paca.Mobile.csproj
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<!-- Windows 에선 ios 워크로드가 없으므로 android 만; macOS 빌드머신에서 ios TFM 추가 (M3) -->
|
||||||
|
<TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('OSX'))">net10.0-android;net10.0-ios</TargetFrameworks>
|
||||||
|
<TargetFrameworks Condition="!$([MSBuild]::IsOSPlatform('OSX'))">net10.0-android</TargetFrameworks>
|
||||||
|
|
||||||
|
<!-- Android 런타임 식별자: 에뮬레이터(x64) + 실기기(arm64) 지원 -->
|
||||||
|
<RuntimeIdentifiers Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">android-x64;android-arm64</RuntimeIdentifiers>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Note for MacCatalyst:
|
||||||
|
The default runtime is maccatalyst-x64, except in Release config, in which case the default is maccatalyst-x64;maccatalyst-arm64.
|
||||||
|
When specifying both architectures, use the plural <RuntimeIdentifiers> instead of the singular <RuntimeIdentifier>.
|
||||||
|
The Mac App Store will NOT accept apps with ONLY maccatalyst-arm64 indicated;
|
||||||
|
either BOTH runtimes must be indicated or ONLY macatalyst-x64. -->
|
||||||
|
<!-- For example: <RuntimeIdentifiers>maccatalyst-x64;maccatalyst-arm64</RuntimeIdentifiers> -->
|
||||||
|
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<RootNamespace>Paca.Mobile</RootNamespace>
|
||||||
|
<UseMaui>true</UseMaui>
|
||||||
|
<SingleProject>true</SingleProject>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<EmbedAssembliesIntoApk Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">true</EmbedAssembliesIntoApk>
|
||||||
|
<AndroidEnableFastDeployment Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">false</AndroidEnableFastDeployment>
|
||||||
|
|
||||||
|
<!-- Enable XAML source generation for faster build times and improved performance.
|
||||||
|
This generates C# code from XAML at compile time instead of runtime inflation.
|
||||||
|
To disable, remove this line.
|
||||||
|
For individual files, you can override by setting Inflator metadata:
|
||||||
|
<MauiXaml Update="MyPage.xaml" Inflator="Default" /> (reverts to defaults: Runtime for Debug, XamlC for Release)
|
||||||
|
<MauiXaml Update="MyPage.xaml" Inflator="Runtime" /> (force runtime inflation) -->
|
||||||
|
<MauiXamlInflator>SourceGen</MauiXamlInflator>
|
||||||
|
|
||||||
|
<!-- Display name -->
|
||||||
|
<ApplicationTitle>Paca</ApplicationTitle>
|
||||||
|
|
||||||
|
<!-- App Identifier -->
|
||||||
|
<ApplicationId>com.companyname.paca.mobile</ApplicationId>
|
||||||
|
|
||||||
|
<!-- Versions -->
|
||||||
|
<ApplicationDisplayVersion>1.0</ApplicationDisplayVersion>
|
||||||
|
<ApplicationVersion>1</ApplicationVersion>
|
||||||
|
|
||||||
|
<!-- To develop, package, and publish an app to the Microsoft Store, see: https://aka.ms/MauiTemplateUnpackaged -->
|
||||||
|
<WindowsPackageType>None</WindowsPackageType>
|
||||||
|
|
||||||
|
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'ios'">15.0</SupportedOSPlatformVersion>
|
||||||
|
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'maccatalyst'">15.0</SupportedOSPlatformVersion>
|
||||||
|
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">21.0</SupportedOSPlatformVersion>
|
||||||
|
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">10.0.17763.0</SupportedOSPlatformVersion>
|
||||||
|
<TargetPlatformMinVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">10.0.17763.0</TargetPlatformMinVersion>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- App Icon -->
|
||||||
|
<MauiIcon Include="Resources\AppIcon\appicon.svg" ForegroundFile="Resources\AppIcon\appiconfg.svg" Color="#2563eb" />
|
||||||
|
|
||||||
|
<!-- Splash Screen -->
|
||||||
|
<MauiSplashScreen Include="Resources\Splash\splash.svg" Color="#2563eb" BaseSize="128,128" />
|
||||||
|
|
||||||
|
<!-- Images -->
|
||||||
|
<MauiImage Include="Resources\Images\*" />
|
||||||
|
<MauiImage Update="Resources\Images\dotnet_bot.png" Resize="True" BaseSize="300,185" />
|
||||||
|
|
||||||
|
<!-- Custom Fonts -->
|
||||||
|
<MauiFont Include="Resources\Fonts\*" />
|
||||||
|
|
||||||
|
<!-- Raw Assets (also remove the "Resources\Raw" prefix) -->
|
||||||
|
<MauiAsset Include="Resources\Raw\**" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Paca.Mobile.Core\Paca.Mobile.Core.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Maui.Controls" Version="$(MauiVersion)" />
|
||||||
|
<!-- U5 완료 알림 -->
|
||||||
|
<PackageReference Include="Plugin.LocalNotification" Version="11.1.0" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="10.0.0" />
|
||||||
|
<PackageReference Include="ZXing.Net.Maui.Controls" Version="0.4.0" />
|
||||||
|
<PackageReference Include="CommunityToolkit.Maui.MediaElement" Version="3.2.0" />
|
||||||
|
<!-- 모달 NavigationPage 를 링커가 싣지 않아 FileNotFoundException 으로 크래시 → 명시 참조 (Android 전용) -->
|
||||||
|
<PackageReference Include="Xamarin.AndroidX.LocalBroadcastManager" Version="1.1.0.24" Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
599
src/Paca.Mobile/Pages.cs
Normal file
|
|
@ -0,0 +1,599 @@
|
||||||
|
using Microsoft.Maui.Controls;
|
||||||
|
using System.Net.Http;
|
||||||
|
|
||||||
|
namespace Paca.Mobile;
|
||||||
|
|
||||||
|
/// <summary>M1 연결 페이지: QR 스캔(ZXing) + 수동 입력 폴백.</summary>
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>M1 큐 페이지: 목록(2초 폴링) + URL 추가 + 제어 + U5 완료 알림 + 외부망 안내.</summary>
|
||||||
|
public sealed class QueuePage : ContentPage
|
||||||
|
{
|
||||||
|
private readonly Entry _url = new() { Placeholder = "YouTube·영상 URL 붙여넣기", Keyboard = Keyboard.Url };
|
||||||
|
private readonly Label _summary = new() { 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<QueueItem> _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}건";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>U1: 공유받은 링크 처리 결과 표시(루트 타이머가 호출).</summary>
|
||||||
|
public void ShowSharedResult(string message) => _summary.Text = message;
|
||||||
|
|
||||||
|
/// <summary>U1: 공유 타겟으로 받은 텍스트에서 URL 을 추출해 PC 대기열에 추가.</summary>
|
||||||
|
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}" : "❌ 공유 링크 전송 실패";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>U5: 완료 로컬 알림(Android 13+ 는 최초 1회 권한 요청).</summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>M2 라이브러리 페이지: 목록 → 탭하여 스트리밍 재생.</summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>U4: 항목을 폰 저장 폴더로 스트리밍 다운로드(진행률 % → 완료 ✓).</summary>
|
||||||
|
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<double>(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)]}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>M2: 오프라인 저장 진행 알림(같은 id 갱신) — 5% 단위 스로틀.</summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>M2 재생 페이지: Range 스트리밍 재생(MediaElement) + 이어보기 위치 동기화(U3). localPath 있으면 오프라인 재생(U4).</summary>
|
||||||
|
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;
|
||||||
|
|
||||||
|
/// <summary>M2: 재생 중 미디어 알림 — ongoing 노티(닫으면 자동 소멸).</summary>
|
||||||
|
private static void StartPlaybackNotification(string title)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Plugin.LocalNotification.LocalNotificationCenter.Current.Show(new Plugin.LocalNotification.NotificationRequest
|
||||||
|
{
|
||||||
|
NotificationId = PlaybackNotificationId,
|
||||||
|
Title = "재생 중",
|
||||||
|
Description = title,
|
||||||
|
Android =
|
||||||
|
{
|
||||||
|
ChannelId = "vd_playback",
|
||||||
|
Ongoing = true,
|
||||||
|
AutoCancel = false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void StopPlaybackNotification()
|
||||||
|
{
|
||||||
|
try { Plugin.LocalNotification.LocalNotificationCenter.Current.Cancel(PlaybackNotificationId); } catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>M2: LibraryItem → 썸네일 URL 변환(바인딩 컨버터).</summary>
|
||||||
|
public sealed class ThumbConverter : IValueConverter
|
||||||
|
{
|
||||||
|
public object? Convert(object? value, Type t, object? p, System.Globalization.CultureInfo c)
|
||||||
|
=> value is LibraryItem it && ApiClient.Instance.IsConfigured
|
||||||
|
? Microsoft.Maui.Controls.ImageSource.FromUri(new Uri(ApiClient.Instance.ThumbUrl(it)))
|
||||||
|
: null;
|
||||||
|
public object ConvertBack(object? v, Type t, object? p, System.Globalization.CultureInfo c) => throw new NotSupportedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>모바일 프로덕션 영상 찾기 & 플랫폼 프리셋 페이지</summary>
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>모바일 즐겨찾기 및 나중에 받기(Stash) 보관함 페이지</summary>
|
||||||
|
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<string> { "전체", "동영상", "음악", "영화" };
|
||||||
|
_tagFilter.SelectedIndex = 0;
|
||||||
|
|
||||||
|
_favoritesList.ItemsSource = _manager.Favorites;
|
||||||
|
|
||||||
|
Content = new VerticalStackLayout
|
||||||
|
{
|
||||||
|
Padding = 16, Spacing = 12,
|
||||||
|
Children = { _tagFilter, _favoritesList }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>모바일 PC 탭 동기화 및 고급 설정 페이지</summary>
|
||||||
|
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<string> { "다크", "라이트", "OLED 블랙", "고대비", "시스템 자동" };
|
||||||
|
_themePicker.SelectedIndex = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
12
src/Paca.Mobile/Platforms/Android/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<application android:allowBackup="true" android:icon="@mipmap/appicon" android:roundIcon="@mipmap/appicon_round" android:supportsRtl="true" android:usesCleartextTraffic="true"></application>
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
<!-- U5 완료 알림(Android 13+) -->
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<!-- M2 재생중 미디어 알림(포그라운드 서비스 mediaPlayback) -->
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||||
|
</manifest>
|
||||||
31
src/Paca.Mobile/Platforms/Android/MainActivity.cs
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src/Paca.Mobile/Platforms/Android/MainApplication.cs
Normal file
|
|
@ -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();
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="colorPrimary">#512BD4</color>
|
||||||
|
<color name="colorPrimaryDark">#2B0B98</color>
|
||||||
|
<color name="colorAccent">#2B0B98</color>
|
||||||
|
</resources>
|
||||||
9
src/Paca.Mobile/Platforms/MacCatalyst/AppDelegate.cs
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
using Foundation;
|
||||||
|
|
||||||
|
namespace Paca.Mobile;
|
||||||
|
|
||||||
|
[Register("AppDelegate")]
|
||||||
|
public class AppDelegate : MauiUIApplicationDelegate
|
||||||
|
{
|
||||||
|
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
|
||||||
|
}
|
||||||
14
src/Paca.Mobile/Platforms/MacCatalyst/Entitlements.plist
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<!-- See https://aka.ms/maui-publish-app-store#add-entitlements for more information about adding entitlements.-->
|
||||||
|
<dict>
|
||||||
|
<!-- App Sandbox must be enabled to distribute a MacCatalyst app through the Mac App Store. -->
|
||||||
|
<key>com.apple.security.app-sandbox</key>
|
||||||
|
<true/>
|
||||||
|
<!-- When App Sandbox is enabled, this value is required to open outgoing network connections. -->
|
||||||
|
<key>com.apple.security.network.client</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
|
||||||
42
src/Paca.Mobile/Platforms/MacCatalyst/Info.plist
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>NSCameraUsageDescription</key>
|
||||||
|
<string>PC 와 페어링하기 위해 QR 코드를 스캔합니다.</string>
|
||||||
|
<!-- The Mac App Store requires you specify if the app uses encryption. -->
|
||||||
|
<!-- Please consult https://developer.apple.com/documentation/bundleresources/information_property_list/itsappusesnonexemptencryption -->
|
||||||
|
<!-- <key>ITSAppUsesNonExemptEncryption</key> -->
|
||||||
|
<!-- Please indicate <true/> or <false/> here. -->
|
||||||
|
|
||||||
|
<!-- Specify the category for your app here. -->
|
||||||
|
<!-- Please consult https://developer.apple.com/documentation/bundleresources/information_property_list/lsapplicationcategorytype -->
|
||||||
|
<!-- <key>LSApplicationCategoryType</key> -->
|
||||||
|
<!-- <string>public.app-category.YOUR-CATEGORY-HERE</string> -->
|
||||||
|
<key>UIDeviceFamily</key>
|
||||||
|
<array>
|
||||||
|
<integer>2</integer>
|
||||||
|
</array>
|
||||||
|
<key>LSApplicationCategoryType</key>
|
||||||
|
<string>public.app-category.lifestyle</string>
|
||||||
|
<key>UIRequiredDeviceCapabilities</key>
|
||||||
|
<array>
|
||||||
|
<string>arm64</string>
|
||||||
|
</array>
|
||||||
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
|
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
|
<key>XSAppIconAssets</key>
|
||||||
|
<string>Assets.xcassets/appicon.appiconset</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
15
src/Paca.Mobile/Platforms/MacCatalyst/Program.cs
Normal file
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
8
src/Paca.Mobile/Platforms/Windows/App.xaml
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
<maui:MauiWinUIApplication
|
||||||
|
x:Class="Paca.Mobile.WinUI.App"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:maui="using:Microsoft.Maui"
|
||||||
|
xmlns:local="using:Paca.Mobile.WinUI">
|
||||||
|
|
||||||
|
</maui:MauiWinUIApplication>
|
||||||
24
src/Paca.Mobile/Platforms/Windows/App.xaml.cs
Normal file
|
|
@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Provides application-specific behavior to supplement the default Application class.
|
||||||
|
/// </summary>
|
||||||
|
public partial class App : MauiWinUIApplication
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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().
|
||||||
|
/// </summary>
|
||||||
|
public App()
|
||||||
|
{
|
||||||
|
this.InitializeComponent();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
|
||||||
|
}
|
||||||
|
|
||||||
46
src/Paca.Mobile/Platforms/Windows/Package.appxmanifest
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<Package
|
||||||
|
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
|
||||||
|
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
|
||||||
|
xmlns:mp="http://schemas.microsoft.com/appx/2014/phone/manifest"
|
||||||
|
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
|
||||||
|
IgnorableNamespaces="uap rescap">
|
||||||
|
|
||||||
|
<Identity Name="maui-package-name-placeholder" Publisher="CN=User Name" Version="0.0.0.0" />
|
||||||
|
|
||||||
|
<mp:PhoneIdentity PhoneProductId="BF83F8D1-2F12-4749-A41C-B693AFE9B129" PhonePublisherId="00000000-0000-0000-0000-000000000000"/>
|
||||||
|
|
||||||
|
<Properties>
|
||||||
|
<DisplayName>$placeholder$</DisplayName>
|
||||||
|
<PublisherDisplayName>User Name</PublisherDisplayName>
|
||||||
|
<Logo>$placeholder$.png</Logo>
|
||||||
|
</Properties>
|
||||||
|
|
||||||
|
<Dependencies>
|
||||||
|
<TargetDeviceFamily Name="Windows.Universal" MinVersion="10.0.17763.0" MaxVersionTested="10.0.19041.0" />
|
||||||
|
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.17763.0" MaxVersionTested="10.0.19041.0" />
|
||||||
|
</Dependencies>
|
||||||
|
|
||||||
|
<Resources>
|
||||||
|
<Resource Language="x-generate" />
|
||||||
|
</Resources>
|
||||||
|
|
||||||
|
<Applications>
|
||||||
|
<Application Id="App" Executable="$targetnametoken$.exe" EntryPoint="$targetentrypoint$">
|
||||||
|
<uap:VisualElements
|
||||||
|
DisplayName="$placeholder$"
|
||||||
|
Description="$placeholder$"
|
||||||
|
Square150x150Logo="$placeholder$.png"
|
||||||
|
Square44x44Logo="$placeholder$.png"
|
||||||
|
BackgroundColor="transparent">
|
||||||
|
<uap:DefaultTile Square71x71Logo="$placeholder$.png" Wide310x150Logo="$placeholder$.png" Square310x310Logo="$placeholder$.png" />
|
||||||
|
<uap:SplashScreen Image="$placeholder$.png" />
|
||||||
|
</uap:VisualElements>
|
||||||
|
</Application>
|
||||||
|
</Applications>
|
||||||
|
|
||||||
|
<Capabilities>
|
||||||
|
<rescap:Capability Name="runFullTrust" />
|
||||||
|
</Capabilities>
|
||||||
|
|
||||||
|
</Package>
|
||||||
17
src/Paca.Mobile/Platforms/Windows/app.manifest
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||||
|
<assemblyIdentity version="1.0.0.0" name="Paca.Mobile.WinUI.app"/>
|
||||||
|
|
||||||
|
<application xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||||
|
<windowsSettings>
|
||||||
|
<!-- The combination of below two tags have the following effect:
|
||||||
|
1) Per-Monitor for >= Windows 10 Anniversary Update
|
||||||
|
2) System < Windows 10 Anniversary Update
|
||||||
|
-->
|
||||||
|
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/PM</dpiAware>
|
||||||
|
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2, PerMonitor</dpiAwareness>
|
||||||
|
|
||||||
|
<longPathAware xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">true</longPathAware>
|
||||||
|
</windowsSettings>
|
||||||
|
</application>
|
||||||
|
</assembly>
|
||||||
9
src/Paca.Mobile/Platforms/iOS/AppDelegate.cs
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
using Foundation;
|
||||||
|
|
||||||
|
namespace Paca.Mobile;
|
||||||
|
|
||||||
|
[Register("AppDelegate")]
|
||||||
|
public class AppDelegate : MauiUIApplicationDelegate
|
||||||
|
{
|
||||||
|
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
|
||||||
|
}
|
||||||
42
src/Paca.Mobile/Platforms/iOS/Info.plist
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>LSRequiresIPhoneOS</key>
|
||||||
|
<true/>
|
||||||
|
<!-- M3 키 3종: LAN http 통신 허용(페어링·스트리밍) + 로컬 네트워크 사유 설명 -->
|
||||||
|
<key>NSAppTransportSecurity</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSAllowsLocalNetworking</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
<key>NSLocalNetworkUsageDescription</key>
|
||||||
|
<string>PC 와 같은 네트워크에서 페어링하고 라이브러리를 스트리밍합니다.</string>
|
||||||
|
<key>NSCameraUsageDescription</key>
|
||||||
|
<string>PC 와 페어링하기 위해 QR 코드를 스캔합니다.</string>
|
||||||
|
<key>UIDeviceFamily</key>
|
||||||
|
<array>
|
||||||
|
<integer>1</integer>
|
||||||
|
<integer>2</integer>
|
||||||
|
</array>
|
||||||
|
<key>UIRequiredDeviceCapabilities</key>
|
||||||
|
<array>
|
||||||
|
<string>arm64</string>
|
||||||
|
</array>
|
||||||
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
|
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
|
<key>XSAppIconAssets</key>
|
||||||
|
<string>Assets.xcassets/appicon.appiconset</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
15
src/Paca.Mobile/Platforms/iOS/Program.cs
Normal file
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!--
|
||||||
|
This is the minimum required version of the Apple Privacy Manifest for .NET MAUI apps.
|
||||||
|
The contents below are needed because of APIs that are used in the .NET framework and .NET MAUI SDK.
|
||||||
|
|
||||||
|
You are responsible for adding extra entries as needed for your application.
|
||||||
|
|
||||||
|
More information: https://aka.ms/maui-privacy-manifest
|
||||||
|
-->
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>NSPrivacyAccessedAPITypes</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>NSPrivacyAccessedAPIType</key>
|
||||||
|
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
|
||||||
|
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||||
|
<array>
|
||||||
|
<string>C617.1</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
<dict>
|
||||||
|
<key>NSPrivacyAccessedAPIType</key>
|
||||||
|
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
|
||||||
|
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||||
|
<array>
|
||||||
|
<string>35F9.1</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
<dict>
|
||||||
|
<key>NSPrivacyAccessedAPIType</key>
|
||||||
|
<string>NSPrivacyAccessedAPICategoryDiskSpace</string>
|
||||||
|
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||||
|
<array>
|
||||||
|
<string>E174.1</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
<!--
|
||||||
|
The entry below is only needed when you're using the Preferences API in your app.
|
||||||
|
<dict>
|
||||||
|
<key>NSPrivacyAccessedAPIType</key>
|
||||||
|
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
|
||||||
|
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||||
|
<array>
|
||||||
|
<string>CA92.1</string>
|
||||||
|
</array>
|
||||||
|
</dict> -->
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
8
src/Paca.Mobile/Properties/launchSettings.json
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"profiles": {
|
||||||
|
"Windows Machine": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"nativeDebugging": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
src/Paca.Mobile/Resources/AppIcon/appicon.png
Normal file
|
After Width: | Height: | Size: 240 KiB |
4
src/Paca.Mobile/Resources/AppIcon/appicon.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg width="456" height="456" viewBox="0 0 456 456" version="1.1" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect x="0" y="0" width="456" height="456" rx="90" fill="#2563eb" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 237 B |
BIN
src/Paca.Mobile/Resources/AppIcon/appiconfg.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
4
src/Paca.Mobile/Resources/AppIcon/appiconfg.svg
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
src/Paca.Mobile/Resources/AppIcon/paca_app_icon.png
Normal file
|
After Width: | Height: | Size: 649 KiB |
BIN
src/Paca.Mobile/Resources/Fonts/OpenSans-Regular.ttf
Normal file
BIN
src/Paca.Mobile/Resources/Fonts/OpenSans-Semibold.ttf
Normal file
BIN
src/Paca.Mobile/Resources/Images/dotnet_bot.png
Normal file
|
After Width: | Height: | Size: 90 KiB |
BIN
src/Paca.Mobile/Resources/Images/paca_empty_queue.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
15
src/Paca.Mobile/Resources/Raw/AboutAssets.txt
Normal file
|
|
@ -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`.
|
||||||
|
|
||||||
|
<MauiAsset Include="Resources\Raw\**" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
BIN
src/Paca.Mobile/Resources/Splash/paca_splash_screen.png
Normal file
|
After Width: | Height: | Size: 632 KiB |
8
src/Paca.Mobile/Resources/Splash/splash.svg
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg width="456" height="456" viewBox="0 0 456 456" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||||
|
<path d="m 105.50037,281.60863 c -2.70293,0 -5.00091,-0.90042 -6.893127,-2.70209 -1.892214,-1.84778 -2.837901,-4.04181 -2.837901,-6.58209 0,-2.58722 0.945687,-4.80389 2.837901,-6.65167 1.892217,-1.84778 4.190197,-2.77167 6.893127,-2.77167 2.74819,0 5.06798,0.92389 6.96019,2.77167 1.93749,1.84778 2.90581,4.06445 2.90581,6.65167 0,2.54028 -0.96832,4.73431 -2.90581,6.58209 -1.89221,1.80167 -4.212,2.70209 -6.96019,2.70209 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
|
||||||
|
<path d="M 213.56111,280.08446 H 195.99044 L 149.69953,207.0544 c -1.17121,-1.84778 -2.14037,-3.76515 -2.90581,-5.75126 h -0.40578 c 0.36051,2.12528 0.54076,6.67515 0.54076,13.6496 v 65.13172 h -15.54349 v -99.36009 h 18.71925 l 44.7374,71.29798 c 1.89222,2.95695 3.1087,4.98917 3.64945,6.09751 h 0.26996 c -0.45021,-2.6325 -0.67573,-7.09015 -0.67573,-13.37293 v -64.02256 h 15.47557 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
|
||||||
|
<path d="m 289.25134,280.08446 h -54.40052 v -99.36009 h 52.23835 v 13.99669 h -36.15411 v 28.13085 h 33.31621 v 13.9271 h -33.31621 v 29.37835 h 38.31628 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
|
||||||
|
<path d="M 366.56466,194.72106 H 338.7222 v 85.3634 h -16.08423 v -85.3634 h -27.77455 v -13.99669 h 71.70124 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.8 KiB |
84
src/Paca.Mobile/Resources/Styles/Colors.xaml
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
|
<ResourceDictionary
|
||||||
|
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml">
|
||||||
|
|
||||||
|
<!-- ===== PACA Desktop 통일 다크 팔레트 토큰 ===== -->
|
||||||
|
<Color x:Key="WindowBg">#0B0B10</Color>
|
||||||
|
<Color x:Key="PanelBg">#14141C</Color>
|
||||||
|
<Color x:Key="Surface2">#181822</Color>
|
||||||
|
<Color x:Key="GlassBg">#1A1A24</Color>
|
||||||
|
<Color x:Key="GlassBorder">#2A2A38</Color>
|
||||||
|
<Color x:Key="CardBg">#1C1C26</Color>
|
||||||
|
<Color x:Key="CardBgHover">#242430</Color>
|
||||||
|
<Color x:Key="BorderSubtle">#20202B</Color>
|
||||||
|
<Color x:Key="BorderBrush">#6C6C8A</Color>
|
||||||
|
<Color x:Key="TextPrimary">#F2F2F6</Color>
|
||||||
|
<Color x:Key="TextSecondary">#A9B0C5</Color>
|
||||||
|
<Color x:Key="TextMuted">#8B91A6</Color>
|
||||||
|
<Color x:Key="TextDisabled">#5A5F72</Color>
|
||||||
|
<Color x:Key="Accent">#8B7CFF</Color>
|
||||||
|
<Color x:Key="AccentStart">#6366F1</Color>
|
||||||
|
<Color x:Key="AccentEnd">#A855F7</Color>
|
||||||
|
<Color x:Key="Success">#34D399</Color>
|
||||||
|
<Color x:Key="Warning">#FB923C</Color>
|
||||||
|
<Color x:Key="Error">#F87171</Color>
|
||||||
|
<Color x:Key="Info">#60A5FA</Color>
|
||||||
|
|
||||||
|
<!-- Standard MAUI Palette Tokens (Mapped to Obsidian Dark) -->
|
||||||
|
<Color x:Key="Primary">#8B7CFF</Color>
|
||||||
|
<Color x:Key="PrimaryDark">#6366F1</Color>
|
||||||
|
<Color x:Key="PrimaryDarkText">#F2F2F6</Color>
|
||||||
|
<Color x:Key="Secondary">#A855F7</Color>
|
||||||
|
<Color x:Key="SecondaryDarkText">#A9B0C5</Color>
|
||||||
|
<Color x:Key="Tertiary">#6366F1</Color>
|
||||||
|
|
||||||
|
<Color x:Key="White">#FFFFFF</Color>
|
||||||
|
<Color x:Key="Black">#000000</Color>
|
||||||
|
|
||||||
|
<Color x:Key="Gray100">#F2F2F6</Color>
|
||||||
|
<Color x:Key="Gray200">#E1E1E6</Color>
|
||||||
|
<Color x:Key="Gray300">#C4C4CC</Color>
|
||||||
|
<Color x:Key="Gray400">#A9B0C5</Color>
|
||||||
|
<Color x:Key="Gray500">#8B91A6</Color>
|
||||||
|
<Color x:Key="Gray600">#5A5F72</Color>
|
||||||
|
<Color x:Key="Gray900">#14141C</Color>
|
||||||
|
<Color x:Key="Gray950">#0B0B10</Color>
|
||||||
|
|
||||||
|
<Color x:Key="Headline">#F2F2F6</Color>
|
||||||
|
<Color x:Key="SubHeadline">#A9B0C5</Color>
|
||||||
|
<Color x:Key="CardBackground">#1C1C26</Color>
|
||||||
|
<Color x:Key="BorderColor">#2A2A38</Color>
|
||||||
|
<Color x:Key="MidnightBlue">#14141C</Color>
|
||||||
|
<Color x:Key="OffBlack">#0B0B10</Color>
|
||||||
|
<Color x:Key="Magenta">#8B7CFF</Color>
|
||||||
|
|
||||||
|
<!-- SolidColorBrushes -->
|
||||||
|
<SolidColorBrush x:Key="WindowBgBrush" Color="{StaticResource WindowBg}"/>
|
||||||
|
<SolidColorBrush x:Key="PanelBgBrush" Color="{StaticResource PanelBg}"/>
|
||||||
|
<SolidColorBrush x:Key="Surface2Brush" Color="{StaticResource Surface2}"/>
|
||||||
|
<SolidColorBrush x:Key="CardBgBrush" Color="{StaticResource CardBg}"/>
|
||||||
|
<SolidColorBrush x:Key="GlassBgBrush" Color="{StaticResource GlassBg}"/>
|
||||||
|
<SolidColorBrush x:Key="GlassBorderBrush" Color="{StaticResource GlassBorder}"/>
|
||||||
|
<SolidColorBrush x:Key="BorderSubtleBrush" Color="{StaticResource BorderSubtle}"/>
|
||||||
|
<SolidColorBrush x:Key="BorderBrushBrush" Color="{StaticResource BorderBrush}"/>
|
||||||
|
<SolidColorBrush x:Key="TextPrimaryBrush" Color="{StaticResource TextPrimary}"/>
|
||||||
|
<SolidColorBrush x:Key="TextSecondaryBrush" Color="{StaticResource TextSecondary}"/>
|
||||||
|
<SolidColorBrush x:Key="TextMutedBrush" Color="{StaticResource TextMuted}"/>
|
||||||
|
<SolidColorBrush x:Key="AccentBrush" Color="{StaticResource Accent}"/>
|
||||||
|
<SolidColorBrush x:Key="SuccessBrush" Color="{StaticResource Success}"/>
|
||||||
|
<SolidColorBrush x:Key="WarningBrush" Color="{StaticResource Warning}"/>
|
||||||
|
<SolidColorBrush x:Key="ErrorBrush" Color="{StaticResource Error}"/>
|
||||||
|
<SolidColorBrush x:Key="InfoBrush" Color="{StaticResource Info}"/>
|
||||||
|
|
||||||
|
<LinearGradientBrush x:Key="AccentGradient" StartPoint="0,0" EndPoint="1,1">
|
||||||
|
<GradientStop Color="#6366F1" Offset="0"/>
|
||||||
|
<GradientStop Color="#A855F7" Offset="1"/>
|
||||||
|
</LinearGradientBrush>
|
||||||
|
|
||||||
|
<LinearGradientBrush x:Key="WindowBgGradient" StartPoint="0,0" EndPoint="0,1">
|
||||||
|
<GradientStop Color="#11111B" Offset="0"/>
|
||||||
|
<GradientStop Color="#0B0B10" Offset="0.45"/>
|
||||||
|
<GradientStop Color="#09090D" Offset="1"/>
|
||||||
|
</LinearGradientBrush>
|
||||||
|
</ResourceDictionary>
|
||||||
434
src/Paca.Mobile/Resources/Styles/Styles.xaml
Normal file
|
|
@ -0,0 +1,434 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
|
<ResourceDictionary
|
||||||
|
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml">
|
||||||
|
|
||||||
|
<Style TargetType="ActivityIndicator">
|
||||||
|
<Setter Property="Color" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style TargetType="IndicatorView">
|
||||||
|
<Setter Property="IndicatorColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray500}}"/>
|
||||||
|
<Setter Property="SelectedIndicatorColor" Value="{AppThemeBinding Light={StaticResource Gray950}, Dark={StaticResource Gray100}}"/>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style TargetType="Border">
|
||||||
|
<Setter Property="Stroke" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray500}}" />
|
||||||
|
<Setter Property="StrokeShape" Value="Rectangle"/>
|
||||||
|
<Setter Property="StrokeThickness" Value="1"/>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style TargetType="BoxView">
|
||||||
|
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource Gray950}, Dark={StaticResource Gray200}}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style TargetType="Button">
|
||||||
|
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource PrimaryDarkText}}" />
|
||||||
|
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource PrimaryDark}}" />
|
||||||
|
<Setter Property="FontFamily" Value="OpenSansRegular"/>
|
||||||
|
<Setter Property="FontSize" Value="14"/>
|
||||||
|
<Setter Property="BorderWidth" Value="0"/>
|
||||||
|
<Setter Property="CornerRadius" Value="8"/>
|
||||||
|
<Setter Property="Padding" Value="14,10"/>
|
||||||
|
<Setter Property="MinimumHeightRequest" Value="44"/>
|
||||||
|
<Setter Property="MinimumWidthRequest" Value="44"/>
|
||||||
|
<Setter Property="VisualStateManager.VisualStateGroups">
|
||||||
|
<VisualStateGroupList>
|
||||||
|
<VisualStateGroup x:Name="CommonStates">
|
||||||
|
<VisualState x:Name="Normal" />
|
||||||
|
<VisualState x:Name="Disabled">
|
||||||
|
<VisualState.Setters>
|
||||||
|
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray950}, Dark={StaticResource Gray200}}" />
|
||||||
|
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray600}}" />
|
||||||
|
</VisualState.Setters>
|
||||||
|
</VisualState>
|
||||||
|
<VisualState x:Name="PointerOver" />
|
||||||
|
</VisualStateGroup>
|
||||||
|
</VisualStateGroupList>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style TargetType="CheckBox">
|
||||||
|
<Setter Property="Color" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
|
||||||
|
<Setter Property="MinimumHeightRequest" Value="44"/>
|
||||||
|
<Setter Property="MinimumWidthRequest" Value="44"/>
|
||||||
|
<Setter Property="VisualStateManager.VisualStateGroups">
|
||||||
|
<VisualStateGroupList>
|
||||||
|
<VisualStateGroup x:Name="CommonStates">
|
||||||
|
<VisualState x:Name="Normal" />
|
||||||
|
<VisualState x:Name="Disabled">
|
||||||
|
<VisualState.Setters>
|
||||||
|
<Setter Property="Color" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
|
||||||
|
</VisualState.Setters>
|
||||||
|
</VisualState>
|
||||||
|
</VisualStateGroup>
|
||||||
|
</VisualStateGroupList>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style TargetType="DatePicker">
|
||||||
|
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource White}}" />
|
||||||
|
<Setter Property="BackgroundColor" Value="Transparent" />
|
||||||
|
<Setter Property="FontFamily" Value="OpenSansRegular"/>
|
||||||
|
<Setter Property="FontSize" Value="14"/>
|
||||||
|
<Setter Property="MinimumHeightRequest" Value="44"/>
|
||||||
|
<Setter Property="MinimumWidthRequest" Value="44"/>
|
||||||
|
<Setter Property="VisualStateManager.VisualStateGroups">
|
||||||
|
<VisualStateGroupList>
|
||||||
|
<VisualStateGroup x:Name="CommonStates">
|
||||||
|
<VisualState x:Name="Normal" />
|
||||||
|
<VisualState x:Name="Disabled">
|
||||||
|
<VisualState.Setters>
|
||||||
|
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray500}}" />
|
||||||
|
</VisualState.Setters>
|
||||||
|
</VisualState>
|
||||||
|
</VisualStateGroup>
|
||||||
|
</VisualStateGroupList>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style TargetType="Editor">
|
||||||
|
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource White}}" />
|
||||||
|
<Setter Property="BackgroundColor" Value="Transparent" />
|
||||||
|
<Setter Property="FontFamily" Value="OpenSansRegular"/>
|
||||||
|
<Setter Property="FontSize" Value="14" />
|
||||||
|
<Setter Property="PlaceholderColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray500}}" />
|
||||||
|
<Setter Property="MinimumHeightRequest" Value="44"/>
|
||||||
|
<Setter Property="MinimumWidthRequest" Value="44"/>
|
||||||
|
<Setter Property="VisualStateManager.VisualStateGroups">
|
||||||
|
<VisualStateGroupList>
|
||||||
|
<VisualStateGroup x:Name="CommonStates">
|
||||||
|
<VisualState x:Name="Normal" />
|
||||||
|
<VisualState x:Name="Disabled">
|
||||||
|
<VisualState.Setters>
|
||||||
|
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
|
||||||
|
</VisualState.Setters>
|
||||||
|
</VisualState>
|
||||||
|
</VisualStateGroup>
|
||||||
|
</VisualStateGroupList>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style TargetType="Entry">
|
||||||
|
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource White}}" />
|
||||||
|
<Setter Property="BackgroundColor" Value="Transparent" />
|
||||||
|
<Setter Property="FontFamily" Value="OpenSansRegular"/>
|
||||||
|
<Setter Property="FontSize" Value="14" />
|
||||||
|
<Setter Property="PlaceholderColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray500}}" />
|
||||||
|
<Setter Property="MinimumHeightRequest" Value="44"/>
|
||||||
|
<Setter Property="MinimumWidthRequest" Value="44"/>
|
||||||
|
<Setter Property="VisualStateManager.VisualStateGroups">
|
||||||
|
<VisualStateGroupList>
|
||||||
|
<VisualStateGroup x:Name="CommonStates">
|
||||||
|
<VisualState x:Name="Normal" />
|
||||||
|
<VisualState x:Name="Disabled">
|
||||||
|
<VisualState.Setters>
|
||||||
|
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
|
||||||
|
</VisualState.Setters>
|
||||||
|
</VisualState>
|
||||||
|
</VisualStateGroup>
|
||||||
|
</VisualStateGroupList>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style TargetType="ImageButton">
|
||||||
|
<Setter Property="Opacity" Value="1" />
|
||||||
|
<Setter Property="BorderColor" Value="Transparent"/>
|
||||||
|
<Setter Property="BorderWidth" Value="0"/>
|
||||||
|
<Setter Property="CornerRadius" Value="0"/>
|
||||||
|
<Setter Property="MinimumHeightRequest" Value="44"/>
|
||||||
|
<Setter Property="MinimumWidthRequest" Value="44"/>
|
||||||
|
<Setter Property="VisualStateManager.VisualStateGroups">
|
||||||
|
<VisualStateGroupList>
|
||||||
|
<VisualStateGroup x:Name="CommonStates">
|
||||||
|
<VisualState x:Name="Normal" />
|
||||||
|
<VisualState x:Name="Disabled">
|
||||||
|
<VisualState.Setters>
|
||||||
|
<Setter Property="Opacity" Value="0.5" />
|
||||||
|
</VisualState.Setters>
|
||||||
|
</VisualState>
|
||||||
|
<VisualState x:Name="PointerOver" />
|
||||||
|
</VisualStateGroup>
|
||||||
|
</VisualStateGroupList>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style TargetType="Label">
|
||||||
|
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource White}}" />
|
||||||
|
<Setter Property="BackgroundColor" Value="Transparent" />
|
||||||
|
<Setter Property="FontFamily" Value="OpenSansRegular" />
|
||||||
|
<Setter Property="FontSize" Value="14" />
|
||||||
|
<Setter Property="VisualStateManager.VisualStateGroups">
|
||||||
|
<VisualStateGroupList>
|
||||||
|
<VisualStateGroup x:Name="CommonStates">
|
||||||
|
<VisualState x:Name="Normal" />
|
||||||
|
<VisualState x:Name="Disabled">
|
||||||
|
<VisualState.Setters>
|
||||||
|
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
|
||||||
|
</VisualState.Setters>
|
||||||
|
</VisualState>
|
||||||
|
</VisualStateGroup>
|
||||||
|
</VisualStateGroupList>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style TargetType="Label" x:Key="Headline">
|
||||||
|
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource MidnightBlue}, Dark={StaticResource White}}" />
|
||||||
|
<Setter Property="FontSize" Value="32" />
|
||||||
|
<Setter Property="HorizontalOptions" Value="Center" />
|
||||||
|
<Setter Property="HorizontalTextAlignment" Value="Center" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style TargetType="Label" x:Key="SubHeadline">
|
||||||
|
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource MidnightBlue}, Dark={StaticResource White}}" />
|
||||||
|
<Setter Property="FontSize" Value="24" />
|
||||||
|
<Setter Property="HorizontalOptions" Value="Center" />
|
||||||
|
<Setter Property="HorizontalTextAlignment" Value="Center" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style TargetType="Picker">
|
||||||
|
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource White}}" />
|
||||||
|
<Setter Property="TitleColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource Gray200}}" />
|
||||||
|
<Setter Property="BackgroundColor" Value="Transparent" />
|
||||||
|
<Setter Property="FontFamily" Value="OpenSansRegular"/>
|
||||||
|
<Setter Property="FontSize" Value="14" />
|
||||||
|
<Setter Property="MinimumHeightRequest" Value="44"/>
|
||||||
|
<Setter Property="MinimumWidthRequest" Value="44"/>
|
||||||
|
<Setter Property="VisualStateManager.VisualStateGroups">
|
||||||
|
<VisualStateGroupList>
|
||||||
|
<VisualStateGroup x:Name="CommonStates">
|
||||||
|
<VisualState x:Name="Normal" />
|
||||||
|
<VisualState x:Name="Disabled">
|
||||||
|
<VisualState.Setters>
|
||||||
|
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
|
||||||
|
<Setter Property="TitleColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
|
||||||
|
</VisualState.Setters>
|
||||||
|
</VisualState>
|
||||||
|
</VisualStateGroup>
|
||||||
|
</VisualStateGroupList>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style TargetType="ProgressBar">
|
||||||
|
<Setter Property="ProgressColor" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
|
||||||
|
<Setter Property="VisualStateManager.VisualStateGroups">
|
||||||
|
<VisualStateGroupList>
|
||||||
|
<VisualStateGroup x:Name="CommonStates">
|
||||||
|
<VisualState x:Name="Normal" />
|
||||||
|
<VisualState x:Name="Disabled">
|
||||||
|
<VisualState.Setters>
|
||||||
|
<Setter Property="ProgressColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
|
||||||
|
</VisualState.Setters>
|
||||||
|
</VisualState>
|
||||||
|
</VisualStateGroup>
|
||||||
|
</VisualStateGroupList>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style TargetType="RadioButton">
|
||||||
|
<Setter Property="BackgroundColor" Value="Transparent"/>
|
||||||
|
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource White}}" />
|
||||||
|
<Setter Property="FontFamily" Value="OpenSansRegular"/>
|
||||||
|
<Setter Property="FontSize" Value="14"/>
|
||||||
|
<Setter Property="MinimumHeightRequest" Value="44"/>
|
||||||
|
<Setter Property="MinimumWidthRequest" Value="44"/>
|
||||||
|
<Setter Property="VisualStateManager.VisualStateGroups">
|
||||||
|
<VisualStateGroupList>
|
||||||
|
<VisualStateGroup x:Name="CommonStates">
|
||||||
|
<VisualState x:Name="Normal" />
|
||||||
|
<VisualState x:Name="Disabled">
|
||||||
|
<VisualState.Setters>
|
||||||
|
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
|
||||||
|
</VisualState.Setters>
|
||||||
|
</VisualState>
|
||||||
|
</VisualStateGroup>
|
||||||
|
</VisualStateGroupList>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style TargetType="RefreshView">
|
||||||
|
<Setter Property="RefreshColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource Gray200}}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style TargetType="SearchBar">
|
||||||
|
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource White}}" />
|
||||||
|
<Setter Property="PlaceholderColor" Value="{StaticResource Gray500}" />
|
||||||
|
<Setter Property="CancelButtonColor" Value="{StaticResource Gray500}" />
|
||||||
|
<Setter Property="BackgroundColor" Value="Transparent" />
|
||||||
|
<Setter Property="FontFamily" Value="OpenSansRegular" />
|
||||||
|
<Setter Property="FontSize" Value="14" />
|
||||||
|
<Setter Property="MinimumHeightRequest" Value="44"/>
|
||||||
|
<Setter Property="MinimumWidthRequest" Value="44"/>
|
||||||
|
<Setter Property="VisualStateManager.VisualStateGroups">
|
||||||
|
<VisualStateGroupList>
|
||||||
|
<VisualStateGroup x:Name="CommonStates">
|
||||||
|
<VisualState x:Name="Normal" />
|
||||||
|
<VisualState x:Name="Disabled">
|
||||||
|
<VisualState.Setters>
|
||||||
|
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
|
||||||
|
<Setter Property="PlaceholderColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
|
||||||
|
</VisualState.Setters>
|
||||||
|
</VisualState>
|
||||||
|
</VisualStateGroup>
|
||||||
|
</VisualStateGroupList>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style TargetType="SearchHandler">
|
||||||
|
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource White}}" />
|
||||||
|
<Setter Property="PlaceholderColor" Value="{StaticResource Gray500}" />
|
||||||
|
<Setter Property="BackgroundColor" Value="Transparent" />
|
||||||
|
<Setter Property="FontFamily" Value="OpenSansRegular" />
|
||||||
|
<Setter Property="FontSize" Value="14" />
|
||||||
|
<Setter Property="VisualStateManager.VisualStateGroups">
|
||||||
|
<VisualStateGroupList>
|
||||||
|
<VisualStateGroup x:Name="CommonStates">
|
||||||
|
<VisualState x:Name="Normal" />
|
||||||
|
<VisualState x:Name="Disabled">
|
||||||
|
<VisualState.Setters>
|
||||||
|
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
|
||||||
|
<Setter Property="PlaceholderColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
|
||||||
|
</VisualState.Setters>
|
||||||
|
</VisualState>
|
||||||
|
</VisualStateGroup>
|
||||||
|
</VisualStateGroupList>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style TargetType="Shadow">
|
||||||
|
<Setter Property="Radius" Value="15" />
|
||||||
|
<Setter Property="Opacity" Value="0.5" />
|
||||||
|
<Setter Property="Brush" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource White}}" />
|
||||||
|
<Setter Property="Offset" Value="10,10" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style TargetType="Slider">
|
||||||
|
<Setter Property="MinimumTrackColor" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
|
||||||
|
<Setter Property="MaximumTrackColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray600}}" />
|
||||||
|
<Setter Property="ThumbColor" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
|
||||||
|
<Setter Property="VisualStateManager.VisualStateGroups">
|
||||||
|
<VisualStateGroupList>
|
||||||
|
<VisualStateGroup x:Name="CommonStates">
|
||||||
|
<VisualState x:Name="Normal" />
|
||||||
|
<VisualState x:Name="Disabled">
|
||||||
|
<VisualState.Setters>
|
||||||
|
<Setter Property="MinimumTrackColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}"/>
|
||||||
|
<Setter Property="MaximumTrackColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}"/>
|
||||||
|
<Setter Property="ThumbColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}"/>
|
||||||
|
</VisualState.Setters>
|
||||||
|
</VisualState>
|
||||||
|
</VisualStateGroup>
|
||||||
|
</VisualStateGroupList>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style TargetType="SwipeItem">
|
||||||
|
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource Black}}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style TargetType="Switch">
|
||||||
|
<Setter Property="OnColor" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
|
||||||
|
<Setter Property="ThumbColor" Value="{StaticResource White}" />
|
||||||
|
<Setter Property="VisualStateManager.VisualStateGroups">
|
||||||
|
<VisualStateGroupList>
|
||||||
|
<VisualStateGroup x:Name="CommonStates">
|
||||||
|
<VisualState x:Name="Normal" />
|
||||||
|
<VisualState x:Name="Disabled">
|
||||||
|
<VisualState.Setters>
|
||||||
|
<Setter Property="OnColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
|
||||||
|
<Setter Property="ThumbColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
|
||||||
|
</VisualState.Setters>
|
||||||
|
</VisualState>
|
||||||
|
<VisualState x:Name="On">
|
||||||
|
<VisualState.Setters>
|
||||||
|
<Setter Property="OnColor" Value="{AppThemeBinding Light={StaticResource Secondary}, Dark={StaticResource Gray200}}" />
|
||||||
|
<Setter Property="ThumbColor" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
|
||||||
|
</VisualState.Setters>
|
||||||
|
</VisualState>
|
||||||
|
<VisualState x:Name="Off">
|
||||||
|
<VisualState.Setters>
|
||||||
|
<Setter Property="ThumbColor" Value="{AppThemeBinding Light={StaticResource Gray400}, Dark={StaticResource Gray500}}" />
|
||||||
|
</VisualState.Setters>
|
||||||
|
</VisualState>
|
||||||
|
</VisualStateGroup>
|
||||||
|
</VisualStateGroupList>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style TargetType="TimePicker">
|
||||||
|
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource White}}" />
|
||||||
|
<Setter Property="BackgroundColor" Value="Transparent"/>
|
||||||
|
<Setter Property="FontFamily" Value="OpenSansRegular"/>
|
||||||
|
<Setter Property="FontSize" Value="14"/>
|
||||||
|
<Setter Property="MinimumHeightRequest" Value="44"/>
|
||||||
|
<Setter Property="MinimumWidthRequest" Value="44"/>
|
||||||
|
<Setter Property="VisualStateManager.VisualStateGroups">
|
||||||
|
<VisualStateGroupList>
|
||||||
|
<VisualStateGroup x:Name="CommonStates">
|
||||||
|
<VisualState x:Name="Normal" />
|
||||||
|
<VisualState x:Name="Disabled">
|
||||||
|
<VisualState.Setters>
|
||||||
|
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
|
||||||
|
</VisualState.Setters>
|
||||||
|
</VisualState>
|
||||||
|
</VisualStateGroup>
|
||||||
|
</VisualStateGroupList>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
<Style TargetType="TitleBar">
|
||||||
|
<Setter Property="MinimumHeightRequest" Value="32"/>
|
||||||
|
<Setter Property="VisualStateManager.VisualStateGroups">
|
||||||
|
<VisualStateGroupList>
|
||||||
|
<VisualStateGroup x:Name="TitleActiveStates">
|
||||||
|
<VisualState x:Name="TitleBarTitleActive">
|
||||||
|
<VisualState.Setters>
|
||||||
|
<Setter Property="BackgroundColor" Value="Transparent" />
|
||||||
|
<Setter Property="ForegroundColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource White}}" />
|
||||||
|
</VisualState.Setters>
|
||||||
|
</VisualState>
|
||||||
|
<VisualState x:Name="TitleBarTitleInactive">
|
||||||
|
<VisualState.Setters>
|
||||||
|
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource Black}}" />
|
||||||
|
<Setter Property="ForegroundColor" Value="{AppThemeBinding Light={StaticResource Gray400}, Dark={StaticResource Gray500}}" />
|
||||||
|
</VisualState.Setters>
|
||||||
|
</VisualState>
|
||||||
|
</VisualStateGroup>
|
||||||
|
</VisualStateGroupList>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
-->
|
||||||
|
|
||||||
|
<Style TargetType="Page" ApplyToDerivedTypes="True">
|
||||||
|
<Setter Property="Padding" Value="0"/>
|
||||||
|
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource OffBlack}}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style TargetType="Shell" ApplyToDerivedTypes="True">
|
||||||
|
<Setter Property="Shell.BackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource OffBlack}}" />
|
||||||
|
<Setter Property="Shell.ForegroundColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource SecondaryDarkText}}" />
|
||||||
|
<Setter Property="Shell.TitleColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource SecondaryDarkText}}" />
|
||||||
|
<Setter Property="Shell.DisabledColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray950}}" />
|
||||||
|
<Setter Property="Shell.UnselectedColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray200}}" />
|
||||||
|
<Setter Property="Shell.NavBarHasShadow" Value="False" />
|
||||||
|
<Setter Property="Shell.TabBarBackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource Black}}" />
|
||||||
|
<Setter Property="Shell.TabBarForegroundColor" Value="{AppThemeBinding Light={StaticResource Magenta}, Dark={StaticResource White}}" />
|
||||||
|
<Setter Property="Shell.TabBarTitleColor" Value="{AppThemeBinding Light={StaticResource Magenta}, Dark={StaticResource White}}" />
|
||||||
|
<Setter Property="Shell.TabBarUnselectedColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource Gray200}}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style TargetType="NavigationPage">
|
||||||
|
<Setter Property="BarBackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource OffBlack}}" />
|
||||||
|
<Setter Property="BarTextColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource White}}" />
|
||||||
|
<Setter Property="IconColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource White}}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style TargetType="TabbedPage">
|
||||||
|
<Setter Property="BarBackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource Gray950}}" />
|
||||||
|
<Setter Property="BarTextColor" Value="{AppThemeBinding Light={StaticResource Magenta}, Dark={StaticResource White}}" />
|
||||||
|
<Setter Property="UnselectedTabColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray950}}" />
|
||||||
|
<Setter Property="SelectedTabColor" Value="{AppThemeBinding Light={StaticResource Gray950}, Dark={StaticResource Gray200}}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
</ResourceDictionary>
|
||||||
16
src/Paca.Mobile/ShareTarget.cs
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
namespace Paca.Mobile;
|
||||||
|
|
||||||
|
/// <summary>M1 공유 타겟(U1): 플랫폼(안드로이드 인텐트)이 전달한 공유 텍스트 큐.</summary>
|
||||||
|
public static class ShareTarget
|
||||||
|
{
|
||||||
|
private static string? _pending;
|
||||||
|
|
||||||
|
public static void Deliver(string? sharedText)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(sharedText)) return;
|
||||||
|
Volatile.Write(ref _pending, sharedText);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>대기 중 공유 텍스트를 꺼낸다(없으면 null).</summary>
|
||||||
|
public static string? Consume() => Interlocked.Exchange(ref _pending, null);
|
||||||
|
}
|
||||||
91
src/Paca.Mobile/SnifferScript.cs
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
namespace Paca.Mobile;
|
||||||
|
|
||||||
|
/// <summary>웹페이지에 주입되어 비디오 태그, blob URL, 미디어 스트림을 실시간 탐지하는 JS 코드</summary>
|
||||||
|
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 내 <video>, <audio> 스캔
|
||||||
|
function scanExisting() {
|
||||||
|
var videos = document.querySelectorAll('video');
|
||||||
|
videos.forEach(function(v) {
|
||||||
|
var src = v.currentSrc || v.src;
|
||||||
|
if (!src) {
|
||||||
|
var source = v.querySelector('source');
|
||||||
|
if (source) src = source.src;
|
||||||
|
}
|
||||||
|
if (src) {
|
||||||
|
reportMedia(src, v.videoWidth, v.videoHeight, v.duration, v.poster);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. DOM MutationObserver 로 동적 추가되는 미디어 감지
|
||||||
|
var observer = new MutationObserver(function(mutations) {
|
||||||
|
mutations.forEach(function(m) {
|
||||||
|
m.addedNodes.forEach(function(node) {
|
||||||
|
if (node.tagName === 'VIDEO') {
|
||||||
|
node.addEventListener('loadedmetadata', function() {
|
||||||
|
reportMedia(node.currentSrc || node.src, node.videoWidth, node.videoHeight, node.duration, node.poster);
|
||||||
|
});
|
||||||
|
var src = node.currentSrc || node.src;
|
||||||
|
if (src) reportMedia(src, node.videoWidth, node.videoHeight, node.duration, node.poster);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(document.documentElement || document.body, { childList: true, subtree: true });
|
||||||
|
|
||||||
|
// 3. HTMLMediaElement.prototype.play 후킹
|
||||||
|
var origPlay = HTMLMediaElement.prototype.play;
|
||||||
|
HTMLMediaElement.prototype.play = function() {
|
||||||
|
var v = this;
|
||||||
|
var src = v.currentSrc || v.src;
|
||||||
|
if (src) {
|
||||||
|
reportMedia(src, v.videoWidth, v.videoHeight, v.duration, v.poster);
|
||||||
|
}
|
||||||
|
return origPlay.apply(this, arguments);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 4. URL.createObjectURL 후킹 (Blob 스트림 감지)
|
||||||
|
var origCreateObjectURL = window.URL.createObjectURL;
|
||||||
|
window.URL.createObjectURL = function(obj) {
|
||||||
|
var url = origCreateObjectURL.apply(this, arguments);
|
||||||
|
if (obj && (obj.type && obj.type.indexOf('video') !== -1 || obj.type.indexOf('mpegurl') !== -1)) {
|
||||||
|
reportMedia(url, 0, 0, 0, '', document.title);
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 즉시 1회 스캔 실행
|
||||||
|
if (document.readyState === 'complete' || document.readyState === 'interactive') {
|
||||||
|
scanExisting();
|
||||||
|
} else {
|
||||||
|
document.addEventListener('DOMContentLoaded', scanExisting);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
";
|
||||||
|
}
|
||||||
260
src/Paca.Mobile/SyncSettingsPage.cs
Normal file
|
|
@ -0,0 +1,260 @@
|
||||||
|
using Microsoft.Maui.Controls;
|
||||||
|
using Microsoft.Maui.Controls.Shapes;
|
||||||
|
|
||||||
|
namespace Paca.Mobile;
|
||||||
|
|
||||||
|
/// <summary>선택적 PC 동기화 및 페어링 관리 화면 (Obsidian Dark Luxury Design)</summary>
|
||||||
|
public sealed partial class SyncSettingsPage : ContentPage
|
||||||
|
{
|
||||||
|
private readonly MobileTabManager? _tabManager;
|
||||||
|
private readonly MobileHistoryStore? _historyStore;
|
||||||
|
|
||||||
|
private readonly Entry _manualAddress;
|
||||||
|
private readonly Entry _manualToken;
|
||||||
|
private readonly Label _status;
|
||||||
|
|
||||||
|
public SyncSettingsPage(MobileTabManager? tabManager = null, MobileHistoryStore? historyStore = null)
|
||||||
|
{
|
||||||
|
_tabManager = tabManager;
|
||||||
|
_historyStore = historyStore;
|
||||||
|
|
||||||
|
NavigationPage.SetHasNavigationBar(this, false);
|
||||||
|
BackgroundColor = Color.FromArgb("#08080C");
|
||||||
|
|
||||||
|
var mainLayout = new VerticalStackLayout
|
||||||
|
{
|
||||||
|
Padding = new Thickness(16, 12, 16, 24),
|
||||||
|
Spacing = 16
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1. 상단 네비게이션 헤더
|
||||||
|
var topHeader = new Grid
|
||||||
|
{
|
||||||
|
ColumnDefinitions =
|
||||||
|
{
|
||||||
|
new ColumnDefinition { Width = GridLength.Auto },
|
||||||
|
new ColumnDefinition { Width = GridLength.Star }
|
||||||
|
},
|
||||||
|
Margin = new Thickness(0, 4, 0, 4)
|
||||||
|
};
|
||||||
|
|
||||||
|
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 = "로컬 LAN 무상태 P2P 연동 센터",
|
||||||
|
TextColor = Color.FromArgb("#94A3B8"),
|
||||||
|
FontSize = 11
|
||||||
|
});
|
||||||
|
topHeader.Add(headerTitleStack, 1, 0);
|
||||||
|
|
||||||
|
mainLayout.Children.Add(topHeader);
|
||||||
|
|
||||||
|
// 2. 정보 배너 카드
|
||||||
|
var headerCard = new Border
|
||||||
|
{
|
||||||
|
BackgroundColor = Color.FromArgb("#12121C"),
|
||||||
|
Stroke = Color.FromArgb("#242436"),
|
||||||
|
StrokeThickness = 1,
|
||||||
|
StrokeShape = new RoundRectangle { CornerRadius = 16 },
|
||||||
|
Padding = new Thickness(16),
|
||||||
|
Content = new VerticalStackLayout
|
||||||
|
{
|
||||||
|
Spacing = 6,
|
||||||
|
Children =
|
||||||
|
{
|
||||||
|
new Label { Text = "💻 PACA 데스크톱 연동 센터", TextColor = Color.FromArgb("#FFFFFF"), FontSize = 15, FontAttributes = FontAttributes.Bold },
|
||||||
|
new Label { Text = "로컬 Wi-Fi(LAN) 상의 PC와 연결하여 탭 전송, 다운로드 큐 공유 및 방문 기록을 무상태 P2P로 안전하게 동기화합니다.", TextColor = Color.FromArgb("#94A3B8"), FontSize = 12 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
mainLayout.Children.Add(headerCard);
|
||||||
|
|
||||||
|
// 3. 연결 설정 카드
|
||||||
|
var connectionCard = new Border
|
||||||
|
{
|
||||||
|
BackgroundColor = Color.FromArgb("#12121C"),
|
||||||
|
Stroke = Color.FromArgb("#242436"),
|
||||||
|
StrokeThickness = 1,
|
||||||
|
StrokeShape = new RoundRectangle { CornerRadius = 16 },
|
||||||
|
Padding = new Thickness(16),
|
||||||
|
Margin = new Thickness(0, 2)
|
||||||
|
};
|
||||||
|
|
||||||
|
var connStack = new VerticalStackLayout { Spacing = 12 };
|
||||||
|
|
||||||
|
connStack.Children.Add(new Label
|
||||||
|
{
|
||||||
|
Text = "연결 정보 입력",
|
||||||
|
TextColor = Color.FromArgb("#FFFFFF"),
|
||||||
|
FontSize = 14,
|
||||||
|
FontAttributes = FontAttributes.Bold
|
||||||
|
});
|
||||||
|
|
||||||
|
_manualAddress = new Entry
|
||||||
|
{
|
||||||
|
Placeholder = "PC IP 및 포트 (예: 192.168.0.12:52100)",
|
||||||
|
PlaceholderColor = Color.FromArgb("#64748B"),
|
||||||
|
TextColor = Color.FromArgb("#FFFFFF"),
|
||||||
|
FontSize = 13,
|
||||||
|
BackgroundColor = Color.FromArgb("#181824"),
|
||||||
|
HeightRequest = 44,
|
||||||
|
ClearButtonVisibility = ClearButtonVisibility.WhileEditing,
|
||||||
|
Keyboard = Keyboard.Url,
|
||||||
|
Text = ApiClient.Instance.BaseUrl
|
||||||
|
};
|
||||||
|
connStack.Children.Add(_manualAddress);
|
||||||
|
|
||||||
|
_manualToken = new Entry
|
||||||
|
{
|
||||||
|
Placeholder = "인증 토큰 (선택 사항)",
|
||||||
|
PlaceholderColor = Color.FromArgb("#64748B"),
|
||||||
|
TextColor = Color.FromArgb("#FFFFFF"),
|
||||||
|
FontSize = 13,
|
||||||
|
IsPassword = true,
|
||||||
|
BackgroundColor = Color.FromArgb("#181824"),
|
||||||
|
HeightRequest = 44,
|
||||||
|
ClearButtonVisibility = ClearButtonVisibility.WhileEditing
|
||||||
|
};
|
||||||
|
connStack.Children.Add(_manualToken);
|
||||||
|
|
||||||
|
_status = new Label
|
||||||
|
{
|
||||||
|
Text = ApiClient.Instance.IsConfigured ? "✅ 연결 구성됨" : "⚪ 미연결 (독립 실행 중)",
|
||||||
|
TextColor = ApiClient.Instance.IsConfigured ? Color.FromArgb("#34D399") : Color.FromArgb("#94A3B8"),
|
||||||
|
FontSize = 12,
|
||||||
|
HorizontalOptions = LayoutOptions.Center,
|
||||||
|
Margin = new Thickness(0, 4)
|
||||||
|
};
|
||||||
|
connStack.Children.Add(_status);
|
||||||
|
|
||||||
|
var connectBtn = new Button
|
||||||
|
{
|
||||||
|
Text = "연결 및 핑 테스트",
|
||||||
|
BackgroundColor = Color.FromArgb("#8B7CFF"),
|
||||||
|
TextColor = Colors.White,
|
||||||
|
FontAttributes = FontAttributes.Bold,
|
||||||
|
FontSize = 14,
|
||||||
|
CornerRadius = 12,
|
||||||
|
HeightRequest = 46
|
||||||
|
};
|
||||||
|
connectBtn.Clicked += async (_, _) =>
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(_manualAddress.Text))
|
||||||
|
{
|
||||||
|
await DisplayAlert("입력 오류", "PC IP 주소를 입력하세요.", "확인");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ApiClient.Instance.Configure(_manualAddress.Text, _manualToken.Text))
|
||||||
|
{
|
||||||
|
var queue = await ApiClient.Instance.GetQueueAsync();
|
||||||
|
if (queue != null)
|
||||||
|
{
|
||||||
|
_status.Text = $"✅ PC 연결 성공! ({_manualAddress.Text})";
|
||||||
|
_status.TextColor = Color.FromArgb("#34D399");
|
||||||
|
await DisplayAlert("연결 성공", "PACA 데스크톱과 성공적으로 페어링되었습니다.", "확인");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_status.Text = "⚠️ 주소 설정됨 (응답 대기 중)";
|
||||||
|
_status.TextColor = Color.FromArgb("#FB923C");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_status.Text = "❌ 올바른 URL 형식이 아닙니다.";
|
||||||
|
_status.TextColor = Color.FromArgb("#F87171");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
connStack.Children.Add(connectBtn);
|
||||||
|
|
||||||
|
connectionCard.Content = connStack;
|
||||||
|
mainLayout.Children.Add(connectionCard);
|
||||||
|
|
||||||
|
// 4. 빠른 액션 카드
|
||||||
|
var actionsCard = new Border
|
||||||
|
{
|
||||||
|
BackgroundColor = Color.FromArgb("#12121C"),
|
||||||
|
Stroke = Color.FromArgb("#242436"),
|
||||||
|
StrokeThickness = 1,
|
||||||
|
StrokeShape = new RoundRectangle { CornerRadius = 16 },
|
||||||
|
Padding = new Thickness(16),
|
||||||
|
Margin = new Thickness(0, 2)
|
||||||
|
};
|
||||||
|
|
||||||
|
var actionsStack = new VerticalStackLayout { Spacing = 10 };
|
||||||
|
actionsStack.Children.Add(new Label
|
||||||
|
{
|
||||||
|
Text = "동기화 액션",
|
||||||
|
TextColor = Color.FromArgb("#FFFFFF"),
|
||||||
|
FontSize = 14,
|
||||||
|
FontAttributes = FontAttributes.Bold
|
||||||
|
});
|
||||||
|
|
||||||
|
var sendTabsBtn = new Button
|
||||||
|
{
|
||||||
|
Text = "📤 현재 활성 탭 PC로 전송",
|
||||||
|
BackgroundColor = Color.FromArgb("#181826"),
|
||||||
|
TextColor = Color.FromArgb("#FFFFFF"),
|
||||||
|
BorderColor = Color.FromArgb("#2C2C40"),
|
||||||
|
BorderWidth = 1,
|
||||||
|
HeightRequest = 44,
|
||||||
|
CornerRadius = 12,
|
||||||
|
FontSize = 13,
|
||||||
|
FontAttributes = FontAttributes.Bold
|
||||||
|
};
|
||||||
|
sendTabsBtn.Clicked += async (_, _) =>
|
||||||
|
{
|
||||||
|
if (!ApiClient.Instance.IsConfigured)
|
||||||
|
{
|
||||||
|
await DisplayAlert("연결 필요", "먼저 상단에서 PC IP와 연결하세요.", "확인");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var activeTab = _tabManager?.ActiveTab;
|
||||||
|
if (activeTab != null && !string.IsNullOrWhiteSpace(activeTab.Url))
|
||||||
|
{
|
||||||
|
var ok = await ApiClient.Instance.AddAsync(activeTab.Url);
|
||||||
|
await DisplayAlert("동기화", ok ? $"✅ '{activeTab.Title}' 탭이 데스크톱 큐로 전송되었습니다." : "❌ 전송 실패", "확인");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await DisplayAlert("전송 불가", "열려 있는 웹 탭이 없습니다.", "확인");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
actionsStack.Children.Add(sendTabsBtn);
|
||||||
|
|
||||||
|
actionsCard.Content = actionsStack;
|
||||||
|
mainLayout.Children.Add(actionsCard);
|
||||||
|
|
||||||
|
Content = new ScrollView { Content = mainLayout };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
401
src/Paca.Mobile/TabManagerPage.cs
Normal file
|
|
@ -0,0 +1,401 @@
|
||||||
|
using Microsoft.Maui.Controls;
|
||||||
|
using Microsoft.Maui.Controls.Shapes;
|
||||||
|
|
||||||
|
namespace Paca.Mobile;
|
||||||
|
|
||||||
|
/// <summary>멀티 탭 그리드 관리 및 전환 화면 (Obsidian Dark 2열 Bento Grid 스위처)</summary>
|
||||||
|
public sealed class TabManagerPage : ContentPage
|
||||||
|
{
|
||||||
|
private readonly MobileTabManager _tabManager;
|
||||||
|
private readonly FlexLayout _grid = new()
|
||||||
|
{
|
||||||
|
Wrap = Microsoft.Maui.Layouts.FlexWrap.Wrap,
|
||||||
|
JustifyContent = Microsoft.Maui.Layouts.FlexJustify.SpaceBetween,
|
||||||
|
AlignItems = Microsoft.Maui.Layouts.FlexAlignItems.Start,
|
||||||
|
Margin = new Thickness(0, 8)
|
||||||
|
};
|
||||||
|
private readonly Label _titleLabel;
|
||||||
|
private bool _showPrivateOnly = false;
|
||||||
|
private readonly Button _normalTabSegment;
|
||||||
|
private readonly Button _privateTabSegment;
|
||||||
|
|
||||||
|
public TabManagerPage(MobileTabManager tabManager)
|
||||||
|
{
|
||||||
|
_tabManager = tabManager;
|
||||||
|
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 }, // 탭 카드 그리드
|
||||||
|
new RowDefinition { Height = GridLength.Auto } // 하단 액션 바
|
||||||
|
},
|
||||||
|
Padding = new Thickness(16, 12, 16, 16)
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1. 상단 컨트롤 툴바 (Top Studio Header)
|
||||||
|
var toolbar = new Grid
|
||||||
|
{
|
||||||
|
ColumnDefinitions =
|
||||||
|
{
|
||||||
|
new ColumnDefinition { Width = GridLength.Star },
|
||||||
|
new ColumnDefinition { Width = GridLength.Auto },
|
||||||
|
new ColumnDefinition { Width = GridLength.Auto }
|
||||||
|
},
|
||||||
|
ColumnSpacing = 8,
|
||||||
|
Margin = new Thickness(0, 4, 0, 10)
|
||||||
|
};
|
||||||
|
|
||||||
|
_titleLabel = new Label
|
||||||
|
{
|
||||||
|
Text = $"📑 열린 탭 ({_tabManager.Count})",
|
||||||
|
TextColor = Color.FromArgb("#FFFFFF"),
|
||||||
|
FontSize = 18,
|
||||||
|
FontAttributes = FontAttributes.Bold,
|
||||||
|
VerticalOptions = LayoutOptions.Center
|
||||||
|
};
|
||||||
|
toolbar.Add(_titleLabel, 0, 0);
|
||||||
|
|
||||||
|
var closeAllBtn = new Button
|
||||||
|
{
|
||||||
|
Text = "모두 닫기",
|
||||||
|
BackgroundColor = Color.FromArgb("#1E1418"),
|
||||||
|
TextColor = Color.FromArgb("#FF6B6B"),
|
||||||
|
FontSize = 12,
|
||||||
|
FontAttributes = FontAttributes.Bold,
|
||||||
|
CornerRadius = 10,
|
||||||
|
HeightRequest = 36,
|
||||||
|
Padding = new Thickness(12, 0)
|
||||||
|
};
|
||||||
|
closeAllBtn.Clicked += async (_, _) =>
|
||||||
|
{
|
||||||
|
var confirm = await DisplayAlert("탭 모두 닫기", "열려 있는 모든 탭을 닫으시겠습니까?", "모두 닫기", "취소");
|
||||||
|
if (confirm)
|
||||||
|
{
|
||||||
|
_tabManager.CloseAllTabs();
|
||||||
|
await Navigation.PopAsync();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
toolbar.Add(closeAllBtn, 1, 0);
|
||||||
|
|
||||||
|
var doneBtn = new Button
|
||||||
|
{
|
||||||
|
Text = "완료",
|
||||||
|
BackgroundColor = Color.FromArgb("#1C1C2C"),
|
||||||
|
TextColor = Color.FromArgb("#8B7CFF"),
|
||||||
|
FontSize = 12,
|
||||||
|
FontAttributes = FontAttributes.Bold,
|
||||||
|
CornerRadius = 10,
|
||||||
|
HeightRequest = 36,
|
||||||
|
Padding = new Thickness(12, 0)
|
||||||
|
};
|
||||||
|
doneBtn.Clicked += async (_, _) => await Navigation.PopAsync();
|
||||||
|
toolbar.Add(doneBtn, 2, 0);
|
||||||
|
|
||||||
|
mainLayout.Add(toolbar, 0, 0);
|
||||||
|
|
||||||
|
// 2. 세그먼트 스위처 (Segmented Selector)
|
||||||
|
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, 8)
|
||||||
|
};
|
||||||
|
var segmentGrid = new Grid
|
||||||
|
{
|
||||||
|
ColumnDefinitions =
|
||||||
|
{
|
||||||
|
new ColumnDefinition { Width = GridLength.Star },
|
||||||
|
new ColumnDefinition { Width = GridLength.Star }
|
||||||
|
},
|
||||||
|
ColumnSpacing = 4
|
||||||
|
};
|
||||||
|
|
||||||
|
_normalTabSegment = new Button
|
||||||
|
{
|
||||||
|
Text = "🌐 일반 탭",
|
||||||
|
BackgroundColor = Color.FromArgb("#8B7CFF"),
|
||||||
|
TextColor = Colors.White,
|
||||||
|
FontSize = 12,
|
||||||
|
FontAttributes = FontAttributes.Bold,
|
||||||
|
CornerRadius = 10,
|
||||||
|
HeightRequest = 34
|
||||||
|
};
|
||||||
|
_normalTabSegment.Clicked += (_, _) =>
|
||||||
|
{
|
||||||
|
_showPrivateOnly = false;
|
||||||
|
UpdateSegmentStyles();
|
||||||
|
RefreshTabs();
|
||||||
|
};
|
||||||
|
|
||||||
|
_privateTabSegment = new Button
|
||||||
|
{
|
||||||
|
Text = "🕵️ 시크릿 탭",
|
||||||
|
BackgroundColor = Colors.Transparent,
|
||||||
|
TextColor = Color.FromArgb("#94A3B8"),
|
||||||
|
FontSize = 12,
|
||||||
|
FontAttributes = FontAttributes.Bold,
|
||||||
|
CornerRadius = 10,
|
||||||
|
HeightRequest = 34
|
||||||
|
};
|
||||||
|
_privateTabSegment.Clicked += (_, _) =>
|
||||||
|
{
|
||||||
|
_showPrivateOnly = true;
|
||||||
|
UpdateSegmentStyles();
|
||||||
|
RefreshTabs();
|
||||||
|
};
|
||||||
|
|
||||||
|
segmentGrid.Add(_normalTabSegment, 0, 0);
|
||||||
|
segmentGrid.Add(_privateTabSegment, 1, 0);
|
||||||
|
segmentBorder.Content = segmentGrid;
|
||||||
|
mainLayout.Add(segmentBorder, 0, 1);
|
||||||
|
|
||||||
|
// 3. 탭 그리드 스크롤 (Bento Grid Deck)
|
||||||
|
var scroll = new ScrollView { Content = _grid };
|
||||||
|
mainLayout.Add(scroll, 0, 2);
|
||||||
|
|
||||||
|
// 4. 하단 새 탭 액션 바 (Floating Action Bottom Bar)
|
||||||
|
var bottomBar = new Grid
|
||||||
|
{
|
||||||
|
ColumnDefinitions =
|
||||||
|
{
|
||||||
|
new ColumnDefinition { Width = GridLength.Star },
|
||||||
|
new ColumnDefinition { Width = GridLength.Star }
|
||||||
|
},
|
||||||
|
ColumnSpacing = 10,
|
||||||
|
Margin = new Thickness(0, 8, 0, 0)
|
||||||
|
};
|
||||||
|
|
||||||
|
var newTabBtn = new Button
|
||||||
|
{
|
||||||
|
Text = "➕ 새 탭 열기",
|
||||||
|
BackgroundColor = Color.FromArgb("#8B7CFF"),
|
||||||
|
TextColor = Colors.White,
|
||||||
|
FontAttributes = FontAttributes.Bold,
|
||||||
|
FontSize = 14,
|
||||||
|
CornerRadius = 14,
|
||||||
|
HeightRequest = 48
|
||||||
|
};
|
||||||
|
newTabBtn.Clicked += async (_, _) =>
|
||||||
|
{
|
||||||
|
_tabManager.AddTab("paca://newtab", isPrivate: false);
|
||||||
|
await Navigation.PopAsync();
|
||||||
|
};
|
||||||
|
bottomBar.Add(newTabBtn, 0, 0);
|
||||||
|
|
||||||
|
var newSecretTabBtn = new Button
|
||||||
|
{
|
||||||
|
Text = "🕵️ 새 시크릿 탭",
|
||||||
|
BackgroundColor = Color.FromArgb("#141420"),
|
||||||
|
TextColor = Color.FromArgb("#A78BFA"),
|
||||||
|
FontAttributes = FontAttributes.Bold,
|
||||||
|
FontSize = 14,
|
||||||
|
CornerRadius = 14,
|
||||||
|
HeightRequest = 48,
|
||||||
|
BorderColor = Color.FromArgb("#8B7CFF"),
|
||||||
|
BorderWidth = 1.5
|
||||||
|
};
|
||||||
|
newSecretTabBtn.Clicked += async (_, _) =>
|
||||||
|
{
|
||||||
|
_tabManager.AddTab("paca://newtab", isPrivate: true);
|
||||||
|
await Navigation.PopAsync();
|
||||||
|
};
|
||||||
|
bottomBar.Add(newSecretTabBtn, 1, 0);
|
||||||
|
|
||||||
|
mainLayout.Add(bottomBar, 0, 3);
|
||||||
|
|
||||||
|
Content = mainLayout;
|
||||||
|
RefreshTabs();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateSegmentStyles()
|
||||||
|
{
|
||||||
|
if (!_showPrivateOnly)
|
||||||
|
{
|
||||||
|
_normalTabSegment.BackgroundColor = Color.FromArgb("#8B7CFF");
|
||||||
|
_normalTabSegment.TextColor = Colors.White;
|
||||||
|
_privateTabSegment.BackgroundColor = Colors.Transparent;
|
||||||
|
_privateTabSegment.TextColor = Color.FromArgb("#94A3B8");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_normalTabSegment.BackgroundColor = Colors.Transparent;
|
||||||
|
_normalTabSegment.TextColor = Color.FromArgb("#94A3B8");
|
||||||
|
_privateTabSegment.BackgroundColor = Color.FromArgb("#8B7CFF");
|
||||||
|
_privateTabSegment.TextColor = Colors.White;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RefreshTabs()
|
||||||
|
{
|
||||||
|
_grid.Children.Clear();
|
||||||
|
_titleLabel.Text = $"📑 열린 탭 ({_tabManager.Count})";
|
||||||
|
var allTabs = _tabManager.Tabs;
|
||||||
|
var tabs = _showPrivateOnly ? allTabs.Where(t => t.IsPrivate).ToList() : allTabs.Where(t => !t.IsPrivate).ToList();
|
||||||
|
var activeTab = _tabManager.ActiveTab;
|
||||||
|
|
||||||
|
if (tabs.Count == 0)
|
||||||
|
{
|
||||||
|
var emptyStack = new VerticalStackLayout
|
||||||
|
{
|
||||||
|
HorizontalOptions = LayoutOptions.Center,
|
||||||
|
Spacing = 10,
|
||||||
|
Margin = new Thickness(0, 60, 0, 0)
|
||||||
|
};
|
||||||
|
emptyStack.Children.Add(new Label
|
||||||
|
{
|
||||||
|
Text = _showPrivateOnly ? "🕵️" : "📑",
|
||||||
|
FontSize = 40,
|
||||||
|
HorizontalTextAlignment = TextAlignment.Center
|
||||||
|
});
|
||||||
|
emptyStack.Children.Add(new Label
|
||||||
|
{
|
||||||
|
Text = _showPrivateOnly ? "열려 있는 시크릿 탭이 없습니다." : "열려 있는 일반 탭이 없습니다.",
|
||||||
|
TextColor = Color.FromArgb("#94A3B8"),
|
||||||
|
FontSize = 14,
|
||||||
|
HorizontalTextAlignment = TextAlignment.Center
|
||||||
|
});
|
||||||
|
_grid.Children.Add(emptyStack);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var tab in tabs)
|
||||||
|
{
|
||||||
|
var isActive = activeTab?.Id == tab.Id;
|
||||||
|
var card = new Border
|
||||||
|
{
|
||||||
|
BackgroundColor = Color.FromArgb("#12121C"),
|
||||||
|
Stroke = isActive ? Color.FromArgb("#8B7CFF") : Color.FromArgb("#242436"),
|
||||||
|
StrokeThickness = isActive ? 2 : 1,
|
||||||
|
StrokeShape = new RoundRectangle { CornerRadius = 16 },
|
||||||
|
Padding = new Thickness(12),
|
||||||
|
Margin = new Thickness(0, 0, 0, 12),
|
||||||
|
WidthRequest = 162,
|
||||||
|
HeightRequest = 168
|
||||||
|
};
|
||||||
|
|
||||||
|
var cardGrid = new Grid
|
||||||
|
{
|
||||||
|
RowDefinitions =
|
||||||
|
{
|
||||||
|
new RowDefinition { Height = GridLength.Auto }, // 상단 헤더
|
||||||
|
new RowDefinition { Height = GridLength.Star }, // 프리뷰 영역
|
||||||
|
new RowDefinition { Height = GridLength.Auto } // 하단 뱃지
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 상단: 뱃지 + 닫기 버튼
|
||||||
|
var headerGrid = new Grid
|
||||||
|
{
|
||||||
|
ColumnDefinitions =
|
||||||
|
{
|
||||||
|
new ColumnDefinition { Width = GridLength.Star },
|
||||||
|
new ColumnDefinition { Width = GridLength.Auto }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var badge = new Label
|
||||||
|
{
|
||||||
|
Text = tab.IsPrivate ? "🕵️ 시크릿" : (isActive ? "🟢 활성" : "🌐 웹"),
|
||||||
|
TextColor = tab.IsPrivate ? Color.FromArgb("#A78BFA") : (isActive ? Color.FromArgb("#34D399") : Color.FromArgb("#64748B")),
|
||||||
|
FontSize = 10,
|
||||||
|
FontAttributes = FontAttributes.Bold,
|
||||||
|
VerticalOptions = LayoutOptions.Center
|
||||||
|
};
|
||||||
|
headerGrid.Add(badge, 0, 0);
|
||||||
|
|
||||||
|
var closeBtn = new Button
|
||||||
|
{
|
||||||
|
Text = "✕",
|
||||||
|
BackgroundColor = Colors.Transparent,
|
||||||
|
TextColor = Color.FromArgb("#94A3B8"),
|
||||||
|
FontSize = 12,
|
||||||
|
FontAttributes = FontAttributes.Bold,
|
||||||
|
WidthRequest = 32,
|
||||||
|
HeightRequest = 32,
|
||||||
|
Padding = 0
|
||||||
|
};
|
||||||
|
closeBtn.Clicked += (_, _) =>
|
||||||
|
{
|
||||||
|
_tabManager.CloseTab(tab.Id);
|
||||||
|
RefreshTabs();
|
||||||
|
};
|
||||||
|
headerGrid.Add(closeBtn, 1, 0);
|
||||||
|
cardGrid.Add(headerGrid, 0, 0);
|
||||||
|
|
||||||
|
// 중앙: 타이틀 및 URL 프리뷰
|
||||||
|
var centerStack = new VerticalStackLayout
|
||||||
|
{
|
||||||
|
VerticalOptions = LayoutOptions.Center,
|
||||||
|
Spacing = 4,
|
||||||
|
Padding = new Thickness(0, 4)
|
||||||
|
};
|
||||||
|
|
||||||
|
var titleLabel = new Label
|
||||||
|
{
|
||||||
|
Text = string.IsNullOrWhiteSpace(tab.Title) ? "새 탭" : tab.Title,
|
||||||
|
TextColor = Color.FromArgb("#FFFFFF"),
|
||||||
|
FontSize = 13,
|
||||||
|
FontAttributes = FontAttributes.Bold,
|
||||||
|
LineBreakMode = LineBreakMode.TailTruncation,
|
||||||
|
MaxLines = 2
|
||||||
|
};
|
||||||
|
centerStack.Children.Add(titleLabel);
|
||||||
|
|
||||||
|
var urlLabel = new Label
|
||||||
|
{
|
||||||
|
Text = tab.Url,
|
||||||
|
TextColor = Color.FromArgb("#94A3B8"),
|
||||||
|
FontSize = 10,
|
||||||
|
LineBreakMode = LineBreakMode.TailTruncation,
|
||||||
|
MaxLines = 1
|
||||||
|
};
|
||||||
|
centerStack.Children.Add(urlLabel);
|
||||||
|
cardGrid.Add(centerStack, 0, 1);
|
||||||
|
|
||||||
|
// 하단: 감지된 영상 수 / 도메인 태그
|
||||||
|
if (tab.DetectedVideos.Count > 0)
|
||||||
|
{
|
||||||
|
var videoBadge = new Label
|
||||||
|
{
|
||||||
|
Text = $"⚡ {tab.DetectedVideos.Count}개 미디어 감지",
|
||||||
|
TextColor = Color.FromArgb("#34D399"),
|
||||||
|
FontSize = 10,
|
||||||
|
FontAttributes = FontAttributes.Bold
|
||||||
|
};
|
||||||
|
cardGrid.Add(videoBadge, 0, 2);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var domainBadge = new Label
|
||||||
|
{
|
||||||
|
Text = tab.Url.StartsWith("https://", StringComparison.OrdinalIgnoreCase) ? "🔒 HTTPS 보안" : "🌐 웹 페이지",
|
||||||
|
TextColor = Color.FromArgb("#64748B"),
|
||||||
|
FontSize = 9
|
||||||
|
};
|
||||||
|
cardGrid.Add(domainBadge, 0, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
card.Content = cardGrid;
|
||||||
|
|
||||||
|
// 탭 카드 클릭 시 해당 탭으로 전환
|
||||||
|
var tapGesture = new TapGestureRecognizer();
|
||||||
|
tapGesture.Tapped += async (_, _) =>
|
||||||
|
{
|
||||||
|
_tabManager.SwitchTab(tab.Id);
|
||||||
|
await Navigation.PopAsync();
|
||||||
|
};
|
||||||
|
card.GestureRecognizers.Add(tapGesture);
|
||||||
|
|
||||||
|
_grid.Children.Add(card);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
38
src/Paca.Mobile/TabbedRootPage.cs
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
namespace Paca.Mobile;
|
||||||
|
|
||||||
|
/// <summary>컴팠니언 루트: 큐 / 라이브러리 / 저장 / 연결 탭 + 공유 타겟 처리.</summary>
|
||||||
|
public sealed class TabbedRootPage : TabbedPage
|
||||||
|
{
|
||||||
|
public TabbedRootPage()
|
||||||
|
{
|
||||||
|
ApiClient.Instance.Restore();
|
||||||
|
var queuePage = new QueuePage();
|
||||||
|
Children.Add(queuePage);
|
||||||
|
Children.Add(new DiscoverPage());
|
||||||
|
Children.Add(new BrowserPage());
|
||||||
|
Children.Add(new FavoritesPage());
|
||||||
|
Children.Add(new SyncSettingsPage());
|
||||||
|
// 레거시 컴패니언 탭 호환 유지 (LibraryPage, DownloadsPage, ConnectPage)
|
||||||
|
Children.Add(new LibraryPage());
|
||||||
|
Children.Add(new DownloadsPage());
|
||||||
|
Children.Add(new ConnectPage());
|
||||||
|
Title = "Paca Mobile";
|
||||||
|
|
||||||
|
// M1 공유 타겟(U1): 어떤 탭에 있든/다른 앱에서 공유받으면 URL 을 PC 큐로 전송 후 큐 탭 표시
|
||||||
|
Device.StartTimer(TimeSpan.FromSeconds(2), () =>
|
||||||
|
{
|
||||||
|
_ = HandleShareAsync(queuePage);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task HandleShareAsync(QueuePage queuePage)
|
||||||
|
{
|
||||||
|
var shared = ShareTarget.Consume();
|
||||||
|
if (shared == null || !ApiClient.Instance.IsConfigured) return;
|
||||||
|
var url = SharePayload.ExtractUrl(shared);
|
||||||
|
if (url == null) { queuePage.ShowSharedResult("🔗 공유받은 텍스트에 URL 이 없습니다"); return; }
|
||||||
|
var ok = await ApiClient.Instance.AddAsync(url);
|
||||||
|
queuePage.ShowSharedResult(ok ? $"🔗 공유 링크 전송됨: {url}" : "❌ 공유 링크 전송 실패");
|
||||||
|
}
|
||||||
|
}
|
||||||
196
src/Paca.Mobile/VideoSnifferModal.cs
Normal file
|
|
@ -0,0 +1,196 @@
|
||||||
|
using Microsoft.Maui.Controls;
|
||||||
|
using Microsoft.Maui.Controls.Shapes;
|
||||||
|
|
||||||
|
namespace Paca.Mobile;
|
||||||
|
|
||||||
|
/// <summary>감지된 비디오 목록 표시 및 다운로드 선택 바텀시트 모달 (Obsidian Dark Luxury Design)</summary>
|
||||||
|
public sealed class VideoSnifferModal : ContentPage
|
||||||
|
{
|
||||||
|
public VideoSnifferModal(IReadOnlyList<VideoSniffItem> videos, Action<VideoSniffItem, bool> onDownloadSelected)
|
||||||
|
{
|
||||||
|
NavigationPage.SetHasNavigationBar(this, false);
|
||||||
|
BackgroundColor = Color.FromArgb("#08080C");
|
||||||
|
|
||||||
|
var layout = new VerticalStackLayout
|
||||||
|
{
|
||||||
|
Padding = new Thickness(16, 12, 16, 20),
|
||||||
|
Spacing = 14
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1. 바텀시트 상단 드래그 필 핸들
|
||||||
|
var dragHandle = new Border
|
||||||
|
{
|
||||||
|
WidthRequest = 44,
|
||||||
|
HeightRequest = 5,
|
||||||
|
BackgroundColor = Color.FromArgb("#3A3A52"),
|
||||||
|
StrokeThickness = 0,
|
||||||
|
StrokeShape = new RoundRectangle { CornerRadius = 2.5 },
|
||||||
|
HorizontalOptions = LayoutOptions.Center,
|
||||||
|
Margin = new Thickness(0, 4, 0, 8)
|
||||||
|
};
|
||||||
|
layout.Children.Add(dragHandle);
|
||||||
|
|
||||||
|
// 2. 헤더
|
||||||
|
var headerRow = new Grid
|
||||||
|
{
|
||||||
|
ColumnDefinitions =
|
||||||
|
{
|
||||||
|
new ColumnDefinition { Width = GridLength.Star },
|
||||||
|
new ColumnDefinition { Width = GridLength.Auto }
|
||||||
|
},
|
||||||
|
Margin = new Thickness(4, 0, 4, 4)
|
||||||
|
};
|
||||||
|
|
||||||
|
var headerTitle = new Label
|
||||||
|
{
|
||||||
|
Text = $"⚡ 감지된 미디어 ({videos.Count}건)",
|
||||||
|
TextColor = Color.FromArgb("#FFFFFF"),
|
||||||
|
FontSize = 18,
|
||||||
|
FontAttributes = FontAttributes.Bold,
|
||||||
|
VerticalOptions = LayoutOptions.Center
|
||||||
|
};
|
||||||
|
headerRow.Add(headerTitle, 0, 0);
|
||||||
|
|
||||||
|
var closeXBtn = new Button
|
||||||
|
{
|
||||||
|
Text = "✕",
|
||||||
|
TextColor = Color.FromArgb("#94A3B8"),
|
||||||
|
BackgroundColor = Color.FromArgb("#161622"),
|
||||||
|
FontSize = 12,
|
||||||
|
FontAttributes = FontAttributes.Bold,
|
||||||
|
WidthRequest = 32,
|
||||||
|
HeightRequest = 32,
|
||||||
|
CornerRadius = 16,
|
||||||
|
Padding = 0
|
||||||
|
};
|
||||||
|
closeXBtn.Clicked += async (_, _) => await Navigation.PopModalAsync();
|
||||||
|
headerRow.Add(closeXBtn, 1, 0);
|
||||||
|
|
||||||
|
layout.Children.Add(headerRow);
|
||||||
|
|
||||||
|
var scroll = new ScrollView
|
||||||
|
{
|
||||||
|
Content = new VerticalStackLayout { Spacing = 12 }
|
||||||
|
};
|
||||||
|
var listLayout = (VerticalStackLayout)scroll.Content;
|
||||||
|
|
||||||
|
foreach (var video in videos)
|
||||||
|
{
|
||||||
|
var card = new Border
|
||||||
|
{
|
||||||
|
BackgroundColor = Color.FromArgb("#12121C"),
|
||||||
|
Stroke = Color.FromArgb("#26263A"),
|
||||||
|
StrokeThickness = 1,
|
||||||
|
StrokeShape = new RoundRectangle { CornerRadius = 16 },
|
||||||
|
Padding = new Thickness(14),
|
||||||
|
Margin = new Thickness(0, 2)
|
||||||
|
};
|
||||||
|
|
||||||
|
var cardStack = new VerticalStackLayout { Spacing = 8 };
|
||||||
|
|
||||||
|
var typeBadgeText = video.VideoType switch
|
||||||
|
{
|
||||||
|
VideoStreamType.Hls => "🟢 HLS 스트림 (.m3u8) · 마스터",
|
||||||
|
VideoStreamType.Mp4 => "🔵 MP4 직접 비디오",
|
||||||
|
VideoStreamType.Dash => "🟠 DASH 적응형 스트림 (.mpd)",
|
||||||
|
VideoStreamType.Blob => "🟣 Blob 미디어",
|
||||||
|
_ => "⚪ 비디오 스트림"
|
||||||
|
};
|
||||||
|
|
||||||
|
var badgeBorder = new Border
|
||||||
|
{
|
||||||
|
BackgroundColor = Color.FromArgb("#182A24"),
|
||||||
|
Stroke = Color.FromArgb("#34D399"),
|
||||||
|
StrokeThickness = 1,
|
||||||
|
StrokeShape = new RoundRectangle { CornerRadius = 8 },
|
||||||
|
Padding = new Thickness(8, 3),
|
||||||
|
HorizontalOptions = LayoutOptions.Start,
|
||||||
|
Content = new Label
|
||||||
|
{
|
||||||
|
Text = typeBadgeText,
|
||||||
|
TextColor = Color.FromArgb("#34D399"),
|
||||||
|
FontSize = 11,
|
||||||
|
FontAttributes = FontAttributes.Bold
|
||||||
|
}
|
||||||
|
};
|
||||||
|
cardStack.Children.Add(badgeBorder);
|
||||||
|
|
||||||
|
var titleLabel = new Label
|
||||||
|
{
|
||||||
|
Text = string.IsNullOrWhiteSpace(video.Title) ? "동영상 미디어" : video.Title,
|
||||||
|
TextColor = Color.FromArgb("#FFFFFF"),
|
||||||
|
FontSize = 14,
|
||||||
|
FontAttributes = FontAttributes.Bold,
|
||||||
|
LineBreakMode = LineBreakMode.TailTruncation,
|
||||||
|
MaxLines = 2
|
||||||
|
};
|
||||||
|
cardStack.Children.Add(titleLabel);
|
||||||
|
|
||||||
|
var urlLabel = new Label
|
||||||
|
{
|
||||||
|
Text = video.StreamUrl,
|
||||||
|
TextColor = Color.FromArgb("#94A3B8"),
|
||||||
|
FontSize = 11,
|
||||||
|
LineBreakMode = LineBreakMode.MiddleTruncation,
|
||||||
|
MaxLines = 1
|
||||||
|
};
|
||||||
|
cardStack.Children.Add(urlLabel);
|
||||||
|
|
||||||
|
// 액션 버튼 (모바일 다운로드 / PC 전송)
|
||||||
|
var btnRow = new Grid
|
||||||
|
{
|
||||||
|
ColumnDefinitions =
|
||||||
|
{
|
||||||
|
new ColumnDefinition { Width = GridLength.Star },
|
||||||
|
new ColumnDefinition { Width = GridLength.Star }
|
||||||
|
},
|
||||||
|
ColumnSpacing = 10,
|
||||||
|
Margin = new Thickness(0, 6, 0, 0)
|
||||||
|
};
|
||||||
|
|
||||||
|
var mobileDownloadBtn = new Button
|
||||||
|
{
|
||||||
|
Text = "📱 기기에 다운로드",
|
||||||
|
BackgroundColor = Color.FromArgb("#8B7CFF"),
|
||||||
|
TextColor = Colors.White,
|
||||||
|
FontAttributes = FontAttributes.Bold,
|
||||||
|
FontSize = 12,
|
||||||
|
CornerRadius = 12,
|
||||||
|
HeightRequest = 42
|
||||||
|
};
|
||||||
|
mobileDownloadBtn.Clicked += async (_, _) =>
|
||||||
|
{
|
||||||
|
await Navigation.PopModalAsync();
|
||||||
|
onDownloadSelected(video, false); // false = 모바일 로컬 다운로드
|
||||||
|
};
|
||||||
|
btnRow.Add(mobileDownloadBtn, 0, 0);
|
||||||
|
|
||||||
|
var pcSendBtn = new Button
|
||||||
|
{
|
||||||
|
Text = "💻 데스크톱 전송",
|
||||||
|
BackgroundColor = Color.FromArgb("#181826"),
|
||||||
|
TextColor = Color.FromArgb("#FFFFFF"),
|
||||||
|
BorderColor = Color.FromArgb("#2C2C40"),
|
||||||
|
BorderWidth = 1,
|
||||||
|
FontSize = 12,
|
||||||
|
FontAttributes = FontAttributes.Bold,
|
||||||
|
CornerRadius = 12,
|
||||||
|
HeightRequest = 42
|
||||||
|
};
|
||||||
|
pcSendBtn.Clicked += async (_, _) =>
|
||||||
|
{
|
||||||
|
await Navigation.PopModalAsync();
|
||||||
|
onDownloadSelected(video, true); // true = PC 큐로 전송
|
||||||
|
};
|
||||||
|
btnRow.Add(pcSendBtn, 1, 0);
|
||||||
|
|
||||||
|
cardStack.Children.Add(btnRow);
|
||||||
|
card.Content = cardStack;
|
||||||
|
listLayout.Children.Add(card);
|
||||||
|
}
|
||||||
|
|
||||||
|
layout.Children.Add(scroll);
|
||||||
|
Content = layout;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
107
tests/Paca.Tests/Mobile/MobileAdvancedConnectivityGateTests.cs
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Paca.Mobile.Core;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Paca.Tests.Mobile;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 로컬 기기 디스커버리, 원격 클립보드, 절전/데이터 가드 및 복원성 게이트 (Gate 109 ~ Gate 114)
|
||||||
|
/// </summary>
|
||||||
|
public class MobileAdvancedConnectivityGateTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Gate109_PacaHostBeacon_Discovery_Audit()
|
||||||
|
{
|
||||||
|
var beacon = new PacaHostBeacon();
|
||||||
|
var beaconPayload = "PACA_BEACON|DESKTOP-PACA|192.168.1.100:52100|token123";
|
||||||
|
var host = beacon.ParseBeacon(beaconPayload);
|
||||||
|
|
||||||
|
Assert.NotNull(host);
|
||||||
|
Assert.Equal("DESKTOP-PACA", host.HostName);
|
||||||
|
Assert.Equal("192.168.1.100:52100", host.Endpoint);
|
||||||
|
Assert.Equal("token123", host.AuthToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Gate110_CrossDeviceClipboardSync_Audit()
|
||||||
|
{
|
||||||
|
var clipSync = new CrossDeviceClipboardSync();
|
||||||
|
string? receivedUrl = null;
|
||||||
|
clipSync.OnMediaUrlReceived += url => receivedUrl = url;
|
||||||
|
|
||||||
|
// PC로부터 수신된 클립보드 패킷 처리
|
||||||
|
clipSync.ProcessIncomingPayload("https://twitter.com/test/status/123456");
|
||||||
|
Assert.Equal("https://twitter.com/test/status/123456", receivedUrl);
|
||||||
|
|
||||||
|
// 비미디어 텍스트는 필터링되어 무시되어야 함
|
||||||
|
receivedUrl = null;
|
||||||
|
clipSync.ProcessIncomingPayload("Hello this is not a media url");
|
||||||
|
Assert.Null(receivedUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Gate111_MobilePowerSaverPolicy_Audit()
|
||||||
|
{
|
||||||
|
var policy = new MobilePowerSaverPolicy();
|
||||||
|
// 기본 6 세그먼트
|
||||||
|
Assert.Equal(6, policy.GetRecommendedSegments(batteryLevelPercent: 80, isCharging: false));
|
||||||
|
|
||||||
|
// 배터리 15% 이하 저전력 모드 -> 2개로 제한
|
||||||
|
Assert.Equal(2, policy.GetRecommendedSegments(batteryLevelPercent: 12, isCharging: false));
|
||||||
|
|
||||||
|
// 충전 중일 때는 100% 터보 허용
|
||||||
|
Assert.Equal(16, policy.GetRecommendedSegments(batteryLevelPercent: 12, isCharging: true));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Gate112_MobileDataSaverGuard_Audit()
|
||||||
|
{
|
||||||
|
var dataGuard = new MobileDataSaverGuard();
|
||||||
|
// 100MB 기본 임계값
|
||||||
|
Assert.Equal(100_000_000L, dataGuard.CellularWarningThresholdBytes);
|
||||||
|
|
||||||
|
// Wi-Fi 상태에서는 대용량도 즉시 허용
|
||||||
|
Assert.True(dataGuard.CanDownload(fileSizeBytes: 500_000_000, isWifi: true));
|
||||||
|
|
||||||
|
// 셀룰러 상태에서 500MB는 경고 플래그
|
||||||
|
Assert.False(dataGuard.CanDownload(fileSizeBytes: 500_000_000, isWifi: false));
|
||||||
|
|
||||||
|
// 셀룰러 상태에서 50MB는 허용
|
||||||
|
Assert.True(dataGuard.CanDownload(fileSizeBytes: 50_000_000, isWifi: false));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Gate113_MobileThumbnailCacheManager_Audit()
|
||||||
|
{
|
||||||
|
var cache = new MobileThumbnailCacheManager();
|
||||||
|
Assert.Null(cache.Get("test-key"));
|
||||||
|
|
||||||
|
cache.Put("test-key", new byte[] { 0xFF, 0xD8, 0xFF });
|
||||||
|
var cached = cache.Get("test-key");
|
||||||
|
Assert.NotNull(cached);
|
||||||
|
Assert.Equal(3, cached.Length);
|
||||||
|
|
||||||
|
// 캐시 삭제
|
||||||
|
cache.Evict("test-key");
|
||||||
|
Assert.Null(cache.Get("test-key"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Gate114_NetworkFlapping_Stress_Resilience_Audit()
|
||||||
|
{
|
||||||
|
var qm = new MobileQueueManager();
|
||||||
|
var guard = new MobileNetworkGuard(qm);
|
||||||
|
var item = qm.Enqueue("https://example.com/live.m3u8", "Live Stream");
|
||||||
|
item.Status = MobileQueueStatus.Downloading;
|
||||||
|
|
||||||
|
// 10회 연속 단절 및 재연결 플래핑 스트레스
|
||||||
|
for (int i = 0; i < 10; i++)
|
||||||
|
{
|
||||||
|
guard.OnNetworkLost();
|
||||||
|
Assert.Equal(MobileQueueStatus.PausedNetwork, item.Status);
|
||||||
|
guard.OnNetworkRestored(isWifi: (i % 2 == 0));
|
||||||
|
Assert.Equal(MobileQueueStatus.Downloading, item.Status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
135
tests/Paca.Tests/Mobile/MobileAdvancedNativeGateTests.cs
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Paca.Mobile.Core;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Paca.Tests.Mobile;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 모바일 네이티브 컨트롤, 햅틱, 타이포 스케일러, 볼트, 오리엔테이션 게이트 (Gate 115 ~ Gate 122)
|
||||||
|
/// </summary>
|
||||||
|
public class MobileAdvancedNativeGateTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Gate115_MobileTypographyScaler_Audit()
|
||||||
|
{
|
||||||
|
var scaler = new MobileTypographyScaler();
|
||||||
|
// 기본 1.0x 스케일
|
||||||
|
Assert.Equal(14.0, scaler.GetScaledFontSize(baseFontSize: 14.0, textScaleFactor: 1.0));
|
||||||
|
|
||||||
|
// 1.5x 접근성 큰 글꼴
|
||||||
|
Assert.Equal(21.0, scaler.GetScaledFontSize(baseFontSize: 14.0, textScaleFactor: 1.5));
|
||||||
|
|
||||||
|
// 최대 상한선(2.0x 초과 방지 클램핑)
|
||||||
|
Assert.Equal(28.0, scaler.GetScaledFontSize(baseFontSize: 14.0, textScaleFactor: 2.5));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Gate116_MobileOrientationSensor_Landscape_Audit()
|
||||||
|
{
|
||||||
|
var sensor = new MobileOrientationSensor();
|
||||||
|
Assert.Equal(MobileScreenOrientation.Portrait, sensor.CurrentOrientation);
|
||||||
|
Assert.False(sensor.IsFullscreenVideo);
|
||||||
|
|
||||||
|
// 가로 회전 감지
|
||||||
|
sensor.OnOrientationChanged(MobileScreenOrientation.Landscape);
|
||||||
|
Assert.Equal(MobileScreenOrientation.Landscape, sensor.CurrentOrientation);
|
||||||
|
Assert.True(sensor.IsFullscreenVideo);
|
||||||
|
|
||||||
|
// 컨트롤 자동 숨김 타이머 (3초)
|
||||||
|
Assert.Equal(3000, sensor.ControlsAutoHideTimeoutMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Gate117_MobileHapticFeedback_Audit()
|
||||||
|
{
|
||||||
|
var haptic = new MobileHapticFeedback();
|
||||||
|
HapticFeedbackType? triggered = null;
|
||||||
|
haptic.OnHapticTriggered += type => triggered = type;
|
||||||
|
|
||||||
|
haptic.NotifySuccess();
|
||||||
|
Assert.Equal(HapticFeedbackType.Success, triggered);
|
||||||
|
|
||||||
|
haptic.NotifyError();
|
||||||
|
Assert.Equal(HapticFeedbackType.Error, triggered);
|
||||||
|
|
||||||
|
haptic.NotifySelectionChange();
|
||||||
|
Assert.Equal(HapticFeedbackType.LightClick, triggered);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Gate118_MobileQueueReorderEngine_Audit()
|
||||||
|
{
|
||||||
|
var qm = new MobileQueueManager();
|
||||||
|
var item1 = qm.Enqueue("https://example.com/1.mp4", "Video 1");
|
||||||
|
var item2 = qm.Enqueue("https://example.com/2.mp4", "Video 2");
|
||||||
|
var item3 = qm.Enqueue("https://example.com/3.mp4", "Video 3");
|
||||||
|
|
||||||
|
var reorder = new MobileQueueReorderEngine(qm);
|
||||||
|
// item3을 맨 앞으로 이동 (index 2 -> 0)
|
||||||
|
reorder.MoveItem(fromIndex: 2, toIndex: 0);
|
||||||
|
|
||||||
|
Assert.Equal(item3.Id, qm.Items[0].Id);
|
||||||
|
Assert.Equal(item1.Id, qm.Items[1].Id);
|
||||||
|
Assert.Equal(item2.Id, qm.Items[2].Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Gate119_MobileBackgroundWorker_Notification_Audit()
|
||||||
|
{
|
||||||
|
var worker = new MobileBackgroundWorker();
|
||||||
|
Assert.False(worker.IsForegroundServiceRunning);
|
||||||
|
|
||||||
|
worker.StartService("Paca 다운로드 진행 중", totalActiveDownloads: 2);
|
||||||
|
Assert.True(worker.IsForegroundServiceRunning);
|
||||||
|
Assert.Equal("Paca 다운로드 진행 중 (2개 활성)", worker.LastNotificationTitle);
|
||||||
|
|
||||||
|
worker.StopService();
|
||||||
|
Assert.False(worker.IsForegroundServiceRunning);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Gate120_MobileMediaVault_Encryption_Audit()
|
||||||
|
{
|
||||||
|
var vault = new MobileMediaVault();
|
||||||
|
var plainBytes = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 };
|
||||||
|
var password = "SafePassword123!";
|
||||||
|
|
||||||
|
// 암호화
|
||||||
|
var encrypted = vault.EncryptMedia(plainBytes, password);
|
||||||
|
Assert.NotEqual(plainBytes, encrypted);
|
||||||
|
|
||||||
|
// 복호화
|
||||||
|
var decrypted = vault.DecryptMedia(encrypted, password);
|
||||||
|
Assert.Equal(plainBytes, decrypted);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Gate121_MobileAdBlockEngine_EasyList_Audit()
|
||||||
|
{
|
||||||
|
var adBlock = new MobileAdBlockEngine();
|
||||||
|
Assert.True(adBlock.IsAdUrl("https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js"));
|
||||||
|
Assert.True(adBlock.IsAdUrl("https://adservice.google.com/adsid/google/ui"));
|
||||||
|
Assert.True(adBlock.IsAdUrl("https://securepubads.g.doubleclick.net/gampad/ads"));
|
||||||
|
|
||||||
|
// 정상 미디어 URL은 허용
|
||||||
|
Assert.False(adBlock.IsAdUrl("https://www.youtube.com/watch?v=dQw4w9WgXcQ"));
|
||||||
|
Assert.False(adBlock.IsAdUrl("https://vimeo.com/12345678"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Gate122_MobileResolutionPicker_Estimation_Audit()
|
||||||
|
{
|
||||||
|
var picker = new MobileResolutionPicker();
|
||||||
|
// 1080p 60fps 비디오 10분(600초) 예상 용량 계산
|
||||||
|
var estimate1080p = picker.EstimateSizeAndDownloadTime(
|
||||||
|
durationSec: 600,
|
||||||
|
resolution: "1080p",
|
||||||
|
currentNetworkSpeedKbps: 10240 // 10MB/s (80Mbps)
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.True(estimate1080p.EstimatedSizeBytes > 0);
|
||||||
|
Assert.True(estimate1080p.EstimatedDurationSec > 0);
|
||||||
|
Assert.Equal("1080p Full HD", estimate1080p.ResolutionLabel);
|
||||||
|
}
|
||||||
|
}
|
||||||
237
tests/Paca.Tests/Mobile/MobileBrowserCoreTests.cs
Normal file
|
|
@ -0,0 +1,237 @@
|
||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Paca.Mobile;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Paca.Tests.Mobile;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PACA Mobile Browser Core 단위 및 도메인 스펙 검증 스위트
|
||||||
|
/// - 탭 관리자 (생성, 전환, 닫기, 시크릿 모드, 세션 영속화)
|
||||||
|
/// - 로컬 방문 기록 (추가, 검색, 삭제, 원자적 저장)
|
||||||
|
/// - 북마크 관리자 (추가, 중복 갱신, 삭제, 조회)
|
||||||
|
/// - 비디오 스니퍼 (M3U8/MP4/DASH/Blob 네트워크 및 DOM JSON 파서)
|
||||||
|
/// - 인앱 다운로드 엔진 (작업 큐, 상태 머신)
|
||||||
|
/// </summary>
|
||||||
|
public class MobileBrowserCoreTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _testTempDir;
|
||||||
|
|
||||||
|
public MobileBrowserCoreTests()
|
||||||
|
{
|
||||||
|
_testTempDir = Path.Combine(Path.GetTempPath(), "paca_mobile_tests_" + Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(_testTempDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (Directory.Exists(_testTempDir))
|
||||||
|
Directory.Delete(_testTempDir, recursive: true);
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 1. 모바일 탭 관리자 (MobileTabManager)
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TabManager_InitialState_HasSingleNewTab()
|
||||||
|
{
|
||||||
|
var manager = new MobileTabManager();
|
||||||
|
Assert.Equal(1, manager.Count);
|
||||||
|
Assert.NotNull(manager.ActiveTab);
|
||||||
|
Assert.True(manager.ActiveTab!.IsNewTabPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TabManager_AddAndSwitchTab_UpdatesActiveTabCorrectly()
|
||||||
|
{
|
||||||
|
var manager = new MobileTabManager();
|
||||||
|
var tab2 = manager.AddTab("https://m.youtube.com", isPrivate: false);
|
||||||
|
|
||||||
|
Assert.Equal(2, manager.Count);
|
||||||
|
Assert.Equal(tab2.Id, manager.ActiveTab?.Id);
|
||||||
|
Assert.Equal("https://m.youtube.com", manager.ActiveTab?.Url);
|
||||||
|
|
||||||
|
var firstTabId = manager.Tabs[0].Id;
|
||||||
|
var switched = manager.SwitchTab(firstTabId);
|
||||||
|
|
||||||
|
Assert.True(switched);
|
||||||
|
Assert.Equal(firstTabId, manager.ActiveTab?.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TabManager_CloseTab_MaintainsValidActiveTab()
|
||||||
|
{
|
||||||
|
var manager = new MobileTabManager();
|
||||||
|
var tab2 = manager.AddTab("https://site2.com");
|
||||||
|
var tab3 = manager.AddTab("https://site3.com");
|
||||||
|
|
||||||
|
Assert.Equal(3, manager.Count);
|
||||||
|
Assert.Equal(tab3.Id, manager.ActiveTab?.Id);
|
||||||
|
|
||||||
|
// 마지막 탭 닫기 -> 바로 이전 탭(tab2)이 활성화되어야 함
|
||||||
|
manager.CloseTab(tab3.Id);
|
||||||
|
Assert.Equal(2, manager.Count);
|
||||||
|
Assert.Equal(tab2.Id, manager.ActiveTab?.Id);
|
||||||
|
|
||||||
|
// 모든 탭 닫기 -> 자동으로 새 탭 1개 생성
|
||||||
|
manager.CloseAllTabs();
|
||||||
|
Assert.Equal(1, manager.Count);
|
||||||
|
Assert.True(manager.ActiveTab?.IsNewTabPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TabManager_SessionPersistence_IgnoresPrivateTabsAndRestoresState()
|
||||||
|
{
|
||||||
|
var sessionFile = Path.Combine(_testTempDir, "tabs_session.json");
|
||||||
|
var manager = new MobileTabManager();
|
||||||
|
manager.AddTab("https://public-site1.com", isPrivate: false);
|
||||||
|
manager.AddTab("https://secret-site.com", isPrivate: true); // InPrivate
|
||||||
|
|
||||||
|
await manager.SaveSessionAsync(sessionFile);
|
||||||
|
Assert.True(File.Exists(sessionFile));
|
||||||
|
|
||||||
|
var restoredManager = new MobileTabManager();
|
||||||
|
await restoredManager.RestoreSessionAsync(sessionFile);
|
||||||
|
|
||||||
|
// InPrivate 탭은 저장되지 않아야 함
|
||||||
|
Assert.DoesNotContain(restoredManager.Tabs, t => t.Url == "https://secret-site.com");
|
||||||
|
Assert.Contains(restoredManager.Tabs, t => t.Url == "https://public-site1.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 2. 방문 기록 영속 저장소 (MobileHistoryStore)
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HistoryStore_AddVisitAndSearch_RanksByRecencyAndFiltersKeyword()
|
||||||
|
{
|
||||||
|
var historyFile = Path.Combine(_testTempDir, "history.json");
|
||||||
|
var store = new MobileHistoryStore(historyFile);
|
||||||
|
|
||||||
|
await store.AddVisitAsync("https://m.youtube.com/watch?v=123", "YouTube Video 1");
|
||||||
|
await store.AddVisitAsync("https://news.naver.com", "네이버 뉴스");
|
||||||
|
await store.AddVisitAsync("https://m.youtube.com/watch?v=456", "YouTube Video 2");
|
||||||
|
|
||||||
|
var recents = store.GetRecent(10);
|
||||||
|
Assert.Equal(3, recents.Count);
|
||||||
|
Assert.Equal("YouTube Video 2", recents[0].Title);
|
||||||
|
|
||||||
|
var searchResults = store.Search("youtube");
|
||||||
|
Assert.Equal(2, searchResults.Count);
|
||||||
|
|
||||||
|
// 중복 방문 시 VisitCount 증가
|
||||||
|
await store.AddVisitAsync("https://news.naver.com", "네이버 뉴스 - 갱신");
|
||||||
|
var naverItem = store.Search("naver").First();
|
||||||
|
Assert.Equal(2, naverItem.VisitCount);
|
||||||
|
Assert.Equal("네이버 뉴스 - 갱신", naverItem.Title);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HistoryStore_DeleteItemAndClearAll_SavesAtomically()
|
||||||
|
{
|
||||||
|
var historyFile = Path.Combine(_testTempDir, "history.json");
|
||||||
|
var store = new MobileHistoryStore(historyFile);
|
||||||
|
|
||||||
|
await store.AddVisitAsync("https://site1.com", "Site 1");
|
||||||
|
await store.AddVisitAsync("https://site2.com", "Site 2");
|
||||||
|
|
||||||
|
var itemToDelete = store.GetRecent().First(i => i.Url == "https://site1.com");
|
||||||
|
var deleted = await store.DeleteAsync(itemToDelete.Id);
|
||||||
|
Assert.True(deleted);
|
||||||
|
|
||||||
|
// 다시 로드하여 영속화 확인
|
||||||
|
var storeReloaded = new MobileHistoryStore(historyFile);
|
||||||
|
Assert.Single(storeReloaded.GetRecent());
|
||||||
|
|
||||||
|
await storeReloaded.ClearAllAsync();
|
||||||
|
Assert.Empty(storeReloaded.GetRecent());
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 3. 북마크 영속 저장소 (MobileBookmarkStore)
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task BookmarkStore_AddUpdateAndRemove_WorksAccurately()
|
||||||
|
{
|
||||||
|
var bookmarkFile = Path.Combine(_testTempDir, "bookmarks.json");
|
||||||
|
var store = new MobileBookmarkStore(bookmarkFile);
|
||||||
|
|
||||||
|
Assert.False(store.IsBookmarked("https://github.com"));
|
||||||
|
|
||||||
|
await store.AddOrUpdateBookmarkAsync("https://github.com", "GitHub", "개발");
|
||||||
|
Assert.True(store.IsBookmarked("https://github.com"));
|
||||||
|
|
||||||
|
var all = store.GetAll();
|
||||||
|
Assert.Single(all);
|
||||||
|
Assert.Equal("개발", all[0].Folder);
|
||||||
|
|
||||||
|
// 삭제
|
||||||
|
var removed = await store.RemoveByUrlAsync("https://github.com");
|
||||||
|
Assert.True(removed);
|
||||||
|
Assert.False(store.IsBookmarked("https://github.com"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 4. 비디오 스니퍼 파서 (VideoSnifferParser)
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("https://cdn.example.com/video/master.m3u8?token=xyz", "application/x-mpegURL", VideoStreamType.Hls)]
|
||||||
|
[InlineData("https://cdn.example.com/video/stream.mpd", "application/dash+xml", VideoStreamType.Dash)]
|
||||||
|
[InlineData("https://cdn.example.com/files/sample.mp4?dl=1", "video/mp4", VideoStreamType.Mp4)]
|
||||||
|
[InlineData("https://cdn.example.com/stream.webm", "video/webm", VideoStreamType.Direct)]
|
||||||
|
[InlineData("blob:https://m.youtube.com/83274-1234", null, VideoStreamType.Blob)]
|
||||||
|
public void VideoSniffer_NetworkStreams_ClassifiedCorrectly(string url, string? mime, VideoStreamType expectedType)
|
||||||
|
{
|
||||||
|
var detected = VideoSnifferParser.TrySniffNetworkStream(url, mime, null, out var item);
|
||||||
|
Assert.True(detected);
|
||||||
|
Assert.NotNull(item);
|
||||||
|
Assert.Equal(expectedType, item!.VideoType);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void VideoSniffer_AdsAndFragments_Ignored()
|
||||||
|
{
|
||||||
|
var adUrl = "https://googleads.g.doubleclick.net/pagead/ads?video_id=123";
|
||||||
|
var isVideo = VideoSnifferParser.TrySniffNetworkStream(adUrl, "video/mp4", null, out _);
|
||||||
|
Assert.False(isVideo);
|
||||||
|
|
||||||
|
var segUrl = "https://cdn.example.com/segment123.ts";
|
||||||
|
var isSeg = VideoSnifferParser.TrySniffNetworkStream(segUrl, "video/mp2t", null, out _);
|
||||||
|
Assert.False(isSeg);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void VideoSniffer_DomJsonPayload_ParsesMetadataAccurately()
|
||||||
|
{
|
||||||
|
var domJson = @"[
|
||||||
|
{
|
||||||
|
""src"": ""https://video.example.com/high_1080p.m3u8"",
|
||||||
|
""title"": ""모바일 테스트 비디오"",
|
||||||
|
""width"": 1920,
|
||||||
|
""height"": 1080,
|
||||||
|
""duration"": 145.5,
|
||||||
|
""poster"": ""https://video.example.com/thumb.jpg""
|
||||||
|
}
|
||||||
|
]";
|
||||||
|
|
||||||
|
var items = VideoSnifferParser.ParseDomPayload(domJson, "https://example.com/watch");
|
||||||
|
Assert.Single(items);
|
||||||
|
|
||||||
|
var video = items[0];
|
||||||
|
Assert.Equal("https://video.example.com/high_1080p.m3u8", video.StreamUrl);
|
||||||
|
Assert.Equal(VideoStreamType.Hls, video.VideoType);
|
||||||
|
Assert.Equal("모바일 테스트 비디오", video.Title);
|
||||||
|
Assert.Equal("1920x1080", video.Resolution);
|
||||||
|
Assert.Equal(145.5, video.DurationSeconds);
|
||||||
|
Assert.Equal("https://video.example.com/thumb.jpg", video.ThumbnailUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
129
tests/Paca.Tests/Mobile/MobileComplexJourneyTests.cs
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using Paca.Core.Config;
|
||||||
|
using Paca.Mobile.Core;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Paca.Tests.Mobile;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 모바일 & 데스크톱 융합 복합 E2E 사용자 여정 시나리오 (Gate 98 ~ Gate 102)
|
||||||
|
/// </summary>
|
||||||
|
public class MobileComplexJourneyTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Gate98_Journey1_Discover_To_Enqueue_To_Pin_To_TurboDownload_Audit()
|
||||||
|
{
|
||||||
|
// 1. 모바일에서 YouTube URL 탐색
|
||||||
|
var url = "https://www.youtube.com/watch?v=sample123";
|
||||||
|
var detection = MobileDiscoveryEngine.DetectPlatform(url);
|
||||||
|
Assert.True(detection.IsSupported);
|
||||||
|
Assert.Equal("YouTube", detection.PlatformName);
|
||||||
|
|
||||||
|
// 2. 큐 관리자에 등록
|
||||||
|
var qm = new MobileQueueManager();
|
||||||
|
var item1 = qm.Enqueue("https://example.com/other.mp4", "Other Video");
|
||||||
|
var item2 = qm.Enqueue(url, "Trending Sample Video");
|
||||||
|
|
||||||
|
// 3. 중요한 항목 상단 고정 (Pin to Top)
|
||||||
|
qm.Pin(item2.Id);
|
||||||
|
Assert.True(item2.IsPinned);
|
||||||
|
Assert.Equal(item2.Id, qm.Items[0].Id); // 0번째 인덱스로 승격
|
||||||
|
|
||||||
|
// 4. 터보 모드 설정 연계
|
||||||
|
var cfg = new AppConfig();
|
||||||
|
cfg.TurboModeEnabled = true;
|
||||||
|
cfg.MaxConcurrentSegments = 16;
|
||||||
|
Assert.True(cfg.TurboModeEnabled);
|
||||||
|
Assert.Equal(16, cfg.MaxConcurrentSegments);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Gate99_Journey2_NetworkLost_To_GuardPause_To_RestoredResume_Audit()
|
||||||
|
{
|
||||||
|
var qm = new MobileQueueManager();
|
||||||
|
var item1 = qm.Enqueue("https://example.com/v1.mp4", "Video 1");
|
||||||
|
var item2 = qm.Enqueue("https://example.com/v2.mp4", "Video 2");
|
||||||
|
|
||||||
|
item1.Status = MobileQueueStatus.Downloading;
|
||||||
|
item2.Status = MobileQueueStatus.Downloading;
|
||||||
|
|
||||||
|
var guard = new MobileNetworkGuard(qm);
|
||||||
|
|
||||||
|
// 엘리베이터 진입으로 LTE/Wi-Fi 신호 상실
|
||||||
|
guard.OnNetworkLost();
|
||||||
|
Assert.Equal(MobileQueueStatus.PausedNetwork, item1.Status);
|
||||||
|
Assert.Equal(MobileQueueStatus.PausedNetwork, item2.Status);
|
||||||
|
|
||||||
|
// Wi-Fi 재연결 감지 시 자동 재개
|
||||||
|
guard.OnNetworkRestored(isWifi: true);
|
||||||
|
Assert.Equal(MobileQueueStatus.Downloading, item1.Status);
|
||||||
|
Assert.Equal(MobileQueueStatus.Downloading, item2.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Gate100_Journey3_PcTabSync_To_RemoteDownload_To_FavoritesStash_Audit()
|
||||||
|
{
|
||||||
|
// 1. PC에서 열린 탭 동기화 수신
|
||||||
|
var syncEngine = new MobileTabSyncEngine();
|
||||||
|
var pcTabs = new List<RemoteTabInfo>
|
||||||
|
{
|
||||||
|
new("tab-101", "Spring Concert Live", "https://vimeo.com/998877")
|
||||||
|
};
|
||||||
|
syncEngine.UpdateRemoteTabs(pcTabs);
|
||||||
|
Assert.Single(syncEngine.RemoteTabs);
|
||||||
|
|
||||||
|
// 2. 모바일에서 원격 다운로드 요청
|
||||||
|
string? downloadedUrl = null;
|
||||||
|
syncEngine.OnRemoteDownloadRequested += u => downloadedUrl = u;
|
||||||
|
syncEngine.RequestRemoteDownload("tab-101");
|
||||||
|
Assert.Equal("https://vimeo.com/998877", downloadedUrl);
|
||||||
|
|
||||||
|
// 3. 완료 후 모바일 보관함(Stash) 및 즐겨찾기에 등록
|
||||||
|
var favManager = new MobileFavoritesManager();
|
||||||
|
var fav = favManager.AddFavorite(downloadedUrl!, "Spring Concert Live", "음악");
|
||||||
|
favManager.ToggleStash(fav.Id);
|
||||||
|
|
||||||
|
Assert.True(fav.IsStashedForDownload);
|
||||||
|
Assert.Equal("음악", fav.Tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Gate101_Journey4_Deduplication_To_FailureRetry_Lifecycle_Audit()
|
||||||
|
{
|
||||||
|
var qm = new MobileQueueManager();
|
||||||
|
var item = qm.Enqueue("https://example.com/stream.m3u8", "HLS Live Stream");
|
||||||
|
item.Status = MobileQueueStatus.Downloading;
|
||||||
|
|
||||||
|
// 서버 503 에러로 실패 발생
|
||||||
|
item.Status = MobileQueueStatus.Failed;
|
||||||
|
item.ErrorMessage = "HTTP 503 Service Unavailable";
|
||||||
|
|
||||||
|
// 사용자 실패 항목 재시도 트리거
|
||||||
|
var retried = qm.RetryFailedItems();
|
||||||
|
Assert.Equal(1, retried);
|
||||||
|
Assert.Equal(MobileQueueStatus.Queued, item.Status);
|
||||||
|
Assert.Null(item.ErrorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Gate102_Journey5_Preferences_Persistence_And_ThemeSwitch_Audit()
|
||||||
|
{
|
||||||
|
var settings = new MobileSettingsManager();
|
||||||
|
settings.ThemeMode = MobileThemeMode.Dark;
|
||||||
|
settings.SpeedLimitKbps = 5120; // 5MB/s
|
||||||
|
settings.WifiOnlyDownload = true;
|
||||||
|
settings.NotifyOnCompletion = true;
|
||||||
|
|
||||||
|
var palette = MobileThemeSystem.GetPalette(settings.ThemeMode);
|
||||||
|
Assert.Equal("#0B0C10", palette.Background);
|
||||||
|
Assert.Equal("#5E56E8", palette.Accent);
|
||||||
|
|
||||||
|
// 테마를 고대비로 전환
|
||||||
|
settings.ThemeMode = MobileThemeMode.HighContrast;
|
||||||
|
var hcPalette = MobileThemeSystem.GetPalette(settings.ThemeMode);
|
||||||
|
Assert.Equal("#000000", hcPalette.Background);
|
||||||
|
Assert.Equal("#FFFF00", hcPalette.Accent);
|
||||||
|
}
|
||||||
|
}
|
||||||
142
tests/Paca.Tests/Mobile/MobileCoreFeaturesGateTests.cs
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using Paca.Mobile.Core;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Paca.Tests.Mobile;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 모바일 프로덕션 7대 핵심 기능 게이트 (Gate 87 ~ Gate 92)
|
||||||
|
/// </summary>
|
||||||
|
public class MobileCoreFeaturesGateTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Gate87_MobileDiscoveryEngine_UrlParsing_And_PlatformDetection_Audit()
|
||||||
|
{
|
||||||
|
// YouTube, Vimeo, Twitter, 일반 미디어 감지
|
||||||
|
var yt = MobileDiscoveryEngine.DetectPlatform("https://www.youtube.com/watch?v=dQw4w9WgXcQ");
|
||||||
|
Assert.Equal("YouTube", yt.PlatformName);
|
||||||
|
Assert.True(yt.IsSupported);
|
||||||
|
|
||||||
|
var vm = MobileDiscoveryEngine.DetectPlatform("https://vimeo.com/12345678");
|
||||||
|
Assert.Equal("Vimeo", vm.PlatformName);
|
||||||
|
Assert.True(vm.IsSupported);
|
||||||
|
|
||||||
|
var general = MobileDiscoveryEngine.DetectPlatform("https://example.com/video.m3u8");
|
||||||
|
Assert.Equal("HLS Stream", general.PlatformName);
|
||||||
|
Assert.True(general.IsSupported);
|
||||||
|
|
||||||
|
var unknown = MobileDiscoveryEngine.DetectPlatform("https://not-a-video.org/page.html");
|
||||||
|
Assert.Equal("Web Page", unknown.PlatformName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Gate88_MobileQueueManager_Control_And_Retry_Audit()
|
||||||
|
{
|
||||||
|
var qm = new MobileQueueManager();
|
||||||
|
var item1 = qm.Enqueue("https://example.com/v1.mp4", "Video 1");
|
||||||
|
var item2 = qm.Enqueue("https://example.com/v2.mp4", "Video 2");
|
||||||
|
|
||||||
|
Assert.Equal(2, qm.Items.Count);
|
||||||
|
|
||||||
|
// 일시정지 & 재개
|
||||||
|
qm.Pause(item1.Id);
|
||||||
|
Assert.Equal(MobileQueueStatus.Paused, item1.Status);
|
||||||
|
qm.Resume(item1.Id);
|
||||||
|
Assert.Equal(MobileQueueStatus.Downloading, item1.Status);
|
||||||
|
|
||||||
|
// 상단 고정 (Pin)
|
||||||
|
qm.Pin(item2.Id);
|
||||||
|
Assert.True(item2.IsPinned);
|
||||||
|
|
||||||
|
// 실패 상태 설정 후 일괄 재시도 검증
|
||||||
|
item1.Status = MobileQueueStatus.Failed;
|
||||||
|
var retriedCount = qm.RetryFailedItems();
|
||||||
|
Assert.Equal(1, retriedCount);
|
||||||
|
Assert.Equal(MobileQueueStatus.Queued, item1.Status);
|
||||||
|
|
||||||
|
// 완료 항목 정리
|
||||||
|
item2.Status = MobileQueueStatus.Completed;
|
||||||
|
qm.ClearCompleted();
|
||||||
|
Assert.Single(qm.Items);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Gate89_MobileFavoritesManager_Stash_And_Tagging_Audit()
|
||||||
|
{
|
||||||
|
var fm = new MobileFavoritesManager();
|
||||||
|
var fav = fm.AddFavorite("https://example.com/movie.mp4", "Great Movie", "영화");
|
||||||
|
|
||||||
|
Assert.Single(fm.Favorites);
|
||||||
|
Assert.Equal("영화", fav.Tag);
|
||||||
|
Assert.False(fav.IsStashedForDownload);
|
||||||
|
|
||||||
|
// Stash(나중에 다운로드) 토글
|
||||||
|
fm.ToggleStash(fav.Id);
|
||||||
|
Assert.True(fav.IsStashedForDownload);
|
||||||
|
|
||||||
|
// 태그 검색 필터
|
||||||
|
var filtered = fm.GetByTag("영화");
|
||||||
|
Assert.Single(filtered);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Gate90_MobileTabSyncEngine_RemoteTabSync_Audit()
|
||||||
|
{
|
||||||
|
var sync = new MobileTabSyncEngine();
|
||||||
|
Assert.Empty(sync.RemoteTabs);
|
||||||
|
|
||||||
|
// PC 브라우저 탭 목록 동기화
|
||||||
|
var remoteTabs = new List<RemoteTabInfo>
|
||||||
|
{
|
||||||
|
new("tab-1", "GitHub Paca Repo", "https://github.com/test/paca"),
|
||||||
|
new("tab-2", "YouTube Video Stream", "https://youtube.com/watch?v=123")
|
||||||
|
};
|
||||||
|
sync.UpdateRemoteTabs(remoteTabs);
|
||||||
|
|
||||||
|
Assert.Equal(2, sync.RemoteTabs.Count);
|
||||||
|
Assert.Equal("GitHub Paca Repo", sync.RemoteTabs[0].Title);
|
||||||
|
|
||||||
|
// 모바일에서 원격 다운로드 요청 이벤트 트리거
|
||||||
|
string? requestedUrl = null;
|
||||||
|
sync.OnRemoteDownloadRequested += url => requestedUrl = url;
|
||||||
|
sync.RequestRemoteDownload("tab-2");
|
||||||
|
|
||||||
|
Assert.Equal("https://youtube.com/watch?v=123", requestedUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Gate91_MobileSettingsManager_Preferences_Audit()
|
||||||
|
{
|
||||||
|
var settings = new MobileSettingsManager();
|
||||||
|
Assert.True(settings.AutoRetryOnReconnect);
|
||||||
|
Assert.False(settings.WifiOnlyDownload);
|
||||||
|
Assert.Equal(0, settings.SpeedLimitKbps);
|
||||||
|
Assert.Equal(MobileThemeMode.Dark, settings.ThemeMode);
|
||||||
|
|
||||||
|
settings.WifiOnlyDownload = true;
|
||||||
|
settings.SpeedLimitKbps = 2048;
|
||||||
|
settings.ThemeMode = MobileThemeMode.Light;
|
||||||
|
|
||||||
|
Assert.True(settings.WifiOnlyDownload);
|
||||||
|
Assert.Equal(2048, settings.SpeedLimitKbps);
|
||||||
|
Assert.Equal(MobileThemeMode.Light, settings.ThemeMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Gate92_MobileNetworkGuard_AutoResume_Audit()
|
||||||
|
{
|
||||||
|
var qm = new MobileQueueManager();
|
||||||
|
var guard = new MobileNetworkGuard(qm);
|
||||||
|
|
||||||
|
var item = qm.Enqueue("https://example.com/video.mp4", "Test Video");
|
||||||
|
item.Status = MobileQueueStatus.PausedNetwork;
|
||||||
|
|
||||||
|
// 네트워크 복구 이벤트 발생
|
||||||
|
guard.OnNetworkRestored(isWifi: true);
|
||||||
|
|
||||||
|
// 네트워크 단절로 멈췄던 항목이 자동으로 재개되어야 함
|
||||||
|
Assert.Equal(MobileQueueStatus.Downloading, item.Status);
|
||||||
|
}
|
||||||
|
}
|
||||||
152
tests/Paca.Tests/Mobile/MobileCoreTests.cs
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
using Paca.Mobile;
|
||||||
|
|
||||||
|
namespace Paca.Tests.Mobile;
|
||||||
|
|
||||||
|
/// <summary>Mobile.Core 순수 로직 — U5 완료 감지, 외부망 안내, U4 저장소(파일 시스템).</summary>
|
||||||
|
public class MobileCoreTests
|
||||||
|
{
|
||||||
|
// ── U5 완료 알림 감지 ──
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void 완료_전환_항목_ID_반환()
|
||||||
|
{
|
||||||
|
var d = new QueueCompletionDetector();
|
||||||
|
var prev = new[] { ("a", "다운로드 중"), ("b", "대기") };
|
||||||
|
var next = new[] { ("a", "완료"), ("b", "다운로드 중") };
|
||||||
|
Assert.Equal(new[] { "a" }, d.Detect(prev, next));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void 이미_완료였던_항목은_재알림_없음()
|
||||||
|
{
|
||||||
|
var d = new QueueCompletionDetector();
|
||||||
|
var prev = new[] { ("a", "완료") };
|
||||||
|
var next = new[] { ("a", "완료") };
|
||||||
|
Assert.Empty(d.Detect(prev, next));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void 첫_폴링에서_이미_완료된_항목은_무시() // 앱 시작 시 과거 완료 스팸 방지
|
||||||
|
{
|
||||||
|
var d = new QueueCompletionDetector();
|
||||||
|
Assert.Empty(d.Detect(Array.Empty<(string, string)>(), new[] { ("a", "완료") }));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void 목록에서_사라진_항목은_알림_없음()
|
||||||
|
{
|
||||||
|
var d = new QueueCompletionDetector();
|
||||||
|
var prev = new[] { ("a", "다운로드 중") };
|
||||||
|
Assert.Empty(d.Detect(prev, Array.Empty<(string, string)>()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 외부망 안내(MOBILE_PLAN §7) ──
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void 미연결_안내()
|
||||||
|
{
|
||||||
|
var m = ConnectionTroubleshoot.Message(isConfigured: false, lastError: null);
|
||||||
|
Assert.Contains("연결", m);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void 연결됐지만_실패_시_같은네트워크_VPN_안내()
|
||||||
|
{
|
||||||
|
var m = ConnectionTroubleshoot.Message(isConfigured: true, lastError: "No such host is known.");
|
||||||
|
Assert.Contains("같은 네트워크", m);
|
||||||
|
Assert.Contains("Tailscale", m); // 계획 §3: 자체 릴레이 대신 Tailscale 문서화
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void 정상_시_빈_문자열()
|
||||||
|
=> Assert.Equal("", ConnectionTroubleshoot.Message(isConfigured: true, lastError: null));
|
||||||
|
|
||||||
|
|
||||||
|
// ── M1 공유 타겟(U1): 공유 텍스트 → URL 추출 ──
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("https://youtu.be/abc123", "https://youtu.be/abc123")] // URL 만
|
||||||
|
[InlineData("재밌는 영상\nhttps://youtu.be/abc123", "https://youtu.be/abc123")] // 제목+URL (유튜브 공유 형식)
|
||||||
|
[InlineData("http://x.com/a?b=1 입니다", "http://x.com/a?b=1")] // http + 쿼리
|
||||||
|
[InlineData("a https://1.com b https://2.com", "https://1.com")] // 첫 번째 우선
|
||||||
|
public void 공유_텍스트에서_URL_추출(string text, string expected)
|
||||||
|
=> Assert.Equal(expected, SharePayload.ExtractUrl(text));
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(null)]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData("URL 없는 그냥 텍스트")]
|
||||||
|
public void URL_없으면_null(string? text)
|
||||||
|
=> Assert.Null(SharePayload.ExtractUrl(text));
|
||||||
|
|
||||||
|
// ── U4 저장소(임시 디렉터리로 파일 시스템 검증) ──
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OfflineStore_기록_목록_삭제_라운드트립()
|
||||||
|
{
|
||||||
|
var baseDir = Path.Combine(Path.GetTempPath(), $"vd_core_{Guid.NewGuid():N}");
|
||||||
|
var store = new OfflineStore(baseDir);
|
||||||
|
Assert.Empty(store.List());
|
||||||
|
|
||||||
|
var p = store.PathFor("id1");
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(p)!);
|
||||||
|
File.WriteAllBytes(p, new byte[123]);
|
||||||
|
store.Record("id1", "video.mp4");
|
||||||
|
|
||||||
|
Assert.True(store.Exists("id1"));
|
||||||
|
var list = store.List();
|
||||||
|
Assert.Single(list);
|
||||||
|
Assert.Equal(("id1", "video.mp4", 123L), (list[0].Id, list[0].FileName, list[0].SizeBytes));
|
||||||
|
|
||||||
|
store.Delete("id1");
|
||||||
|
Assert.False(store.Exists("id1"));
|
||||||
|
Assert.Empty(store.List());
|
||||||
|
|
||||||
|
Directory.Delete(baseDir, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OfflineStore_재시작_시_매니페스트_유지()
|
||||||
|
{
|
||||||
|
var baseDir = Path.Combine(Path.GetTempPath(), $"vd_core_{Guid.NewGuid():N}");
|
||||||
|
var store = new OfflineStore(baseDir);
|
||||||
|
var p = store.PathFor("id2");
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(p)!);
|
||||||
|
File.WriteAllBytes(p, new byte[10]);
|
||||||
|
store.Record("id2", "keep.mp4");
|
||||||
|
|
||||||
|
var reopened = new OfflineStore(baseDir); // 새 인스턴스 = 앱 재시작
|
||||||
|
Assert.True(reopened.Exists("id2"));
|
||||||
|
Assert.Equal("keep.mp4", reopened.List().Single().FileName);
|
||||||
|
|
||||||
|
Directory.Delete(baseDir, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MobileSyncManager_네트워크_SSID_및_WiFi_필터_검증()
|
||||||
|
{
|
||||||
|
var config = new Paca.Mobile.Core.Sync.MobileSyncConfig
|
||||||
|
{
|
||||||
|
AutoSyncOnWifi = true,
|
||||||
|
TargetSsid = "Home_5G"
|
||||||
|
};
|
||||||
|
var mgr = new Paca.Mobile.Core.Sync.MobileSyncManager(config);
|
||||||
|
|
||||||
|
Assert.True(mgr.CanSyncOnNetwork("Home_5G", isWifi: true));
|
||||||
|
Assert.False(mgr.CanSyncOnNetwork("Public_Cafe", isWifi: true));
|
||||||
|
Assert.False(mgr.CanSyncOnNetwork("Home_5G", isWifi: false)); // LTE/5G
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task MobileSyncManager_동기화_트리거_및_상태_갱신_검증()
|
||||||
|
{
|
||||||
|
var mgr = new Paca.Mobile.Core.Sync.MobileSyncManager();
|
||||||
|
Assert.Null(mgr.Status.LastSyncUtc);
|
||||||
|
|
||||||
|
bool ok = await mgr.TriggerSyncAsync(() => Task.FromResult(5));
|
||||||
|
Assert.True(ok);
|
||||||
|
Assert.NotNull(mgr.Status.LastSyncUtc);
|
||||||
|
Assert.Equal(5, mgr.Status.CompletedDownloadsCount);
|
||||||
|
Assert.Empty(mgr.Status.LastError);
|
||||||
|
}
|
||||||
|
}
|
||||||
118
tests/Paca.Tests/Mobile/MobileDesignSystemGateTests.cs
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using Paca.Mobile.Core;
|
||||||
|
using Paca.Tests.Infrastructure;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Paca.Tests.Mobile;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 모바일 프로덕션 레벨 디자인 시스템, 테마, 아이콘, 탭 및 에셋 무결성 게이트 (Gate 82 ~ Gate 86)
|
||||||
|
/// </summary>
|
||||||
|
public class MobileDesignSystemGateTests
|
||||||
|
{
|
||||||
|
private static string MobileProjectDir => Directory.Exists(Path.Combine(SourceTree.Root, "src", "Paca.Mobile"))
|
||||||
|
? Path.Combine(SourceTree.Root, "src", "Paca.Mobile")
|
||||||
|
: Path.Combine(SourceTree.Root, "Paca.Mobile");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Gate82_MobileThemeSystem_Themes_And_Tokens_Audit()
|
||||||
|
{
|
||||||
|
// 1. 테마 모드 정의 확인
|
||||||
|
Assert.True(Enum.IsDefined(typeof(MobileThemeMode), MobileThemeMode.Dark));
|
||||||
|
Assert.True(Enum.IsDefined(typeof(MobileThemeMode), MobileThemeMode.Light));
|
||||||
|
Assert.True(Enum.IsDefined(typeof(MobileThemeMode), MobileThemeMode.HighContrast));
|
||||||
|
Assert.True(Enum.IsDefined(typeof(MobileThemeMode), MobileThemeMode.SystemAuto));
|
||||||
|
|
||||||
|
// 2. 테마별 팔레트 토큰 검증
|
||||||
|
var darkPalette = MobileThemeSystem.GetPalette(MobileThemeMode.Dark);
|
||||||
|
Assert.Equal("#0B0C10", darkPalette.Background);
|
||||||
|
Assert.Equal("#161822", darkPalette.Surface1);
|
||||||
|
Assert.Equal("#5E56E8", darkPalette.Accent);
|
||||||
|
Assert.Equal("#FFFFFF", darkPalette.TextPrimary);
|
||||||
|
Assert.Equal("#94A3B8", darkPalette.TextSecondary);
|
||||||
|
|
||||||
|
var lightPalette = MobileThemeSystem.GetPalette(MobileThemeMode.Light);
|
||||||
|
Assert.Equal("#F8FAFC", lightPalette.Background);
|
||||||
|
Assert.Equal("#FFFFFF", lightPalette.Surface1);
|
||||||
|
Assert.Equal("#4F46E5", lightPalette.Accent);
|
||||||
|
Assert.Equal("#0F172A", lightPalette.TextPrimary);
|
||||||
|
|
||||||
|
var hcPalette = MobileThemeSystem.GetPalette(MobileThemeMode.HighContrast);
|
||||||
|
Assert.Equal("#000000", hcPalette.Background);
|
||||||
|
Assert.Equal("#FFFF00", hcPalette.Accent);
|
||||||
|
Assert.Equal("#FFFFFF", hcPalette.TextPrimary);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Gate83_MobileIconSystem_Glyphs_Completeness_Audit()
|
||||||
|
{
|
||||||
|
// 모바일 프로덕션 필수 아이콘 글리프 정의 검증
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(MobileIconSystem.Queue));
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(MobileIconSystem.Discover));
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(MobileIconSystem.Browser));
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(MobileIconSystem.Favorites));
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(MobileIconSystem.SyncSettings));
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(MobileIconSystem.Play));
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(MobileIconSystem.Pause));
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(MobileIconSystem.Retry));
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(MobileIconSystem.Delete));
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(MobileIconSystem.Pin));
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(MobileIconSystem.Download));
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(MobileIconSystem.Check));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Gate84_MobileTouchTarget_Standard_Audit()
|
||||||
|
{
|
||||||
|
// 모바일 인간공학 최소 터치 규격 및 패딩 검증
|
||||||
|
Assert.Equal(48.0, MobileThemeSystem.MinTouchTargetDp);
|
||||||
|
Assert.Equal(8.0, MobileThemeSystem.BaseGridUnitDp);
|
||||||
|
Assert.Equal(12.0, MobileThemeSystem.CornerRadiusCardDp);
|
||||||
|
Assert.Equal(24.0, MobileThemeSystem.CornerRadiusPillDp);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Gate85_MobileTabModel_Navigation_Audit()
|
||||||
|
{
|
||||||
|
// 5대 탭 시스템 모델 무결성 검증
|
||||||
|
var tabs = MobileNavigationModel.GetDefaultTabs();
|
||||||
|
Assert.Equal(5, tabs.Count);
|
||||||
|
|
||||||
|
Assert.Equal(MobileTabKind.Queue, tabs[0].Kind);
|
||||||
|
Assert.Equal("대기열", tabs[0].Title);
|
||||||
|
|
||||||
|
Assert.Equal(MobileTabKind.Discover, tabs[1].Kind);
|
||||||
|
Assert.Equal("탐색", tabs[1].Title);
|
||||||
|
|
||||||
|
Assert.Equal(MobileTabKind.Browser, tabs[2].Kind);
|
||||||
|
Assert.Equal("브라우저", tabs[2].Title);
|
||||||
|
|
||||||
|
Assert.Equal(MobileTabKind.Favorites, tabs[3].Kind);
|
||||||
|
Assert.Equal("보관함", tabs[3].Title);
|
||||||
|
|
||||||
|
Assert.Equal(MobileTabKind.SyncSettings, tabs[4].Kind);
|
||||||
|
Assert.Equal("동기화", tabs[4].Title);
|
||||||
|
|
||||||
|
// 뱃지 업데이트 지원
|
||||||
|
tabs[0].BadgeCount = 3;
|
||||||
|
Assert.Equal(3, tabs[0].BadgeCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Gate86_Mobile_Generated_Assets_Existence_Audit()
|
||||||
|
{
|
||||||
|
// 이미지 생성 도구로 제작되어 저장된 에셋 검증
|
||||||
|
var iconPath = Path.Combine(MobileProjectDir, "Resources", "AppIcon", "paca_app_icon.png");
|
||||||
|
var splashPath = Path.Combine(MobileProjectDir, "Resources", "Splash", "paca_splash_screen.png");
|
||||||
|
var emptyPath = Path.Combine(MobileProjectDir, "Resources", "Images", "paca_empty_queue.png");
|
||||||
|
|
||||||
|
Assert.True(File.Exists(iconPath), $"앱 아이콘이 존재해야 합니다: {iconPath}");
|
||||||
|
Assert.True(File.Exists(splashPath), $"스플래시 이미지가 존재해야 합니다: {splashPath}");
|
||||||
|
Assert.True(File.Exists(emptyPath), $"빈 큐 일러스트가 존재해야 합니다: {emptyPath}");
|
||||||
|
|
||||||
|
Assert.True(new FileInfo(iconPath).Length > 10000, "앱 아이콘 파일 크기가 정상이어야 합니다.");
|
||||||
|
Assert.True(new FileInfo(splashPath).Length > 10000, "스플래시 파일 크기가 정상이어야 합니다.");
|
||||||
|
Assert.True(new FileInfo(emptyPath).Length > 10000, "빈 큐 일러스트 파일 크기가 정상이어야 합니다.");
|
||||||
|
}
|
||||||
|
}
|
||||||
107
tests/Paca.Tests/Mobile/MobileMediaPlaybackGateTests.cs
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Paca.Mobile.Core;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Paca.Tests.Mobile;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 모바일 프로덕션 미디어 플레이어 제스처, 자막, 배속, 스토리지 및 공유 게이트 (Gate 103 ~ Gate 108)
|
||||||
|
/// </summary>
|
||||||
|
public class MobileMediaPlaybackGateTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Gate103_MobilePlayerGestureEngine_Audit()
|
||||||
|
{
|
||||||
|
var gesture = new MobilePlayerGestureEngine();
|
||||||
|
|
||||||
|
// 1. 수평 스와이프 -> Seek
|
||||||
|
var seekResult = gesture.OnHorizontalPan(deltaX: 100, currentPositionSec: 30, durationSec: 300);
|
||||||
|
Assert.True(seekResult.NewPositionSec > 30);
|
||||||
|
|
||||||
|
// 2. 좌측 수직 스와이프 -> 밝기 조절
|
||||||
|
var brightness = gesture.OnVerticalPan(isLeftScreen: true, deltaY: -50, currentBrightness: 0.5);
|
||||||
|
Assert.True(brightness > 0.5);
|
||||||
|
|
||||||
|
// 3. 우측 수직 스와이프 -> 볼륨 조절
|
||||||
|
var volume = gesture.OnVerticalPan(isLeftScreen: false, deltaY: -50, currentVolume: 0.5);
|
||||||
|
Assert.True(volume > 0.5);
|
||||||
|
|
||||||
|
// 4. 더블 탭 -> 토글
|
||||||
|
Assert.True(gesture.OnDoubleTap(isPlaying: false));
|
||||||
|
Assert.False(gesture.OnDoubleTap(isPlaying: true));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Gate104_MobilePlaybackSpeedController_Audit()
|
||||||
|
{
|
||||||
|
var controller = new MobilePlaybackSpeedController();
|
||||||
|
Assert.Equal(1.0, controller.CurrentSpeed);
|
||||||
|
|
||||||
|
controller.SetSpeed(1.5);
|
||||||
|
Assert.Equal(1.5, controller.CurrentSpeed);
|
||||||
|
|
||||||
|
controller.SetSpeed(3.0);
|
||||||
|
Assert.Equal(3.0, controller.CurrentSpeed);
|
||||||
|
|
||||||
|
// 음높이(Pitch) 보정 활성화
|
||||||
|
Assert.True(controller.PitchCorrectionEnabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Gate105_MobileSubtitleRenderer_Audit()
|
||||||
|
{
|
||||||
|
var renderer = new MobileSubtitleRenderer();
|
||||||
|
var vttSample = "WEBVTT\n\n00:00:01.000 --> 00:00:04.000\n안녕하세요, Paca 비디오 다운로더입니다.\n";
|
||||||
|
renderer.LoadSubtitles(vttSample);
|
||||||
|
|
||||||
|
Assert.Single(renderer.Cues);
|
||||||
|
var activeCue = renderer.GetActiveCue(TimeSpan.FromSeconds(2));
|
||||||
|
Assert.NotNull(activeCue);
|
||||||
|
Assert.Equal("안녕하세요, Paca 비디오 다운로더입니다.", activeCue.Text);
|
||||||
|
|
||||||
|
// 자막 스타일
|
||||||
|
renderer.FontSizeMode = SubtitleFontSize.Large;
|
||||||
|
Assert.Equal(SubtitleFontSize.Large, renderer.FontSizeMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Gate106_MobilePipController_Audit()
|
||||||
|
{
|
||||||
|
var pip = new MobilePipController();
|
||||||
|
Assert.False(pip.IsPipActive);
|
||||||
|
Assert.True(pip.BackgroundAudioEnabled);
|
||||||
|
|
||||||
|
pip.EnterPipMode();
|
||||||
|
Assert.True(pip.IsPipActive);
|
||||||
|
|
||||||
|
pip.ExitPipMode();
|
||||||
|
Assert.False(pip.IsPipActive);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Gate107_MobileStoragePolicy_Audit()
|
||||||
|
{
|
||||||
|
var policy = new MobileStoragePolicy();
|
||||||
|
// 90% 임계값
|
||||||
|
Assert.Equal(90.0, policy.WarningThresholdPercent);
|
||||||
|
|
||||||
|
// 95% 사용 시 경고 발생 검증
|
||||||
|
var warning = policy.CheckStorageUsage(totalBytes: 100_000_000, usedBytes: 95_000_000);
|
||||||
|
Assert.True(warning.IsWarningTriggered);
|
||||||
|
Assert.Equal(95.0, warning.UsagePercent);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Gate108_MobileMediaExporter_Audit()
|
||||||
|
{
|
||||||
|
var exporter = new MobileMediaExporter();
|
||||||
|
var exportResult = exporter.PrepareExport("sample_video.mp4", isAudioOnly: false);
|
||||||
|
Assert.Equal("video/mp4", exportResult.MimeType);
|
||||||
|
Assert.Contains("Movies", exportResult.DestinationFolder);
|
||||||
|
|
||||||
|
var audioResult = exporter.PrepareExport("sample_audio.mp3", isAudioOnly: true);
|
||||||
|
Assert.Equal("audio/mpeg", audioResult.MimeType);
|
||||||
|
Assert.Contains("Music", audioResult.DestinationFolder);
|
||||||
|
}
|
||||||
|
}
|
||||||
310
tests/Paca.Tests/Mobile/MobilePipelineTests.cs
Normal file
|
|
@ -0,0 +1,310 @@
|
||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Paca.Mobile;
|
||||||
|
using Paca.Mobile.Core.Sync;
|
||||||
|
using Paca.Tests.Infrastructure;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Paca.Tests.Mobile;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 모바일 컴패니언 앱(Paca.Mobile / Paca.Mobile.Core) 전용 RED/GREEN 파이프라인 확장 스위트.
|
||||||
|
/// 1. 모바일 아키텍처 및 TFM/단일 프로젝트 빌드 설정 검증
|
||||||
|
/// 2. Designpaca 모바일 디자인 토큰, WCAG 2.1 대비율, 터치 타겟(48dp) 무결성 검증
|
||||||
|
/// 3. 페이지 계층 및 UI 컴포넌트 구조 검증 (Connect, Queue, Library, Downloads, Player)
|
||||||
|
/// 4. ApiClient REST 프로토콜 & 모바일 백그라운드 동기화(MobileSyncManager) 무결성 검증
|
||||||
|
/// 5. 오프라인 스토리지(OfflineStore) 파일 격리 및 원자적 관리 검증
|
||||||
|
/// 6. 안드로이드 공유 시트(Share Intent) 페이로드 파서 및 엣지 케이스 검증
|
||||||
|
/// </summary>
|
||||||
|
public class MobilePipelineTests
|
||||||
|
{
|
||||||
|
private static string MobileProjectDir => Directory.Exists(Path.Combine(SourceTree.Root, "src", "Paca.Mobile")) ? Path.Combine(SourceTree.Root, "src", "Paca.Mobile") : Path.Combine(SourceTree.Root, "Paca.Mobile");
|
||||||
|
private static string MobileCoreProjectDir => Directory.Exists(Path.Combine(SourceTree.Root, "src", "Paca.Mobile.Core")) ? Path.Combine(SourceTree.Root, "src", "Paca.Mobile.Core") : Path.Combine(SourceTree.Root, "Paca.Mobile.Core");
|
||||||
|
private static string MobileCsprojPath => Path.Combine(MobileProjectDir, "Paca.Mobile.csproj");
|
||||||
|
private static string MobileCoreCsprojPath => Path.Combine(MobileCoreProjectDir, "Paca.Mobile.Core.csproj");
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 1. 모바일 아키텍처 & TFM / 프로젝트 설정 게이트
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Architecture_MobileProjects_ExistAndConfigured()
|
||||||
|
{
|
||||||
|
Assert.True(File.Exists(MobileCsprojPath), "Paca.Mobile.csproj 파일이 존재해야 합니다.");
|
||||||
|
Assert.True(File.Exists(MobileCoreCsprojPath), "Paca.Mobile.Core.csproj 파일이 존재해야 합니다.");
|
||||||
|
|
||||||
|
var mobileCsproj = File.ReadAllText(MobileCsprojPath);
|
||||||
|
Assert.Contains("UseMaui", mobileCsproj);
|
||||||
|
Assert.Contains("SingleProject", mobileCsproj);
|
||||||
|
Assert.Contains("net10.0-android", mobileCsproj);
|
||||||
|
Assert.Contains("ApplicationTitle", mobileCsproj);
|
||||||
|
Assert.Contains("com.companyname.paca.mobile", mobileCsproj);
|
||||||
|
|
||||||
|
var coreCsproj = File.ReadAllText(MobileCoreCsprojPath);
|
||||||
|
Assert.Contains("net8.0", coreCsproj);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Architecture_MobileCore_HasNoPlatformSpecificUiCoupling()
|
||||||
|
{
|
||||||
|
var coreFiles = Directory.GetFiles(MobileCoreProjectDir, "*.cs", SearchOption.AllDirectories);
|
||||||
|
foreach (var file in coreFiles)
|
||||||
|
{
|
||||||
|
var content = File.ReadAllText(file);
|
||||||
|
Assert.DoesNotContain("using Microsoft.Maui.Controls;", content);
|
||||||
|
Assert.DoesNotContain("using Android.", content);
|
||||||
|
Assert.DoesNotContain("using UIKit;", content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 2. Designpaca 모바일 디자인 토큰 & WCAG 명도 대비 & 터치 타겟 게이트
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("#0B0C10", "#FFFFFF", 19.0)] // Dark canvas vs Pure White text -> AAA (>= 7.0:1)
|
||||||
|
[InlineData("#0B0C10", "#E2E8F0", 15.0)] // Dark canvas vs TextPrimary -> AAA (>= 7.0:1)
|
||||||
|
[InlineData("#0B0C10", "#94A3B8", 7.0)] // Dark canvas vs TextSecondary -> AAA (>= 7.0:1)
|
||||||
|
[InlineData("#161822", "#FFFFFF", 16.0)] // Surface1 vs White -> AAA (>= 7.0:1)
|
||||||
|
[InlineData("#5E56E8", "#FFFFFF", 4.5)] // Accent button vs White text -> AA (>= 4.5:1)
|
||||||
|
[InlineData("#222438", "#FFFFFF", 14.0)] // Card/Frame vs White text -> AAA (>= 7.0:1)
|
||||||
|
public void MobileDesign_WcagContrast_MeetsStandard(string bgHex, string fgHex, double minExpectedRatio)
|
||||||
|
{
|
||||||
|
var ratio = CalculateContrastRatio(bgHex, fgHex);
|
||||||
|
Assert.True(ratio >= minExpectedRatio,
|
||||||
|
$"[WCAG Gate] {bgHex} 배경 위 {fgHex} 전경의 명도 대비({ratio:F2}:1)가 최소 기준({minExpectedRatio:F2}:1)에 미달합니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MobileDesign_InteractiveElements_MeetMinimumTouchTarget()
|
||||||
|
{
|
||||||
|
// 모바일 가이드라인: 인터랙티브 버튼/입력창의 터치 타겟은 최소 44~48dp 이상
|
||||||
|
const double MinimumTouchTargetDp = 44.0;
|
||||||
|
var pagesCs = File.ReadAllText(Path.Combine(MobileProjectDir, "Pages.cs"));
|
||||||
|
|
||||||
|
// Button 또는 Entry와 결합된 HeightRequest 검증
|
||||||
|
var buttonHeightMatches = Regex.Matches(pagesCs, @"new\s+(Button|Entry)\s*\{[^}]*HeightRequest\s*=\s*(\d+)");
|
||||||
|
foreach (Match match in buttonHeightMatches)
|
||||||
|
{
|
||||||
|
var controlType = match.Groups[1].Value;
|
||||||
|
var height = double.Parse(match.Groups[2].Value);
|
||||||
|
Assert.True(height >= MinimumTouchTargetDp,
|
||||||
|
$"[Touch Target Gate] Pages.cs의 {controlType} HeightRequest={height}dp는 최소 터치 타겟({MinimumTouchTargetDp}dp) 미만입니다.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 3. 페이지 계층 및 UI 구조 게이트
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Pages_TabbedRoot_Has4CoreTabs()
|
||||||
|
{
|
||||||
|
var rootCs = File.ReadAllText(Path.Combine(MobileProjectDir, "TabbedRootPage.cs"));
|
||||||
|
Assert.Contains("QueuePage", rootCs);
|
||||||
|
Assert.Contains("LibraryPage", rootCs);
|
||||||
|
Assert.Contains("DownloadsPage", rootCs);
|
||||||
|
Assert.Contains("ConnectPage", rootCs);
|
||||||
|
Assert.Contains("Children.Add", rootCs);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Pages_ConnectPage_ContainsQrAndManualInputs()
|
||||||
|
{
|
||||||
|
var pagesCs = File.ReadAllText(Path.Combine(MobileProjectDir, "Pages.cs"));
|
||||||
|
Assert.Contains("CameraBarcodeReaderView", pagesCs);
|
||||||
|
Assert.Contains("PC 주소", pagesCs);
|
||||||
|
Assert.Contains("수동 연결", pagesCs);
|
||||||
|
Assert.Contains("연결 해제", pagesCs);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Pages_QueuePage_ContainsInputAndPollingControls()
|
||||||
|
{
|
||||||
|
var pagesCs = File.ReadAllText(Path.Combine(MobileProjectDir, "Pages.cs"));
|
||||||
|
Assert.Contains("PC에서 받기", pagesCs);
|
||||||
|
Assert.Contains("QueueCompletionDetector", pagesCs);
|
||||||
|
Assert.Contains("ConnectionTroubleshoot", pagesCs);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Pages_LibraryPage_ContainsStreamAndOfflineSave()
|
||||||
|
{
|
||||||
|
var pagesCs = File.ReadAllText(Path.Combine(MobileProjectDir, "Pages.cs"));
|
||||||
|
Assert.Contains("PlayerPage", pagesCs);
|
||||||
|
Assert.Contains("SaveOffline", pagesCs);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Pages_DownloadsPage_ContainsListAndPlayDelete()
|
||||||
|
{
|
||||||
|
var targetFile = File.Exists(Path.Combine(MobileProjectDir, "DownloadsPage.cs"))
|
||||||
|
? Path.Combine(MobileProjectDir, "DownloadsPage.cs")
|
||||||
|
: Path.Combine(MobileProjectDir, "Pages.cs");
|
||||||
|
var content = File.ReadAllText(targetFile);
|
||||||
|
Assert.Contains("AppOfflineStore", content);
|
||||||
|
Assert.True(content.Contains("저장된 항목이 없습니다") || content.Contains("저장된 비디오가 없습니다"));
|
||||||
|
Assert.True(content.Contains("삭제") || content.Contains("Delete") || content.Contains("🗑️"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 4. REST 프로토콜 계약 & MobileSyncManager 무결성 게이트
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ApiProtocol_MobileClientContract_MatchesServerEndpoints()
|
||||||
|
{
|
||||||
|
var apiClientCs = File.ReadAllText(Path.Combine(MobileProjectDir, "ApiClient.cs"));
|
||||||
|
Assert.Contains("/api/queue", apiClientCs);
|
||||||
|
Assert.Contains("/api/pair", apiClientCs);
|
||||||
|
Assert.Contains("/api/library", apiClientCs);
|
||||||
|
Assert.Contains("/stream", apiClientCs);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MobileSyncManager_DefaultConfig_ValidAndSecured()
|
||||||
|
{
|
||||||
|
var manager = new MobileSyncManager();
|
||||||
|
Assert.NotNull(manager.Config);
|
||||||
|
Assert.NotNull(manager.Status);
|
||||||
|
Assert.True(manager.Config.AutoSyncOnWifi);
|
||||||
|
Assert.True(manager.Config.RequireBiometrics);
|
||||||
|
Assert.False(manager.Status.IsSyncing);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MobileSyncManager_WifiSsidPolicy_EnforcedCorrectly()
|
||||||
|
{
|
||||||
|
var config = new MobileSyncConfig
|
||||||
|
{
|
||||||
|
AutoSyncOnWifi = true,
|
||||||
|
TargetSsid = "HomeNetwork_5G"
|
||||||
|
};
|
||||||
|
var manager = new MobileSyncManager(config);
|
||||||
|
|
||||||
|
// LTE/5G 모바일 데이터 시 동기화 거부
|
||||||
|
Assert.False(manager.CanSyncOnNetwork("HomeNetwork_5G", isWifi: false));
|
||||||
|
|
||||||
|
// 다른 Wi-Fi SSID 시 동기화 거부
|
||||||
|
Assert.False(manager.CanSyncOnNetwork("Public_Cafe_WiFi", isWifi: true));
|
||||||
|
|
||||||
|
// 일치하는 홈 Wi-Fi 시 허용
|
||||||
|
Assert.True(manager.CanSyncOnNetwork("HomeNetwork_5G", isWifi: true));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task MobileSyncManager_TriggerSync_UpdatesStatusAndCount()
|
||||||
|
{
|
||||||
|
var manager = new MobileSyncManager();
|
||||||
|
Assert.Null(manager.Status.LastSyncUtc);
|
||||||
|
Assert.Equal(0, manager.Status.CompletedDownloadsCount);
|
||||||
|
|
||||||
|
var success = await manager.TriggerSyncAsync(async () =>
|
||||||
|
{
|
||||||
|
await Task.Delay(10);
|
||||||
|
return 3; // 3 items synced
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.True(success);
|
||||||
|
Assert.False(manager.Status.IsSyncing);
|
||||||
|
Assert.NotNull(manager.Status.LastSyncUtc);
|
||||||
|
Assert.Equal(3, manager.Status.CompletedDownloadsCount);
|
||||||
|
Assert.Equal("", manager.Status.LastError);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 5. 오프라인 스토리지(OfflineStore) 무결성 & 복원력
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OfflineStore_DirectoryIsolationAndSanitization()
|
||||||
|
{
|
||||||
|
var tempDir = Path.Combine(Path.GetTempPath(), $"paca_offline_test_{Guid.NewGuid():N}");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var store = new OfflineStore(tempDir);
|
||||||
|
Assert.Empty(store.List());
|
||||||
|
|
||||||
|
// 경로 계산 검증
|
||||||
|
var path = store.PathFor("safe_id_100");
|
||||||
|
Assert.StartsWith(tempDir, path);
|
||||||
|
Assert.EndsWith("safe_id_100.mp4", path);
|
||||||
|
|
||||||
|
// 파일 생성 및 기록
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||||
|
File.WriteAllBytes(path, new byte[512]);
|
||||||
|
store.Record("safe_id_100", "my_special_video.mp4");
|
||||||
|
|
||||||
|
Assert.True(store.Exists("safe_id_100"));
|
||||||
|
var list = store.List();
|
||||||
|
Assert.Single(list);
|
||||||
|
Assert.Equal("safe_id_100", list[0].Id);
|
||||||
|
Assert.Equal("my_special_video.mp4", list[0].FileName);
|
||||||
|
Assert.Equal(512L, list[0].SizeBytes);
|
||||||
|
|
||||||
|
// 삭제 라운드트립
|
||||||
|
store.Delete("safe_id_100");
|
||||||
|
Assert.False(store.Exists("safe_id_100"));
|
||||||
|
Assert.Empty(store.List());
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (Directory.Exists(tempDir))
|
||||||
|
Directory.Delete(tempDir, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 6. 안드로이드 공유 시트(Share Intent) 페이로드 파서 엣지 케이스
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("https://www.youtube.com/watch?v=dQw4w9WgXcQ", "https://www.youtube.com/watch?v=dQw4w9WgXcQ")]
|
||||||
|
[InlineData("Check this out! https://twitter.com/user/status/123456789 (via Mobile)", "https://twitter.com/user/status/123456789")]
|
||||||
|
[InlineData("http://localhost:5000/stream.m3u8?token=abc#t=10", "http://localhost:5000/stream.m3u8?token=abc#t=10")]
|
||||||
|
[InlineData("여러 줄 텍스트 첫 번째\nhttps://vimeo.com/987654321\n두 번째 줄", "https://vimeo.com/987654321")]
|
||||||
|
public void SharePayload_ExtractsValidUrlsFromMessyInputs(string rawShareText, string expectedUrl)
|
||||||
|
{
|
||||||
|
var extracted = SharePayload.ExtractUrl(rawShareText);
|
||||||
|
Assert.Equal(expectedUrl, extracted);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("공유 텍스트이지만 URL이 전혀 없습니다.")]
|
||||||
|
[InlineData("ftp://unsupported-scheme.com/file.mp4")]
|
||||||
|
[InlineData(" \t\r\n ")]
|
||||||
|
[InlineData(null)]
|
||||||
|
public void SharePayload_ReturnsNullForInvalidOrMissingUrls(string? invalidShareText)
|
||||||
|
{
|
||||||
|
var extracted = SharePayload.ExtractUrl(invalidShareText);
|
||||||
|
Assert.Null(extracted);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 헬퍼 메서드 ──
|
||||||
|
|
||||||
|
private static double CalculateContrastRatio(string hex1, string hex2)
|
||||||
|
{
|
||||||
|
var l1 = RelativeLuminance(hex1);
|
||||||
|
var l2 = RelativeLuminance(hex2);
|
||||||
|
var lighter = Math.Max(l1, l2);
|
||||||
|
var darker = Math.Min(l1, l2);
|
||||||
|
return (lighter + 0.05) / (darker + 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double RelativeLuminance(string hex)
|
||||||
|
{
|
||||||
|
hex = hex.TrimStart('#');
|
||||||
|
if (hex.Length == 8) hex = hex[2..]; // ARGB -> RGB
|
||||||
|
var r = Convert.ToInt32(hex[..2], 16) / 255.0;
|
||||||
|
var g = Convert.ToInt32(hex.Substring(2, 2), 16) / 255.0;
|
||||||
|
var b = Convert.ToInt32(hex.Substring(4, 2), 16) / 255.0;
|
||||||
|
|
||||||
|
r = (r <= 0.03928) ? r / 12.92 : Math.Pow((r + 0.055) / 1.055, 2.4);
|
||||||
|
g = (g <= 0.03928) ? g / 12.92 : Math.Pow((g + 0.055) / 1.055, 2.4);
|
||||||
|
b = (b <= 0.03928) ? b / 12.92 : Math.Pow((b + 0.055) / 1.055, 2.4);
|
||||||
|
|
||||||
|
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||||
|
}
|
||||||
|
}
|
||||||
199
tests/Paca.Tests/Mobile/MobileProductionExcellenceGateTests.cs
Normal file
|
|
@ -0,0 +1,199 @@
|
||||||
|
using Paca.Mobile;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using Paca.Mobile.Core;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Paca.Tests.Mobile;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 모바일 프로덕션 레벨 종합 고도화 게이트 테스트 (Gate 103 ~ Gate 110)
|
||||||
|
/// - 아이콘 시스템, 테마 시스템, 탭 시스템, 즐겨찾기, 영상 찾기, 재시도 큐 관리, 설정, 탭 동기화
|
||||||
|
/// </summary>
|
||||||
|
public class MobileProductionExcellenceGateTests
|
||||||
|
{
|
||||||
|
// [Gate 103] 아이콘 시스템: 이모지 배제 및 시맨틱 아이콘 글리프 매핑 무결성
|
||||||
|
[Theory]
|
||||||
|
[InlineData(MobileIconKind.Search, "magnifyingglass")]
|
||||||
|
[InlineData(MobileIconKind.Queue, "list.bullet")]
|
||||||
|
[InlineData(MobileIconKind.Download, "arrow.down.circle")]
|
||||||
|
[InlineData(MobileIconKind.Favorites, "star.fill")]
|
||||||
|
[InlineData(MobileIconKind.Settings, "gearshape.fill")]
|
||||||
|
[InlineData(MobileIconKind.Retry, "arrow.clockwise")]
|
||||||
|
public void Gate103_MobileIconSystem_SemanticGlyph_Audit(MobileIconKind kind, string expectedGlyph)
|
||||||
|
{
|
||||||
|
var glyph = MobileIconSystem.GetGlyph(kind);
|
||||||
|
Assert.Equal(expectedGlyph, glyph);
|
||||||
|
// 유니코드 이모지가 포함되지 않았는지 검증
|
||||||
|
Assert.DoesNotMatch(@"[\uD83C-\uDBFF\uDC00-\uDFFF]", glyph);
|
||||||
|
}
|
||||||
|
|
||||||
|
// [Gate 104] 테마 시스템: OLED Black을 포함한 4대 테마 토큰 및 명도 대비
|
||||||
|
[Theory]
|
||||||
|
[InlineData(MobileThemeMode.Dark, "#0B0C10", "#161822", "#FFFFFF", "#5E56E8")]
|
||||||
|
[InlineData(MobileThemeMode.Light, "#F8FAFC", "#FFFFFF", "#0F172A", "#4F46E5")]
|
||||||
|
[InlineData(MobileThemeMode.OLEDBlack, "#000000", "#090A0F", "#FFFFFF", "#00F0FF")]
|
||||||
|
[InlineData(MobileThemeMode.HighContrast, "#000000", "#000000", "#FFFFFF", "#FFFF00")]
|
||||||
|
public void Gate104_MobileThemeSystem_Tokens_And_Contrast_Audit(
|
||||||
|
MobileThemeMode mode, string bg, string card, string text, string accent)
|
||||||
|
{
|
||||||
|
var p = MobileThemeSystem.GetPalette(mode);
|
||||||
|
Assert.Equal(bg, p.Background);
|
||||||
|
Assert.Equal(card, p.CardBackground);
|
||||||
|
Assert.Equal(text, p.TextPrimary);
|
||||||
|
Assert.Equal(accent, p.Accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
// [Gate 105] 탭 시스템: 탭 추가, 전환, 닫기, 시크릿 탭 모드 라이프사이클
|
||||||
|
[Fact]
|
||||||
|
public void Gate105_MobileTabManager_MultiTab_Lifecycle_Audit()
|
||||||
|
{
|
||||||
|
var tm = new MobileTabManager();
|
||||||
|
var tab1 = tm.NewTab("https://www.youtube.com", "YouTube");
|
||||||
|
var tab2 = tm.NewTab("https://vimeo.com", "Vimeo", isIncognito: true);
|
||||||
|
|
||||||
|
Assert.Equal(3, tm.Tabs.Count);
|
||||||
|
Assert.Equal(tab2.Id, tm.ActiveTab?.Id);
|
||||||
|
Assert.True(tab2.IsIncognito);
|
||||||
|
|
||||||
|
// 탭 1로 전환
|
||||||
|
tm.SwitchTab(tab1.Id);
|
||||||
|
Assert.Equal(tab1.Id, tm.ActiveTab?.Id);
|
||||||
|
|
||||||
|
// 탭 닫기
|
||||||
|
tm.CloseTab(tab2.Id);
|
||||||
|
Assert.Equal(2, tm.Tabs.Count);
|
||||||
|
Assert.Equal(tab1.Id, tm.ActiveTab?.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// [Gate 106] 즐겨찾기 & 보관함(Stash): 태그 필터링 및 다운로드 대기 큐 연계
|
||||||
|
[Fact]
|
||||||
|
public void Gate106_MobileFavoritesManager_TagFilter_And_Stash_Audit()
|
||||||
|
{
|
||||||
|
var fav = new MobileFavoritesManager();
|
||||||
|
fav.AddFavorite("https://youtube.com/v1", "Rock Festival Live", "음악");
|
||||||
|
fav.AddFavorite("https://youtube.com/v2", "Dev Conference 2026", "기술");
|
||||||
|
var f3 = fav.AddFavorite("https://youtube.com/v3", "Jazz Night", "음악");
|
||||||
|
|
||||||
|
fav.ToggleStash(f3.Id);
|
||||||
|
|
||||||
|
var musicList = fav.GetByTag("음악");
|
||||||
|
Assert.Equal(2, musicList.Count);
|
||||||
|
|
||||||
|
var stashed = fav.GetStashedItems();
|
||||||
|
Assert.Single(stashed);
|
||||||
|
Assert.Equal(f3.Id, stashed[0].Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// [Gate 107] 영상 찾기(스니퍼 탐색): 플랫폼 감지 및 메타데이터 추출
|
||||||
|
[Fact]
|
||||||
|
public void Gate107_MobileDiscoveryEngine_PlatformDetection_And_FormatFilter_Audit()
|
||||||
|
{
|
||||||
|
var res1 = MobileDiscoveryEngine.DetectPlatform("https://youtu.be/abcdefg");
|
||||||
|
Assert.True(res1.IsSupported);
|
||||||
|
Assert.Equal("YouTube", res1.PlatformName);
|
||||||
|
Assert.Contains("1080p", res1.RecommendedQualities);
|
||||||
|
|
||||||
|
var res2 = MobileDiscoveryEngine.DetectPlatform("https://example.com/live.m3u8");
|
||||||
|
Assert.True(res2.IsSupported);
|
||||||
|
Assert.Equal("HLS Stream", res2.PlatformName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// [Gate 108] 영상 재시도 및 큐 관리: 우선순위 핀, 실패 재시도, 일괄 비우기
|
||||||
|
[Fact]
|
||||||
|
public void Gate108_MobileQueueManager_Priorities_And_FailureRecovery_Audit()
|
||||||
|
{
|
||||||
|
var qm = new MobileQueueManager();
|
||||||
|
var i1 = qm.Enqueue("https://v1.mp4", "Video 1");
|
||||||
|
var i2 = qm.Enqueue("https://v2.mp4", "Video 2");
|
||||||
|
var i3 = qm.Enqueue("https://v3.mp4", "Video 3");
|
||||||
|
|
||||||
|
i2.Status = MobileQueueStatus.Failed;
|
||||||
|
i2.ErrorMessage = "Network Timeout";
|
||||||
|
|
||||||
|
// 실패 항목만 재시도
|
||||||
|
var count = qm.RetryFailedItems();
|
||||||
|
Assert.Equal(1, count);
|
||||||
|
Assert.Equal(MobileQueueStatus.Queued, i2.Status);
|
||||||
|
|
||||||
|
// 중요 항목 고정
|
||||||
|
qm.Pin(i3.Id);
|
||||||
|
Assert.Equal(i3.Id, qm.Items[0].Id);
|
||||||
|
|
||||||
|
// 완료된 항목 일괄 정리
|
||||||
|
i1.Status = MobileQueueStatus.Completed;
|
||||||
|
qm.ClearCompleted();
|
||||||
|
Assert.DoesNotContain(qm.Items, x => x.Id == i1.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// [Gate 109] 설정 관리자: 영속성 및 기본값
|
||||||
|
[Fact]
|
||||||
|
public void Gate109_MobileSettingsManager_Defaults_And_Persistence_Audit()
|
||||||
|
{
|
||||||
|
var settings = new MobileSettingsManager();
|
||||||
|
Assert.Equal(MobileThemeMode.Dark, settings.ThemeMode);
|
||||||
|
Assert.False(settings.WifiOnlyDownload);
|
||||||
|
Assert.Equal(0, settings.SpeedLimitKbps); // 0 = 무제한
|
||||||
|
|
||||||
|
settings.SpeedLimitKbps = 2048;
|
||||||
|
settings.ThemeMode = MobileThemeMode.OLEDBlack;
|
||||||
|
Assert.Equal(2048, settings.SpeedLimitKbps);
|
||||||
|
Assert.Equal(MobileThemeMode.OLEDBlack, settings.ThemeMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// [Gate 110] 사용자 탭 동기화: PC 열린 탭 원격 수신 및 동기화 요청
|
||||||
|
[Fact]
|
||||||
|
public void Gate110_MobileTabSyncEngine_RemoteTabMapping_Audit()
|
||||||
|
{
|
||||||
|
var sync = new MobileTabSyncEngine();
|
||||||
|
sync.UpdateRemoteTabs(new List<RemoteTabInfo>
|
||||||
|
{
|
||||||
|
new("t1", "Design System Talk", "https://talk.com/1"),
|
||||||
|
new("t2", "Tutorial Part 2", "https://tutorial.com/2")
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal(2, sync.RemoteTabs.Count);
|
||||||
|
|
||||||
|
string? requestedUrl = null;
|
||||||
|
sync.OnRemoteDownloadRequested += u => requestedUrl = u;
|
||||||
|
sync.RequestRemoteDownload("t2");
|
||||||
|
Assert.Equal("https://tutorial.com/2", requestedUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// [Gate 114] 비디오 스니퍼: 다중 스트림 품질 자동 협상 (Quality Negotiation)
|
||||||
|
[Fact]
|
||||||
|
public void Gate114_VideoSniffer_QualityNegotiator_Audit()
|
||||||
|
{
|
||||||
|
var streams = new List<VideoSniffItem>
|
||||||
|
{
|
||||||
|
new() { StreamUrl = "https://cdn.com/4k.mp4", Resolution = "4K" },
|
||||||
|
new() { StreamUrl = "https://cdn.com/1080p.mp4", Resolution = "1080p" },
|
||||||
|
new() { StreamUrl = "https://cdn.com/720p.mp4", Resolution = "720p" },
|
||||||
|
new() { StreamUrl = "https://cdn.com/audio.m4a", Resolution = "Audio Only" }
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1. 선호 화질 1080p -> 1080p 매칭
|
||||||
|
var best1080 = VideoSnifferParser.NegotiateBestQuality(streams, "1080p");
|
||||||
|
Assert.NotNull(best1080);
|
||||||
|
Assert.Equal("1080p", best1080.Resolution);
|
||||||
|
|
||||||
|
// 2. 선호 화질 1440p (목록에 없음) -> 1440p 이하 최고 화질인 1080p 선택
|
||||||
|
var fallback = VideoSnifferParser.NegotiateBestQuality(streams, "1440p");
|
||||||
|
Assert.NotNull(fallback);
|
||||||
|
Assert.Equal("1080p", fallback.Resolution);
|
||||||
|
|
||||||
|
// 3. 선호 화질 최고 화질("Max") -> 4K 선택
|
||||||
|
var max = VideoSnifferParser.NegotiateBestQuality(streams, "Max");
|
||||||
|
Assert.NotNull(max);
|
||||||
|
Assert.Equal("4K", max.Resolution);
|
||||||
|
|
||||||
|
// 4. 선호 화질 오디오("Audio Only") -> Audio Only 선택
|
||||||
|
var audio = VideoSnifferParser.NegotiateBestQuality(streams, "Audio Only");
|
||||||
|
Assert.NotNull(audio);
|
||||||
|
Assert.Equal("Audio Only", audio.Resolution);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
93
tests/Paca.Tests/Mobile/MobileUiProductionGateTests.cs
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using Paca.Tests.Infrastructure;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Paca.Tests.Mobile;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 모바일 프로덕션 UI 뷰, 5대 탭, 컨트롤 실존 및 레이아웃 무결성 게이트 (Gate 93 ~ Gate 97)
|
||||||
|
/// </summary>
|
||||||
|
public class MobileUiProductionGateTests
|
||||||
|
{
|
||||||
|
private static string MobileProjectDir => Directory.Exists(Path.Combine(SourceTree.Root, "src", "Paca.Mobile"))
|
||||||
|
? Path.Combine(SourceTree.Root, "src", "Paca.Mobile")
|
||||||
|
: Path.Combine(SourceTree.Root, "Paca.Mobile");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Gate93_TabbedRootPage_FiveTabs_Completeness_Audit()
|
||||||
|
{
|
||||||
|
var rootCs = File.ReadAllText(Path.Combine(MobileProjectDir, "TabbedRootPage.cs"));
|
||||||
|
Assert.Contains("QueuePage", rootCs);
|
||||||
|
Assert.Contains("DiscoverPage", rootCs);
|
||||||
|
Assert.Contains("BrowserPage", rootCs);
|
||||||
|
Assert.Contains("FavoritesPage", rootCs);
|
||||||
|
Assert.Contains("SyncSettingsPage", rootCs);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Gate94_DiscoverPage_SearchControls_Audit()
|
||||||
|
{
|
||||||
|
var pagesCs = File.ReadAllText(Path.Combine(MobileProjectDir, "Pages.cs"));
|
||||||
|
Assert.Contains("class DiscoverPage", pagesCs);
|
||||||
|
Assert.Contains("_searchEntry", pagesCs);
|
||||||
|
Assert.Contains("_platformChips", pagesCs);
|
||||||
|
Assert.Contains("YouTube", pagesCs);
|
||||||
|
Assert.Contains("Vimeo", pagesCs);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Gate95_FavoritesPage_Stash_And_Tagging_Ui_Audit()
|
||||||
|
{
|
||||||
|
var pagesCs = File.ReadAllText(Path.Combine(MobileProjectDir, "Pages.cs"));
|
||||||
|
Assert.Contains("class FavoritesPage", pagesCs);
|
||||||
|
Assert.Contains("_tagFilter", pagesCs);
|
||||||
|
Assert.Contains("_favoritesList", pagesCs);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Gate96_QueuePage_EmptyIllustration_And_Retry_Audit()
|
||||||
|
{
|
||||||
|
var pagesCs = File.ReadAllText(Path.Combine(MobileProjectDir, "Pages.cs"));
|
||||||
|
Assert.Contains("paca_empty_queue.png", pagesCs);
|
||||||
|
Assert.Contains("재시도", pagesCs);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Gate97_SyncSettingsPage_RemoteTabs_Ui_Audit()
|
||||||
|
{
|
||||||
|
var pagesCs = File.ReadAllText(Path.Combine(MobileProjectDir, "Pages.cs"));
|
||||||
|
Assert.Contains("class SyncSettingsPage", pagesCs);
|
||||||
|
Assert.Contains("_remoteTabsList", pagesCs);
|
||||||
|
Assert.Contains("PC 탭 동기화", pagesCs);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Gate111_DiscoverPage_Enqueue_And_Favorite_Action_Buttons_Audit()
|
||||||
|
{
|
||||||
|
var pagesCs = File.ReadAllText(Path.Combine(MobileProjectDir, "Pages.cs"));
|
||||||
|
Assert.Contains("_enqueueBtn", pagesCs);
|
||||||
|
Assert.Contains("_favoriteBtn", pagesCs);
|
||||||
|
Assert.Contains("다운로드 큐 추가", pagesCs);
|
||||||
|
Assert.Contains("즐겨찾기 저장", pagesCs);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Gate112_SyncSettingsPage_ThemeOptions_Includes_OLEDBlack()
|
||||||
|
{
|
||||||
|
var pagesCs = File.ReadAllText(Path.Combine(MobileProjectDir, "Pages.cs"));
|
||||||
|
Assert.Contains("OLED 블랙", pagesCs);
|
||||||
|
Assert.Contains("_wifiOnlySwitch", pagesCs);
|
||||||
|
Assert.Contains("_speedLimitEntry", pagesCs);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Gate113_AppIcon_And_Splash_Assets_Exist_And_Referenced()
|
||||||
|
{
|
||||||
|
var appIconPath = Path.Combine(MobileProjectDir, "Resources", "AppIcon", "paca_app_icon.png");
|
||||||
|
var splashPath = Path.Combine(MobileProjectDir, "Resources", "Splash", "paca_splash_screen.png");
|
||||||
|
Assert.True(File.Exists(appIconPath), "paca_app_icon.png must exist");
|
||||||
|
Assert.True(File.Exists(splashPath), "paca_splash_screen.png must exist");
|
||||||
|
}
|
||||||
|
}
|
||||||