Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions Screenbox.Core/Services/FilesService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -232,4 +232,15 @@ private FileOpenPicker GetFilePickerForFormats(IReadOnlyCollection<string> forma

return picker;
}

public IAsyncOperation<StorageFile> PickSaveFileAsync(string suggestedFileName, string description, IList<string> extensions)
{
FileSavePicker picker = new()
{
SuggestedStartLocation = PickerLocationId.DocumentsLibrary,
SuggestedFileName = suggestedFileName
};
picker.FileTypeChoices.Add(description, extensions);
return picker.PickSaveFileAsync();
}
}
1 change: 1 addition & 0 deletions Screenbox.Core/Services/IFilesService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,6 @@ public interface IFilesService
public Task<T> LoadFromDiskAsync<T>(StorageFolder folder, string fileName);
public Task<T> LoadFromDiskAsync<T>(StorageFile file);
public Task<MediaInfo> GetMediaInfoAsync(StorageFile file);
public IAsyncOperation<StorageFile> PickSaveFileAsync(string suggestedFileName, string description, IList<string> extensions);
}
}
10 changes: 10 additions & 0 deletions Screenbox.Core/Services/IPlaylistService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -70,4 +70,14 @@ public interface IPlaylistService
/// Appends media items to an existing persistent playlist and persists the updated playlist.
/// </summary>
Task AddToPlaylistAsync(string playlistId, IReadOnlyList<MediaViewModel> items);

/// <summary>
/// Export playlist items to an M3U8 file
/// </summary>
Task ExportToM3u8Async(IReadOnlyList<MediaViewModel> items, StorageFile file);

/// <summary>
/// Import media paths from an M3U8 file
/// </summary>
Task<IReadOnlyList<string>> ImportFromM3u8Async(StorageFile file);
}
37 changes: 37 additions & 0 deletions Screenbox.Core/Services/PlaylistService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

using System;
using System.Collections.Generic;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using CommunityToolkit.Diagnostics;
Expand Down Expand Up @@ -231,4 +232,40 @@ public async Task AddToPlaylistAsync(string playlistId, IReadOnlyList<MediaViewM
playlist.LastUpdated = DateTimeOffset.Now;
await SavePlaylistAsync(playlist);
}

/// <summary>
/// Exports the given media items to an M3U8 playlist file.
/// </summary>
public async Task ExportToM3u8Async(IReadOnlyList<MediaViewModel> 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());
}

/// <summary>
/// Reads media paths from an M3U8 playlist file, skipping comment lines.
/// </summary>
public async Task<IReadOnlyList<string>> ImportFromM3u8Async(StorageFile file)
{
string content = await FileIO.ReadTextAsync(file);
var paths = new List<string>();
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;
}
}
20 changes: 20 additions & 0 deletions Screenbox.Core/ViewModels/PlaylistDetailsPageViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -87,6 +88,25 @@ private async Task AddFilesAsync()
await Source.AddItemsAsync(mediaList);
}

/// <summary>
/// Exports the current playlist to an M3U8 file chosen by the user.
/// </summary>
[RelayCommand(CanExecute = nameof(CanExportPlaylist))]
private async Task ExportPlaylistAsync()
{
if (Source == null) return;

StorageFile? file = await _filesService.PickSaveFileAsync(
Source.Name,
"M3U Playlist",
new List<string> { ".m3u8" });
if (file == null) return;

await _playlistService.ExportToM3u8Async(Source.Items, file);
}

private bool CanExportPlaylist() => Source?.ItemsCount > 0;

public async Task<bool> DeletePlaylistAsync()
{
if (Source == null) return false;
Expand Down
93 changes: 92 additions & 1 deletion Screenbox.Core/ViewModels/PlaylistsPageViewModel.cs
Original file line number Diff line number Diff line change
@@ -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<PlaylistViewModel> 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)
Expand All @@ -50,6 +61,50 @@ public async Task DeletePlaylistAsync(PlaylistViewModel playlist)
Playlists.Remove(playlist);
}

/// <summary>
/// Imports a playlist from an M3U8 file chosen by the user and adds it to the collection.
/// </summary>
[RelayCommand]
private async Task ImportPlaylistAsync()
{
StorageFile? file = await _filesService.PickFileAsync(".m3u8", ".m3u");
if (file == null) return;

IReadOnlyList<string> paths = await _playlistService.ImportFromM3u8Async(file);

var playlist = Ioc.Default.GetRequiredService<PlaylistViewModel>();
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);
}

/// <summary>
/// Exports the given playlist to an M3U8 file chosen by the user.
/// </summary>
[RelayCommand(CanExecute = nameof(NotEmpty))]
private async Task ExportPlaylistAsync(PlaylistViewModel playlist)
{
StorageFile? file = await _filesService.PickSaveFileAsync(
playlist.Name,
"M3U Playlist",
new List<string> { ".m3u8" });
if (file == null) return;

await _playlistService.ExportToM3u8Async(playlist.Items, file);
}

private static bool NotEmpty(PlaylistViewModel? playlist) => playlist?.ItemsCount > 0;

[RelayCommand(CanExecute = nameof(NotEmpty))]
Expand All @@ -70,4 +125,40 @@ private async Task AddToQueue(PlaylistViewModel playlistVm)
{
Messenger.SendAddToQueue(playlistVm.Items);
}

/// <summary>
/// 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 <paramref name="m3u8File"/>.
/// </summary>
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;
}
}
6 changes: 6 additions & 0 deletions Screenbox/Pages/PlaylistDetailsPage.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,12 @@
Label="{strings:Resources Key=AddFiles}"
Style="{StaticResource DefaultButtonAppBarButtonStyle}" />

<AppBarButton
Command="{x:Bind ViewModel.ExportPlaylistCommand}"
Icon="{ui:FontIcon Glyph=&#xEDE1;}"
Label="{strings:Resources Key=ExportPlaylist}"
Style="{StaticResource DefaultButtonAppBarButtonStyle}" />

<CommandBar.SecondaryCommands>
<AppBarButton
Command="{x:Bind RenamePlaylistCommand}"
Expand Down
18 changes: 16 additions & 2 deletions Screenbox/Pages/PlaylistsPage.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@
CommandParameter="{Binding}"
Icon="{ui:SymbolIcon Symbol=Delete}"
Text="{strings:Resources Key=Delete}" />
<MenuFlyoutSeparator />
<MenuFlyoutItem
Command="{x:Bind ViewModel.ExportPlaylistCommand}"
CommandParameter="{Binding}"
Icon="{ui:FontIcon Glyph=&#xEDE1;}"
Text="{strings:Resources Key=ExportPlaylist}" />
</MenuFlyout>

<DataTemplate x:Key="MediaGridViewItemTemplate" x:DataType="viewmodels:PlaylistViewModel">
Expand Down Expand Up @@ -84,7 +90,7 @@
x:Name="HeaderText"
Style="{StaticResource TitleMediumTextBlockStyle}"
Text="{strings:Resources Key=Playlists}" />
<Button
<muxc:SplitButton
x:Name="HeaderCreateButton"
HorizontalAlignment="Right"
VerticalAlignment="Top"
Expand All @@ -96,7 +102,15 @@
Margin="8,0,0,0"
Text="{strings:Resources Key=NewPlaylist}" />
</StackPanel>
</Button>
<muxc:SplitButton.Flyout>
<MenuFlyout>
<MenuFlyoutItem
Command="{x:Bind ViewModel.ImportPlaylistCommand}"
Icon="{ui:FontIcon Glyph=&#xEB05;}"
Text="{strings:Resources Key=ImportPlaylist}" />
</MenuFlyout>
</muxc:SplitButton.Flyout>
</muxc:SplitButton>
</Grid>

<GridView
Expand Down
2 changes: 1 addition & 1 deletion Screenbox/Pages/PlaylistsPage.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ public PlaylistsPage()
Common = Ioc.Default.GetRequiredService<CommonViewModel>();
}

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))
Expand Down
6 changes: 6 additions & 0 deletions Screenbox/Strings/en-US/Resources.resw
Original file line number Diff line number Diff line change
Expand Up @@ -1033,6 +1033,12 @@
<value>Are you sure you want to delete '{0}' playlist? This action cannot be undone.</value>
<comment>#Format[String playlistName]</comment>
</data>
<data name="ImportPlaylist" xml:space="preserve">
<value>Import playlist</value>
</data>
<data name="ExportPlaylist" xml:space="preserve">
<value>Export playlist</value>
</data>
<data name="SettingsSavePlaybackPositionHeader" xml:space="preserve">
<value>Playback position history</value>
</data>
Expand Down