From f7302b0e9c6bf5b276b2f9581111a5b541a62a9b Mon Sep 17 00:00:00 2001 From: JPVenson Date: Sun, 22 Dec 2024 18:24:00 +0100 Subject: [PATCH] Feature/video queue (#23) * Added VideoQueue Control * Added VideoQueue Example * Added Additional Attributes bag support * Added conditional render for additional attributes * Fixed NREX with Additional Attributes * Added Play method for direct playback of a single VideoSource * Added warning ignores * Added additional check that no prior event is overwritten * Updated comment for delay clarification * Added additional event types * Added Extensive exception information * Updated Example property access * Updated Example * Updated Readme --------- Co-authored-by: JPVenson --- README.md | 61 +++++ samples/SharedRCL/Pages/QueueItems.razor | 82 +++++++ samples/SharedRCL/Shared/NavMenu.razor | 5 + src/Blazored.Video/BlazoredVideo.razor | 4 +- src/Blazored.Video/BlazoredVideo.razor.cs | 8 +- src/Blazored.Video/RepeatValues.cs | 22 ++ src/Blazored.Video/VideoItem.cs | 69 ++++++ src/Blazored.Video/VideoItemData.cs | 23 ++ src/Blazored.Video/VideoQueue.cs | 277 ++++++++++++++++++++++ src/Blazored.Video/VideoSource.cs | 48 ++++ src/Blazored.Video/VideoSourceData.cs | 38 +++ 11 files changed, 633 insertions(+), 4 deletions(-) create mode 100644 samples/SharedRCL/Pages/QueueItems.razor create mode 100644 src/Blazored.Video/RepeatValues.cs create mode 100644 src/Blazored.Video/VideoItem.cs create mode 100644 src/Blazored.Video/VideoItemData.cs create mode 100644 src/Blazored.Video/VideoQueue.cs create mode 100644 src/Blazored.Video/VideoSource.cs create mode 100644 src/Blazored.Video/VideoSourceData.cs diff --git a/README.md b/README.md index 7eff1fb..979ff81 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ The easiest html5 `video` implementation for [Blazor](https://blazor.net) applic ![Screenshot of the component in action](screenshot.png) ## Changelog + ### 2022-24-12 Version 1.1 - Bump dotnet version to 6.0 as 3.x and 5.x are now out of support. - Add standard Methods and Properties (big thanks to https://github.com/JPVenson) and Async versions (for Server/WASM). (Issues #17 #9) @@ -243,6 +244,66 @@ _Note: Attempting to read/write Properties from Blazor Server will throw a runti Example - Remote JS (Server) and WASM `int duration = await videoRef.GetDurationAsync()` +## Video Queue +> Hint: When using the `VideoQueue` component, the `BlazoredVideo.EndedEvent` is unavailable as it is utilized by the `VideoQueue`. You must instead use the `VideoQueue.OnNextPlayed` or `OnNextPlayed.OnPlaylistEnded` events. + +By using the `` component instead of setting your `` directly you can create a queue of videos that will be played sequentially. The VideoQueue supports multiple versions of each source and can be set to different repeat behaviors: + +Example Simple queue +```razor + + + + + + + +``` +The simple queue will play all 3 videos after each other and then stop. It is possible to control the repeat behavior by setting the Repeat property. + +| Repeat | Description +| --- | --- | +| NoLoop | Plays all videos in order and stops after the last one +| LoopOne | Repeats the current video forever +| LoopAll | Starts at the beginning of the queue after the last video was played + +It is also possible to control the VideoQueue directly by obtaining the VideoQueue reference and invoking ether `PlayNext` or `PlayPrevious` like this: +```razor + + + + + + + + + +
+ + +
+ +@code +{ + VideoQueue videoQueue; +} + +``` + +To provide multiple versions of your video you can create a `VideoSource` under each `VideoItem`. +```razor + + + + + + + + + + +``` + ### Customising the html The Video can be customised using standard CSS techniques. diff --git a/samples/SharedRCL/Pages/QueueItems.razor b/samples/SharedRCL/Pages/QueueItems.razor new file mode 100644 index 0000000..31eacf8 --- /dev/null +++ b/samples/SharedRCL/Pages/QueueItems.razor @@ -0,0 +1,82 @@ +@page "/queue" + +

Blazored Video Demo - Queue

+ +
+ + + + + + + + + + + + + @if (videoQueue != null) + { +
+ @foreach (var videos in videoQueue.VideoItems) + { +
+ @(videos.VideoSourceData.First().Source.Split('/').Last()) +
+ } +
+ } + +
+ + +
+ + +
+ +
+ +
+ + + + @foreach (var repeatVal in Enum.GetValues(typeof(RepeatValues))) + { + + } + +
+
+ + +
+
+ +@code +{ + VideoQueue videoQueue; + + public RepeatValues Repeat { get; set; } + public int Delay { get; set; } + + protected override void OnAfterRender(bool firstRender) + { + if (firstRender) + { + SetupDisplay(); + } + } + + private void SetupDisplay() + { + StateHasChanged(); + } +} diff --git a/samples/SharedRCL/Shared/NavMenu.razor b/samples/SharedRCL/Shared/NavMenu.razor index 5ed61fa..7f32a6e 100644 --- a/samples/SharedRCL/Shared/NavMenu.razor +++ b/samples/SharedRCL/Shared/NavMenu.razor @@ -42,6 +42,11 @@ Multiple Videos + diff --git a/src/Blazored.Video/BlazoredVideo.razor b/src/Blazored.Video/BlazoredVideo.razor index 62174b5..43772d6 100644 --- a/src/Blazored.Video/BlazoredVideo.razor +++ b/src/Blazored.Video/BlazoredVideo.razor @@ -19,4 +19,6 @@ } } *@ - \ No newline at end of file + + + \ No newline at end of file diff --git a/src/Blazored.Video/BlazoredVideo.razor.cs b/src/Blazored.Video/BlazoredVideo.razor.cs index d136f74..a204410 100644 --- a/src/Blazored.Video/BlazoredVideo.razor.cs +++ b/src/Blazored.Video/BlazoredVideo.razor.cs @@ -178,11 +178,11 @@ async Task Implement(VideoEvents eventName) { await jsModule.InvokeVoidAsync("registerCustomEventHandler", videoRef, eventName.ToString().ToLower(), options.GetPayload()); } - catch (Exception ex) - { + catch (Exception ex) + { LoggerFactory .CreateLogger(nameof(BlazoredVideo)) - .LogError(ex, "Failed to register an event handler for {0}", eventName); + .LogError(ex, "Failed to register an event handler for {0}", eventName); } } @@ -199,6 +199,7 @@ protected virtual void OnChange(ChangeEventArgs args) { videoData = JsonSerializer.Deserialize(ThisEvent, serializationOptions); videoData.Video = this; + videoData.State ??= new VideoState(); videoData.State.Video = this; } catch (Exception ex) @@ -304,6 +305,7 @@ protected virtual void OnChange(ChangeEventArgs args) // Here is our catch-all event handler call! EventFired?.Invoke(videoData); } + bool RegisterEventFired => EventFiredEventRequired || EventFiredRequired; bool RegisterAbort => AbortEventRequired || AbortRequired; bool RegisterCanPlay => CanPlayEventRequired || CanPlayRequired; diff --git a/src/Blazored.Video/RepeatValues.cs b/src/Blazored.Video/RepeatValues.cs new file mode 100644 index 0000000..c99cda3 --- /dev/null +++ b/src/Blazored.Video/RepeatValues.cs @@ -0,0 +1,22 @@ +namespace Blazored.Video; + +/// +/// Defines methods for a to behave when a video source was played to its end. +/// +public enum RepeatValues +{ + /// + /// Defines no repetition after the queue reached its end. + /// + NoLoop, + + /// + /// Will repeat the current loaded video forever. + /// + LoopOne, + + /// + /// Loops back to the first video after the last one ended. + /// + LoopAll +} \ No newline at end of file diff --git a/src/Blazored.Video/VideoItem.cs b/src/Blazored.Video/VideoItem.cs new file mode 100644 index 0000000..a54476b --- /dev/null +++ b/src/Blazored.Video/VideoItem.cs @@ -0,0 +1,69 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; + +namespace Blazored.Video; + +/// +/// Defines a Source from which the will schedule playback. +/// +public sealed class VideoItem : ComponentBase, IDisposable +{ + public VideoItem() + { + VideoItemData = new VideoItemData(); + } + + /// + /// The source URI from which to playback. + /// + [Parameter] + public string Source { get; set; } + + /// + /// The mime type of the URI. Optional. + /// + [Parameter] + public string Type { get; set; } + + [CascadingParameter()] + public VideoQueue VideoQueue { get; set; } + + [Parameter] + public RenderFragment ChildContent { get; set; } + + public VideoItemData VideoItemData { get; set; } + + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + if (ChildContent == null) + { + return; + } + + builder.OpenComponent>(0); + builder.AddAttribute(1, nameof(CascadingValue.Value), this); + builder.AddAttribute(2, nameof(CascadingValue.IsFixed), true); + builder.AddAttribute(3, nameof(CascadingValue.ChildContent), (RenderFragment)((cBuilder) => + { + cBuilder.AddContent(4, ChildContent); + })); + builder.CloseComponent(); + } + + protected override void OnInitialized() + { + if (!string.IsNullOrWhiteSpace(Source)) + { + VideoItemData.VideoSourceData.Add(new VideoSourceData(Source, Type)); + } + + VideoQueue.AddVideoItem(VideoItemData); + } + + public void Dispose() + { + VideoQueue.VideoItems.Remove(VideoItemData); + } +} \ No newline at end of file diff --git a/src/Blazored.Video/VideoItemData.cs b/src/Blazored.Video/VideoItemData.cs new file mode 100644 index 0000000..10f0ec4 --- /dev/null +++ b/src/Blazored.Video/VideoItemData.cs @@ -0,0 +1,23 @@ +using System; +using System.Collections.Generic; + +namespace Blazored.Video; + +/// +/// Contains the list of sources to be played back by a . +/// +public class VideoItemData +{ + public VideoItemData() + { + VideoSourceData = new List(); + Id = Guid.NewGuid().ToString("N"); + } + + internal string Id { get; } + + /// + /// The that can be used to playback a source. + /// + public IList VideoSourceData { get; set; } +} \ No newline at end of file diff --git a/src/Blazored.Video/VideoQueue.cs b/src/Blazored.Video/VideoQueue.cs new file mode 100644 index 0000000..47a1b6d --- /dev/null +++ b/src/Blazored.Video/VideoQueue.cs @@ -0,0 +1,277 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Blazored.Video.Support; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; +using static System.Net.Mime.MediaTypeNames; + +namespace Blazored.Video +{ + /// + /// Provides a structured way to queue a number of video sources for sequential playback. + /// + public class VideoQueue : ComponentBase + { + private bool _playAfterRender = false; + private bool _playAfterRenderLoop = false; + + public VideoQueue() + { + Delay = 0; + Repeat = RepeatValues.NoLoop; + VideoItems = new List(); + } + + /// + /// Specifies the delay between source changes in milliseconds. + /// + [Parameter] + public int Delay { get; set; } + + /// + /// Defines how the should behave. + /// + [Parameter] + public RepeatValues Repeat { get; set; } + + /// + /// Provides easy access to the queued elements. Can be set instead of 's. + /// + [Parameter] + public string[] QueueData { get; set; } + + /// + /// Should contain a number of 's to provide a playlist of sources. + /// + [Parameter] + public RenderFragment ChildContent { get; set; } + + /// + /// Will be invoked when ether the current item is repeated or a next item is selected to be played. + /// + [Parameter] + public EventCallback OnNextPlayedEvent { get; set; } + + /// + /// Will be invoked when there is no other item to play. + /// + [Parameter] + public EventCallback OnPlaylistEndedEvent { get; set; } + + /// + /// Will be invoked when ether the current item is repeated or a next item is selected to be played. + /// + [Parameter] + public Action OnNextPlayed { get; set; } + + /// + /// Will be invoked when there is no other item to play. + /// + [Parameter] + public Action OnPlaylistEnded { get; set; } + + [CascadingParameter] + public BlazoredVideo BlazoredVideo { get; set; } + + public IList VideoItems { get; } + public VideoItemData CurrentItem { get; private set; } + + /// + /// Skips the current item and plays the next in queue. + /// + /// + public ValueTask PlayNext() + { + return TryPlayItem(VideoItems + .SkipWhile(e => e.Id != CurrentItem.Id) + .Skip(1) + .FirstOrDefault()); + } + + /// + /// Skips the current item and plays the previous in queue. + /// + /// + public ValueTask PlayPrevious() + { + return TryPlayItem(VideoItems + .Reverse() + .SkipWhile(e => e.Id != CurrentItem.Id) + .Skip(1) + .FirstOrDefault()); + } + + /// + /// Starts the playback of the given . + /// + /// + public async Task Play(VideoItemData value) + { + await BlazoredVideo.PausePlayback(); + _playAfterRender = true; + CurrentItem = value; + await InvokeNextPlayed(); + StateHasChanged(); + } + + private Task InvokeNextPlayed() + { + OnNextPlayed?.Invoke(); + return OnNextPlayedEvent.InvokeAsync(); + } + + private Task InvokePlaylistEndedEvent() + { + OnPlaylistEnded?.Invoke(); + return OnPlaylistEndedEvent.InvokeAsync(); + } + + /// + /// Adds a new item into the queue at its end and updates the if necessary. + /// + /// + public void AddVideoItem(VideoItemData videoItem) + { + if (VideoItems.Any(f => f.Id == videoItem.Id)) + { + return; + } + VideoItems.Add(videoItem); + if (VideoItems.Count == 1) + { + CurrentItem = videoItem; + } + StateHasChanged(); + } + + private async ValueTask TryPlayItem(VideoItemData next) + { + await BlazoredVideo.PausePlayback(); + if (Repeat is RepeatValues.LoopAll && next is null) + { + _playAfterRender = true; + CurrentItem = VideoItems.FirstOrDefault(); + StateHasChanged(); + await InvokeNextPlayed(); + } + else if (next is not null) + { + _playAfterRender = true; + CurrentItem = next; + StateHasChanged(); + await InvokeNextPlayed(); + } + else + { + await InvokePlaylistEndedEvent(); + await BlazoredVideo.PausePlayback(); + } + } + + protected override void OnInitialized() + { + if (QueueData is { Length: > 0 }) + { + foreach (var queueItem in QueueData) + { + var item = new VideoItemData(); + item.VideoSourceData.Add(new VideoSourceData(queueItem, null)); + VideoItems.Add(item); + } + } + + if (BlazoredVideo.EndedEvent.HasDelegate) + { + throw new InvalidOperationException("When you are using VideoQueue, you cannot subscribe to 'BlazoredVideo.EndedEvent' - you should instead subscribe to 'VideoQueue.OnPlaylistEnded'"); + } + +#pragma warning disable BL0005 + BlazoredVideo.EndedEvent = new EventCallback(this, OnPlaybackEnded); +#pragma warning restore BL0005 + } + + private async ValueTask OnPlaybackEnded(VideoState videoState) + { + if (Delay != 0) + { + await Task.Delay(Delay); + } + + if (Repeat is RepeatValues.LoopOne) + { + await BlazoredVideo.SetCurrentTimeAsync(0); + if (await BlazoredVideo.GetPausedAsync()) + { + await BlazoredVideo.StartPlayback(); + } + + await InvokeNextPlayed(); + } + else + { + await PlayNext(); + } + } + + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + builder.OpenRegion(0); + builder.OpenComponent>(1); + builder.AddAttribute(2, nameof(CascadingValue.Value), this); + builder.AddAttribute(3, nameof(CascadingValue.IsFixed), true); + builder.AddAttribute(4, nameof(CascadingValue.ChildContent), (RenderFragment)((cBuilder) => + { + cBuilder.AddContent(5, ChildContent); + })); + + builder.CloseComponent(); + builder.CloseRegion(); + + if (CurrentItem is null) + { + return; + } + + foreach (var item in CurrentItem.VideoSourceData) + { + builder.OpenRegion(10); + builder.OpenElement(11, "source"); + builder.AddAttribute(12, "src", item.Source); + if (!string.IsNullOrWhiteSpace(item.Type)) + { + builder.AddAttribute(13, "type", item.Type); + } + + if (item.AdditionalAttributes is { Count: > 0 }) + { + builder.AddMultipleAttributes(14, item.AdditionalAttributes); + } + builder.CloseElement(); + builder.CloseRegion(); + } + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + //the dual render cycle is necessary because only after the first cycle the source elements are created and updated into the parent control + //after that first cycle the items are loaded and in the 2nd cycle we can call Play with the updated sources + if (_playAfterRender) + { + _playAfterRenderLoop = true; + _playAfterRender = false; + StateHasChanged(); + return; + } + + if (_playAfterRenderLoop) + { + _playAfterRenderLoop = false; + await BlazoredVideo.ReloadControl(); + await BlazoredVideo.StartPlayback(); + } + } + } +} diff --git a/src/Blazored.Video/VideoSource.cs b/src/Blazored.Video/VideoSource.cs new file mode 100644 index 0000000..1e60bbe --- /dev/null +++ b/src/Blazored.Video/VideoSource.cs @@ -0,0 +1,48 @@ +using System.Collections.Generic; +using Microsoft.AspNetCore.Components; + +namespace Blazored.Video; + +/// +/// A source that a will display as an option. +/// +public class VideoSource : ComponentBase +{ + public VideoSource() + { + _videoSourceData = new VideoSourceData(); + } + + /// + /// The source URI from which to playback. + /// + [Parameter] + public string Source + { + get => _videoSourceData.Source; + set => _videoSourceData.Source = value; + } + + /// + /// The mime type of the URI. Optional. + /// + [Parameter] + public string Type + { + get => _videoSourceData.Type; + set => _videoSourceData.Type = value; + } + + [CascadingParameter] + public VideoItem VideoItem { get; set; } + + [Parameter(CaptureUnmatchedValues = true)] + public IDictionary AdditionalAttributes { get; set; } + + private VideoSourceData _videoSourceData; + + protected override void OnInitialized() + { + VideoItem.VideoItemData.VideoSourceData.Add(_videoSourceData); + } +} \ No newline at end of file diff --git a/src/Blazored.Video/VideoSourceData.cs b/src/Blazored.Video/VideoSourceData.cs new file mode 100644 index 0000000..6945ee6 --- /dev/null +++ b/src/Blazored.Video/VideoSourceData.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections.Generic; + +namespace Blazored.Video; + +/// +/// Defines one source to load a video from. +/// +public class VideoSourceData +{ + public VideoSourceData() + { + Id = Guid.NewGuid().ToString("N"); + } + + public VideoSourceData(string source, string type) : this() + { + Source = source; + Type = type; + } + + internal string Id { get; } + + /// + /// The source URI from which to playback. + /// + public string Source { get; set; } + + /// + /// The mime type of the URI. Optional. + /// + public string Type { get; set; } + + /// + /// Custom attributes to be rendered into the source element + /// + public IDictionary AdditionalAttributes { get; set; } +} \ No newline at end of file