diff --git a/Screenbox.Core/Services/FilesService.cs b/Screenbox.Core/Services/FilesService.cs index 0103fc6dd..295250256 100644 --- a/Screenbox.Core/Services/FilesService.cs +++ b/Screenbox.Core/Services/FilesService.cs @@ -232,4 +232,15 @@ private FileOpenPicker GetFilePickerForFormats(IReadOnlyCollection forma return picker; } + + public IAsyncOperation PickSaveFileAsync(string suggestedFileName, string description, IList extensions) + { + FileSavePicker picker = new() + { + SuggestedStartLocation = PickerLocationId.DocumentsLibrary, + SuggestedFileName = suggestedFileName + }; + picker.FileTypeChoices.Add(description, extensions); + return picker.PickSaveFileAsync(); + } } diff --git a/Screenbox.Core/Services/IFilesService.cs b/Screenbox.Core/Services/IFilesService.cs index f807abbcf..89a75a35b 100644 --- a/Screenbox.Core/Services/IFilesService.cs +++ b/Screenbox.Core/Services/IFilesService.cs @@ -29,5 +29,6 @@ public interface IFilesService public Task LoadFromDiskAsync(StorageFolder folder, string fileName); public Task LoadFromDiskAsync(StorageFile file); public Task GetMediaInfoAsync(StorageFile file); + public IAsyncOperation PickSaveFileAsync(string suggestedFileName, string description, IList extensions); } } \ No newline at end of file diff --git a/Screenbox.Core/Services/IPlaylistService.cs b/Screenbox.Core/Services/IPlaylistService.cs index 79c8503d0..a25a1c8e5 100644 --- a/Screenbox.Core/Services/IPlaylistService.cs +++ b/Screenbox.Core/Services/IPlaylistService.cs @@ -70,4 +70,14 @@ public interface IPlaylistService /// Appends media items to an existing persistent playlist and persists the updated playlist. /// Task AddToPlaylistAsync(string playlistId, IReadOnlyList items); + + /// + /// Export playlist items to an M3U8 file + /// + Task ExportToM3u8Async(IReadOnlyList items, StorageFile file); + + /// + /// Import media paths from an M3U8 file + /// + Task> ImportFromM3u8Async(StorageFile file); } diff --git a/Screenbox.Core/Services/PlaylistService.cs b/Screenbox.Core/Services/PlaylistService.cs index 8b7199f4d..5d489f1ab 100644 --- a/Screenbox.Core/Services/PlaylistService.cs +++ b/Screenbox.Core/Services/PlaylistService.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; +using System.Text; using System.Threading; using System.Threading.Tasks; using CommunityToolkit.Diagnostics; @@ -231,4 +232,40 @@ public async Task AddToPlaylistAsync(string playlistId, IReadOnlyList + /// Exports the given media items to an M3U8 playlist file. + /// + public async Task ExportToM3u8Async(IReadOnlyList items, StorageFile file) + { + var sb = new StringBuilder(); + sb.AppendLine("#EXTM3U"); + foreach (MediaViewModel item in items) + { + long duration = (long)item.Duration.TotalSeconds; + sb.AppendLine($"#EXTINF:{duration},{item.Name}"); + sb.AppendLine(item.Location); + } + + await FileIO.WriteTextAsync(file, sb.ToString()); + } + + /// + /// Reads media paths from an M3U8 playlist file, skipping comment lines. + /// + public async Task> ImportFromM3u8Async(StorageFile file) + { + string content = await FileIO.ReadTextAsync(file); + var paths = new List(); + foreach (string line in content.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)) + { + string trimmed = line.Trim(); + if (!trimmed.StartsWith("#") && trimmed.Length > 0) + { + paths.Add(trimmed); + } + } + + return paths; + } } diff --git a/Screenbox.Core/ViewModels/PlaylistDetailsPageViewModel.cs b/Screenbox.Core/ViewModels/PlaylistDetailsPageViewModel.cs index 591eb45cb..1d4c6451f 100644 --- a/Screenbox.Core/ViewModels/PlaylistDetailsPageViewModel.cs +++ b/Screenbox.Core/ViewModels/PlaylistDetailsPageViewModel.cs @@ -19,6 +19,7 @@ namespace Screenbox.Core.ViewModels; public sealed partial class PlaylistDetailsPageViewModel : ObservableRecipient { [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(ExportPlaylistCommand))] private PlaylistViewModel? _source; private readonly IFilesService _filesService; @@ -87,6 +88,25 @@ private async Task AddFilesAsync() await Source.AddItemsAsync(mediaList); } + /// + /// Exports the current playlist to an M3U8 file chosen by the user. + /// + [RelayCommand(CanExecute = nameof(CanExportPlaylist))] + private async Task ExportPlaylistAsync() + { + if (Source == null) return; + + StorageFile? file = await _filesService.PickSaveFileAsync( + Source.Name, + "M3U Playlist", + new List { ".m3u8" }); + if (file == null) return; + + await _playlistService.ExportToM3u8Async(Source.Items, file); + } + + private bool CanExportPlaylist() => Source?.ItemsCount > 0; + public async Task DeletePlaylistAsync() { if (Source == null) return false; diff --git a/Screenbox.Core/ViewModels/PlaylistsPageViewModel.cs b/Screenbox.Core/ViewModels/PlaylistsPageViewModel.cs index 3e61ecd33..6f6d1a2d2 100644 --- a/Screenbox.Core/ViewModels/PlaylistsPageViewModel.cs +++ b/Screenbox.Core/ViewModels/PlaylistsPageViewModel.cs @@ -1,31 +1,42 @@ #nullable enable +using System; +using System.Collections.Generic; using System.Collections.ObjectModel; +using System.IO; +using System.Linq; using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.DependencyInjection; using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Messaging; using Screenbox.Core.Contexts; +using Screenbox.Core.Factories; using Screenbox.Core.Helpers; using Screenbox.Core.Messages; using Screenbox.Core.Services; +using Windows.Storage; namespace Screenbox.Core.ViewModels; public partial class PlaylistsPageViewModel : ObservableRecipient { private readonly IPlaylistService _playlistService; + private readonly IFilesService _filesService; private readonly PlaylistsContext _playlistsContext; + private readonly MediaViewModelFactory _mediaFactory; public ObservableCollection Playlists => _playlistsContext.Playlists; [ObservableProperty] private PlaylistViewModel? _selectedPlaylist; - public PlaylistsPageViewModel(IPlaylistService playlistService, PlaylistsContext playlistsContext) + public PlaylistsPageViewModel(IPlaylistService playlistService, IFilesService filesService, + PlaylistsContext playlistsContext, MediaViewModelFactory mediaFactory) { _playlistService = playlistService; + _filesService = filesService; _playlistsContext = playlistsContext; + _mediaFactory = mediaFactory; } public async Task CreatePlaylistAsync(string displayName) @@ -50,6 +61,50 @@ public async Task DeletePlaylistAsync(PlaylistViewModel playlist) Playlists.Remove(playlist); } + /// + /// Imports a playlist from an M3U8 file chosen by the user and adds it to the collection. + /// + [RelayCommand] + private async Task ImportPlaylistAsync() + { + StorageFile? file = await _filesService.PickFileAsync(".m3u8", ".m3u"); + if (file == null) return; + + IReadOnlyList paths = await _playlistService.ImportFromM3u8Async(file); + + var playlist = Ioc.Default.GetRequiredService(); + string playlistName = Path.GetFileNameWithoutExtension(file.Name); + playlist.Name = string.IsNullOrWhiteSpace(playlistName) ? file.Name : playlistName; + + var mediaItems = paths + .Select(p => TryParseUri(p, file, out Uri? uri) ? uri : null) + .Where(uri => uri != null) + .Select(uri => _mediaFactory.GetSingleton(uri!)) + .ToList(); + + if (mediaItems.Count > 0) + await playlist.AddItemsAsync(mediaItems); + else + await playlist.SaveAsync(); + + Playlists.Insert(0, playlist); + } + + /// + /// Exports the given playlist to an M3U8 file chosen by the user. + /// + [RelayCommand(CanExecute = nameof(NotEmpty))] + private async Task ExportPlaylistAsync(PlaylistViewModel playlist) + { + StorageFile? file = await _filesService.PickSaveFileAsync( + playlist.Name, + "M3U Playlist", + new List { ".m3u8" }); + if (file == null) return; + + await _playlistService.ExportToM3u8Async(playlist.Items, file); + } + private static bool NotEmpty(PlaylistViewModel? playlist) => playlist?.ItemsCount > 0; [RelayCommand(CanExecute = nameof(NotEmpty))] @@ -70,4 +125,40 @@ private async Task AddToQueue(PlaylistViewModel playlistVm) { Messenger.SendAddToQueue(playlistVm.Items); } + + /// + /// Tries to parse the given path as an absolute URI. + /// Handles local Windows/Unix file paths as well as standard URI strings. + /// Relative paths are resolved against the directory of . + /// + private static bool TryParseUri(string path, StorageFile m3u8File, out Uri? uri) + { + // Try as absolute URI first (covers http://, file:///, etc.) + if (Uri.TryCreate(path, UriKind.Absolute, out uri)) + return true; + + // Try as a local absolute file path (e.g. C:\... or /home/...) + try + { + uri = new Uri(path); + return uri.IsAbsoluteUri; + } + catch { } + + // Try as a path relative to the M3U8 file's directory + try + { + string? folder = Path.GetDirectoryName(m3u8File.Path); + if (!string.IsNullOrEmpty(folder)) + { + string combined = Path.GetFullPath(Path.Combine(folder, path)); + uri = new Uri(combined); + return true; + } + } + catch { } + + uri = null; + return false; + } } diff --git a/Screenbox/Pages/PlaylistDetailsPage.xaml b/Screenbox/Pages/PlaylistDetailsPage.xaml index fbcb3da06..bf14dc68a 100644 --- a/Screenbox/Pages/PlaylistDetailsPage.xaml +++ b/Screenbox/Pages/PlaylistDetailsPage.xaml @@ -204,6 +204,12 @@ Label="{strings:Resources Key=AddFiles}" Style="{StaticResource DefaultButtonAppBarButtonStyle}" /> + + + + @@ -84,7 +90,7 @@ x:Name="HeaderText" Style="{StaticResource TitleMediumTextBlockStyle}" Text="{strings:Resources Key=Playlists}" /> - + + + + + + (); } - private async void HeaderCreateButton_OnClick(object sender, RoutedEventArgs e) + private async void HeaderCreateButton_OnClick(Microsoft.UI.Xaml.Controls.SplitButton sender, Microsoft.UI.Xaml.Controls.SplitButtonClickEventArgs args) { string? playlistName = await CreatePlaylistDialog.GetPlaylistNameAsync(); if (!string.IsNullOrWhiteSpace(playlistName)) diff --git a/Screenbox/Strings/en-US/Resources.resw b/Screenbox/Strings/en-US/Resources.resw index d71531487..2dcbf29cd 100644 --- a/Screenbox/Strings/en-US/Resources.resw +++ b/Screenbox/Strings/en-US/Resources.resw @@ -1033,6 +1033,12 @@ Are you sure you want to delete '{0}' playlist? This action cannot be undone. #Format[String playlistName] + + Import playlist + + + Export playlist + Playback position history