M2-U3+U4 완성: 이어보기 위치 동기화(POST/GET /api/position + PositionStore JSON 영속, 플레이어 5초 주기·종료 저장·재개 시크) + 폰 오프라인 저장(ApiClient.DownloadFileAsync .part 스트리밍, OfflineStore manifest, 라이브러리 💾 버튼, 저장 탭 오프라인 재생/삭제). 서버 RED→GREEN E2E + 에뮬레이터 E2E(위치 13.3→35.2초 이어재생, 서버 정지 후 오프라인 재생 프레임 실증). 104 tests GREEN
This commit is contained in:
parent
17f3e41cd2
commit
27c91c04dd
9 changed files with 356 additions and 8 deletions
|
|
@ -165,10 +165,15 @@ public sealed class LibraryPage : ContentPage
|
|||
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"));
|
||||
|
||||
// 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(0, 4), Padding = 12, CornerRadius = 10,
|
||||
Content = new VerticalStackLayout { Children = { name, size } }
|
||||
Content = new HorizontalStackLayout { Spacing = 8, Children = { new VerticalStackLayout { Children = { name, size } }, save } }
|
||||
};
|
||||
// CollectionView.SelectionChanged 는 Android 에서 발화 불안정 → 템플릿 카드에 탭 제스처
|
||||
var tap = new TapGestureRecognizer();
|
||||
|
|
@ -185,6 +190,27 @@ public sealed class LibraryPage : ContentPage
|
|||
Appearing += async (_, _) => await Reload();
|
||||
}
|
||||
|
||||
/// <summary>U4: 항목을 폰 저장 폴더로 스트리밍 다운로드(진행률 % → 완료 ✓).</summary>
|
||||
private static async Task SaveOffline(Button button, LibraryItem? item)
|
||||
{
|
||||
if (item == null) return;
|
||||
if (OfflineStore.Default.Exists(item.Id)) { button.Text = "✓ 저장됨"; return; }
|
||||
button.Text = "0%";
|
||||
try
|
||||
{
|
||||
var dest = OfflineStore.Default.PathFor(item.Id);
|
||||
var progress = new Progress<double>(p => MainThread.BeginInvokeOnMainThread(
|
||||
() => button.Text = p >= 1 ? "✓ 저장됨" : $"{(int)(p * 100)}%"));
|
||||
await ApiClient.Instance.DownloadFileAsync(item, dest, progress, CancellationToken.None);
|
||||
OfflineStore.Default.Record(item.Id, item.FileName);
|
||||
button.Text = "✓ 저장됨";
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
MainThread.BeginInvokeOnMainThread(() => button.Text = $"❌ {e.Message[..Math.Min(20, e.Message.Length)]}");
|
||||
}
|
||||
}
|
||||
|
||||
private async void OpenPlayer(LibraryItem item)
|
||||
{
|
||||
// 탭 자식엔 NavigationPage 가 없어 PushAsync 불가 → 루트에서 모달 push (닫기는 상단바 뒤로)
|
||||
|
|
@ -198,7 +224,66 @@ public sealed class LibraryPage : ContentPage
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>M2 재생 페이지: Range 스트리밍 재생(MediaElement).</summary>
|
||||
/// <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) { OfflineStore.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), OfflineStore.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 = OfflineStore.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()
|
||||
|
|
@ -207,16 +292,41 @@ public sealed class PlayerPage : ContentPage
|
|||
ShouldAutoPlay = true,
|
||||
ShouldShowPlaybackControls = true,
|
||||
};
|
||||
private readonly string? _itemId; // 스트리밍 재생 시 위치 동기화 대상
|
||||
private bool _sync; // 위치 저장 활성(오프라인 재생은 서버 없이도 동작)
|
||||
|
||||
public PlayerPage(LibraryItem item)
|
||||
public PlayerPage(LibraryItem item, string? localPath = null)
|
||||
{
|
||||
Title = item.FileName;
|
||||
_media.Source = CommunityToolkit.Maui.Views.MediaSource.FromUri(ApiClient.Instance.StreamUrl(item));
|
||||
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;
|
||||
}
|
||||
|
||||
protected override void OnDisappearing()
|
||||
{
|
||||
if (_sync && _itemId != null && _media.Position > TimeSpan.Zero)
|
||||
_ = ApiClient.Instance.PostPositionAsync(_itemId, _media.Position.TotalSeconds);
|
||||
_media.Stop();
|
||||
_media.Handler?.DisconnectHandler(); // 재생 리소스 해제
|
||||
base.OnDisappearing();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue