480 lines
22 KiB
C#
480 lines
22 KiB
C#
using Microsoft.Maui.Controls;
|
|
using System.Net.Http;
|
|
|
|
namespace VideoDownloader.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,
|
|
};
|
|
if (DeviceInfo.Current.DeviceType == DeviceType.Physical)
|
|
{
|
|
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 = "수동 연결" };
|
|
connectBtn.Clicked += (_, _) =>
|
|
{
|
|
if (ApiClient.Instance.Configure(_manual.Text, _manualToken.Text)) _status.Text = "✅ 연결됨";
|
|
else _status.Text = "❌ 연결 실패 — 주소/토큰 확인";
|
|
};
|
|
var forgetBtn = new Button { Text = "연결 해제", BackgroundColor = Colors.Gray };
|
|
forgetBtn.Clicked += (_, _) => { ApiClient.Instance.Forget(); _status.Text = "해제됨"; };
|
|
|
|
Content = new ScrollView
|
|
{
|
|
Content = new VerticalStackLayout
|
|
{
|
|
Padding = 20, Spacing = 10,
|
|
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();
|
|
private readonly Label _trouble = new() { FontSize = 12, TextColor = Colors.OrangeRed, IsVisible = false };
|
|
private readonly CollectionView _list = new();
|
|
private readonly IDispatcherTimer _timer;
|
|
private readonly QueueCompletionDetector _completions = new();
|
|
private List<QueueItem> _last = new();
|
|
|
|
public QueuePage()
|
|
{
|
|
Title = "다운로드 큐";
|
|
var addBtn = new Button { Text = "PC에서 받기" };
|
|
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();
|
|
};
|
|
|
|
_list.ItemTemplate = new DataTemplate(() =>
|
|
{
|
|
var title = new Label { FontSize = 14, FontAttributes = FontAttributes.Bold, LineBreakMode = LineBreakMode.TailTruncation };
|
|
title.SetBinding(Label.TextProperty, "Title");
|
|
var status = new Label { FontSize = 12, TextColor = Colors.Gray };
|
|
status.SetBinding(Label.TextProperty, new Binding("Percent", stringFormat: "{0:0}%"));
|
|
var bar = new ProgressBar { HeightRequest = 6 };
|
|
bar.SetBinding(ProgressBar.ProgressProperty, new Binding("Percent", converter: new PercentConverter()));
|
|
var state = new Label { FontSize = 11, TextColor = Colors.Gray };
|
|
state.SetBinding(Label.TextProperty, "Status");
|
|
|
|
var pause = new Button { Text = "⏸" };
|
|
var resume = new Button { Text = "▶" };
|
|
var cancel = new Button { Text = "✕", BackgroundColor = Colors.Gray };
|
|
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, 4), Padding = 12, CornerRadius = 10,
|
|
Content = new VerticalStackLayout
|
|
{
|
|
Spacing = 6,
|
|
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, _summary, _trouble, _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();
|
|
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 = Colors.Black };
|
|
thumb.SetBinding(Image.SourceProperty, new Binding(".", converter: new ThumbConverter()));
|
|
var name = new Label { FontSize = 13, FontAttributes = FontAttributes.Bold, LineBreakMode = LineBreakMode.TailTruncation, LineHeight = 1.1 };
|
|
name.SetBinding(Label.TextProperty, "FileName");
|
|
var size = new Label { FontSize = 10, TextColor = Colors.Gray };
|
|
size.SetBinding(Label.TextProperty, new Binding("SizeBytes", stringFormat: "{0:N0} bytes"));
|
|
|
|
// U4 폰 저장: 카드 우측 버튼, 진행률을 버튼 텍스트로 표시
|
|
var save = new Button { Text = "💾 저장", FontSize = 12, Padding = new Thickness(10, 4), VerticalOptions = LayoutOptions.Center };
|
|
save.Clicked += async (_, _) => await SaveOffline(save, save.BindingContext as LibraryItem);
|
|
|
|
var card = new Frame
|
|
{
|
|
Margin = new Thickness(6, 4), Padding = 8, CornerRadius = 10,
|
|
Content = new VerticalStackLayout { Spacing = 4, Children = { thumb, name, size, save } }
|
|
};
|
|
// CollectionView.SelectionChanged 는 Android 에서 발화 불안정 → 템플릿 카드에 탭 제스처
|
|
var tap = new TapGestureRecognizer();
|
|
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 = "새로고침" };
|
|
refresh.Clicked += async (_, _) => await Reload();
|
|
|
|
Content = new VerticalStackLayout { Padding = 16, 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-U4 저장 탭: 폰에 저장된 항목 목록 — 오프라인 재생/삭제.</summary>
|
|
public sealed class DownloadsPage : ContentPage
|
|
{
|
|
private readonly CollectionView _list = new();
|
|
private readonly Label _empty = new()
|
|
{
|
|
Text = "저장된 항목이 없습니다 — 라이브러리에서 💾 저장",
|
|
TextColor = Colors.Gray, HorizontalOptions = LayoutOptions.Center, Margin = new Thickness(0, 24),
|
|
};
|
|
|
|
public DownloadsPage()
|
|
{
|
|
Title = "저장";
|
|
_list.ItemTemplate = new DataTemplate(() =>
|
|
{
|
|
var name = new Label { FontSize = 14, FontAttributes = FontAttributes.Bold, LineBreakMode = LineBreakMode.TailTruncation };
|
|
name.SetBinding(Label.TextProperty, "FileName");
|
|
var size = new Label { FontSize = 11, TextColor = Colors.Gray };
|
|
size.SetBinding(Label.TextProperty, new Binding("SizeBytes", stringFormat: "{0:N0} bytes — 오프라인"));
|
|
|
|
var del = new Button { Text = "🗑", FontSize = 12, Padding = new Thickness(10, 4), VerticalOptions = LayoutOptions.Center };
|
|
del.Clicked += (_, _) =>
|
|
{
|
|
if ((del.BindingContext as OfflineItem) is { } it) { AppOfflineStore.Default.Delete(it.Id); Reload(); }
|
|
};
|
|
|
|
var card = new Frame
|
|
{
|
|
Margin = new Thickness(0, 4), Padding = 12, CornerRadius = 10,
|
|
Content = new HorizontalStackLayout { Spacing = 8, Children = { new VerticalStackLayout { Children = { name, size } }, del } }
|
|
};
|
|
var tap = new TapGestureRecognizer();
|
|
tap.Tapped += (_, _) =>
|
|
{
|
|
if ((card.BindingContext as OfflineItem) is { } it)
|
|
_ = Application.Current!.MainPage!.Navigation.PushModalAsync(
|
|
new NavigationPage(new PlayerPage(new LibraryItem(it.Id, it.FileName, it.SizeBytes, DateTime.MinValue), AppOfflineStore.Default.PathFor(it.Id))));
|
|
};
|
|
card.GestureRecognizers.Add(tap);
|
|
return card;
|
|
});
|
|
_list.SelectionMode = SelectionMode.None;
|
|
|
|
var refresh = new Button { Text = "새로고침" };
|
|
refresh.Clicked += (_, _) => Reload();
|
|
|
|
Content = new VerticalStackLayout { Padding = 16, Children = { refresh, _empty, _list } };
|
|
_empty.IsVisible = false;
|
|
Appearing += (_, _) => Reload();
|
|
}
|
|
|
|
private void Reload()
|
|
{
|
|
var items = AppOfflineStore.Default.List();
|
|
_list.ItemsSource = items.Count > 0 ? items : null;
|
|
_empty.IsVisible = items.Count == 0;
|
|
}
|
|
}
|
|
|
|
/// <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();
|
|
}
|