From f3bcf38cd100302d59cb7aead5c8950670cb0608 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20JANISZEWSKI?= Date: Sun, 28 Sep 2025 20:09:03 +0200 Subject: [PATCH 1/8] Adda fluentcxaudio --- .../FluentUI.Blazor.Community.Components.xml | 1571 +++++++++++++++++ .../Pages/Audio/AudioPage.razor | 56 + .../Pages/Audio/Examples/AudioDefault.razor | 131 ++ .../wwwroot/audio/cover.webp | Bin 0 -> 227668 bytes .../Components/Audio/Audio.razor | 15 + .../Components/Audio/AudioControls.razor | 29 + .../Components/Audio/AudioControls.razor.cs | 105 ++ .../Components/Audio/AudioPlayerView.cs | 29 + .../Components/Audio/AudioPlaylist.razor | 14 + .../Components/Audio/AudioPlaylist.razor.cs | 38 + .../Components/Audio/AudioPlaylist.razor.css | 5 + .../Components/Audio/AudioPlaylistItem.razor | 32 + .../Audio/AudioPlaylistItem.razor.cs | 71 + .../Audio/AudioPlaylistItem.razor.css | 6 + .../Components/Audio/AudioRepeatMode.cs | 27 + .../Components/Audio/AudioTitleScroller.razor | 12 + .../Audio/AudioTitleScroller.razor.cs | 17 + .../Audio/AudioTitleScroller.razor.css | 23 + .../Components/Audio/AudioTrackItem.cs | 53 + .../Components/Audio/AudioVisualizer.razor | 9 + .../Components/Audio/AudioVisualizer.razor.cs | 135 ++ .../Components/Audio/AudioVisualizer.razor.js | 374 ++++ .../Components/Audio/CompactPlayer.razor | 31 + .../Components/Audio/CompactPlayer.razor.cs | 125 ++ .../Components/Audio/CompactPlayer.razor.css | 33 + .../Audio/Controls/CompactSeekBar.razor | 16 + .../Audio/Controls/CompactSeekBar.razor.cs | 38 + .../Audio/Controls/CompactSeekBar.razor.css | 30 + .../Audio/Controls/DownloadButton.razor | 8 + .../Audio/Controls/DownloadButton.razor.cs | 50 + .../Audio/Controls/NextButton.razor | 9 + .../Audio/Controls/NextButton.razor.cs | 54 + .../Audio/Controls/PlayOrPauseButton.razor | 9 + .../Audio/Controls/PlayOrPauseButton.razor.cs | 78 + .../Audio/Controls/PlaylistButton.razor | 8 + .../Audio/Controls/PlaylistButton.razor.cs | 55 + .../Audio/Controls/PreviousButton.razor | 9 + .../Audio/Controls/PreviousButton.razor.cs | 57 + .../Audio/Controls/RepeatButton.razor | 8 + .../Audio/Controls/RepeatButton.razor.cs | 129 ++ .../Components/Audio/Controls/SeekBar.razor | 57 + .../Audio/Controls/SeekBar.razor.cs | 460 +++++ .../Audio/Controls/SeekBar.razor.css | 134 ++ .../Audio/Controls/SeekBar.razor.js | 45 + .../Audio/Controls/ShuffleButton.razor | 7 + .../Audio/Controls/ShuffleButton.razor.cs | 57 + .../Audio/Controls/StopButton.razor | 8 + .../Audio/Controls/StopButton.razor.cs | 48 + .../Audio/Controls/VolumeButton.razor | 30 + .../Audio/Controls/VolumeButton.razor.cs | 91 + .../Components/Audio/FluentCxAudio.razor | 94 + .../Components/Audio/FluentCxAudio.razor.cs | 586 ++++++ .../Components/Audio/FluentCxAudio.razor.css | 65 + .../Components/Audio/FluentCxAudio.razor.js | 196 ++ .../Components/Audio/MinimalPlayer.razor | 9 + .../Components/Audio/MinimalPlayer.razor.cs | 24 + .../Components/Audio/MinimalPlayer.razor.css | 6 + .../Components/Audio/TrackInfo.razor | 25 + .../Components/Audio/TrackInfo.razor.cs | 48 + .../Components/Audio/VisualizerMode.cs | 52 + .../Components/Chapter.cs | 16 + .../Components/ChapterStatus.cs | 27 + .../Components/MediaMode.cs | 7 + .../Components/Audio/AudioControlsTests.cs | 141 ++ .../Audio/AudioPlaylistItemTests.cs | 144 ++ .../Components/Audio/AudioPlaylistTests.cs | 82 + .../Audio/AudioTitleScrollerTests.cs | 31 + .../Components/Audio/AudioVisualizerTests.cs | 124 ++ .../Components/Audio/CompactPlayerTests.cs | 66 + .../Components/Audio/DownloadButtonTests.cs | 70 + .../Components/Audio/FluentCxAudioTests.cs | 139 ++ .../Components/Audio/MinimalPlayerTests.cs | 51 + .../Components/Audio/NextButtonTests.cs | 33 + .../Audio/PlayOrPauseButtonTests.cs | 34 + .../Components/Audio/PlaylistButtonTests.cs | 34 + .../Components/Audio/PreviousButtonTests.cs | 33 + .../Components/Audio/RepeatButtonTests.cs | 37 + .../Components/Audio/SeekBarTests.cs | 223 +++ .../Components/Audio/ShuffleButtonTests.cs | 34 + .../Components/Audio/StopButtonTests.cs | 33 + .../Components/Audio/TrackInfoTests.cs | 80 + .../Components/Audio/VolumeButtonTests.cs | 91 + 82 files changed, 7071 insertions(+) create mode 100644 examples/demo/FluentUI.Demo.Shared/Pages/Audio/AudioPage.razor create mode 100644 examples/demo/FluentUI.Demo.Shared/Pages/Audio/Examples/AudioDefault.razor create mode 100644 examples/demo/FluentUI.Demo.Shared/wwwroot/audio/cover.webp create mode 100644 src/Community.Components/Components/Audio/Audio.razor create mode 100644 src/Community.Components/Components/Audio/AudioControls.razor create mode 100644 src/Community.Components/Components/Audio/AudioControls.razor.cs create mode 100644 src/Community.Components/Components/Audio/AudioPlayerView.cs create mode 100644 src/Community.Components/Components/Audio/AudioPlaylist.razor create mode 100644 src/Community.Components/Components/Audio/AudioPlaylist.razor.cs create mode 100644 src/Community.Components/Components/Audio/AudioPlaylist.razor.css create mode 100644 src/Community.Components/Components/Audio/AudioPlaylistItem.razor create mode 100644 src/Community.Components/Components/Audio/AudioPlaylistItem.razor.cs create mode 100644 src/Community.Components/Components/Audio/AudioPlaylistItem.razor.css create mode 100644 src/Community.Components/Components/Audio/AudioRepeatMode.cs create mode 100644 src/Community.Components/Components/Audio/AudioTitleScroller.razor create mode 100644 src/Community.Components/Components/Audio/AudioTitleScroller.razor.cs create mode 100644 src/Community.Components/Components/Audio/AudioTitleScroller.razor.css create mode 100644 src/Community.Components/Components/Audio/AudioTrackItem.cs create mode 100644 src/Community.Components/Components/Audio/AudioVisualizer.razor create mode 100644 src/Community.Components/Components/Audio/AudioVisualizer.razor.cs create mode 100644 src/Community.Components/Components/Audio/AudioVisualizer.razor.js create mode 100644 src/Community.Components/Components/Audio/CompactPlayer.razor create mode 100644 src/Community.Components/Components/Audio/CompactPlayer.razor.cs create mode 100644 src/Community.Components/Components/Audio/CompactPlayer.razor.css create mode 100644 src/Community.Components/Components/Audio/Controls/CompactSeekBar.razor create mode 100644 src/Community.Components/Components/Audio/Controls/CompactSeekBar.razor.cs create mode 100644 src/Community.Components/Components/Audio/Controls/CompactSeekBar.razor.css create mode 100644 src/Community.Components/Components/Audio/Controls/DownloadButton.razor create mode 100644 src/Community.Components/Components/Audio/Controls/DownloadButton.razor.cs create mode 100644 src/Community.Components/Components/Audio/Controls/NextButton.razor create mode 100644 src/Community.Components/Components/Audio/Controls/NextButton.razor.cs create mode 100644 src/Community.Components/Components/Audio/Controls/PlayOrPauseButton.razor create mode 100644 src/Community.Components/Components/Audio/Controls/PlayOrPauseButton.razor.cs create mode 100644 src/Community.Components/Components/Audio/Controls/PlaylistButton.razor create mode 100644 src/Community.Components/Components/Audio/Controls/PlaylistButton.razor.cs create mode 100644 src/Community.Components/Components/Audio/Controls/PreviousButton.razor create mode 100644 src/Community.Components/Components/Audio/Controls/PreviousButton.razor.cs create mode 100644 src/Community.Components/Components/Audio/Controls/RepeatButton.razor create mode 100644 src/Community.Components/Components/Audio/Controls/RepeatButton.razor.cs create mode 100644 src/Community.Components/Components/Audio/Controls/SeekBar.razor create mode 100644 src/Community.Components/Components/Audio/Controls/SeekBar.razor.cs create mode 100644 src/Community.Components/Components/Audio/Controls/SeekBar.razor.css create mode 100644 src/Community.Components/Components/Audio/Controls/SeekBar.razor.js create mode 100644 src/Community.Components/Components/Audio/Controls/ShuffleButton.razor create mode 100644 src/Community.Components/Components/Audio/Controls/ShuffleButton.razor.cs create mode 100644 src/Community.Components/Components/Audio/Controls/StopButton.razor create mode 100644 src/Community.Components/Components/Audio/Controls/StopButton.razor.cs create mode 100644 src/Community.Components/Components/Audio/Controls/VolumeButton.razor create mode 100644 src/Community.Components/Components/Audio/Controls/VolumeButton.razor.cs create mode 100644 src/Community.Components/Components/Audio/FluentCxAudio.razor create mode 100644 src/Community.Components/Components/Audio/FluentCxAudio.razor.cs create mode 100644 src/Community.Components/Components/Audio/FluentCxAudio.razor.css create mode 100644 src/Community.Components/Components/Audio/FluentCxAudio.razor.js create mode 100644 src/Community.Components/Components/Audio/MinimalPlayer.razor create mode 100644 src/Community.Components/Components/Audio/MinimalPlayer.razor.cs create mode 100644 src/Community.Components/Components/Audio/MinimalPlayer.razor.css create mode 100644 src/Community.Components/Components/Audio/TrackInfo.razor create mode 100644 src/Community.Components/Components/Audio/TrackInfo.razor.cs create mode 100644 src/Community.Components/Components/Audio/VisualizerMode.cs create mode 100644 src/Community.Components/Components/Chapter.cs create mode 100644 src/Community.Components/Components/ChapterStatus.cs create mode 100644 src/Community.Components/Components/MediaMode.cs create mode 100644 tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/AudioControlsTests.cs create mode 100644 tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/AudioPlaylistItemTests.cs create mode 100644 tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/AudioPlaylistTests.cs create mode 100644 tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/AudioTitleScrollerTests.cs create mode 100644 tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/AudioVisualizerTests.cs create mode 100644 tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/CompactPlayerTests.cs create mode 100644 tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/DownloadButtonTests.cs create mode 100644 tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/FluentCxAudioTests.cs create mode 100644 tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/MinimalPlayerTests.cs create mode 100644 tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/NextButtonTests.cs create mode 100644 tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/PlayOrPauseButtonTests.cs create mode 100644 tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/PlaylistButtonTests.cs create mode 100644 tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/PreviousButtonTests.cs create mode 100644 tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/RepeatButtonTests.cs create mode 100644 tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/SeekBarTests.cs create mode 100644 tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/ShuffleButtonTests.cs create mode 100644 tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/StopButtonTests.cs create mode 100644 tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/TrackInfoTests.cs create mode 100644 tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/VolumeButtonTests.cs diff --git a/examples/demo/FluentUI.Demo.Shared/FluentUI.Blazor.Community.Components.xml b/examples/demo/FluentUI.Demo.Shared/FluentUI.Blazor.Community.Components.xml index 09cbde34..1b9d3c66 100644 --- a/examples/demo/FluentUI.Demo.Shared/FluentUI.Blazor.Community.Components.xml +++ b/examples/demo/FluentUI.Demo.Shared/FluentUI.Blazor.Community.Components.xml @@ -947,6 +947,1577 @@ Marks the component for re-rendering, causing the UI to be updated during the next render cycle. + + + Represents audio control buttons for play, pause, stop, and other functionalities. + + + + + Represents the play or pause button control used to toggle playback state. + + + + + Initializes a new instance of the class. + + + + + Gets or sets a value indicating whether the download button is visible. + + + + + Gets or sets the event callback that is invoked when the shuffle state changes. + + + + + Gets or sets the event callback that is invoked when the playlist button is toggled. + + + + + Gets or sets the event callback that is invoked when the previous button is clicked. + + + + + Gets or sets the event callback that is invoked when the download button is clicked. + + + + + Gets or sets the event callback that is invoked when the stop button is clicked. + + + + + Gets or sets the callback to be invoked when the next button is clicked. + + + + + Gets or sets the callback to be invoked when the repeat mode changes. + + + + + Gets or sets the callback to be invoked when the play/pause button is toggled. + + + + + Gets or sets the callback to be invoked when the volume changes. + + + + + Gets or sets a value indicating whether the previous button is disabled. + + + + + Gets or sets a value indicating whether the next button is disabled. + + + + + Sets the play/pause state of the associated control. + + A value indicating whether the control should reflect the "playing" state. if the + control should indicate playback is active; otherwise, . + + + + Specifies the rendering mode for an audio player. + + The rendering mode determines the layout and appearance of the audio player. Use the appropriate mode + based on the desired user interface and available screen space. + + + + Represents the normal rendering mode of the audio player, which includes all standard controls and features. + + + + + Represents the compact rendering mode of the audio player, which provides a smaller layout with essential controls only. + + + + + Represents the floating rendering mode of the audio player, which allows the player to be positioned independently of other content. + + + + + Represents the minimal rendering mode of the audio player, which offers a very simplified interface with only the most basic controls. + + + + + Represents an audio playlist component that displays a list of audio tracks and allows users to select and play them. + + + + + Initializes a new instance of the class. + + The playlist is assigned a unique identifier upon creation. + + + + Gets or sets the playlist of audio tracks. + + + + + Gets or sets the currently selected audio track. + + + + + Gets or sets the event callback that is invoked when a track is selected. + + + + + Represents an item in an audio playlist, including its associated track, selection state, and actions. + + This component is designed to display an audio track within a playlist and manage its selection + state. It provides functionality to handle user interactions, such as selecting a track, and displays an appropriate + icon based on whether the track is currently selected. + + + + Represents the icon used to indicate the play action for an audio track. + + + + + Represents the icon used to indicate the poll action for an audio track. + + + + + Initializes a new instance of the class. + + + + + Gets or sets the audio track item to be displayed in the playlist. + + + + + Gets or sets a value indicating whether the track is currently selected. + + + + + Gets the appropriate icon based on whether the track is selected. + + + + + Gets or sets the event callback that is invoked when the track is selected. + + + + + Handles the click event asynchronously and invokes the callback with the current . + + This method checks if the callback has been assigned and if the is not null before invoking the callback. Ensure that both and are properly set before calling this method. + + + + + Specifies the repeat mode for audio playback. + + + + + The track will play once and stop. + + + + + The track will loop continuously. + + + + + The playlist will play once and stop. + + + + + The playlist will loop continuously. + + + + + Renders a scrolling title for audio tracks. + + + + + Gets or sets the title to display. + + + + + Represents an audio track with properties for title, artist, source URL, and cover URL. + + + + + Gets or sets the title of the audio track. + + + + + Gets or sets the artist of the audio track. + + + + + Gets or sets the source URL of the audio track. + + + + + Gets or sets the cover URL of the audio track. + + + + + Gets or sets the parent component. + + + + + + + + + + + Represents an audio visualizer component that can display audio data in various visual formats. + + + + + Represets the JavaScript file for the audio visualizer component. + + + + + Represents the JavaScript module for the audio visualizer component. + + + + + Value indicating whether the visualizer mode has changed. + + + + + Initializes a new instance of the class. + + + + + Gets or sets the visualizer mode. + + + + + Gets or sets the cover source URL. + + + + + Gets or sets the width of the visualizer. + + + + + Gets or sets the height of the visualizer. + + + + + Gets or sets the anchor audio element identifier. + + + + + Gets or sets the JavaScript runtime. + + + + + + + + + + + + + + + + + + + + Represents a compact audio player component that provides basic audio playback functionality. + + + + + Value indicating whether to show the playback controls. + + + + + Initializes a new instance of the class. + + + + + Gets or sets the current audio track to be played. + + + + + Gets or sets the callback that is invoked when the play/pause button is clicked. + + + + + Gets or sets a value indicating whether the previous button is disabled. + + + + + Gets or sets the callback that is invoked when the previous button is clicked. + + + + + Gets or sets the label for the previous button. + + + + + Gets or sets a value indicating whether the next button is disabled. + + + + + Gets or sets the callback that is invoked when the next button is clicked. + + + + + Gets or sets the label for the next button. + + + + + Gets or sets the label for the play button. + + + + + Gets or sets the label for the pause button. + + + + + Gets or sets the total duration of the current track, in seconds. + + + + + Gets or sets the current playback time of the track, in seconds. + + + + + Gets the style string that includes the opacity for the playback controls based on the current state. + + + + + Handles the pointer enter event by updating the component's state. + + The event arguments associated with the pointer enter event. + + + + Handles the pointer leave event by updating the component's state. + + The event data associated with the pointer leave event. + + + + Represents a compact seek bar component for media playback, providing functionality to display and interact with + playback progress. + + + + + Initializes a new instance of the class. + + + + + Gets or sets the duration of the track, in seconds. + + + + + Gets or sets the current playback time of the media, in seconds. + + + + + Gets the progression percentage of the current time relative to the duration. + + + + + Represents a button component designed for initiating download actions. + + The component provides a customizable button with an optional label and + icon. It supports an event callback that is triggered when the button is clicked, allowing developers to handle + download-related logic in their applications. + + + + Represents the icon to be displayed on the button. + + + + + Gets or sets the event callback that is invoked when the download button is clicked. + + + + + Gets or sets the label for the download button. + + + + + Initializes a new instance of the class. + + + + + Occurs when the download button is clicked. + + Returns a task which invokes the callback. + + + + Represents the previous button. + + + + + Represents the icon displayed when shuffling is disabled. + + + + + Gets or sets the event callback that is invoked when the next button is clicked. + + + + + Gets or sets the label for the next button. + + + + + Gets or sets a value indicating whether the next button is disabled. + + + + + Initializes a new instance of the class. + + + + + Occurs when the next button is clicked. + + Returns a task which invokes the callback. + + + + Represents the playlist button. + + + + + Indicates whether the playlist should be displayed. + + + + + Represents the icon displayed when shuffling is disabled. + + + + + Gets or sets the event callback that is invoked when the stop button is clicked. + + + + + Gets or sets the label for the previous button. + + + + + Initializes a new instance of the class. + + + + + Occurs when the playlist button is clicked. + + Returns a task which invokes the callback. + + + + Represents the icon displayed when shuffling is disabled. + + + + + Represents the icon displayed when shuffling is enabvled. + + + + + Indicates whether the collection is currently being shuffled. + + + + + Gets or sets the event callback that is invoked when the play state changes. + + + + + Gets or sets the label for the play button. + + + + + Gets or sets the label for the pause button. + + + + + Gets the label for the current state of the button. + + + + + Initializes a new instance of the class. + + + + + Occurs when the shuffle button is clicked. + + Returns a task which toggles on / off the shuffle + + + + Sets the play/pause state of the button. + + Value indicating if the button is in playing state. + + + + Represents the previous button. + + The component provides functionality to toggle between shuffle modes for + a collection. It displays an appropriate icon based on the current shuffle state and invokes a callback when the + shuffle state changes. + + + + Represents the icon displayed when shuffling is disabled. + + + + + Gets or sets the event callback that is invoked when the stop button is clicked. + + + + + Gets or sets the label for the previous button. + + + + + Gets or sets a value indicating whether the previous button is disabled. + + + + + Initializes a new instance of the class. + + + + + Occurs when the previous button is clicked. + + Returns a task which invokes the callback. + + + + Represents a button that allows users to cycle through different audio repeat modes. + + + + + Represents the icon for the "Single Once" repeat mode. + + + + + Represents the icon for the "Single Loop" repeat mode. + + + + + Represents the icon for the "Playlist Once" repeat mode. + + + + + Represents the icon for the "Playlist Loop" repeat mode. + + + + + Represents the current repeat mode of the audio player. + + + + + Initializes a new instance of the class. + + + + + Gets the icon corresponding to the current repeat mode. + + + + + Gets or sets the event callback that is invoked when the repeat mode changes. + + + + + Gets or sets the label for the "Single Once" repeat mode. + + + + + Gets or sets the label for the "Single Loop" repeat mode. + + + + + Gets or sets the label for the "Playlist Once" repeat mode. + + + + + Gets or sets the label for the "Playlist Loop" repeat mode. + + + + + Gets the label corresponding to the current repeat mode. + + + + + Cycles through the available audio repeat modes in a predefined order. + + The repeat modes transition in the following sequence: . After updating the repeat mode, the component's state is + refreshed. + + + + Represents a seek bar component for audio or video playback, allowing users to navigate through the media timeline. + + + + + Variable to track if the user is currently dragging the seek bar thumb. + + + + + Represents a cancellation token source used for hiding the preview. + + + + + Indicates whether the preview is displayed. + + + + + Represents the preview time in seconds. + + + + + Represents the width of the container. + + + + + Represents a reference to a .NET object of type that can be passed to JavaScript interop. + + This field is used to enable JavaScript to invoke methods on the associated instance. It is a readonly field and should be initialized with a valid instance. + + + + Represents a reference to a JavaScript module that can be used for invoking JavaScript functions. + + This field holds an instance of that represents a JavaScript + module. It is nullable, indicating that the module may not be initialized. Ensure the module is initialized + before attempting to use it. + + + + Initializes a new instance of the class. + + + + + Gets or sets the duration of the track, in seconds. + + + + + Gets or sets the current playback time of the media, in seconds. + + + + + Gets or sets the collection of chapters associated with the current context. + + + + + Gets or sets the media mode for the seek bar. + + + + + Gets or sets a function that generates a thumbnail URL based on a given timeline. + + + + + Gets or sets the step value used to increment or decrement the associated value. + + + + + Gets or sets the callback that is invoked when a seek operation occurs. + + + + + Gets or sets the callback that is invoked when a seek operation starts. + + + + + Gets or sets the callback that is invoked when a seek operation is completed. + + + + + Gets or sets the callback that is invoked when the chapter changes. + + + + + Gets or sets the JavaScript runtime instance used for invoking JavaScript functions from .NET. + + + + + Gets the progression percentage of the current time relative to the duration. + + + + + + + + Invoked by JavaScript to handle resize events and update the component's state accordingly. + + This method is intended to be called from JavaScript via the with the identifier "onResize". It triggers an asynchronous operation to measure + the width of the component and update its state. + A representing the asynchronous operation. + + + + Asynchronously measures the width of the container associated with the current instance. + + This method invokes a JavaScript function to retrieve the width of the container element + identified by the Id property. Ensure that the JavaScript module is properly initialized before calling + this method. + A representing the asynchronous operation. + + + + Initiates a drag operation based on the specified pointer event. + + This method sets the internal dragging state and triggers the event + if it has subscribers. It also updates the seek position based on the pointer's client X-coordinate. + The pointer event arguments containing details about the pointer event, such as the client X-coordinate. + A task that represents the asynchronous operation. + + + + Handles the drag event and updates the seek position based on the pointer's horizontal position. + + The pointer event arguments containing information about the drag event, including the pointer's position. + A task that represents the asynchronous operation. + + + + Ends the drag operation and triggers the seek end event if applicable. + + This method is called to finalize a drag operation. If the drag operation was active, it + stops the drag, hides the preview, and invokes the event with the current time + value. + The pointer event arguments associated with the drag operation. + + + + + Initiates the touch interaction for seeking functionality. + + This method sets the dragging state to active and triggers the seek start event with the + current time. It then updates the seek position based on the horizontal client coordinate of the first touch + point. + The touch event arguments containing details about the touch interaction. The first touch point is used to + determine the starting position. + A task that represents the asynchronous operation. + + + + Handles the touch move event and updates the seek position based on the touch's client X-coordinate. + + This method is invoked during a touch move gesture when dragging is active. It processes the + first touch point and updates the seek position accordingly. Ensure that dragging is enabled before invoking + this method. + The touch event arguments containing information about the touch gesture. + A task that represents the asynchronous operation. + + + + Ends the current touch interaction, finalizing any ongoing drag operation. + + This method is typically called when a touch interaction is completed. If a drag operation + was in progress, it stops the drag and hides the preview. Additionally, it triggers the + event with the current time value. + The containing details about the touch event. + + + + + Handles key press events to perform seek operations in a media playback context. + + This method processes specific key inputs to adjust the playback position: seeks backward by a predefined + step. seeks forward by a predefined + step. seeks to the beginning of the + media. seeks to the end of the + media. + The key event arguments containing information about the key pressed. + A task that represents the asynchronous operation. + + + + Updates the seek position of the media playback based on the specified horizontal offset. + + The method calculates the seek position as a percentage of the total duration based on the + provided offset and the width of the container. The seek operation is performed asynchronously and may include a + preview of the media at the calculated position. + The horizontal offset, in pixels, used to calculate the new seek position. Must be a value between 0 and the + width of the container. + A task that represents the asynchronous operation. + + + + Updates the seek position based on the specified client X-coordinate. + + This method calculates the offset relative to the seek bar using the provided client + X-coordinate and updates the seek position accordingly. If the module is not initialized, the method exits + without performing any action. + The X-coordinate, in pixels, from the client that determines the seek position. + A task that represents the asynchronous operation. + + + + Seeks to the specified time within the media and optionally enables preview mode. + + This method updates the current playback time and triggers the event if + it has subscribers. If the seek operation results in a chapter change, the event + is also triggered. + The target time, in seconds, to seek to. The value is clamped between 0 and the total duration of the media. + A boolean value indicating whether to enable preview mode. If , the preview state is + updated without committing the seek operation. + A task that represents the asynchronous operation. + + + + Hides the preview after a delay of 2 seconds. + + If the operation is canceled via the provided , the preview will not + be hidden. + A that can be used to cancel the delay operation. + A representing the asynchronous operation. + + + + Handles the click event on the seek bar and updates the seek position based on the mouse click location. + + This method does not perform any action if a drag operation is currently in + progress. + The containing information about the mouse click event, including the client + X-coordinate. + A representing the asynchronous operation. + + + + Determines the color representation for a chapter based on its status. + + The status of the chapter. Must be one of the enumeration values. + A CSS linear-gradient string representing the color associated with the specified chapter status. Returns "gray" + if the status is not recognized. + + + + Formats a time value in seconds into a string representation in the format "mm:ss". + + Number of elapsed seconds. + Returns the formatted value. + + + + + + + Represents the icon displayed when shuffling is disabled. + + + + + Represents the icon displayed when shuffling is enabvled. + + + + + Indicates whether the collection is currently being shuffled. + + + + + Gets or sets the event callback that is invoked when the shuffle state changes. + + + + + Gets or sets the label for the shuffle button. + + + + + Initializes a new instance of the class. + + + + + Occurs when the shuffle button is clicked. + + Returns a task which toggles on / off the shuffle + + + + Represents the stop button. + + + + + Represents the icon displaye. + + + + + Gets or sets the event callback that is invoked when the stop button is clicked. + + + + + Gets or sets the label for the stop button. + + + + + Initializes a new instance of the class. + + + + + Occurs when the stop button is clicked. + + Returns a task which invokes the callback. + + + + Represents a button to control the volume of an audio player. + + + + + Represents an icon for a speaker with zero volume. + + This icon can be used to visually indicate a muted or silent state in the user + interface. + + + + Represents an icon for a speaker with one sound wave, typically used to indicate low volume. + + + + + Represents an icon for a speaker with two sound waves, typically used to indicate medium volume. + + + + + Represents whether the volume popover is open. + + + + + Represents the volume level, where 1.0 is the default maximum value. + + + + + Initializes a new instance of the class. + + + + + Gets the appropriate volume icon based on the current volume level. + + + + + Gets or sets the callback that is invoked when the volume changes. + + + + + Gets or sets the label for the volume button. + + + + + Gets the ARIA label for the volume button, combining the volume label and the current volume percentage. + + + + + Handles the volume change event asynchronously. + + This method updates the internal volume state and invokes the + event if it has subscribers. + The new volume level. Must be a value between 0.0 and 1.0, where 0.0 represents mute and 1.0 represents the + maximum volume. + + + + + Represents a full-featured audio player component with various playback and visualization options. + + + + + Represents the reference to the HTML audio element. + + + + + Represents the height of the container element. + + + + + Value indicating whether the view has changed. + + + + + Represents the playlist of audio tracks. + + + + + Represents the shuffled version of the playlist. + + + + + Represents whether to display the playlist. + + + + + Represents the .NET object reference for JavaScript interop. + + + + + Represents whether the audio player is in shuffle mode. + + + + + Represents the audio controls component. + + + + + Reference to the audio visualizer component. + + + + + Represents the index of the current track in the playlist. + + + + + The relative path to the JavaScript file used by the FluentCxAudio component. + + + + + The JavaScript module reference for interop calls. + + + + + Represents the volume level, where 1.0 is the default maximum value. + + + + + Represents the current time value. + + + + + Represents the duration of the audio file. + + + + + Represents the repeat mode of the audio player. + + + + + Initializes a new instance of the class. + + + + + Gets a value indicating whether the previous button should be disabled. + + + + + Gets a value indicating whether the next button should be disabled. + + + + + Gets or sets the child content to be rendered inside the audio player component. + + + + + Gets the current audio track to be played. + + + + + Gets or sets the render mode of the audio player. + + + + + Gets or sets the visualizer mode. + + + + + Gets or sets a value indicating whether to display the audio visualizer. + + + + + Gets or sets a value indicating whether the download button is visible. + + + + + Gets or sets the JavaScript runtime for interop calls. + + + + + Occurs when the download button is clicked. + + Returns a task which downloads the track. + + + + Toggles the visibility of the playlist. + + A boolean value indicating whether the playlist should be shown. to show the playlist; + otherwise, . + + + + Sets the audio source and volume for the current track asynchronously. + + + + + + Plays the audio associated with the current instance asynchronously. + + + + + + Pauses the audio playback associated with the current instance. + + This method invokes a JavaScript function to pause the audio playback. Ensure that the + associated module is initialized before calling this method. + + + + + Toggles the playback state of the audio element between play and pause. + + A task that represents the asynchronous operation. + + + + Skips to the previous track in the playlist and starts playback. + + A task that represents the asynchronous operation. + + + + Advances to the next track in the playlist and begins playback. + + A task that represents the asynchronous operation of advancing to the next track and starting playback. + + + + Handles the seek operation by updating the current playback time and invoking the corresponding JavaScript + function. + + + + + + Asynchronously updates the volume level and notifies the associated audio module. + + The new volume level to set. Must be a value between 0.0 and 1.0, where 0.0 represents mute and 1.0 represents + the maximum volume. + A task that represents the asynchronous operation. + + + + Seeks to the specified position in the audio track and resumes playback. + + This method invokes a JavaScript function to perform the seek operation. Ensure that the + audio module is initialized before calling this method. If the module is not initialized, the method will have + no effect. + The position, in seconds, to seek to within the audio track. Must be a non-negative value. + + + + + Plays the specified audio track by setting it as the current track and starting playback. + + If the specified track exists in the playlist, it is set as the current track, and playback + begins. If the track is not found in the playlist, no action is taken. + The audio track to play. Must be an item in the current playlist. + Returns a task which plays the track. + + + + Stops the audio playback and resets the internal playback state. + + Returns a task which stops the audio playback. + + + + Invoked when the shuffle mode is toggled. + + Value indicating if the list must be shuffled or not. + + + + Invoked when a media track ends. This method is called via JavaScript interop. + + + + + + Sets the duration of the audio playback. + + + + + + Sets the current of the audio playback. + + + + + + Updates the current elapsed time with the specified value. + + This method updates the internal state to reflect the provided elapsed time and triggers a + re-render of the component. It can be invoked from JavaScript using the identifier + "updateElapsedTime". + The new elapsed time, in seconds. + + + + Adds an audio track to the playlist. + + Audio track to add. + + + + Removes an audio track from the playlist. + + Audio track to remove. + + + + + + + + + + Represents a minimal audio player component with play/pause functionality. + + + + + Initializes a new instance of the class with a unique identifier. + + + + + Gets or sets the event callback that is triggered when the play/pause button is clicked. + + + + + Represents a component that displays information about an audio track. + + This class is used to manage and display details about a specific audio track. The property can be set to specify the audio track for which information should be + displayed. + + + + Initializes a new instance of the class. + + + + + Gets or sets the current audio track to display information for. + + + + + Gets or sets the callback that is invoked when the track info component is clicked. + + + + + Handles the click event by invoking the associated callback with the current track. + + This method checks if a track is available and if a callback delegate is assigned before + invoking the callback asynchronously. + + + + + Represents the different modes available for the audio visualizer. + + + + + Represents the spectrum dispaly mode. + + + + + Represents the waveform display mode. + + + + + Represents the spatial display mode. + + + + + Represents the vortex display mode. + + + + + Represents a particles field. + + + + + Represents a radial waveform. + + + + + Represents a tunnel. + + + + + Represents a constellation. + + + + + Represents a fractal. + + + + + Represents a chapter within a media file, defined by its start and end times, title, and status. + + This record is commonly used to represent segments of a media file, such as chapters in an audiobook + or scenes in a video. The and properties define the time range of the chapter, + while the provides a descriptive name. The indicates the current state of + the chapter, such as whether it is active or inactive. + The start time of the chapter, in seconds. Must be greater than or equal to 0. + The end time of the chapter, in seconds. Must be greater than . + The title of the chapter. Cannot be null or empty. + The status of the chapter, indicating its current state. + + + + Represents a chapter within a media file, defined by its start and end times, title, and status. + + This record is commonly used to represent segments of a media file, such as chapters in an audiobook + or scenes in a video. The and properties define the time range of the chapter, + while the provides a descriptive name. The indicates the current state of + the chapter, such as whether it is active or inactive. + The start time of the chapter, in seconds. Must be greater than or equal to 0. + The end time of the chapter, in seconds. Must be greater than . + The title of the chapter. Cannot be null or empty. + The status of the chapter, indicating its current state. + + + The start time of the chapter, in seconds. Must be greater than or equal to 0. + + + The end time of the chapter, in seconds. Must be greater than . + + + The title of the chapter. Cannot be null or empty. + + + The status of the chapter, indicating its current state. + + + + Represents the status of a chapter in media content. + + + + + Represents an unspecified state. + + + + + Represents the state of a chapter that has not yet started. + + + + + Represents the state of a chapter that is currently in progress. + + + + + Represents the state of a chapter that has been completed. + + Represents the available choices for the cookie. diff --git a/examples/demo/FluentUI.Demo.Shared/Pages/Audio/AudioPage.razor b/examples/demo/FluentUI.Demo.Shared/Pages/Audio/AudioPage.razor new file mode 100644 index 00000000..9d78c13b --- /dev/null +++ b/examples/demo/FluentUI.Demo.Shared/Pages/Audio/AudioPage.razor @@ -0,0 +1,56 @@ +@page "/audio" +@using FluentUI.Demo.Shared.Pages.Audio.Examples + +

Audio

+ +

+ The FluentCxAudio component allows you to play audio files in your Blazor application. +

+ + + The HTML Audio Element has an issue when the user uses the seek bar. + + +

Examples

+ + + + The FluentCxAudio component can plays many audio files. + You can play, pause, move to next or previous track, show the playlist, manage volume, download track. + To download a track, if the track is on an outside website, you need to configure CORS. + + The FluentCxAudio has 4 views : + +
    +
  • Default
  • +
  • Compact
  • +
  • Minimal
  • +
  • Floating
  • +
+ + The visualizer has 9 modes for now : +
    +
  • Spectrum
  • +
  • Waveform
  • +
  • Spatial
  • +
  • Vortex
  • +
  • Particles
  • +
  • RadialWaveform
  • +
  • Tunnel
  • +
  • Constellation
  • +
  • Liquid
  • +
  • Fractal
  • +
+
+
+ +

Documentation

+ + + + + + + + + diff --git a/examples/demo/FluentUI.Demo.Shared/Pages/Audio/Examples/AudioDefault.razor b/examples/demo/FluentUI.Demo.Shared/Pages/Audio/Examples/AudioDefault.razor new file mode 100644 index 00000000..9bf3b164 --- /dev/null +++ b/examples/demo/FluentUI.Demo.Shared/Pages/Audio/Examples/AudioDefault.razor @@ -0,0 +1,131 @@ +@implements IDisposable + +
+ + + + + + + + + + + + +
+ Drag files here you wish to upload, + or + for them*. +
+ Maximum of 10 files allowed. +
+ + +
+
+ + + @foreach (var item in _audioFiles) + { + + } + +
+
+
+ +@code { + private VisualizerMode _mode; + private AudioPlayerView _view = AudioPlayerView.Default; + + int ProgressPercent = 0; + private readonly Dictionary _audioFiles = []; + private readonly List _files = []; + + private static string GetPath(string path) + { + var index = path.IndexOf("uploads"); + + if (index >= 0) + { + return path[(index - 1)..].Replace("\\", "/"); + } + + return path.Replace(Directory.GetCurrentDirectory(), string.Empty) + .Replace("wwwroot", string.Empty) + .Replace("\\", "/") + .Substring(2); + } + + private async Task OnCompletedAsync(IEnumerable files) + { + foreach(var file in files) + { + var newFileName = $"RenamedFile_{Guid.NewGuid()}{Path.GetExtension(file.Name)}"; + + // Save the file with the new name + var filePath = Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", "uploads", newFileName); + var directory = Path.GetDirectoryName(filePath); + if (!Directory.Exists(directory)) + { + Directory.CreateDirectory(directory!); + } + + await using var stream = File.OpenRead(file.LocalFile!.FullName); + await using var fileStream = File.Create(filePath); + await stream.CopyToAsync(fileStream); + _audioFiles.Add(file.Name, filePath); + + _files.Add(file); + } + + await Task.Delay(1000); + ProgressPercent = 0; + } + + public void Dispose() + { + try + { + foreach (var file in _audioFiles) + { + if (File.Exists(file.Value)) + { + File.Delete(file.Value); + } + } + + foreach(var file in _files) + { + if (file.LocalFile is not null && File.Exists(file.LocalFile.FullName)) + { + File.Delete(file.LocalFile.FullName); + } + } + } + catch { } + } +} diff --git a/examples/demo/FluentUI.Demo.Shared/wwwroot/audio/cover.webp b/examples/demo/FluentUI.Demo.Shared/wwwroot/audio/cover.webp new file mode 100644 index 0000000000000000000000000000000000000000..45858b22129d633a7c85eff16b7c96a5ae8aaacf GIT binary patch literal 227668 zcmV)eK&HP^Nk&FYc>@4fMM6+kP&il$000080002U0|3ke09H^qAW(P%0PxBNodGJz z1I+^f7yu`wyI_NJWTyR%<^5Hos?E8a#YP5zEu<=2Tmc`;U(kOad)tXX+RB zAN616K3Ay!#y_k6EdIm(Yy5w`A1Pe@{^Rk>`Uko%iGCOS-}<9RV@_+2V*}rRlet+Zs zDE~YDvHg4R|LjleclZzRAMXA@KZgG!`>g%^|AYAx{#V$C`}g>tzTcof-*== z^uNh|=y>Sq|Mmao|8c*0enkF1_5<(#-+#=1hW_9Gv;6nmU*W&of1drZ{~7@$p6y+ zTmGy5r}n@5|3JRCe9Qay_|NnI;=iu{^#5D!gZ`)bf7}1<|NDMi{YU&a{9p8+w!i4V z|Na2~BK~Fl)BE@NkMqCSzyE)?`^WvC{onULfBxOSo&Syh3;nm|U)UG(pXh(&f69NX z|B3z=|Ns5Z#UJQ@JnkG&v7-B@C9tj zRfd&i=<5zVyK&OiYtBfonnupMei57Qgs#E{&z)t8?@E+n_JHlFzj?CsQWcC zrd_vGKN=C>>B8BTio?Ot7J*mJo$Q|XLyg0vmT`D$z*rt7xmfWB>vhW;8W*?1G9*=|5DVy$8dvY2@#G}$gKDh!-Wi1j4 zCEGOgbi`8ZXgI%eJ$y_!+5Gc~Z!4x`@WNB(8Y{H7 z;GfmBVw6c-xK~i3Ved=ct@^aY9+%rfUx}h)O`>C;brrS#knn!5wv5)U#OfIGU;fn% z83B#py4P6q4w-d#ilFJpQkEt-b^P&@gTZoTp1|$*v5F%un_MD~+58P@ zE?hq#p^CoKRH_w*m=}kpo>>dJCsUN$PdW1^Q%U+){c++gcmvqQof&QVw?W(%qbLv_F<<^JG%bVG*fo%iKyyNJ0%C!DhN+jVv zBrvmuPnPxz*z2@+o9RuIWB)6uIhb~TfgE{bA9Y@;NxeG;zyHtT)ws@j>o@tyq7sX-fRgV1f~Dt znbuuwl1m-JKkav6M9eAM3P_Mrar^FTI8M-H@utx+rzm{}&&b>;Uqx~E)0@9ksFd6uy3{MUs3>k;N7eOTWN}R%u`hf#3E(EF69hc#b_Vd?SZHV<^Q%rX*rq+=(L|B zY`Cv6iu^7)hTj1{^UNS(n%Crj!WXy+5GM*o!f`f*5k1mYeJWx7n46v3F%xB;Tv`4*|V4*6So!iEBN>iZE+6w*cFb=lk) zS;!@gS-mS_+e_;NNC8bWqIwUL-u{Bf973v*>Z!>T1VLxsGnBoK!fBtK$JD6frf|_H z3z(a7I8FyDczo31mQ_dKb!R%zghW9CSIa+sA^`&A7R$G>$%e*m9uO&4g6Va~Jd+UH znQcH_$M!tOD=r*YkXZXdG*-H@yv zk(=>!Jm6*w(W;rtE)FPoCrv8)aXsB*=c+6|I5xy+lPGwDKUNc2@Wnu&gZe%WAcp-_ zrst79_QgbMaCrY$s0jSJbsIL)?{`j^@oqM_teBg$6|O+wqQl!P-PuGp2u;1X7; zGw)e_B;0`bvWeq;+mM)|8{1g^(5TZRlWKV+uR|p0V!}66Iu4yIZ6fgnz*}*rk-KFO z_P!Ze0x9>*4IrJ9pRosYaiME1fqnx=_HFY)dM7J1F!m4h2A?82U=-00cUgs=Nyz_nx-9ZsC%n-KzZ^nHIs*YuI4T}b&*G6B3#-s z($#YTyHAYnY(pQk3xfMxj|H*@!X`sZtL*QL+GEqRijlJ*N-gx2*{7>iH^>PN;x10# zxFwkDqs2=5qgaHRBRV-JaPwgF_|m{*Ic@?${a5zY&_rD|%k-(4-vX@U7H@`%p--j0 zQoiWcAts29eF}?V}$uc*|&-G4m&yOHvla^%>kC$7JCB z#okqbbDB>;&#nS)5AAo0D*3H@F7nA<3g`Zz|t6*vaZNiK2;}% zAjtS5ydl*Fo*!6EhN5{&c_>PLj)$X>XzKH^Q&nxujL%uCa2$6xozi9Tm zBekek8=Hs(&~-pp>zDPmywIbP>dz=+i1qK{LB6(P8UNAHp-XmVR}}z1I(Hp`BcFu^ z6Z`eZfk}+e^rhYS0*$=2`mlMh6uWZLs_r@=S}i#V`ZQ+8;WK!KfS= zd)}n$+^VWd8qxpq77IhJ6yNIWC@a9n_{nmDV+XWxw9_uO7v2G7*W4O45;aQVKGs_F z^RnBKL*6HZCH>W}N}lmRG1pHDdG&tq#N@VPhODD z!IhBDogy#C9(%6fH0Dx!cHOiCqvSDe9CBQuY-hlemGJwi(Ea)E;@A(pv^q)b2CVfFe5JU>y zIa0|Jy}+QSwSF@*{Kw`m-ak(*dVJYbq)Bt8NBpoM{OHpf&()blU@?tGF!*Rgz~VM(u>Y&X!tk!JlY^NR;!W2!Ks}J?7ou6rS7#%!xY_)o{j_ z>zu$a4;S~;;d^nh&p}|mfA_U+LfS6n{qS6LDJ?6FDHE^D z%Ssk=2EfG`KX+I$jU;o_ok7cz6HBGtW~_qj##15rVgRveOl3EE4e+`6KhxqhW$cgO zj&@@!ac}Q!NOk7vSSE(q!Zg#DE zUY|07w`ztFZVT@v`|QBEZ4&ofX6eZyEiM?ZDZTIoztI{$xtOI5SN!Iq$;HE%%M2a1RMV1r=^GYE=3FGh(k@KILuSpD(q} zkXP0Sv*fZY>H;D=UKt0203Gx6`>s3SvX*7bt*q4S9r0}*yImqC zRV(j?z?$h<=L@_z3PQULT?Ec;dSRR0Xf489|OGx+F$fM-gkt^#D9HI zxVtswvXvSsB+)xvY#xf3LgS_0Loir2?dAI3%x$FW9Oq+73Frq7sE!!XyQ8LZMt{rE zv1^|_du+dhDWtfkZ9)Ig3BFE-OT55n&wxy}S)~YDy$wtAxx52E<)x9DgBSZ?h z?DXeO)fDwDH)C;CBrXS0KModZh|lqkwkJ4wYt!o;2}OJ)<^HyET|C96(-b_Tqogxb zpeNNZh{5_vbrmbuT~EqoCh)d?@pYEwlL(SW#e?Uhc!JpZSD1{#toZY^EPX`#~whEvnrUF_}nz| zO}C#`3rR`-(~H40--zNo1a#iR`?8S(B-z$#HF(BpdBB*eJaTIPulIL*xgz1+CTT@A zfuO+JGQQBKj+?;AJfxi1APoqMdxMfI#cuRUH{X%&Xi}yvpvmJ+qEynv>n0^{lZEod?VfrPZxHm| z2z3X*>n1Z=wOlI8de`2joA*XHEF*dk23|fl_Lh?>IlRW3Rm}CcaNTU1fdk_0`~o!I zKi}{fsVOo)-cFN>4}h7AAB3r>C=bOg(c4V8xhLJOUN=f(&mD$$H7W5;eOLJwAottR@T>!$)}-+t0W|I6!I*;Q)u0OD1!avxk@ap zgU6AkTz?t;j3OWURzHr2>>0~JHwzwBTcPe((XF|5+$8xRg*Bq%l5&`J0&*n0GVayOYW@HU&t&%YI?+&&k7Y75U zmuO<(MGyWMM1U7gt;iF;O7`ZUGD(g;I{V2Tig5#ElKMWesC|d7ooxxknzwpWB8HMZqP`NS1KY@UA4`7Wl z;yWob<6|3!gokX+n?x-yAF9(~?hD2XeIBWdRp9T*xZQmk0V^&hPM2FWl*f;pFL%>D zpKiX>!>MBP&jB|#n3q)PX{@e=97NvjC&2i$h2&#ZDY?C#v*fvuvE-(CtzE5|*3R|I zyqr-E+Z)fdnIOy6{C?L)b4D_+(OXD+P&(On@U(ms!>ovx+$to>Q~3k#4VR?qlq6bz zmc`E?lFMStKTuaWJg*r{;g?Z6moRr|&vSjs%uf<-VP5Wp%WzbM(0f)lAzoGs1 zL8Soev9zj4f~eZmX3DMzNH|Tewz_GzHu&Xf*PC4!ck8*hvcxZBlOGg9xefI5dh+ScTv z&FP6DXvIvvI93~>QMWM08YUz?Te&DhW3S+jpi`#Lk|Z*A*I+h8Gu3rZ~aIEE{v-U42{1Q8t%{XbT#zn`zBj|q*oK> zP^eO$9lw-f&uF()vgZcoj^Mv90y8tiKwohzvqzak1YMN9@=yJtDV3 zo{^_k-@H!4=;tnqC^jz7C=lM5Z_u&JpIoub<3}uO6{fJ{RhL%#zn>+Y&1-*a*%m0M z?avd|Xiuo7e8w+&>Bs9OTHlku1`qbQMXyp;YBsj5 zzG_abeu5Ci47vSt+L;ZI68%>xFZO-SO6NFLi#*6Xaoy}PlmLu>-^2+wSJ-{EGlk#xA^3Jb1BK{HYh^vkQ9hDYo{*{&Oi{>256DJ4 zY3i-NK-ra)Q}HcKgFu8#W>9_sjD&7l42MXq z(91Ql7c>B3E=%~+cGp>~R*i!j>`9YOO^?{r_g?F!G}|=VM!JB{qc}MLDN}pXd`-x^ zuhpUVz`|fq}iGAzT3N!bO~e&z##q$K32a*pMRj!n*1L1 zmbVpO?u`Hb+57ew3o-rf(SHG)zsgDf3T#i~ZSvT$Y-KFBw3sTY5YO6JRKQrH1vROR z9EBa5+FQHiAJDLXZY-^xbRBX!dE@qg#4y>`OqL{sC=%U-v;D8k7N?$Qg+LC4Og3N~ zMm9j#K4W^GXiff~>)J7gv!sy4Y1=i*z^;goX-VdRv+R|%p|K-8063?ZqAmfovrQO|@EM4Yaq^&zpt7KZFFANm|kqm%2u2hSIdNr3Up+8zKg(eLFVA&mi7im>xv; z22UDo60KtI^4$-|I$8{E6B=z38ICMBNISC}88ErQZ%}d`MoyX|DuK7^JQ&S3Zm#J7wBk!pgi`QUp0kme?r&B*Z zj`g7nbnPi>i&D|Ad99Nev0kYJ$+e8(1%-y|CS&xb``^p-4S;DQMh_X|^N{}mw>-~% zaHk05pJ5z47>dp6xF?Ea1X^{ri21ZK6ff4vk$%^5%Ti0tNEilADw{leDE-EpI_&+L z*f=LPyoS6JGz#u_OE3P4jDP?5|Mk6csJ-qP9>TqBko4Is=ypUQzg}5!H5jPB!6g%^ zKlBEE>tiw4S|fEC_FVrRRI{W~xntvhHkl!Nu=FvQH}wHVS_Ce0(-p<44NjI*kCZ+u$OXsX};|LX>m_Fq5# z>;9%W#6MH-O6&@epCgj}hp`>;VI|y4o`X-A(&6p(r0G4@7Zu~P;ExTKPTy{UT_UXe zJmIHlRH{d6dETBK1{;xIX8k4{N)+gUum;&4zsyuO)n6u)g}A*0iUfR;=l!owP~|_A8u1zi-5xgDE=oy zu4jI2ylAa@rF2MnxG)a&NEG3y$N9`Z#JHu{mJF&JrnIPpqCkc>txHbrBo~1PPcgVp zzZ1$`X^O-0yoyEP%pu1q%l*lsX+l|p8m7|0r+1@M-k$Zg1t+KK;TT6R55X-BAN4l9xY=JT$3c=%z z8yoTYP0J=>?`$wW=x9{tl2DD=+^f)D1wS;)6nIwDI%ZJwIWR&Wyy&RV6tDDJ32q-0 zB${htpU&;^t}ehrE$iXa`akxpnb4clZ!K_`zpY40I=79!5B@oYu$lN;Ja#IxP%aX`xC8#3=YMfq>NfW~tb)&HcK>b&o9`x>$tcf&VCxSx z{JsuBCisoIQl_e39M5+G1FVKaMbUY#JXlduSK-c!XTnNh&mKX)n@h`SfU+A^J(#X! zh)>Z)m2Mj9O`KND*!+KSZCg@zsR9E9l z=8AE=K1!Tfj_C|TB&UZq{IrH|z}I^T8f|ABAd=h<9T&P;SF8&pzKjI{hI{CH;AJuz zx8&XjkLul8o(d#uSE)*|bP=KdL3X81xz&^ar%;|`xQyyC^D9()dIQEweSWqW0+lB8 zxIbxqv6@ZqG{QKLs zBlT4Eu(OF=`r)tqw2F`Yo5>6CWRJ(2-sXV>h=FsG*o zT~rjKv*~gP)a2n%n&Vf#2&2*yEa_lC=}Chs^!z@{xT!R($VXD6_|=djdZZF^p#I8a zEA%chU*>+)H$#pXe1z}l5LR1V-4X@>&LU&(n+eO~_p8TQ64P@-6Eb=HCc}(za4|l6 zXo6=I_LTb7-HwyF9*TUUK!WRV5Zi2U;V88M*;o(1xmmA`d9Sz}D3$RUK0%wHLVW=s=Qv zeWKQiV_5ISxVV?uPZL|72A#Bj__F^o>ECr{uclV7TGaBkRJ~@SVMXk`cInTeoJML> zyf7vod$m>VXFboaE*p_izqy+>o z;+%~td6sg!@0x`-H#?f=zDv$_j>dyF1#k{5#j|yTv5uaT;et^~(Tf=wG7o9EKQppe z%i&2TK!<0>xVreikwP2Xbte7nFhT1OU#*Yl)WKkDr9tQrU;>*RL<(bFrWwht)VN<=v-1c2;#jZxL7lS-!wl(biKt#W|6o^#~4X|TMI*v9zg^vtgDQ$er4 z318*XuMabtyqu)?(Qi~M5ojB|!h%;@#!hgFv)9;>-4l3BO5k4qWEY`fTa3kbR*!ud z^esoeOKzhJiSUf(~%FRZfWv^IiAo*O^nCoj^GA ztHL287ZY=FK-(4&GerSLH)nbE!wZztMQYV`}#Vi16<`&Lhm^6xg z_C-jyxJA6M(%++r&ymlHU3*bleKF`?&t8sb1Jng>g?`dUSvYkI*M3O)_})KLQ5}+l z&6C~`_DoN!^9LQ&Xjj7dODoEh=p98UoTXCP#%^qfujJ&h1e!mpXWqqJ&)&J2pG1W2 zxF7&{AfulOlP?=j@;4?P1A{0ZQ)5`F`$&MgZ=vpq6QQW zpng>`AHM5RCK0LwfVE0?1jyYqL#I2LR%FMfF8&qRlDk2@AJnf(D%BWSE_j^bGYax> zt8Fv1b$q<;At?XAckJ*rJRzD;dnDuk;a`3^r-cqdAmzl{pc6lP9q4IHqdDKeE#gi&>KOc1HYDJQ8pF;2`xf**M+#2rL^wP=WAv!;sMeuyId8z;Zx?TS7vh^N0Y$fvw zkssE78F$x>`iny~!4M@Wxmsxb>1^&T2tS3{N~YuYsqwvUapX1x@~w#s$MBr~usqtt z!;Ma?Bcm4{dP@QZW{hfB$rke3d}Sx(``OWQpj7N= zV5-M+dBv}-Wu6o5qyMqqeELlI9dIhxouGkfzvfGE*7B|Tv%a@6z0v$ANXT6|d4mgy z=)Ii|Lbo(7M0f(zGHB4Bj4aEhaN=njU+oQx!n6Ec${S6)$zzg0>IaG))|5cM?MIys zZ%801eR`3Mw!)_C^pIiWY}fr>r6vZ7))#0W7|mcw@yPtTvq(An`Y|rR<0UEJxlC5q zl$Xx9l7QJV?Rk^+FONypfn->jx@b$24izfF`?9m~5dD)xm^ySGqOXbVJv}lU3JTeV zDv&;FiuoMZ=Tto;#_xT5mwtX#${?Bb3eiP-H~bK}xQo}>4P{mX0m%ivhWg>;y1Q)w za?@)EbH!hb+!^T;e#~s_A$HU$^Eu2>x^RgiR7$$>+V#zsd>Yz|AA(Ze#DT2}?CYVp z@{G{Pi`NzO4O0{Gb2ouvX!onqZN4 zyVa$S^%1s$jciry(_!m7dcX>=WDJX>H0@K>nS#DC!Uqc^6lzSV@Y6DXBqw$vU))#Rx*Z0(H(GfV2&#{X%bUS}0eGt|5emq7gDK;@rLWiGTDOy(rY@W-4 z)R(4iTG1Sb?m1MOBjIdBk&33h)KC4(Sy24ck>hyPtm#b1NFnDsRa5Ur#D2!mZcw%O^ZJh19Yk@wCb0E_6Sab-`uMSZI2zE)?-1ZI+xb>ht(k6&Azm$);!xMiaYDb zUzicARMROKz-v|zGMlzm$fmIJ-0Hu27OXpLmx<@(Hn*#7Ig`ggnM1;3mEep(gRNpS z!p(fpq*w1#V!F0^h0J7O0nL459Ds@p?ZJrp$Gpbs1RyjcTFzD}NJc0>MTZfoYJ~_{ z8RT*(SU*#3Gf327X zoitPPdGV-c@1v}nwfgn_*4-4<~ zgFwg|#{)kHOhLv-6Z4{jYT~j_842L08x8os;n2L@2bI>_{5>PV4vD{6$PAc`_2G`6 z?USH1wl2Fv`Dwznhek;1gBwig00q>plh&x`8@XL;)8<=Ql6Rrzo=t?x-+-cvH|M=X z$9nPSgPWFe4TU+72i+5aR(nStXy51seTdWQ2VSNA@{yw>Zxm&!8duy*I~DMLhA>}G z$gftfPG6&?Z#uQqVJ$f1*LqVWx{UH#Pf0FA-DYLA7!q832Hf|%pQVipGlS9)<|`MJ z@I-byEV%+p-}Wkj$^Gbc|LgQuigC10=HQspi#_u^%YCjI>~WtpzxKxpM)jK@9-O)|{j*3U1tzfdKs(s;4944JeUAJI=0+O^d z>usW}8aMGthZ>2Hg~_o=TclUM)5f=+Qmd z(|;qmss!4bBur_vOxWq?I3ZY(QS3UcibJP$?J)yY1<3W+#QE^toi{BeA@W+RasXob z3;Kt+6i$O!>C^)J2K*|Xis#I3ldc3NjPL0C<`(B|kt@Pv5%s)&DXoaT!chJI%Y&v~ zQozJ^bs7mOS-Ar8Eab}IG+?d&V|VO_S_XPC%EJRipnD{ z_(>nby&7-fw7pJlMyaI)X;LmstLoE(@>zaPzt7(H`<);ZH~gID%)>I=n5sc~5pCpe zBdC)x!N>SgCN$b6Y;^PRcIeZE8Qe)>uscF$mf(V`w=(n7i&ls66@(aWio%ozc%5Rv zH~u6eXiqJ11 zq?uU^I!pcnf8QeI;HO}D^PU>9NSs`)m{DVe=dl&8~-#mn)u{p1N5_?^+ zakWJTsCNY`Z21YJb}PFU07@VVv=nutpY@+hEOZX^4;W^c!5P3Jy{mbvM!#C<;nqo)|y+J`N6glre? zqAQWn=$2FWedE$xnSR^Hn)FmQ8-%Ti%1gSxPYtl5%341e zQ_qqV88*+EW2E&4xk11Gcm1*C2$katOO3WlrB2vCk?>N49G~&mSswd{}d6j>GS8J9Uww)Ok*-5pK6YR%yGiQ7)Eb>&qht`jG$6AdBE&oy;V zs*ExV|D9EPmnV6hzCgpNMB?FrNjnrno;QUfspZ@~3*F1?g{Dc0gVgdb`OLPIiDSr| zOk|r+nLKHpf+b0J+*#G}ORJIqPl2xVuiD{wbT= ztel&FX`Euw7pg)k-<4gy4D2smvkK>lr8oR#=~%y6AS=@55iK zmhhQi+S_}rZQoaMO6vb@gOx~oHT-Aqblbqcz?g^KcBlH){~@7OnEN#nX^=k>Bu`@E zw@8F21=lW+j_94fj%8!0AA{bu)jOurNIT*bpc}xPU%kl$wyMeAJG%sLull&uahUx$ zxxSas*S=`A$JQfj)87Lmy|*jzY~xBU{F%?4@iFtx=my}!F|*2D z)DuxWavtgplgJx3AZ%&eAddsHEyTP{%IRe=jcnRj-&_TBLRU9lzmJZD=$QIdxXIn- zX4SVFz6IwU37Ph79s$88=zruaJUUCqW9|c;T(~PwCq@SyE+_^Q#+_Qj3+S$JWJ7AS zF>9cyf*3bfG@r`4i1w zSuKO99a+JIB2bEXqeMznA2?2Yt!E~wg#HRhm1Jc6zZFK=}YVwe*cP&5DOx2diKZxF~42^of_h3 z2RcUZi3%p*cXld=t>d1YxELKNs?B1GMn_j$!su}P+2@U+)hR4q6sAy%mRm=uj#}zR zkT#}GpX9CJ**Tg2?yPzX{0sa)BQ}TseQ1CE^AOwe|G=mGY1D1in@9EIuf>fM8f_CA zZ4(-8nr(8i*^Yy78zx+%VnuTQUr5gUFaVjG*vvoN|2*f8L?=^BJIC;cdnuKxe?jeQ z5d+}wU)Bioz=QXOrVP3Ivt6VP(e<0`wduY^_NRN-9obWOGtR>$}{Jj{t*(V z>G2NS+fSpdkLh|(_){O1Vsv%4#ZE7_jC{%CO`>B>qxG*(nM51*@+LXqJ)@bl66Pab zBTd7yb1FLxmy%0+7Q^gN8U7Xq8$i@F=uL~T1atKK=|gdqDQ-!966wTuVfU!jyj=*> zZ6u?~x+{xd_4e1N%$_vbCN$b6G}|f}s#AWw{2>M;vyVwNS7o(c->PkM{s3~15<|_B z+n+xOL5VEm(oI#_ZO@;CAjFn&=_ab|IBID2XaN5IxOZaMvKV%kKg}D!OOoIp4Ob#N zS`H;W#5fr^whV25Q%+~Y;ir?R=AQ%mRLEE-SdURyBOVS4q)#{esKIv?d#m?pRBB^z zf=9J!{K`Yu3F8;9fLg1MattvzgXt7{zRlPiCw|OY^!h({ahV6P(8x52`{OHOhuDj& zP>BPnQR*3oC^;K?7}qkc2nlh*VsjR>hg-J39Kvx2!4P4`qaaA@ds1ZaEEl?wn(q-6 zn$jW;NnnFnGQ?jh2wcTAa-sob?k&3F3VMRr{8cp^&&jnQC|vpniquPtIr4=l91bN^ z5PMs3Gx+>fj??;~tQ%(49jBFHAMtoU35s3=SmDLHXxcZZ&38x~Tg!(2FD8C9zQ9UZF1yTQ2E|J!h zvx!V~hP*=>5ywAJifk#=-LhR$^2g}!6y>wIG!W%E!D$Z|cPSWNkF^g-;<1mw>lyPP z9ybO?F65QL)UuJ-f91rgX;om12zfKt56EwGTIGn69pCkWz{vP4AT3hH0B%R3q|NM8 ztCa_(xx#SZ;VK(N`AC@&t+Tak1~ol2e>EQt#^kz^K50q%JAaVPXUC+k_>)YC=igCD zVPV5aC-fo&skhbjv`0=I@#a#K^TSfk7lt=gQSQTV(@988_P{ahoe8P}PVoT?4B8f^ zWrFs&8-cn>w+zjK$ooY-s;Pk<*xYS-2{EBl+{bpoq>_zpI1~ddygl0SI%j*)dTyw6 zr#%3Wu8{gG8}VkO<@F@DYqJW@`U7iyU9i^< zT_ecFU+MzRj_+TDHsYEr4xi$Bt8<>IuDEhGtd(g=TCEk4%>XnK8{>T66cHX_y0$x9 z6=EdCrON%3IUd+@5Ps-cF4pj)3YVSrX%%)+>MZV2#e{OiK>Lt+63?oU)mHqT@avmw zojk$`OXHL$kgKUZMj~-dX*(HeL;jc}Dby5ILnXArQgB2(O(ZycIlJ##*V6p34XRy_ z67?DcwR4$pyCbG{lJGUcLT5`W#ZD0Vb^@q4s}pverKK$H((E*KyOdbM1L1AMCq^;{ zO?S2xgrK%-uoY;04X3NqEoO z|KA@Qb=$v^+e${^8)z)sib^sS8u<4`Wf*?=NH%Wc=@S(I`3=*=0cN3g=v4mlfZ{se zWO@$pOzcB2MZ9Zj+=n?bU=zZ-NmCA2>y6cGFZ1|(kSb}al{=YR5N4ady{MwtL8!0Q z-{=~%$bauYCsr?9HQ(7TG?#TaB*!80vyGL?APS讐NuodIu|c~QU|%@P@P*+d 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

aiN@yo>X}(MNr} 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 z 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

b{^R*R%5&4$S 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

7pG8cxKI3oiBY zRsV$-^)&bQF)S_Oi*Avy1W3Ee9maeg3_$PGZzolT>JsgYw^2igY9%T^Z$pZ$s;Qi! zm(A-fss$TIwalT7ZnZP3Gz&d0?3s6ZKBT~DYqmO^2yRI#b^G06tIBY#IbS(o7rF6R zg^JG@%t$b2_2+l6@&FlZc#n!k36|gKG7bY6L(M4C2YAgf(R(2Rwx+~sPo{59Er4fp zl0cP_0|M&T5Ia%GmPB}Dr?!obC!%!X)LtCoSxRK4-%0!Wc!*Y3RdBB_nLzGO=E8AA z#A&*Fv_}E2wHBY;BZL{m`g!QAXeg6pv`%`i)X6>^Mp&nn<)wc~ga2BHZLjCa3#dMI z@oODId$=0&Gh>t6!^|EYQ!j>|Ux+4O(eA6PniUf?GTv{8D0o{wTR`#&3JwF=8`Hj8 zn_uvJV%i6WsL$F)QX7({Us-fBaQtT}3FSmw*_Ez7F+kCsI1Oa2BDs9Kqfj3*-p0s! zu{&Uh+YVmN5Ffd44KWwj6|!h-*A{My2GZ^==nSo0Cr#a{e>a=Xe|<5-y-O0cOmE~% z;5pI9&IXps?19%Ah787#jWV^&zqnBuM1!4~3g=zZTBuYz{I}fIQ~w0nH@pE?NQ
iLpXJhiNUuB(QG*~{l;AeEnu z&->c}Irl&CvGs3)(6Qw3xc{+V_|y~FfZ+lB@q4e>nnI4YqPunCUiJS%~(M^h0Umd z`Q>&dd*wJo-u?A+270n(b5pcx@?Z3Fz(rD5)2i#jXs}MTdirR^#wK(}U;7M4MPP3Q z_y|dCG!_R)g7SgNrkqx<&aMvJw$dx; zcH=t-F@_E*`KM4drz2Qb!r+aFJVCK<+_IZ3-HAxei8e-Xiq>0H7!KUYB(QKKa{s;{~j#43K=el2zXRNEax zo8{wCJgRPABCtbRmzYc>mADhTf@GgWvZHw)GQwJMFcdhr9zGLMFdSEkH=Xi+!s?-rNHBZ7@Z?tqRjYW^l$`+8%7mjE z6rrI8V_rATIB=&+Qte7dHWHPtVx^X<2-k?!g4w>ElrrA{t#Ukgz0g6AvAX)zqU0iw z)>@>>oF;Q~T(G6eLgBP(%D}xrgE7R!qCcy)n*i`Sk&kr^9AG!rr$T&DNtA;$!}`>X zldu1uVb@%NymG>!0caBA++$^_%iYOPppO}2JQ|;x4e-MXCGP#*HOD6BLqDw?z5xDH z2acOytZ_|7-hq!d0*8#t>H}F~?)rGH+k&nd+rxYquC}5jtE>ap<(TGDU2643PNzoL z1U`marONVM6@H4k9g`JaVJYdr_U^ajN)O2I#h~n%#viZ<30#_7gYP`^pxcE4c^Kff z$Q8-0<@eGW$RXfgRzQ=CTCVDdk?&$pNbGG_-|LP*{1wq+{_OsC`>JcwSx5)gYwOx;unT`w%8Wg>_W=cioZA9dj|zjSI0>XYGjg>ky0Kx{rUJp0fHOa zC(SQpc~l3xkS&ynyCX9=3Gf4dvUAxKWf7jpQ^z0vbi+in+VJUq{6u+K2W5mH1nIa> zZsCbB&`aUtaA^2O`(;q@vPRjcxSnQ5;PKxB%DDNpP>=}=0+Ftq5yUUhSGOS%kT`kJ zLj10msofxx6l6u|d~9LFe7>8S6>j;tRfpSE#?-6D=WjD5cm3a@%;C4OPPY!u?U;bo< z$A3w4;7j6$!bvc!zyAGbq$*}6U^VqFmvRheWYPC&66aXa1*c#7q}lB3=ZW6rH|u!M zW02^dX!~L{{kk$f@BL|H>d1+rItNq9#5Qi`ScN9&ug7D4^y0$jhtD3{OrPopoULZ@ z6Xerb@=B^&E4GA7Kjp4oMadekF*gK3?3G^_N2`~@6X&ipX!K+NIuRSMWpRhKIYLkB zvujyeo^x;jJ$cVI9F$-1-+}y)F<6DXhVkTzYNPPD-$I2Yumz&3o^qIWtYEXJ&mSNQ zR6&?W3CpLm5Ae=T#w*6{e0$md?MBgiG2LVtTFZZcU-6Xff2L_Xza2A zrAwqva3gA!2Ae1{3k-ILagjEr6SNwbZ{(u=tsDpE5yH*l>-aN4>i?kTv~0>5%0#?Pqt=_UhVEb zH*hBN*EG(N%va;Z*q+;(MSnYzezYN7DpezQ@Vx6e49l<0cn;qCOv?u_nkOkR7Ui$o z>fNIW9@2ck)fZpHqe41c9BUt*Mo{i^W+%Q%a&PAG7iZn|GL)2>y37Uw8A_}KhQ)|m zRR|9H7+g60W9~kaQeV*x?#uJ7c?cXE8Xirmmvx;{J`0YSvw2b=u)Ky9h6n;C9Yr(;K#0n6gkxi}cve$hu zF=ebY1AXWds>Bq`@qa?!-GFl0_8&5g0!^LClpYv62|J=}q}^+>vK_vYXO~)n?AHrt zr=imYUm^pP^!FJTp`mGwauXX1lYQMs{Sm_qdH$W5fi(seQ{QP5;9ODXV-$=oPhKaw z-UKq38ig=-wJbgVq8IA{AxM^$msiOsOwqH-)B zmz1r9i2lv^&Iu96?tUN$iQy*EeV>%b`U4}h;nvdlickQ^&f&_-*+$= z$KT-4>)}eW>aI?N5n#45@g2Q$O6**>95Ui!#snWjG*G%F{@;92k;s?F5t@CVVXm;k zK~3iu(qj{MYVBrML>+-7#zipp2K`U{WG-62Pn!!It$`tPxY-4Hbw}mb%^4`${Aei? zja~>#qITZkVC1z@58Sdf&`p$t)wkC@UE58!57H0h*{~;ant?D5`rat6@^=TFIhOkX zWnc2(2aIN;NJ=>z+#5%6?bMI}N1^QzNZ17!mqyDxozj5(+9N0xcLtS?~szxLszDA%Gp+O+a2t!aNboZ${u) zLEa%DPsEhRkG`K7-E1hGC5^4TxIYe#z6m;MdKl}VAY*}BzWDu*qah;WS+P-+*o}N* z(*52Q(VWGnjVoXX+2S7yyqhOH|3>gA?sZy!sWk!~vV7 zJi@QS6H$dF&SAheWzm{w>2tC#Z@Sv?cZ1=2oAVUU#vYoIOB|nw6$MLnw%<0YebmHG z6;zHmzNmK#fC|~48sV+ZgBos|Ii=$*o5E&$^KJeN4yK(rezEy>5Q0Nuc)pk?3W-*d zaryqjQ46`#6K)_^r+-6)mFN53bjt?%GBVbMD5vO(q1-o8*CZmW2?rE}d(_W>nQ}fK z>?PsWc9KK}=&gp_e(6zWZW78yTLRbE!|yRJ90=Wq+NsskBscjJn`DmjwiaI*aa2Fc zSaJg2dyk@Go+aavBfW^F2A{^|b}JVYEYzF; zr=WBk{dnd$xDbv)%`(8YLhey=DPYjDi<$ZKu z+ddN&;%2IPN`rOIC6ERv12hF(qVKxf6L6W^+M3rRK{UpyNUf96EB1lo?G}vFSqgFf zlcVxEm>88Cz5OuS@2EJyu3_@!*TzJU`1LJGKTEWx@EcC}qL`<-q0Svc5>%Nq5=dVh zkk{#XGuW5-S1Cj-*Z7j)N zi<$^mm9kp;Ora$y%IODT&CabBm*%p7YN$0po9VUoBPRli2*9lC(4jk;O`9 zGwv?LZBM04W|WJwMe7nOjh3>*1lnQZ64j@J;_OOeGzWJKK>t?+llA5qKa0G!Eg}*Y zW+Nsk#|3?hZd7h0Dxn&6yjqvXrB4MCkJu!{r0v`eqQ&~bTA7PN$Kr!q+r%Z&=Hs|8#= zV4TY24Px*_I(UbCcpn3{jIlg|q*frSSJ9$8aq4DzmNLSSy%vr!jbn-7hLT zmhyCGv}4_e^$UWM*ldW@fA7~6*qhAqWgUkkwuKlo!49;5ITQccfI(uz2STH5?96=Y z-$HuJ`(a|~Jt2;*6OCwU9Chy>VWHZB0Mn-R<=!4lXiJdClD+g*bA}IGVYKNx&`NN{ zkmxMz0W+(rZ+D&^4ECt*iy^p&2!kua^HGc{Sylu^x-L_veP`|uC6L7fy6!l0$ct*E zEYk(02@=MPIDs3750lR~7bf>{jZ7Bm>A_ z7e;|T+D+A*6UNI9xR%~bZQhAJ@5(LhzXHd^%aQf5%Yw<%2 zsbdIrMlr9PB@87{pPN~^kjDdQKkWve;^qg*d0Jk_y(V`AGNb&XZ>VwX_gY-Y*B8^4 zbX>lM^Ul92*$+u}Gi^lmpMnhOZ(l4vj#xHq8qOGFGn?78Blz0LxS*N!SJ}$N{pMVw zk|-}njIL^LkCWy!Ag3gzQ`g1BgxnePFWDaBhgUbfmxwe4lbIHKS3((X#j>cOW=#>J z(+FU|I&RxcKC-B<;BpB)&_|lWZ*XoAz~=j)6&Bfpo~|`)but7zi`y%e%gS>CI;NnM zOWhfjRA!FQD$ht9Viz~nbqQ0&nx;HI{|HDIbgf5BmfdHsw|QEuui3x;4V%QUf0^sd z{)x_9{li7WvuAud7N?%^+l}sh4Q=Y`kTi4Jan_fD$2Thy2q>Vb1 z^;+VLvmW%TAydaM7iUg2hbCHHB%qxM3~N|pJ(3z-HQn>8jFIX-sc@fRC>_~1Ds4P&4fY+_GoJ_xO zsjoRIj2bMPq}{3}v~{$DrANk_j7@^96X}2oKraDZxCS@vU5Vcn%O1DEeJcOD0)?$4 zZzT>ulB{12)%8MW{9)lyz2m%Yl`VqY=rQt77ZMWSJC5)xfJ@i8NYKwZ%p@bZtrp1G zv6FCGD7JJk)C1=H2x>)nqh*g2$;HgF%zO1Lrh`MPE5y8xr?y6+zQGCB-g5a86+D~7 zsuZMrSiI{Cxy{4^c+L;?gE!pBx!!l1C-_Z2f*v~XdUPX{ii7vPk;oeOqbSb0E+Dz} z;hX?|Dz1aY$0sV;l7v0&eV+%WxxBw_0KkZNCHYCzL;}u~f1KElbz!UMGc8G>rVU(s zJ^NIRY&up2CLMApu?*uIywPx}L?AkRRH~K()5%#TuYXvm$g8mWM$=o!q%`Wd-B&%* zJIH8pxai_PZc6!3$E-YN4$uy-r@%cV?gxy@_g}opNt?v?Q6;%;7I1>w3by!@0W7!zmBLaGb4ld=MgSr$7fAJK?dMLHGahX@N>a zSlmbt@buKl`>BJsv1CU7{8W7|0nMDx|Mep&We$)3=PzjLAnzNCd|k<#jFSJ+>tk*u zOnA|%Sc$d|9UqVKtWQ(BohCNnug+OAg&MgF@YEsFdn6QeMu!;#dZ+oU!(r^J9xbKl zmYzpCZ7MZPe}!xag3b{ZC(q3=_>ZQp@If`h32VF2Sis zwG!0ratbf>_j5`y>%#FH85ft2!lvJc35in*WmMDZ4O3*F4;}L>Atumg`Y&M#HNDy0 z*zn1REp4GFHnrD}29i291zM@*hWXZhyw{e+icgP3`H*e(m)Y!~ZYh&9<4-pbE;0t8 zFn09i^fw|q*y`y339-e-jaLvGwdX}8R9n+wvW&ex; zZnc2=1t7Jyr-hX*)&eY$UAqDT+!rQ$3g*Mc-)6r4IpI4P);3NA0^_X2fS9U zXI|Vlt?S#IDAxUgOp*o?oHys*FC6!eQR8~Z^Z>$9 zm^{9lQbPn)9Gqulng7Q04&d@9#$-(3y^HkYU%L|j_qjZA#~Eh@_Noh%gg1Gvnf{BC z%t75Yq{l@M%ORrWe3Udc0m?5DV)I#-twrR^Q?sX0%fk zTZCxxxz1L>L#Nb&!K1cA3#lvZde#)Df=^P*vQ|9uf@-Pk@gJfgd=pAy1&Bff#t9}{ zcu-snZP;I=G_q9!a^@cH&Pmld_1zvd{|VLlJ1q1(|J~qOU^5XsrPg{E0`Ez{_&%PbF3K9 zlKrLjrmS*SRPZ^xYM*Qn#mllWP8{|Rbxs`oBkWCGc`B>H2tdRlF8*Y$G*DJ& zR@ASTGWS@3^4}1aOZZm?TrLGrw1hld&oSIxu8iqaJIuj8e+D$~`DPr|v_Ro4aeikK zjJ{k`dVMm-BYvIcQYW$n73vAN@l#HJ|D7i_qpZUpGX+Ul7@}NceBf++OUVz|EA$!~ zmJYJwI7~vq{_tQ%YUILp>O}x)_0Fo^Yc8&Xri6uXAG%LbL#amuzk2 zAy{h9st{MavtRLH?y))fc{HT)i2pX7GC+&&Qu@gIMjWzh+EJMXxRpo|xpoJ6IXtG4 zvcoJ>6(5~0zV6*4O3*02)pz!kC%~jj1DG?9O%{7gp<$P9PC=vw^^y|~8bxbcVHfrj z>LPPNpZpJ>N9t`>Bj-#^ldN%qCmW##;``@vT#xM9!>9nP331&N=)Aa~XE5_csQS$` z$dkQ?q!1NWV{l{2Im`1TqgozNO!OP>hw1&e6-#b1TtY_cnzy%uel$oh{xo{N{UMLd zN>IV^F6jQSq_=U+G3EBtuWP?{n8`R5`h=Mb$Ikh9qA{Aulmibw6|=d% zG?2-OMdhp@_aP0yv>9Gy#A~CJG6s-C5>{JWzLrEUKby?-iZ`|BbFiNIAKmQ`#k4F% zPCG7I+2ljst4w&NYgU)Ko3nwJ1Xt#@=RC|3Axo9h9-hKEn?hk_SVX-%@{VQ?pjotL zu+jmBbUIJXcpLK=B9H1Nw4fi=wcw)c)+~}^&do>_YHYysH$tEpF>A6=i{ut1Qm?RW zBunE#BaHHPy96FD6!+L_qUgw&S6*OTMu+dFTjEzf8hB!h97lg6#YupeFzULNkzdg4 zbDn>*Ou}$@8U^}nsv87jb-tu!KEw+UFineWb@w334=rU=P42C1>In#E1NA}qAzgiP zCk2nSz@x`6i@}j--!LqfH!$OwIkEmU=$r@c}8+QiT42TkwD-$~610BCKFeR=8_;mb}KCG5YQ02|EX zjI!iU57$3?UNAshCx>T4onIgE-o}9Viu9jboT*b|camo5GKr~@Lb!VQ4ag0sviNHw zMyBh?tKKB3ln!wNn;LF-8YMZWQUyoOJ=R1};praUIW$soqo>zZeHHyv_LQ7ZHTB;p z0q)D($|Qg)k0ae2FY_We!IURH`<5q11*COiD;!JqhC*YiT34;c^Y=KMo8~ukH=#}Y zd9_{h(j|J`V2xpofcP3lkU`wZcKD>m6!pGJGF@XY{p9*AC6tBaPAO#Xy(Zo{hMi_Z zMy_TDuPx{e2`<&`itk!=S%T{M>$*R6x7eBaBf8XIdDs)7!GMJ|yH&%cp=XcNzjhja z4g>4eUX5YiIZ`$1bnVDu5yYfy7v4rM^xuEv-3s_ePyW>Ru$CV7=UL6foVIhGJY1fs zV$0+Yd)6iTx`@Wihm6Cij)Pt+ZF|3aQa=P;qO{h9zUIF(;e$tE*8yX|T`xaUVyX;k z7MVw&_xe@UmJ_jcA$b#ozZ|Za$GTxR#O>ziapSC)wY8g38(#G|ft$-b`(n5xxFaPd zwmYts&b`-=eED-u6$oDxyFBbWbx3V_=U4%)cx*aRlV`Z`hA|Q7w2+$diwj1C!U3N1 z#70P}oG>KKbNxXZ>^%kKm~KnMp<_`VxBu*{zpLQ)frD25#1Y)UENsD8di7fwgK5~i zkhn_8Jjb*&oMa9m+(L+l%c-k1PA!?4aNfS3lc|kx#-ZrP45IL5C19b^+vCO(2Lq!b z0-SSe-}U3RrC^L;+$AyjU2^(xw@L9+H2Jqxww()?!#jkf`k7b`gzHrUw)@lDS>qz< zJu@Z*$Dnbn(WT*A4HEuZ^E)|qy{Vz_UcMY6Etujnz`FFkk(L$6<$4&Vcf^vWUFlu7 za7osNB{CU7zwSAyP#g{}MUxSE4>jT~yAk`fyEelIXAVOphE&e@2c+i|&S?1LvgU>j zD;>D)Bz47p&L#P=U@HzPRFw?+XPj%=$3Yk~x`}$Qti)VK3aF>~aSc?o5@RyCPD!)w zhHbiYT55;4mZh>c*1Jk>X&1y;@k|v;2|)tH6ke+i{1nWvbu86T z(+uRYEsi6P3Qug#WLZ01 zzngZd!kjNXY?*UKXqsU1Doa8MzZ~Knl6Aqs!(BE+6d;%p9~T;^pzq`%XQ>d|9UHXU z9X9slw^xJ{n9MdI(DEn7z{Q78IoI?AKxyFDA(C1Tst=89uZwec>?+6}u6O58;GI^&3Sk2onI1b>o%E8l-Pr%Cq`J=ywAK^QRf6ZA^B$BW`KDbeRk^ zja!KgA=;jlDV6b}ap0!Ik!IxEj6#*iNTSV>)`=H;#33J1yJcySYA01&A$(FX9u*e6 zZ9Uh@!JO)jC((`O3@0#@OfXeWJWQ=38ANZOLD1i_h*LsF=P&p(p^IXI8&FU1)wo%A z(Cet`iUjx2#lA%{hvDjrj)&^7U}VR+$pg`#{u2LB@G?85*=&Zs-OQ!JZ`&<&BwHyt!|I^k!u zFp;6KWnn<1#lYJ>U$o?$8@#3@@+NWxb?)Z78zR)1n(lu12FNJM>SMpgSzZzPV%Bn} zo~Z$f+e$Js+GZksmv7!P(kVkvl~h$J$AuU{!0*r3JKzBXt0&0__zg?^h=YdXeLOA# zhbD77KWdrwE*H-tDL38Xr8JypR+N)>O~8R8*5KA81iGEJl4?o}f{O)6--h95T6q0Q zg%pN*@SgbhiH;MW?+e~g8pQh-(w5&;T3#~M{t$LX=`?54ZtxYu8n{87hYb%PS3cHg!x=uyt2AV09H*`-pn->selW<4+{=hG69i}4{ydySN zmswNy?RCSb%0j58YtS6t2&p0TWX~7{1b?MV-i-3OQb&GJ^Uz4Hcxa;BXN=(P6zwiE+*$2HAtQBrIs03W3Il8L^7*d$YxoB z+*8X{q2e;09pUEN?d}0C0I$@k$OmF`dp9Y;W%b9AB|~Jd8I8LEa1gsF9p1S5?Ggzx zx9p}-_fkWz*(z0W6(Kn1WCg*xoavbcG(@f{xlBWSPRy`i@FR~_w^98`{}#RRoJRh` zn=-lPFY4MMvT_Cx242NGz=Dj4{N`11o^RB*6T6h9QssTrh?!Vy4%_4y**g%44+V>b z)ctlfULQGIMxblvh{RO0oD?A-qfh{<8P(S?iHF~@{lNFDEalz3F=RdpltGq%a9=*R zbWpeNMs}VKTuTkR6>1)8#Hg{ySm<2RB>(iK!YZ&o)JTHfsYj-VijMi&zEdAX+`?2u zsBa+7$D-+|adZr76_#qeoZe>Ty``CiQ}zl`ca|`QDU3zG7EQqgZ=_hfDGxH1u?ymoKGlhd`|`j^`WpCnz~-Sw$RF%+sL2wQ2Z54~ zfx@Yh3iw0Y7oZ|M0iwl50H=@eJv=wkTwQh3c?9X~e~XJa28BoXWU0)-tN2TP1AbOq z?wo{iUVkK?g8l-Oj)GK;ne%|*0Vw2uZy16_SY*}@pwUW3$2ZsYd#z<|fQ`{ykFU&o z^r0!LMOuB7*U`Xd+usmIp_%wo()(^QVl;EL>PjT^*@mG7J{Mx#!Jk2eKJXDtOOngf zR8n8ru9}wM@D1G)Q*aV4Pux=|tj=eb9!yqT-ckhdP(wLD^&B)&oTg`o1q3Bz0}YSJ zj>ON1>P7%gdpcMGq+JAC~gCeK|3+dpg_C4i&{g@RPyO zO{{)>RlcDEC-nfanyQh}W7Zs&Rr;Q)u)yZ_nAy(1mN5hmQGR@a$|0~eKSJA_`Rp+FzrAu~7OukfWp)#5T=0SHo%`?V z4Cv;3H$GI4Y*z^`HS&-LDcuKX2C{-6ErmQeRx`inWR*qK$S{>OwC^{ICpTStmFMuQawZ0;% zfr1H7g1t29)-J-eo#rMcBXs#^MWoF#O7t+gkvx%ygU?upw?iVRyEDzxL(6bFL!%u6 z3dA7zSj2l}*@+r7W6Rl>SoT*F^~ECoo^d=I4e%N2Uapni$Kwx$xiEBEwk}znG4)U$ zXx#!jr=w$K-u*xKh$N=}$0Saa_{-P2Ms$G(U`Pd`BmF+gT7N8o?|nT*&$t>S`z=PZ zNC)J36fh(sEtPSV0B&ux*Na?>4v7UhShvMg$4sj)+YdpmD=L?767qMDknVrHB#L^a z{@g_(zI?8eN17SRKQ4cm+K-A<2BWUa?eNit6o){~Wc@urLZkMp8tum?OHr`|{T>CDb!5+lPqc7QP*FO#TStX_86(WEXD zunj%+E+Zi}?daU%Q#_Z#G!rBsmhH$*vGM-1JRm%b)AuS#_m_p<6nmzTR{4SI{qnbu zp^VZcH}X)5`KZQyIe5YAtgf{zrPlP9=zy`NM)RN$nFYMkxq%gm)={xQ^bVkOQms;z z{4R|qaRucqD0CQ%eM_p3P?Qn_ETj8|LN%3EVvPm(85}$CH=>ky6b(Awmk&o#VmeF1 zFPT7hRZ;op2X_remCl!5uP2@Bd z6CVVtAu}!8PVSgOH`Fnl!||f2WR+umBmIkHtJ9 zj|RMQ*b4M5y^@8`^MGB!rj_L*$?>tC%CocAd!}aY`y6wT9y2Piz&@1kyO6=zX%o~=MeYO=e#d=rbgJtTcA1rB@&D7)y z1e~cw`Mmn^;#m*M<-*M|CfqOSsY1CnxGJr9tI0!R)xZ>0+-e6!d&?#UUuG4w9x-zI zi}skJNclFqv*Wybje<`t`r6Qe9+fYU;U&N(y6CIO>A`%+&WrV+S3g?-ZU*S_T88!) zex^jFa0P~9`)gn-&wD=U@QS>H1-PkG|2=yUPOS`i{pQ<4VtnG@Kq}S@AkQxCt}~+* zG|k-q*GbuVEO^ZA!oKS)je8HgP+f3JJb3kFLeJFZ6_+W$@fe7Guk3yJ&I%XcgqZ;C z->p&4RVU&sxj0A=TFM8jYB6|PAWPV-P#0GUL1`%TEY_{OyQSGBIZi$!#+w) zN4EYx+M+GSzacMPl}@lmbXsodPr_2Sexs;&Z-eDMLo_DSJ%WlDCqzVsYCGd{YqRRn z;JRfVd#&K5uQehd24L^-fzqU+?!)S-j+q_U!&?&tQ)lGNGpu4vwLjTsh=LDEn|xs; zQ9lYGq(l%TbWhjuFy0m0gsr^GaYF-6<>hFimO+@{%z)hgW|o z&$>O9C0PLmLK@odoferPACRnV^mZsj^wY#YNr)G|Djsu}E9nXrFsE zd2jsc`3w&t!zjAUpLH9L^>Cp5%OdC>!?jrH)B-g*m4N@V{;SjvYzBqS3TwODUMxMG zK%CxT{u4}26-Cu092{8oe`?`eLga}9eP^oG_F~lSwqy#B@S5=HEMVw(#4A_dWWQ=L zbg@1Tv0+JCA#{}UCRZc$bLtJsz}l?D#X$3$FAZThhSfj|_umRmivSV|u-ge$B#M;WZHp?06}kNnX48&PINP_sh)rhVHMaax+I`%)AvX%mqLi8mAR1)QoK4 z?9neKUXwhhfwToZtN91NHai^o%u-2>15|!RY;V9E-@CQWP$vb&(vjf8fPGy|vGJpy zE&Adp8r%vFCP_QQDW8}f4(@g2@^|>j+mXNIQ?@8VJJ&UuDJn%-jSy<MK4zZpRzjCJSsAjM0MN?G$31l}JQ1S=ugi>MxJPk0Sn zu76*|Vt!f`jNAa+ihYQbuIY=JNJ3ZIX19?tr0!Sc8KD^MQ)mM&F%zTrwc6JH2kT*` zbBtFTaj)|Y8ggPptHe{Nn@E*uwJHK`0A91d8LOAKSj(qg331%J(ep%9TOF5(x5Q|* zohv-bZs>R#OFee8G6>n}ML`3OYPnlek?AC1G9h5teqA`eXXHZadYdSX&gZEcJEpl; zUUIVkAlU4kDC1C#i5a>E1hEp`UAik!D(p@a8@+Qk&XD~dd=?Ql^^1c0dM!vmmLzvs zM+NY?v)cAmEFO7=uWoSw6p{+n(Ao% zd1kS;`YU!KB=>uJcx3;nz?4!(5I=IBb?EGt*Vz>9@Q1-&v+FI`^%}arggMkf|FjII z|CyM@5zSfEP0^WWa6swPe;iJXKz?&{#=UO9DD)xuNi{^P+P2!NY;VpGP931{WZu|@ zNpGy^ZpR=%dNj=a5+G12MO{5k0V$V5O*Bz2(%*`8iAMr;4vCx==?%K=#M1ct*6B1iK&=$laq|cRK{|%# zrYL^UK; z))~bgb?3v6VKO`A=4gm$zqywanX+6e6&vHn(LcmpZcc-t;*Y{ zY#H{IIiLx&1g_a#2)p)cl_~?R zwKAF;|B1d7U^vJ zk$eC~Y#k2irG;Oa!G=Ll3aCaG^{y+D^9x6HmtN+xUC#jwA5ik4>jlwz5^)0cU%$9W zO%*1kn%Iok9b0iez!^+(^MgmSyW4@>{4YD~c*iootZ1_0o}~?-098P$zobuarg@N? ztJlKFVoIxJ3(i~iZ~4Jzl!NjzF~$55bcCaNUaYedKMtdL>McHqXRG4ZPjnk#PQqE= zo7x@luVBy`rz>lai}rC3$#PezDH|+3y3Iy-ylD(KP=+iKLjRPD0it)FtPYY*v|K8$ z4Fcswf0L^-zqp0= zM#QQvVZ03q0kvba>oAy*wL$j3fu&x7K|-IWz0)e6Y(mKJ?rx+&5Q#76iNN zB!`(3tfl?Y9*3LAzYD-I z)bz6Q6xg5UPRv4FJSM^{t8Jf?m=eHQE<*%){z@s>m#v5`NrkW4ve$9_T z00qy`fT5?lD}rYewC_s0-DQxl3T7=*w*U-`H^q-Z3ec?3Pld;h?@e}EY;qyX_X5r5 zySdCMx@zw`IWxzj_KWlTyrUHySfzy2A^FKHQF9W!{>0?(`ZjL_z!PA|@5(5BkGD-B z0ML#0;Aq%%a4(5pK^ZDa)ZJmM=r6%( z4|%pRwlZ+sjPKW zrV<}V?kUOwC@lxZIwc7z-nQbT^kU(L{dW|-Qsj2^Nihy;o{MwBMxC_&6YAU)4L{8l z|%U9UX-#6xgiNRYV#pb`JDod|_Yw$Kz; zmttFq9`MqLmh@PBVlGU2Lgq+l1@&@t%l2+R_T!d(sUcpoZlQbcb z*||39YYQ6GCbj$(z@?G}!%q>%!Q%l)PS#1?-0vrl{1kNk9iDo7=$4@5bo%Ip;5-hc zpd)DI!)-5gbQ$GFur>C2otau;Grbl?Rj^dCeh`r!a@H?p9;EPBoH=NwUMJ2SL3ajf0D} z&L}oD0<}P|pvbuSy?f+W;MzT%SX<82gHYObuWG3gYMWnA@!a<94s_}`fKG3I_KKm~ zyK&JrV;>-U7IKe`veL6oMB!VU_lQ^z^1AXZVIZOW7oOyp|MJP?P8(`=LCjCU=E$%g zF+WPYuUkGUQGG?IWfl-xkFvQ6y0Qy`;DX|mM{@X~N{7hBUd==ET6g}DJy49DmAvRN znN*#BlKY$(-+}iMxiwy>>5b6-w?GoBxkoj-BX80k!a0M^(U!qC{Czw)Wdx;~j`48h zPDbx|v#P04`cC=Hx%7*NA*)-z&sj0a(B)cx0c-=>OSHLH=2IL7$EQ_$z(+&1{fF6^ z2LZqMD=?yJPEQ%1Y~?jc5G$Ig{&Z^KyC-fm{jg}u7<%ehkw(;iS&-y?R_X+%@k9vv z{oc}zUZGU^8Oe~+Y$}gj%r)XigQ>vdLQH7}ZEJD0u~jc@v&KP&pZ_>E1wf(;?EKUJ zG23`~6+!7z8SSUEfu3$uD@DhAnSJ?jo#Vte5a#Xd$C@$FmKTi zcJSC9U9Br$@z7o8i>i=L2p3r$NUE%GFVKGI1UX!C=GNte+$bmu_tq^dq_=1>W&7or zd29#n?wzg1g^t_|11lft>&$Wa-{{56!W8`)TBRuLf%Hs_;Zh~OY zs0#9=B5$1`V;9u~g9^7#DaKlB2A$2dpe?_(mI3qsd8e*1 zSFdhW#}zBhFPhbvILWZ7ydvYuW7>Mh!x9-+U|=kT(WJ;p?S$!?yQcpagOpwLZnm3i z7HGOEN$Q6BnWlBocMIH0brJ-1zC}*cp;exGU z&7B7mBaW;RRLA2r#sPYj`S56MvGIxhcrCD4YN7b0f65B4+pp>DYaXlv<#hHZ!qHf) zw`HN+o_)4UA3YLRe9yJr{S=~EKu8?$`f)6cy4w7xsfm1#+_o- z?7mFJX_RdtSSrLo50i}+7OiB_N8{~nE(h6j_6M5qzl=dze9%x|GB>MV zRE{L8L04j=FXvg(?QUR_Pl@RF1lXnd=`z26a##C|gTpB_)wa~l`wul`x0836?uj7w zYMWY+i^th6tTB)-i}tqwZ_RfuXKs8`SSN=oa9Kz&oTW;l2!#zOSUx-JOm;#{b3E1K zjZ|b7oFlh2ti?5`*tKMef`PtT>@NBMaU6^S0ya}%1=U0|O43?`yDJ6}f_O3Y&Z5Ha*kb>MeIttFcH$c2ODMxxADK*eI$c&EgR3MpOm-(z2z|{sINprnF~~hVkEB=~ zuj^vl-`0al^x2rU3j1-_lL-l$n-c_QFjPI)uy!1Y11YKY*E#;QjH9oIV;`xfr{|UU zbo$}H6>ZUDr!mA#)IJ~oNsb;vr!xjN;PBYr@KPhO-9}-^(o6GpubwH=S5hmecZE%R z2^11ePQ8+T4X3EeNfoyLq`$MN2Da(+H;m);?-_+1$-#|w-UnVk8_9=U9T{Y>N{!A* z8rQlwfV>QJIcm8O1_vR*MskVRw<3>)9~rm4RZIF+JGBh>#4 zFp1G(KuG0ki)b8pu`MOoxoUv3XakhcYVji6?{L*tSFlgozLB|33L_70D@PI;obS=U zDBvMEQSy0(2-+MyWBnhP@JXS9G?*Xpk1BhuUgee>COXFCFsx%Kx&&HhkxWqi8) z-jh-(Qo~rkrD=0OLH<-VjMa`Rd*iqw5p~5*ldkNa%K_AAiPEgW?Y|5HM_4UFfbxIk zW(br%$?5H=Qyf_Rpd_Thlp30f5^-3yDezsHUIo-kUJIq<-VvO4Di&L31skMjFR~<+bGOZkz7M*)j1*)Ci{H)opK7XUam!aUjCy;f6bUc{(kMPG(CqY}9J(8LlGtSwC z*-jLlDu?c7RAtqwLY zl)X_(rdf_0%3BX4vli7N%KS|Jj%}SotsD`ql)SiKg|A}j3>|c=S`o_wld^Nq= z&}<8zsgGf4{&`0?dHWjBeNAY8F2w?Ec+&y3g=9$xZX%6wvRb)YMgBShu=}RkfhPEC z&_#gF^T~K}z5K{?WvLD1d+fPvRf~9u$YjRAK}=OQXF5StR8(mg0hBb|`{|C%vRLn^ z1937BN1%kUQb@H0EKmH|wOy2bW=WsUna2*Kz>Hw9iC*wkOvbf6O~k_gyS<-|j(Un> zQoX}+IL0WLUc2z6zR{iGYc7*MM7ll+GM>E0EE*KV5lD+)w2z@3O-(p7G6?Lqc0o6<)+S=Ah4KgmQ54Hz~N(JVwp?HAQy@`T;~72{?%`WAFYR&rBUKM{kNL3l=m^y=MrEWWSyM9OuDmEd`N$i{Zkf=*Xa7 zhD7mY9dNRXU(E=j*!MeR)F^4_{2zm1g12z~T<1oPnH$efH1GOzCGzTLPT;xN(jg^^ zy8JBNQyBUCR5``OR&irF%dv`=Kq=$pwOo|;NDQTNwIzt>g_Hs!%%H*v?R z>?(>H+BY_H^>1I=jEcs?U~FUqC$!yJw@(nzQ8xX4ZkyXlXJ}lOz*89l^1|FRE{I5u z3!D!dWiriBZHjs_^j@pOgyTUe*LZ$7v*gwk$WV{@+x-=_3^^n>x5 z>u63vsynwkTlPzKVmHue9)x6`-f#ndutz_MRV(ltOW5S+3}sgW^mS?@;6@@ul3-XWK4Pb$6uXqHJa|A#=~Jy(*@f_ z@pZIl9k@X)eq2`)_O^I(U*q(&15ZZO5td*FWnU+h6tZ>1jvzP}&8rD8a*n)J=IA^Y z^61NXF>5E>5+`?V5-oYc83B`CdLrR2o`YscxHQA%hoyXU?zK7PXd1%Ldg3Q;{BdT^ zsGLfgun}|kXW1qRFZ02NZaX`HG^)#FLt3?H*}EChuvM&ffV7hTZlG07{v#uN1yKpC zBmxTDKNkUBZId7GilIdf0A?W5ofgtl#vg|16_pg$b{qNQ-e7Ce68I|t6VLFT5t#Wb zEA;&hn5rEz@!e#$Jrl;XO8uScW@UEcu0yv8c%?4h6qxPic>iItbyJeZAr!E8so@hy ze2Sfl(-LgW9a?kY>7P1~R>=cny17qmgEUgk@-XEmK(|eer~b*_QlCL`T;4B5&o6N& zjt~_-fsRqq{r@c)d=NCNnLZ{WbwZxa*;#Z-v6<>ND5-< zgW~tpE?WfOC8^#?0e_0t{3@B|dRLkQXiQyl)Xq`od6G>Gf7%~oU|*JL1I`=ru~^lG z+_`_^qb>>f0#!h>0!=;{`dLohbNNR>H|v!>^qt_}(ec;;%4V*OEZ$7;<9cdQ@xNfT zRfK)#2%p~Yr<@OvQ`#{gOF9^)bDw1YA+Y|`MEBp_5dOp-p8aG&qtFsvqg^XHA%7;g zyH+(%vu0D)*$PsHzp*m@V8Za) z`9g~rBhQ*^?F{U_o`;r91IAr^Zi%QD8J%xbXOk(8k@mA zl7t^R#n0x)*&VRz|J2%P%ZuWyYQX+gbSsIq61uVFnV*^2bO?e4aSMLaq~l>#8bma*}jUyE8`- zJi8@QHTRysP;9#9H!EVT;Dv=Z!)k>1{AaB{_xcBUmOz0C>?R;j#Gx94sQQd)f6#s8oI(w87!Gpp;V zKonQT#GPia*Yp;J-x7tG>6%LbRNba<9LQ1Uo1F7nx3?~|_gvH&=XYKxexiX!_f%_l zAy=hX_=KWjL7~Z@Ffu?-o4fEd0E0j-yFAO~==i4o^X-0h_UCgEMyRp5G|W_qk1uGb zVjFdMspkiS;>H1gFln+&-!@>FW`4EP3M<-C;*eDqXg-Sm=(<7C_3<D!1yOxlSBMDnfp{}#7ZsLymQCoU^ z=fLb16mq>|Y<$V05gLDZNeuqY5ImlkHA-``{+I1@&dD%`4`PE6l8XtB(J6CpOeLWQZ1N=n>wKQ$8MecSA& zqR6;EsVxioqqUF?i79`>Drkutl#1445qgBIiHi_byMzZ&sjvD^pEy>$asUEo0qfD0 zjw6wUXel?Z1)-rJSK=&<*Z?VuV${##2G!fzw+%x5(2=)(;GlpHZuOYofK$zJT%o8q z4cursI)ZInkkr5UNvs?^Ke*`eLpqd}@?@;v1is;m?qq>RG%Cwb45XfWA&p02mp=1^ zST1fXv~?j2PxF9a>*B=R`+6%z5X9dS;vh)q&*Wge3(58idBDGzMlGn6*N;U0nE^jU zG;@}?{f5p9|tOfw|_v|mWX-ysa%4Y~X&mTX&3bbux7V5Izv87ZzJ2&9LH z+Vb)yvDyB31RFS9{caiG*XBzqb&Aqwr?}>7qptE+0?z~|)hpmDWM=js z?WLR98D*$61-)}6?V!redyLRjh~5$LaH(>Jw@v#$yGb*GizHy>Zk5&2+-Rta=SLE$dfBTY0IGE_|0FV%Q?h4?H1Auo~uHeAZ2ko?XTUU`t-48e5lOKbuuyMDZi8Z|iHhQoVQd-%9b|X zO<~%y|U(NTx(C7mmhFK0|qbXsDFFJ~+t{ zc+s5C;mAJ}-dp;`8NjV%KA{&?R7~aJ{SwQ6a{kPyP;}lwZJ1~~r^8U9{7e`XFwX^1 z(bSC$g0FTFYhb5B#4T`Oqz9b5dNW@hurMgZ^r)j2&inB3am!)EKhvF*Dxj7R(+qIFC>Wy>23Vcq;8nq#I99ZX4B4Hm% zO7>w7Y*1L3$&ud&L)=UV6sEVV6ZO;Vj*ss3+PiUzB%7G&{5(aGbAC~GvDJt{*^+#) zBqN~0Yj4y@g6LKGonej+0t3j70i&!%K0*u@?FOcV2UXL0AkI7&mHnc?x^mSG3q<5F zbDni_p`3Q`AKF2)vm}?jfV5-F-;!Q#_5w|cP(*F=2c0BK<&^);aikM?AK>g+mXR^t zj%&*rCoB>j8p~5_ov<<7Lo#!{vNH|6ILUof;suuu{>*D@iyIAp3gYeuj#Rc=8ic#m z5Hg6D76?l2Je|aIFmFvCGHj)5+rcFUHRAKlv(`iZ?^!sAy+V*pZC$;py~iEJK`*S42520rGu}HE zz0a?jY2BC5oei71ZL11weS@p&a2kGYE3GgR8}Kax!$M+i=hcIJvtVYEDu))T!iV++ZYwCSy>*-=n<5dfSB zW!u_a>M=JkrlZCNiC4B+w=yH%FA~m`HDn94NVPW^?k8tEoL8DdGQt8b<(GV|lpPTD z+@=scec2M!2dtDgl!xHe9P0IQ9coL>_L8>1%>ejNnG?iW~I)yll0Ql@ln( z?J(Sc#4I8-?XR{bnL@Fu13<28nv~P8V>-=O=hjCW3j&Kv-7~V>Tn4s3Wgo=hHFrEP zrb=M>ry2=_1}SmO1ns)^^VL%kw{bg|0v}CD25a8dDTO@(!DOTO+pGE@t*SIBHh#?T zf&zxr5*3TMfVY z5;rIzrY84+Nc-}SGa+{X(r;dBJ)Au0wMizCeP{NY2P+RH4$84qUj$nxrIy5ZrYByewb&ip_qqvRa z{Tjm`lX^2{q4{W{IG``_RNj-N?e{d3D#xVh5gFImUYmko&^s=H?tI)6$yEh*xa8gc zJJ7sQ>49M3u@#4pG1`4_2?F?Px4*N|eB7VH_4Uy&tbx>)bFAWt#!H$-gZNWC1h6wv zCYkm*@GB!TtC15BLKZPF^0(DYhjPukqc^okIN8;lt4!L8zYYz)`t>NRP-tCsk&#wv|NPmC=rj z_r9k*FyQ2PYc+hN16VV0n=dSi6dTKUfFaLdl;MlMx>y=ALq8oi$1RBcKdPmIeIs^Z zf%KVpLm9x{pScP$d^2G-;z%1(!uZo_52Dv#;F-_mDUPXd6BzApN+MwQaNWB{ zBw>h4@1Nvp-}Fe#U=e+gZk2Z%?8SPDWPy60Kp(CRyKn#pS1xGUWU=|!u2s`Vyj3w4 zpP;INLB$#k=3iOu3V7xT5dr^lR_6D?eoek5E3I)o$Gpu4Jx(cl_+>M0*tNuumq+@p zyzAu>1YCndPsC+i*HE#ny8-vrGd3;fZSbn%JG^Q47Nq|>F5u(gxGmiT@I7+BPQ0|} ze!&v&mKx6sl7qt`ivp{R%FGRj9ANZ^iPqaSl0ovVDiHd=PL3jeMdA3FKpDx7Y)=!m zTxxG~4MTzSh0WUJxk}tZi%CLa{F&G%5kW!Mh0N0R_+wAJ4#PA_k{bgz-Zj=ffazi? zWP~lKvQ6xm1xn%CsQr;4=yu~8!^}V{WR;!v$)-mK!kahQ49bIy|J78%m#y)Kt{Dnw2JAln~Xqo9{P=bMkJ(=?vBRx(r4 zA}9`Zl#8>?+$Gie`uaI0+pQBotj!49H>oLjb(s-c>N%jO{|5{pSk*kY{T;Lx1XhA` zjQxi$8(6fivHBgl{=B8%|B!kAr-gCFhzF8vOlhNdYJjup?wC!ebEU*9OkA9fW-IMa zNX?R|-}>Oo9sq7>!~qc+olLoc{RroP_I3y~VXDhr)k|A-sg+wGhDUKM#OutNO(nRj zemHLAr0B5f7!cIZxE9x`rz^f0@91We$^;eEDk@^(#M zh!V6JZxtgH}lVFl_7OiJzAD89-_LAXPi=;F;~ilc$#sSjE0s;m4%`pfKKPGM--u z92Mc=SvR4j;s*Zxl`k8SxAs$?)?OG5 zQboNL&yeo3DS_6+cNk;zm3*xla9I$B{ng6XMNKCauivjX|5j(xe4KUz3UnZPbJI#; zA#%3Bf8_hoNJc20lsJj%6C8fT&wg@Z^`lL~US9OMRe3uiiN193HcNgCR=;NlvTA@) zV-rfp__qTd<$G*R4`y>LgOkf(N(P66aWlM&hMsaxe+H5V(cURlLhe4J;Y2u!X6~%+ z*m})t(p3nqUIonnNH=*hY`ostb<0$49HndhV)UBL_~`yY>89sipPnVjxooK6lO89o_Tx1dV&^N1_qj?OcrtP`(_ z8O$RC_{T(_=hJF3t^9^9tC=B0p)dIvz7w~bqiB2Lg`T8Cv_&l|R6?K}%6s*oJG5QL zljT(zB6DxrpXcXNmXj2B85ql5l>FfXSsp zju+k#SYc%0F_r~)zyc5*(!_x>|E_XrRUiV%FgaG6{9!H049nS5$ymZE&Fgyw z5!niTvfV_b``Ybxc~_zXD7?rN@h;U}1xRXft;g281Wu-7NAdBievGk!5OG%O=z3q-U|_F+r+i0H$c8f89`QW z$W~5QrCo9BYylWDY3MqXZ8F$0SH+r(i(D zhE)o;m5XE<=aI1=r%XFbo2MDm#Suo+jDq(&zK|D01CZslPKL|$0fQCoF4UfEJlb=k zL+|;AQuO6JT89=d;F=8bnU~Y?QI?;H6uNm+b@AP7{%s*^?|bj1>dF!r$=qjYB_(Cf z-B62J2CW?ZuF!?#2WSAvdln|@@YHUxVpni^0H|Q|mH5}n!l-xX9>EdM67@=qDoE~s z!Z`}RZ**@Cx0cXGtII3&z_2X1&#-&eLQI}AX|jOpFud5FZ*;8iA6PBjl~XF`#R+y| zr;a?S_Q*aArXsj-|0KyMeTp18WXiTxOX_1-7N#8pW=bq*Fia0jquhFt5OEmGj=<;u zMEtE=x^fB@aaLVM0E^glf!wtI64Q)3>ZbbNcV*RQfFb4MB$0;{+p!Sv_ytGAPL@@d zwaNcVQd=vvWxQWL2ILhl9+Xu1z-XZ+gA1lr0O>V&>BkNK=`-r?R`@A@9=RGzOKORw zl0LefE&JrEyUIcCDr&Ep<^Uw(udJ(X+$XM_DA4<)HVL#J0SDqFm} z$s)j~BE_ni3dTFO+3wT-X0Rj!YtMtHJtpzObaHhq$XzJJqC0mE#^OnG*_`wz@T;P1 zhaHuhW!l#I$`hrQ`ACsTUQ6w2oZ6qS>xuRigP%Z?8wJNtnMdaA5>SLsq)^nqz0^o2 z9gk=PY9fAM#%CQIjiM=$$!;+c2MRcUhF3weOMtz}E=vgrj_`Y7W~G=~hpVrEJydVj zi&YX#ueLK>#XZ@81LD&gqoYLt(cc#NN3fL$BJT60=%~R5X$a;7{sA@WnEOESMAa8d zWB8@Ng{Oq|FOFIGl?Y5%;Bj4|8$%?tv*H63V-+cWrYl^iTm@kZjds)^TxLAx`-yI69Dxt z8`$z#-+nCTeuZ4xlL}XfqmHH{YU%>3IBW)T{>-hx=V@hPS%CEuPl0R->U=bWB;evW zk=!rz|8zfdw3IUc&UZZObE3neG^yPeH7ER1Mx2&Ge`K4 zdkK9-!UOJ&raU3%=&2sWDG)`kXAgG|$*^PQKD=c&Ow&nx@w8I6Ln=g5d5u93rCe?l ze+pX&IpzF3cVak7y(Ypsf}6{SFFc#X%`EO!M3K}Ea0Uwq2qi%=bcyYrEV+8C@BDvp z$~uBrxPdw(-xPJ8FXu)ta`DOvr|_uQaK!=FJjfH%@# z-SmUY2vZJ2<6}EF;(fNKgolRq=0>)V-9K2Q2mS~PG6clJIzyP~$< z5#YNyXc|Wvo1NIhPHWT;?TafNyv~@mHqyt|=71(iV5pKz{B%*u<^L$;d74w?Y*E%x zJ$G7m9H4MO2?g`mQ+gjKq*Z6aAS@0ycDO}Hl)qjp>e9=HdnnN^%XylgImRE_}8X$hNcuv4Xn5yBV}WodVEusGNtNKvw&Cq3L`y{uEBFjLg2)f z^R0Rm0Kf73zo9_VG+rSW@mz}Hl<2L}rXm2N{Tj*Gr~KKx#)burnU9`#LlK%i2rw%~ z5FAJ%`fPEt+Ujl-X)d0cSu&JL`YwCXfUxGu z(8ojyY+=rP)j=Km)Bo^O!w8Nj(Y|-#gJcq`S(Cw2rIm<1*K<)>3vg_}*M((;hJ9-T zU@;XvHF)MYI>0gU3NbfYHe|Pdf$=d4T-wV{=lp(9$0ETKLtA??JpiBH@gq)GrHNES zDY1kY?4WD}j0Iw=~E=*{9$cmM)M!!aWCzt_J1F1zXrtA_RMh!F3Y2lovX&|ZS)59;h zG$YQNgG8($gU-zIUlp1X`phmQ*gOV8Jn#`F_0_e(Brzdv!kQQ#$$O8?DtPSDv7ZXgTA zU#v$}J2YLLbq)mU>TH|L7ZE;^1VhUuvS>1s_@BJ2FsDt3O7Ie+I8VEqQR!Ors-kQ3 zFg34)UZ-+Z=-C?E^UQ+NFs{G|Ut}0EsyauFID>i>AJ9XFDaWAE*E)t65_gs;g{j)F_K@FSult9}v4(&chxL{b5VE09=7?TMHjU(zZ zD>(czpimCTLy1ZeTXxBpK1bcCKCw~)1+qFY31U6O1)UX)TGT1taHSR1P`p4t?WBY3 z@i>BIl5_QX(5dq}_p4ZpLr=MP(y1mep-lKcJ9`OX~I=Xx%K+>d!I+ z9qBw`putp$B2;Dg;32A^h^)481zOhsKCz$zXFapu+@^F|~epj`-(kRBbr17H4_y>|7&z5|K>76a;vi(ITD_U`|iS=|i4nW2VokrifhK0!S z&8c#5Ecr+fv2p+6kcZq;txr+Hbm3+6Dj0m-8QQqLmnRI#)>lp`PxeqwE5REGyC+yHF}UksPxY&4b& z()i^f4RC=~2fZUBjYn2>8o&a zM19OXNzL#D*ZuF~gh@ax#@2zo{|H{NbFRQy`U%vlcZ#r+S37x0X1FjW)!@@=CaqPI zH6T>fuDIIZ!xIMd{UOl}D9J8t*6s)USHRM6_&g8<2W9Ncn-qKbGP*k2xLCgVg%fZ= z%97VQ@Pl}+gQp+^@cs$L5~4O^&&mXbIzr8)#WI3qchlkwG=`*{8&?D z4cC0PZ%WS$^W)yRhwVPrE|ux^hiofUbInGm(bu z30STJH4|EQ0~3)?-~4@qyKo5|1EF9)Yry5kc`E8JQ-F|FN!}2Gl|vG&x?2MsB?>;j z>$0NRRK>ftvAgxwQIK!sx~(C*VA^Orhyfat)=qgATP?sbdGiJgHpG-IMx5N6I$Jh$ zeP+30=tOL5>=3=hs@e*-QUSTpBkx#;*4}JHg$BR|Lt;~1p1%G!NKg%PXCB$329lQ4 zgnGOehmd@-2~2-^5Wd!5VOGI3P3UPXoC+{&>Zpqxul;+iNBtWkF!AY$=c&e|thIe& zmPV0>fnCNd^K|Iq^8APv2h@UfH{Qg9z4iz*@Pq-#7-)s;yu))rAaMaqTz*$nK6vEL zb`&&7N971j@JF?u#Ju=9HpZb?o$dh4-y9o49prgQ{{TG(Q0OrFs$vlEU5Nre)-QN( zY()~^%Hn=c{V>aTZQCrKV#Lp?9aRO^!}8z)i(0EC=+S0bA}5jVQfS)=G?oD)gxb9p zU#tj$&nrXeaC>ZM=bxw0o6}^vE?)o#qb$c(+llei04?uFn_ebb4LRz6ctrjDhJP*T z$0AfUzN~JoarrXY!#ow`gt@aBYxC42tF__BZa98virE5Zf z-SL?fWSM2eGSXy6){eaafqgREc8dDjm+xz()#EDNyd@S; z{)ml)@P;jj-n=g{ai$A63}QY0;?G@S1bL~bn9GK;C>x=NkV3apP}!Bgi1zDi$CrsM z-$}acu447pMtKq!citP`t=Jz5>+KH1jt{sK#s_#fCYKK~6BXQ)3JI19b9dj)7fBke}xJ|Mp}KFS@Mj!TJZvYHl3zCJV_6eG2*aEqQKtD`SCux??12iQSm_~};0 z%w@CYOhs;Yjos6p!JYJ@(cThk#R-6lGBY8Oju%Ph`a*(&)#ciCZp4b>9U8AF=d*z@*BFW9|nrmuQQ`PdlS9mt+`UQT0o&o^RT<@*d&$ z!ellY`WALTy@xq;LU23bEj-=;m?S7W^FC%Ve9me1>2iVlK5$okC;J1FPy zp#P6+lx6RXZAn8mR{=t_lQq5_LzlRVUW@8ke_UG!(`a)8rdHbd3Tv_qieli%8JD7E zKwbYEAP!FlmLXOS?0@v7h;4ves!ZkmmM=c^%JHlo0|w2*X1(LzaV58d)})q(U0i)C%`(i|>r4^f}q0 zy6^QUA6@?FzoDW`g2F-4-)4;%Ou6WSYS%>PF~oN3g1UzX3STyR^uwcpMv#KOfY50k z)znHuN0f4k-*gV)?6ayurEXpAPC4S9Tqyf?ro};+R_P%`piA~T3l++9a~$*#T^yEG znZ2btyBTBwR`4RLgZ4^cg*dUMQ2249x;B=&AR4tulgBk)>A|p%2u&B(QDoBgm*J^q zody@1cM-d?NBtsR(+zak2)3od%h za>BX>?(RgOm7}x?k35i;_erc11P|eyK@;Pvg}g97tDp;9$*P^1Ev>cJ&w;H+8Ct^V zr1!BBPTfQ*Dy7wr);T27<`nRXr)PGyN`vBu-6>maT<53%(ZpVY8Y*pr^W4?GU&Yp# zd(}y{mK^2XgYA`YJZ%dnpBmC=O9vpZ`)MQ1m8>YtjZ|+NRSI+W-6)#wo)MNxkERbX zx{xJwYUSWQ65tu+E@%Bn&yE3YR&!kf`@qV`-|mC`-ou>T-(r@YI<^!K;UD3p$sV~N#w3WUN9tQ8rd1n9{Z>s#l~+jrBEHmpnl=A87KzLsJKAw zr2XeHnP2X4tWTO!UC1USyw+eM$JxF+DK!1gHz3K($W)1Eua8{B_r*wn`sWJLdQQ;l zTm!rXIgvUXPb|}wkvE{<0KAG;UHMpF6zI`U*D!Or-t72j{>7+UE&on7uKW$?B3e?1 z8CzJ&lr?CGsAGpxYFh)>2w@Sy$970NZUZB&i@#P@oqy{W``0Pxo|xL5&|T5&0)3v> zT@0M*nk?KF(+DmMw&L?klckao>?I2Umh1m^^if0dvH|2@sbu?X*KM1*!ObFc6W<>> zX1E^azPqWoS43E9RWj~wxQ{^rIXL#1FjPm4#X$ROLZR?{OVVlA)*!?05W8cKjx z#!#(2;mk>R77YAI)X|>r>u%jVt^g4m^swx5V@3ciK+?Z=c|L)$1ADwrIZ(WA8bZDe zb}QHM0zju>| z+a1c_^gP5#0fcQZv(BPU_crWvw5w^Y*?2OuTXa9<;Xe^5s^yzP7+MhKx+e|qdKtH> zPDk_1UaK_$sWZdL5h;N-X&I=n(SwVkG7DDyy_n!X>u9blrkKf=!Z#T{z&_!ib2M*Zqv{#}NDzIL7Y>VUCjoXt1i~%Yl-}=|#&oj0;{7O>b(xiP^0H7C`e4Hp$wp z#MT))_Hw+2&h&O(w~CE`nf_faAt!D54iG`^R~3qF>b36f*{syuwZ4C8!CII-nt&g9 zFG>AzI$n9mprG2f#8;0bEUEz2(SDve)~e~qkrE|Hg7sf0XZe%th3RpN-QlmMO?}4 z0aoEWe23v&MVYV0=Q==P?*EYnr@58~G*GS0PL|UF`Gc6c`6CTxe(PVUC7M>uPh)G>X4fGNFoS{#q z#XG4<7sB(iU7*pbhP4JlmtH#6@-3hFm9YmA_$BN^e41gz8X`7Ab z{H&wk#!tj8#*-^A1P#qJ)*W=H#^(U(HCcwAlN&kdiRIKxLE0@yLPLD&)v+SV5p|vx z8ywrXE4u%h1F|A7Z+^-p>?%?5)=ujDsa$N1RGQP7C-I(IiMJrT?J`FR=z0xZMR!@X@u>@n;Q-=$*jV2xzGitnvUsc#m9bTN zbPzx{E8JFCY@kJQaV$^tp;~th2_(YhEFe2Ka&xrMm;g=~Ce0DaXM9cm3HMxW zW|iF)`H#b|i3ri}W~#?@3V1qM_7Co$>`v5`Xis8tFq!H(c9X@T5%dvh>@!QcLNQz; z$(nXxdwCAqMhPBR4cc>xMQOp<1v?}E%D&mX3j0f&&DMRb`z9czU_4gj-C+;QJp9tg ziIn@C0OldkK9m^7X0zY7Vbb`wHygh8W@}gy9L1NbFAfpm;yu1emms);PeLYf#jx&? zvpgBrTF@Lb{85_KZvj6bNyP4fY%ZaV-1;>0I`6l0>Dm7i+cmw|4iZb(HwFsDUVWES zhptE}JaEC8+COS9?Ew5+r)97T(KO;Q($8A7A#PQO2*5Nh<2J^1WUxqM^D+<9x!0TX zfHeLr0vz*B{~mmY>MOd@7J#s(rHFlFxcskIP1U8iO>K*_G#k13EolE3m8D_5sx`z` z(=s)ju-U7$XMT|cb96r(D~Z$0dH{o%)_>I72NCPsh3e*jF}~BZY`0~^ik_;zbv}iB z3nV3IoQ?zkoBu{U81UesK2(c1hobuN8RNPygxb^B4lxLochw47qM^!7cwg2~c+=fM z9u$u$8{66c2aB$gvc#G%60x&&r=SDKJhfJ0hV){Dh>JyH1dIP*v@b=wFhzVNl!uqv zImZhqtXVx#D5v)(zd;W5#TIrh2c5=Qlqy`>LIy0n^t!UgDszqOe56PgAq#3RA&qfz zggx8adnPBwc6aZ&UmC$l4V&;4Ljos%vq`8yYQG>!mBx=Ib^2S<67iQVw0-g(y zDz=lYo1n#KI+sYDBI)3D|5U3@qY2dDg zCTawI4()qJd5Npb;i9^zUIM1BH?V>iG=(jEV>A*H+*E^#AFz{Pc%on&7DhgkHQ3j3 zr!Btz+36HQ^D{IoYi+QDFlA+Ozd%5gFwH{}b@Z5(0MwYd!47eATgW7^@DXp&sPj zW;32iVtN`E*;lSPrbBf=Q{66@Pq%a956inA_`?!$q#&o35y~wyYTwm2Z@cl8HYSR( z=9lbN$7=IHvcAX4l$CVKxG!{rNjGbrAy@0S72KC^TQNSPoz}WQ)AE>=ok#NL4~Z76 zAGJ%hyXmg(iMxI~LcEWPr3kyOH$JWEboqHKT}U%#vPrIR*VI9 zW&cW{wJ@xL|9L@B$AE74yHjcgmVa9aAX%_$ z0Bbw=7Qo+N&GFj*gTnn&Jh`^GN_Ijq%JsTnGmxALRE&V^V}wMkFZn*0rwDV6wF;uW zbA%bB-St_&f{^NNfCHYiROXAj;LQC7{~)rUM;L=%t`SPw*meA5c3t7WVv&VFrO6O0 zbpoTNeCp<$%fl9my;iOxijNkhti0!VugrVoF~}y=f8Fu^fT@NYS2Xl<@q&!w&fdR2XGNNIU0M_~xIjQhR_($iS&1>m+gH z61m_{ZT5nS+ylM+aSSd(r|q0cz&LRz@Zc{DF>4!G0f(pdyD zaGY$)faC)6@=8zZ4Xe`xwmG48ailU`W0@zN$wb|de@p4veCIbk=S=$&;oXp(!p8m# z7esBhyi^?D!$(|5?|(_EAbz-_letX*21SUC%{_PX3Mvg^-ga;aIxau{bw$C7G*0Dl z>l?mcW8**FwvDC`sI=sJ!{dv(@F-1LbMpcqmyD`~ds6OQ9Q<7v0$=&ml)QaMHMr zifU492c5gfEIG&SlZmUdh1l7Y4A4U&I@c_*JAx((CV2v8@Iyp;Ql9k(y z>Zy}F;#PZ2YOma;TE$$;T-Z@-nQ=sH3Mc&^FMo44#4|o2m2h<+UNr%uzM>TmxulejG2sdW-LA#9+ zb#u@CBvBzhA0Wh&0!NIn$DcgB75KtE5L~AT(_Y{j}p0){)iAfO| zFl3gI#MLV)z(eJpVe3rbX5X?3=`8?ZGfL@oq46P<^wHwq*g?$eWVYF6w0WfLayN5# z9B^@4Blf=OA6|isP1bROm_(BQn?xS+>cYy|Ou(Ri z=>G48y_cKMH{N9>NbD{gSi$Dh!X2_Xk#lpCl<^EN-BYpoFhv7HwvG~(P=@*>CtLQg z67o-8a)By5Zj0dT{ngcK$&rV&n%*p@@`DOBgIeQ(N-OoAER=-)w51+sW{o^YfrkSY z7~Lsly(Yv($;~FOL|;pp$fWT#TOUfhLm`h{p2aV4XsAzX+5Zlpso@t1p8q$MFSjGv z4%oBwn0G}&Q#!m`Bc?gUpnOd2T!m4I?J#xrb8IEl)YU|JiWkJ^Y-hX2tP;%AG0W;Sl;cF+b@s}L8CC;;`=xh^+J(a+ofF?KzbQ5peLi>PJDj^(6iyn`+IJJ@|7Ou@~bMgpfFb)!q z@ra+>>e=tLnV;w|)O>?r5W9HaU4Ms7q^Ly#d2#n}F=C^qcO}t z0uaS^54VS1lFE(~X-HQ{rYdgiJap-p5sn3BEwi-=v$R)V7qIx(e@EM8lQzJc-60di zZ?_9)6uLX?$F2b=H0T1*@jM_+F-xZ@pJLesWF z2;Mr$V88zofa~I`Njb>;$|#Qiat?;Wd$7Y0&YxShiwK3Cb zKOU$hG@z} z;5JE+nKd!Ao6`Pwigf}DR>pB9J10-rI@pFBfi={hib&=T)C8;kxiy=ED{lBMNIgHo za?-uK%&{A}m$`)RsYmkDOTs?G-41yo3?oaGnk$v=m34DG(k{Y2+($}!trOx0RHbwS zySshZYrs@N?Y_&>Gfu221y-{}kKfDsTk<(gPRBs4Z4 zkI)j*oTu2~MfhC!*AUHljG7CWtrpF6jUUL}V_+89cbh|OXM^OKQS9OT8H(zh@A0#l zLUNt~{Kr~srxJ;3ClBm`Yv#&6u8~Rh(J6%d*6}QFdPiKy#YOg|G-N(7Eq4L4B9L33 zb9yEX?lZ7c7jR8^E|`?Au4a$N3v;LuWrn4BY*;clbEP>ziT2V9N9V_Jo%pbF$9q9Vc!8~u`rA!0Q0W%YM1wPi z=>C@f8k&%*L<%;Ub;iu>;jiAj4bNFZ^46`>qzmO(I8VxQ`4#gSn4~Y@G*Ngw&6>{e z$>H~-dDkzv1TT+pW_>__G?dMQUBu_;U>)-LFW>iw)KE%NthLXUR^BzAe?=STBgt$Y zl=p@Fz)V9yzV7RyD9L#7fa3{9xhp^Ic8*nPTq|?pTWj8`hh(ajQsL`O z#kJmrhPN|ccc=ByAQN}U1+|bQvW6yDp>PrR%-fAC%;l0cjDZe(Z@faC=4BORe72Vx z5#Lr-v3hY%)X2L|i8i}-%xds^w7)1_KEMAQfE{er$YNd}n+Wen^peW|szge$-Vend zWF*wKD>-5DFc~d@0ZMM9l3QkBQR~>yZ4tr@DN`orZhEm?z4!a-Y$2s!_L6s*4q)NH z8`~uqf);&6Qg1CD(~P2HcCu@IFkcO_d0~jcbJy7=*x|+m0|9}_!O@+AF*e7q+{>oC58%p z#ufZ?kaqNe2nqit6i+6^NE!`2k9VSLdqzfYGx^qo#Bbw0b(c`rlJDf}Go=l34{@CP z+pzQCe1RR)LU_O_MDxDFkJ1W%m+qGA=@~+E_`UoSY0b>7% zASot4szTlL2<@tsM_p4t7xVEcHwfFEX=d~ zH~v}CXKCBij?b_*4M*Z-0hhKWUDz~d5K(+wYTa2(bI&FMPGIrPN^_au?pjiq{SpJb zmxu@dQDN!@xU?3rvxUH1|DGzGgHjFa0J?SVO*Q;%mIoQeEGkuf!YJHN$|C(Y?YH0C zSi-m``-epkj-j#m@J#rN-?rJN(OfruuV6b7S8c%MkpN?F-44xQm z7%(qhM>}A0xUqXO>uI~$^@a&{wZ3yY<`29s3YM_9 zRsJF(raEH3Dqh&dSJ|i%bBju*AI%u7*;f(7b~F=&5zNlPTUAKFd6NhPOG6kNvXC^0 zZ(_&3wT(3y`ok`pwqd%@be4GeN48srG!V zc)M=3(ZKVo$Wji9lB3^`_n7ii%^GZ}Qk}4(O&6cly|TA#Yjo6Eka~_b|C*Vufd+UO zH8&?j{pz9>LamHR)p?owN3}H-HF%JXeZ<-6ZoiA+OToQ120AP&qn8o9O zIj2PZ&X*jP(pO@1FX5>jmyd^V0B)ZDI|dtg_V5IIzAzyxu9iMyjDWZoFA*Am6kd@u zElWSJz5e%p857km@C?{4OJ+Y=pr+NqctpX_fwnFGpovb(D5Lk&BTT%MCIpm7kz;(;!x~jM33e6cI*TVUNUHvu*H5s5bf6!r?8x8h5gr~ zn$4qP86B9^N)Y(s;IO350!6i;G9CXJi>*y8`xh;}nPtKH-jQ8uujr0wO_ zf|bnj6({n4-E``EdlV-vplpahNwavcfjVFigLYC}xH?<7zcUnX?xUWCPw{!nvq}6l zEQIz6m34*muP0?gOaRpiGCGRk5ecV}X1@`>I97k~=x_;khT?9~5Z@ut%*5d<$IwEf z#vJ~F;hO-QtsvbwEJebalCI#IdN}ZT+d9a{QS(uYN9p%hea$IE=A;}iWYlqMU(1`@ zfIORX(#({|aud_&1m&7dC;!vVJ!V|H=FQAhRi^=f4L}={6FeoDTlqBOfLBEqDK*2OXcDt+}NC{QeR%B@<(#pA8Tdm5%fvIdN zV?hdjpJ?Zf)^we@WWKfC_d9qj>)LinM$CU(D>ONB)k0(9coGgU;&h7k;6Zz%Bs*Jt z_pfbqp;xXtMpgpH_Av1YHxMx`;|O3x^_c&>ilcZl;BVUm7SPo)0ZcL`TSHEW;6p)U zmYT5GX=NQ7*=DlmlB9zWMIW>2c#)raW=joyrm@tfTlRIFN`yGT?~XQC=AWkH%@{6S zvVK8KPwG*@fEkowy+E;A#mi`xdtYjRPk}{92QDc>`sU+v&Gfn+s-?qTgSQQy1QQ_mSzfUNBPwWu~K zqp*Ii=Rb8MFv$$uac8|q*{};bYrpPyYyP@Ppb!yR%?TCi1PmnB_HMDey8K0=8r?dR zD8G{mIPm%Fhf~MxD%k7j1jbAuwZVP`P>B>Ozk={@gPOt^;MZ8X$MZaPAVKb{0Fu!# z9lvr`FW{+v$eN|>?x}@ZpsA0oOPBc$+AgnX zs>ONKyk>%0m3`mTwfb^n)$^0gU=WvyUcf&DGN|?&w|5L3%n$bww0KoC!ULh;LVbPo zF=md_%=yYONI)zmlK4glpt@P)NTgD?(4@6{Jn#q?DC-bxDxsgKjGF*yOoE{C%K(l$ zuvhW=umaR82_Rd0B0Qa00C>#XXO?qdw7&~#Xa0ht$3wq(;v*55!1A7y< z7w~>kFoR(2V=A)i*o;Pc&3g3KWyg{^xs27H$Y^(?j+#Bnr&A&Xnvjow&CcW5{JD4LktP>$ry)0y*On z%naWkRE+r7IiVwB#&qcK9(T)`;4+LBEzG3rG~VJ2l9txMHX)c-{o{9A2_X*2bXbB|7nSrlYz5fLywE=X9`Vx&9eX(nW18FnAU2I4E<4l%Hlj+N~Q{(gm(C)Vi7Genbm0>zI4G2Ej(Km4bknXhFP>&M5rObe`fE&u_ z3BS-3^``G*F1}^koUFXZX~Q+#q(qu}5cl=K@kuUw4PvR-gSMY-5+;iB-|)BV{XFq}^ZAy7HS z8vuz-jchV6Ij)@UY?1K5k!*ig*<1BzGpBLmS*`qZ0Cxt3{>BxDa;PfveulJB5^re< z-kiBxeHRpy+YCLJ!^N8j=i|kA_!%v)duT^4eYbG3?KVgJbd8&kY2yti#$N|D7BOs& z&F1f4mj>zR-^ofv^EM`{F8hpGlKD@ZxPROO{sasKyd$$rqrMWvFSSHVR{QulPCnRP z)u6sMU|ga&6b?mNB6)7S3DLN$xkrIKQDNtnF(7Xw#Mo!wo2Kmy((^to>L{~M$8Z(c z!m7J(3&hsHRSFxz%sos~%t4exR;CsABi0)EQypqS4;()U+mIl{*7v0 zc<{=X+|oM@E!U&3ggYRy2?rYiOi}&R!>pTR44YRqK^}QMw1NK6i6uu>gz7oes+jsZ zy8nRT+w)rnq9rs5r8v^Aq^0o3kXU@5^qReXn}SyS(Y5#lf>E>tIrMr$28L)nG_GO^ zff^5+ZG_=>N72~Hd(2^#RHioAa0@u z@HQ9%#%j=J@sF7!CS*8uJvaF(?zix`QX1{~q}$&MBpRp2jk+kq95Ld-?H?tsiT{&J zcOXHSM*1nN_pmswgdF~)f(2WCe|0;b2ou^#KVcP_ebweQxY!G9%WBHKDu~=NF3iLS zz=AW6R0a+SL(f46cvv`$Z!WzA%;-;=4eY|oGbin9sY0YgLp3H009RsWm_&s3brWf| z!&F7X{}QB{>Y@ObSi;r;dnwD)zl;H+B^x`b$oejzihzjoaA`eQr3nf^SsPPlQLc*( zUnqBi$X$5>aQ>k;h9XJ>FAOh6kS+?fp1d)sD%a=;d>`YQ2!iiQ;K%KUpdm=5B`t)j zS-8OJZ5gm%)E(6*fAd>23jYC`R-AjPoxIrTbCO#TmQ=!5b^f-x-MXJ{H!kuuu?AQe zFF?V#C-rATH@B7K(s%b?#LTjiSxnjtzZ5oW*4Cmoz6UDwHS!mAi1^)>k%d0Af*t&Y zj&+0PEI8pfzx)jFC2cu?dX(UCIQvK6?F^+Qd{}U7m_%%8Ng^P(s4bcq??K6Ps<1dWY)iL~w;5HQy*GMIq@x1mfN40W&!HmXX<}9hS^o+?$1Qc( ztNccV=T@b?ZoF+<@9c;h-90I70ciKRxAUx{3SUAmS2K!Zh9H>y%p`=>PX?5n>T{f< zNx`=f7iGh_i=w(B6gxiQ!r2tX4vmql{x5Qav?tAnncX5t zuTaAWh&aj<+&TGQTmXNoG#}QD^{hrT;}nxhRz`|5uMFXjz36G>jh|1f)^wN<%GK47 zH3{X>=ZOzT`61oS_+`#fF_FZCJQZh>BnaPyFa4JMe3@mBdRRwYZ}8qf6zsRCjDX*h z8-l?Pi_&zLm5Mpb*NGMcwJzcTeJckRkgu|0FHV;yr%E*J$F#mojfdj&E7aLszUTQe1Vaz);2J9it!OO7mkW^;bmQf+sAkS4 zB~RQXq)|HlqIzRIdt98WQNeZ}w?BPac`VnuH4Hm;WWPn0k-(gN&D1b0C0*C_E*V{O z_64INq~b1Kd>7OGI?22ieu7Mkt|h@)#!LJM*~|H`#Ehmit>*%Nl4x% zqq)dhX{Fn2eY4Mq=_!>d<;0+w#EuvaZpjC4-anz)(?1xtO_Xwrj~B+rZ^#{%pjqFk z+R0=|V02dS#sB4Cq`nDB@*^X_K8afq3!=`%T=+JLtTg)|*+`eO9u(y%Ju!eY^S0D{ zQ?_?Ub_3Ta^un()Jj&!TW`nn@Q0JTG58`5CBM?_aLHT6vaaV?xW9RbUd5z_xfF=9K zI?L&2LRk2z|H$Ipr6_(wi%F@y!8gLd;9vpfCE5EpGM6zwl)=pJ$_*QRI!sorukA6V3fk=1M=Bj%V!_FO+mhH(en2E}@bdq2yvGF47Sn`xap%6+9**1ec% z0;8(v&#iLyCDEp|6-Wv3B0SaAqk0&1IWQO{m$@igM0nEAcyBb{{hL-u&-JK;C;63 z^&j}?j>2$F^gADp$deP8wG(~ZzH{3Wy5%rb*lWZYV`NJYzJx~szq2F>;tgg4-6)ec z9%kQgnrRfBHO|Lty9B#7s~dR5nBXE&fA3Sjk=n!vG!*1NPCO%@BDDF>>)CV8BU{8YxiH}eD0FoqB?n~cCYb=aeufmkPio1T}Y_rk$Id$MR z#~I_M-!+Qf%bL){DS)q-w36X&=FfIIbTVVI(vCkFmcp!V-j0(nt*PpT_v?(#XzV1{ zp4<(<+G3hhKe+ZIkmfvaI9#3dcu;c;AckKICSE>ch-)8fg&w^G5m~ExrvllPI=#Ni zgSPQc*q!Bbc!6mPr0XGsLkfxen7{~Kg@yoyWz*wOSv9nge_>E_{N+PbCEyHbF+KRE z>$2>stdgiImvvEza<^W8j*^ks=oL=IQa};9&v@u*7lbTKppsNp}tYIZW` zB4vTiRG*-LsmW7FX;_8s^5F&7({!Xb&(~LVIYojt=Ggb{FqB1>WzIEq%?~9OKg;~i ze)ZWtI(5r+th04wSu6a@u&0|Yk$L)Z72%4>W=cEnoj6zN{d_nsvSJX4qnkVelCFd= z20d3Ey1#DGm&%PHm$$}b3UfCf+5c;SrhU^FIbH@zrtFeZ^Di0CuVrQ#f#DL} zx`6|BllrcEJH1feGKz0te85eyd_evA0XMGJ(F(RTL9K(7tylc|cS z!VP^_BKpmmL#bE`KKZ%xGEz<8E*9fuMnw9JG{(A{emHB`<{xmm!w5sc+LV`J@MOp; z1zxj5FuK|Md6rKXO{dx!6f(ZmdjM!|;9GC2FILA_O4JBmNa1r8XTiW2$kD#zA<3i` zZ7F)UrM#Nq7&5{nuATFfLOFsJ-~GY=Z+E}qteiQO8|U``-Mqd3o4<915EL|+ITMyY ztQy}Wa-NKT-M(S&E7cmHT{T1gZz1C`>EJgqwgnV^L`D!PF|r(LN7}5O%d@vO`9l)Y zthY@-wef8im;C$aJxa99Z={(&i*TwgkiP$_28{@V3QEU_XLzosWbJmn38Ta~u6R7= zsrs0IK#@ z9cJ!IKsdCh#>l{PBL!FC4SL(OrqY@77OQsLjFE}}DrU2rK_-egij5uJjv%u7V7}p4 z2|-r`)R$m$t>6MDhP;R0bZMiV#X!v75p|m%xTRsQ|L z!ciKl)dL=3Zve)X%GLOuZYj%(4 zA%XM|hfaPRHN0kuhp6WX8F>Pmi26(O@UhSn0uHtfpUYijGa2{qshS|WLMV3eg~Tp| zzauq6aevWsvW2&ag{cHhSdV0v%auRd&M!DvYt{CN_IH%e7!Jjq?2|Ifj|2{cYR)j5X$8Rsxpt%FGQwNoBNJES+i9BfNjf1b+w)_& zK5QD@od0!su-pAa4MYnT#Km!4fqFy6c;Mws{l*aG54Dn#AM&pMqH_^xzmBF097eJC zRrTr#D^=bP2AexU^rF$DuC@l7Ofnj~vttg?B|FVh*vK47lm0;2aEQS_X zdocZp$@~&Owc{g#QlgWRkV-tbh)}Or4Ims|wupv*kkrCXo~rmtGgwYM&2UAqDj^O9 zejXh8gLZa?&j8B&SOmO*Q>)HG591O7^~vRVG=e1SUT1(CN(BJ`s3vl9>+q|o09uMf zma~-8?JNaQVBC4@&~%hubliTy={8ga>?eV$=@yjT;#kS-hQNEDXNr8{E_Y-78P{B= z*ddlD=&y&p`sfEVLhm8`RYXu?McO$Z9>a1HZaU>>CkOq^X47kc-D7bm?bw zpwGcp+_{9=z!VkGTpa<%h&W(pq{jP3{H`K8N*W$GL+li@KE%4#J;9SFRkjnVd=Gp^ zjyF^ngoOy(WjF{jrNaX(0ke)DVS94a?}olr3bqRqtk99q(u{$0<>k4Cv^s17>hR}4&bExmAC&>fbo88?jj^|wvX|Mvg4%%SUfck;FtyG zARX>(gV=nL8n6;|6-nuQTYNQEq@rG54txzq=8`-Y-!OG+P?@4k zqibkLks12Ym0&EyJKq4H#5;0g0FBjZJuX3je+OMI{GZ(onuu%GHtlUg{JM=5zG@-d z->KzT_u((N;%aakWtuQ&_xhW7R1UscAalCS`^=h;Lg5aP z3y=NjGL_q$P#>kj^Vjbs!Y*8#c650txf7i|xW|-4Ac=;pjPLn8yU3~7qcqphM;=+7 zVe`<4Qqb$BwPoT#1s=Y_#U7;cAILyXn((5tBVG#3Aci5Fq>UxHV=1idn%|((x9Q&{ zu0=7Rkq!|NfP@&k_ClD{WORFwJf(QY?_R+<1|qo+Vy2Ioo@I=CIJ|o?lR|+d33smP z1`f2vU&CtX{1ny=WT5;E0k%JznmD7I7$|oPaou1ls=`SM&)CA$&d?vFYK##+q<|Pc!UR7`%U5+$X~eWl{Ex) z)SuK27cOpK3XXI0%+y*;a3vt$3P&-&Z6ff!%o)@Ox7vOaC%+US@wxJ3@qcf2yAbQ` z<=KQ}MZgV9bGZ(@kJ{{cA?x>17oW5gcQ&B7Y2wW|K=6YERS`cecTQ~E!pcFLQM%0- z*8Y?2H)T#RUh+YOm8M(E=v~v0QnRfw*w;FPS}hY7p~|+xUDbgcw|@^hUd9}coJMtu zPxJe9i3c8(Z-a!M{Q(B|5VToTV)2$8yO!d)JZ*OPl6ceApOG_hA(hBPQ&wB$P&?P` zbI?t72Y+C%is%7?5F|sdtC+qk0qd&+Q^xq-C|gbrj_+$}hO_IuZtgK((|A{b6nC(2 zt#mPC8s&dG6+D3%D_pnwg;QLY#G(rc7@0rxHFg1@O=tI0*M}K74@KkWO6AmcWf9l8 z8t=w%fjK#b{bi({}j)1dLsI z8J&`tb9UYq?0W@cWGBf*Pl$JE$4IKW5f4F{r&7CR6$J|j_I^+bA#4I9L_aWH z^@{luUG7NYRYVEU`wtP=`vL*i9dFv@ImV!b^Yk=Z2yG0Luo}D#CAl_jg2}QqyL(?r zOOv0%uinwE1PPL}T$xEbzOeJB_|n?eT@~R-HwxFP*giBM6IkX$4^2zff9&z;{~W&Z0^&izR8WST;!pv(DOoDT% z&IJD@qlLUNg!_T@@384Zatdef8OZGNW}76#t;gw53cnU#UG4Mh+ue{R>&n6nB-~{X z(0%>sl6lN)BQ@{Wg<1`-JUP81fcv!jolOD?!GxD?<>vqlkyjo|Ad?@SX|<`!;atSI zyX8QEIB=GV2ebR=vkIP5Ygf(+O>(F_^M(kSqTs!sT~IstFo7`CKVM_p3W2SB1$XJE zK2TT0HPdDDBu@g%haaDR+ma#K-_nO0f1FT#)2;8NGzl^sEz<*eE{2rJj|Z2nrN9;#3aNgrTl2xxLMu-)N zc$}C-tq;z)gip5FFiUP^{irNs#cY5KptPu&3jGi+O@hjc$hkI5tl8|ftS3#d)!?^J zNLzD>I1DeeOle7B|4p;%(>7&w0+!IUxAw04@T=jVmNiqx7Eq5Fy?GK3hwQ~cl7FuB zIW`p28q9FlyCuQYDFxo7gQUKriP2$Vm~UAgx<$8eC^)lwsxABoU`84Yd&67h)5{!w z$42c<2rGyyIOc2^gIxK)&;OCosp9503EYNP%1kRMjRXXasg*CyQ3WrE2@}Bl~2nSt_+S|XxWVyKXUXhli+RPJzApNK8B}S}JNUkle z^ze^wbe`>(w6cTFkLB2We44YGb7iLGO&+$=8804hzE<_d@5gXJdqPYs)hG`DcNGps z!GHp#oK1xx@unQM8r@P-k?t-IAp7>VlL3t(f$yCf7r>cRJ)f6lEQ=}8Q*2$GR?|f! zG;m&V$u10;W^P`K$F~M>t9bnyQZRWtm0K8VUWB$b2|EgxZG{jbctGIZTO3?x<$t8X zD%{C82<~cSso=Ejd>I{OM{|voo{X~(*TXK&t5&mSQkkj4!|bPLAd4Dzs*`t=*c4}v z*~GoWz(D@t#{zUaYm{Iz*j*)w&NAsT-^3}vfa%BdvFbI8^OiMe#aGxsW(paH0?aw& z4lnN_qowozH`H#Em5~F?F(6|&9M78llCgf^A2-xo@uelZg{xya{>NpVWwV!FEc5d| zd5XZaHgoa0CGDb%GxRs0=06NEeS?0VR{}LC(C%`52U5TaEHHcb$Pc){b8@szV2i16Z~6aUlWt5-K_Aw=+Abll4mYHz{tr!PCF!># z9wssHPxM7$bshN-KpzSi+a_T9v#N*h58ub#WQ_wLu6@#B8Ar5CJ;Gk9O@H_o1OV@H z#|*Ul8?cRS^VA!j?%-1S3imFTWR8N~H9gCx zD(~(i{#oT(xhjhhasq}%AyWWLn6b%dvRKx(sN;`QZ2Yil=4re`^f9%De!Q6q)b#1l za6d3$&wB~LJv5YzZID+k9e~G9=ZK0T)b8&bldvQya>JfD)WL{e-X5!8>~}9#0!C&; zqBURd3I|H&ZI;ZU=}LQ7Xtbw~Ct^Q#Ap=GDdf7VaP6B+nqmrU}jH+I*OWh%1E}Kx2 zn%yflm*chvF`6h~$7_}^Nj5>M_QbPS(oS zBYgl$>46q8U02Ed<_M{?Ospy%fM@;<%cI9RV7>WFFDHT+&%NCT7i}^K$hBnUy~`BV zNf1uFOfFH|5p?0JH>wDj2?A-;ZCNieK;A{`3gRC0i|xa^M;nPryrU@#_21%RXb?_t zA`=}qU%-OFCL}4$bjA~9I?0vOQZOZ?bfLw{jX(sfht>Rz)jU0$Fgu;Ns}9QXOAU)a zVa-K()Kjgk<%nQaxgEe#pfX32yR=ep`2c8P1hJ|{v6 z>DHifLws6aMLN>X0*ZX%W@;)i%dw)Goq8^}R%-Vb_?^-kc-V0K#Z>X2hEPc3nh$lw7cH&@RTCuqV*a~F8=sZ;Zk8fFf*9tN7W^Z2W**m|}S6*bx%&TFzhNlcD2M=B+0`$*A1TJ>dcsDs+V4Nc&z z94*|XA+vJX_#)m?X*!qt4tlr}v`(%tqFyWSa|A0X&69&)nOf#zEwPyH;>7#^97E}O zeMABN>HzcRF(K&B7ob(tj_Hv}F1yp~v)hy;eg*wR_@K|I%BmSg870*TB|4J@EuT?4 zIKaAb7?`f`Rv1ZaZ0Kn926D^#h77)< z5E76vO9zZ9Vr$>qWcq*9{&m{vq!e%LZ^*8ejpR3iys@Mnfy@x_$6(HLH_Ok8H8?-=LM`fpm^^|Lg7CJ;EgcEDn=pNz=&Lk^(`^%}G1NXGo)?i1kE+@> zeTXcZRAuYUQ|9-$TfoSJCoyN%sNlPdBb|vWVN{FZ34$x6Z{pIJ7KV#6VRD;ytjddy z%C%F*AnJ_|#+|=qqUY||exh@zM0Mv(T_N0DYbNfsj+T|U$lPy=93?Tbf17CCpY8~8 zd+p4Ztu_?7oq;;PcJLtVnS1cd6jLrz5ti`A)!I zJ5P!~1^l0wuN}p#F`k^Z-NB{%r&O92jD& zY`sSR1)~`5HYn?+v#4L^c-5B&yz~nfy+}3KRkj~aMy6II zr#KnxF6gJ?59wqe0)3P8UbPP(SQSkDgapbJw}%8Uf#f zNWO5x$)?r{&F+$yHv)s|(^gg_R_HdiCb=3oOpd1YBP##A-uW`!(HMKtoB~#XW=w}q z3)x^HSB6jeuoUP{%@_HtZw@2nyebr$jS4OvLlm^|SmU@Ca;hccY*6hFCq8cmO5v}K ziw%s+14r|op9YZx)OJUT5b6xGBW!K)j;2pFCjerD$r`cF&Vn2|JoOjJl;mR_RP)!N zo|wNUkW&JSx3xrXHm4C8;jqG&}b9!6bR_`UgX62 zi3FxY1`$*gYr*)1t=Pi_V%;@sb0uT95ZX){cwb0wDBC`>@Bix8dtWW#NvO}mTmGXB z&`f`P)p-SV_>n%%w>;B@A?&QLGb-@B860(nQyChULt8G5ViYHS&3^|+r@6r!9l6GM z`5YUos8PmFq%dZjU|hrcmyWX;a6hoX_7^TRntX4(yrpqeGfZw;z03&(XqV%>8mM3i zZrOcduu~ua>x_lzV?;382Ohpjz-p1(;H&;A8oq4_hL|Yq?VELKZBsWj&OC%o2k>ui ztxm|GftJm#5p0HfUa~o{S?8vYv(>z1UPjE0W-PUA*)5bc#VF+n9`s>DVdHYlaaUQ3 z*J#tf-_S15K?Ca|NTalC2W@gP`y*Z4>T5#LT?J;F{L9ki%6Y>b8Z&q*JM&{ND+oN{ zm{6MN+TYqDk48PA#gCXZrY1g8phT@sLr3d2|2EcJb!#hm2IfZq`24nW%X!p`p|C5> z>NW!+^m=$`J-r+IH*!s|x+7$4IbR_bld>4W~eF6c_ zv1KN2x4Vfk(K%{qYr&*hcJxp01}cp78aIC{X_Ag#UsZ*>b11FuQeQ~x3G7?XEqO*V zEQ=C`{Q%D>j0_<3Vd}$`>3Zfu)QW1#dB_OlDIK$*@6G=C-wphMfUijrb9Q+}hs1qe zFKf>sv}iXDu8o0?!%VEfFNeKhBN{7L(jMhnW#b~tE+2&qoSq6pPA#7klpZj}hhI&+v|j9_Q6hK0)z8vXtS3?@Ud3=WwN5xsX2vPTatX>jf0FV&iJw zH=~_O`~1LoazKeCpD+_y`Y=Nc0-g;EjrsIVcRVLjkq(PZbjd zT@?N3tjZBl5x8Rdfo&K}+d=hy!S9#iLq=l9tJGX|Ezv!Yw~|L31?C;`X|KQcotmm} zN(Mqxg#`O8LO8+$CUkgnF;&y;+XPBjA%d}i@NyXXy1&OU0oIp5N|#wDUA63QKKu@k zR9U8}Y^bYwXgJsR1?|mYC>Y_ZI_V%Mp=-EX}(9(5x$0swaQuZ9{wOd!_E~f*$YN0tfy(-BnBj)d(2wm=0r=n$J6kV|iN=UW5a zT^InW9A3;yW4nViJkJm5emS``crZ!rMpZaQmqMitYdwlEdS^Ra&z2){Ib&t$GRAgQ z>dVNz%(tygj=EoBKhm}A74Q&|R~%;^B^ogs(i9N_#t3MOdNLpT2cdb;T2P3E5aL3a zaYwPufhx;;Ed^WA#~e>+{cT{Ap!cnu@|`?iZu++q`O)LR7cUJvFjwEpAtrvZM3b!E_nh|L?!Mo)2zj29)ckY@dI{345~doA0m42PWxBS z+VTa*Sj>K_sq1gopKzO8zP4p$r~N4AF7VANGURzFnc}9Y{M?nx;q~JiFtFNB{=D}! zfg(G3hD{eBO{rJIg+m4ess3xY=tCFeg{{8b~4#zDl_d&Dj$Gg;|Ygfoynnx^dz6=+GO*Ij053Dqc4D&$ooN`=5hZ zD65J`(P2xOBiih_kUhWF>CnXQLJ;ZTpwWo+Ojb5^X(-Wzfy@Tpfu62g*zv+_OaTs{ z)l+Q)sy|o&)ZMg)*}x>|vVrH(mx-Vx8uDcj#b#{gFH`Vudorl7U^AEJRc52@F+F{BjGm`P7;88-t_cfEah!-<+i+lnc1D!rttR9%ylc!^5`Euo zDKXYy@B)7eaGB>e)hi_)k%oK@SCCMtYqy~H|MoHYqpqsP5l;lZmZ+4^jb)p#$hxe8 zK_wotttzb}rT;pXh7c!*I|Fg7%E}FcP-W>NpK-36f#v&bgjTHjDAmeC3FN4AJ_2hg zb*ZnIq|L!b=`9kKRj=qGp5z=2HUN6=J4UB#4W7?K9rC&TwWg!cCA9iOvs>&21~cQW zuG8$7JzqPCsidU=SCI09u$%g#9cNZ}&2tnof=)_(2nQv;w5YkdgfbL?q5wj-usPM{ zyVv~C>B$cPRhx39q_cKry62Jad=4t^?JSP{>yHU-1_1NOz+8?mdU~xlF}f`1QPhI| z&mzHW71$Y-yfJ03t4R~hAT>Eu7(?4H9P6tsaOR>|XD9%Qi+ruZ7-~G|=lXDbF+M77 z%Dp861?^(72GQv((Fl9HTIzg6b?@1#V5-?%foQ4}_t1btTAQGHJylvLmsTDN$_m^& zA5rzRO`1)amP75v#%H~E_v4t7SE>J^5yad%@YYjS=6cXh@xFq#Q1`kk9-*XK)`da! zg{?AUAJ+5eBQ0Q#m;;h5JG%!m%oQk^R<4vDh~`ilE_&F3HUAUoQ8#o6=J*SsbTziC zzN}19cMlnL=3|fWg_<<)jl{RQpqg3TNO}Dx3z>yRYXM430DKL3Sli z?{^OfCnF=Hg)H(D*W5|;&o&K5#N21Cl+}XC z_jRruvKQ^{E79|G^fci5#NGABYnAc zl4@iKb|kyS-VT?dgvry#C7y$KR{m+Jzp3K0=;-yDXGw4&AqQu4u%Ht!*4jEN17xqPfQ{+KK2k%{=k z^pt&2%174g5>!y=IjfmnYgEZpRG&lcTullOg}*wRHqTj5U42%`IU(KotBr!W&3SSr z)aAfM1tjdTQv+il76T?l`cD7y5(0g}v5;8x$CAsU67s0qqSPJ34^7ge!v%8dZa7yX z4;ci{?qYD>fZ%2T65#e|A^a&;7qIXeiFi%>cU>+HMV-}yb_7ipT;)Y$s>j5Mjqt3+ z7FPWH%b}r9dDIZZJyHqZ9#Luy#R~FO{$qqwbohec|9a=M;Md4HoKB*H6M;F7EUt zO82^DKPdA>TwS7ijKpP?zN#t(hD^YBdvvA2qlD5W641`Z6 zug#NcLC7f(r_&2R)9ojrhafAQB5!ei+=fTuqni%J-0(#rgA-zGtWWcJ9V!I#wtiV% z3fFaf{@(o=XiYH^GSI_2|6_i@Y!AauCvT5R!md?$tSU|czQP;;ANP+8n2!V(7LjgM zc#cA@*OR5hX4!KO)Xsjg#W2^WZXJDU*JnG_16)TC{)MVB}t9jih4ZerrajDu5Jp2=}okeprwRYtcQ#oVe%fS=1 z6{WGf;N?UuCqTzs9bx=H*D#6AmtCUeg$UBJBlp@nrb8BYsl+zPf1pN$4*rZrWLjM2 z!Kdo=%{w*Uv z{nv&lc_xPs8zGwaNR%@ba#W1BAU6G|1e;W&ncy6QHgtEv>s$+Z9=x>exbg3pVM1-i zW-|;!W$70i)AS^^HvW|8uIc29j$=~-ul*3u$xlbMHQ3(P)gtnTnLo66$m8PpeCFweEC(a)nA<5FTb z0pAy+dFk8`CMpgu4LX1pOWQZW5iXKtb#B|-35i2vG_z48*I^6MiNg0a?qb&R;_fFB zNXI5nw1L43P8^3)2eTvoYgmdIbu{pP{5hkD#?RO=Luhmga^3ZFJXSXmzUWz>V9a~W z2sHviE4YNjlnwn~HopLhYWXM%RDxpQZ>4?$JLIS=i;GUzjicB3&&S|v@DiXAwzw!w zRkmRU$an)u_$YJgmPwxsWFhSLsf#Y2Gj*$4>Scv3WG~@oWt4QWJc^mM* zJbxYJG1?Cb1}}53klG(Lu*hBv6(vg0NzYC7m@vZm#H@oW`=hjINjk8`Agj)3FB${h zS*w+nSI>x@vY1leIS6P>-N@+jF3Z(XHdUyhQ+)yS2;fiBaGR{1#vhz#K(aX6^gZPY z;;Y^xV*eEsDQVHE6OM}<2A_RQnu{Z-6g{gj>axiKR*Sv%o8MVMwsHOinOlpmufW-u zlX6HtvaqQT+sTd@Bmt2NB}XW)iAJ^y-<(yVjLY@w8bHawKCo-60SyV6@XrZCI$>}G z!|SJ8bBEhbZLA#h7!+IUK9_WwswCuFz%20j1RF5`fxkk@^O#NxN{#0n?YzhOUh|F33ODl#pFV3!2=JxBU#&BBK%7Cg>rck5x{u^bm4KR$O zhMJ>PUMngP--6(Plck-&N#E%3DrJn#IJE(-X)VfND4IR06>$Bbm1E_cw+f$kktoIi zQK@Ze)z%UR0&VD-o<9| zU7j}xi_T$cF!cL>V@5Uzt*D^n2HH9VWv4uoT58@*?Z14=i*4xuf*9(6uio=i^6d@( zQGBistb4xlE*V4Q!Uw60jM54Y#0LTcI#$x;!OtzQevR|v-niq#vU@(vA5#Ut+C#~0 zD77{h<3i)>_I@K%1~0RfTmT@kJk;h6KqO|ua!XyZ)4#oEFa9|zv4or+Q!T-*Ag;%~ z2DhAyvJs^9;j-k+Hvp?1h($RBH4vxw3waW@`1fu(dk&yAwSHR#fhXWg2yEgA;s3-| zC596q(SM_GKE9>l8R{*6)?EUEf2O$tA#jG15WKz4ydjt-sodg)3dONdrMdcGhQnyY z+W7BSuDSMxyo|#8uZ^sca(Ms--%K>>!p->0LS{sq|GCB~{-+9#ic3*4$;pe{5RH|> z`R7G^yaH^h%?t`Gq(67n!<#M);s(^?VIi>W?)?!VoUWWAviE9GR9L6t6RDk_474FF zhP>i~E!fn)3&r~=4mxZ$k7C)rE)k*oZrGiJ$Pg=F+=<#c%>8><0ll;jMIrO@XQ~=y z9p@B4lhgR|3vkEr#`08*z>Q4SSko{p!?(X#hykD<%er)ug4}8o8@Z7_!xU53Z&PxS zaZ+pQJiE-i88AW;s2*}sa>txwr#4c&H--DKCwoO~ZE1T{`&d^hr>Lo}fi@cHFubHE zclxqJ8zMWm6u~Kv#Hk+yJrKBu2`w4MN2vjpP9{H^yGZer3OhMvZSyPct?%>@kTup3t9H;mLgh1|%Q1weanWzQ<>7XK=Q&ytegIGg8^e1xH zVe7L-fkH2ZUtGRAg{Zhxtk$W=Q0kMdOGzwk91_~5dTK9^Ae_u>DPXCWl-*wsJUT7#e|5qigODyv!jH@R@n zb0jGE{Qt2#bN@?6E?KY^WRjAf86Nfe$7Tdm6ydKRsQWhoJI?%tYNy9}h*jmW6)-jI zR@pm$6ju7)0ZtqltKW2wWqi(&xbvuh5di)Y6a()`rD7YOloB z!?a5Fb=afZr3yaGDI~uKPhDpxH3R(Tg`4Tp$4R}OVy%(4Y*}ybmL{fk>D5Es?EBZX z7Y<=8-3hW;(P0zbX0kduwYU>>5dD)9>P{~mON=a)0!6y;M^JtyTkI9iF4|!o9oD&L zRQUtS)&bf$+8_Be;8#vX%=78)5lG3z89MA6J6tM#NGR>eZiT`bfoRf)-Id;>d`$TE zC;Zt;mS$E}?37j%^sxU`2^e|q@2zIyC%Sn3k{PuOTJ@A_*flAl|71+SL+p>;u?`IE zc$YmC6_O*xnC33q!AbDkKWy-dQ6v!-gJ60UCwRP`>0Nkj`G4r!Q5;M6ej)g7$9`0uNE3c9p)miGiMM1jpgF-LF(3FgkxDn)=q zaC@KLd0SfsO#F!6Zmyj%>1KRBlwT341NpDY_Y8XZ`0$tt5$ZDlvKoaP@n_1Jae=1< zDZgPSv|IuAISZ{1$tVomc5~*rcHh5Dvfr$c2fQDpL zeT;r#Lv_X1y0~B=iV9W@#MV2iEL`D6T~gieIyx{E^4onyv{{Q>UB_R3Ru;%en&i%fD~)|HgQ$?B`U ze=CUX{il+Bj)zk`6M-nUI147U>w{>a zAjt7v4QLaks2b<}pEG%$$A~3jst2t0mm+;4!6+Hx%JLch|(pnAl8brD5e3 z54=s(I2Kbvuud@zpx1#s5?<%! zTZ$PSo(lMT-&(qG!70Ud5OILOl$^1{l`)7#&PJS4K)Y9dygdq8&}tV!eXJ}W zmflwJ+ZTu|{>+yj&R`rte5^(ucPF=q2&WMOxnpZ`HyAnG#W$C1;i?K9&~{NY|P?5sKk`WS**|Guk9)>AQ6B70wAQKIVP?IGzWAW`pDaw9Y8W_ zIguy535or>ep7L5cUWbPmt^w$0ztaDmSG2s{v&nmg>KzK_N?b6)|w z$#A#$OW|ycWY!FqwAh#w$4IN-&+Wr(#}`47!1*Jm=;WNe@Jb6SPfvW7?+m7d7*P zGk|wgis*R37R9Tqr1#O~#c>9D25zwacTp^!TP) zJAZK!e7}1ou6;^t)oZ5GJwmy-J+W1TKzV#|#S?L}Zt;Sgt~JO%Oa_%4hL);9Jn^UP ziV?X_JxTso%>$vFw!A_HekyjIqfG{s9lt(~=D|z^@DdU6;J%T~aU9``^m%F>%FAqT z?7C>FPw?X{Q%C&i62(m4Guoan&D-g)-k+6A6jHl z!a*+gp*nP(=-{x=%T9&rUVYtCSP+fJAPYq@m10#_34{FsxV2Qi%M~e!?8E95(MG8L z&eUJQtaNpo+jjDgBN8GAJNq<{g?tSh>@P^y5PUprHAHk^RFx%O2egZHJi-v^yQmX_ zT37*G?8f;Egd!zK<`({f>bR4$aK+XaHwRJ6uU_`@r*UJ#Q$ZUr$LUvag?RmbELJ~J zt*iiN%%JG+<)0@0- z;^2YLeVFs$(aDEnL9KT{?Nt@9joKJ;n-wo;WM%;ARrV}DhEbCcCMs0MtO8H9%i|30 zC0uH!}076Rr|JpUQ=NwdC_3+7#`f7<@u1mR(p13j? zc6~`3n^Xo`zWkJ6eoZ?!#U_NM_6!o)$ej!s0bF?G%5Q+ zq038r&q%or)nkT4^g7?>uYIE-A)xUmhN1Ol3)&*#+8!|=m#qBfzRGkcI?GJgX2$RS zX3Wsz7Asj|ac?!2X{Sju32)k}#ZadjMdKfR@Vea44#gw1YBa@lWwF879*{Q0)Gv75 z@F-oUK)%*XvdFQW4o)1HqQEds8wvr_m?R+?*ke(vw*h;s6A7V+xq+z3APyVix}R1w zeNLLAXK$d3{SSf*%tvnIsA3cG9yz@96lJbJ==g=kzTL!-6C>ILkYCI@IPQJcpO~9s zThPPVn-#6!?#k4@4FRDlQbmaBcF#m@tHc0h51mqe?5LjXId9OG{bF8QzTvxpZaq)W z=l^r)#R`xME9uA=zL7M({&6irvt5`@@3V-h(P6__Kr`T)8dr~5LKcT5B-B*yp>}ktk$N3hv4#CsvQD8q~gSPnj#os=Gpr32f3)Xu~YYyk%)) zxTHapY~imIw>O^P%)qY)pC{3{101qe3Iafd+asOQ8&d$&Sok4=RPB&ELpc|AFVzQE zeCYG7Y>VlAh>Hbwq|d;nodW!3-8mJArL_^R0-XN+N5-NcBv5m7N$pcY@msd74u7Gi zy5S%S4U&LCkEH-f^5aRQ`_HYtWP8i<>4+suv1#0xj#`jkl-}bP_LLszO=Rb38@J91 z*g6>m$V3So)Jl^iWT9*+VYD0BlM7E6g*LG-=JU{2=5u`-$1QU>A%K8@jE6^Mf(aFF zp{qz5>`Ds1PW?CdVrObs!u3X{ipDD+T0zn(ahZY zbr$C7ipxS|;HJZ8@8B^C@RcUOKXdCOa|+9N0e^I=u;{q|fQurO&x6v~lOu z9qZVv=i*>ibE3em+0G-R!^J1<`fwkX^O-w~B)3c(G&Uaw7uDo@g@$|1EXl#e`CK$w zUgW+yL(kv$@$R8soIuhnWct7mpO*FBx;c149OB0o-%r; zq2)n8pAC|Iv}zivh$-H# z%ziK>uY(lAn?uhkhM5S0%SY?g6xqJwE~|e72>Y#J01Bx%sxO3RTP_JV39oqvCqtD!ZPfbk zY{Q0vS%E`yq=v#ByiDs4f`+(dS0~)!R>dX2^q-Lce*5vZ*sm|OlU+S>6CRP`6PSV{p{a+9qQ%i z*-L5gWjX2M03iHeO}X4$J)U+jD0vo(yBvju>xR1jbH8;AvBZPQyUYjsOWwx(s$Zf6^IhU#zi!AQ8X}M+>AA4Q`-o4=RMXF^Njd^2FUb%7Xy*I# zz_C6+_Gqohn!8BckVKzl)@_V$5n<-P4afB|*~3`g(y#gbjc>8Vqj;LV%SiW$`RUd% zJ=Dt^*PdlAdt#G8Lu24asavO02B}%cL8~M#EUi}c)encM;`7sdTlQzovSIWDD`!!s zHPv$O&n|bkcncCypvJ+P_}=S8i+|`P`D^*o4~%H`X0tEz=>Tawfi+V10R8YQa7lb#C{J^nRRM68X|jq06A7I`c$E;0O2~9 zk}T>%NK`Vs0OkzG8=1sjr5LG=>d}wRI1&#TV!)KAdgbJ4JT!#zo^rlFwGPM*ABDd& z#>8TqO;|lfmT|jrD=N$!&x#F&6r)NpK?B;$ydcSyn$MwLLcGsiqeV&m&Xnx>>p!~{ zH8%mSd+ltKF^1kH-BLnc(B^jF_igqpSyEC5*5xkvc9$fIBpN2vCyAa+Zk@t5Et|Ax z9-=ho21CAffpia#2k~X0S8>>Aa*60$*+~en`=91*CEFU$FRC;Fdy&w{qDfg2Erv#4 zAa!YJq^_8((DI(#W2c(YNddFAu^XIt1WSktVz1rq3V{EoT&MJmg5M?m;A2DE)$;o3 zj~g>E%|0YvE9Nkl#wTImgV&+#b)JReh)y`E6)sWU9(Hg(){y|UsR+Z ze_>|>jjd{Tb?6+Fy^o!08kLE4 z&=9?)luaF(YB4dg`!Bl&fA@WEQFQj_`Dt3}aRK26IW#HG>acoL%tv>X3}jlsud(?j zOk{|iaa{UoY%77tl(IY6pGvFx5;HIyWIFc>f&^cpaY>pOlbE;c7Mzj(Cs*tU?XadC zc(2Wxcl&f53cB*1d7g!$3$ToFrLFpL8fJv)BOFPLh{`^l&S%C=XHC@Xp|r}yjmlj= z3cw;;Mo6UNUHW&Y?IeSdv#s zrzL1aa`GJ8^XdCnW)p1E6|&5C82JH18TW)uJO~E}kL02ui2EPx4LsjEPbcbC@A2N! zkRc%$%Y`e7Nm^jJT-_}HdVk1oV3}kh-14icd))wYyB?yd2G9zWTLmNh|C3w|32jmw z6e6oWvijm@!mRG=-_{6RAu3sY8gZQI)svB0oq}->1tH!{u!^0Rr+x4hVE~YNQ8$^` z(>U0sUE#0LXRk5x;DJkXgMLNh zRSA7OY=*M5!qOM%W@5nrS-OXaVO)3$Ndi(qM(!i|(N~nsS*C9h=^a4uai$P=;dJ^P zAl@lqMr%RQ!hXoxVHayOm}k`ciV zTk31fn7p?Urm#iZMREmH?E*Yw*aMz!6&&*#-(L+ATj0aTUa#BZ-%MO=qh~uemnrY? zPhNWZ*7SYb-=VS#;(s)Yw1QLg#DwpJ(n#P8r-5?`{%B4{x=UG_8ysFbBq+=+%$_1~ z9_4n*wi@*sH)c#)>p1zHUD+nyxJshgtIXx+s2TYO=R@)vazFf3RbF2e*3i?2v-!B$ z^I1^3V~Vo#o#vXixu5Ov0_dX&4E$C;E=q9|BleKz4BNX!z^+;5!`*<2M-#I_N$>?y zn#3bqE@>qX#~oS3Eq4rk4C6^uPesYxoH5M5C@^0Hc;5d3RIV|D?K?}@_{*D)a zMBeW^mqZ9!9}g=!JqiP7&tFCuIKmL~#$de~)NjMvLSt+n_!d412X^Mc4^Fx=ZQsr1 z^EO)ZOpk=`$C47wdvF=9=3w?>Z`850O0nN#tx#pxQQ6V&oH!CO@){^7Ux!dJZ)z;X zVEs~|j_|mF18?saIZfLyhb|8`D2(jaZOT?c$@8<9B0_7Jk`!sgZ|P+Ds|bv1IG)rU zgR!3MCsF_dyj~49?4vValqn;H*;BB6Eilx7^PNjo>X-jk;^P)Os*j%Y^1b*9Z=<%M zV0%t=jCE69LtMYE~Uwb~h11)v5JzO9dG>i)6~FcvyCWMM*6^WW=k z_MY~c)aZ?4Hg+pZ08ZnG*H*f*Z`tmA^NTz?lJ9rl?*Pjk83Z>7L~-DTatFuif~8$l zv($f@#4NQ7khTHIcBwiDh`O4!vT@}{A1?kB(KsXfi>cTj@gCuVPkky)P2YlP*E^aR zr`OJ;4sYcAs$I>EHv2rZ@<&Dx5XchMGMRG;HJ8viNLwSRI3qAyL5qJT?p~O!4-X<$ zxI!v&O_Qb|5~xwYx$E58ul=rFI&6=OD1YUg+OY#Ft`Uxe(qH$ot_n<3iEux6#cQw! z-hb_{AXI`c1vAs$r<*xU#RpwX0OJ8+Z>rgbaeH6YbssM$cgBcwj#qQdX3-_c;*BJs z#@+N;<*`fxg+jX89V%6QRx0mGNJ2J+ahqE>U?dQ1TTxo6-0~`~zPn5en>{GWi7*+} z1k-L;G5~K+6uW*Z=ppJYyCczxanIoDJ*DV?-iNBcHTWc;DUAMbUe|0Hi@6Q-5Xa0)zio+n>_Zf8W%jiykQWdA7+wrD@XPG-G<$ zUt-u^`~)gXNG(bI8NLo51rk1tD(8LDR5%YeM{_PT|3pOdVp!dB?aBI<*M&R^=<^Rr^ zN|@ZO37&*rO32%({9x?~sY8p*qqYjP?)L7LlhPda>H zdCP}}DYO4lN>~ad$85+#^6+@PO!Vmu(e8m{ElLWN(1P+v&(^Umm5e6fmuU6afOt<- zTOcGDT(tLX)fw)@6HQsxAu2DrufditD3x`T(WQYeLQ>O^FjgGk< zB#}6C3xqEblPL#3d#O-Wru~vusp>JSf()63?GfSTXmJ$XAkkaM>1-DXhI<*17|@Sr zB5S#IwReW*Q+2?E$8IUSfj>2rRx&xJ2=&mY(hHMrw_cI2b>C77!k;AZ zjsR4q&$6%0WH2#tF=m|^^yGo+@iv_V#f{{!{fqw86c!7{s4zu?@H9%YyyjAM5hUxO zu`*uiFfx9jSD4$^VZ?eZR$KSO1u3QQ((({RC&A)Lx<|BYS?m9fke{e)EnFO%R!ED(7{$ zU?TDx0~RcjT{=DRAH9pAF<10r&`LM{}}fn{i7(Su-xRf4J4teNCf5M%;19feawlTk0L$jl}ASx>zn zJb*nc^R(>Sn6A*{3*15*TasJ`rSVo7!o>N1<5^;#o>rVvW_RxBYn~Lwf`xj;M^zbZ zS6OQWfaMUBaTy1i%xFU+zx|{85z&9BVf-jthLwp?q?kW|?T2_Zv zR1|)g0u5rN+%^-gys}Xu@HWZN|#KOk74bBu{GgJXuI{_t$a=p^JjQa(?<*y-f-{m_Y z9EO=4FLZ^S6qW%_oIDx~GzVeL6t^s1I1&Zj5$Nf#4R2FwdLqBR%5vKcwqfNSbLKp{#ftyQkx}5I!q4Q8oF`AVd&7v zkh8$Lew0I=@AIufLBwaBm(k%PSqJQs(jCX*lpU1Nz@73esA=&K?BC-cMZ|sAi-o?v zxVKT<5=(pmIy4Ch6Q_gxDPV0@*?H2Unio_=jqtt~uJj_Kg0Tz*K~6ZpA9S9?;&1~a z`QP$qoFjWbA$u&oq_wThL_fOu?|uRq0ft#>!|z$$SI2Yif@f9Z1=@g1uhA=N1ABVXgimTx~(cJI$cgk6;T| z$u~S$Qg1x@qW~MF{x2_q=jQk6a}LTP|a~! z8yGjbax)B*iH(OeUeN)cS3=C@^eNQPz zjXFrhPT!raC}TrR%P<>hn4xC(W4ke^%uUc{mSDNHWOToy(M-HS(23KgPq;0H#cdIP z|5?cdt9Vn|DhXIV&XE&ThAm{O1C?>}QmC;9A2}OZ5!4xOPP)iiSjXQ~JIzEx82nV` ztn!2wPS^Q!gOG(J7|0cNtADaUB{AMsn zT8^zWT9lfPC_4`!XWMdTb`BTEN#opkQoE~DZ zo9BWDFUq;!s=8fdRWQ|~+VR8q(g>Nw>XfqB$G`BZAeXi&)6=zuA;x-03i$Ub8y^Dw zGr1MAq@Kv$qJd+nY=nTR>PI@3C21Ln`BTtrSSXObIrO>@$gnqJaHEIqo^z-&`p8-A z3`3zFlpt|}-G$6Tvt1Qv)Pg-sxB7gwscOK8K6^RJBvEt5Tdc*X&03sGqWyb!H0f?F zJPA)>GBYr&2xHNVYKG5fC+F7hc)``!Ab2x|9kcyIP8^_^vaTi}8e8YG;#sOuZ{X~= z)v2Doe<~kHpYxP&hcNa*rUQlWZ1e+1{o9xGABS4sfYdnnoHZg1R@e?(9R&zZH9mL0 zR;m0196wG&HH&}^K}VLduPsLk5hBHBrm`TH2L8^io_!IC@^BN`lsRlBilZjsHUHo- zkT2?AioX8t+h8x@7Sm*eudZHrf+H?l%3?fX$o$t9w7_1$B#~H*#|N!6U~zjFePN_r zvpY3d-W~Qo=w^Y1_Cw5p-=vMI(@#zBKp^*(Mme~@Ce2$K4lp`R{T}v9_DAU(oXbf^ z)j5JLv~;o_V_j{w4nT&>;kxlr(FN)?OZ)P1Av#jnKe*tk;iJJ>bl8)D)$&(k(sU*5 zsLw1E7D!J@R_GoZ5xFf$9QYPgaU)hV%q{f(04~>`dy?vG zS8R=2u1!g{F@Cs2F3-GHrRO&K{BIVFPLa5JU&A-B{{Akgojno*m8ObmXm6@WAkmOU zg!YF}%nK-4m3XGdie0?u-h+YMaZu7#8j^6VsXHbYMPBos4}%-sz7i-I=y0MhT3%;- zg8SyFcv}1b0N7FSUwH)+E}VqD`1Rbe3hms{S*F`3j`q;2>{~su;!(Q>B)vdR_1_b2 zll#d>k~4#)oDkF|*H|x@N^_%@Fn4yilOaXAKm(QtY%OJ!oQMR{6SSBd%NJ%SjD*v8sJu)5 z7uqPDND?kcMOpbsjUb(m)nJcbL_uD*b!bB$4dHVKr*tkd~bH|fVUK@_Y2f2 zF)L2&?dV;_T2lQD*Kdp7a`}#5ac8yFN-*UQj<|^cZQsb&an)TT4^$WZv%h(M>16X7 z`D`pwtEabA`Wnw-rAJEgZUQb)v#G6sqFC=QJ@b1O3D34Xk0epGJtS7BEJH#3CLr{0 zAU0cjJdBHBXSHEc=D!KGcio3)Zla^9Jzh-@K}vl$?C#43C?u!bE)!M+jta%KO}t)w z0w%lyw%})E4G4!TwA(IgzZ?LvMjiKFa(LBi-NWKj{x=%)yG>MZ6EoTw?;F4h%i*_g zVZEb&ygHB_VCEfUQI<@#I9jpg1UeZc{de{A1@8B&BFXZBC@%*Q!(e0amttsqw2#(5 z2q90q^xU9_YUkcwsw&qH>%GrqAYY39g#pf5u74@O;3j?c;Xd8-hsEE&1XFP}OvJB3cAB`yy(s;w{joJd>qy?+ zY689IhCRk4?}R<$H$f^@^k&JJ*N?os{&3MsZSpFNl!%~==t#U^Mgkk?6;2yM&v;wC zMSWw8Xu%TZ*tTukzGK_AZQHhO+qUhwW8;p^o%i-_wvtMHNmp0r{5a`!cU658hU> z%~4;hB28UtJQ^eQSOJx{5gk4#*oJZ95Bz$eg=)HAlv>1pXsglyHIz-@L?G54e zW|;o61RfbPmqU*&@a6zHjm3s!@}N{~3d;T!?VQHg-7~`YytR; z=aND_dXHfl_{a(sNt|Az;{bRyT?256YYYX~ZD<39IFW+?EL1PS9CXCv4RvoO`+!a# zz%9<7+b0kb@E&%YTuK00h}c3$9OhM?Zk-|z{ep)lQnr+&W&^;|n5fD*JfiOhUb;CATG&TPnK zWi;}7G+>HSG!SACyrsM7uDs3$P8ZS~Bfwr7qnD7v<4&Z0Jm<&V|AQ_Il6zu7211Nh ztB&ixMo|-xpDP)q3z9}qbVosI;Q_s_QoYYbo|$gdV>FeQ)>82abvQ*5{x&o6KOIv2 zvpqt*z39y_c|}%la_)YEv2suMrW1>CvLQ#Q8jMrC^*C&g z$W1;>!bFP;$el)uF*dYjN{U!3GO3!e3fG>KB!6xm&_Z3}FEgMmu@c4Oe$`%^WTO4s^P1o)%h&fA~$ zHJtvh=n8nhY5UW0WGq_|bucO!|ARrg-Zjj(HEFY0<%U3AO;<8eWYo?2Qx@VOS_&bN zZ4J(sDWsAd?DX@aTkuni5rs7y50u>Dn1>9H!gthQCNMeb<`jf}d79Z@YFgg9GgH^$ ztA;EMP$5c&R1O3Ex+8*7q3H%emP%UJ4g>(^85bt=Z3^tk>;u>bNyZ(Q)q|=FWQcWS z#p0zbetkh1YMs+#q2S&h;NAT$=_|IHPZpI|xy3-i0$7J>pYZdS)&xeWK&IN%yAKgR zWVfBUa3Wm(E3s%pVyK#{d`X#o@HNtGau zBEQd*C3kUK8$fh6oQ@t$1+l+V%Qs5&H360Lm)3lRRewZYhcy56N%No0x))zfk*@`- z&cP4H#Tq-Woi@F=iG=!omp{QxSE}4x4SBZ>JUY1Sx1J`B&4V$+x8f@JHLG6e-RP z-Wj)=rRb(R&_rqXhEL1WEXzDIDP5AAWpLCNYk1ecwZ6K72tZ-szMt%{k=Q8j+o$W) z#q{N9<7$de%FzjAEN%<+vqoQQdqrg*H2G7WxAW?9J>1n zWAabfk!%MzDA|E8<;62YL)M}*?A2zZd-hTHjBzm1hgdTWF#T_05d4%k7kZzqob zFuyTiGhwh_PR)}V^wgp6tjN>8=SO_$17!})^pcM7b3Std5b1gV3znwHcP<7rv;RhU z#yoo=bR)BZ8!C8NCpddDNMOK9?uc~Ld_cm21~AtY$}w(d&^@{mtvZAG;}76L8&G}A7GZ`IPkFP? z{U){u48H#6_l0IdKvU*$Wv6WLMwQFr}E3UOXO{mJp z%>2J5UeHVH2P3i|R8U>8Ze+4eHru0m=9elgg$*K5&Z@j!tdx}E0?gSFVWXrr*QTKd zt8!_xlH{I%#QH%b?SY)Z1BLOiF%#aM;w$CKldeIpN&iwQnrR$0ADv4beBl8mgheCWjK~V7{7aX-Q{^~;ird%5o+M! zTNyBZCdi#>*W+e4l|Glr6kWYNLI%j#wJh&kI+LIg^_PtE^h{TEbZM@EH@UJ_GM+K= z))a-^QoVH-UAon#vJP>5XE^B!vXszlh?=wV!O+aCq<~3N%Md^HZkDiJEBemn%Dih> zcAo+B4BIYP90m>-FRfhVF=QLOyC?O}UyGW%-l*E2t8=BD7G?NlL`XPb6EN9eUaKbo zUoYOCKSxJ1k z$kVIm4mQE`sU0tDF*;VJ=Qv2X0W7d({b-0~4w+THOm=8)Tr$(U%$KImI3haILuW;n zDyu8Ri062ns~OZatL9fuEa*McDE-}kKqeP}~OLQ7$xy$_wS z{=lJ_1n*DNeT821V&QZ<%l@l4q|_>H+I~V!%n?aheyH8HIzZ)CF9QytaPJ(4h8N^u zoI=N%Kfh_dM>Lg_?9Dt)BS_pas0&}x)JYx1g*OE*n&K-C7Qmc)=Jna(t$&iDSjjqE zQRs#=-|^~I&MALCR}eo3rG}&Za^bV$^O|6PJI9e%j@N!X9@ z#-lo&5*R#~;AIKLSeSd>XLI`p8C^(G>zlkUph$)|FvO^z?Y6uM?ftg-JRdI)v=Kex z7p6Z&0VY^5H_&XM`zvI9uewxTv(8kg3Jb|+3pDVlqG2tBrVM946wzpU zf?CLpcbNCmSv<;jxPsG+AT*Sys0@T-LTC9?daDcD+%ni?nWf(o6gLd-fE%mwrdc!{ z#Z^2P-(!o4O}Fx@gYZzMgs&fIW)VMSajW1r{e0B@+Qa4pWN{ESL9)<>mJc#*%J20r z(vfn@!g*|(yX)$}?O>!cRuV(5;6~pb!zy?tETMq3c;o)vWnj$)mD_F=ORwOpp6ufz zsLjkx&_XS{6`40f&hg}PI{jy;4z$P%FCw@20h^jD*>xLbVAd zg9&5mSvUfHAiKAO5Z3Rj%LpX=&k~AErIOO!GUj7BqvVO)!o*-=Zv1ISDtMQ7rw{34 zHjN}YpQF$r?vco1eq@nnxcF0fxjJLx;u2r<#P)Xrc0nT4Yjsuxoy_bgXi`CS1@dC5 zcIYGq+1W+Rb?f)j#lA~kCz8(M_VFxBJ9MgS8CIjdkVxrupKHh@@j{?l`t!|aN;+%olYB_4e&;lzo)SmeR@kTgu&26ud zgOQ}3d#y_y5ft|SGLO6hIlhQN&QAd3{;lZFDClNjSZGR?+Ju3?qY7JuZxoL#*GC}{ z`J@aBt)S6Tf%2WM1iLQzUNZh*m9WL0#d1z$Pv*2 znxQ9PE8`JqaS6JErv52C?ZN{gRA)35l3Y5%#Rata%86>5^*cjfcuecXZ!fjkl5bK| zW3<=%(r?*)JkdhhtRR3iXtUT**E^iVe=MGs5%TpXIXvS=nolcdBRS<#wv^Rw*D|Fg ztp2T&!{ss)!F66URI>(tv;d4cd!fS-P5$~E3LyZqb1kf<9Tv%H%l-mNE6nl~2n5{1 z^yjA1Mc^v^XQg`qqzgwdlZLw-4*<}?Pv8Q=a2{UI<;Pm@hQFQ;jwJG=kA}yxHw7Sl zEM%arf5KbggP!%fZ{V5m%yh2J8ziT8lvZYR2$70nkWa#4(YoY%D&tN4qcLO;Y0Hx| zZU3QTJDM~@U-=?S>&KFif=BkG^ghep&qp)U!@!yhYrp^ibRlYof42> zdODS?8-_HrmUZv*YE!;&b2Uu2i&-J&MIP4!<_E0VKm1q8d0b$cFsXTRw3N?v3?g@Q z_xyYV9s*ss!JvSc<*+!N>CGOk;WKKy>@foLq1@`IRgPPxsv$sl)Qd8;Z-@tw#jO8E z=l9giSxt)Jsc*)R#C*xtu}UkH{ceO)9$WK9{x>nP3ewUFs!31CvNWzj#&62?Sx;KHx8yEG+X6>w(`sgxxB%p~R@?l;phX#y z9Nnu}Q?vJoCEnbfQ3H4u^OYpf#$&&Mr%W~|0>GQRwLVK!cUm5#WeQQ^g^*`@Mjg>J z^Yi1S+kI$nu0m+hBGk32cAS&C6F(O2Pj=Ri70R5il};@SH`4Xy%93UjFshPtCc7!8 zvEbnXOcNR4r}!@;DVG}?Ko|A(1Eo^fO8KSZCyX1k=3Tz!l9Y30Wwiq?eCIrvZgOx4 zqCJ`HyOXM6q72nCBLgcKpjg*i0IyWUVAK#vj*c2?mDB9M4-bs`x7X`>_+?q@b~tSX z5nN^Wf9U2{zH;?l_-md%yOlf&6IHx4-ka63Gu6m!k5GF)z;XP-AgfcbOlMXyG9|40 z@57~V3NZvlUHnrJ%Y8&YT35xaTlK15_;ujdGBT4BeN49Igm4=fuql$J_tm^$g6fJf z#ciF8IrfW>7nG`v-S7<38E8#UWA!4gaQ+kAT(drrIA5b{O=9sde`+VkQJo>d$Jp8t z*|d&eR11@!Fco-=zsLD4PI&3+tW~k8qU2$?wqxqW69?wu46#=zwmTKQz7ACSHgB#- zoA=f*uI@R3EqeReAwVz>yBYR%jUXSYugMmcR(w3<&G^z~$%7Mt+sKvqm;ltbLh^{z z1=Z0=yJ>#}*kNKwV`Kk3SxxUTs<)J_)6aXyw4B1Y0XU?Qo#{P~*chSyP;+-? zN}JLU!0m@?9F-*-aETLC%S}n@e%b9V-RbVBQH5&mE<}7!wB8&j7HQ?zc^BGe>=2*l zKFjEW8UUXrPzMQ>_~8H7VY)f#jM$7v6vDclke4R<_3e5G+$;7r2%SLjW<)c?rLdf-u=VI+)pSkiv{<_Wd<0Hd2bo(~= z(?Li!nmdO()T1cM%IE{7sxJi1lM@N#HI5kb8Vs($luHl1(6Anq(6$_6CmodsO>mf$dXD<~s5#F^YZFWLlC zV1}ZZlSqg#oh_EoGlRl65oMWL#A}Nge5R9ZLW8cCom$r%C4|iLpnePU_Rr~;4fA|q zctv$b-^#WqhsszCTn;^o+~B0+gdDo{Jj_K~rlwndNfUzX8Et4IZL}^Fv;HP(R3=dY zl8!jnq|FG*v(h!MJbBtv$F<{eGa#t)jf+OFAvz;q4*ZeHzDu>5@9Kgj1}3%Z90I2N zW9vv*Q4;k_h7jE%Bv&upYoO{ie;#m<_PIzD*ca$Fin~nAJWZLJckplp&=B)(s;=BI zQxSe%ey#$5C-3304$>ro;J66cW+XZ5HUoC-jfpb!#9>1Xh|!Exd&Kt#^F!^O9ECrj z{p#k)BM&uqhVP7`1+e0Wuu3|rKjy5q6V|KZgAzJ z7r+yeuU#)alt*Zga=6mf-%wayhVElZg3ckaBp30pAy|O~+@mysks6MB}8rFEZS&jvgt4%e= zBg9b|euurHW?Jbp%ss%>cm=y%ctz1he;yJsu~QC;*Q`vV$p|^o4Lr0vv}B_&1;!9u zaB+N7COjqd9r#B0S8nW%!G`8`Z>NflLDGeex2*<=<5e=w-~=6DFv!4}PczzUn9wX$ z$XzoXq}tJRYv_$M#xn?ZrPhrGSslqtiv3jWvhH>{Rg=bY{~e-Nk_L6yC|?r`NGM&A zOk^8Ilyd7PA@LiZr)TgX?!lOOG*#@*zO;xHef9Y*fco{$;urM)RfN z`c3o7D?L#|^&&R=MshHRzBj6O$|@Ie5w4o77Y!1#`lCR^(ldCMuQ~ObOSeleJ9l@d zx?m;O_;bdMA75f%3VJQTgbK01NE8qf*q{sL7uXn~2kSI!;Z{Q$w3tU;vRp`(?YWYQ!X2N-tT!8@wMNR`r6xr z*fe1G=La#Su93W6IIoV^B*{rcE#(1LjWb7$3PAmd1Q#HYF(7QvNt#C!bW>{j)Jd~; zQT4MLQ@*qi5&T4vhNA-x9wPGCn%Ma z?EK*M&c4)MQ!Rm|to+}_$rs3H?o-Q!O0^6$6%r&p##QKsenuf)(mF?HJVa4dDN7b= zNoB#u#tb_>XOFnk?>1asaN^g@e`y<19o%2I!zv0#M3%H6;ER3DZSP>i7MTi`En%!T z%;@!cPbM=T>fh#1gW!?ZF?CdfQWx+5&`YL|CUd9{99u~`c zW)G0<+||Ta8*e!FNzxk>8Tu_3x9Ob&p1hcI9Pcn?!H$G=F>s8Gdib3G$+Et63V2#io>9IJm<5ot4&^ zj;L%m5CKYRB^Uu0%zzo+m?)Et^7~#mG8M3-BVOGz7JV2M%OO~HBFG39ChxO4-ASUOoOcjH|;lQ{udV3}aW ztGRtI!6nSYO|R2oPWT#!o>|bG>Sn12M$Yz#_dIV!4|l$Le90TVvEX>CH}|P(6;g%r z63prF7eqK8}8qenFto6K-_gN$sJ7#qW?WA4Jfc`eNgtwIzAcg~mz z8HF1MCj7VyI~WYM`Zh6Sf%xG}-<+mw%TrzuC+zM|K05fdE=iMFr?IlzsCNwx244|vW z+j+Ib-Q2YV8H~Apm@76Swf748&Z-%v&bhdLCE76d%2AcN*}nqZ;H9)GxFv$z0$qsl zfhdH@#l(neViRLd0*zdoPt=VAs!2Wf=puXlQ%GtXwv2DbGc5@}*YlCo{x=FMO=ka@ z;zzEoc{d_2e&Ux8PHtOF;NsX>b1=I5`)4f+7y`3)e|0I;nGZj29gshLiz=_`ePA8{ z05HPe#vJuw@gLCB)_goJC5)bEvEd)>iTqKK*!;@;$ZPd2tavhB6~+6qxkWA0C+zS)75A`k|Y ze4&EiuM4Q;$BDt5#p^mal03xNxN_=LLDb7<1dtX9jPjTJCT*>mM6e^ts6 zqa{1mkJM1k2UF0cDThMUhxCt{xfZLvJWmmS<$xCJBEXq0rQsjqQ*=PFT0&Ar3l?O? znM(eV`nq|i=^ULWrSC}L9J5-f40Geuh!3l+L~6rMH$CYBLtaQ-hP!T>*zYDLa8!Wa z=^ceO(CCiXHpo{fsWiizeHNI9+|@(^_r&)@JD3{NB~+flkU{BLDEN8j%tqN~XAY)V zt3z{L80$Bv2?52(?P8BkL3#%>Vz+9@bqy9&xz~m5hDT?6>r=@G{0}cEM;N%Pg&&~E zj}6F#;Um@PyFXp@<25c@veGt@wN7E@Q0Jp%B*&)I&HpTTdB^G=+Oq?Wy~qbF8*3r< z!-MW_r7p{~4Muj8!wu zk(pVV7v7v}+ZsW`eBpEqEDy3bF@dI4x@uZVpt3H=Y?=vpGP+`wS~ft<8-+K|VmcVz zfKhgY!;f@SGILys{ZW@5xq)GAtz?h#=vY&8Ki8OZa==DPqo5x zU(#wZO<|)B9S@0i9{vQG+Ken<^;F1_GJ1Z!e@-Krm4{(H>U1uw&V;;FXy~i5Xt~Q# zmjH|FRyC)na#T@^6v3i6lFxFJ0HYe*qUcLe4A6J3%q}4Zq=#lMaB5$w_$}`BrFK@d z!q6{0KYkoDuXmP+*sEfDLJ$bc`ewnY zR5#$PX;2V7v@}ynO!{1VZ&hs^&CzxD!lR>S2a=mvVpXGSH^ZCtj5V z6Y0WX!Alr<4b}wG@Mf?xFTs0vAV(|~PXqp`U%5j&mi_Y70^Q-4*D5m*$&5oyPu3Kv zYH!wNd_d-QWp*c;CoEYB^ul_64SJyKM^=$1RMWzaz|@177^jks&V`^H!7bS}xk-x> zsORZ2@$Rb}!FqwwuYSnzdY5_PWW*2lIE!o-#(16sb<{dg0@$w^k!j^p6=2(2y}{m5 zT1T4Ma-a(dV`j~p$&l$hmcyj|)tQUyouFk;-`%}c!8TW5ciR| zTzLyRp0Z*8zK}Ab(1?aN8@j{V{q}PlKxtpBFA{YnE1vL*^&a86Q})FU695V4Nq$RI zyFufX#SMvU80iazGsrwG0seRpz9Ss1XgJnvfE+t!y`8r8Hpv*Qy1GV=oTl$3t8OdO zI@`Y|D4cX(m7Wz!DoMSRn{&&a;=&29DV-~{x)A(o@#%woQs?2XM1)s z-fH0v@t#zC%6J$SAv#za9dyu$&Y-lTb8Ac8t>)dUkf%XeroDbo!BQ~|eetH~Vy#KM zp&qq)k9#@fR5{XC%F1`9*>-eZk?Qhbfv1ADlA&39)FJP`Gu=J@Gb@lwPX$?1O{PBL z&6pIibr(hV(0fPJfsomF4w(l5HSC`mC^;+VtzXZZ=)vnPT2-e3m8bmVGvHL8=*4)9MjwrWVYV--; zZ&&3-wIp?cK0q;-P$@qd+WWqQ8SkW_(j!-6b?D#^kz(S{aSX={aL0fLA#uYL2(zbi zsLmi=p#WHqX0)ipfen|-en<}gUhk1sG2SCxcTC`pQBGa}Q}`>)pDl}|_#IUtv$TYG zBDdUPIy;Rkzp66|)$4$uU;|U^xG02s^HHvlRMx}H9tok_1;i|_?f7PlHBtBIMmP_Q zbLS5Z`R7Z3bT(Wy8A!g$Me<1(AmI-6UlbA84HBLa2fb(}iza1+Rhy4fn&z?c1w8ET z=^VXMD;oTZM|T`4BeX$<%1UU=KdX%Aa&xoPKrQ4}UC4RIjpRGFVuUjBw(f0>zva_; zPp#Ri{xu{YDaLoUr*8Y*tdhyo@r~lAxj#?zV`1v4RV-mF)~gGm@?A!h4=g8mt92b& zdOLsojRtDlW)YF1HXr9*L3b>JekR*ry+J&!dLDwW9XliBx3VqDXDUSpM+>52e-YCR zz=_g&{oLYC5$(ss3U*;dCx`3)DccbWl7Ny+n_Rcpu1;qe1(dnIe6jmYo!in!BdipZ zF0D+p(iurU%jOfEticWhSLO<(zebkX!3c^AGTVvA-2VtnOYK7C2zgy`9~(OH1;zPU zJuTTL!bXsle1xX$w9m72ttet*ax1E1%t*wLgzZyqh3WlA;p^3~Bj z>2S3I|4@!w#bx_sGJe7)Z-#`O``pEfQhH;0>`LBg#N{UOh=qY)nZ`JFRn=uU`QzDK zCgf{O-Aiz(i|*DysID2Fjqg|5?t9%GhYDhWt7z8oN11NjUFz)3i#1R(2Qb22;(Q+6 zk~Jm@FR?M5BIdE^1hvKrM(n|9zrl}n5UltaN=P{E=+?7o_0dG{;DP0_*cNFide|_j zsfCk<;Nuk{A|rP(YBC(ZB~lgni6Hef)}u~5*ps{wH1QagTCnPq*g^75f`O$p!u17> zIoCqSkpi@?_^#LEAE>}niHCO|N1Vo7z73rj_Yx_AGA{Tn@GGmAU{t9OS?SE!x__l=fFyx!mxvl$2If;FD0S z@E#uFrm{1M)in3Kn*QEkt>a{KL^$iFS}1;{*&Rnr{)nnj{%kwAev@b`8?Jt>0n-y? zF#u^VD;6%{_ej$X;9DZHMS^xwFvnJwa1fSDA+dzkud}`|TTRJZwDd48>)Ld8)T+$K zXw&gje(DTBQZ6(N-AR}5MFa>ciMw4@(rafo1%eim0i9KebwZ8-pC||Hc^9F(P8$bm zphT4i4g6`s`Uhl{aDQ0lmZBPcO(v1Ae;x>RN1AA&j#yJxF!Z+y^N`VME9ATQSTxygJ2gBKxI4!bJ$-QfZ|c$ z@&~*FKPPxHiR))pdD)2(Q6Rrs5D#L;7B()Sf;7l@b%+x({rpAMN*-NaZb8csD$+l&r>@=^EYZ(1$9lnV~axZX_lDgPFtqOQfH#8vYz)t^y8#vmg#?muo?N`>aA5!o(QQhuK};999J zkkG4woOj;)>o6bcay6(1))~%KK^=aq)Hgvf*`!Pzz~yHA1s{GVrbs(5#1s_^6s8FSLjuzWv& zs0>-C+O!*&0&e1WoSiu-C|YhAkW56z{lV7&F!K57^YrEntrV5$z;v=hrcEq7{aNc}8biRmpkWZzM!i_k&e{QKtjDTm~% znj|FNG_+eW*plC)d=MC zJdzgg9_eZrwV%;cU=*HJsx0vYJo;BI^X;J4F`F8wbOD2<(FViwUr}fhdt)^Q*wF{%=(J3KN-GNM&8^1pzz5Dozn0PuN97AIRjKsYCtB)@EB5}X zjz1MW_b1>c-%pXCkzjcEPp1Cq6k6?0asLW}qS9WtR}KuHOy^W2*(D`NeM*L}NEwqI zee{|ysp39(<_p2~-;pL{z5sW}A$fO7(~?8k^B_DbXo}86n2uT96(!;JHe-;N=6-ph z(Bu6hIgzL^!{U<6ik*y~#E-tPzeA+d;r6ir6Jan66I>zd7q=?&&}IjyT#Axy{w#?h z9GvjVI_8CGoemr~j|w~nfkHC;HvdA?U4tn`PO)n;63e%r-}JrFn!QN2Y&d3}osrv8 z%hbzBKw zGDdD4v8hh>ChV<03^BXEsq!_gtn|-?s`(e7dn(s69%pao55vW)AW;2S`iHA$lxDaK z-LMNEV!wo^#9ghOgb~w9E*ZoPtOjhszji zU6($2-L~rDAoZ#vc_$f(3uo<*b0OdwEj``@H1nQF{~|Gq`7o?)p1OW~OErawg96Lh z+h{G*uv2#2J1@FdhTX+N69pag_g;=du)1&oE7Q%KWt1^y?cI>Qui7L zV?IV47c53G^y>3-#b^iTic9{XsTzKBV=b~p)L9Au%EEUlez{vqxouwHyVRFi#W!~m zK?FC!uA_!;iOmnJ^mWOm2P3x>817};2m)so%Z7X8?O~GD7!E0tjr5;fR=te!ufv;E z2TFObD!Wp{iAI6YPCDQMoSuKE#?#n!y8HE&Iz0XYOG znzjvjXc(gof+>y_vhg8f5gj0L z=6IQWYwH2{-(;z76Fc|WETa{M5;4Sn9px#f7$KSfzrw`DGEyPeyRYHh$&9&m!}H)P z#Ne^E>OK4oF3MoV#L^>sHPXf8W+~hdi$kuvC$7*0&O8*s;d)U34`|$Inhe)G*x7!~M-Wg{YWH5|U-SRG9 zb4uym<4gMo%3<6Izy6vPuw4u^1B(Q#lIfKBKFrX7b^{K_#p>{H(YYsj#E;gp8F#sQ z!b_ne0go|lN%Wrvj13>Jr$T!iE|R)rs>P4U7d^Cap%hpMx)4y?gxHErT_B(lz{`Jh zdvjD(c^s}>D>sjitcC32gg@Z0<{%*3I~fsJ;s+ELgYa+pthB7rVo>sP%)45Tu!`V4 zjK7`pA!%cF;un{b$M3wlievm=#g_NPyu%O&!;k5taot;!PYoJ|XrgY(s_6f^xT8o(n8_!Jk#-R|~4x8Ed2nr)VLPoCc14+s&-_U!9Vk76O+lrc|h<^5m973#_3*2SNSn3glrSo^! zBu%C{QmX@;D!97`J1}!$W)(Si2XFXc%LwfBT>WmPX7#4S>UO}B(dWp3lzf{$?<|y6 z5NQwtG4cogO8#k(TpIbLETZ^xmb}hf zYduTtTK%+V{p~bW17WyqXKj2fOiQlkchR0~8E3^7D305q`NWF(p5p3)0}gV_UXi}E z&~^Z*43=tJxe_ervh*13FG4|dls(1M=Jg7@x%`a!2v*iJs5r~NCETz|H?r5|2NVEFaQAX|M1K&2K^sy|DE$60s4&(^xp`IoE*RC Nf6*8j82&T*e*m((raAxs literal 0 HcmV?d00001 diff --git a/src/Community.Components/Components/Audio/Audio.razor b/src/Community.Components/Components/Audio/Audio.razor new file mode 100644 index 00000000..26d18fef --- /dev/null +++ b/src/Community.Components/Components/Audio/Audio.razor @@ -0,0 +1,15 @@ +@namespace FluentUI.Blazor.Community.Components +@inherits FluentComponentBase + + + +@code { + public Audio() + { + Id = $"audio-{Identifier.NewId()}"; + } +} diff --git a/src/Community.Components/Components/Audio/AudioControls.razor b/src/Community.Components/Components/Audio/AudioControls.razor new file mode 100644 index 00000000..ca097481 --- /dev/null +++ b/src/Community.Components/Components/Audio/AudioControls.razor @@ -0,0 +1,29 @@ +@namespace FluentUI.Blazor.Community.Components +@inherits FluentComponentBase + + + + + + + + + + + + + + + + + + + @if (IsDownloadVisible) + { + + } + + diff --git a/src/Community.Components/Components/Audio/AudioControls.razor.cs b/src/Community.Components/Components/Audio/AudioControls.razor.cs new file mode 100644 index 00000000..f851454f --- /dev/null +++ b/src/Community.Components/Components/Audio/AudioControls.razor.cs @@ -0,0 +1,105 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.FluentUI.AspNetCore.Components; + +namespace FluentUI.Blazor.Community.Components; + +/// +/// Represents audio control buttons for play, pause, stop, and other functionalities. +/// +public partial class AudioControls : FluentComponentBase +{ + /// + /// Represents the play or pause button control used to toggle playback state. + /// + private PlayOrPauseButton? _playOrPauseButton; + + /// + /// Initializes a new instance of the class. + /// + public AudioControls() + { + Id = $"audio-controls-{Identifier.NewId()}"; + } + + /// + /// Gets or sets a value indicating whether the download button is visible. + /// + [Parameter] + public bool IsDownloadVisible { get; set; } = true; + + /// + /// Gets or sets the event callback that is invoked when the shuffle state changes. + /// + [Parameter] + public EventCallback OnShuffleChanged { get; set; } + + /// + /// Gets or sets the event callback that is invoked when the playlist button is toggled. + /// + [Parameter] + public EventCallback OnPlaylistToggled { get; set; } + + /// + /// Gets or sets the event callback that is invoked when the previous button is clicked. + /// + [Parameter] + public EventCallback OnPrevious { get; set; } + + /// + /// Gets or sets the event callback that is invoked when the download button is clicked. + /// + [Parameter] + public EventCallback OnDownload { get; set; } + + /// + /// Gets or sets the event callback that is invoked when the stop button is clicked. + /// + [Parameter] + public EventCallback OnStop { get; set; } + + /// + /// Gets or sets the callback to be invoked when the next button is clicked. + /// + [Parameter] + public EventCallback OnNext { get; set; } + + /// + /// Gets or sets the callback to be invoked when the repeat mode changes. + /// + [Parameter] + public EventCallback OnRepeatModeChanged { get; set; } + + /// + /// Gets or sets the callback to be invoked when the play/pause button is toggled. + /// + [Parameter] + public EventCallback OnPlayPauseToggled { get; set; } + + /// + /// Gets or sets the callback to be invoked when the volume changes. + /// + [Parameter] + public EventCallback OnVolumeChanged { get; set; } + + /// + /// Gets or sets a value indicating whether the previous button is disabled. + /// + [Parameter] + public bool IsPreviousDisabled { get; set; } + + /// + /// Gets or sets a value indicating whether the next button is disabled. + /// + [Parameter] + public bool IsNextDisabled { get; set; } + + /// + /// Sets the play/pause state of the associated control. + /// + /// A value indicating whether the control should reflect the "playing" state. if the + /// control should indicate playback is active; otherwise, . + internal void SetPlayPauseState(bool isPlaying) + { + _playOrPauseButton?.SetPlayPauseState(isPlaying); + } +} diff --git a/src/Community.Components/Components/Audio/AudioPlayerView.cs b/src/Community.Components/Components/Audio/AudioPlayerView.cs new file mode 100644 index 00000000..08b5f67a --- /dev/null +++ b/src/Community.Components/Components/Audio/AudioPlayerView.cs @@ -0,0 +1,29 @@ +namespace FluentUI.Blazor.Community.Components; + +/// +/// Specifies the rendering mode for an audio player. +/// +/// The rendering mode determines the layout and appearance of the audio player. Use the appropriate mode +/// based on the desired user interface and available screen space. +public enum AudioPlayerView +{ + /// + /// Represents the normal rendering mode of the audio player, which includes all standard controls and features. + /// + Default, + + /// + /// Represents the compact rendering mode of the audio player, which provides a smaller layout with essential controls only. + /// + Compact, + + /// + /// Represents the floating rendering mode of the audio player, which allows the player to be positioned independently of other content. + /// + Floating, + + /// + /// Represents the minimal rendering mode of the audio player, which offers a very simplified interface with only the most basic controls. + /// + Minimal +} diff --git a/src/Community.Components/Components/Audio/AudioPlaylist.razor b/src/Community.Components/Components/Audio/AudioPlaylist.razor new file mode 100644 index 00000000..5c15d68c --- /dev/null +++ b/src/Community.Components/Components/Audio/AudioPlaylist.razor @@ -0,0 +1,14 @@ +@namespace FluentUI.Blazor.Community.Components +@inherits FluentComponentBase + +
+ + + +
+ diff --git a/src/Community.Components/Components/Audio/AudioPlaylist.razor.cs b/src/Community.Components/Components/Audio/AudioPlaylist.razor.cs new file mode 100644 index 00000000..6f537be0 --- /dev/null +++ b/src/Community.Components/Components/Audio/AudioPlaylist.razor.cs @@ -0,0 +1,38 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.FluentUI.AspNetCore.Components; + +namespace FluentUI.Blazor.Community.Components; + +/// +/// Represents an audio playlist component that displays a list of audio tracks and allows users to select and play them. +/// +public partial class AudioPlaylist + : FluentComponentBase +{ + /// + /// Initializes a new instance of the class. + /// + /// The playlist is assigned a unique identifier upon creation. + public AudioPlaylist() + { + Id = Identifier.NewId(); + } + + /// + /// Gets or sets the playlist of audio tracks. + /// + [Parameter] + public List Playlist { get; set; } = []; + + /// + /// Gets or sets the currently selected audio track. + /// + [Parameter] + public AudioTrackItem? CurrentTrack { get; set; } + + /// + /// Gets or sets the event callback that is invoked when a track is selected. + /// + [Parameter] + public EventCallback OnTrackSelected { get; set; } +} diff --git a/src/Community.Components/Components/Audio/AudioPlaylist.razor.css b/src/Community.Components/Components/Audio/AudioPlaylist.razor.css new file mode 100644 index 00000000..40db84fc --- /dev/null +++ b/src/Community.Components/Components/Audio/AudioPlaylist.razor.css @@ -0,0 +1,5 @@ +.audio-playlist { + overflow-y: auto; + overflow-x: hidden; + height: 100%; +} diff --git a/src/Community.Components/Components/Audio/AudioPlaylistItem.razor b/src/Community.Components/Components/Audio/AudioPlaylistItem.razor new file mode 100644 index 00000000..e80c0151 --- /dev/null +++ b/src/Community.Components/Components/Audio/AudioPlaylistItem.razor @@ -0,0 +1,32 @@ +@namespace FluentUI.Blazor.Community.Components +@inherits FluentComponentBase + +
+ + @if (Track is not null) + { + @if (!string.IsNullOrEmpty(Track.Cover)) + { + + } + + + @Track.Title + @Track.Artist + + + + } + + + + +
diff --git a/src/Community.Components/Components/Audio/AudioPlaylistItem.razor.cs b/src/Community.Components/Components/Audio/AudioPlaylistItem.razor.cs new file mode 100644 index 00000000..904def57 --- /dev/null +++ b/src/Community.Components/Components/Audio/AudioPlaylistItem.razor.cs @@ -0,0 +1,71 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.FluentUI.AspNetCore.Components; + +namespace FluentUI.Blazor.Community.Components; + +/// +/// Represents an item in an audio playlist, including its associated track, selection state, and actions. +/// +/// This component is designed to display an audio track within a playlist and manage its selection +/// state. It provides functionality to handle user interactions, such as selecting a track, and displays an appropriate +/// icon based on whether the track is currently selected. +public partial class AudioPlaylistItem + : FluentComponentBase +{ + /// + /// Represents the icon used to indicate the play action for an audio track. + /// + private static readonly Icon PlayIcon = new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size24.Play(); + + /// + /// Represents the icon used to indicate the poll action for an audio track. + /// + private static readonly Icon PollIcon = new Microsoft.FluentUI.AspNetCore.Components.Icons.Filled.Size24.Poll(); + + /// + /// Initializes a new instance of the class. + /// + public AudioPlaylistItem() + { + Id = $"audio-playlist-item-{Identifier.NewId()}"; + } + + /// + /// Gets or sets the audio track item to be displayed in the playlist. + /// + [Parameter] + public AudioTrackItem? Track { get; set; } + + /// + /// Gets or sets a value indicating whether the track is currently selected. + /// + [Parameter] + public bool IsSelected { get; set; } + + /// + /// Gets the appropriate icon based on whether the track is selected. + /// + private Icon TrackIcon => IsSelected ? PollIcon : PlayIcon; + + /// + /// Gets or sets the event callback that is invoked when the track is selected. + /// + [Parameter] + public EventCallback OnSelected { get; set; } + + /// + /// Handles the click event asynchronously and invokes the callback with the current . + /// + /// This method checks if the callback has been assigned and if the is not null before invoking the callback. Ensure that both and are properly set before calling this method. + /// + private async Task OnHandleClickAsync() + { + if (OnSelected.HasDelegate && Track != null) + { + await OnSelected.InvokeAsync(Track); + } + } +} diff --git a/src/Community.Components/Components/Audio/AudioPlaylistItem.razor.css b/src/Community.Components/Components/Audio/AudioPlaylistItem.razor.css new file mode 100644 index 00000000..31d8f258 --- /dev/null +++ b/src/Community.Components/Components/Audio/AudioPlaylistItem.razor.css @@ -0,0 +1,6 @@ +.audio-playlist-item { + height: 70px; + margin-left: 5px; + margin-right: 5px; + cursor: pointer; +} diff --git a/src/Community.Components/Components/Audio/AudioRepeatMode.cs b/src/Community.Components/Components/Audio/AudioRepeatMode.cs new file mode 100644 index 00000000..4a88956b --- /dev/null +++ b/src/Community.Components/Components/Audio/AudioRepeatMode.cs @@ -0,0 +1,27 @@ +namespace FluentUI.Blazor.Community.Components; + +/// +/// Specifies the repeat mode for audio playback. +/// +public enum AudioRepeatMode +{ + /// + /// The track will play once and stop. + /// + SingleOnce, + + /// + /// The track will loop continuously. + /// + SingleLoop, + + /// + /// The playlist will play once and stop. + /// + PlaylistOnce, + + /// + /// The playlist will loop continuously. + /// + PlaylistLoop +} diff --git a/src/Community.Components/Components/Audio/AudioTitleScroller.razor b/src/Community.Components/Components/Audio/AudioTitleScroller.razor new file mode 100644 index 00000000..2c558f9c --- /dev/null +++ b/src/Community.Components/Components/Audio/AudioTitleScroller.razor @@ -0,0 +1,12 @@ +@namespace FluentUI.Blazor.Community.Components +@inherits FluentComponentBase + +
+
+ @Title +
+
+ diff --git a/src/Community.Components/Components/Audio/AudioTitleScroller.razor.cs b/src/Community.Components/Components/Audio/AudioTitleScroller.razor.cs new file mode 100644 index 00000000..aef641fa --- /dev/null +++ b/src/Community.Components/Components/Audio/AudioTitleScroller.razor.cs @@ -0,0 +1,17 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.FluentUI.AspNetCore.Components; + +namespace FluentUI.Blazor.Community.Components; + +/// +/// Renders a scrolling title for audio tracks. +/// +public sealed partial class AudioTitleScroller + : FluentComponentBase +{ + /// + /// Gets or sets the title to display. + /// + [Parameter] + public string? Title { get; set; } +} diff --git a/src/Community.Components/Components/Audio/AudioTitleScroller.razor.css b/src/Community.Components/Components/Audio/AudioTitleScroller.razor.css new file mode 100644 index 00000000..47424f27 --- /dev/null +++ b/src/Community.Components/Components/Audio/AudioTitleScroller.razor.css @@ -0,0 +1,23 @@ +.audio-title-container { + width: 100%; + overflow: hidden; + white-space: nowrap; +} + +.audio-title-scroller { + display: inline-block; + padding-left: 100%; + animation: scroll-left 15s linear infinite; + font-size: 16px; + color: var(--accent-fill-rest); +} + +@keyframes scroll-left { + 0% { + transform: translateX(0); + } + + 100% { + transform: translateX(-100%); + } +} diff --git a/src/Community.Components/Components/Audio/AudioTrackItem.cs b/src/Community.Components/Components/Audio/AudioTrackItem.cs new file mode 100644 index 00000000..83d83c34 --- /dev/null +++ b/src/Community.Components/Components/Audio/AudioTrackItem.cs @@ -0,0 +1,53 @@ +using Microsoft.AspNetCore.Components; + +namespace FluentUI.Blazor.Community.Components; + +/// +/// Represents an audio track with properties for title, artist, source URL, and cover URL. +/// +public sealed class AudioTrackItem : ComponentBase, IDisposable +{ + /// + /// Gets or sets the title of the audio track. + /// + [Parameter] + public string? Title { get; set; } + + /// + /// Gets or sets the artist of the audio track. + /// + [Parameter] + public string? Artist { get; set; } + + /// + /// Gets or sets the source URL of the audio track. + /// + [Parameter] + public string? Source { get; set; } + + /// + /// Gets or sets the cover URL of the audio track. + /// + [Parameter] + public string? Cover { get; set; } + + /// + /// Gets or sets the parent component. + /// + [CascadingParameter] + private FluentCxAudio? Parent { get; set; } + + /// + protected override void OnInitialized() + { + base.OnInitialized(); + + Parent?.AddTrack(this); + } + + /// + public void Dispose() + { + Parent?.RemoveTrack(this); + } +} diff --git a/src/Community.Components/Components/Audio/AudioVisualizer.razor b/src/Community.Components/Components/Audio/AudioVisualizer.razor new file mode 100644 index 00000000..c32a8e21 --- /dev/null +++ b/src/Community.Components/Components/Audio/AudioVisualizer.razor @@ -0,0 +1,9 @@ +@namespace FluentUI.Blazor.Community.Components +@inherits FluentComponentBase + + + diff --git a/src/Community.Components/Components/Audio/AudioVisualizer.razor.cs b/src/Community.Components/Components/Audio/AudioVisualizer.razor.cs new file mode 100644 index 00000000..28cf9675 --- /dev/null +++ b/src/Community.Components/Components/Audio/AudioVisualizer.razor.cs @@ -0,0 +1,135 @@ +using FluentUI.Blazor.Community.Extensions; +using Microsoft.AspNetCore.Components; +using Microsoft.FluentUI.AspNetCore.Components; +using Microsoft.JSInterop; + +namespace FluentUI.Blazor.Community.Components; + +/// +/// Represents an audio visualizer component that can display audio data in various visual formats. +/// +public partial class AudioVisualizer + : FluentComponentBase, IAsyncDisposable +{ + /// + /// Represets the JavaScript file for the audio visualizer component. + /// + private const string JavaScriptFile = "./_content/FluentUI.Blazor.Community.Components/Components/Audio/AudioVisualizer.razor.js"; + + /// + /// Represents the JavaScript module for the audio visualizer component. + /// + private IJSObjectReference? _module; + + /// + /// Value indicating whether the visualizer mode has changed. + /// + private bool _hasModeChanged; + + /// + /// Initializes a new instance of the class. + /// + public AudioVisualizer() + { + Id = Identifier.NewId(); + } + + /// + /// Gets or sets the visualizer mode. + /// + [Parameter] + public VisualizerMode Mode { get; set; } = VisualizerMode.Waveform; + + /// + /// Gets or sets the cover source URL. + /// + [Parameter] + public string? Cover { get; set; } + + /// + /// Gets or sets the width of the visualizer. + /// + [Parameter] + public int Width { get; set; } = 600; + + /// + /// Gets or sets the height of the visualizer. + /// + [Parameter] + public int Height { get; set; } = 280; + + /// + /// Gets or sets the anchor audio element identifier. + /// + [Parameter, EditorRequired] + public string? Anchor { get; set; } + + /// + /// Gets or sets the JavaScript runtime. + /// + [Inject] + private IJSRuntime JS { get; set; } = default!; + + /// + protected override async Task OnAfterRenderAsync(bool firstRender) + { + await base.OnAfterRenderAsync(firstRender); + + if (firstRender) + { + _module ??= await JS.InvokeAsync("import", JavaScriptFile); + await _module.InvokeVoidAsync("fluentCxAudioVisualizer.initialize", Id, Anchor, Mode, Cover); + await _module.InvokeVoidAsync("fluentCxAudioVisualizer.setMode", Id, Mode); + } + } + + /// + protected override void OnInitialized() + { + base.OnInitialized(); + + if (string.IsNullOrWhiteSpace(Anchor)) + { + throw new InvalidOperationException($"{nameof(Anchor)} parameter is required."); + } + } + + /// + protected override async Task OnParametersSetAsync() + { + if (_hasModeChanged && _module is not null) + { + _hasModeChanged = false; + await _module.InvokeVoidAsync("fluentCxAudioVisualizer.setMode", Id, Mode); + } + + await base.OnParametersSetAsync(); + } + + /// + public override Task SetParametersAsync(ParameterView parameters) + { + _hasModeChanged = parameters.HasValueChanged(nameof(Mode), Mode); + + return base.SetParametersAsync(parameters); + } + + /// + public async ValueTask DisposeAsync() + { + try + { + if (_module is not null) + { + await _module.InvokeVoidAsync("fluentCxAudioVisualizer.dispose", Id); + await _module.DisposeAsync(); + } + } + catch (JSDisconnectedException) + { + // Ignore the exception that occurs when the JS runtime is already disposed. + } + + GC.SuppressFinalize(this); + } +} diff --git a/src/Community.Components/Components/Audio/AudioVisualizer.razor.js b/src/Community.Components/Components/Audio/AudioVisualizer.razor.js new file mode 100644 index 00000000..9df85fac --- /dev/null +++ b/src/Community.Components/Components/Audio/AudioVisualizer.razor.js @@ -0,0 +1,374 @@ +const spectrum = 0; +const waveform = 1; +const spatial = 2; +const vortex = 3; +const particlesField = 4; +const radialWaveform = 5; +const tunnel = 6; +const constellationField = 7; +const fractal = 8; + +const _instances = {}; + +function bandEnergy(data, startHz, endHz) { + const bins = data.length; + const maxHz = 22050; // approx + const i0 = Math.floor((startHz / maxHz) * bins); + const i1 = Math.floor((endHz / maxHz) * bins); + let sum = 0; + for (let i = i0; i <= i1; i++) sum += data[i]; + return sum / Math.max(1, i1 - i0 + 1); +} + +function initialize(id, audioElement, mode, cover, color) { + const canvas = document.getElementById(id); + + if (canvas) { + const circles = Array.from({ length: 50 }, (x, i) => ( + { + r: i * 40 + })); + + const stars = Array.from({ length: 300 }, () => ({ + x: (Math.random() - 0.5) * canvas.width, + y: (Math.random() - 0.5) * canvas.height, + z: Math.random() * 1 + 0.2 + })); + + const particles = Array.from({ length: 200 }, () => ({ + x: canvas.width / 2, + y: canvas.height / 2, + angle: Math.random() * Math.PI * 2, + speed: Math.random() * 2, + size: Math.random() * 3 + })); + + const constellations = Array.from({ length: 100 }, () => ({ + x: Math.random() * canvas.width, + y: Math.random() * canvas.height + })); + + const audio = document.getElementById(audioElement); + const audioCtx = new AudioContext(); + const analyser = audioCtx.createAnalyser(); + const source = audioCtx.createMediaElementSource(audio); + source.connect(analyser); + analyser.connect(audioCtx.destination); + + audio.addEventListener("play", () => { + if (audioCtx.state === "suspended") { + audioCtx.resume(); + } + }); + + const bufferLength = analyser.frequencyBinCount; + const dataArray = new Uint8Array(bufferLength); + + _instances[id] = { + audio: audio, + mode: mode, + cover: cover, + color: color, + ctx: canvas.getContext("2d"), + width: canvas.width, + height: canvas.height, + centerX: canvas.width / 2, + centerY: canvas.height / 2, + radius: 100, + circles: circles, + stars: stars, + particles: particles, + constellations: constellations, + analyzer: analyser, + bufferLength: bufferLength, + dataArray: dataArray, + audioCtx: audioCtx + }; + } +} + +function setMode(id, mode) { + const instance = _instances[id]; + + if (instance) { + const analyser = instance.analyzer; + const ctx = instance.ctx; + const dataArray = instance.dataArray; + const width = instance.width; + const height = instance.height; + const centerX = instance.centerX; + const centerY = instance.centerY; + const radius = instance.radius; + const bufferLength = instance.bufferLength; + const stars = instance.stars; + const particles = instance.particles; + const circles = instance.circles; + const constellations = instance.constellations; + const color = instance.color; + + function drawBranch(x, y, len, angle, depth, freq) { + if (depth === 0) return; + const x2 = x + len * Math.cos(angle); + const y2 = y + len * Math.sin(angle); + + ctx.beginPath(); + ctx.moveTo(x, y); + ctx.lineTo(x2, y2); + ctx.strokeStyle = `hsl(${freq},100%,50%)`; + ctx.stroke(); + + drawBranch(x2, y2, len * 0.7, angle - 0.5, depth - 1, freq); + drawBranch(x2, y2, len * 0.7, angle + 0.5, depth - 1, freq); + } + + function draw() { + requestAnimationFrame(draw); + analyser.getByteFrequencyData(dataArray); + analyser.smoothingTimeConstant = 0.85; + ctx.clearRect(0, 0, width, height); + analyser.getByteFrequencyData(dataArray); + + if (mode === spectrum) { + const barWidth = 4; + const barSpacing = 2; + for (let i = 0; i < bufferLength; i++) { + const value = dataArray[i]; + const x = centerX + (i - bufferLength / 2) * (barWidth + barSpacing); + const y = height - value; + const scale = 1 + value / 256; + ctx.fillStyle = `rgb(${value},${value / 2},${255 - value})`; + ctx.fillRect(x, y, barWidth, value * scale); + } + } + else if (mode === waveform) { + const timeArray = new Uint8Array(analyser.fftSize); + analyser.getByteTimeDomainData(timeArray); + + ctx.lineWidth = 2; + ctx.strokeStyle = color ?? "#78e8ff"; + ctx.beginPath(); + + const slice = width / timeArray.length; + for (let i = 0; i < timeArray.length; i++) { + const v = timeArray[i] / 128.0; + const y = v * height / 2; + const x = i * slice; + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.stroke(); + } + else if (mode === spatial) { + analyser.getByteFrequencyData(dataArray); + + const bass = bandEnergy(dataArray, 20, 200) / 255; + const mid = bandEnergy(dataArray, 200, 2000) / 255; + const high = bandEnergy(dataArray, 2000, 12000) / 255; + let speed = 0.01 + bass * 0.1; + if (bass > 0.6) { + speed *= 3; + } + + ctx.fillStyle = "rgba(0,0,0,0.1)"; + ctx.fillRect(0, 0, width, height); + ctx.save(); + ctx.translate(centerX, centerY); + + for (const s of stars) { + s.z -= speed; + + if (s.z < 0.1) { + s.x = (Math.random() - 0.5) * width; + s.y = (Math.random() - 0.5) * height; + s.z = 1; + } + + const angle = mid * 0.05; + const cosA = Math.cos(angle); + const sinA = Math.sin(angle); + const rx = s.x * cosA - s.y * sinA; + const ry = s.x * sinA + s.y * cosA; + s.x = rx; + s.y = ry; + + const px = s.x / s.z; + const py = s.y / s.z; + const rawSize = (1 - s.z) * (2 + mid * 4); + const size = Math.max(0.5, rawSize); + const hue = 180 + Math.floor(150 * high); + const lightness = 50 + bass * 30; + + ctx.fillStyle = `hsl(${hue}, 100%, ${lightness}%)`; + ctx.shadowBlur = 20; + ctx.shadowColor = ctx.fillStyle; + + ctx.beginPath(); + ctx.arc(px, py, size, 0, Math.PI * 2); + ctx.fill(); + } + + ctx.restore(); + + // Nebulas + ctx.globalAlpha = 0.1 + high * 0.3; + const nebulaHue = 280 + high * 80; + const gradient = ctx.createRadialGradient(centerX, centerY, 0, centerX, centerY, width / 2); + gradient.addColorStop(0, `hsla(${nebulaHue}, 100%, 60%, 0.6)`); + gradient.addColorStop(1, "transparent"); + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, width, height); + ctx.globalAlpha = 1; + } + else if (mode === vortex) { + analyser.getByteFrequencyData(dataArray); + + const bass = bandEnergy(dataArray, 20, 200) / 255; + const mid = bandEnergy(dataArray, 200, 2000) / 255; + const high = bandEnergy(dataArray, 2000, 12000) / 255; + const speed = 1 + bass * 6; + const rotationSpeed = 0.002 + high * 0.01; + + ctx.fillStyle = 'rgba(0, 0, 0, 0.2)'; + ctx.fillRect(0, 0, width, height); + + ctx.save(); + ctx.translate(centerX, centerY); + + for (const s of stars) { + s.z -= 0.01 * speed; + + if (s.z < 0.2) { + s.x = (Math.random() - 0.5) * width; + s.y = (Math.random() - 0.5) * height; + s.z = Math.random() * 0.8 + 0.2; + } + + // Rotation en spirale + const angle = Math.atan2(s.y, s.x); + const radius = Math.sqrt(s.x * s.x + s.y * s.y); + const newAngle = angle + rotationSpeed; + + s.x = Math.cos(newAngle) * radius; + s.y = Math.sin(newAngle) * radius; + + const px = s.x / s.z; + const py = s.y / s.z; + const rawSize = (1 - s.z) * (1 + mid * 3); + const size = Math.max(0.5, rawSize); + const hue = 200 + Math.floor(60 * high); + + ctx.fillStyle = `hsla(${hue}, 90%, 70%, 0.8)`; + ctx.beginPath(); + ctx.arc(px, py, size, 0, Math.PI * 2); + ctx.fill(); + } + + ctx.restore(); + } + else if (mode === particlesField) { + analyser.getByteFrequencyData(dataArray); + const avg = dataArray.reduce((a, b) => a + b) / dataArray.length; + + particles.forEach(p => { + p.x += Math.cos(p.angle) * p.speed * (avg / 100); + p.y += Math.sin(p.angle) * p.speed * (avg / 100); + + if (p.x < 0 || p.x > width || p.y < 0 || p.y > height) { + p.x = centerX; + p.y = centerY; + } + + ctx.fillStyle = `hsl(${avg},100%,50%)`; + ctx.beginPath(); + ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2); + ctx.fill(); + }); + } + else if (mode === radialWaveform) { + analyser.getByteTimeDomainData(dataArray); + + ctx.beginPath(); + for (let i = 0; i < dataArray.length; i++) { + const angle = (i / dataArray.length) * Math.PI * 2; + const value = dataArray[i] / 128.0; + const r = radius + (value - 1) * 50; + const x = centerX + r * Math.cos(angle); + const y = centerY + r * Math.sin(angle); + if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); + } + ctx.closePath(); + ctx.strokeStyle = color ?? "#78e8ff"; + ctx.stroke(); + } + else if (mode === tunnel) { + analyser.getByteFrequencyData(dataArray); + const avg = dataArray.reduce((a, b) => a + b) / dataArray.length; + + circles.forEach(c => { + c.r -= 2 + avg / 200; + if (c.r < 0) c.r = width; + ctx.beginPath(); + ctx.arc(centerX, centerY, c.r, 0, Math.PI * 2); + ctx.strokeStyle = `hsl(${c.r},100%,50%)`; + ctx.stroke(); + }); + } + else if (mode === constellationField) { + analyser.getByteFrequencyData(dataArray); + const avg = dataArray.reduce((a, b) => a + b) / dataArray.length; + + ctx.fillStyle = color ?? "#78e8ff"; + constellations.forEach(s => { + ctx.beginPath(); + ctx.arc(s.x, s.y, 2, 0, Math.PI * 2); + ctx.fill(); + }); + + if (avg > 100) { + for (let i = 0; i < stars.length; i++) { + for (let j = i + 1; j < stars.length; j++) { + if (Math.hypot(stars[i].x - stars[j].x, stars[i].y - stars[j].y) < 100) { + ctx.strokeStyle = "rgba(255,255,255,0.2)"; + ctx.beginPath(); + ctx.moveTo(stars[i].x, stars[i].y); + ctx.lineTo(stars[j].x, stars[j].y); + ctx.stroke(); + } + } + } + } + } + else if (mode === liquid) { + analyser.getByteTimeDomainData(dataArray); + ctx.beginPath(); + for (let i = 0; i < dataArray.length; i++) { + const x = (i / dataArray.length) * width; + const y = centerY + (dataArray[i] - 128); + if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); + } + ctx.strokeStyle = color ?? "#78e8ff"; + ctx.stroke(); + } + else if (mode === fractal) { + analyser.getByteFrequencyData(dataArray); + const avg = dataArray.reduce((a, b) => a + b) / dataArray.length; + drawBranch(width, height, avg / 2, -Math.PI / 2, 6, avg); + } + } + + draw(); + } +} + +async function dispose(id) { + if (_instances[id]) { + await _instances[id].audioCtx.close(); + delete _instances[id]; + } +} + +export const fluentCxAudioVisualizer = { + initialize: (id, audioElement, mode, cover, dotNetRef) => initialize(id, audioElement, mode, cover, dotNetRef), + dispose: (id) => dispose(id), + setMode: (id, mode) => setMode(id, mode) +} diff --git a/src/Community.Components/Components/Audio/CompactPlayer.razor b/src/Community.Components/Components/Audio/CompactPlayer.razor new file mode 100644 index 00000000..d7cd8692 --- /dev/null +++ b/src/Community.Components/Components/Audio/CompactPlayer.razor @@ -0,0 +1,31 @@ +@namespace FluentUI.Blazor.Community.Components +@inherits FluentComponentBase + +
+ + @if (!string.IsNullOrEmpty(CurrentTrack?.Cover)) + { + + } + +
+ + + + + + + +
+ +
+ +
+
diff --git a/src/Community.Components/Components/Audio/CompactPlayer.razor.cs b/src/Community.Components/Components/Audio/CompactPlayer.razor.cs new file mode 100644 index 00000000..dac81cb4 --- /dev/null +++ b/src/Community.Components/Components/Audio/CompactPlayer.razor.cs @@ -0,0 +1,125 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.FluentUI.AspNetCore.Components; +using Microsoft.FluentUI.AspNetCore.Components.Utilities; + +namespace FluentUI.Blazor.Community.Components; + +/// +/// Represents a compact audio player component that provides basic audio playback functionality. +/// +public sealed partial class CompactPlayer + : FluentComponentBase +{ + /// + /// Value indicating whether to show the playback controls. + /// + private bool _showControls; + + /// + /// Initializes a new instance of the class. + /// + public CompactPlayer() + { + Id = $"compact-player-{Identifier.NewId()}"; + } + + /// + /// Gets or sets the current audio track to be played. + /// + [Parameter] + public AudioTrackItem? CurrentTrack { get; set; } + + /// + /// Gets or sets the callback that is invoked when the play/pause button is clicked. + /// + [Parameter] + public EventCallback OnPlayPauseChanged { get; set; } + + /// + /// Gets or sets a value indicating whether the previous button is disabled. + /// + [Parameter] + public bool IsPreviousDisabled { get; set; } + + /// + /// Gets or sets the callback that is invoked when the previous button is clicked. + /// + [Parameter] + public EventCallback OnPrevious { get; set; } + + /// + /// Gets or sets the label for the previous button. + /// + [Parameter] + public string? PreviousLabel { get; set; } + + /// + /// Gets or sets a value indicating whether the next button is disabled. + /// + [Parameter] + public bool IsNextDisabled { get; set; } + + /// + /// Gets or sets the callback that is invoked when the next button is clicked. + /// + [Parameter] + public EventCallback OnNext { get; set; } + + /// + /// Gets or sets the label for the next button. + /// + [Parameter] + public string? NextLabel { get; set; } + + /// + /// Gets or sets the label for the play button. + /// + [Parameter] + public string? PlayLabel { get; set; } + + /// + /// Gets or sets the label for the pause button. + /// + [Parameter] + public string? PauseLabel { get; set; } + + /// + /// Gets or sets the total duration of the current track, in seconds. + /// + [Parameter] + public double Duration { get; set; } + + /// + /// Gets or sets the current playback time of the track, in seconds. + /// + [Parameter] + public double CurrentTime { get; set; } + + /// + /// Gets the style string that includes the opacity for the playback controls based on the current state. + /// + private string? InternalStyle => new StyleBuilder(Style) + .AddStyle("--compact-player-controls-opacity", _showControls ? "1" : "0") + .Build(); + + /// + /// Handles the pointer enter event by updating the component's state. + /// + /// The event arguments associated with the pointer enter event. + private void OnPointerEnter(PointerEventArgs e) + { + _showControls = true; + StateHasChanged(); + } + + /// + /// Handles the pointer leave event by updating the component's state. + /// + /// The event data associated with the pointer leave event. + private void OnPointerLeave(PointerEventArgs e) + { + _showControls = false; + StateHasChanged(); + } +} diff --git a/src/Community.Components/Components/Audio/CompactPlayer.razor.css b/src/Community.Components/Components/Audio/CompactPlayer.razor.css new file mode 100644 index 00000000..aaab077f --- /dev/null +++ b/src/Community.Components/Components/Audio/CompactPlayer.razor.css @@ -0,0 +1,33 @@ +.compact-player { + width: 400px; + height: 400px; + position: relative; + border-radius: 8px; +} + +.compact-player-image { + width: 100%; + height: 100%; + object-fit: cover; + position: absolute; + border-radius: 8px; + pointer-events: none; +} + +.compact-player-controls { + display: flex; + position: absolute; + width: 100%; + height: 100%; + align-items: center; + justify-content: center; + background-color: rgba(0, 0, 0, 0.3); + border-radius: 8px; + opacity: var(--compact-player-controls-opacity, 0); +} + +.compact-seek-bar { + position: absolute; + bottom: 0px; + width: 100%; +} diff --git a/src/Community.Components/Components/Audio/Controls/CompactSeekBar.razor b/src/Community.Components/Components/Audio/Controls/CompactSeekBar.razor new file mode 100644 index 00000000..f9158dab --- /dev/null +++ b/src/Community.Components/Components/Audio/Controls/CompactSeekBar.razor @@ -0,0 +1,16 @@ +@namespace FluentUI.Blazor.Community.Components +@inherits FluentComponentBase + +
+
+
+
+
diff --git a/src/Community.Components/Components/Audio/Controls/CompactSeekBar.razor.cs b/src/Community.Components/Components/Audio/Controls/CompactSeekBar.razor.cs new file mode 100644 index 00000000..aef21ff8 --- /dev/null +++ b/src/Community.Components/Components/Audio/Controls/CompactSeekBar.razor.cs @@ -0,0 +1,38 @@ +using System.Globalization; +using Microsoft.AspNetCore.Components; +using Microsoft.FluentUI.AspNetCore.Components; + +namespace FluentUI.Blazor.Community.Components; + +/// +/// Represents a compact seek bar component for media playback, providing functionality to display and interact with +/// playback progress. +/// +public partial class CompactSeekBar + : FluentComponentBase +{ + /// + /// Initializes a new instance of the class. + /// + public CompactSeekBar() + { + Id = $"compact-seekbar-{Identifier.NewId()}"; + } + + /// + /// Gets or sets the duration of the track, in seconds. + /// + [Parameter] + public double Duration { get; set; } + + /// + /// Gets or sets the current playback time of the media, in seconds. + /// + [Parameter] + public double CurrentTime { get; set; } + + /// + /// Gets the progression percentage of the current time relative to the duration. + /// + private string Progression => string.Format(CultureInfo.InvariantCulture, "{0}%", CurrentTime / Duration * 100); +} diff --git a/src/Community.Components/Components/Audio/Controls/CompactSeekBar.razor.css b/src/Community.Components/Components/Audio/Controls/CompactSeekBar.razor.css new file mode 100644 index 00000000..befd1ec4 --- /dev/null +++ b/src/Community.Components/Components/Audio/Controls/CompactSeekBar.razor.css @@ -0,0 +1,30 @@ +.compact-seekbar-container { + position: relative; + width: 100%; + height: 6px; + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; + background: linear-gradient(to right, #f3f2f1, #e1dfdd); + cursor: pointer; + transition: box-shadow 0.2s ease; +} + +.compact-seekbar-track { + position: absolute; + top: 0; + left: 0; + height: 100%; + width: 100%; + overflow: hidden; +} + +.compact-seekbar-progress { + position: absolute; + top: 0; + left: 0; + height: 100%; + background: var(--accent-fill-rest); + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; + transition: width 0.1s linear; +} diff --git a/src/Community.Components/Components/Audio/Controls/DownloadButton.razor b/src/Community.Components/Components/Audio/Controls/DownloadButton.razor new file mode 100644 index 00000000..aed6e772 --- /dev/null +++ b/src/Community.Components/Components/Audio/Controls/DownloadButton.razor @@ -0,0 +1,8 @@ +@namespace FluentUI.Blazor.Community.Components +@inherits FluentComponentBase + + diff --git a/src/Community.Components/Components/Audio/Controls/DownloadButton.razor.cs b/src/Community.Components/Components/Audio/Controls/DownloadButton.razor.cs new file mode 100644 index 00000000..4cd1dc8c --- /dev/null +++ b/src/Community.Components/Components/Audio/Controls/DownloadButton.razor.cs @@ -0,0 +1,50 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.FluentUI.AspNetCore.Components; + +namespace FluentUI.Blazor.Community.Components; + +/// +/// Represents a button component designed for initiating download actions. +/// +/// The component provides a customizable button with an optional label and +/// icon. It supports an event callback that is triggered when the button is clicked, allowing developers to handle +/// download-related logic in their applications. +public partial class DownloadButton : FluentComponentBase +{ + /// + /// Represents the icon to be displayed on the button. + /// + private static readonly Icon DownloadIcon = new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size24.ArrowDownload(); + + /// + /// Gets or sets the event callback that is invoked when the download button is clicked. + /// + [Parameter] + public EventCallback OnDownload { get; set; } + + /// + /// Gets or sets the label for the download button. + /// + [Parameter] + public string? DownloadLabel { get; set; } = "Download"; + + /// + /// Initializes a new instance of the class. + /// + public DownloadButton() + { + Id = $"download-button-{Identifier.NewId()}"; + } + + /// + /// Occurs when the download button is clicked. + /// + /// Returns a task which invokes the callback. + private async Task OnDownloadAsync() + { + if (OnDownload.HasDelegate) + { + await OnDownload.InvokeAsync(); + } + } +} diff --git a/src/Community.Components/Components/Audio/Controls/NextButton.razor b/src/Community.Components/Components/Audio/Controls/NextButton.razor new file mode 100644 index 00000000..0cea3b6d --- /dev/null +++ b/src/Community.Components/Components/Audio/Controls/NextButton.razor @@ -0,0 +1,9 @@ +@namespace FluentUI.Blazor.Community.Components +@inherits FluentComponentBase + + diff --git a/src/Community.Components/Components/Audio/Controls/NextButton.razor.cs b/src/Community.Components/Components/Audio/Controls/NextButton.razor.cs new file mode 100644 index 00000000..05e1e41c --- /dev/null +++ b/src/Community.Components/Components/Audio/Controls/NextButton.razor.cs @@ -0,0 +1,54 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.FluentUI.AspNetCore.Components; + +namespace FluentUI.Blazor.Community.Components; + +/// +/// Represents the previous button. +/// +public partial class NextButton + : FluentComponentBase +{ + /// + /// Represents the icon displayed when shuffling is disabled. + /// + private static readonly Icon NextIcon = new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size24.Next(); + + /// + /// Gets or sets the event callback that is invoked when the next button is clicked. + /// + [Parameter] + public EventCallback OnNext { get; set; } + + /// + /// Gets or sets the label for the next button. + /// + [Parameter] + public string? NextLabel { get; set; } = "Next"; + + /// + /// Gets or sets a value indicating whether the next button is disabled. + /// + [Parameter] + public bool IsDisabled { get; set; } + + /// + /// Initializes a new instance of the class. + /// + public NextButton() + { + Id = $"next-button-{Identifier.NewId()}"; + } + + /// + /// Occurs when the next button is clicked. + /// + /// Returns a task which invokes the callback. + private async Task OnNextAsync() + { + if (OnNext.HasDelegate) + { + await OnNext.InvokeAsync(); + } + } +} diff --git a/src/Community.Components/Components/Audio/Controls/PlayOrPauseButton.razor b/src/Community.Components/Components/Audio/Controls/PlayOrPauseButton.razor new file mode 100644 index 00000000..31b8fc06 --- /dev/null +++ b/src/Community.Components/Components/Audio/Controls/PlayOrPauseButton.razor @@ -0,0 +1,9 @@ +@namespace FluentUI.Blazor.Community.Components +@inherits FluentComponentBase + + diff --git a/src/Community.Components/Components/Audio/Controls/PlayOrPauseButton.razor.cs b/src/Community.Components/Components/Audio/Controls/PlayOrPauseButton.razor.cs new file mode 100644 index 00000000..08baed76 --- /dev/null +++ b/src/Community.Components/Components/Audio/Controls/PlayOrPauseButton.razor.cs @@ -0,0 +1,78 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.FluentUI.AspNetCore.Components; + +namespace FluentUI.Blazor.Community.Components; + +public partial class PlayOrPauseButton + : FluentComponentBase +{ + /// + /// Represents the icon displayed when shuffling is disabled. + /// + private static readonly Icon PlayIcon = new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size24.Play(); + + /// + /// Represents the icon displayed when shuffling is enabvled. + /// + private static readonly Icon PauseIcon = new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size24.Pause(); + + /// + /// Indicates whether the collection is currently being shuffled. + /// + private bool _isPlaying; + + /// + /// Gets or sets the event callback that is invoked when the play state changes. + /// + [Parameter] + public EventCallback OnPlayChanged { get; set; } + + /// + /// Gets or sets the label for the play button. + /// + [Parameter] + public string PlayLabel { get; set; } = "Play"; + + /// + /// Gets or sets the label for the pause button. + /// + [Parameter] + public string PauseLabel { get; set; } = "Pause"; + + /// + /// Gets the label for the current state of the button. + /// + private string PlayOrPauseLabel => _isPlaying ? PauseLabel ?? "Pause" : PlayLabel ?? "Play"; + + /// + /// Initializes a new instance of the class. + /// + public PlayOrPauseButton() + { + Id = $"play-pause-button-{Identifier.NewId()}"; + } + + /// + /// Occurs when the shuffle button is clicked. + /// + /// Returns a task which toggles on / off the shuffle + private async Task OnTogglePlayOrPauseAsync() + { + _isPlaying = !_isPlaying; + + if (OnPlayChanged.HasDelegate) + { + await OnPlayChanged.InvokeAsync(_isPlaying); + } + } + + /// + /// Sets the play/pause state of the button. + /// + /// Value indicating if the button is in playing state. + internal void SetPlayPauseState(bool isPlaying) + { + _isPlaying = isPlaying; + StateHasChanged(); + } +} diff --git a/src/Community.Components/Components/Audio/Controls/PlaylistButton.razor b/src/Community.Components/Components/Audio/Controls/PlaylistButton.razor new file mode 100644 index 00000000..9f26e140 --- /dev/null +++ b/src/Community.Components/Components/Audio/Controls/PlaylistButton.razor @@ -0,0 +1,8 @@ +@namespace FluentUI.Blazor.Community.Components +@inherits FluentComponentBase + + diff --git a/src/Community.Components/Components/Audio/Controls/PlaylistButton.razor.cs b/src/Community.Components/Components/Audio/Controls/PlaylistButton.razor.cs new file mode 100644 index 00000000..d56546f7 --- /dev/null +++ b/src/Community.Components/Components/Audio/Controls/PlaylistButton.razor.cs @@ -0,0 +1,55 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.FluentUI.AspNetCore.Components; + +namespace FluentUI.Blazor.Community.Components; + +/// +/// Represents the playlist button. +/// +public partial class PlaylistButton + : FluentComponentBase +{ + /// + /// Indicates whether the playlist should be displayed. + /// + private bool _showPlaylist; + + /// + /// Represents the icon displayed when shuffling is disabled. + /// + private static readonly Icon PlaylistIcon = new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size24.AppsList(); + + /// + /// Gets or sets the event callback that is invoked when the stop button is clicked. + /// + [Parameter] + public EventCallback OnPlaylist { get; set; } + + /// + /// Gets or sets the label for the previous button. + /// + [Parameter] + public string? PlaylistLabel { get; set; } = "Playlist"; + + /// + /// Initializes a new instance of the class. + /// + public PlaylistButton() + { + Id = $"playlist-button-{Identifier.NewId()}"; + } + + /// + /// Occurs when the playlist button is clicked. + /// + /// Returns a task which invokes the callback. + private async Task OnTogglePlaylistAsync() + { + _showPlaylist = !_showPlaylist; + + if (OnPlaylist.HasDelegate) + { + await OnPlaylist.InvokeAsync(_showPlaylist); + } + } +} diff --git a/src/Community.Components/Components/Audio/Controls/PreviousButton.razor b/src/Community.Components/Components/Audio/Controls/PreviousButton.razor new file mode 100644 index 00000000..84483a4a --- /dev/null +++ b/src/Community.Components/Components/Audio/Controls/PreviousButton.razor @@ -0,0 +1,9 @@ +@namespace FluentUI.Blazor.Community.Components +@inherits FluentComponentBase + + diff --git a/src/Community.Components/Components/Audio/Controls/PreviousButton.razor.cs b/src/Community.Components/Components/Audio/Controls/PreviousButton.razor.cs new file mode 100644 index 00000000..eced8ca1 --- /dev/null +++ b/src/Community.Components/Components/Audio/Controls/PreviousButton.razor.cs @@ -0,0 +1,57 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.FluentUI.AspNetCore.Components; + +namespace FluentUI.Blazor.Community.Components; + +/// +/// Represents the previous button. +/// +/// The component provides functionality to toggle between shuffle modes for +/// a collection. It displays an appropriate icon based on the current shuffle state and invokes a callback when the +/// shuffle state changes. +public partial class PreviousButton + : FluentComponentBase +{ + /// + /// Represents the icon displayed when shuffling is disabled. + /// + private static readonly Icon PreviousIcon = new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size24.Previous(); + + /// + /// Gets or sets the event callback that is invoked when the stop button is clicked. + /// + [Parameter] + public EventCallback OnPrevious { get; set; } + + /// + /// Gets or sets the label for the previous button. + /// + [Parameter] + public string? PreviousLabel { get; set; } = "Previous"; + + /// + /// Gets or sets a value indicating whether the previous button is disabled. + /// + [Parameter] + public bool IsDisabled { get; set; } + + /// + /// Initializes a new instance of the class. + /// + public PreviousButton() + { + Id = $"previous-button-{Identifier.NewId()}"; + } + + /// + /// Occurs when the previous button is clicked. + /// + /// Returns a task which invokes the callback. + private async Task OnPreviousAsync() + { + if (OnPrevious.HasDelegate) + { + await OnPrevious.InvokeAsync(); + } + } +} diff --git a/src/Community.Components/Components/Audio/Controls/RepeatButton.razor b/src/Community.Components/Components/Audio/Controls/RepeatButton.razor new file mode 100644 index 00000000..990ccc0d --- /dev/null +++ b/src/Community.Components/Components/Audio/Controls/RepeatButton.razor @@ -0,0 +1,8 @@ +@namespace FluentUI.Blazor.Community.Components +@inherits FluentComponentBase + + diff --git a/src/Community.Components/Components/Audio/Controls/RepeatButton.razor.cs b/src/Community.Components/Components/Audio/Controls/RepeatButton.razor.cs new file mode 100644 index 00000000..7ef7bd1d --- /dev/null +++ b/src/Community.Components/Components/Audio/Controls/RepeatButton.razor.cs @@ -0,0 +1,129 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Components; +using Microsoft.FluentUI.AspNetCore.Components; + +namespace FluentUI.Blazor.Community.Components; + +/// +/// Represents a button that allows users to cycle through different audio repeat modes. +/// +public partial class RepeatButton : FluentComponentBase +{ + /// + /// Represents the icon for the "Single Once" repeat mode. + /// + private static readonly Icon SingleOnceIcon = new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size24.ArrowRepeatAllOff(); + + /// + /// Represents the icon for the "Single Loop" repeat mode. + /// + private static readonly Icon SingleLoopIcon = new Microsoft.FluentUI.AspNetCore.Components.Icons.Filled.Size24.ArrowRepeat1(); + + /// + /// Represents the icon for the "Playlist Once" repeat mode. + /// + private static readonly Icon PlaylistOnceIcon = new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size24.ArrowRepeatAll(); + + /// + /// Represents the icon for the "Playlist Loop" repeat mode. + /// + private static readonly Icon PlaylistLoopIcon = new Microsoft.FluentUI.AspNetCore.Components.Icons.Filled.Size24.ArrowSync(); + + /// + /// Represents the current repeat mode of the audio player. + /// + private AudioRepeatMode _repeatMode = AudioRepeatMode.SingleOnce; + + /// + /// Initializes a new instance of the class. + /// + public RepeatButton() + { + Id = $"repeat-button-{Identifier.NewId()}"; + } + + /// + /// Gets the icon corresponding to the current repeat mode. + /// + private Icon RepeatIcon { get; set; } = SingleOnceIcon; + + /// + /// Gets or sets the event callback that is invoked when the repeat mode changes. + /// + [Parameter] + public EventCallback OnRepeatModeChanged { get; set; } + + /// + /// Gets or sets the label for the "Single Once" repeat mode. + /// + [Parameter] + public string SingleOnceLabel { get; set; } = "Play Single Once"; + + /// + /// Gets or sets the label for the "Single Loop" repeat mode. + /// + [Parameter] + public string SingleLoopLabel { get; set; } = "Repeat Single"; + + /// + /// Gets or sets the label for the "Playlist Once" repeat mode. + /// + [Parameter] + public string PlaylistOnceLabel { get; set; } = "Play Playlist Once"; + + /// + /// Gets or sets the label for the "Playlist Loop" repeat mode. + /// + [Parameter] + public string PlaylistLoopLabel { get; set; } = "Repeat Playlist"; + + /// + /// Gets the label corresponding to the current repeat mode. + /// + private string RepeatLabel => _repeatMode switch + { + AudioRepeatMode.SingleOnce => SingleOnceLabel ?? "Play Single Once", + AudioRepeatMode.SingleLoop => SingleLoopLabel ?? "Repeat Single", + AudioRepeatMode.PlaylistOnce => PlaylistOnceLabel ?? "Play Playlist Once", + AudioRepeatMode.PlaylistLoop => PlaylistLoopLabel ?? "Repeat Playlist", + _ => SingleOnceLabel ?? "Play Single Once" + }; + + /// + /// Cycles through the available audio repeat modes in a predefined order. + /// + /// The repeat modes transition in the following sequence: . After updating the repeat mode, the component's state is + /// refreshed. + private async Task OnChangeRepeatModeAsync() + { + _repeatMode = _repeatMode switch + { + AudioRepeatMode.SingleOnce => AudioRepeatMode.SingleLoop, + AudioRepeatMode.SingleLoop => AudioRepeatMode.PlaylistOnce, + AudioRepeatMode.PlaylistOnce => AudioRepeatMode.PlaylistLoop, + AudioRepeatMode.PlaylistLoop => AudioRepeatMode.SingleOnce, + _ => AudioRepeatMode.SingleOnce + }; + + RepeatIcon = _repeatMode switch + { + AudioRepeatMode.SingleOnce => SingleOnceIcon, + AudioRepeatMode.SingleLoop => SingleLoopIcon, + AudioRepeatMode.PlaylistOnce => PlaylistOnceIcon, + AudioRepeatMode.PlaylistLoop => PlaylistLoopIcon, + _ => SingleOnceIcon + }; + + if (OnRepeatModeChanged.HasDelegate) + { + await OnRepeatModeChanged.InvokeAsync(_repeatMode); + } + } +} diff --git a/src/Community.Components/Components/Audio/Controls/SeekBar.razor b/src/Community.Components/Components/Audio/Controls/SeekBar.razor new file mode 100644 index 00000000..88aaf29c --- /dev/null +++ b/src/Community.Components/Components/Audio/Controls/SeekBar.razor @@ -0,0 +1,57 @@ +@namespace FluentUI.Blazor.Community.Components +@inherits FluentComponentBase + + + + + @FormatTime(CurrentTime) + + +
+ +
+
+ + @foreach (var chapter in Chapters) + { +
+
+ } +
+ +
+ + @if (_showPreview) + { +
+ @if (Mode == MediaMode.Video && ThumbnailProvider is not null) + { +
+ +
+ } +
@FormatTime(_previewTime)
+
+ } +
+ + @FormatTime(Duration) +
diff --git a/src/Community.Components/Components/Audio/Controls/SeekBar.razor.cs b/src/Community.Components/Components/Audio/Controls/SeekBar.razor.cs new file mode 100644 index 00000000..a7d718da --- /dev/null +++ b/src/Community.Components/Components/Audio/Controls/SeekBar.razor.cs @@ -0,0 +1,460 @@ +using System.Globalization; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.FluentUI.AspNetCore.Components; +using Microsoft.JSInterop; + +namespace FluentUI.Blazor.Community.Components; + +/// +/// Represents a seek bar component for audio or video playback, allowing users to navigate through the media timeline. +/// +public partial class SeekBar : FluentComponentBase, IAsyncDisposable +{ + /// + /// Variable to track if the user is currently dragging the seek bar thumb. + /// + private bool _isDragging; + + /// + /// Represents a cancellation token source used for hiding the preview. + /// + private CancellationTokenSource? _cts; + + /// + /// Indicates whether the preview is displayed. + /// + private bool _showPreview; + + /// + /// Represents the preview time in seconds. + /// + private double _previewTime; + + /// + /// Represents the width of the container. + /// + private double _containerWidth = 1; + + /// + /// Represents a reference to a .NET object of type that can be passed to JavaScript interop. + /// + /// This field is used to enable JavaScript to invoke methods on the associated instance. It is a readonly field and should be initialized with a valid instance. + private readonly DotNetObjectReference _dotNetRef; + + /// + /// Represents a reference to a JavaScript module that can be used for invoking JavaScript functions. + /// + /// This field holds an instance of that represents a JavaScript + /// module. It is nullable, indicating that the module may not be initialized. Ensure the module is initialized + /// before attempting to use it. + private IJSObjectReference? _module; + + /// + /// Initializes a new instance of the class. + /// + public SeekBar() + { + Id = $"seekbar-{Identifier.NewId()}"; + _dotNetRef = DotNetObjectReference.Create(this); + } + + /// + /// Gets or sets the duration of the track, in seconds. + /// + [Parameter] + public double Duration { get; set; } + + /// + /// Gets or sets the current playback time of the media, in seconds. + /// + [Parameter] + public double CurrentTime { get; set; } + + /// + /// Gets or sets the collection of chapters associated with the current context. + /// + [Parameter] + public List Chapters { get; set; } = []; + + /// + /// Gets or sets the media mode for the seek bar. + /// + [Parameter] + public MediaMode Mode { get; set; } = MediaMode.Audio; + + /// + /// Gets or sets a function that generates a thumbnail URL based on a given timeline. + /// + [Parameter] + public Func? ThumbnailProvider { get; set; } + + /// + /// Gets or sets the step value used to increment or decrement the associated value. + /// + [Parameter] + public double Step { get; set; } = 5; + + /// + /// Gets or sets the callback that is invoked when a seek operation occurs. + /// + [Parameter] + public EventCallback OnSeek { get; set; } + + /// + /// Gets or sets the callback that is invoked when a seek operation starts. + /// + [Parameter] + public EventCallback OnSeekStart { get; set; } + + /// + /// Gets or sets the callback that is invoked when a seek operation is completed. + /// + [Parameter] + public EventCallback OnSeekEnd { get; set; } + + /// + /// Gets or sets the callback that is invoked when the chapter changes. + /// + [Parameter] + public EventCallback OnChapterChanged { get; set; } + + /// + /// Gets or sets the JavaScript runtime instance used for invoking JavaScript functions from .NET. + /// + [Inject] + private IJSRuntime JS { get; set; } = default!; + + /// + /// Gets the progression percentage of the current time relative to the duration. + /// + private string Progression => string.Format(CultureInfo.InvariantCulture, "{0}%", CurrentTime / Duration * 100); + + /// + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + _module = await JS.InvokeAsync("import", "./_content/FluentUI.Blazor.Community.Components/Components/Audio/Controls/SeekBar.razor.js"); + await _module.InvokeVoidAsync("fluentCxSeekBar.initialize", Id, _dotNetRef); + await MeasureWidthAsync(); + } + } + + /// + /// Invoked by JavaScript to handle resize events and update the component's state accordingly. + /// + /// This method is intended to be called from JavaScript via the with the identifier "onResize". It triggers an asynchronous operation to measure + /// the width of the component and update its state. + /// A representing the asynchronous operation. + [JSInvokable("onResize")] + public async Task OnResizeAsync() => await MeasureWidthAsync(); + + /// + /// Asynchronously measures the width of the container associated with the current instance. + /// + /// This method invokes a JavaScript function to retrieve the width of the container element + /// identified by the Id property. Ensure that the JavaScript module is properly initialized before calling + /// this method. + /// A representing the asynchronous operation. + private async Task MeasureWidthAsync() + { + if (_module is not null) + { + _containerWidth = await _module.InvokeAsync("fluentCxSeekBar.getWidth", Id); + } + } + + /// + /// Initiates a drag operation based on the specified pointer event. + /// + /// This method sets the internal dragging state and triggers the event + /// if it has subscribers. It also updates the seek position based on the pointer's client X-coordinate. + /// The pointer event arguments containing details about the pointer event, such as the client X-coordinate. + /// A task that represents the asynchronous operation. + private async Task StartDragAsync(PointerEventArgs e) + { + _isDragging = true; + + if (OnSeekStart.HasDelegate) + { + await OnSeekStart.InvokeAsync(CurrentTime); + } + + await UpdateSeekFromClientXAsync(e.ClientX); + } + + /// + /// Handles the drag event and updates the seek position based on the pointer's horizontal position. + /// + /// The pointer event arguments containing information about the drag event, including the pointer's position. + /// A task that represents the asynchronous operation. + private async Task OnDragAsync(PointerEventArgs e) + { + if (_isDragging) + { + await UpdateSeekFromClientXAsync(e.ClientX); + } + } + + /// + /// Ends the drag operation and triggers the seek end event if applicable. + /// + /// This method is called to finalize a drag operation. If the drag operation was active, it + /// stops the drag, hides the preview, and invokes the event with the current time + /// value. + /// The pointer event arguments associated with the drag operation. + /// + private async Task EndDragAsync(PointerEventArgs e) + { + if (_isDragging) + { + _isDragging = false; + _showPreview = false; + + if (OnSeekEnd.HasDelegate) + { + await OnSeekEnd.InvokeAsync(CurrentTime); + } + + await InvokeAsync(StateHasChanged); + } + } + + /// + /// Initiates the touch interaction for seeking functionality. + /// + /// This method sets the dragging state to active and triggers the seek start event with the + /// current time. It then updates the seek position based on the horizontal client coordinate of the first touch + /// point. + /// The touch event arguments containing details about the touch interaction. The first touch point is used to + /// determine the starting position. + /// A task that represents the asynchronous operation. + private async Task StartTouchAsync(TouchEventArgs e) + { + _isDragging = true; + await OnSeekStart.InvokeAsync(CurrentTime); + var touch = e.Touches[0]; + await UpdateSeekFromClientXAsync(touch.ClientX); + } + + /// + /// Handles the touch move event and updates the seek position based on the touch's client X-coordinate. + /// + /// This method is invoked during a touch move gesture when dragging is active. It processes the + /// first touch point and updates the seek position accordingly. Ensure that dragging is enabled before invoking + /// this method. + /// The touch event arguments containing information about the touch gesture. + /// A task that represents the asynchronous operation. + private async Task OnTouchMoveAsync(TouchEventArgs e) + { + if (_isDragging) + { + var touch = e.Touches[0]; + await UpdateSeekFromClientXAsync(touch.ClientX); + } + } + + /// + /// Ends the current touch interaction, finalizing any ongoing drag operation. + /// + /// This method is typically called when a touch interaction is completed. If a drag operation + /// was in progress, it stops the drag and hides the preview. Additionally, it triggers the + /// event with the current time value. + /// The containing details about the touch event. + /// + private async Task EndTouchAsync(TouchEventArgs e) + { + if (_isDragging) + { + _isDragging = false; + _showPreview = false; + await OnSeekEnd.InvokeAsync(CurrentTime); + + await InvokeAsync(StateHasChanged); + } + } + + /// + /// Handles key press events to perform seek operations in a media playback context. + /// + /// This method processes specific key inputs to adjust the playback position: seeks backward by a predefined + /// step. seeks forward by a predefined + /// step. seeks to the beginning of the + /// media. seeks to the end of the + /// media. + /// The key event arguments containing information about the key pressed. + /// A task that represents the asynchronous operation. + private async Task OnKeyDownAsync(FluentKeyCodeEventArgs e) + { + switch(e.Key) + { + case KeyCode.Left: + await SeekToAsync(CurrentTime - Step); + break; + + case KeyCode.Right: + await SeekToAsync(CurrentTime + Step); + break; + + case KeyCode.Home: + await SeekToAsync(0); + break; + + case KeyCode.End: + await SeekToAsync(Duration); + break; + } + } + + /// + /// Updates the seek position of the media playback based on the specified horizontal offset. + /// + /// The method calculates the seek position as a percentage of the total duration based on the + /// provided offset and the width of the container. The seek operation is performed asynchronously and may include a + /// preview of the media at the calculated position. + /// The horizontal offset, in pixels, used to calculate the new seek position. Must be a value between 0 and the + /// width of the container. + /// A task that represents the asynchronous operation. + private async Task UpdateSeekAsync(double offsetX) + { + var percent = Math.Clamp(offsetX / _containerWidth, 0, 1); + await SeekToAsync(percent * Duration, preview: true); + } + + /// + /// Updates the seek position based on the specified client X-coordinate. + /// + /// This method calculates the offset relative to the seek bar using the provided client + /// X-coordinate and updates the seek position accordingly. If the module is not initialized, the method exits + /// without performing any action. + /// The X-coordinate, in pixels, from the client that determines the seek position. + /// A task that represents the asynchronous operation. + private async Task UpdateSeekFromClientXAsync(double clientX) + { + if (_module is null) + { + return; + } + + var offset = await _module.InvokeAsync("fluentCxSeekBar.getOffsetX", Id, clientX); + await UpdateSeekAsync(offset); + } + + /// + /// Seeks to the specified time within the media and optionally enables preview mode. + /// + /// This method updates the current playback time and triggers the event if + /// it has subscribers. If the seek operation results in a chapter change, the event + /// is also triggered. + /// The target time, in seconds, to seek to. The value is clamped between 0 and the total duration of the media. + /// A boolean value indicating whether to enable preview mode. If , the preview state is + /// updated without committing the seek operation. + /// A task that represents the asynchronous operation. + private async Task SeekToAsync(double time, bool preview = false) + { + CurrentTime = Math.Clamp(time, 0, Duration); + _previewTime = CurrentTime; + _showPreview = preview; + + _cts?.Cancel(); + _cts = new CancellationTokenSource(); + + if (OnSeek.HasDelegate) + { + await OnSeek.InvokeAsync(CurrentTime); + } + + var chapter = Chapters.FirstOrDefault(c => c.Start <= CurrentTime && CurrentTime < c.End); + + if (chapter is not null && OnChapterChanged.HasDelegate) + { + await OnChapterChanged.InvokeAsync(chapter); + } + + _ = HidePreviewAfterDelayAsync(_cts.Token); + } + + /// + /// Hides the preview after a delay of 2 seconds. + /// + /// If the operation is canceled via the provided , the preview will not + /// be hidden. + /// A that can be used to cancel the delay operation. + /// A representing the asynchronous operation. + private async Task HidePreviewAfterDelayAsync(CancellationToken token) + { + try + { + await Task.Delay(2000, token); + _showPreview = false; + await InvokeAsync(StateHasChanged); + } + catch (TaskCanceledException) + { + } + } + + /// + /// Handles the click event on the seek bar and updates the seek position based on the mouse click location. + /// + /// This method does not perform any action if a drag operation is currently in + /// progress. + /// The containing information about the mouse click event, including the client + /// X-coordinate. + /// A representing the asynchronous operation. + private async Task OnClickSeek(MouseEventArgs e) + { + if (_isDragging) + { + return; + } + + await UpdateSeekFromClientXAsync(e.ClientX); + } + + /// + /// Determines the color representation for a chapter based on its status. + /// + /// The status of the chapter. Must be one of the enumeration values. + /// A CSS linear-gradient string representing the color associated with the specified chapter status. Returns "gray" + /// if the status is not recognized. + private static string GetChapterColor(ChapterStatus status) => status switch + { + ChapterStatus.Completed => "linear-gradient(90deg, #87d068, #5cb85c)", + ChapterStatus.Current => "linear-gradient(90deg, #ffd773, #ffb900)", + ChapterStatus.NotStarted => "linear-gradient(90deg, #ff8a8a, #e81123)", + _ => "gray" + }; + + /// + /// Formats a time value in seconds into a string representation in the format "mm:ss". + /// + /// Number of elapsed seconds. + /// Returns the formatted value. + private static string FormatTime(double seconds) => TimeSpan.FromSeconds(seconds).ToString(@"mm\:ss", CultureInfo.InvariantCulture); + + /// + public async ValueTask DisposeAsync() + { + try + { + _dotNetRef.Dispose(); + + if (_module is not null) + { + await _module.InvokeVoidAsync("fluentCxSeekBar.dispose", Id); + await _module.DisposeAsync(); + } + } + catch(JSDisconnectedException) + { + } + + GC.SuppressFinalize(this); + } +} diff --git a/src/Community.Components/Components/Audio/Controls/SeekBar.razor.css b/src/Community.Components/Components/Audio/Controls/SeekBar.razor.css new file mode 100644 index 00000000..b22736e4 --- /dev/null +++ b/src/Community.Components/Components/Audio/Controls/SeekBar.razor.css @@ -0,0 +1,134 @@ +@keyframes pulse { + 0% { + transform: translate(-50%, -50%) scale(1); + } + + 50% { + transform: translate(-50%, -50%) scale(1.3); + } + + 100% { + transform: translate(-50%, -50%) scale(1); + } +} + +.seekbar-container { + position: relative; + width: 100%; + height: 6px; + background: linear-gradient(to right, #f3f2f1, #e1dfdd); + border-radius: 6px; + cursor: pointer; + transition: box-shadow 0.2s ease; +} + +.seekbar-container:focus { + outline: none; + box-shadow: 0 0 0 3px rgba(0,120,212,0.6); +} + +.seekbar-track { + position: absolute; + top: 0; + left: 0; + height: 100%; + width: 100%; + border-radius: 6px; + overflow: hidden; +} + +.seekbar-chapter { + position: absolute; + top: 0; + height: 100%; + border-radius: 0; + opacity: 0.85; +} + +.seekbar-progress { + position: absolute; + top: 0; + left: 0; + height: 100%; + background: var(--accent-fill-rest); + border-radius: 3px; + transition: width 0.1s linear; +} + +.seekbar-handle { + position: absolute; + top: 50%; + transform: translate(-50%, -50%); + width: 18px; + height: 18px; + background: white; + border-radius: 50%; + box-shadow: 0 2px 6px rgba(0,0,0,0.3); + transition: transform 0.15s ease, background 0.2s ease; + cursor: pointer; +} + +.seekbar-handle::after { + content: ""; + position: absolute; + top: 50%; + left: 50%; + width: 10px; + height: 10px; + background: var(--accent-fill-rest); + border-radius: 50%; + transform: translate(-50%, -50%); + transition: transform 0.15s ease, background 0.2s ease; + } + +.seekbar-handle:hover { + box-shadow: 0 0 10px var(--accent-fill-rest), 0 0 20px var(--accent-fill-rest), 0 2px 6px rgba(0,0,0,0.3); +} + +.seekbar-handle:hover::after { + animation: pulse 0.8s infinite ease-in-out; +} + +.seekbar-handle:active { + transform: translate(-50%, -50%) scale(1.2); + box-shadow: 0 0 12px var(--accent-fill-rest), 0 0 24px var(--accent-fill-rest), 0 2px 6px rgba(0,0,0,0.3); +} + +.seekbar-handle:active::after { + animation: none; + transform: translate(-50%, -50%) scale(1.4); +} + +.seekbar-preview { + position: absolute; + top: -40px; + transform: translateX(-50%); + text-align: center; + pointer-events: none; +} + +.thumbnail-box { + width: 140px; + height: 90px; + border-radius: 10px; + overflow: hidden; + background: #000; + margin-bottom: 6px; + box-shadow: 0 4px 12px rgba(0,0,0,0.4); +} + + .thumbnail-box img { + width: 100%; + height: 100%; + object-fit: cover; + } + +.time-label { + display: inline-block; + background: rgba(0,0,0,0.75); + color: #fff; + font-size: 12px; + padding: 3px 8px; + border-radius: 4px; + font-family: "Segoe UI", sans-serif; +} diff --git a/src/Community.Components/Components/Audio/Controls/SeekBar.razor.js b/src/Community.Components/Components/Audio/Controls/SeekBar.razor.js new file mode 100644 index 00000000..7cf5c912 --- /dev/null +++ b/src/Community.Components/Components/Audio/Controls/SeekBar.razor.js @@ -0,0 +1,45 @@ +const _instances = {}; + +function getWidth(id) { + if (!_instances[id] || !_instances[id].element) return 1; + + return _instances[id].element.getBoundingClientRect().width; +} + +function initialize(id, dotnetHelper) { + _instances[id] = { + element: document.getElementById(id), + dotnetHelper: dotnetHelper, + ro: new ResizeObserver((entries) => { + for (let entry of entries) { + if (entry.target.id === id) { + dotnetHelper.invokeMethodAsync("onResize", entry.contentRect.width); + } + } + }) + }; + + _instances[id].ro.observe(_instances[id].element); +} + +function getOffsetX(id, clientX) { + if (!_instances[id] || !_instances[id].element) return 0; + + const rect = _instances[id].element.getBoundingClientRect(); + return clientX - rect.left; +} + +function dispose(id) { + if (_instances[id]) { + _instances[id].ro.disconnect(); + _instances[id].dotnetHelper.dispose(); + delete _instances[id]; + } +} + +export const fluentCxSeekBar = { + getWidth: (id) => getWidth(id), + initialize: (id, dotnetHelper) => initialize(id, dotnetHelper), + getOffsetX: (id, clientX) => getOffsetX(id, clientX), + dispose: (id) => dispose(id) +}; diff --git a/src/Community.Components/Components/Audio/Controls/ShuffleButton.razor b/src/Community.Components/Components/Audio/Controls/ShuffleButton.razor new file mode 100644 index 00000000..0a6685ff --- /dev/null +++ b/src/Community.Components/Components/Audio/Controls/ShuffleButton.razor @@ -0,0 +1,7 @@ +@namespace FluentUI.Blazor.Community.Components +@inherits FluentComponentBase + + diff --git a/src/Community.Components/Components/Audio/Controls/ShuffleButton.razor.cs b/src/Community.Components/Components/Audio/Controls/ShuffleButton.razor.cs new file mode 100644 index 00000000..963a8d35 --- /dev/null +++ b/src/Community.Components/Components/Audio/Controls/ShuffleButton.razor.cs @@ -0,0 +1,57 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.FluentUI.AspNetCore.Components; + +namespace FluentUI.Blazor.Community.Components; + +public partial class ShuffleButton + : FluentComponentBase +{ + /// + /// Represents the icon displayed when shuffling is disabled. + /// + private static readonly Icon ShuffleOffIcon = new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size24.ArrowShuffleOff(); + + /// + /// Represents the icon displayed when shuffling is enabvled. + /// + private static readonly Icon ShuffleOnIcon = new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size24.ArrowShuffle(); + + /// + /// Indicates whether the collection is currently being shuffled. + /// + private bool _isShuffling; + + /// + /// Gets or sets the event callback that is invoked when the shuffle state changes. + /// + [Parameter] + public EventCallback OnShuffleChanged { get; set; } + + /// + /// Gets or sets the label for the shuffle button. + /// + [Parameter] + public string? ShuffleLabel { get; set; } = "Shuffle"; + + /// + /// Initializes a new instance of the class. + /// + public ShuffleButton() + { + Id = $"shuffle-button-{Identifier.NewId()}"; + } + + /// + /// Occurs when the shuffle button is clicked. + /// + /// Returns a task which toggles on / off the shuffle + private async Task OnToggleShuffleAsync() + { + _isShuffling = !_isShuffling; + + if (OnShuffleChanged.HasDelegate) + { + await OnShuffleChanged.InvokeAsync(_isShuffling); + } + } +} diff --git a/src/Community.Components/Components/Audio/Controls/StopButton.razor b/src/Community.Components/Components/Audio/Controls/StopButton.razor new file mode 100644 index 00000000..1977a110 --- /dev/null +++ b/src/Community.Components/Components/Audio/Controls/StopButton.razor @@ -0,0 +1,8 @@ +@namespace FluentUI.Blazor.Community.Components +@inherits FluentComponentBase + + diff --git a/src/Community.Components/Components/Audio/Controls/StopButton.razor.cs b/src/Community.Components/Components/Audio/Controls/StopButton.razor.cs new file mode 100644 index 00000000..a75be7c5 --- /dev/null +++ b/src/Community.Components/Components/Audio/Controls/StopButton.razor.cs @@ -0,0 +1,48 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.FluentUI.AspNetCore.Components; + +namespace FluentUI.Blazor.Community.Components; + +/// +/// Represents the stop button. +/// +public partial class StopButton + : FluentComponentBase +{ + /// + /// Represents the icon displaye. + /// + private static readonly Icon StopIcon = new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size24.Stop(); + + /// + /// Gets or sets the event callback that is invoked when the stop button is clicked. + /// + [Parameter] + public EventCallback OnStop { get; set; } + + /// + /// Gets or sets the label for the stop button. + /// + [Parameter] + public string? StopLabel { get; set; } = "Stop"; + + /// + /// Initializes a new instance of the class. + /// + public StopButton() + { + Id = $"stop-button-{Identifier.NewId()}"; + } + + /// + /// Occurs when the stop button is clicked. + /// + /// Returns a task which invokes the callback. + private async Task OnStopAsync() + { + if (OnStop.HasDelegate) + { + await OnStop.InvokeAsync(); + } + } +} diff --git a/src/Community.Components/Components/Audio/Controls/VolumeButton.razor b/src/Community.Components/Components/Audio/Controls/VolumeButton.razor new file mode 100644 index 00000000..d71b62f4 --- /dev/null +++ b/src/Community.Components/Components/Audio/Controls/VolumeButton.razor @@ -0,0 +1,30 @@ +@namespace FluentUI.Blazor.Community.Components +@inherits FluentComponentBase + + + + + + + + + + + + + diff --git a/src/Community.Components/Components/Audio/Controls/VolumeButton.razor.cs b/src/Community.Components/Components/Audio/Controls/VolumeButton.razor.cs new file mode 100644 index 00000000..31a4b916 --- /dev/null +++ b/src/Community.Components/Components/Audio/Controls/VolumeButton.razor.cs @@ -0,0 +1,91 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.FluentUI.AspNetCore.Components; + +namespace FluentUI.Blazor.Community.Components; + +/// +/// Represents a button to control the volume of an audio player. +/// +public partial class VolumeButton + : FluentComponentBase +{ + /// + /// Represents an icon for a speaker with zero volume. + /// + /// This icon can be used to visually indicate a muted or silent state in the user + /// interface. + private static readonly Icon VolumeZero = new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size24.Speaker0(); + + /// + /// Represents an icon for a speaker with one sound wave, typically used to indicate low volume. + /// + private static readonly Icon VolumeOne = new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size24.Speaker1(); + + /// + /// Represents an icon for a speaker with two sound waves, typically used to indicate medium volume. + /// + private static readonly Icon VolumeTwo = new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size24.Speaker2(); + + /// + /// Represents whether the volume popover is open. + /// + private bool _isVolumePopoverOpen; + + /// + /// Represents the volume level, where 1.0 is the default maximum value. + /// + private double _volume = 1.0; + + /// + /// Initializes a new instance of the class. + /// + public VolumeButton() + { + Id = $"volume-button-{Identifier.NewId()}"; + } + + /// + /// Gets the appropriate volume icon based on the current volume level. + /// + private Icon VolumeIcon => _volume switch + { + > 0.5 => VolumeTwo, + > 0.0 => VolumeOne, + _ => VolumeZero + }; + + /// + /// Gets or sets the callback that is invoked when the volume changes. + /// + [Parameter] + public EventCallback OnVolumeChanged { get; set; } + + /// + /// Gets or sets the label for the volume button. + /// + [Parameter] + public string VolumeLabel { get; set; } = "Volume"; + + /// + /// Gets the ARIA label for the volume button, combining the volume label and the current volume percentage. + /// + private string AriaLabel => $"{VolumeLabel} ({_volume * 100:0}%)"; + + /// + /// Handles the volume change event asynchronously. + /// + /// This method updates the internal volume state and invokes the + /// event if it has subscribers. + /// The new volume level. Must be a value between 0.0 and 1.0, where 0.0 represents mute and 1.0 represents the + /// maximum volume. + /// + private async Task OnVolumeChangedAsync(double value) + { + _volume = value; + + if (OnVolumeChanged.HasDelegate) + { + await OnVolumeChanged.InvokeAsync(value); + } + } +} diff --git a/src/Community.Components/Components/Audio/FluentCxAudio.razor b/src/Community.Components/Components/Audio/FluentCxAudio.razor new file mode 100644 index 00000000..43049fc6 --- /dev/null +++ b/src/Community.Components/Components/Audio/FluentCxAudio.razor @@ -0,0 +1,94 @@ +@namespace FluentUI.Blazor.Community.Components +@inherits FluentComponentBase + + +
+ @ChildContent + + @if (View == AudioPlayerView.Default) + { + + @if (_showPlaylist) + { + + + + } + + + + + } + else if (View == AudioPlayerView.Compact || View == AudioPlayerView.Floating) + { + var viewClass = View == AudioPlayerView.Compact ? "compact" : "floating-player"; + +
+
diff --git a/src/Community.Components/Components/Audio/FluentCxAudio.razor.cs b/src/Community.Components/Components/Audio/FluentCxAudio.razor.cs new file mode 100644 index 00000000..b42c483b --- /dev/null +++ b/src/Community.Components/Components/Audio/FluentCxAudio.razor.cs @@ -0,0 +1,586 @@ +using System.Drawing; +using System.Globalization; +using FluentUI.Blazor.Community.Extensions; +using Microsoft.AspNetCore.Components; +using Microsoft.FluentUI.AspNetCore.Components; +using Microsoft.JSInterop; + +namespace FluentUI.Blazor.Community.Components; + +/// +/// Represents a full-featured audio player component with various playback and visualization options. +/// +public sealed partial class FluentCxAudio + : FluentComponentBase +{ + /// + /// Represents the reference to the HTML audio element. + /// + private Audio? _audioReference; + + /// + /// Represents the height of the container element. + /// + private string? _containerHeight; + + /// + /// Value indicating whether the view has changed. + /// + private bool _hasViewChanged; + + /// + /// Represents the playlist of audio tracks. + /// + private readonly List _originalPlaylist = []; + + /// + /// Represents the shuffled version of the playlist. + /// + private readonly List _shuffledPlaylist = []; + + /// + /// Represents whether to display the playlist. + /// + private bool _showPlaylist; + + /// + /// Represents the .NET object reference for JavaScript interop. + /// + private readonly DotNetObjectReference _dotNetRef; + + /// + /// Represents whether the audio player is in shuffle mode. + /// + private bool _isShuffling; + + /// + /// Represents the audio controls component. + /// + private AudioControls? _audioControls; + + /// + /// Reference to the audio visualizer component. + /// + private AudioVisualizer? _audioVisualizer; + + /// + /// Represents the index of the current track in the playlist. + /// + private int _currentTrackIndex = -1; + + /// + /// The relative path to the JavaScript file used by the FluentCxAudio component. + /// + private const string JavaScriptFile = "./_content/FluentUI.Blazor.Community.Components/Components/Audio/FluentCxAudio.razor.js"; + + /// + /// The JavaScript module reference for interop calls. + /// + private IJSObjectReference? _module; + + /// + /// Represents the volume level, where 1.0 is the default maximum value. + /// + private double _volume = 1.0; + + /// + /// Represents the current time value. + /// + private double _currentTime; + + /// + /// Represents the duration of the audio file. + /// + private double _duration; + + /// + /// Represents the repeat mode of the audio player. + /// + private AudioRepeatMode _repeatMode; + + /// + /// Initializes a new instance of the class. + /// + public FluentCxAudio() + { + Id = Identifier.NewId(); + _dotNetRef = DotNetObjectReference.Create(this); + } + + /// + /// Gets a value indicating whether the previous button should be disabled. + /// + private bool IsPreviousDisabled + { + get + { + if (_originalPlaylist.Count == 0) + { + return true; + } + + if (_repeatMode == AudioRepeatMode.PlaylistOnce) + { + return _currentTrackIndex == 0; + } + + return _repeatMode == AudioRepeatMode.SingleLoop || _repeatMode == AudioRepeatMode.SingleOnce; + } + } + + /// + /// Gets a value indicating whether the next button should be disabled. + /// + private bool IsNextDisabled + { + get + { + if (_originalPlaylist.Count == 0) + { + return true; + } + + if (_repeatMode == AudioRepeatMode.PlaylistOnce) + { + return _currentTrackIndex == _originalPlaylist.Count - 1; + } + + return _repeatMode == AudioRepeatMode.SingleLoop || _repeatMode == AudioRepeatMode.SingleOnce; + } + } + + /// + /// Gets or sets the child content to be rendered inside the audio player component. + /// + [Parameter] + public RenderFragment? ChildContent { get; set; } + + /// + /// Gets the current audio track to be played. + /// + private AudioTrackItem? CurrentTrack => _originalPlaylist.Count > 0 && _currentTrackIndex >= 0 && _currentTrackIndex < _originalPlaylist.Count ? _originalPlaylist[_currentTrackIndex] : null; + + /// + /// Gets or sets the render mode of the audio player. + /// + [Parameter] + public AudioPlayerView View { get; set; } = AudioPlayerView.Default; + + /// + /// Gets or sets the visualizer mode. + /// + [Parameter] + public VisualizerMode VisualizerMode { get; set; } = VisualizerMode.Vortex; + + /// + /// Gets or sets a value indicating whether to display the audio visualizer. + /// + [Parameter] + public bool ShowVisualizer { get; set; } = true; + + /// + /// Gets or sets a value indicating whether the download button is visible. + /// + [Parameter] + public bool IsDownloadVisible { get; set; } = true; + + /// + /// Gets or sets the JavaScript runtime for interop calls. + /// + [Inject] + private IJSRuntime JSRuntime { get; set; } = default!; + + /// + /// Occurs when the download button is clicked. + /// + /// Returns a task which downloads the track. + private async Task OnDownloadAsync() + { + if (CurrentTrack is not null && + _module is not null) + { + await _module.InvokeVoidAsync("fluentCxAudio.download", CurrentTrack.Source, CurrentTrack.Title); + } + } + + /// + /// Toggles the visibility of the playlist. + /// + /// A boolean value indicating whether the playlist should be shown. to show the playlist; + /// otherwise, . + private void OnPlaylistToogled(bool value) + { + _showPlaylist = value; + StateHasChanged(); + } + + /// + /// Sets the audio source and volume for the current track asynchronously. + /// + /// + private async Task SetAudioSourceAsync() + { + if (_module is not null && + CurrentTrack is not null) + { + await _module.InvokeVoidAsync("fluentCxAudio.setAudioSource", _audioReference!.Element, CurrentTrack.Source); + await _module.InvokeVoidAsync("fluentCxAudio.setVolume", _audioReference!.Element, _volume); + } + } + + /// + /// Plays the audio associated with the current instance asynchronously. + /// + /// + private async Task PlayAsync() + { + if (_module is not null) + { + await _module.InvokeVoidAsync("fluentCxAudio.play", _audioReference!.Element); + } + } + + /// + /// Pauses the audio playback associated with the current instance. + /// + /// This method invokes a JavaScript function to pause the audio playback. Ensure that the + /// associated module is initialized before calling this method. + /// + private async Task OnPauseAsync() + { + if (_module is not null) + { + await _module.InvokeVoidAsync("fluentCxAudio.pause", _audioReference!.Element); + } + } + + /// + /// Toggles the playback state of the audio element between play and pause. + /// + /// A task that represents the asynchronous operation. + private async Task OnTogglePlayPauseAsync() + { + if (_module is not null) + { + await _module.InvokeVoidAsync("fluentCxAudio.togglePlayPause", _audioReference!.Element); + } + } + + /// + /// Skips to the previous track in the playlist and starts playback. + /// + /// A task that represents the asynchronous operation. + private async Task OnPreviousTrackAsync() + { + if (_originalPlaylist.Count == 0) + { + return; + } + + switch (_repeatMode) + { + case AudioRepeatMode.SingleOnce: + case AudioRepeatMode.SingleLoop: + + break; + + case AudioRepeatMode.PlaylistOnce: + { + if (_currentTrackIndex > 0) + { + _currentTrackIndex--; + } + else + { + return; + } + } + break; + + case AudioRepeatMode.PlaylistLoop: + _currentTrackIndex = (_currentTrackIndex - 1 + _originalPlaylist.Count) % _originalPlaylist.Count; + break; + } + + await SetAudioSourceAsync(); + await PlayAsync(); + } + + /// + /// Advances to the next track in the playlist and begins playback. + /// + /// A task that represents the asynchronous operation of advancing to the next track and starting playback. + private async Task OnNextTrackAsync() + { + if (_originalPlaylist.Count == 0) + { + return; + } + + switch (_repeatMode) + { + case AudioRepeatMode.PlaylistOnce: + if (_currentTrackIndex < _originalPlaylist.Count - 1) + { + _currentTrackIndex++; + } + else + { + return; + } + break; + + case AudioRepeatMode.PlaylistLoop: + _currentTrackIndex = (_currentTrackIndex + 1) % _originalPlaylist.Count; + break; + } + + await SetAudioSourceAsync(); + await PlayAsync(); + } + + /// + /// Handles the seek operation by updating the current playback time and invoking the corresponding JavaScript + /// function. + /// + /// + private async Task OnSeekAsync(double e) + { + if (_module is not null) + { + _currentTime = e; + await _module.InvokeVoidAsync("fluentCxAudio.seek", _audioReference!.Element, _currentTime); + } + } + + /// + /// Asynchronously updates the volume level and notifies the associated audio module. + /// + /// The new volume level to set. Must be a value between 0.0 and 1.0, where 0.0 represents mute and 1.0 represents + /// the maximum volume. + /// A task that represents the asynchronous operation. + private async Task OnChangeVolumeAsync(double value) + { + _volume = value; + + if (_module is not null) + { + await _module.InvokeVoidAsync("fluentCxAudio.setVolume", _audioReference!.Element, value); + } + } + + /// + /// Seeks to the specified position in the audio track and resumes playback. + /// + /// This method invokes a JavaScript function to perform the seek operation. Ensure that the + /// audio module is initialized before calling this method. If the module is not initialized, the method will have + /// no effect. + /// The position, in seconds, to seek to within the audio track. Must be a non-negative value. + /// + private async Task OnSeekEndAsync(double value) + { + if (_module is not null) + { + await _module.InvokeVoidAsync("fluentCxAudio.seekAndResume", _audioReference!.Element, value); + } + } + + /// + /// Plays the specified audio track by setting it as the current track and starting playback. + /// + /// If the specified track exists in the playlist, it is set as the current track, and playback + /// begins. If the track is not found in the playlist, no action is taken. + /// The audio track to play. Must be an item in the current playlist. + /// Returns a task which plays the track. + private async Task OnPlayTrackAsync(AudioTrackItem track) + { + var idx = _originalPlaylist.IndexOf(track); + + if (idx >= 0) + { + _audioControls?.SetPlayPauseState(true); + _currentTrackIndex = idx; + await SetAudioSourceAsync(); + await PlayAsync(); + } + } + + /// + /// Stops the audio playback and resets the internal playback state. + /// + /// Returns a task which stops the audio playback. + private async Task OnStopAsync() + { + _currentTime = 0; + + if (_module is not null) + { + await _module.InvokeVoidAsync("fluentCxAudio.stop", _audioReference!.Element); + } + + await InvokeAsync(StateHasChanged); + } + + /// + /// Invoked when the shuffle mode is toggled. + /// + /// Value indicating if the list must be shuffled or not. + private void OnToggleShuffle(bool shuffling) + { + _isShuffling = shuffling; + + if (_isShuffling) + { + _shuffledPlaylist.Clear(); + var list = new List(_originalPlaylist); + + for (var i = list.Count - 1; i > 0; i--) + { + var j = Random.Shared.Next(i + 1); + _shuffledPlaylist.Add(list[j]); + list.RemoveAt(j); + } + } + } + + /// + /// Invoked when a media track ends. This method is called via JavaScript interop. + /// + /// + [JSInvokable("onTrackEnded")] + public async Task OnTrackEndedAsync() + { + switch (_repeatMode) + { + case AudioRepeatMode.SingleOnce: + { + _audioControls?.SetPlayPauseState(false); + await OnStopAsync(); + } + + break; + + case AudioRepeatMode.SingleLoop: + { + await SetAudioSourceAsync(); + await PlayAsync(); + } + + break; + + case AudioRepeatMode.PlaylistOnce: + if (_currentTrackIndex < _originalPlaylist.Count - 1) + { + await OnNextTrackAsync(); + } + else + { + _audioControls?.SetPlayPauseState(false); + await OnStopAsync(); + } + + break; + + case AudioRepeatMode.PlaylistLoop: + { + await OnNextTrackAsync(); + } + + break; + } + } + + /// + /// Sets the duration of the audio playback. + /// + /// + [JSInvokable("setDuration")] + public void SetDuration(double duration) + { + _duration = duration; + StateHasChanged(); + } + + /// + /// Sets the current of the audio playback. + /// + /// + [JSInvokable("setSeek")] + public void SetSeek(double value) + { + _currentTime = value; + StateHasChanged(); + } + + /// + /// Updates the current elapsed time with the specified value. + /// + /// This method updates the internal state to reflect the provided elapsed time and triggers a + /// re-render of the component. It can be invoked from JavaScript using the identifier + /// "updateElapsedTime". + /// The new elapsed time, in seconds. + [JSInvokable("updateElapsedTime")] + public void UpdateElapsedTime(double value) + { + _currentTime = value; + StateHasChanged(); + } + + /// + /// Adds an audio track to the playlist. + /// + /// Audio track to add. + internal void AddTrack(AudioTrackItem audioTrack) + { + _originalPlaylist.Add(audioTrack); + } + + /// + /// Removes an audio track from the playlist. + /// + /// Audio track to remove. + internal void RemoveTrack(AudioTrackItem audioTrack) + { + _originalPlaylist.Remove(audioTrack); + } + + /// + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + _module ??= await JSRuntime.InvokeAsync("import", JavaScriptFile); + await _module.InvokeVoidAsync("fluentCxAudio.observeResize", Id, _dotNetRef); + var size = await _module.InvokeAsync("fluentCxAudio.measure", Id); + _containerHeight = $"{size.Height.ToString(CultureInfo.InvariantCulture)}px"; + } + + if (firstRender || _hasViewChanged) + { + _hasViewChanged = false; + await _module!.InvokeVoidAsync("fluentCxAudio.dispose", _audioReference!.Element); + await _module!.InvokeVoidAsync("fluentCxAudio.initialize", _audioReference!.Element, _dotNetRef); + + if (_originalPlaylist.Count > 0) + { + _currentTrackIndex = 0; + await SetAudioSourceAsync(); + } + } + } + + /// + public override Task SetParametersAsync(ParameterView parameters) + { + _hasViewChanged = parameters.HasValueChanged(nameof(View), View); + + return base.SetParametersAsync(parameters); + } + + [JSInvokable("onResize")] + public void OnResize(double width, double height) + { + _containerHeight = $"{height.ToString(CultureInfo.InvariantCulture)}px"; + StateHasChanged(); + } +} diff --git a/src/Community.Components/Components/Audio/FluentCxAudio.razor.css b/src/Community.Components/Components/Audio/FluentCxAudio.razor.css new file mode 100644 index 00000000..ed87a57d --- /dev/null +++ b/src/Community.Components/Components/Audio/FluentCxAudio.razor.css @@ -0,0 +1,65 @@ +.fluent-audio-player.compact, +.fluent-audio-player.floating-player { + max-width: 400px; + max-height: 400px; +} + +.main-ui { + display: flex; + flex-direction: column; + gap: 1rem; + height: 100%; +} + +.player-controls { + display: flex; + flex-direction: column; + width:100%; + gap: 0.5rem; +} + +.cover { + width: 100px; + height: 100px; + border-radius: 8px; +} + +.controls { + display: flex; + gap: 0.5rem; +} + +input[type=range] { + width: 100%; +} + +.mini-cover { + width: 48px; + height: 48px; + border-radius: 6px; +} + +.floating-player { + box-shadow: 0 4px 16px #0003; + background: #fff; + border-radius: 10px; + position: absolute; + bottom: 16px; + right: 16px; + z-index: 1000; +} + +@media (max-width: 600px) { + .fluent-audio-player { + max-width: 100vw; + } + + .main-ui { + flex-direction: column; + } + + .cover { + width: 64px; + height: 64px; + } +} diff --git a/src/Community.Components/Components/Audio/FluentCxAudio.razor.js b/src/Community.Components/Components/Audio/FluentCxAudio.razor.js new file mode 100644 index 00000000..63886e4e --- /dev/null +++ b/src/Community.Components/Components/Audio/FluentCxAudio.razor.js @@ -0,0 +1,196 @@ +function initialize(audio, dotnetRef) { + if (audio) { + audio.addEventListener('loadedmetadata', () => { + dotnetRef.invokeMethodAsync("setDuration", audio.duration); + }); + + audio.addEventListener('ended', () => { + dotnetRef.invokeMethodAsync("onTrackEnded"); + }); + + audio.addEventListener('seeked', () => { + dotnetRef.invokeMethodAsync("setSeek", audio.currentTime); + }); + + audio.addEventListener('timeupdate', () => { + dotnetRef.invokeMethodAsync("updateElapsedTime", audio.currentTime); + }); + } +} + +function measure(id) { + const element = document.getElementById(id); + + if (element) { + const rect = element.getBoundingClientRect(); + return { width: rect.width, height: rect.height }; + } + + return { width: 0, height: 0 }; +} + +function observeResize(id, dotnetRef) { + const element = document.getElementById(id); + + if (element) { + const resizeObserver = new ResizeObserver(entries => { + for (let entry of entries) { + const rect = entry.contentRect; + dotnetRef.invokeMethodAsync("onResize", rect.width, rect.height); + } + }); + + resizeObserver.observe(element); + } +} + +function download(source, filename) { + const a = document.createElement("a"); + document.body.appendChild(a); + a.href = source; + a.download = filename; + a.target = "_blank"; + a.click(); + + document.body.removeChild(a); +} + +function play(audio) { + if (audio) { + audio.play(); + } +} + +function pause(audio) { + if (audio) { + audio.pause(); + } +} + +function setAudioSource(audio, source) { + if (audio) { + audio.src = source; + audio.load(); + } +} + +function togglePlayPause(audio) { + if (audio) { + if (audio.paused) { + audio.play(); + } else { + audio.pause(); + } + } +} + +function stop(audio) { + if (audio) { + audio.pause(); + audio.currentTime = 0; + } +} + +function setVolume(audio, volume) { + if (audio) { + audio.volume = volume; + } +} + +function seek(audio, time) { + if (audio) { + audio.currentTime = time; + } +} + +function dispose(audio) { + stop(audio); + + audio.removeEventListener('loadedmetadata', () => { + dotnetRef.invokeMethodAsync("setDuration", audio.duration); + }); + + audio.removeEventListener('ended', () => { + dotnetRef.invokeMethodAsync("onTrackEnded"); + }); + + audio.removeEventListener('seeked', () => { + dotnetRef.invokeMethodAsync("setSeek", audio.currentTime); + }); + + audio.removeEventListener('timeupdate', () => { + dotnetRef.invokeMethodAsync("updateElapsedTime", audio.currentTime); + }); +} + +export const fluentCxAudio = { + initialize: (id, element, dotNetRef) => initialize(id, element, dotNetRef), + measure: (id) => measure(id), + play: (id) => play(id), + pause: (id) => pause(id), + togglePlayPause: (id) => togglePlayPause(id), + stop: (id) => stop(id), + setVolume: (id, volume) => setVolume(id, volume), + seek: (id, time) => seek(id, time), + setAudioSource: (id, source) => setAudioSource(id, source), + download: (source, filename) => download(source, filename), + dispose: (id) => dispose(id), + observeResize: (id, dotNetRef) => observeResize(id, dotNetRef), + seekAndResume: async function (audio, time) { + if (!audio) return false; + + try { audio.pause(); } catch { } + + if (!isFinite(audio.duration) || isNaN(audio.duration)) { + await new Promise(resolve => { + const onMeta = () => { audio.removeEventListener('loadedmetadata', onMeta); resolve(); }; + audio.addEventListener('loadedmetadata', onMeta); + audio.load?.(); + }); + } + + const target = Math.min(Math.max(time, 0), audio.duration || time); + if (typeof audio.fastSeek === 'function') { + try { audio.fastSeek(target); } catch { audio.currentTime = target; } + } else { + audio.currentTime = target; + } + + await new Promise((resolve) => { + let done = false; + const t = setTimeout(() => { if (!done) { done = true; cleanup(); resolve(); } }, 800); + const onSeeked = () => { if (!done) { done = true; cleanup(); resolve(); } }; + const cleanup = () => { clearTimeout(t); audio.removeEventListener('seeked', onSeeked); }; + audio.addEventListener('seeked', onSeeked, { once: true }); + }); + + const tryPlay = async () => { + try { await audio.play(); } catch { } + await new Promise((resolve) => { + let done = false; + const t = setTimeout(() => { if (!done) { done = true; cleanup(); resolve(); } }, 1500); + const onPlaying = () => { if (!done) { done = true; cleanup(); resolve(); } }; + const onCanPlay = () => { if (!done) { done = true; cleanup(); resolve(); } }; + const cleanup = () => { + clearTimeout(t); + audio.removeEventListener('playing', onPlaying); + audio.removeEventListener('canplay', onCanPlay); + }; + audio.addEventListener('playing', onPlaying, { once: true }); + audio.addEventListener('canplay', onCanPlay, { once: true }); + }); + }; + + const before = audio.currentTime; + await tryPlay(); + const after = audio.currentTime; + + if (Math.abs(after - before) < 0.01) { + audio.pause(); + audio.currentTime = target + 0.001; + await tryPlay(); + } + + return true; + } +} diff --git a/src/Community.Components/Components/Audio/MinimalPlayer.razor b/src/Community.Components/Components/Audio/MinimalPlayer.razor new file mode 100644 index 00000000..bac2d7b4 --- /dev/null +++ b/src/Community.Components/Components/Audio/MinimalPlayer.razor @@ -0,0 +1,9 @@ +@namespace FluentUI.Blazor.Community.Components +@inherits FluentComponentBase + +
+
diff --git a/src/Community.Components/Components/Audio/MinimalPlayer.razor.cs b/src/Community.Components/Components/Audio/MinimalPlayer.razor.cs new file mode 100644 index 00000000..c5a351b1 --- /dev/null +++ b/src/Community.Components/Components/Audio/MinimalPlayer.razor.cs @@ -0,0 +1,24 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.FluentUI.AspNetCore.Components; + +namespace FluentUI.Blazor.Community.Components; + +/// +/// Represents a minimal audio player component with play/pause functionality. +/// +public partial class MinimalPlayer : FluentComponentBase +{ + /// + /// Initializes a new instance of the class with a unique identifier. + /// + public MinimalPlayer() + { + Id = $"minimal-player-{Identifier.NewId()}"; + } + + /// + /// Gets or sets the event callback that is triggered when the play/pause button is clicked. + /// + [Parameter] + public EventCallback OnPlayPause { get; set; } +} diff --git a/src/Community.Components/Components/Audio/MinimalPlayer.razor.css b/src/Community.Components/Components/Audio/MinimalPlayer.razor.css new file mode 100644 index 00000000..a55fabe6 --- /dev/null +++ b/src/Community.Components/Components/Audio/MinimalPlayer.razor.css @@ -0,0 +1,6 @@ +.minimal-player { + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; +} diff --git a/src/Community.Components/Components/Audio/TrackInfo.razor b/src/Community.Components/Components/Audio/TrackInfo.razor new file mode 100644 index 00000000..6b215835 --- /dev/null +++ b/src/Community.Components/Components/Audio/TrackInfo.razor @@ -0,0 +1,25 @@ +@namespace FluentUI.Blazor.Community.Components +@inherits FluentComponentBase + + + @if (Track is not null) + { + @if (!string.IsNullOrEmpty(Track.Cover)) + { + + } + + + @Track.Title + @Track.Artist + + } + diff --git a/src/Community.Components/Components/Audio/TrackInfo.razor.cs b/src/Community.Components/Components/Audio/TrackInfo.razor.cs new file mode 100644 index 00000000..19702d6f --- /dev/null +++ b/src/Community.Components/Components/Audio/TrackInfo.razor.cs @@ -0,0 +1,48 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.FluentUI.AspNetCore.Components; + +namespace FluentUI.Blazor.Community.Components; + +/// +/// Represents a component that displays information about an audio track. +/// +/// This class is used to manage and display details about a specific audio track. The property can be set to specify the audio track for which information should be +/// displayed. +public partial class TrackInfo + : FluentComponentBase +{ + /// + /// Initializes a new instance of the class. + /// + public TrackInfo() + { + Id = Identifier.NewId(); + } + + /// + /// Gets or sets the current audio track to display information for. + /// + [Parameter] + public AudioTrackItem? Track { get; set; } + + /// + /// Gets or sets the callback that is invoked when the track info component is clicked. + /// + [Parameter] + public EventCallback OnClick { get; set; } + + /// + /// Handles the click event by invoking the associated callback with the current track. + /// + /// This method checks if a track is available and if a callback delegate is assigned before + /// invoking the callback asynchronously. + /// + private async Task OnHandleClickAsync() + { + if (Track != null && OnClick.HasDelegate) + { + await OnClick.InvokeAsync(Track); + } + } +} diff --git a/src/Community.Components/Components/Audio/VisualizerMode.cs b/src/Community.Components/Components/Audio/VisualizerMode.cs new file mode 100644 index 00000000..03d8fa10 --- /dev/null +++ b/src/Community.Components/Components/Audio/VisualizerMode.cs @@ -0,0 +1,52 @@ +namespace FluentUI.Blazor.Community.Components; + +/// +/// Represents the different modes available for the audio visualizer. +/// +public enum VisualizerMode +{ + /// + /// Represents the spectrum dispaly mode. + /// + Spectrum = 0, + + /// + /// Represents the waveform display mode. + /// + Waveform = 1, + + /// + /// Represents the spatial display mode. + /// + Spatial = 2, + + /// + /// Represents the vortex display mode. + /// + Vortex = 3, + + /// + /// Represents a particles field. + /// + Particles = 4, + + /// + /// Represents a radial waveform. + /// + RadialWaveform = 5, + + /// + /// Represents a tunnel. + /// + Tunnel = 6, + + /// + /// Represents a constellation. + /// + Constellation = 7, + + /// + /// Represents a fractal. + /// + Fractal = 8 +} diff --git a/src/Community.Components/Components/Chapter.cs b/src/Community.Components/Components/Chapter.cs new file mode 100644 index 00000000..ae3ab0cb --- /dev/null +++ b/src/Community.Components/Components/Chapter.cs @@ -0,0 +1,16 @@ +namespace FluentUI.Blazor.Community.Components; + +/// +/// Represents a chapter within a media file, defined by its start and end times, title, and status. +/// +/// This record is commonly used to represent segments of a media file, such as chapters in an audiobook +/// or scenes in a video. The and properties define the time range of the chapter, +/// while the provides a descriptive name. The indicates the current state of +/// the chapter, such as whether it is active or inactive. +/// The start time of the chapter, in seconds. Must be greater than or equal to 0. +/// The end time of the chapter, in seconds. Must be greater than . +/// The title of the chapter. Cannot be null or empty. +/// The status of the chapter, indicating its current state. +public record Chapter(double Start, double End, string Title, ChapterStatus Status) +{ +} diff --git a/src/Community.Components/Components/ChapterStatus.cs b/src/Community.Components/Components/ChapterStatus.cs new file mode 100644 index 00000000..02b3ab2a --- /dev/null +++ b/src/Community.Components/Components/ChapterStatus.cs @@ -0,0 +1,27 @@ +namespace FluentUI.Blazor.Community.Components; + +/// +/// Represents the status of a chapter in media content. +/// +public enum ChapterStatus +{ + /// + /// Represents an unspecified state. + /// + Unspecified, + + /// + /// Represents the state of a chapter that has not yet started. + /// + NotStarted, + + /// + /// Represents the state of a chapter that is currently in progress. + /// + Current, + + /// + /// Represents the state of a chapter that has been completed. + /// + Completed, +} diff --git a/src/Community.Components/Components/MediaMode.cs b/src/Community.Components/Components/MediaMode.cs new file mode 100644 index 00000000..3872345a --- /dev/null +++ b/src/Community.Components/Components/MediaMode.cs @@ -0,0 +1,7 @@ +namespace FluentUI.Blazor.Community.Components; + +public enum MediaMode +{ + Audio, + Video +} diff --git a/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/AudioControlsTests.cs b/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/AudioControlsTests.cs new file mode 100644 index 00000000..bafe8533 --- /dev/null +++ b/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/AudioControlsTests.cs @@ -0,0 +1,141 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Bunit; +using FluentUI.Blazor.Community.Extensions; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.FluentUI.AspNetCore.Components; +using Microsoft.FluentUI.AspNetCore.Components.Tests; + +namespace FluentUI.Blazor.Community.Components.Tests.Components.Audio; + +public class AudioControlsTests : TestBase +{ + public AudioControlsTests() + { + JSInterop.Mode = Bunit.JSRuntimeMode.Loose; + Services.AddSingleton(UnitTestLibraryConfiguration); + Services.AddFluentUIComponents(); + Services.AddFluentCxUIComponents(); + } + + [Fact] + public void Constructor_SetsId() + { + var cut = RenderComponent(); + Assert.StartsWith("audio-controls-", cut.Instance.Id); + Assert.False(string.IsNullOrWhiteSpace(cut.Instance.Id)); + } + + [Fact] + public void IsDownloadVisible_DefaultIsTrue() + { + var cut = RenderComponent(); + Assert.True(cut.Instance.IsDownloadVisible); + } + + [Fact] + public void IsDownloadVisible_CanBeSet() + { + var cut = RenderComponent(p => p.Add(a => a.IsDownloadVisible, false)); + Assert.False(cut.Instance.IsDownloadVisible); + } + + [Fact] + public async Task OnShuffleChanged_IsInvoked() + { + bool called = false; + var cut = RenderComponent(p => p.Add(a => a.OnShuffleChanged, EventCallback.Factory.Create(this, _ => called = true))); + await cut.Instance.OnShuffleChanged.InvokeAsync(true); + Assert.True(called); + } + + [Fact] + public async Task OnPlaylistToggled_IsInvoked() + { + bool called = false; + var cut = RenderComponent(p => p.Add(a => a.OnPlaylistToggled, EventCallback.Factory.Create(this, _ => called = true))); + await cut.Instance.OnPlaylistToggled.InvokeAsync(true); + Assert.True(called); + } + + [Fact] + public async Task OnPrevious_IsInvoked() + { + bool called = false; + var cut = RenderComponent(p => p.Add(a => a.OnPrevious, EventCallback.Factory.Create(this, () => called = true))); + await cut.Instance.OnPrevious.InvokeAsync(); + Assert.True(called); + } + + [Fact] + public async Task OnDownload_IsInvoked() + { + bool called = false; + var cut = RenderComponent(p => p.Add(a => a.OnDownload, EventCallback.Factory.Create(this, () => called = true))); + await cut.Instance.OnDownload.InvokeAsync(); + Assert.True(called); + } + + [Fact] + public async Task OnStop_IsInvoked() + { + bool called = false; + var cut = RenderComponent(p => p.Add(a => a.OnStop, EventCallback.Factory.Create(this, () => called = true))); + await cut.Instance.OnStop.InvokeAsync(); + Assert.True(called); + } + + [Fact] + public async Task OnNext_IsInvoked() + { + bool called = false; + var cut = RenderComponent(p => p.Add(a => a.OnNext, EventCallback.Factory.Create(this, () => called = true))); + await cut.Instance.OnNext.InvokeAsync(); + Assert.True(called); + } + + [Fact] + public async Task OnRepeatModeChanged_IsInvoked() + { + bool called = false; + var cut = RenderComponent(p => p.Add(a => a.OnRepeatModeChanged, EventCallback.Factory.Create(this, _ => called = true))); + await cut.Instance.OnRepeatModeChanged.InvokeAsync(AudioRepeatMode.SingleLoop); + Assert.True(called); + } + + [Fact] + public async Task OnPlayPauseToggled_IsInvoked() + { + bool called = false; + var cut = RenderComponent(p => p.Add(a => a.OnPlayPauseToggled, EventCallback.Factory.Create(this, _ => called = true))); + await cut.Instance.OnPlayPauseToggled.InvokeAsync(true); + Assert.True(called); + } + + [Fact] + public async Task OnVolumeChanged_IsInvoked() + { + bool called = false; + var cut = RenderComponent(p => p.Add(a => a.OnVolumeChanged, EventCallback.Factory.Create(this, _ => called = true))); + await cut.Instance.OnVolumeChanged.InvokeAsync(0.5); + Assert.True(called); + } + + [Fact] + public void IsPreviousDisabled_CanBeSet() + { + var cut = RenderComponent(p => p.Add(a => a.IsPreviousDisabled, true)); + Assert.True(cut.Instance.IsPreviousDisabled); + } + + [Fact] + public void IsNextDisabled_CanBeSet() + { + var cut = RenderComponent(p => p.Add(a => a.IsNextDisabled, true)); + Assert.True(cut.Instance.IsNextDisabled); + } +} diff --git a/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/AudioPlaylistItemTests.cs b/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/AudioPlaylistItemTests.cs new file mode 100644 index 00000000..095202bc --- /dev/null +++ b/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/AudioPlaylistItemTests.cs @@ -0,0 +1,144 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using FluentUI.Blazor.Community.Extensions; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.FluentUI.AspNetCore.Components; +using Microsoft.FluentUI.AspNetCore.Components.Tests; + +namespace FluentUI.Blazor.Community.Components.Tests.Components.Audio; +public class AudioPlaylistItemTests : TestBase +{ + public AudioPlaylistItemTests() + { + JSInterop.Mode = Bunit.JSRuntimeMode.Loose; + Services.AddSingleton(UnitTestLibraryConfiguration); + Services.AddFluentUIComponents(); + Services.AddFluentCxUIComponents(); + } + + [Fact] + public void AudioPlaylistItem_Rendered_HasUniqueId() + { + // Act + var cut = RenderComponent(); + + // Assert + var instance = cut.Instance; + Assert.StartsWith("audio-playlist-item-", instance.Id); + Assert.False(string.IsNullOrWhiteSpace(instance.Id)); + } + + [Fact] + public void AudioPlaylistItem_Track_ParameterIsSet() + { + // Arrange + var track = new AudioTrackItem { Title = "Track", Artist = "Artist", Source = "track.mp3", Cover = "cover.png" }; + + // Act + var cut = RenderComponent(parameters => parameters + .Add(p => p.Track, track) + ); + + // Assert + Assert.Equal(track, cut.Instance.Track); + } + + [Fact] + public void AudioPlaylistItem_IsSelected_ParameterIsSet() + { + // Act + var cut = RenderComponent(parameters => parameters + .Add(p => p.IsSelected, true) + ); + + // Assert + Assert.True(cut.Instance.IsSelected); + } + + [Fact] + public void AudioPlaylistItem_TrackIcon_ReturnsPollIcon_WhenSelected() + { + // Act + var cut = RenderComponent(parameters => parameters + .Add(p => p.IsSelected, true) + ); + + // Assert + var iconField = cut.Instance.GetType().GetProperty("TrackIcon", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + var icon = iconField?.GetValue(cut.Instance); + Assert.NotNull(icon); + Assert.Equal("Poll", icon?.GetType().Name); + } + + [Fact] + public void AudioPlaylistItem_TrackIcon_ReturnsPlayIcon_WhenNotSelected() + { + // Act + var cut = RenderComponent(parameters => parameters + .Add(p => p.IsSelected, false) + ); + + // Assert + var iconField = cut.Instance.GetType().GetProperty("TrackIcon", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + var icon = iconField?.GetValue(cut.Instance); + Assert.NotNull(icon); + Assert.Equal("Play", icon?.GetType().Name); + } + + [Fact] + public async Task AudioPlaylistItem_OnHandleClickAsync_InvokesCallback_WhenTrackAndDelegateSet() + { + // Arrange + var track = new AudioTrackItem { Title = "Track", Artist = "Artist", Source = "track.mp3", Cover = "cover.png" }; + bool callbackInvoked = false; + var cut = RenderComponent(parameters => parameters + .Add(p => p.Track, track) + .Add(p => p.OnSelected, EventCallback.Factory.Create(this, (selectedTrack) => { callbackInvoked = true; })) + ); + + // Act + var method = cut.Instance.GetType().GetMethod("OnHandleClickAsync", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + await (Task)method!.Invoke(cut.Instance, null); + + // Assert + Assert.True(callbackInvoked); + } + + [Fact] + public async Task AudioPlaylistItem_OnHandleClickAsync_DoesNotInvokeCallback_WhenTrackIsNull() + { + // Arrange + bool callbackInvoked = false; + var cut = RenderComponent(parameters => parameters + .Add(p => p.OnSelected, EventCallback.Factory.Create(this, (selectedTrack) => { callbackInvoked = true; })) + ); + + // Act + var method = cut.Instance.GetType().GetMethod("OnHandleClickAsync", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + await (Task)method!.Invoke(cut.Instance, null); + + // Assert + Assert.False(callbackInvoked); + } + + [Fact] + public async Task AudioPlaylistItem_OnHandleClickAsync_DoesNotInvokeCallback_WhenDelegateNotSet() + { + // Arrange + var track = new AudioTrackItem { Title = "Track", Artist = "Artist", Source = "track.mp3", Cover = "cover.png" }; + var cut = RenderComponent(parameters => parameters + .Add(p => p.Track, track) + ); + + // Act + var method = cut.Instance.GetType().GetMethod("OnHandleClickAsync", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + await (Task)method!.Invoke(cut.Instance, null); + + // Assert + // No exception, no callback + } +} diff --git a/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/AudioPlaylistTests.cs b/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/AudioPlaylistTests.cs new file mode 100644 index 00000000..d9d9a715 --- /dev/null +++ b/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/AudioPlaylistTests.cs @@ -0,0 +1,82 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using FluentUI.Blazor.Community.Extensions; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.FluentUI.AspNetCore.Components; +using Microsoft.FluentUI.AspNetCore.Components.Tests; + +namespace FluentUI.Blazor.Community.Components.Tests.Components.Audio; +public class AudioPlaylistTests : TestBase +{ + public AudioPlaylistTests() + { + JSInterop.Mode = Bunit.JSRuntimeMode.Loose; + Services.AddSingleton(UnitTestLibraryConfiguration); + Services.AddFluentUIComponents(); + Services.AddFluentCxUIComponents(); + } + + [Fact] + public void AudioPlaylist_Rendered_HasUniqueId() + { + // Act + var cut = RenderComponent(); + + // Assert + var instance = cut.Instance; + Assert.False(string.IsNullOrWhiteSpace(instance.Id)); + } + + [Fact] + public void AudioPlaylist_Playlist_ParameterIsSet() + { + // Arrange + var track1 = new AudioTrackItem { Title = "Track 1", Artist = "Artist 1", Source = "track1.mp3", Cover = "cover1.png" }; + var track2 = new AudioTrackItem { Title = "Track 2", Artist = "Artist 2", Source = "track2.mp3", Cover = "cover2.png" }; + var playlist = new List { track1, track2 }; + + // Act + var cut = RenderComponent(parameters => parameters + .Add(p => p.Playlist, playlist) + ); + + // Assert + Assert.Equal(playlist, cut.Instance.Playlist); + } + + [Fact] + public void AudioPlaylist_CurrentTrack_ParameterIsSet() + { + // Arrange + var track = new AudioTrackItem { Title = "Current", Artist = "Artist", Source = "current.mp3", Cover = "cover.png" }; + + // Act + var cut = RenderComponent(parameters => parameters + .Add(p => p.CurrentTrack, track) + ); + + // Assert + Assert.Equal(track, cut.Instance.CurrentTrack); + } + + [Fact] + public async Task AudioPlaylist_OnTrackSelected_EventCallbackIsTriggered() + { + // Arrange + var track = new AudioTrackItem { Title = "Selected", Artist = "Artist", Source = "selected.mp3", Cover = "cover.png" }; + bool callbackInvoked = false; + var cut = RenderComponent(parameters => parameters + .Add(p => p.OnTrackSelected, EventCallback.Factory.Create(this, (selectedTrack) => { callbackInvoked = true; })) + ); + + // Act + await cut.Instance.OnTrackSelected.InvokeAsync(track); + + // Assert + Assert.True(callbackInvoked); + } +} diff --git a/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/AudioTitleScrollerTests.cs b/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/AudioTitleScrollerTests.cs new file mode 100644 index 00000000..42b4472d --- /dev/null +++ b/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/AudioTitleScrollerTests.cs @@ -0,0 +1,31 @@ +using FluentUI.Blazor.Community.Extensions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.FluentUI.AspNetCore.Components; +using Microsoft.FluentUI.AspNetCore.Components.Tests; + +namespace FluentUI.Blazor.Community.Components.Tests.Components.Audio; +public class AudioTitleScrollerTests : TestBase +{ + public AudioTitleScrollerTests() + { + JSInterop.Mode = Bunit.JSRuntimeMode.Loose; + Services.AddSingleton(UnitTestLibraryConfiguration); + Services.AddFluentUIComponents(); + Services.AddFluentCxUIComponents(); + } + + [Fact] + public void TitleParameter_ShouldRenderCorrectly() + { + // Arrange + var title = "Titre audio"; + + // Act + var cut = RenderComponent(parameters => parameters + .Add(p => p.Title, title) + ); + + Assert.Equal(title, cut.Instance.Title); + Assert.Contains(title, cut.Markup); + } +} diff --git a/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/AudioVisualizerTests.cs b/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/AudioVisualizerTests.cs new file mode 100644 index 00000000..b11dda29 --- /dev/null +++ b/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/AudioVisualizerTests.cs @@ -0,0 +1,124 @@ +using Bunit; +using FluentUI.Blazor.Community.Extensions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.FluentUI.AspNetCore.Components; +using Microsoft.FluentUI.AspNetCore.Components.Tests; +using Microsoft.JSInterop; + +namespace FluentUI.Blazor.Community.Components.Tests.Components.Audio; + +public class AudioVisualizerTests : TestBase +{ + public AudioVisualizerTests() + { + JSInterop.Mode = Bunit.JSRuntimeMode.Loose; + Services.AddSingleton(UnitTestLibraryConfiguration); + Services.AddFluentUIComponents(); + Services.AddFluentCxUIComponents(); + } + + [Fact] + public void AudioVisualizer_Render_Default() + { + // Arrange & Act + var cut = RenderComponent(p => p.Add(x => x.Anchor, "a123456")); + + // Assert + Assert.NotNull(cut.Instance); + } + + [Fact] + public void OnInitialized_ThrowsIfAnchorIsNullOrWhitespace() + { + // Anchor null + var ex1 = Assert.Throws(() => + RenderComponent(p => p.Add(a => a.Anchor, null)) + ); + Assert.Contains("Anchor parameter is required", ex1.Message); + + // Anchor vide + var ex2 = Assert.Throws(() => + RenderComponent(p => p.Add(a => a.Anchor, " ")) + ); + Assert.Contains("Anchor parameter is required", ex2.Message); + } + + [Fact] + public void Constructor_SetsId() + { + var cut = RenderComponent(p => p.Add(a => a.Anchor, "audio1")); + Assert.False(string.IsNullOrWhiteSpace(cut.Instance.Id)); + } + + [Fact] + public void OnAfterRenderAsync_ImportsJsAndInitializesVisualizer() + { + // Arrange + var jsModule = JSInterop.SetupModule("./_content/FluentUI.Blazor.Community.Components/Components/Audio/AudioVisualizer.razor.js"); + jsModule.SetupVoid("fluentCxAudioVisualizer.initialize"); + + var cut = RenderComponent(p => p + .Add(a => a.Anchor, "audio1") + .Add(a => a.Mode, VisualizerMode.Spectrum) + .Add(a => a.Cover, "cover.png") + .Add(a => a.Width, 400) + .Add(a => a.Height, 200) + ); + + // Assert + JSInterop.VerifyInvoke("import"); + jsModule.VerifyInvoke("fluentCxAudioVisualizer.initialize"); + } + + [Fact] + public async Task DisposeAsync_CallsJsDisposeAndDisposesModule() + { + // Arrange + var jsModule = JSInterop.SetupModule("./_content/FluentUI.Blazor.Community.Components/Components/Audio/AudioVisualizer.razor.js"); + jsModule.SetupVoid("fluentCxAudioVisualizer.dispose"); + + var cut = RenderComponent(p => p.Add(a => a.Anchor, "audio1")); + + // Act + await cut.Instance.DisposeAsync(); + + // Assert + jsModule.VerifyInvoke("fluentCxAudioVisualizer.dispose"); + } + + [Fact] + public async Task DisposeAsync_HandlesJsDisconnectedException() + { + // Arrange + var cut = RenderComponent(p => p.Add(a => a.Anchor, "audio1")); + var moduleField = typeof(AudioVisualizer).GetField("_module", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + moduleField?.SetValue(cut.Instance, new ThrowingJSObjectReference()); + + // Act & Assert (ne doit pas lever d'exception) + await cut.Instance.DisposeAsync(); + } + + // Helper pour simuler JSDisconnectedException + private class ThrowingJSObjectReference : IJSObjectReference + { + public ValueTask DisposeAsync() + { + throw new JSDisconnectedException("Simulated disconnect"); + } + public ValueTask InvokeAsync(string identifier, object?[]? args) => ValueTask.FromResult(default!); + public ValueTask InvokeAsync(string identifier, System.Threading.CancellationToken cancellationToken, object?[]? args) => throw new NotImplementedException(); + } + + [Fact] + public void AudioVisualizer_JSInterop_ModuleIsLoaded() + { + // Arrange + var jsModule = JSInterop.SetupModule("./_content/FluentUI.Blazor.Community.Components/Components/Audio/AudioVisualizer.razor.js"); + + // Act + var cut = RenderComponent(p => p.Add(x => x.Anchor, "a123456")); + + // Assert + JSInterop.VerifyInvoke("import"); + } +} diff --git a/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/CompactPlayerTests.cs b/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/CompactPlayerTests.cs new file mode 100644 index 00000000..aab6d34c --- /dev/null +++ b/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/CompactPlayerTests.cs @@ -0,0 +1,66 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using FluentUI.Blazor.Community.Extensions; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.FluentUI.AspNetCore.Components; +using Microsoft.FluentUI.AspNetCore.Components.Tests; + +namespace FluentUI.Blazor.Community.Components.Tests.Components.Audio; + +public class CompactPlayerTests : TestBase +{ + public CompactPlayerTests() + { + JSInterop.Mode = Bunit.JSRuntimeMode.Loose; + Services.AddSingleton(UnitTestLibraryConfiguration); + Services.AddFluentUIComponents(); + Services.AddFluentCxUIComponents(); + } + + [Fact] + public void CompactPlayer_Rendered_HasUniqueId() + { + // Act + var cut = RenderComponent(); + + // Assert + var instance = cut.Instance; + Assert.StartsWith("compact-player-", instance.Id); + Assert.False(string.IsNullOrWhiteSpace(instance.Id)); + } + + [Fact] + public void CompactPlayer_CurrentTrack_ParameterIsSet() + { + // Arrange + var track = new AudioTrackItem { Title = "Test", Artist = "Art", Source = "src.mp3", Cover = "cover.png" }; + + // Act + var cut = RenderComponent(parameters => parameters + .Add(p => p.CurrentTrack, track) + ); + + // Assert + Assert.Equal(track, cut.Instance.CurrentTrack); + } + + [Fact] + public async Task CompactPlayer_OnPlayPause_EventCallbackIsTriggered() + { + // Arrange + var callbackInvoked = false; + var cut = RenderComponent(parameters => parameters + .Add(p => p.OnPlayPauseChanged, EventCallback.Factory.Create(this, (playing) => { callbackInvoked = true; })) + ); + + // Act + await cut.Instance.OnPlayPauseChanged.InvokeAsync(true); + + // Assert + Assert.True(callbackInvoked); + } +} diff --git a/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/DownloadButtonTests.cs b/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/DownloadButtonTests.cs new file mode 100644 index 00000000..014eb544 --- /dev/null +++ b/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/DownloadButtonTests.cs @@ -0,0 +1,70 @@ +using Bunit; +using FluentUI.Blazor.Community.Extensions; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.FluentUI.AspNetCore.Components; +using Microsoft.FluentUI.AspNetCore.Components.Tests; + +namespace FluentUI.Blazor.Community.Components.Tests.Components.Audio; + +public class DownloadButtonTests : TestBase +{ + public DownloadButtonTests() + { + JSInterop.Mode = Bunit.JSRuntimeMode.Loose; + Services.AddSingleton(UnitTestLibraryConfiguration); + Services.AddFluentUIComponents(); + Services.AddFluentCxUIComponents(); + } + + [Fact] + public void DownloadLabel_DefaultValue_IsDownload() + { + // Arrange & Act + var cut = RenderComponent(); + + // Assert + Assert.Equal("Download", cut.Instance.DownloadLabel); + } + + [Fact] + public void Constructor_SetsUniqueId() + { + // Arrange & Act + var cut = RenderComponent(); + + // Assert + Assert.StartsWith("download-button-", cut.Instance.Id); + Assert.False(string.IsNullOrWhiteSpace(cut.Instance.Id)); + } + + [Fact] + public async Task OnDownloadAsync_InvokesCallback_WhenDelegateSet() + { + // Arrange + var wasCalled = false; + var cut = RenderComponent(parameters => parameters + .Add(p => p.OnDownload, EventCallback.Factory.Create(this, () => wasCalled = true)) + ); + + // Act + await (Task)cut.Instance.GetType() + .GetMethod("OnDownloadAsync", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)! + .Invoke(cut.Instance, null); + + // Assert + Assert.True(wasCalled); + } + + [Fact] + public async Task OnDownloadAsync_DoesNothing_WhenNoDelegate() + { + // Arrange + var cut = RenderComponent(); + + // Act & Assert (should not throw) + await (Task)cut.Instance.GetType() + .GetMethod("OnDownloadAsync", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)! + .Invoke(cut.Instance, null); + } +} diff --git a/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/FluentCxAudioTests.cs b/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/FluentCxAudioTests.cs new file mode 100644 index 00000000..0e9f7e2e --- /dev/null +++ b/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/FluentCxAudioTests.cs @@ -0,0 +1,139 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using FluentUI.Blazor.Community.Extensions; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.FluentUI.AspNetCore.Components; +using Microsoft.FluentUI.AspNetCore.Components.Tests; + +namespace FluentUI.Blazor.Community.Components.Tests.Components.Audio; +public class FluentCxAudioTests : TestBase +{ + public FluentCxAudioTests() + { + JSInterop.Mode = Bunit.JSRuntimeMode.Loose; + Services.AddSingleton(UnitTestLibraryConfiguration); + Services.AddFluentUIComponents(); + Services.AddFluentCxUIComponents(); + } + + [Fact] + public void Constructor_SetsId() + { + var cut = RenderComponent(); + Assert.StartsWith("audio-controls-", cut.Instance.Id); + Assert.False(string.IsNullOrWhiteSpace(cut.Instance.Id)); + } + + [Fact] + public void IsDownloadVisible_DefaultIsTrue() + { + var cut = RenderComponent(); + Assert.True(cut.Instance.IsDownloadVisible); + } + + [Fact] + public void IsDownloadVisible_CanBeSet() + { + var cut = RenderComponent(p => p.Add(a => a.IsDownloadVisible, false)); + Assert.False(cut.Instance.IsDownloadVisible); + } + + [Fact] + public async Task OnShuffleChanged_IsInvoked() + { + bool called = false; + var cut = RenderComponent(p => p.Add(a => a.OnShuffleChanged, EventCallback.Factory.Create(this, _ => called = true))); + await cut.Instance.OnShuffleChanged.InvokeAsync(true); + Assert.True(called); + } + + [Fact] + public async Task OnPlaylistToggled_IsInvoked() + { + bool called = false; + var cut = RenderComponent(p => p.Add(a => a.OnPlaylistToggled, EventCallback.Factory.Create(this, _ => called = true))); + await cut.Instance.OnPlaylistToggled.InvokeAsync(true); + Assert.True(called); + } + + [Fact] + public async Task OnPrevious_IsInvoked() + { + bool called = false; + var cut = RenderComponent(p => p.Add(a => a.OnPrevious, EventCallback.Factory.Create(this, () => called = true))); + await cut.Instance.OnPrevious.InvokeAsync(); + Assert.True(called); + } + + [Fact] + public async Task OnDownload_IsInvoked() + { + bool called = false; + var cut = RenderComponent(p => p.Add(a => a.OnDownload, EventCallback.Factory.Create(this, () => called = true))); + await cut.Instance.OnDownload.InvokeAsync(); + Assert.True(called); + } + + [Fact] + public async Task OnStop_IsInvoked() + { + bool called = false; + var cut = RenderComponent(p => p.Add(a => a.OnStop, EventCallback.Factory.Create(this, () => called = true))); + await cut.Instance.OnStop.InvokeAsync(); + Assert.True(called); + } + + [Fact] + public async Task OnNext_IsInvoked() + { + bool called = false; + var cut = RenderComponent(p => p.Add(a => a.OnNext, EventCallback.Factory.Create(this, () => called = true))); + await cut.Instance.OnNext.InvokeAsync(); + Assert.True(called); + } + + [Fact] + public async Task OnRepeatModeChanged_IsInvoked() + { + bool called = false; + var cut = RenderComponent(p => p.Add(a => a.OnRepeatModeChanged, EventCallback.Factory.Create(this, _ => called = true))); + await cut.Instance.OnRepeatModeChanged.InvokeAsync(AudioRepeatMode.SingleLoop); + Assert.True(called); + } + + [Fact] + public async Task OnPlayPauseToggled_IsInvoked() + { + bool called = false; + var cut = RenderComponent(p => p.Add(a => a.OnPlayPauseToggled, EventCallback.Factory.Create(this, _ => called = true))); + await cut.Instance.OnPlayPauseToggled.InvokeAsync(true); + Assert.True(called); + } + + [Fact] + public async Task OnVolumeChanged_IsInvoked() + { + bool called = false; + var cut = RenderComponent(p => p.Add(a => a.OnVolumeChanged, EventCallback.Factory.Create(this, _ => called = true))); + await cut.Instance.OnVolumeChanged.InvokeAsync(0.5); + Assert.True(called); + } + + [Fact] + public void IsPreviousDisabled_CanBeSet() + { + var cut = RenderComponent(p => p.Add(a => a.IsPreviousDisabled, true)); + Assert.True(cut.Instance.IsPreviousDisabled); + } + + [Fact] + public void IsNextDisabled_CanBeSet() + { + var cut = RenderComponent(p => p.Add(a => a.IsNextDisabled, true)); + Assert.True(cut.Instance.IsNextDisabled); + } +} diff --git a/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/MinimalPlayerTests.cs b/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/MinimalPlayerTests.cs new file mode 100644 index 00000000..a1c14e0d --- /dev/null +++ b/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/MinimalPlayerTests.cs @@ -0,0 +1,51 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using FluentUI.Blazor.Community.Extensions; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.FluentUI.AspNetCore.Components; +using Microsoft.FluentUI.AspNetCore.Components.Tests; + +namespace FluentUI.Blazor.Community.Components.Tests.Components.Audio; + +public class MinimalPlayerTests : TestBase +{ + public MinimalPlayerTests() + { + JSInterop.Mode = Bunit.JSRuntimeMode.Loose; + Services.AddSingleton(UnitTestLibraryConfiguration); + Services.AddFluentUIComponents(); + Services.AddFluentCxUIComponents(); + } + + [Fact] + public void MinimalPlayer_Rendered_HasUniqueId() + { + // Act + var cut = RenderComponent(); + + // Assert + var instance = cut.Instance; + Assert.StartsWith("minimal-player-", instance.Id); + Assert.False(string.IsNullOrWhiteSpace(instance.Id)); + } + + [Fact] + public async Task MinimalPlayer_OnPlayPause_EventCallbackIsTriggered() + { + // Arrange + var callbackInvoked = false; + var cut = RenderComponent(parameters => parameters + .Add(p => p.OnPlayPause, EventCallback.Factory.Create(this, (playing) => { callbackInvoked = true; })) + ); + + // Act + await cut.Instance.OnPlayPause.InvokeAsync(true); + + // Assert + Assert.True(callbackInvoked); + } +} diff --git a/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/NextButtonTests.cs b/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/NextButtonTests.cs new file mode 100644 index 00000000..fb880bba --- /dev/null +++ b/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/NextButtonTests.cs @@ -0,0 +1,33 @@ +using Bunit; +using FluentUI.Blazor.Community.Extensions; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.FluentUI.AspNetCore.Components; +using Microsoft.FluentUI.AspNetCore.Components.Tests; + +namespace FluentUI.Blazor.Community.Components.Tests.Components.Audio; + +public class NextButtonTests : TestBase +{ + public NextButtonTests() + { + JSInterop.Mode = Bunit.JSRuntimeMode.Loose; + Services.AddSingleton(UnitTestLibraryConfiguration); + Services.AddFluentUIComponents(); + Services.AddFluentCxUIComponents(); + } + + [Fact] + public void NextButton_OnNextAsync_InvokesCallback() + { + var invoked = false; + var cut = RenderComponent(parameters => parameters + .Add(p => p.OnNext, EventCallback.Factory.Create(this, () => invoked = true)) + ); + + cut.InvokeAsync(() => cut.Instance.GetType().GetMethod("OnNextAsync", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance) + .Invoke(cut.Instance, null)); + + Assert.True(invoked); + } +} diff --git a/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/PlayOrPauseButtonTests.cs b/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/PlayOrPauseButtonTests.cs new file mode 100644 index 00000000..cd93cac4 --- /dev/null +++ b/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/PlayOrPauseButtonTests.cs @@ -0,0 +1,34 @@ +using Bunit; +using FluentUI.Blazor.Community.Extensions; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.FluentUI.AspNetCore.Components; +using Microsoft.FluentUI.AspNetCore.Components.Tests; + +namespace FluentUI.Blazor.Community.Components.Tests.Components.Audio; + +public class PlayOrPauseButtonTests : TestBase +{ + public PlayOrPauseButtonTests() + { + JSInterop.Mode = Bunit.JSRuntimeMode.Loose; + Services.AddSingleton(UnitTestLibraryConfiguration); + Services.AddFluentUIComponents(); + Services.AddFluentCxUIComponents(); + } + + [Fact] + public void PlayOrPauseButton_OnTogglePlayOrPauseAsync_TogglesStateAndInvokesCallback() + { + bool? playState = null; + var cut = RenderComponent(parameters => parameters + .Add(p => p.OnPlayChanged, EventCallback.Factory.Create(this, s => playState = s)) + ); + + cut.InvokeAsync(() => cut.Instance.GetType().GetMethod("OnTogglePlayOrPauseAsync", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance) + .Invoke(cut.Instance, null)); + + Assert.NotNull(playState); + Assert.True(playState.Value || !playState.Value); + } +} diff --git a/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/PlaylistButtonTests.cs b/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/PlaylistButtonTests.cs new file mode 100644 index 00000000..84e31ba1 --- /dev/null +++ b/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/PlaylistButtonTests.cs @@ -0,0 +1,34 @@ +using Bunit; +using FluentUI.Blazor.Community.Extensions; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.FluentUI.AspNetCore.Components; +using Microsoft.FluentUI.AspNetCore.Components.Tests; + +namespace FluentUI.Blazor.Community.Components.Tests.Components.Audio; + +public class PlaylistButtonTests : TestBase +{ + public PlaylistButtonTests() + { + JSInterop.Mode = Bunit.JSRuntimeMode.Loose; + Services.AddSingleton(UnitTestLibraryConfiguration); + Services.AddFluentUIComponents(); + Services.AddFluentCxUIComponents(); + } + + [Fact] + public void PlaylistButton_OnTogglePlaylistAsync_TogglesStateAndInvokesCallback() + { + bool? playlistState = null; + var cut = RenderComponent(parameters => parameters + .Add(p => p.OnPlaylist, EventCallback.Factory.Create(this, s => playlistState = s)) + ); + + cut.InvokeAsync(() => cut.Instance.GetType().GetMethod("OnTogglePlaylistAsync", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance) + .Invoke(cut.Instance, null)); + + Assert.NotNull(playlistState); + Assert.True(playlistState.Value || !playlistState.Value); + } +} diff --git a/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/PreviousButtonTests.cs b/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/PreviousButtonTests.cs new file mode 100644 index 00000000..3c124c05 --- /dev/null +++ b/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/PreviousButtonTests.cs @@ -0,0 +1,33 @@ +using Bunit; +using FluentUI.Blazor.Community.Extensions; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.FluentUI.AspNetCore.Components; +using Microsoft.FluentUI.AspNetCore.Components.Tests; + +namespace FluentUI.Blazor.Community.Components.Tests.Components.Audio; + +public class PreviousButtonTests : TestBase +{ + public PreviousButtonTests() + { + JSInterop.Mode = Bunit.JSRuntimeMode.Loose; + Services.AddSingleton(UnitTestLibraryConfiguration); + Services.AddFluentUIComponents(); + Services.AddFluentCxUIComponents(); + } + + [Fact] + public void PreviousButton_OnPreviousAsync_InvokesCallback() + { + bool invoked = false; + var cut = RenderComponent(parameters => parameters + .Add(p => p.OnPrevious, EventCallback.Factory.Create(this, () => invoked = true)) + ); + + cut.InvokeAsync(() => cut.Instance.GetType().GetMethod("OnPreviousAsync", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance) + .Invoke(cut.Instance, null)); + + Assert.True(invoked); + } +} diff --git a/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/RepeatButtonTests.cs b/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/RepeatButtonTests.cs new file mode 100644 index 00000000..d08d9aac --- /dev/null +++ b/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/RepeatButtonTests.cs @@ -0,0 +1,37 @@ +using Bunit; +using FluentUI.Blazor.Community.Extensions; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.FluentUI.AspNetCore.Components; +using Microsoft.FluentUI.AspNetCore.Components.Tests; + +namespace FluentUI.Blazor.Community.Components.Tests.Components.Audio; + +public class RepeatButtonTests : TestBase +{ + public RepeatButtonTests() + { + JSInterop.Mode = Bunit.JSRuntimeMode.Loose; + Services.AddSingleton(UnitTestLibraryConfiguration); + Services.AddFluentUIComponents(); + Services.AddFluentCxUIComponents(); + } + + [Fact] + public void RepeatButton_OnChangeRepeatModeAsync_CyclesModeAndInvokesCallback() + { + AudioRepeatMode? mode = null; + var cut = RenderComponent(parameters => parameters + .Add(p => p.OnRepeatModeChanged, EventCallback.Factory.Create(this, m => mode = m)) + ); + + var method = cut.Instance.GetType().GetMethod("OnChangeRepeatModeAsync", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + + cut.InvokeAsync(() => method.Invoke(cut.Instance, null)); + Assert.NotNull(mode); + + var initialMode = mode.Value; + cut.InvokeAsync(() => method.Invoke(cut.Instance, null)); + Assert.NotEqual(initialMode, mode.Value); + } +} diff --git a/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/SeekBarTests.cs b/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/SeekBarTests.cs new file mode 100644 index 00000000..63689ff2 --- /dev/null +++ b/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/SeekBarTests.cs @@ -0,0 +1,223 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Text; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.FluentUI.AspNetCore.Components; +using Microsoft.FluentUI.AspNetCore.Components.Tests; +using Microsoft.JSInterop; +using Moq; + +namespace FluentUI.Blazor.Community.Components.Tests.Components.Audio; +public class SeekBarTests : TestBase +{ + /* private SeekBar CreateSeekBar(IJSObjectReference? module = null) + { + var seekBar = new SeekBar(); + var jsMock = Services.GetRequiredService>(); + jsMock.Setup(js => js.InvokeAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(module ?? Mock.Of()); + typeof(SeekBar).GetProperty("JS", BindingFlags.Instance | BindingFlags.NonPublic) + ?.SetValue(seekBar, jsMock.Object); + return seekBar; + } + + [Fact] + public async Task OnAfterRenderAsync_FirstRender_InitializesModule() + { + var seekBar = CreateSeekBar(); + var method = typeof(SeekBar).GetMethod("OnAfterRenderAsync", BindingFlags.Instance | BindingFlags.NonPublic); + await (Task)method.Invoke(seekBar, new object[] { true }); + + var moduleField = typeof(SeekBar).GetField("_module", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(moduleField.GetValue(seekBar)); + } + + [Fact] + public async Task OnResizeAsync_CallsMeasureWidthAsync() + { + var seekBar = CreateSeekBar(); + var method = typeof(SeekBar).GetMethod("OnResizeAsync", BindingFlags.Instance | BindingFlags.Public); + await (Task)method.Invoke(seekBar, null); + } + + [Fact] + public async Task MeasureWidthAsync_SetsContainerWidth() + { + var moduleMock = new Mock(); + moduleMock.Setup(m => m.InvokeAsync("fluentCxSeekBar.getWidth", It.IsAny())) + .ReturnsAsync(123.0); + var seekBar = CreateSeekBar(moduleMock.Object); + typeof(SeekBar).GetField("_module", BindingFlags.Instance | BindingFlags.NonPublic) + ?.SetValue(seekBar, moduleMock.Object); + + var method = typeof(SeekBar).GetMethod("MeasureWidthAsync", BindingFlags.Instance | BindingFlags.NonPublic); + await (Task)method.Invoke(seekBar, null); + + var width = (double)typeof(SeekBar).GetField("_containerWidth", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(seekBar); + Assert.Equal(123.0, width); + } + + [Fact] + public async Task StartDragAsync_SetsIsDraggingAndCallsUpdateSeek() + { + var seekBar = CreateSeekBar(); + var pointerArgs = new PointerEventArgs { ClientX = 50 }; + var method = typeof(SeekBar).GetMethod("StartDragAsync", BindingFlags.Instance | BindingFlags.NonPublic); + await (Task)method.Invoke(seekBar, new object[] { pointerArgs }); + var isDragging = (bool)typeof(SeekBar).GetField("_isDragging", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(seekBar); + Assert.True(isDragging); + } + + [Fact] + public async Task OnDragAsync_WhenDragging_CallsUpdateSeek() + { + var seekBar = CreateSeekBar(); + typeof(SeekBar).GetField("_isDragging", BindingFlags.Instance | BindingFlags.NonPublic).SetValue(seekBar, true); + var pointerArgs = new PointerEventArgs { ClientX = 60 }; + var method = typeof(SeekBar).GetMethod("OnDragAsync", BindingFlags.Instance | BindingFlags.NonPublic); + await (Task)method.Invoke(seekBar, new object[] { pointerArgs }); + } + + [Fact] + public async Task EndDragAsync_ResetsIsDraggingAndShowPreview() + { + var seekBar = CreateSeekBar(); + typeof(SeekBar).GetField("_isDragging", BindingFlags.Instance | BindingFlags.NonPublic).SetValue(seekBar, true); + var pointerArgs = new PointerEventArgs(); + var method = typeof(SeekBar).GetMethod("EndDragAsync", BindingFlags.Instance | BindingFlags.NonPublic); + await (Task)method.Invoke(seekBar, new object[] { pointerArgs }); + Assert.False((bool)typeof(SeekBar).GetField("_isDragging", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(seekBar)); + Assert.False((bool)typeof(SeekBar).GetField("_showPreview", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(seekBar)); + } + + [Fact] + public async Task StartTouchAsync_SetsIsDraggingAndCallsUpdateSeek() + { + var seekBar = CreateSeekBar(); + var touchArgs = new TouchEventArgs { Touches = new[] { new TouchPoint { ClientX = 70 } } }; + var method = typeof(SeekBar).GetMethod("StartTouchAsync", BindingFlags.Instance | BindingFlags.NonPublic); + await (Task)method.Invoke(seekBar, new object[] { touchArgs }); + Assert.True((bool)typeof(SeekBar).GetField("_isDragging", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(seekBar)); + } + + [Fact] + public async Task OnTouchMoveAsync_WhenDragging_CallsUpdateSeek() + { + var seekBar = CreateSeekBar(); + typeof(SeekBar).GetField("_isDragging", BindingFlags.Instance | BindingFlags.NonPublic).SetValue(seekBar, true); + var touchArgs = new TouchEventArgs { Touches = new[] { new TouchPoint { ClientX = 80 } } }; + var method = typeof(SeekBar).GetMethod("OnTouchMoveAsync", BindingFlags.Instance | BindingFlags.NonPublic); + await (Task)method.Invoke(seekBar, new object[] { touchArgs }); + } + + [Fact] + public async Task EndTouchAsync_ResetsIsDraggingAndShowPreview() + { + var seekBar = CreateSeekBar(); + typeof(SeekBar).GetField("_isDragging", BindingFlags.Instance | BindingFlags.NonPublic).SetValue(seekBar, true); + var touchArgs = new TouchEventArgs { Touches = new[] { new TouchPoint { ClientX = 90 } } }; + var method = typeof(SeekBar).GetMethod("EndTouchAsync", BindingFlags.Instance | BindingFlags.NonPublic); + await (Task)method.Invoke(seekBar, new object[] { touchArgs }); + Assert.False((bool)typeof(SeekBar).GetField("_isDragging", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(seekBar)); + Assert.False((bool)typeof(SeekBar).GetField("_showPreview", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(seekBar)); + } + + [Fact] + public async Task OnKeyDownAsync_HandlesKeys() + { + var seekBar = CreateSeekBar(); + seekBar.Duration = 100; + seekBar.CurrentTime = 50; + seekBar.Step = 10; + var keyArgs = new FluentKeyCodeEventArgs { Key = KeyCode.Left }; + var method = typeof(SeekBar).GetMethod("OnKeyDownAsync", BindingFlags.Instance | BindingFlags.NonPublic); + await (Task)method.Invoke(seekBar, new object[] { keyArgs }); + Assert.Equal(40, seekBar.CurrentTime); + } + + [Fact] + public async Task UpdateSeekAsync_ClampsPercentAndCallsSeekToAsync() + { + var seekBar = CreateSeekBar(); + seekBar.Duration = 100; + typeof(SeekBar).GetField("_containerWidth", BindingFlags.Instance | BindingFlags.NonPublic).SetValue(seekBar, 100); + var method = typeof(SeekBar).GetMethod("UpdateSeekAsync", BindingFlags.Instance | BindingFlags.NonPublic); + await (Task)method.Invoke(seekBar, new object[] { 50.0 }); + Assert.Equal(50, seekBar.CurrentTime); + } + + [Fact] + public async Task UpdateSeekFromClientXAsync_WithoutModule_DoesNothing() + { + var seekBar = CreateSeekBar(null); + var method = typeof(SeekBar).GetMethod("UpdateSeekFromClientXAsync", BindingFlags.Instance | BindingFlags.NonPublic); + await (Task)method.Invoke(seekBar, new object[] { 100.0 }); + } + + [Fact] + public async Task SeekToAsync_ClampsTimeAndSetsPreview() + { + var seekBar = CreateSeekBar(); + seekBar.Duration = 100; + seekBar.Chapters = new List { new Chapter(0, 100, "Chapter 1", ChapterStatus.NotStarted) }; + var method = typeof(SeekBar).GetMethod("SeekToAsync", BindingFlags.Instance | BindingFlags.NonPublic); + await (Task)method.Invoke(seekBar, new object[] { 120.0, true }); + Assert.Equal(100, seekBar.CurrentTime); + Assert.True((bool)typeof(SeekBar).GetField("_showPreview", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(seekBar)); + } + + [Fact] + public async Task HidePreviewAfterDelayAsync_SetsShowPreviewFalse() + { + var seekBar = CreateSeekBar(); + typeof(SeekBar).GetField("_showPreview", BindingFlags.Instance | BindingFlags.NonPublic).SetValue(seekBar, true); + var method = typeof(SeekBar).GetMethod("HidePreviewAfterDelayAsync", BindingFlags.Instance | BindingFlags.NonPublic); + var cts = new CancellationTokenSource(); + await (Task)method.Invoke(seekBar, new object[] { cts.Token }); + Assert.False((bool)typeof(SeekBar).GetField("_showPreview", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(seekBar)); + } + + [Fact] + public async Task OnClickSeek_WhenDragging_DoesNothing() + { + var seekBar = CreateSeekBar(); + typeof(SeekBar).GetField("_isDragging", BindingFlags.Instance | BindingFlags.NonPublic).SetValue(seekBar, true); + var mouseArgs = new MouseEventArgs { ClientX = 10 }; + var method = typeof(SeekBar).GetMethod("OnClickSeek", BindingFlags.Instance | BindingFlags.NonPublic); + await (Task)method.Invoke(seekBar, new object[] { mouseArgs }); + } + + [Fact] + public void GetChapterColor_ReturnsExpectedGradient() + { + var method = typeof(SeekBar).GetMethod("GetChapterColor", BindingFlags.Static | BindingFlags.NonPublic); + Assert.Equal("linear-gradient(90deg, #87d068, #5cb85c)", method.Invoke(null, new object[] { ChapterStatus.Completed })); + Assert.Equal("linear-gradient(90deg, #ffd773, #ffb900)", method.Invoke(null, new object[] { ChapterStatus.Current })); + Assert.Equal("linear-gradient(90deg, #ff8a8a, #e81123)", method.Invoke(null, new object[] { ChapterStatus.NotStarted })); + Assert.Equal("gray", method.Invoke(null, new object[] { ChapterStatus.Unspecified })); + } + + [Fact] + public void FormatTime_ReturnsFormattedString() + { + var method = typeof(SeekBar).GetMethod("FormatTime", BindingFlags.Static | BindingFlags.NonPublic); + Assert.Equal("01:40", method.Invoke(null, new object[] { 100.0 })); + } + + [Fact] + public async Task DisposeAsync_DisposesModuleAndDotNetRef() + { + var moduleMock = new Mock(); + moduleMock.Setup(m => m.InvokeVoidAsync("fluentCxSeekBar.dispose", It.IsAny())).Returns(ValueTask.CompletedTask); + moduleMock.Setup(m => m.DisposeAsync()).Returns(ValueTask.CompletedTask); + var seekBar = CreateSeekBar(moduleMock.Object); + typeof(SeekBar).GetField("_module", BindingFlags.Instance | BindingFlags.NonPublic).SetValue(seekBar, moduleMock.Object); + var method = typeof(SeekBar).GetMethod("DisposeAsync", BindingFlags.Instance | BindingFlags.Public); + await (ValueTask)method.Invoke(seekBar, null); + } + */ +} diff --git a/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/ShuffleButtonTests.cs b/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/ShuffleButtonTests.cs new file mode 100644 index 00000000..f8db54c0 --- /dev/null +++ b/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/ShuffleButtonTests.cs @@ -0,0 +1,34 @@ +using Bunit; +using FluentUI.Blazor.Community.Extensions; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.FluentUI.AspNetCore.Components; +using Microsoft.FluentUI.AspNetCore.Components.Tests; + +namespace FluentUI.Blazor.Community.Components.Tests.Components.Audio; + +public class ShuffleButtonTests : TestBase +{ + public ShuffleButtonTests() + { + JSInterop.Mode = Bunit.JSRuntimeMode.Loose; + Services.AddSingleton(UnitTestLibraryConfiguration); + Services.AddFluentUIComponents(); + Services.AddFluentCxUIComponents(); + } + + [Fact] + public void ShuffleButton_OnToggleShuffleAsync_TogglesStateAndInvokesCallback() + { + bool? shuffleState = null; + var cut = RenderComponent(parameters => parameters + .Add(p => p.OnShuffleChanged, EventCallback.Factory.Create(this, s => shuffleState = s)) + ); + + cut.InvokeAsync(() => cut.Instance.GetType().GetMethod("OnToggleShuffleAsync", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance) + .Invoke(cut.Instance, null)); + + Assert.NotNull(shuffleState); + Assert.True(shuffleState.Value || !shuffleState.Value); + } +} diff --git a/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/StopButtonTests.cs b/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/StopButtonTests.cs new file mode 100644 index 00000000..4a029124 --- /dev/null +++ b/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/StopButtonTests.cs @@ -0,0 +1,33 @@ +using Bunit; +using FluentUI.Blazor.Community.Extensions; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.FluentUI.AspNetCore.Components; +using Microsoft.FluentUI.AspNetCore.Components.Tests; + +namespace FluentUI.Blazor.Community.Components.Tests.Components.Audio; + +public class StopButtonTests : TestBase +{ + public StopButtonTests() + { + JSInterop.Mode = Bunit.JSRuntimeMode.Loose; + Services.AddSingleton(UnitTestLibraryConfiguration); + Services.AddFluentUIComponents(); + Services.AddFluentCxUIComponents(); + } + + [Fact] + public void StopButton_OnStopAsync_InvokesCallback() + { + var invoked = false; + var cut = RenderComponent(parameters => parameters + .Add(p => p.OnStop, EventCallback.Factory.Create(this, () => invoked = true)) + ); + + cut.InvokeAsync(() => cut.Instance.GetType().GetMethod("OnStopAsync", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance) + .Invoke(cut.Instance, null)); + + Assert.True(invoked); + } +} diff --git a/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/TrackInfoTests.cs b/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/TrackInfoTests.cs new file mode 100644 index 00000000..180041fd --- /dev/null +++ b/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/TrackInfoTests.cs @@ -0,0 +1,80 @@ +using Bunit; +using Microsoft.AspNetCore.Components; +using Microsoft.FluentUI.AspNetCore.Components.Tests; + +namespace FluentUI.Blazor.Community.Components.Tests.Components.Audio; + +public class TrackInfoTests : TestBase +{ + [Fact] + public void RendersNothing_WhenTrackIsNull() + { + // Arrange & Act + var cut = RenderComponent(parameters => parameters + .Add(p => p.Track, null) + ); + + // Assert + Assert.Contains("stack-horizontal", cut.Markup); + Assert.DoesNotContain("stack-vertical", cut.Markup); + } + + [Fact] + public void RendersTrackInfo_WhenTrackIsProvided() + { + var track = new AudioTrackItem + { + Title = "Test Title", + Artist = "Test Artist", + Cover = "cover.png" + }; + + var cut = RenderComponent(parameters => parameters + .Add(p => p.Track, track) + ); + + cut.Find("img").MarkupMatches(@""); + + cut.Markup.Contains("Test Title"); + cut.Markup.Contains("Test Artist"); + cut.Markup.Contains("color: var(--accent-fill-rest)"); + cut.Markup.Contains("color: var(--neutral-fill-inverse-rest)"); + } + + [Fact] + public void DoesNotRenderImage_WhenTrackCoverIsNullOrEmpty() + { + var track = new AudioTrackItem + { + Title = "Test Title", + Artist = "Test Artist", + Cover = null + }; + + var cut = RenderComponent(parameters => parameters + .Add(p => p.Track, track) + ); + + Assert.Empty(cut.FindAll("img")); + } + + [Fact] + public void CallsOnHandleClickAsync_WhenClicked() + { + var track = new AudioTrackItem + { + Title = "Test Title", + Artist = "Test Artist", + Cover = "cover.png" + }; + + var clicked = false; + var cut = RenderComponent(parameters => parameters + .Add(p => p.Track, track) + .Add(p => p.OnClick, EventCallback.Factory.Create(this, (e) => clicked = true)) + ); + + cut.Find("div").Click(); + Assert.True(clicked); + } +} diff --git a/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/VolumeButtonTests.cs b/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/VolumeButtonTests.cs new file mode 100644 index 00000000..94963d8c --- /dev/null +++ b/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/VolumeButtonTests.cs @@ -0,0 +1,91 @@ +using Bunit; +using FluentUI.Blazor.Community.Extensions; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.FluentUI.AspNetCore.Components; +using Microsoft.FluentUI.AspNetCore.Components.Tests; + +namespace FluentUI.Blazor.Community.Components.Tests.Components.Audio; + +public class VolumeButtonTests : TestBase +{ + public VolumeButtonTests() + { + JSInterop.Mode = Bunit.JSRuntimeMode.Loose; + Services.AddSingleton(UnitTestLibraryConfiguration); + Services.AddFluentUIComponents(); + Services.AddFluentCxUIComponents(); + } + + [Fact] + public void VolumeLabel_DefaultValue_IsVolume() + { + var cut = RenderComponent(); + Assert.Equal("Volume", cut.Instance.VolumeLabel); + } + + [Fact] + public void Constructor_SetsUniqueId() + { + var cut = RenderComponent(); + Assert.StartsWith("volume-button-", cut.Instance.Id); + Assert.False(string.IsNullOrWhiteSpace(cut.Instance.Id)); + } + + [Theory] + [InlineData(0.0, "VolumeZero")] + [InlineData(0.2, "VolumeOne")] + [InlineData(0.6, "VolumeTwo")] + public void VolumeIcon_ReturnsExpectedIcon(double volume, string expectedIconField) + { + var cut = RenderComponent(); + var instance = cut.Instance; + var volumeField = instance.GetType().GetField("_volume", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + volumeField.SetValue(instance, volume); + + var iconProp = instance.GetType().GetProperty("VolumeIcon", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + var icon = iconProp.GetValue(instance); + + var expectedIcon = instance.GetType().GetField(expectedIconField, System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static).GetValue(null); + Assert.Equal(expectedIcon, icon); + } + + [Theory] + [InlineData(1.0, "Volume (100%)")] + [InlineData(0.5, "Volume (50%)")] + [InlineData(0.0, "Volume (0%)")] + public void AriaLabel_ReturnsExpectedString(double volume, string expectedLabel) + { + var cut = RenderComponent(); + var instance = cut.Instance; + var volumeField = instance.GetType().GetField("_volume", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + volumeField.SetValue(instance, volume); + + var ariaLabelProp = instance.GetType().GetProperty("AriaLabel", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + var ariaLabel = ariaLabelProp.GetValue(instance); + + Assert.Equal(expectedLabel, ariaLabel); + } + + [Fact] + public async Task OnVolumeChangedAsync_InvokesCallback_WhenDelegateSet() + { + double? receivedValue = null; + var cut = RenderComponent(parameters => parameters + .Add(p => p.OnVolumeChanged, EventCallback.Factory.Create(this, v => receivedValue = v)) + ); + + var method = cut.Instance.GetType().GetMethod("OnVolumeChangedAsync", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + await (Task)method.Invoke(cut.Instance, new object[] { 0.7 }); + + Assert.Equal(0.7, receivedValue); + } + + [Fact] + public async Task OnVolumeChangedAsync_DoesNothing_WhenNoDelegate() + { + var cut = RenderComponent(); + var method = cut.Instance.GetType().GetMethod("OnVolumeChangedAsync", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + await (Task)method.Invoke(cut.Instance, new object[] { 0.3 }); + } +} From 669b112aac3134aa50b9857772480622b24f4581 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20JANISZEWSKI?= Date: Wed, 1 Oct 2025 20:50:28 +0200 Subject: [PATCH 2/8] Correct many issues with the audio player Add the metadata of the file Modify the position of the playlist Modify the compact player and floating to be better Prepare the properties --- Directory.Packages.props | 1 + .../FluentUI.Demo.Client/wwwroot/index.html | 1 + .../FluentUI.Blazor.Community.Components.xml | 514 ++++++++++++++++-- .../Layout/DemoNavProvider.cs | 6 +- .../Pages/Audio/AudioPage.razor | 7 +- .../Pages/Audio/Examples/AudioDefault.razor | 182 ++++--- .../FluentUI.Demo.Shared/wwwroot/js/blob.js | 4 + .../Components/Audio/AudioControls.razor | 4 +- .../Components/Audio/AudioControls.razor.cs | 12 + .../Components/Audio/AudioPlaylist.razor | 24 +- .../Components/Audio/AudioPlaylist.razor.cs | 20 + .../Components/Audio/AudioPlaylist.razor.css | 3 +- .../Components/Audio/AudioPlaylistItem.razor | 22 +- .../Audio/AudioPlaylistItem.razor.cs | 39 ++ .../Components/Audio/AudioTrackItem.cs | 38 +- .../Components/Audio/AudioVisualizer.razor | 15 +- .../Components/Audio/AudioVisualizer.razor.cs | 31 +- .../Components/Audio/AudioVisualizer.razor.js | 133 ++--- .../Components/Audio/CompactPlayer.razor | 14 +- .../Components/Audio/CompactPlayer.razor.cs | 6 + .../Components/Audio/CompactPlayer.razor.css | 12 + .../Audio/Controls/DownloadButton.razor | 6 +- .../Audio/Controls/DownloadButton.razor.cs | 6 +- .../Audio/Controls/NextButton.razor | 6 +- .../Audio/Controls/NextButton.razor.cs | 6 +- .../Audio/Controls/PlayOrPauseButton.razor | 1 + .../Audio/Controls/PlayOrPauseButton.razor.cs | 6 + .../Audio/Controls/PlaylistButton.razor | 4 +- .../Audio/Controls/PlaylistButton.razor.cs | 4 +- .../Audio/Controls/PreviousButton.razor | 4 +- .../Audio/Controls/PreviousButton.razor.cs | 4 +- .../Audio/Controls/PropertiesButton.razor | 8 + .../Audio/Controls/PropertiesButton.razor.cs | 47 ++ .../Audio/Controls/StopButton.razor | 5 +- .../Audio/Controls/StopButton.razor.cs | 10 +- .../Components/Audio/FluentCxAudio.razor | 66 ++- .../Components/Audio/FluentCxAudio.razor.cs | 43 +- .../Components/Audio/FluentCxAudio.razor.css | 10 - .../Audio/Metadata/AudioMetadata.cs | 35 ++ .../Audio/Metadata/AudioMetadataProvider.cs | 153 ++++++ .../Audio/Metadata/DescriptiveMetadata.cs | 56 ++ .../Audio/Metadata/ExtendedMetadata.cs | 49 ++ .../Audio/Metadata/IAudioMetadataProvider.cs | 42 ++ .../Audio/Metadata/LegalMetadata.cs | 20 + .../Audio/Metadata/StreamFileAbstraction.cs | 27 + .../Audio/Metadata/TechnicalMetadata.cs | 54 ++ .../Audio/Metadata/VisualMetadata.cs | 27 + .../Components/Audio/TrackInfo.razor | 18 +- .../Extensions/ServiceCollectionExtensions.cs | 3 +- ...luentUI.Blazor.Community.Components.csproj | 1 + .../Audio/AudioPlaylistItemTests.cs | 6 +- .../Components/Audio/AudioPlaylistTests.cs | 8 +- .../Components/Audio/CompactPlayerTests.cs | 2 +- .../Components/Audio/DownloadButtonTests.cs | 6 +- .../Components/Audio/NextButtonTests.cs | 2 +- .../Components/Audio/TrackInfoTests.cs | 30 +- 56 files changed, 1506 insertions(+), 357 deletions(-) create mode 100644 examples/demo/FluentUI.Demo.Shared/wwwroot/js/blob.js create mode 100644 src/Community.Components/Components/Audio/Controls/PropertiesButton.razor create mode 100644 src/Community.Components/Components/Audio/Controls/PropertiesButton.razor.cs create mode 100644 src/Community.Components/Components/Audio/Metadata/AudioMetadata.cs create mode 100644 src/Community.Components/Components/Audio/Metadata/AudioMetadataProvider.cs create mode 100644 src/Community.Components/Components/Audio/Metadata/DescriptiveMetadata.cs create mode 100644 src/Community.Components/Components/Audio/Metadata/ExtendedMetadata.cs create mode 100644 src/Community.Components/Components/Audio/Metadata/IAudioMetadataProvider.cs create mode 100644 src/Community.Components/Components/Audio/Metadata/LegalMetadata.cs create mode 100644 src/Community.Components/Components/Audio/Metadata/StreamFileAbstraction.cs create mode 100644 src/Community.Components/Components/Audio/Metadata/TechnicalMetadata.cs create mode 100644 src/Community.Components/Components/Audio/Metadata/VisualMetadata.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index f23ef771..3d4e99e8 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -17,6 +17,7 @@ + all diff --git a/examples/demo/FluentUI.Demo.Client/wwwroot/index.html b/examples/demo/FluentUI.Demo.Client/wwwroot/index.html index b0b6e694..dacf1a02 100644 --- a/examples/demo/FluentUI.Demo.Client/wwwroot/index.html +++ b/examples/demo/FluentUI.Demo.Client/wwwroot/index.html @@ -33,6 +33,7 @@ + diff --git a/examples/demo/FluentUI.Demo.Shared/FluentUI.Blazor.Community.Components.xml b/examples/demo/FluentUI.Demo.Shared/FluentUI.Blazor.Community.Components.xml index 1b9d3c66..71e1bd7b 100644 --- a/examples/demo/FluentUI.Demo.Shared/FluentUI.Blazor.Community.Components.xml +++ b/examples/demo/FluentUI.Demo.Shared/FluentUI.Blazor.Community.Components.xml @@ -1022,6 +1022,16 @@ Gets or sets a value indicating whether the next button is disabled.
+ + + Gets or sets a value indicating whether the stop button is disabled. + + + + + Gets or sets a value indicating whether the play/pause button is disabled. + + Sets the play/pause state of the associated control. @@ -1082,6 +1092,21 @@ Gets or sets the event callback that is invoked when a track is selected. + + + Gets or sets a value indicating whether the playlist is visible. + + + + + Gets or sets the height of the playlist component. + + + + + Gets the internal style for the component, including the height. + + Represents an item in an audio playlist, including its associated track, selection state, and actions. @@ -1100,6 +1125,16 @@ Represents the icon used to indicate the poll action for an audio track. + + + Represents whether the track has changed since the last render. + + + + + Represents the performers of the audio track. + + Initializes a new instance of the class. @@ -1135,6 +1170,12 @@ cref="P:FluentUI.Blazor.Community.Components.AudioPlaylistItem.Track"/> are properly set before calling this method. + + + + + + Specifies the repeat mode for audio playback. @@ -1175,35 +1216,25 @@ Represents an audio track with properties for title, artist, source URL, and cover URL. - - - Gets or sets the title of the audio track. - - - - - Gets or sets the artist of the audio track. - - Gets or sets the source URL of the audio track. - + - Gets or sets the cover URL of the audio track. + Gets or sets the audio metadata associated with the track. - Gets or sets the parent component. + Gets or sets the parent of this track item. - + - + @@ -1221,7 +1252,7 @@ Represents the JavaScript module for the audio visualizer component. - + Value indicating whether the visualizer mode has changed. @@ -1256,6 +1287,11 @@ Gets or sets the anchor audio element identifier. + + + Gets or sets a value indicating whether the visualizer is visible. + + Gets or sets the JavaScript runtime. @@ -1267,9 +1303,6 @@ - - - @@ -1351,6 +1384,11 @@ Gets or sets the current playback time of the track, in seconds. + + + Gets or sets a value indicating whether the player is floating. + + Gets the style string that includes the opacity for the playback controls based on the current state. @@ -1402,7 +1440,7 @@ icon. It supports an event callback that is triggered when the button is clicked, allowing developers to handle download-related logic in their applications. - + Represents the icon to be displayed on the button. @@ -1412,7 +1450,7 @@ Gets or sets the event callback that is invoked when the download button is clicked. - + Gets or sets the label for the download button. @@ -1422,7 +1460,7 @@ Initializes a new instance of the class. - + Occurs when the download button is clicked. @@ -1433,7 +1471,7 @@ Represents the previous button. - + Represents the icon displayed when shuffling is disabled. @@ -1443,7 +1481,7 @@ Gets or sets the event callback that is invoked when the next button is clicked. - + Gets or sets the label for the next button. @@ -1458,7 +1496,7 @@ Initializes a new instance of the class. - + Occurs when the next button is clicked. @@ -1474,7 +1512,7 @@ Indicates whether the playlist should be displayed. - + Represents the icon displayed when shuffling is disabled. @@ -1484,7 +1522,7 @@ Gets or sets the event callback that is invoked when the stop button is clicked. - + Gets or sets the label for the previous button. @@ -1540,6 +1578,11 @@ Initializes a new instance of the class. + + + Gets or sets a value indicating whether the button is disabled. + + Occurs when the shuffle button is clicked. @@ -1560,7 +1603,7 @@ a collection. It displays an appropriate icon based on the current shuffle state and invokes a callback when the shuffle state changes. - + Represents the icon displayed when shuffling is disabled. @@ -1570,7 +1613,7 @@ Gets or sets the event callback that is invoked when the stop button is clicked. - + Gets or sets the label for the previous button. @@ -1591,6 +1634,37 @@ Returns a task which invokes the callback. + + + Represents a button component designed for initiating properties actions. + + + + + Represents the icon to be displayed on the button. + + + + + Gets or sets the event callback that is invoked when the download button is clicked. + + + + + Gets or sets the label for the download button. + + + + + Initializes a new instance of the class. + + + + + Occurs when the download button is clicked. + + Returns a task which invokes the callback. + Represents a button that allows users to cycle through different audio repeat modes. @@ -1984,7 +2058,7 @@ Represents the stop button. - + Represents the icon displaye. @@ -1994,11 +2068,16 @@ Gets or sets the event callback that is invoked when the stop button is clicked. - + Gets or sets the label for the stop button. + + + Gets or sets a value indicating whether the stop button is disabled. + + Initializes a new instance of the class. @@ -2192,6 +2271,16 @@ Gets the current audio track to be played. + + + Gets a value indicating whether the stop button should be disabled. + + + + + Gets a value indicating whether the play or pause button should be disabled. + + Gets or sets the render mode of the audio player. @@ -2217,6 +2306,11 @@ Gets or sets the JavaScript runtime for interop calls. + + + Gets or sets the height of the visualizer or the playlist viewer in pixels. + + private static readonly Icon PollIcon = new Microsoft.FluentUI.AspNetCore.Components.Icons.Filled.Size24.Poll(); + /// + /// Represents whether the track has changed since the last render. + /// + private bool _hasTrackChanged; + + /// + /// Represents the performers of the audio track. + /// + private string? _performers; + /// /// Initializes a new instance of the class. /// @@ -68,4 +79,32 @@ private async Task OnHandleClickAsync() await OnSelected.InvokeAsync(Track); } } + + /// + public override Task SetParametersAsync(ParameterView parameters) + { + _hasTrackChanged = parameters.HasValueChanged(nameof(Track), Track); + + return base.SetParametersAsync(parameters); + } + + /// + protected override void OnParametersSet() + { + base.OnParametersSet(); + + if(_hasTrackChanged) + { + _hasTrackChanged = false; + + if (Track?.Metadata?.Descriptive?.Performers.Length > 0) + { + _performers = string.Join(", ", Track.Metadata.Descriptive.Performers); + } + else + { + _performers = null; + } + } + } } diff --git a/src/Community.Components/Components/Audio/AudioTrackItem.cs b/src/Community.Components/Components/Audio/AudioTrackItem.cs index 83d83c34..8add9d05 100644 --- a/src/Community.Components/Components/Audio/AudioTrackItem.cs +++ b/src/Community.Components/Components/Audio/AudioTrackItem.cs @@ -5,20 +5,8 @@ namespace FluentUI.Blazor.Community.Components; /// /// Represents an audio track with properties for title, artist, source URL, and cover URL. /// -public sealed class AudioTrackItem : ComponentBase, IDisposable +public sealed class AudioTrackItem : ComponentBase, IAsyncDisposable { - /// - /// Gets or sets the title of the audio track. - /// - [Parameter] - public string? Title { get; set; } - - /// - /// Gets or sets the artist of the audio track. - /// - [Parameter] - public string? Artist { get; set; } - /// /// Gets or sets the source URL of the audio track. /// @@ -26,28 +14,34 @@ public sealed class AudioTrackItem : ComponentBase, IDisposable public string? Source { get; set; } /// - /// Gets or sets the cover URL of the audio track. + /// Gets or sets the audio metadata associated with the track. /// [Parameter] - public string? Cover { get; set; } + public AudioMetadata? Metadata { get; set; } /// - /// Gets or sets the parent component. + /// Gets or sets the parent of this track item. /// [CascadingParameter] private FluentCxAudio? Parent { get; set; } /// - protected override void OnInitialized() + protected override async Task OnInitializedAsync() { - base.OnInitialized(); + await base.OnInitializedAsync(); - Parent?.AddTrack(this); + if (Parent is not null) + { + await Parent.AddTrackAsync(this); + } } /// - public void Dispose() + public async ValueTask DisposeAsync() { - Parent?.RemoveTrack(this); - } + if(Parent is not null) + { + await Parent.RemoveTrackAsync(this); + } + } } diff --git a/src/Community.Components/Components/Audio/AudioVisualizer.razor b/src/Community.Components/Components/Audio/AudioVisualizer.razor index c32a8e21..a2e3b9de 100644 --- a/src/Community.Components/Components/Audio/AudioVisualizer.razor +++ b/src/Community.Components/Components/Audio/AudioVisualizer.razor @@ -1,9 +1,12 @@ @namespace FluentUI.Blazor.Community.Components @inherits FluentComponentBase - - +@if (IsVisible) +{ + + +} diff --git a/src/Community.Components/Components/Audio/AudioVisualizer.razor.cs b/src/Community.Components/Components/Audio/AudioVisualizer.razor.cs index 28cf9675..34b10b58 100644 --- a/src/Community.Components/Components/Audio/AudioVisualizer.razor.cs +++ b/src/Community.Components/Components/Audio/AudioVisualizer.razor.cs @@ -24,7 +24,7 @@ public partial class AudioVisualizer /// /// Value indicating whether the visualizer mode has changed. /// - private bool _hasModeChanged; + private bool _hasChanged; /// /// Initializes a new instance of the class. @@ -64,6 +64,12 @@ public AudioVisualizer() [Parameter, EditorRequired] public string? Anchor { get; set; } + /// + /// Gets or sets a value indicating whether the visualizer is visible. + /// + [Parameter] + public bool IsVisible { get; set; } + /// /// Gets or sets the JavaScript runtime. /// @@ -81,6 +87,14 @@ protected override async Task OnAfterRenderAsync(bool firstRender) await _module.InvokeVoidAsync("fluentCxAudioVisualizer.initialize", Id, Anchor, Mode, Cover); await _module.InvokeVoidAsync("fluentCxAudioVisualizer.setMode", Id, Mode); } + + if (_hasChanged && + _module is not null && + IsVisible) + { + _hasChanged = false; + await _module.InvokeVoidAsync("fluentCxAudioVisualizer.setMode", Id, Mode); + } } /// @@ -94,22 +108,11 @@ protected override void OnInitialized() } } - /// - protected override async Task OnParametersSetAsync() - { - if (_hasModeChanged && _module is not null) - { - _hasModeChanged = false; - await _module.InvokeVoidAsync("fluentCxAudioVisualizer.setMode", Id, Mode); - } - - await base.OnParametersSetAsync(); - } - /// public override Task SetParametersAsync(ParameterView parameters) { - _hasModeChanged = parameters.HasValueChanged(nameof(Mode), Mode); + _hasChanged = parameters.HasValueChanged(nameof(Mode), Mode) || + parameters.HasValueChanged(nameof(IsVisible), IsVisible); return base.SetParametersAsync(parameters); } diff --git a/src/Community.Components/Components/Audio/AudioVisualizer.razor.js b/src/Community.Components/Components/Audio/AudioVisualizer.razor.js index 9df85fac..67b0d21d 100644 --- a/src/Community.Components/Components/Audio/AudioVisualizer.razor.js +++ b/src/Community.Components/Components/Audio/AudioVisualizer.razor.js @@ -23,76 +23,74 @@ function bandEnergy(data, startHz, endHz) { function initialize(id, audioElement, mode, cover, color) { const canvas = document.getElementById(id); - if (canvas) { - const circles = Array.from({ length: 50 }, (x, i) => ( - { - r: i * 40 - })); - - const stars = Array.from({ length: 300 }, () => ({ - x: (Math.random() - 0.5) * canvas.width, - y: (Math.random() - 0.5) * canvas.height, - z: Math.random() * 1 + 0.2 + const circles = Array.from({ length: 50 }, (x, i) => ( + { + r: i * 40 })); - const particles = Array.from({ length: 200 }, () => ({ - x: canvas.width / 2, - y: canvas.height / 2, - angle: Math.random() * Math.PI * 2, - speed: Math.random() * 2, - size: Math.random() * 3 - })); - - const constellations = Array.from({ length: 100 }, () => ({ - x: Math.random() * canvas.width, - y: Math.random() * canvas.height - })); - - const audio = document.getElementById(audioElement); - const audioCtx = new AudioContext(); - const analyser = audioCtx.createAnalyser(); - const source = audioCtx.createMediaElementSource(audio); - source.connect(analyser); - analyser.connect(audioCtx.destination); - - audio.addEventListener("play", () => { - if (audioCtx.state === "suspended") { - audioCtx.resume(); - } - }); - - const bufferLength = analyser.frequencyBinCount; - const dataArray = new Uint8Array(bufferLength); - - _instances[id] = { - audio: audio, - mode: mode, - cover: cover, - color: color, - ctx: canvas.getContext("2d"), - width: canvas.width, - height: canvas.height, - centerX: canvas.width / 2, - centerY: canvas.height / 2, - radius: 100, - circles: circles, - stars: stars, - particles: particles, - constellations: constellations, - analyzer: analyser, - bufferLength: bufferLength, - dataArray: dataArray, - audioCtx: audioCtx - }; - } + const stars = Array.from({ length: 300 }, () => ({ + x: (Math.random() - 0.5) * canvas.width, + y: (Math.random() - 0.5) * canvas.height, + z: Math.random() * 1 + 0.2 + })); + + const particles = Array.from({ length: 200 }, () => ({ + x: canvas.width / 2, + y: canvas.height / 2, + angle: Math.random() * Math.PI * 2, + speed: Math.random() * 2, + size: Math.random() * 3 + })); + + const constellations = Array.from({ length: 100 }, () => ({ + x: Math.random() * canvas.width, + y: Math.random() * canvas.height + })); + + const audio = document.getElementById(audioElement); + const audioCtx = new AudioContext(); + const analyser = audioCtx.createAnalyser(); + const source = audioCtx.createMediaElementSource(audio); + source.connect(analyser); + analyser.connect(audioCtx.destination); + + audio.addEventListener("play", () => { + if (audioCtx.state === "suspended") { + audioCtx.resume(); + } + }); + + const bufferLength = analyser.frequencyBinCount; + const dataArray = new Uint8Array(bufferLength); + + _instances[id] = { + audio: audio, + mode: mode, + cover: cover, + color: color, + width: canvas.width, + height: canvas.height, + centerX: canvas.width / 2, + centerY: canvas.height / 2, + radius: 100, + circles: circles, + stars: stars, + particles: particles, + constellations: constellations, + analyzer: analyser, + bufferLength: bufferLength, + dataArray: dataArray, + audioCtx: audioCtx + }; } function setMode(id, mode) { const instance = _instances[id]; + const canvas = document.getElementById(id); - if (instance) { + if (instance && canvas) { const analyser = instance.analyzer; - const ctx = instance.ctx; + const ctx = canvas.getContext("2d"); const dataArray = instance.dataArray; const width = instance.width; const height = instance.height; @@ -338,17 +336,6 @@ function setMode(id, mode) { } } } - else if (mode === liquid) { - analyser.getByteTimeDomainData(dataArray); - ctx.beginPath(); - for (let i = 0; i < dataArray.length; i++) { - const x = (i / dataArray.length) * width; - const y = centerY + (dataArray[i] - 128); - if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); - } - ctx.strokeStyle = color ?? "#78e8ff"; - ctx.stroke(); - } else if (mode === fractal) { analyser.getByteFrequencyData(dataArray); const avg = dataArray.reduce((a, b) => a + b) / dataArray.length; diff --git a/src/Community.Components/Components/Audio/CompactPlayer.razor b/src/Community.Components/Components/Audio/CompactPlayer.razor index d7cd8692..2fbe1247 100644 --- a/src/Community.Components/Components/Audio/CompactPlayer.razor +++ b/src/Community.Components/Components/Audio/CompactPlayer.razor @@ -2,26 +2,26 @@ @inherits FluentComponentBase
- @if (!string.IsNullOrEmpty(CurrentTrack?.Cover)) + @if (!string.IsNullOrEmpty(CurrentTrack?.Metadata?.Visual?.CoverUrl)) { - }
- + - +
diff --git a/src/Community.Components/Components/Audio/CompactPlayer.razor.cs b/src/Community.Components/Components/Audio/CompactPlayer.razor.cs index dac81cb4..edf4c6c3 100644 --- a/src/Community.Components/Components/Audio/CompactPlayer.razor.cs +++ b/src/Community.Components/Components/Audio/CompactPlayer.razor.cs @@ -96,6 +96,12 @@ public CompactPlayer() [Parameter] public double CurrentTime { get; set; } + /// + /// Gets or sets a value indicating whether the player is floating. + /// + [Parameter] + public bool IsFloating { get; set; } + /// /// Gets the style string that includes the opacity for the playback controls based on the current state. /// diff --git a/src/Community.Components/Components/Audio/CompactPlayer.razor.css b/src/Community.Components/Components/Audio/CompactPlayer.razor.css index aaab077f..aa851cbe 100644 --- a/src/Community.Components/Components/Audio/CompactPlayer.razor.css +++ b/src/Community.Components/Components/Audio/CompactPlayer.razor.css @@ -5,6 +5,18 @@ border-radius: 8px; } + .compact-player.floating { + box-shadow: 0 4px 16px #0003; + background: #fff; + border-radius: 10px; + position: absolute; + bottom: 16px; + right: 16px; + z-index: 1000; + width: 300px; + height: 300px; + } + .compact-player-image { width: 100%; height: 100%; diff --git a/src/Community.Components/Components/Audio/Controls/DownloadButton.razor b/src/Community.Components/Components/Audio/Controls/DownloadButton.razor index aed6e772..36f8fc9a 100644 --- a/src/Community.Components/Components/Audio/Controls/DownloadButton.razor +++ b/src/Community.Components/Components/Audio/Controls/DownloadButton.razor @@ -2,7 +2,7 @@ @inherits FluentComponentBase diff --git a/src/Community.Components/Components/Audio/Controls/DownloadButton.razor.cs b/src/Community.Components/Components/Audio/Controls/DownloadButton.razor.cs index 4cd1dc8c..3ecba6df 100644 --- a/src/Community.Components/Components/Audio/Controls/DownloadButton.razor.cs +++ b/src/Community.Components/Components/Audio/Controls/DownloadButton.razor.cs @@ -14,7 +14,7 @@ public partial class DownloadButton : FluentComponentBase /// /// Represents the icon to be displayed on the button. /// - private static readonly Icon DownloadIcon = new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size24.ArrowDownload(); + private static readonly Icon Icon = new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size24.ArrowDownload(); /// /// Gets or sets the event callback that is invoked when the download button is clicked. @@ -26,7 +26,7 @@ public partial class DownloadButton : FluentComponentBase /// Gets or sets the label for the download button. /// [Parameter] - public string? DownloadLabel { get; set; } = "Download"; + public string? Label { get; set; } = "Download"; /// /// Initializes a new instance of the class. @@ -40,7 +40,7 @@ public DownloadButton() /// Occurs when the download button is clicked. /// /// Returns a task which invokes the callback. - private async Task OnDownloadAsync() + private async Task OnClickAsync() { if (OnDownload.HasDelegate) { diff --git a/src/Community.Components/Components/Audio/Controls/NextButton.razor b/src/Community.Components/Components/Audio/Controls/NextButton.razor index 0cea3b6d..52a3f905 100644 --- a/src/Community.Components/Components/Audio/Controls/NextButton.razor +++ b/src/Community.Components/Components/Audio/Controls/NextButton.razor @@ -2,8 +2,8 @@ @inherits FluentComponentBase diff --git a/src/Community.Components/Components/Audio/Controls/NextButton.razor.cs b/src/Community.Components/Components/Audio/Controls/NextButton.razor.cs index 05e1e41c..64673380 100644 --- a/src/Community.Components/Components/Audio/Controls/NextButton.razor.cs +++ b/src/Community.Components/Components/Audio/Controls/NextButton.razor.cs @@ -12,7 +12,7 @@ public partial class NextButton /// /// Represents the icon displayed when shuffling is disabled. /// - private static readonly Icon NextIcon = new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size24.Next(); + private static readonly Icon Icon = new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size24.Next(); /// /// Gets or sets the event callback that is invoked when the next button is clicked. @@ -24,7 +24,7 @@ public partial class NextButton /// Gets or sets the label for the next button. /// [Parameter] - public string? NextLabel { get; set; } = "Next"; + public string? Label { get; set; } = "Next"; /// /// Gets or sets a value indicating whether the next button is disabled. @@ -44,7 +44,7 @@ public NextButton() /// Occurs when the next button is clicked. /// /// Returns a task which invokes the callback. - private async Task OnNextAsync() + private async Task OnClickAsync() { if (OnNext.HasDelegate) { diff --git a/src/Community.Components/Components/Audio/Controls/PlayOrPauseButton.razor b/src/Community.Components/Components/Audio/Controls/PlayOrPauseButton.razor index 31b8fc06..af9e6ae8 100644 --- a/src/Community.Components/Components/Audio/Controls/PlayOrPauseButton.razor +++ b/src/Community.Components/Components/Audio/Controls/PlayOrPauseButton.razor @@ -4,6 +4,7 @@ diff --git a/src/Community.Components/Components/Audio/Controls/PlayOrPauseButton.razor.cs b/src/Community.Components/Components/Audio/Controls/PlayOrPauseButton.razor.cs index 08baed76..fade1280 100644 --- a/src/Community.Components/Components/Audio/Controls/PlayOrPauseButton.razor.cs +++ b/src/Community.Components/Components/Audio/Controls/PlayOrPauseButton.razor.cs @@ -52,6 +52,12 @@ public PlayOrPauseButton() Id = $"play-pause-button-{Identifier.NewId()}"; } + /// + /// Gets or sets a value indicating whether the button is disabled. + /// + [Parameter] + public bool IsDisabled { get; set; } + /// /// Occurs when the shuffle button is clicked. /// diff --git a/src/Community.Components/Components/Audio/Controls/PlaylistButton.razor b/src/Community.Components/Components/Audio/Controls/PlaylistButton.razor index 9f26e140..7f8ed46f 100644 --- a/src/Community.Components/Components/Audio/Controls/PlaylistButton.razor +++ b/src/Community.Components/Components/Audio/Controls/PlaylistButton.razor @@ -2,7 +2,7 @@ @inherits FluentComponentBase diff --git a/src/Community.Components/Components/Audio/Controls/PlaylistButton.razor.cs b/src/Community.Components/Components/Audio/Controls/PlaylistButton.razor.cs index d56546f7..bdcdf49c 100644 --- a/src/Community.Components/Components/Audio/Controls/PlaylistButton.razor.cs +++ b/src/Community.Components/Components/Audio/Controls/PlaylistButton.razor.cs @@ -17,7 +17,7 @@ public partial class PlaylistButton /// /// Represents the icon displayed when shuffling is disabled. /// - private static readonly Icon PlaylistIcon = new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size24.AppsList(); + private static readonly Icon Icon = new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size24.AppsList(); /// /// Gets or sets the event callback that is invoked when the stop button is clicked. @@ -29,7 +29,7 @@ public partial class PlaylistButton /// Gets or sets the label for the previous button. /// [Parameter] - public string? PlaylistLabel { get; set; } = "Playlist"; + public string? Label { get; set; } = "Playlist"; /// /// Initializes a new instance of the class. diff --git a/src/Community.Components/Components/Audio/Controls/PreviousButton.razor b/src/Community.Components/Components/Audio/Controls/PreviousButton.razor index 84483a4a..06405901 100644 --- a/src/Community.Components/Components/Audio/Controls/PreviousButton.razor +++ b/src/Community.Components/Components/Audio/Controls/PreviousButton.razor @@ -2,8 +2,8 @@ @inherits FluentComponentBase diff --git a/src/Community.Components/Components/Audio/Controls/PreviousButton.razor.cs b/src/Community.Components/Components/Audio/Controls/PreviousButton.razor.cs index eced8ca1..dd82ca92 100644 --- a/src/Community.Components/Components/Audio/Controls/PreviousButton.razor.cs +++ b/src/Community.Components/Components/Audio/Controls/PreviousButton.razor.cs @@ -15,7 +15,7 @@ public partial class PreviousButton /// /// Represents the icon displayed when shuffling is disabled. /// - private static readonly Icon PreviousIcon = new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size24.Previous(); + private static readonly Icon Icon = new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size24.Previous(); /// /// Gets or sets the event callback that is invoked when the stop button is clicked. @@ -27,7 +27,7 @@ public partial class PreviousButton /// Gets or sets the label for the previous button. /// [Parameter] - public string? PreviousLabel { get; set; } = "Previous"; + public string? Label { get; set; } = "Previous"; /// /// Gets or sets a value indicating whether the previous button is disabled. diff --git a/src/Community.Components/Components/Audio/Controls/PropertiesButton.razor b/src/Community.Components/Components/Audio/Controls/PropertiesButton.razor new file mode 100644 index 00000000..36f8fc9a --- /dev/null +++ b/src/Community.Components/Components/Audio/Controls/PropertiesButton.razor @@ -0,0 +1,8 @@ +@namespace FluentUI.Blazor.Community.Components +@inherits FluentComponentBase + + diff --git a/src/Community.Components/Components/Audio/Controls/PropertiesButton.razor.cs b/src/Community.Components/Components/Audio/Controls/PropertiesButton.razor.cs new file mode 100644 index 00000000..94921a5d --- /dev/null +++ b/src/Community.Components/Components/Audio/Controls/PropertiesButton.razor.cs @@ -0,0 +1,47 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.FluentUI.AspNetCore.Components; + +namespace FluentUI.Blazor.Community.Components; + +/// +/// Represents a button component designed for initiating properties actions. +/// +public partial class PropertiesButton : FluentComponentBase +{ + /// + /// Represents the icon to be displayed on the button. + /// + private static readonly Icon Icon = new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size24.ArrowDownload(); + + /// + /// Gets or sets the event callback that is invoked when the download button is clicked. + /// + [Parameter] + public EventCallback OnProperties { get; set; } + + /// + /// Gets or sets the label for the download button. + /// + [Parameter] + public string? Label { get; set; } = "Properties"; + + /// + /// Initializes a new instance of the class. + /// + public PropertiesButton() + { + Id = $"properties-button-{Identifier.NewId()}"; + } + + /// + /// Occurs when the download button is clicked. + /// + /// Returns a task which invokes the callback. + private async Task OnClickAsync() + { + if (OnProperties.HasDelegate) + { + await OnProperties.InvokeAsync(); + } + } +} diff --git a/src/Community.Components/Components/Audio/Controls/StopButton.razor b/src/Community.Components/Components/Audio/Controls/StopButton.razor index 1977a110..e66841b7 100644 --- a/src/Community.Components/Components/Audio/Controls/StopButton.razor +++ b/src/Community.Components/Components/Audio/Controls/StopButton.razor @@ -2,7 +2,8 @@ @inherits FluentComponentBase diff --git a/src/Community.Components/Components/Audio/Controls/StopButton.razor.cs b/src/Community.Components/Components/Audio/Controls/StopButton.razor.cs index a75be7c5..51ac489a 100644 --- a/src/Community.Components/Components/Audio/Controls/StopButton.razor.cs +++ b/src/Community.Components/Components/Audio/Controls/StopButton.razor.cs @@ -12,7 +12,7 @@ public partial class StopButton /// /// Represents the icon displaye. /// - private static readonly Icon StopIcon = new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size24.Stop(); + private static readonly Icon Icon = new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size24.Stop(); /// /// Gets or sets the event callback that is invoked when the stop button is clicked. @@ -24,7 +24,13 @@ public partial class StopButton /// Gets or sets the label for the stop button. /// [Parameter] - public string? StopLabel { get; set; } = "Stop"; + public string? Label { get; set; } = "Stop"; + + /// + /// Gets or sets a value indicating whether the stop button is disabled. + /// + [Parameter] + public bool IsDisabled { get; set; } /// /// Initializes a new instance of the class. diff --git a/src/Community.Components/Components/Audio/FluentCxAudio.razor b/src/Community.Components/Components/Audio/FluentCxAudio.razor index 43049fc6..f7f49df1 100644 --- a/src/Community.Components/Components/Audio/FluentCxAudio.razor +++ b/src/Community.Components/Components/Audio/FluentCxAudio.razor @@ -10,27 +10,23 @@ @if (View == AudioPlayerView.Default) { - @if (_showPlaylist) - { - - - - } - - + } - else if (View == AudioPlayerView.Compact || View == AudioPlayerView.Floating) + else if (View == AudioPlayerView.Compact || + View == AudioPlayerView.Floating) { - var viewClass = View == AudioPlayerView.Compact ? "compact" : "floating-player"; - private AudioTrackItem? CurrentTrack => _originalPlaylist.Count > 0 && _currentTrackIndex >= 0 && _currentTrackIndex < _originalPlaylist.Count ? _originalPlaylist[_currentTrackIndex] : null; + /// + /// Gets a value indicating whether the stop button should be disabled. + /// + private bool IsStopDisabled => CurrentTrack is null; + + /// + /// Gets a value indicating whether the play or pause button should be disabled. + /// + private bool IsPlayOrPauseDisabled => CurrentTrack is null; + /// /// Gets or sets the render mode of the audio player. /// @@ -190,6 +201,12 @@ private bool IsNextDisabled [Inject] private IJSRuntime JSRuntime { get; set; } = default!; + /// + /// Gets or sets the height of the visualizer or the playlist viewer in pixels. + /// + [Parameter] + public int Height { get; set; } = 280; + /// /// Occurs when the download button is clicked. /// @@ -199,7 +216,7 @@ private async Task OnDownloadAsync() if (CurrentTrack is not null && _module is not null) { - await _module.InvokeVoidAsync("fluentCxAudio.download", CurrentTrack.Source, CurrentTrack.Title); + await _module.InvokeVoidAsync("fluentCxAudio.download", CurrentTrack.Source, CurrentTrack?.Metadata?.Descriptive?.Title); } } @@ -530,18 +547,38 @@ public void UpdateElapsedTime(double value) /// Adds an audio track to the playlist. /// /// Audio track to add. - internal void AddTrack(AudioTrackItem audioTrack) + internal async Task AddTrackAsync(AudioTrackItem audioTrack) { _originalPlaylist.Add(audioTrack); + + if (_currentTrackIndex != 0) + { + _currentTrackIndex = 0; + await SetAudioSourceAsync(); + } + + await InvokeAsync(StateHasChanged); } /// /// Removes an audio track from the playlist. /// /// Audio track to remove. - internal void RemoveTrack(AudioTrackItem audioTrack) + internal async Task RemoveTrackAsync(AudioTrackItem audioTrack) { _originalPlaylist.Remove(audioTrack); + + if(_currentTrackIndex != 0 && _originalPlaylist.Count > 0) + { + _currentTrackIndex = 0; + await SetAudioSourceAsync(); + } + else if (_originalPlaylist.Count == 0) + { + _currentTrackIndex = -1; + } + + await InvokeAsync(StateHasChanged); } /// diff --git a/src/Community.Components/Components/Audio/FluentCxAudio.razor.css b/src/Community.Components/Components/Audio/FluentCxAudio.razor.css index ed87a57d..201ce1b0 100644 --- a/src/Community.Components/Components/Audio/FluentCxAudio.razor.css +++ b/src/Community.Components/Components/Audio/FluentCxAudio.razor.css @@ -39,16 +39,6 @@ input[type=range] { border-radius: 6px; } -.floating-player { - box-shadow: 0 4px 16px #0003; - background: #fff; - border-radius: 10px; - position: absolute; - bottom: 16px; - right: 16px; - z-index: 1000; -} - @media (max-width: 600px) { .fluent-audio-player { max-width: 100vw; diff --git a/src/Community.Components/Components/Audio/Metadata/AudioMetadata.cs b/src/Community.Components/Components/Audio/Metadata/AudioMetadata.cs new file mode 100644 index 00000000..6203a664 --- /dev/null +++ b/src/Community.Components/Components/Audio/Metadata/AudioMetadata.cs @@ -0,0 +1,35 @@ +namespace FluentUI.Blazor.Community.Components; + +/// +/// Represents a collection of metadata categories associated with an audio asset. +/// +/// This class provides access to various types of metadata, including descriptive, technical, legal, +/// and visual information. Each metadata category is represented by a corresponding property, which can be used to +/// retrieve or modify the associated metadata. +public sealed class AudioMetadata +{ + /// + /// Gets or sets the descriptive metadata associated with the current object. + /// + public DescriptiveMetadata Descriptive { get; set; } = new DescriptiveMetadata(); + + /// + /// Gets or sets the extended metadata associated with the current object. + /// + public ExtendedMetadata Extended { get; set; } = new ExtendedMetadata(); + + /// + /// Gets or sets the technical metadata associated with the current object. + /// + public TechnicalMetadata Technical { get; set; } = new TechnicalMetadata(); + + /// + /// Gets or sets the legal metadata associated with the current object. + /// + public LegalMetadata Legal { get; set; } = new LegalMetadata(); + + /// + /// Gets or sets the visual metadata associated with the current object. + /// + public VisualMetadata Visual { get; set; } = new VisualMetadata(); +} diff --git a/src/Community.Components/Components/Audio/Metadata/AudioMetadataProvider.cs b/src/Community.Components/Components/Audio/Metadata/AudioMetadataProvider.cs new file mode 100644 index 00000000..7ebc01e1 --- /dev/null +++ b/src/Community.Components/Components/Audio/Metadata/AudioMetadataProvider.cs @@ -0,0 +1,153 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Components.Forms; +using Microsoft.AspNetCore.StaticFiles; +using Microsoft.Extensions.Logging; + +namespace FluentUI.Blazor.Community.Components; + +/// +/// Provides functionality to retrieve metadata for audio files. +/// +/// This class is intended to be used for extracting and managing metadata such as title, artist, album, +/// and other relevant information from audio files. It supports various audio formats. +internal class AudioMetadataProvider(HttpClient httpClient, ILogger logger) + : IAudioMetadataProvider +{ + /// + /// Provides a static instance of used to map file extensions to MIME + /// content types. + /// + /// This instance can be used to look up MIME types for file extensions. It is initialized with + /// default mappings provided by the framework. + private static FileExtensionContentTypeProvider _fileExtensionContentTypeProvider = new(); + + /// + /// Asynchronously retrieves audio metadata from the specified file. + /// + /// This method reads the file's content into memory and extracts metadata from the stream. + /// Ensure the file size is within the limits supported by the application to avoid memory issues. + /// The file to extract audio metadata from. Must not be null. + /// A task that represents the asynchronous operation. The task result contains the extracted if the operation is successful; otherwise, null. + public async Task GetFromFileAsync(IBrowserFile browserFile) + { + using var stream = browserFile.OpenReadStream(); + using var ms = new MemoryStream(); + await stream.CopyToAsync(ms); + ms.Position = 0; + + return GetFromStream(browserFile.Name, ms); + } + + /// + /// Extracts audio metadata from a given stream. + /// + /// This method uses the TagLib library to parse the audio file and extract metadata. The + /// extracted metadata includes information such as title, album, artists, duration, bitrate, and cover art. If the + /// audio file is corrupt or in an unsupported format, the method logs the error and returns an empty object. + /// The name of the audio file, used for logging and identification purposes. + /// The input stream containing the audio file data. The stream must be readable and seekable. + /// An object containing descriptive, technical, extended, legal, and visual metadata + /// extracted from the audio file. Returns an empty object if the file is corrupt, + /// unsupported, or an error occurs during processing. + public AudioMetadata GetFromStream(string name, Stream stream) + { + try + { + var contentType = _fileExtensionContentTypeProvider.TryGetContentType(name, out var mime) ? mime : "application/octet-stream"; + using var internalStream = new MemoryStream(); + stream.CopyTo(internalStream); + internalStream.Position = 0; + using var file = TagLib.File.Create(new StreamFileAbstraction(name, internalStream, internalStream), mime, TagLib.ReadStyle.Average); + var tag = file.Tag; + + return new AudioMetadata() + { + Descriptive = new DescriptiveMetadata() + { + Title = tag.Title, + Album = tag.Album, + AlbumArtists = tag.AlbumArtists, + Comment = tag.Comment, + Composers = tag.Composers, + Conductor = tag.Conductor, + Genres = tag.Genres, + Performers = tag.Performers, + Year = tag.Year, + }, + + Technical = new TechnicalMetadata() + { + AudioBitrate = file.Properties.AudioBitrate, + AudioChannels = file.Properties.AudioChannels, + Duration = file.Properties.Duration, + SampleRate = file.Properties.AudioSampleRate, + MediaTypes = file.Properties.MediaTypes.ToString(), + FileType = file.MimeType, + FileSize = stream.Length, + Codec = file.Properties.Codecs.FirstOrDefault()?.Description, + }, + + Extended = new ExtendedMetadata() + { + TrackNumber = tag.Track, + TotalTracks = tag.TrackCount, + DiscNumber = tag.Disc, + TotalDiscs = tag.DiscCount, + BeatsPerMinute = tag.BeatsPerMinute, + Grouping = tag.Grouping, + ISRC = tag.ISRC, + Lyrics = tag.Lyrics, + }, + + Legal = new LegalMetadata() + { + Copyright = tag.Copyright, + Publisher = tag.Publisher, + }, + + Visual = new VisualMetadata() + { + Description = tag.Description, + MimeType = tag.Pictures.FirstOrDefault()?.MimeType, + Type = tag.Pictures.FirstOrDefault()?.Type.ToString(), + CoverUrl = tag.Pictures.Length > 0 ? $"data:{tag.Pictures[0].MimeType};base64,{Convert.ToBase64String(tag.Pictures[0].Data.Data)}" : null + } + }; + } + catch(TagLib.CorruptFileException ex) + { + logger.LogError(ex, "Corrupt audio file: {FileName}", name); + return new(); + } + catch (TagLib.UnsupportedFormatException ex) + { + logger.LogError(ex, "Unsupported audio format: {FileName}", name); + return new(); + } + catch (Exception ex) + { + logger.LogError(ex, "Error reading audio metadata from stream."); + return new(); + } + } + + /// + /// Retrieves audio metadata from a specified URL. + /// + /// This method downloads the audio file from the specified URL, processes it in memory, and + /// extracts its metadata. Ensure that the URL points to a valid audio file. + /// The name to associate with the audio metadata. + /// The URL of the audio file. Can be if no URL is provided. + /// An object containing the metadata of the audio file, or if + /// the metadata could not be retrieved. + public async Task GetFromUrlAsync(string name, string? url) + { + var audioData = await httpClient.GetStreamAsync(url); + using var ms = new MemoryStream(); + await audioData.CopyToAsync(ms); + + return GetFromStream(name, ms); + } +} diff --git a/src/Community.Components/Components/Audio/Metadata/DescriptiveMetadata.cs b/src/Community.Components/Components/Audio/Metadata/DescriptiveMetadata.cs new file mode 100644 index 00000000..355daee4 --- /dev/null +++ b/src/Community.Components/Components/Audio/Metadata/DescriptiveMetadata.cs @@ -0,0 +1,56 @@ +namespace FluentUI.Blazor.Community.Components; + +/// +/// Represents descriptive metadata for a musical track, including details such as title, album, performers, and other +/// related information. +/// +/// This class provides a structured way to store and access metadata commonly associated with musical +/// tracks. It includes properties for identifying the track's title, album, contributors, genres, and other +/// descriptive details. All string array properties are initialized as empty arrays to ensure safe iteration. +public class DescriptiveMetadata +{ + /// + /// Gets or sets the title associated with the object. + /// + public string? Title { get; set; } + + /// + /// Gets or sets the name of the album associated with the item. + /// + public string? Album { get; set; } + + /// + /// Gets or sets the list of performers associated with the event. + /// + public string[] Performers { get; set; } = []; + + /// + /// Gets or sets the list of album artists associated with the album. + /// + public string[] AlbumArtists { get; set; } = []; + + /// + /// Gets or sets the list of composers associated with the current context. + /// + public string[] Composers { get; set; } = []; + + /// + /// Gets or sets the name of the conductor associated with the performance. + /// + public string? Conductor { get; set; } + + /// + /// Gets or sets the list of genres associated with the item. + /// + public string[] Genres { get; set; } = []; + + /// + /// Gets or sets the year associated with the item. + /// + public uint Year { get; set; } + + /// + /// Gets or sets comments associated with the item. + /// + public string? Comment { get; set; } +} diff --git a/src/Community.Components/Components/Audio/Metadata/ExtendedMetadata.cs b/src/Community.Components/Components/Audio/Metadata/ExtendedMetadata.cs new file mode 100644 index 00000000..0c05fc19 --- /dev/null +++ b/src/Community.Components/Components/Audio/Metadata/ExtendedMetadata.cs @@ -0,0 +1,49 @@ +namespace FluentUI.Blazor.Community.Components; + +/// +/// Represents extended metadata for an audio file. +/// +public class ExtendedMetadata +{ + /// + /// Gets or sets the track number associated with the item. + /// + public uint? TrackNumber { get; set; } + + /// + /// Gets or sets the disc number associated with the item. + /// + public uint? DiscNumber { get; set; } + + /// + /// Gets or sets the total number of tracks in the collection. + /// + public uint? TotalTracks { get; set; } + + /// + /// Gets or sets the total number of discs in a collection or set. + /// + public uint? TotalDiscs { get; set; } + + /// + /// Gets or sets the lyrics of the song. + /// + public string? Lyrics { get; set; } + + /// + /// Gets or sets the International Standard Recording Code (ISRC) associated with the recording. + /// + public string? ISRC { get; set; } + + /// + /// Gets or sets the grouping identifier used to categorize related items. + /// + public string? Grouping { get; set; } + + /// + /// Gets or sets the tempo of the music in beats per minute (BPM). + /// + /// The value must be a positive integer. Common tempos range from 40 BPM (slow) to 200 BPM + /// (fast), but the property does not enforce specific limits. + public uint BeatsPerMinute { get; set; } +} diff --git a/src/Community.Components/Components/Audio/Metadata/IAudioMetadataProvider.cs b/src/Community.Components/Components/Audio/Metadata/IAudioMetadataProvider.cs new file mode 100644 index 00000000..5da7a3f3 --- /dev/null +++ b/src/Community.Components/Components/Audio/Metadata/IAudioMetadataProvider.cs @@ -0,0 +1,42 @@ +using Microsoft.AspNetCore.Components.Forms; + +namespace FluentUI.Blazor.Community.Components; + +/// +/// Defines methods for retrieving audio metadata from various sources, such as URLs or uploaded files. +/// +/// This interface provides asynchronous methods to extract metadata from audio files, including details +/// such as title, artist, album, and duration. Implementations may support different audio formats and +/// sources. +public interface IAudioMetadataProvider +{ + /// + /// Asynchronously retrieves audio metadata from the specified URL. + /// + /// This method performs an asynchronous operation to fetch and parse metadata from the provided + /// URL. Ensure the URL points to a valid and accessible audio file. + /// The URL of the audio file to retrieve metadata from. Can be null or empty, in which case the method will return + /// . + /// A task that represents the asynchronous operation. The task result contains an + /// object with the metadata of the audio file, or if the metadata could not be retrieved. + Task GetFromUrlAsync(string name, string? url); + + /// + /// Asynchronously retrieves audio metadata from the specified file. + /// + /// The file from which to extract audio metadata. Must not be null. + /// A task that represents the asynchronous operation. The task result contains the extracted if the operation is successful; otherwise, if the metadata could + /// not be retrieved. + Task GetFromFileAsync(IBrowserFile browserFile); + + /// + /// Extracts audio metadata from the specified stream. + /// + /// The caller is responsible for ensuring the stream remains open and readable during the + /// operation. The method does not modify the position of the stream. + /// The input stream containing audio data. The stream must be readable and positioned at the start of the audio + /// content. + /// An object containing the extracted metadata, such as duration, format, and bitrate. + AudioMetadata GetFromStream(string name, Stream stream); +} diff --git a/src/Community.Components/Components/Audio/Metadata/LegalMetadata.cs b/src/Community.Components/Components/Audio/Metadata/LegalMetadata.cs new file mode 100644 index 00000000..198d0c75 --- /dev/null +++ b/src/Community.Components/Components/Audio/Metadata/LegalMetadata.cs @@ -0,0 +1,20 @@ +namespace FluentUI.Blazor.Community.Components; + +/// +/// Represents metadata related to legal information, such as copyright and publisher details. +/// +/// This class is typically used to store and manage legal information associated with a document, +/// product, or other content. Both properties are optional and can be set to if the +/// corresponding information is not available. +public class LegalMetadata +{ + /// + /// Gets or sets the copyright information associated with the application. + /// + public string? Copyright { get; set; } + + /// + /// Gets or sets the name of the publisher associated with the item. + /// + public string? Publisher { get; set; } +} diff --git a/src/Community.Components/Components/Audio/Metadata/StreamFileAbstraction.cs b/src/Community.Components/Components/Audio/Metadata/StreamFileAbstraction.cs new file mode 100644 index 00000000..aa72733c --- /dev/null +++ b/src/Community.Components/Components/Audio/Metadata/StreamFileAbstraction.cs @@ -0,0 +1,27 @@ +namespace FluentUI.Blazor.Community.Components; + +/// +/// Provides an abstraction for file operations using streams, implementing the interface. +/// +/// Name or identifier of the abstraction. +/// Represents the readable stream. +/// Represents the writeable stream. +internal sealed class StreamFileAbstraction(string name, Stream readStream, Stream writeStream) + : TagLib.File.IFileAbstraction +{ + /// + public string Name { get; } = name; + + /// + public Stream ReadStream { get; } = readStream; + + /// + public Stream WriteStream { get; } = writeStream; + + /// + public void CloseStream(Stream stream) + { + stream.Close(); + } +} diff --git a/src/Community.Components/Components/Audio/Metadata/TechnicalMetadata.cs b/src/Community.Components/Components/Audio/Metadata/TechnicalMetadata.cs new file mode 100644 index 00000000..79bc58ba --- /dev/null +++ b/src/Community.Components/Components/Audio/Metadata/TechnicalMetadata.cs @@ -0,0 +1,54 @@ +namespace FluentUI.Blazor.Community.Components; + +/// +/// Represents technical metadata for a media file, including properties such as duration, bitrate, sample rate, and +/// codec information. +/// +/// This class provides a set of properties to describe the technical characteristics of a media file. +/// All properties are nullable, allowing for scenarios where specific metadata may not be available. +public class TechnicalMetadata +{ + /// + /// Gets or sets the duration of the operation. + /// + public TimeSpan? Duration { get; set; } + + /// + /// Gets or sets the bitrate of the media, in bits per second. + /// + public int? AudioBitrate { get; set; } + + /// + /// Gets or sets the sample rate, in hertz, for the audio processing operation. + /// + /// The sample rate determines the number of audio samples processed per second. Ensure the value + /// is compatible with the audio source and processing requirements. + public int? SampleRate { get; set; } + + /// + /// Gets or sets the number of channels to be used. + /// + /// A value of indicates that the default number of channels will be + /// used. + public int? AudioChannels { get; set; } + + /// + /// Gets or sets the codec used for encoding or decoding data. + /// + public string? Codec { get; set; } + + /// + /// Gets or sets the media types associated with the file. + /// + public string MediaTypes { get; set; } + + /// + /// Gets or sets the file type of the media, such as "audio/mpeg" or "video/mp4". + /// + public string FileType { get; set; } + + /// + /// Gets or sets the size of the file in bytes. + /// + public long FileSize { get; set; } +} diff --git a/src/Community.Components/Components/Audio/Metadata/VisualMetadata.cs b/src/Community.Components/Components/Audio/Metadata/VisualMetadata.cs new file mode 100644 index 00000000..bed54a3e --- /dev/null +++ b/src/Community.Components/Components/Audio/Metadata/VisualMetadata.cs @@ -0,0 +1,27 @@ +namespace FluentUI.Blazor.Community.Components; + +/// +/// Represents visual metadata, including cover image URL, MIME type, description, and type. +/// +public class VisualMetadata +{ + /// + /// Gets or sets the URL of the cover image. + /// + public string? CoverUrl { get; set; } + + /// + /// Gets or sets the MIME type of the cover image. + /// + public string? MimeType { get; set; } + + /// + /// Gets or sets the description associated with the object. + /// + public string? Description { get; set; } + + /// + /// Gets or sets the type of the entity or object. + /// + public string? Type { get; set; } +} diff --git a/src/Community.Components/Components/Audio/TrackInfo.razor b/src/Community.Components/Components/Audio/TrackInfo.razor index 6b215835..b84d51e1 100644 --- a/src/Community.Components/Components/Audio/TrackInfo.razor +++ b/src/Community.Components/Components/Audio/TrackInfo.razor @@ -8,18 +8,20 @@ @onclick="OnHandleClickAsync"> @if (Track is not null) { - @if (!string.IsNullOrEmpty(Track.Cover)) + @if (!string.IsNullOrEmpty(Track?.Metadata?.Visual?.CoverUrl)) { - +
+ +
} - @Track.Title - @Track.Artist + @Track?.Metadata?.Descriptive?.Title + @(string.Join(", ", Track?.Metadata?.Descriptive?.Performers)) } diff --git a/src/Community.Components/Extensions/ServiceCollectionExtensions.cs b/src/Community.Components/Extensions/ServiceCollectionExtensions.cs index e426a37b..a61165f8 100644 --- a/src/Community.Components/Extensions/ServiceCollectionExtensions.cs +++ b/src/Community.Components/Extensions/ServiceCollectionExtensions.cs @@ -18,6 +18,7 @@ public static IServiceCollection AddFluentCxUIComponents(this IServiceCollection { return services.AddScoped(typeof(DropZoneState<>)) .AddScoped() - .AddScoped(); + .AddScoped() + .AddScoped(); } } diff --git a/src/Community.Components/FluentUI.Blazor.Community.Components.csproj b/src/Community.Components/FluentUI.Blazor.Community.Components.csproj index 27babf6b..a823b167 100644 --- a/src/Community.Components/FluentUI.Blazor.Community.Components.csproj +++ b/src/Community.Components/FluentUI.Blazor.Community.Components.csproj @@ -70,6 +70,7 @@ + diff --git a/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/AudioPlaylistItemTests.cs b/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/AudioPlaylistItemTests.cs index 095202bc..0654993c 100644 --- a/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/AudioPlaylistItemTests.cs +++ b/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/AudioPlaylistItemTests.cs @@ -36,7 +36,7 @@ public void AudioPlaylistItem_Rendered_HasUniqueId() public void AudioPlaylistItem_Track_ParameterIsSet() { // Arrange - var track = new AudioTrackItem { Title = "Track", Artist = "Artist", Source = "track.mp3", Cover = "cover.png" }; + var track = new AudioTrackItem { Source = "track.mp3" }; // Act var cut = RenderComponent(parameters => parameters @@ -93,7 +93,7 @@ public void AudioPlaylistItem_TrackIcon_ReturnsPlayIcon_WhenNotSelected() public async Task AudioPlaylistItem_OnHandleClickAsync_InvokesCallback_WhenTrackAndDelegateSet() { // Arrange - var track = new AudioTrackItem { Title = "Track", Artist = "Artist", Source = "track.mp3", Cover = "cover.png" }; + var track = new AudioTrackItem { Source = "track.mp3" }; bool callbackInvoked = false; var cut = RenderComponent(parameters => parameters .Add(p => p.Track, track) @@ -129,7 +129,7 @@ public async Task AudioPlaylistItem_OnHandleClickAsync_DoesNotInvokeCallback_Whe public async Task AudioPlaylistItem_OnHandleClickAsync_DoesNotInvokeCallback_WhenDelegateNotSet() { // Arrange - var track = new AudioTrackItem { Title = "Track", Artist = "Artist", Source = "track.mp3", Cover = "cover.png" }; + var track = new AudioTrackItem { Source = "track.mp3" }; var cut = RenderComponent(parameters => parameters .Add(p => p.Track, track) ); diff --git a/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/AudioPlaylistTests.cs b/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/AudioPlaylistTests.cs index d9d9a715..ac0e4ec0 100644 --- a/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/AudioPlaylistTests.cs +++ b/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/AudioPlaylistTests.cs @@ -35,8 +35,8 @@ public void AudioPlaylist_Rendered_HasUniqueId() public void AudioPlaylist_Playlist_ParameterIsSet() { // Arrange - var track1 = new AudioTrackItem { Title = "Track 1", Artist = "Artist 1", Source = "track1.mp3", Cover = "cover1.png" }; - var track2 = new AudioTrackItem { Title = "Track 2", Artist = "Artist 2", Source = "track2.mp3", Cover = "cover2.png" }; + var track1 = new AudioTrackItem { Source = "track1.mp3" }; + var track2 = new AudioTrackItem { Source = "track2.mp3" }; var playlist = new List { track1, track2 }; // Act @@ -52,7 +52,7 @@ public void AudioPlaylist_Playlist_ParameterIsSet() public void AudioPlaylist_CurrentTrack_ParameterIsSet() { // Arrange - var track = new AudioTrackItem { Title = "Current", Artist = "Artist", Source = "current.mp3", Cover = "cover.png" }; + var track = new AudioTrackItem { Source = "current.mp3" }; // Act var cut = RenderComponent(parameters => parameters @@ -67,7 +67,7 @@ public void AudioPlaylist_CurrentTrack_ParameterIsSet() public async Task AudioPlaylist_OnTrackSelected_EventCallbackIsTriggered() { // Arrange - var track = new AudioTrackItem { Title = "Selected", Artist = "Artist", Source = "selected.mp3", Cover = "cover.png" }; + var track = new AudioTrackItem { Source = "selected.mp3" }; bool callbackInvoked = false; var cut = RenderComponent(parameters => parameters .Add(p => p.OnTrackSelected, EventCallback.Factory.Create(this, (selectedTrack) => { callbackInvoked = true; })) diff --git a/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/CompactPlayerTests.cs b/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/CompactPlayerTests.cs index aab6d34c..53bf0646 100644 --- a/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/CompactPlayerTests.cs +++ b/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/CompactPlayerTests.cs @@ -37,7 +37,7 @@ public void CompactPlayer_Rendered_HasUniqueId() public void CompactPlayer_CurrentTrack_ParameterIsSet() { // Arrange - var track = new AudioTrackItem { Title = "Test", Artist = "Art", Source = "src.mp3", Cover = "cover.png" }; + var track = new AudioTrackItem { Source = "src.mp3" }; // Act var cut = RenderComponent(parameters => parameters diff --git a/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/DownloadButtonTests.cs b/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/DownloadButtonTests.cs index 014eb544..fb90fb6e 100644 --- a/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/DownloadButtonTests.cs +++ b/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/DownloadButtonTests.cs @@ -24,7 +24,7 @@ public void DownloadLabel_DefaultValue_IsDownload() var cut = RenderComponent(); // Assert - Assert.Equal("Download", cut.Instance.DownloadLabel); + Assert.Equal("Download", cut.Instance.Label); } [Fact] @@ -49,7 +49,7 @@ public async Task OnDownloadAsync_InvokesCallback_WhenDelegateSet() // Act await (Task)cut.Instance.GetType() - .GetMethod("OnDownloadAsync", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)! + .GetMethod("OnClickAsync", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)! .Invoke(cut.Instance, null); // Assert @@ -64,7 +64,7 @@ public async Task OnDownloadAsync_DoesNothing_WhenNoDelegate() // Act & Assert (should not throw) await (Task)cut.Instance.GetType() - .GetMethod("OnDownloadAsync", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)! + .GetMethod("OnClickAsync", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)! .Invoke(cut.Instance, null); } } diff --git a/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/NextButtonTests.cs b/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/NextButtonTests.cs index fb880bba..61007fc2 100644 --- a/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/NextButtonTests.cs +++ b/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/NextButtonTests.cs @@ -25,7 +25,7 @@ public void NextButton_OnNextAsync_InvokesCallback() .Add(p => p.OnNext, EventCallback.Factory.Create(this, () => invoked = true)) ); - cut.InvokeAsync(() => cut.Instance.GetType().GetMethod("OnNextAsync", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance) + cut.InvokeAsync(() => cut.Instance.GetType().GetMethod("OnClickAsync", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance) .Invoke(cut.Instance, null)); Assert.True(invoked); diff --git a/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/TrackInfoTests.cs b/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/TrackInfoTests.cs index 180041fd..ee99a507 100644 --- a/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/TrackInfoTests.cs +++ b/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/TrackInfoTests.cs @@ -24,16 +24,28 @@ public void RendersTrackInfo_WhenTrackIsProvided() { var track = new AudioTrackItem { - Title = "Test Title", - Artist = "Test Artist", - Cover = "cover.png" + Source = "track.mp3", + Metadata = new() + { + Descriptive = new() + { + Title = "Test Title", + Performers = ["Test Artist"], + }, + Visual = new() + { + CoverUrl = "cover.png", + }, + } }; var cut = RenderComponent(parameters => parameters .Add(p => p.Track, track) ); - cut.Find("img").MarkupMatches(@""); + var img = cut.Find("img"); + + img.MarkupMatches(@""); cut.Markup.Contains("Test Title"); cut.Markup.Contains("Test Artist"); @@ -46,9 +58,8 @@ public void DoesNotRenderImage_WhenTrackCoverIsNullOrEmpty() { var track = new AudioTrackItem { - Title = "Test Title", - Artist = "Test Artist", - Cover = null + Source = "track.mp3", + Metadata = new() }; var cut = RenderComponent(parameters => parameters @@ -63,9 +74,8 @@ public void CallsOnHandleClickAsync_WhenClicked() { var track = new AudioTrackItem { - Title = "Test Title", - Artist = "Test Artist", - Cover = "cover.png" + Source = "track.mp3", + Metadata = new() }; var clicked = false; From 1d4232f56f94314940371223f34689c0c4b93e6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20JANISZEWSKI?= Date: Wed, 1 Oct 2025 21:10:43 +0200 Subject: [PATCH 3/8] Adds the labels for Localisation --- .../FluentUI.Blazor.Community.Components.xml | 101 +++++++++++++++++- .../Components/Audio/AudioControls.razor | 34 ++++-- .../Components/Audio/AudioControls.razor.cs | 6 ++ .../Components/Audio/AudioLabels.cs | 94 ++++++++++++++++ .../Audio/Controls/ShuffleButton.razor | 2 +- .../Audio/Controls/ShuffleButton.razor.cs | 10 +- .../Audio/Controls/VolumeButton.razor | 2 +- .../Audio/Controls/VolumeButton.razor.cs | 4 +- .../Components/Audio/FluentCxAudio.razor | 15 +-- .../Components/Audio/FluentCxAudio.razor.cs | 6 ++ .../Components/Audio/MinimalPlayer.razor | 4 +- .../Components/Audio/MinimalPlayer.razor.cs | 12 +++ .../Components/Audio/VolumeButtonTests.cs | 2 +- 13 files changed, 266 insertions(+), 26 deletions(-) create mode 100644 src/Community.Components/Components/Audio/AudioLabels.cs diff --git a/examples/demo/FluentUI.Demo.Shared/FluentUI.Blazor.Community.Components.xml b/examples/demo/FluentUI.Demo.Shared/FluentUI.Blazor.Community.Components.xml index 71e1bd7b..db1e6310 100644 --- a/examples/demo/FluentUI.Demo.Shared/FluentUI.Blazor.Community.Components.xml +++ b/examples/demo/FluentUI.Demo.Shared/FluentUI.Blazor.Community.Components.xml @@ -962,6 +962,11 @@ Initializes a new instance of the class.
+ + + Gets or sets the labels for the audio control buttons. + + Gets or sets a value indicating whether the download button is visible. @@ -1039,6 +1044,76 @@ A value indicating whether the control should reflect the "playing" state. if the control should indicate playback is active; otherwise, . + + + Gets the label for the play button. + + + + + Gets the label for the pause button. + + + + + Gets the label for the previous button. + + + + + Gets the label for the next button. + + + + + Gets the label for the volume control. + + + + + Gets the label for the shuffle on button. + + + + + Gets the label for the shuffle off button. + + + + + Gets the label for the playlist button. + + + + + Gets the label for the download button. + + + + + Gets the label for the stop button. + + + + + Gets the label for the playlist loop mode. + + + + + Gets the label for the playlist once mode. + + + + + Gets the label for the single loop mode. + + + + + Gets the label for the single once mode. + + Specifies the rendering mode for an audio player. @@ -2037,9 +2112,14 @@ Gets or sets the event callback that is invoked when the shuffle state changes. - + - Gets or sets the label for the shuffle button. + Gets or sets the label for the shuffle on button. + + + + + Gets or sets the label displayed when the shuffle feature is turned off. @@ -2136,7 +2216,7 @@ Gets or sets the callback that is invoked when the volume changes. - + Gets or sets the label for the volume button. @@ -2311,6 +2391,11 @@ Gets or sets the height of the visualizer or the playlist viewer in pixels.
+ + + Gets or sets the labels for the audio control buttons. + + Occurs when the download button is clicked. @@ -2822,6 +2907,16 @@ Gets or sets the event callback that is triggered when the play/pause button is clicked. + + + Gets or sets the label for the play button. + + + + + Gets or sets the label for the pause button. + + Represents a component that displays information about an audio track. diff --git a/src/Community.Components/Components/Audio/AudioControls.razor b/src/Community.Components/Components/Audio/AudioControls.razor index fa90e67c..ebb7dee8 100644 --- a/src/Community.Components/Components/Audio/AudioControls.razor +++ b/src/Community.Components/Components/Audio/AudioControls.razor @@ -5,25 +5,41 @@ VerticalAlignment=VerticalAlignment.Center HorizontalAlignment="HorizontalAlignment.Center" HorizontalGap="30"> - + - + - + - + - + - + - + - + @if (IsDownloadVisible) { - + } diff --git a/src/Community.Components/Components/Audio/AudioControls.razor.cs b/src/Community.Components/Components/Audio/AudioControls.razor.cs index 9244aa7c..a169615b 100644 --- a/src/Community.Components/Components/Audio/AudioControls.razor.cs +++ b/src/Community.Components/Components/Audio/AudioControls.razor.cs @@ -21,6 +21,12 @@ public AudioControls() Id = $"audio-controls-{Identifier.NewId()}"; } + /// + /// Gets or sets the labels for the audio control buttons. + /// + [Parameter] + public AudioLabels Labels { get; set; } = AudioLabels.Default; + /// /// Gets or sets a value indicating whether the download button is visible. /// diff --git a/src/Community.Components/Components/Audio/AudioLabels.cs b/src/Community.Components/Components/Audio/AudioLabels.cs new file mode 100644 index 00000000..30d761cc --- /dev/null +++ b/src/Community.Components/Components/Audio/AudioLabels.cs @@ -0,0 +1,94 @@ +namespace FluentUI.Blazor.Community.Components; + +public record AudioLabels +{ + public static AudioLabels Default { get; } = new AudioLabels(); + + public static AudioLabels French { get; } = new AudioLabels + { + PlayLabel = "Lire", + PauseLabel = "Pause", + PreviousLabel = "Piste précédente", + NextLabel = "Piste suivante", + VolumeLabel = "Volume", + ShuffleOnLabel = "Mélange activé", + ShuffleOffLabel = "Mélange désactivé", + PlaylistLabel = "Liste de lecture", + DownloadLabel = "Télécharger", + StopLabel = "Arrêter", + PlaylistLoopLabel = "Automatic playback of the next music track and repeat of the playlist", + PlaylistOnceLabel = "Automatic playback of the next music track and stop at the end of the last track", + SingleLoopLabel = "Play the music on loop", + SingleOnceLabel = "Stop playing at the end of the music" + }; + + /// + /// Gets the label for the play button. + /// + public string PlayLabel { get; init; } = "Play"; + + /// + /// Gets the label for the pause button. + /// + public string PauseLabel { get; init; } = "Pause"; + + /// + /// Gets the label for the previous button. + /// + public string PreviousLabel { get; init; } = "Previous track"; + + /// + /// Gets the label for the next button. + /// + public string NextLabel { get; init; } = "Next track"; + + /// + /// Gets the label for the volume control. + /// + public string VolumeLabel { get; init; } = "Volume"; + + /// + /// Gets the label for the shuffle on button. + /// + public string ShuffleOnLabel { get; init; } = "Shuffle on"; + + /// + /// Gets the label for the shuffle off button. + /// + public string ShuffleOffLabel { get; init; } = "Shuffle off"; + + /// + /// Gets the label for the playlist button. + /// + public string PlaylistLabel { get; init; } = "Playlist"; + + /// + /// Gets the label for the download button. + /// + public string DownloadLabel { get; init; } = "Download"; + + /// + /// Gets the label for the stop button. + /// + public string StopLabel { get; init; } = "Stop"; + + /// + /// Gets the label for the playlist loop mode. + /// + public string PlaylistLoopLabel { get; init; } = "Playlist loop"; + + /// + /// Gets the label for the playlist once mode. + /// + public string PlaylistOnceLabel { get; init; } = "Playlist once"; + + /// + /// Gets the label for the single loop mode. + /// + public string SingleLoopLabel { get; init; } = "Single loop"; + + /// + /// Gets the label for the single once mode. + /// + public string SingleOnceLabel { get; init; } = "Single once"; +} diff --git a/src/Community.Components/Components/Audio/Controls/ShuffleButton.razor b/src/Community.Components/Components/Audio/Controls/ShuffleButton.razor index 0a6685ff..33ac2227 100644 --- a/src/Community.Components/Components/Audio/Controls/ShuffleButton.razor +++ b/src/Community.Components/Components/Audio/Controls/ShuffleButton.razor @@ -3,5 +3,5 @@ diff --git a/src/Community.Components/Components/Audio/Controls/ShuffleButton.razor.cs b/src/Community.Components/Components/Audio/Controls/ShuffleButton.razor.cs index 963a8d35..f2dbf096 100644 --- a/src/Community.Components/Components/Audio/Controls/ShuffleButton.razor.cs +++ b/src/Community.Components/Components/Audio/Controls/ShuffleButton.razor.cs @@ -28,10 +28,16 @@ public partial class ShuffleButton public EventCallback OnShuffleChanged { get; set; } /// - /// Gets or sets the label for the shuffle button. + /// Gets or sets the label for the shuffle on button. /// [Parameter] - public string? ShuffleLabel { get; set; } = "Shuffle"; + public string? ShuffleOnLabel { get; set; } = "Shuffle on"; + + /// + /// Gets or sets the label displayed when the shuffle feature is turned off. + /// + [Parameter] + public string? ShuffleOffLabel { get; set; } = "Shuffle off"; /// /// Initializes a new instance of the class. diff --git a/src/Community.Components/Components/Audio/Controls/VolumeButton.razor b/src/Community.Components/Components/Audio/Controls/VolumeButton.razor index d71b62f4..5924a092 100644 --- a/src/Community.Components/Components/Audio/Controls/VolumeButton.razor +++ b/src/Community.Components/Components/Audio/Controls/VolumeButton.razor @@ -4,7 +4,7 @@ diff --git a/src/Community.Components/Components/Audio/Controls/VolumeButton.razor.cs b/src/Community.Components/Components/Audio/Controls/VolumeButton.razor.cs index 31a4b916..bb0a4ba6 100644 --- a/src/Community.Components/Components/Audio/Controls/VolumeButton.razor.cs +++ b/src/Community.Components/Components/Audio/Controls/VolumeButton.razor.cs @@ -64,12 +64,12 @@ public VolumeButton() /// Gets or sets the label for the volume button. /// [Parameter] - public string VolumeLabel { get; set; } = "Volume"; + public string Label { get; set; } = "Volume"; /// /// Gets the ARIA label for the volume button, combining the volume label and the current volume percentage. /// - private string AriaLabel => $"{VolumeLabel} ({_volume * 100:0}%)"; + private string AriaLabel => $"{Label} ({_volume * 100:0}%)"; /// /// Handles the volume change event asynchronously. diff --git a/src/Community.Components/Components/Audio/FluentCxAudio.razor b/src/Community.Components/Components/Audio/FluentCxAudio.razor index f7f49df1..35107ef9 100644 --- a/src/Community.Components/Components/Audio/FluentCxAudio.razor +++ b/src/Community.Components/Components/Audio/FluentCxAudio.razor @@ -55,7 +55,8 @@ IsPlayOrPauseDisabled=@IsPlayOrPauseDisabled IsPreviousDisabled=@IsPreviousDisabled IsNextDisabled=@IsNextDisabled - IsStopDisabled=@IsStopDisabled/> + IsStopDisabled=@IsStopDisabled + Labels="@Labels"/> @@ -72,10 +73,10 @@ OnPrevious="@OnPreviousTrackAsync" OnNext="@OnNextTrackAsync" OnPlayPauseChanged="@OnTogglePlayPauseAsync" - NextLabel="" - PreviousLabel="" - PauseLabel="" - PlayLabel="" + NextLabel="@Labels.NextLabel" + PreviousLabel="@Labels.PreviousLabel" + PauseLabel="@Labels.PauseLabel" + PlayLabel="@Labels.PlayLabel" IsFloating="@(View == AudioPlayerView.Floating)" Duration="@_duration" CurrentTime="@_currentTime"/> @@ -84,7 +85,9 @@ { [Parameter] public EventCallback OnPlayPause { get; set; } + + /// + /// Gets or sets the label for the play button. + /// + [Parameter] + public string PlayLabel { get; set; } = "Play"; + + /// + /// Gets or sets the label for the pause button. + /// + [Parameter] + public string PauseLabel { get; set; } = "Pause"; } diff --git a/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/VolumeButtonTests.cs b/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/VolumeButtonTests.cs index 94963d8c..d25f5493 100644 --- a/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/VolumeButtonTests.cs +++ b/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/VolumeButtonTests.cs @@ -21,7 +21,7 @@ public VolumeButtonTests() public void VolumeLabel_DefaultValue_IsVolume() { var cut = RenderComponent(); - Assert.Equal("Volume", cut.Instance.VolumeLabel); + Assert.Equal("Volume", cut.Instance.Label); } [Fact] From 5e2703ddd3d54562fed1917e4140a3b0cf267372 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20JANISZEWSKI?= Date: Fri, 3 Oct 2025 21:08:23 +0200 Subject: [PATCH 4/8] make better spatial rendering --- .../Components/Audio/AudioVisualizer.razor.js | 92 +++++++++++-------- 1 file changed, 56 insertions(+), 36 deletions(-) diff --git a/src/Community.Components/Components/Audio/AudioVisualizer.razor.js b/src/Community.Components/Components/Audio/AudioVisualizer.razor.js index 67b0d21d..93973f98 100644 --- a/src/Community.Components/Components/Audio/AudioVisualizer.razor.js +++ b/src/Community.Components/Components/Audio/AudioVisualizer.razor.js @@ -53,6 +53,7 @@ function initialize(id, audioElement, mode, cover, color) { const source = audioCtx.createMediaElementSource(audio); source.connect(analyser); analyser.connect(audioCtx.destination); + analyser.fftSize = 2048; audio.addEventListener("play", () => { if (audioCtx.state === "suspended") { @@ -80,7 +81,9 @@ function initialize(id, audioElement, mode, cover, color) { analyzer: analyser, bufferLength: bufferLength, dataArray: dataArray, - audioCtx: audioCtx + audioCtx: audioCtx, + hueBase: 200, + time: 0 }; } @@ -103,6 +106,7 @@ function setMode(id, mode) { const circles = instance.circles; const constellations = instance.constellations; const color = instance.color; + let last = performance.now(); function drawBranch(x, y, len, angle, depth, freq) { if (depth === 0) return; @@ -121,11 +125,15 @@ function setMode(id, mode) { function draw() { requestAnimationFrame(draw); - analyser.getByteFrequencyData(dataArray); + const now = performance.now(); + const dt = Math.min(0.05, (now - last) / 1000); + last = now; + instance.time += dt; analyser.smoothingTimeConstant = 0.85; - ctx.clearRect(0, 0, width, height); analyser.getByteFrequencyData(dataArray); + ctx.clearRect(0, 0, width, height); + if (mode === spectrum) { const barWidth = 4; const barSpacing = 2; @@ -157,65 +165,77 @@ function setMode(id, mode) { ctx.stroke(); } else if (mode === spatial) { - analyser.getByteFrequencyData(dataArray); - const bass = bandEnergy(dataArray, 20, 200) / 255; const mid = bandEnergy(dataArray, 200, 2000) / 255; const high = bandEnergy(dataArray, 2000, 12000) / 255; - let speed = 0.01 + bass * 0.1; - if (bass > 0.6) { - speed *= 3; - } - ctx.fillStyle = "rgba(0,0,0,0.1)"; + ctx.globalCompositeOperation = 'source-over'; + ctx.fillStyle = 'rgba(5, 8, 12, 0.35)'; ctx.fillRect(0, 0, width, height); + + const maxR = Math.hypot(centerX, centerY); + const breathing = 1 + Math.sin(instance.time * 2.4) * (0.05 + bass * 0.12); + const rotation = instance.time * (0.15 + high * 0.4); + instance.hueBase = (instance.hueBase + (20 + mid * 120) * dt) % 360; + + ctx.save(); + ctx.translate(centerX, centerY); + ctx.rotate(rotation); + ctx.scale(breathing, 1); + + const ringCount = 18; + for (let i = 0; i < ringCount; i++) { + const t = i / ringCount; + const radius = 30 + t * maxR * 0.95; + const widthRing = 6 + t * 22 * (0.4 + mid); + const hue = (instance.hueBase + i * 10) % 360; + const alpha = 0.06 + (1 - t) * (0.10 + high * 0.10); + + const g = ctx.createRadialGradient(0, 0, radius - widthRing, 0, 0, radius + widthRing); + g.addColorStop(0, `hsla(${hue}, 90%, 65%, 0)`); + g.addColorStop(0.5, `hsla(${hue}, 90%, 65%, ${alpha})`); + g.addColorStop(1, `hsla(${hue}, 90%, 65%, 0)`); + + ctx.strokeStyle = g; + ctx.lineWidth = widthRing; + ctx.beginPath(); + ctx.arc(0, 0, radius, 0, Math.PI * 2); + ctx.stroke(); + } + ctx.restore(); + ctx.save(); ctx.translate(centerX, centerY); + ctx.globalCompositeOperation = 'lighter'; + ctx.shadowBlur = 8; + const starSpeed = 0.9 + bass * 2.0; for (const s of stars) { - s.z -= speed; - - if (s.z < 0.1) { + s.z -= 0.008 * starSpeed; + if (s.z < 0.12) { s.x = (Math.random() - 0.5) * width; s.y = (Math.random() - 0.5) * height; s.z = 1; } - const angle = mid * 0.05; - const cosA = Math.cos(angle); - const sinA = Math.sin(angle); + const a = mid * 0.03; + const cosA = Math.cos(a), sinA = Math.sin(a); const rx = s.x * cosA - s.y * sinA; const ry = s.x * sinA + s.y * cosA; - s.x = rx; - s.y = ry; + s.x = rx; s.y = ry; const px = s.x / s.z; const py = s.y / s.z; - const rawSize = (1 - s.z) * (2 + mid * 4); - const size = Math.max(0.5, rawSize); - const hue = 180 + Math.floor(150 * high); - const lightness = 50 + bass * 30; - - ctx.fillStyle = `hsl(${hue}, 100%, ${lightness}%)`; - ctx.shadowBlur = 20; + const size = Math.max(0.5, (1 - s.z) * (1.5 + high * 2.5)); + const hue = (instance.hueBase + s.z * 120) % 360; + ctx.fillStyle = `hsl(${hue}, 90%, ${60 + bass * 30}%)`; ctx.shadowColor = ctx.fillStyle; ctx.beginPath(); ctx.arc(px, py, size, 0, Math.PI * 2); ctx.fill(); } - ctx.restore(); - - // Nebulas - ctx.globalAlpha = 0.1 + high * 0.3; - const nebulaHue = 280 + high * 80; - const gradient = ctx.createRadialGradient(centerX, centerY, 0, centerX, centerY, width / 2); - gradient.addColorStop(0, `hsla(${nebulaHue}, 100%, 60%, 0.6)`); - gradient.addColorStop(1, "transparent"); - ctx.fillStyle = gradient; - ctx.fillRect(0, 0, width, height); - ctx.globalAlpha = 1; } else if (mode === vortex) { analyser.getByteFrequencyData(dataArray); From b727793fda33a86bc35b482ad132b6ba79e75774 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20JANISZEWSKI?= Date: Fri, 3 Oct 2025 21:08:50 +0200 Subject: [PATCH 5/8] remove french summary --- .../Components/Audio/AudioVisualizer.razor.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Community.Components/Components/Audio/AudioVisualizer.razor.js b/src/Community.Components/Components/Audio/AudioVisualizer.razor.js index 93973f98..13a05e91 100644 --- a/src/Community.Components/Components/Audio/AudioVisualizer.razor.js +++ b/src/Community.Components/Components/Audio/AudioVisualizer.razor.js @@ -261,7 +261,6 @@ function setMode(id, mode) { s.z = Math.random() * 0.8 + 0.2; } - // Rotation en spirale const angle = Math.atan2(s.y, s.x); const radius = Math.sqrt(s.x * s.x + s.y * s.y); const newAngle = angle + rotationSpeed; From ba5bdccd5bed1177854ce3cd81d6c3a3a8cdc752 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20JANISZEWSKI?= Date: Sat, 4 Oct 2025 15:49:10 +0200 Subject: [PATCH 6/8] Adds the propertie panel --- Directory.Packages.props | 8 +- FluentUI.Blazor.Community.sln | 4 +- .../FluentUI.Blazor.Community.Components.xml | 131 ++++++++++++++++++ .../Components/Audio/AudioControls.razor | 18 ++- .../Components/Audio/AudioControls.razor.cs | 18 +++ .../Components/Audio/AudioLabels.cs | 86 +++++++++++- .../Audio/Controls/DownloadButton.razor | 1 + .../Audio/Controls/DownloadButton.razor.cs | 6 + .../Audio/Controls/PropertiesButton.razor | 1 + .../Audio/Controls/PropertiesButton.razor.cs | 8 +- .../Components/Audio/FluentCxAudio.razor | 3 + .../Components/Audio/FluentCxAudio.razor.cs | 41 ++++++ .../Components/Audio/TrackProperties.razor | 108 +++++++++++++++ .../Components/Audio/TrackProperties.razor.cs | 21 +++ 14 files changed, 442 insertions(+), 12 deletions(-) create mode 100644 src/Community.Components/Components/Audio/TrackProperties.razor create mode 100644 src/Community.Components/Components/Audio/TrackProperties.razor.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 929a81dd..07ca1127 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,7 +5,7 @@ - + @@ -17,9 +17,9 @@ - + - + @@ -28,7 +28,7 @@ - + diff --git a/FluentUI.Blazor.Community.sln b/FluentUI.Blazor.Community.sln index 77a7c403..33653870 100644 --- a/FluentUI.Blazor.Community.sln +++ b/FluentUI.Blazor.Community.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.0.31903.59 +# Visual Studio Version 18 +VisualStudioVersion = 18.0.11018.127 MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" EndProject diff --git a/examples/demo/FluentUI.Demo.Shared/FluentUI.Blazor.Community.Components.xml b/examples/demo/FluentUI.Demo.Shared/FluentUI.Blazor.Community.Components.xml index 88730558..585592d0 100644 --- a/examples/demo/FluentUI.Demo.Shared/FluentUI.Blazor.Community.Components.xml +++ b/examples/demo/FluentUI.Demo.Shared/FluentUI.Blazor.Community.Components.xml @@ -1017,6 +1017,11 @@ Gets or sets the callback to be invoked when the volume changes. + + + Gets or sets the callback to be invoked when the properties button is clicked. + + Gets or sets a value indicating whether the previous button is disabled. @@ -1032,11 +1037,21 @@ Gets or sets a value indicating whether the stop button is disabled. + + + Gets or sets a value indicating whether downloading is disabled for this component. + + Gets or sets a value indicating whether the play/pause button is disabled. + + + Gets or sets a value indicating whether the properties button is disabled. + + Sets the play/pause state of the associated control. @@ -1114,6 +1129,76 @@ Gets the label for the single once mode. + + + Gets the label for the title field. + + + + + Gets the label for the performers field. + + + + + Gets the label for the properties section. + + + + + Gets the label for close dialog. + + + + + Gets the label for the album field. + + + + + Gets the display label used for the album artist field. + + + + + Gets the label for the track number field. + + + + + Gets the label for the duration field. + + + + + Gets the label text used to represent the genre field. + + + + + Gets the label text used to represent a year. + + + + + Gets the display label used to represent the bitrate value. + + + + + Gets the label for the MIME type field. + + + + + Gets the display label used for the publisher field. + + + + + Gets the label for the cover field. + + Specifies the rendering mode for an audio player. @@ -1530,6 +1615,11 @@ Gets or sets the label for the download button. + + + Gets or sets a value indicating whether the download button is disabled. + + Initializes a new instance of the class. @@ -1729,6 +1819,11 @@ Gets or sets the label for the download button. + + + Gets or sets a value indicating whether the download button is disabled. + + Initializes a new instance of the class. @@ -2361,6 +2456,19 @@ Gets a value indicating whether the play or pause button should be disabled. + + + Gets a value indicating whether the audio properties should be disabled. + + + + + Gets a value indicating whether downloading the current track is disabled. + + Downloading is disabled if there is no current track selected or if the current track does + not have a valid source. This property can be used to determine whether download-related UI elements should be + enabled. + Gets or sets the render mode of the audio player. @@ -2396,6 +2504,11 @@ Gets or sets the labels for the audio control buttons. + + + Gets or sets the service used to display dialogs within the component. + + Occurs when the download button is clicked. @@ -2447,6 +2560,12 @@ A task that represents the asynchronous operation of advancing to the next track and starting playback. + + + Displays the track properties dialog asynchronously and waits for the user to close it. + + A task that represents the asynchronous operation of showing the properties dialog and awaiting its closure. + Handles the seek operation by updating the current playback time and invoking the corresponding JavaScript @@ -2948,6 +3067,18 @@ invoking the callback asynchronously. + + + Gets or sets the set of audio labels to be used for classification or annotation. + + The default value is . Changing this property allows + customization of the labels used in audio processing scenarios. + + + + Gets or sets the audio track item associated with this instance. + + Represents the different modes available for the audio visualizer. diff --git a/src/Community.Components/Components/Audio/AudioControls.razor b/src/Community.Components/Components/Audio/AudioControls.razor index ebb7dee8..2ba7c399 100644 --- a/src/Community.Components/Components/Audio/AudioControls.razor +++ b/src/Community.Components/Components/Audio/AudioControls.razor @@ -7,6 +7,8 @@ HorizontalGap="30"> + + @@ -37,9 +39,17 @@ - @if (IsDownloadVisible) - { - - } + + + @if (IsDownloadVisible) + { + + } + + diff --git a/src/Community.Components/Components/Audio/AudioControls.razor.cs b/src/Community.Components/Components/Audio/AudioControls.razor.cs index a169615b..fd755f10 100644 --- a/src/Community.Components/Components/Audio/AudioControls.razor.cs +++ b/src/Community.Components/Components/Audio/AudioControls.razor.cs @@ -87,6 +87,12 @@ public AudioControls() [Parameter] public EventCallback OnVolumeChanged { get; set; } + /// + /// Gets or sets the callback to be invoked when the properties button is clicked. + /// + [Parameter] + public EventCallback OnProperties { get; set; } + /// /// Gets or sets a value indicating whether the previous button is disabled. /// @@ -105,12 +111,24 @@ public AudioControls() [Parameter] public bool IsStopDisabled { get; set; } + /// + /// Gets or sets a value indicating whether downloading is disabled for this component. + /// + [Parameter] + public bool IsDownloadDisabled { get; set; } + /// /// Gets or sets a value indicating whether the play/pause button is disabled. /// [Parameter] public bool IsPlayOrPauseDisabled { get; set; } + /// + /// Gets or sets a value indicating whether the properties button is disabled. + /// + [Parameter] + public bool IsPropertiesDisabled { get; set; } + /// /// Sets the play/pause state of the associated control. /// diff --git a/src/Community.Components/Components/Audio/AudioLabels.cs b/src/Community.Components/Components/Audio/AudioLabels.cs index 30d761cc..43124d91 100644 --- a/src/Community.Components/Components/Audio/AudioLabels.cs +++ b/src/Community.Components/Components/Audio/AudioLabels.cs @@ -19,7 +19,21 @@ public record AudioLabels PlaylistLoopLabel = "Automatic playback of the next music track and repeat of the playlist", PlaylistOnceLabel = "Automatic playback of the next music track and stop at the end of the last track", SingleLoopLabel = "Play the music on loop", - SingleOnceLabel = "Stop playing at the end of the music" + SingleOnceLabel = "Stop playing at the end of the music", + TitleLabel = "Titre", + AlbumArtistsLabel = "Artistes ayant collaboré", + PropertiesLabel = "Propriétés", + CloseLabel = "Fermer", + AlbumLabel = "Album", + AlbumArtistLabel = "Artiste de l'album", + TrackNumberLabel = "Numéro de piste", + DurationLabel = "Durée", + GenreLabel = "Genre", + YearLabel = "Année", + BitrateLabel = "Débit binaire", + MimeLabel = "Type MIME", + PublisherLabel = "Éditeur", + CoverLabel = "Pochette" }; /// @@ -91,4 +105,74 @@ public record AudioLabels /// Gets the label for the single once mode. /// public string SingleOnceLabel { get; init; } = "Single once"; + + /// + /// Gets the label for the title field. + /// + public string TitleLabel { get; init; } = "Title"; + + /// + /// Gets the label for the performers field. + /// + public string AlbumArtistsLabel { get; init; } = "Artists who have collaborated"; + + /// + /// Gets the label for the properties section. + /// + public string PropertiesLabel { get; init; } = "Properties"; + + /// + /// Gets the label for close dialog. + /// + public string CloseLabel { get; init; } = "Close"; + + /// + /// Gets the label for the album field. + /// + public string AlbumLabel { get; init; } = "Album"; + + /// + /// Gets the display label used for the album artist field. + /// + public string AlbumArtistLabel { get; init; } = "Album artist"; + + /// + /// Gets the label for the track number field. + /// + public string TrackNumberLabel { get; init; } = "Track number"; + + /// + /// Gets the label for the duration field. + /// + public string DurationLabel { get; init; } = "Duration"; + + /// + /// Gets the label text used to represent the genre field. + /// + public string GenreLabel { get; init; } = "Genre"; + + /// + /// Gets the label text used to represent a year. + /// + public string YearLabel { get; init; } = "Year"; + + /// + /// Gets the display label used to represent the bitrate value. + /// + public string BitrateLabel { get; init; } = "Bitrate"; + + /// + /// Gets the label for the MIME type field. + /// + public string MimeLabel { get; init; } = "MIME type"; + + /// + /// Gets the display label used for the publisher field. + /// + public string PublisherLabel { get; init; } = "Publisher"; + + /// + /// Gets the label for the cover field. + /// + public string CoverLabel { get; init; } = "Cover"; } diff --git a/src/Community.Components/Components/Audio/Controls/DownloadButton.razor b/src/Community.Components/Components/Audio/Controls/DownloadButton.razor index 36f8fc9a..c8002dd5 100644 --- a/src/Community.Components/Components/Audio/Controls/DownloadButton.razor +++ b/src/Community.Components/Components/Audio/Controls/DownloadButton.razor @@ -5,4 +5,5 @@ IconStart="@(Icon)" Title="@Label" OnClick="@OnClickAsync" + Disabled="@IsDisabled" Id="@Id" /> diff --git a/src/Community.Components/Components/Audio/Controls/DownloadButton.razor.cs b/src/Community.Components/Components/Audio/Controls/DownloadButton.razor.cs index 3ecba6df..d68715b8 100644 --- a/src/Community.Components/Components/Audio/Controls/DownloadButton.razor.cs +++ b/src/Community.Components/Components/Audio/Controls/DownloadButton.razor.cs @@ -28,6 +28,12 @@ public partial class DownloadButton : FluentComponentBase [Parameter] public string? Label { get; set; } = "Download"; + /// + /// Gets or sets a value indicating whether the download button is disabled. + /// + [Parameter] + public bool IsDisabled { get; set; } + /// /// Initializes a new instance of the class. /// diff --git a/src/Community.Components/Components/Audio/Controls/PropertiesButton.razor b/src/Community.Components/Components/Audio/Controls/PropertiesButton.razor index 36f8fc9a..c26e5306 100644 --- a/src/Community.Components/Components/Audio/Controls/PropertiesButton.razor +++ b/src/Community.Components/Components/Audio/Controls/PropertiesButton.razor @@ -4,5 +4,6 @@ diff --git a/src/Community.Components/Components/Audio/Controls/PropertiesButton.razor.cs b/src/Community.Components/Components/Audio/Controls/PropertiesButton.razor.cs index 94921a5d..5f72fb34 100644 --- a/src/Community.Components/Components/Audio/Controls/PropertiesButton.razor.cs +++ b/src/Community.Components/Components/Audio/Controls/PropertiesButton.razor.cs @@ -11,7 +11,7 @@ public partial class PropertiesButton : FluentComponentBase /// /// Represents the icon to be displayed on the button. /// - private static readonly Icon Icon = new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size24.ArrowDownload(); + private static readonly Icon Icon = new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size24.Info(); /// /// Gets or sets the event callback that is invoked when the download button is clicked. @@ -25,6 +25,12 @@ public partial class PropertiesButton : FluentComponentBase [Parameter] public string? Label { get; set; } = "Properties"; + /// + /// Gets or sets a value indicating whether the download button is disabled. + /// + [Parameter] + public bool IsDisabled { get; set; } + /// /// Initializes a new instance of the class. /// diff --git a/src/Community.Components/Components/Audio/FluentCxAudio.razor b/src/Community.Components/Components/Audio/FluentCxAudio.razor index 35107ef9..0b03471d 100644 --- a/src/Community.Components/Components/Audio/FluentCxAudio.razor +++ b/src/Community.Components/Components/Audio/FluentCxAudio.razor @@ -52,10 +52,13 @@ OnDownload="@OnDownloadAsync" OnPlaylistToggled="@OnPlaylistToogled" OnStop="@OnStopAsync" + OnProperties="@OnPropertiesAsync" IsPlayOrPauseDisabled=@IsPlayOrPauseDisabled IsPreviousDisabled=@IsPreviousDisabled IsNextDisabled=@IsNextDisabled IsStopDisabled=@IsStopDisabled + IsDownloadDisabled="@IsDownloadDisabled" + IsPropertiesDisabled=@IsPropertiesDisabled Labels="@Labels"/> diff --git a/src/Community.Components/Components/Audio/FluentCxAudio.razor.cs b/src/Community.Components/Components/Audio/FluentCxAudio.razor.cs index ab1da6ec..48bb3765 100644 --- a/src/Community.Components/Components/Audio/FluentCxAudio.razor.cs +++ b/src/Community.Components/Components/Audio/FluentCxAudio.razor.cs @@ -171,6 +171,19 @@ private bool IsNextDisabled /// private bool IsPlayOrPauseDisabled => CurrentTrack is null; + /// + /// Gets a value indicating whether the audio properties should be disabled. + /// + private bool IsPropertiesDisabled => CurrentTrack is null || CurrentTrack.Metadata is null; + + /// + /// Gets a value indicating whether downloading the current track is disabled. + /// + /// Downloading is disabled if there is no current track selected or if the current track does + /// not have a valid source. This property can be used to determine whether download-related UI elements should be + /// enabled. + private bool IsDownloadDisabled => CurrentTrack is null || string.IsNullOrWhiteSpace(CurrentTrack.Source); + /// /// Gets or sets the render mode of the audio player. /// @@ -213,6 +226,12 @@ private bool IsNextDisabled [Parameter] public AudioLabels Labels { get; set; } = AudioLabels.Default; + /// + /// Gets or sets the service used to display dialogs within the component. + /// + [Inject] + private IDialogService DialogService { get; set; } = default!; + /// /// Occurs when the download button is clicked. /// @@ -362,6 +381,28 @@ private async Task OnNextTrackAsync() await PlayAsync(); } + /// + /// Displays the track properties dialog asynchronously and waits for the user to close it. + /// + /// A task that represents the asynchronous operation of showing the properties dialog and awaiting its closure. + private async Task OnPropertiesAsync() + { + if (CurrentTrack?.Metadata is null) + { + return; + } + + var dialog = await DialogService.ShowDialogAsync(CurrentTrack.Metadata, new DialogParameters() + { + Title = Labels.PropertiesLabel, + PrimaryAction = Labels.CloseLabel, + SecondaryAction = null, + Width = "60%" + }); + + await dialog.Result; + } + /// /// Handles the seek operation by updating the current playback time and invoking the corresponding JavaScript /// function. diff --git a/src/Community.Components/Components/Audio/TrackProperties.razor b/src/Community.Components/Components/Audio/TrackProperties.razor new file mode 100644 index 00000000..6fe456dc --- /dev/null +++ b/src/Community.Components/Components/Audio/TrackProperties.razor @@ -0,0 +1,108 @@ +@namespace FluentUI.Blazor.Community.Components +@inherits FluentComponentBase + + + + + @Labels.TitleLabel + @Content?.Descriptive.Title + + + + + + @Labels.AlbumArtistsLabel + + @if (Content?.Descriptive.AlbumArtists is not null) + { + @string.Join(", ", Content.Descriptive.AlbumArtists) + } + + + + + + + @Labels.AlbumLabel + @Content?.Descriptive.Album + + + + + + @Labels.AlbumArtistLabel + @Content?.Descriptive.Performers.FirstOrDefault() + + + + + + @Labels.TrackNumberLabel + @Content?.Extended.TrackNumber + + + + + + @Labels.DurationLabel + @Content?.Technical.Duration + + + + + + @Labels.GenreLabel + + @if (Content?.Descriptive.Genres is not null) + { + @string.Join(", ", Content.Descriptive.Genres) + } + + + + + + + @Labels.YearLabel + @Content?.Descriptive.Year + + + + + + @Labels.BitrateLabel + @(Content?.Technical.AudioBitrate) Kbits/s + + + + + + @Labels.MimeLabel + @Content?.Technical.FileType + + + + + + @Labels.PublisherLabel + @Content?.Legal.Publisher + + + + + + @Labels.CoverLabel + + @if (!string.IsNullOrEmpty(Content?.Visual.CoverUrl)) + { +
+ +
+ } +
+
+
diff --git a/src/Community.Components/Components/Audio/TrackProperties.razor.cs b/src/Community.Components/Components/Audio/TrackProperties.razor.cs new file mode 100644 index 00000000..a02b686f --- /dev/null +++ b/src/Community.Components/Components/Audio/TrackProperties.razor.cs @@ -0,0 +1,21 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.FluentUI.AspNetCore.Components; + +namespace FluentUI.Blazor.Community.Components; + +public partial class TrackProperties : IDialogContentComponent +{ + /// + /// Gets or sets the set of audio labels to be used for classification or annotation. + /// + /// The default value is . Changing this property allows + /// customization of the labels used in audio processing scenarios. + [Parameter] + public AudioLabels Labels { get; set; } = AudioLabels.Default; + + /// + /// Gets or sets the audio track item associated with this instance. + /// + [Parameter] + public AudioMetadata Content { get; set; } = default!; +} From 2cd7b869b889b9c2f051880741689836fe2bb2ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20JANISZEWSKI?= Date: Mon, 6 Oct 2025 19:51:54 +0200 Subject: [PATCH 7/8] update packlages --- Directory.Packages.props | 12 ++++++------ .../Pages/TileGrid/Examples/TileGridDashboard.razor | 2 +- ...FluentUI.Blazor.Community.Components.Tests.csproj | 5 ++++- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 7e03c8cc..d9112901 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -6,24 +6,24 @@ - - - + + + - + - + - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/examples/demo/FluentUI.Demo.Shared/Pages/TileGrid/Examples/TileGridDashboard.razor b/examples/demo/FluentUI.Demo.Shared/Pages/TileGrid/Examples/TileGridDashboard.razor index 2bcb51c9..3abb92b8 100644 --- a/examples/demo/FluentUI.Demo.Shared/Pages/TileGrid/Examples/TileGridDashboard.razor +++ b/examples/demo/FluentUI.Demo.Shared/Pages/TileGrid/Examples/TileGridDashboard.razor @@ -66,7 +66,7 @@ @code { private readonly RenderFragment renderCard = context => __builder => { - +
@context.Header diff --git a/tests/FluentUI.Blazor.Community.Components.Tests/FluentUI.Blazor.Community.Components.Tests.csproj b/tests/FluentUI.Blazor.Community.Components.Tests/FluentUI.Blazor.Community.Components.Tests.csproj index 029e42cc..08f87932 100644 --- a/tests/FluentUI.Blazor.Community.Components.Tests/FluentUI.Blazor.Community.Components.Tests.csproj +++ b/tests/FluentUI.Blazor.Community.Components.Tests/FluentUI.Blazor.Community.Components.Tests.csproj @@ -19,7 +19,10 @@ - + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + From 2f9c53fd815680ce63d1c63f1627562ac63af5a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20JANISZEWSKI?= Date: Thu, 30 Oct 2025 11:14:36 +0100 Subject: [PATCH 8/8] update doc --- .../FluentUI.Blazor.Community.Components.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/demo/FluentUI.Demo.Shared/FluentUI.Blazor.Community.Components.xml b/examples/demo/FluentUI.Demo.Shared/FluentUI.Blazor.Community.Components.xml index 68b71f40..b6f4c951 100644 --- a/examples/demo/FluentUI.Demo.Shared/FluentUI.Blazor.Community.Components.xml +++ b/examples/demo/FluentUI.Demo.Shared/FluentUI.Blazor.Community.Components.xml @@ -12232,7 +12232,7 @@ cref="T:FluentUI.Blazor.Community.Components.FluentCxObserverProvider"/> to be passed to JavaScript and invoked from there. It is intended for internal use and should not be accessed directly by external code. - + Indicates whether the object has been rendered.
Occurs when the download button is clicked. @@ -2341,13 +2435,13 @@ "updateElapsedTime". The new elapsed time, in seconds. - + Adds an audio track to the playlist. Audio track to add. - + Removes an audio track from the playlist. @@ -2359,6 +2453,360 @@ + + + Represents a collection of metadata categories associated with an audio asset. + + This class provides access to various types of metadata, including descriptive, technical, legal, + and visual information. Each metadata category is represented by a corresponding property, which can be used to + retrieve or modify the associated metadata. + + + + Gets or sets the descriptive metadata associated with the current object. + + + + + Gets or sets the extended metadata associated with the current object. + + + + + Gets or sets the technical metadata associated with the current object. + + + + + Gets or sets the legal metadata associated with the current object. + + + + + Gets or sets the visual metadata associated with the current object. + + + + + Provides functionality to retrieve metadata for audio files. + + This class is intended to be used for extracting and managing metadata such as title, artist, album, + and other relevant information from audio files. It supports various audio formats. + + + + Provides functionality to retrieve metadata for audio files. + + This class is intended to be used for extracting and managing metadata such as title, artist, album, + and other relevant information from audio files. It supports various audio formats. + + + + Provides a static instance of used to map file extensions to MIME + content types. + + This instance can be used to look up MIME types for file extensions. It is initialized with + default mappings provided by the framework. + + + + Asynchronously retrieves audio metadata from the specified file. + + This method reads the file's content into memory and extracts metadata from the stream. + Ensure the file size is within the limits supported by the application to avoid memory issues. + The file to extract audio metadata from. Must not be null. + A task that represents the asynchronous operation. The task result contains the extracted if the operation is successful; otherwise, null. + + + + Extracts audio metadata from a given stream. + + This method uses the TagLib library to parse the audio file and extract metadata. The + extracted metadata includes information such as title, album, artists, duration, bitrate, and cover art. If the + audio file is corrupt or in an unsupported format, the method logs the error and returns an empty object. + The name of the audio file, used for logging and identification purposes. + The input stream containing the audio file data. The stream must be readable and seekable. + An object containing descriptive, technical, extended, legal, and visual metadata + extracted from the audio file. Returns an empty object if the file is corrupt, + unsupported, or an error occurs during processing. + + + + Retrieves audio metadata from a specified URL. + + This method downloads the audio file from the specified URL, processes it in memory, and + extracts its metadata. Ensure that the URL points to a valid audio file. + The name to associate with the audio metadata. + The URL of the audio file. Can be if no URL is provided. + An object containing the metadata of the audio file, or if + the metadata could not be retrieved. + + + + Represents descriptive metadata for a musical track, including details such as title, album, performers, and other + related information. + + This class provides a structured way to store and access metadata commonly associated with musical + tracks. It includes properties for identifying the track's title, album, contributors, genres, and other + descriptive details. All string array properties are initialized as empty arrays to ensure safe iteration. + + + + Gets or sets the title associated with the object. + + + + + Gets or sets the name of the album associated with the item. + + + + + Gets or sets the list of performers associated with the event. + + + + + Gets or sets the list of album artists associated with the album. + + + + + Gets or sets the list of composers associated with the current context. + + + + + Gets or sets the name of the conductor associated with the performance. + + + + + Gets or sets the list of genres associated with the item. + + + + + Gets or sets the year associated with the item. + + + + + Gets or sets comments associated with the item. + + + + + Represents extended metadata for an audio file. + + + + + Gets or sets the track number associated with the item. + + + + + Gets or sets the disc number associated with the item. + + + + + Gets or sets the total number of tracks in the collection. + + + + + Gets or sets the total number of discs in a collection or set. + + + + + Gets or sets the lyrics of the song. + + + + + Gets or sets the International Standard Recording Code (ISRC) associated with the recording. + + + + + Gets or sets the grouping identifier used to categorize related items. + + + + + Gets or sets the tempo of the music in beats per minute (BPM). + + The value must be a positive integer. Common tempos range from 40 BPM (slow) to 200 BPM + (fast), but the property does not enforce specific limits. + + + + Defines methods for retrieving audio metadata from various sources, such as URLs or uploaded files. + + This interface provides asynchronous methods to extract metadata from audio files, including details + such as title, artist, album, and duration. Implementations may support different audio formats and + sources. + + + + Asynchronously retrieves audio metadata from the specified URL. + + This method performs an asynchronous operation to fetch and parse metadata from the provided + URL. Ensure the URL points to a valid and accessible audio file. + The URL of the audio file to retrieve metadata from. Can be null or empty, in which case the method will return + . + A task that represents the asynchronous operation. The task result contains an + object with the metadata of the audio file, or if the metadata could not be retrieved. + + + + Asynchronously retrieves audio metadata from the specified file. + + The file from which to extract audio metadata. Must not be null. + A task that represents the asynchronous operation. The task result contains the extracted if the operation is successful; otherwise, if the metadata could + not be retrieved. + + + + Extracts audio metadata from the specified stream. + + The caller is responsible for ensuring the stream remains open and readable during the + operation. The method does not modify the position of the stream. + The input stream containing audio data. The stream must be readable and positioned at the start of the audio + content. + An object containing the extracted metadata, such as duration, format, and bitrate. + + + + Represents metadata related to legal information, such as copyright and publisher details. + + This class is typically used to store and manage legal information associated with a document, + product, or other content. Both properties are optional and can be set to if the + corresponding information is not available. + + + + Gets or sets the copyright information associated with the application. + + + + + Gets or sets the name of the publisher associated with the item. + + + + + Provides an abstraction for file operations using streams, implementing the interface. + + Name or identifier of the abstraction. + Represents the readable stream. + Represents the writeable stream. + + + + Provides an abstraction for file operations using streams, implementing the interface. + + Name or identifier of the abstraction. + Represents the readable stream. + Represents the writeable stream. + + + + + + + + + + + + + + + + Represents technical metadata for a media file, including properties such as duration, bitrate, sample rate, and + codec information. + + This class provides a set of properties to describe the technical characteristics of a media file. + All properties are nullable, allowing for scenarios where specific metadata may not be available. + + + + Gets or sets the duration of the operation. + + + + + Gets or sets the bitrate of the media, in bits per second. + + + + + Gets or sets the sample rate, in hertz, for the audio processing operation. + + The sample rate determines the number of audio samples processed per second. Ensure the value + is compatible with the audio source and processing requirements. + + + + Gets or sets the number of channels to be used. + + A value of indicates that the default number of channels will be + used. + + + + Gets or sets the codec used for encoding or decoding data. + + + + + Gets or sets the media types associated with the file. + + + + + Gets or sets the file type of the media, such as "audio/mpeg" or "video/mp4". + + + + + Gets or sets the size of the file in bytes. + + + + + Represents visual metadata, including cover image URL, MIME type, description, and type. + + + + + Gets or sets the URL of the cover image. + + + + + Gets or sets the MIME type of the cover image. + + + + + Gets or sets the description associated with the object. + + + + + Gets or sets the type of the entity or object. + + Represents a minimal audio player component with play/pause functionality. diff --git a/examples/demo/FluentUI.Demo.Shared/Layout/DemoNavProvider.cs b/examples/demo/FluentUI.Demo.Shared/Layout/DemoNavProvider.cs index 4d7a3357..27b4875e 100644 --- a/examples/demo/FluentUI.Demo.Shared/Layout/DemoNavProvider.cs +++ b/examples/demo/FluentUI.Demo.Shared/Layout/DemoNavProvider.cs @@ -1,5 +1,4 @@ using Microsoft.AspNetCore.Components.Routing; -using Microsoft.FluentUI.AspNetCore.Components; using Icons = Microsoft.FluentUI.AspNetCore.Components.Icons; namespace FluentUI.Demo.Shared.Layout; @@ -56,6 +55,11 @@ public DemoNavProvider() gap: "10px", children: [ + new NavLink( + href: "audio", + icon: new Icons.Regular.Size20.SoundWaveCircle(), + title: "Audio" + ), new NavLink( href: "artisticscatter", icon: new Icons.Regular.Size20.FilmstripImage(), diff --git a/examples/demo/FluentUI.Demo.Shared/Pages/Audio/AudioPage.razor b/examples/demo/FluentUI.Demo.Shared/Pages/Audio/AudioPage.razor index 9d78c13b..a35091a4 100644 --- a/examples/demo/FluentUI.Demo.Shared/Pages/Audio/AudioPage.razor +++ b/examples/demo/FluentUI.Demo.Shared/Pages/Audio/AudioPage.razor @@ -7,10 +7,6 @@ The FluentCxAudio component allows you to play audio files in your Blazor application.

- - The HTML Audio Element has an issue when the user uses the seek bar. - -

Examples

@@ -28,7 +24,7 @@
  • Floating
  • - The visualizer has 9 modes for now : + The visualizer has 8 modes for now :
    • Spectrum
    • Waveform
    • @@ -38,7 +34,6 @@
    • RadialWaveform
    • Tunnel
    • Constellation
    • -
    • Liquid
    • Fractal
    diff --git a/examples/demo/FluentUI.Demo.Shared/Pages/Audio/Examples/AudioDefault.razor b/examples/demo/FluentUI.Demo.Shared/Pages/Audio/Examples/AudioDefault.razor index 9bf3b164..5806f09d 100644 --- a/examples/demo/FluentUI.Demo.Shared/Pages/Audio/Examples/AudioDefault.razor +++ b/examples/demo/FluentUI.Demo.Shared/Pages/Audio/Examples/AudioDefault.razor @@ -1,72 +1,91 @@ -@implements IDisposable - -
    - - - - - +@using Microsoft.AspNetCore.StaticFiles +@using Microsoft.JSInterop +@implements IDisposable +@inject IAudioMetadataProvider AudioMetadataProvider + + + + The HTML Audio Element has an issue when the user uses the seek bar. + + + + The metadata are extracted using TagLib#. This lib is very strict, so if your audio file is not well formed, it will throw an exception, + and an empty metadata will be used instead. Be sure that your audio files are well formed. For example, if you download an mp3 file which was + encoded with m4a codec, this will not work. But if you rename it with m4a, this will work. + + +
    + + + + + + + + + + + + +
    + Drag files here you wish to upload, + or + for them*. +
    + Maximum of 10 files allowed. +
    +
    +
    + + + @foreach (var item in _audioFiles) + { + + } + +
    +
    - - - - - -
    - Drag files here you wish to upload, - or - for them*. -
    - Maximum of 10 files allowed. -
    - -
    - - - - - @foreach (var item in _audioFiles) - { - - } - - - -
    + @code { private VisualizerMode _mode; private AudioPlayerView _view = AudioPlayerView.Default; + private FluentCxAudio? _audio; int ProgressPercent = 0; private readonly Dictionary _audioFiles = []; private readonly List _files = []; + private readonly Dictionary _metadataCache = []; + + [Inject] + private IJSRuntime Runtime { get; set; } = null!; private static string GetPath(string path) { + if (path.StartsWith("blob")) + { + return path; + } + var index = path.IndexOf("uploads"); if (index >= 0) @@ -82,27 +101,54 @@ private async Task OnCompletedAsync(IEnumerable files) { - foreach(var file in files) + foreach (var file in files) { var newFileName = $"RenamedFile_{Guid.NewGuid()}{Path.GetExtension(file.Name)}"; + var filePath = newFileName; + + if (System.OperatingSystem.IsBrowser()) + { + var provider = new FileExtensionContentTypeProvider(); + if (!provider.TryGetContentType(file.Name, out var contentType)) + { + contentType = "application/octet-stream"; + } - // Save the file with the new name - var filePath = Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", "uploads", newFileName); - var directory = Path.GetDirectoryName(filePath); - if (!Directory.Exists(directory)) + filePath = await Runtime.InvokeAsync("getBlobUrl", contentType, File.ReadAllBytes(file.LocalFile!.FullName)); + } + else { - Directory.CreateDirectory(directory!); + filePath = Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", "uploads", newFileName); + var directory = Path.GetDirectoryName(filePath); + if (!Directory.Exists(directory)) + { + Directory.CreateDirectory(directory!); + } + + await using var stream = File.OpenRead(file.LocalFile!.FullName); + await using var fileStream = File.Create(filePath); + await stream.CopyToAsync(fileStream); } - await using var stream = File.OpenRead(file.LocalFile!.FullName); - await using var fileStream = File.Create(filePath); - await stream.CopyToAsync(fileStream); - _audioFiles.Add(file.Name, filePath); + if (!_audioFiles.ContainsKey(file.Name)) + { + _audioFiles.Add(file.Name, filePath); + } - _files.Add(file); + if (!_metadataCache.ContainsKey(file.Name)) + { + using var ms = new MemoryStream(File.ReadAllBytes(file.LocalFile.FullName)); + var metadata = AudioMetadataProvider.GetFromStream(file.Name, ms); + _metadataCache.Add(file.Name, metadata); + } + + if (!_files.Contains(file)) + { + _files.Add(file); + } } - await Task.Delay(1000); + await Task.Delay(500); ProgressPercent = 0; } diff --git a/examples/demo/FluentUI.Demo.Shared/wwwroot/js/blob.js b/examples/demo/FluentUI.Demo.Shared/wwwroot/js/blob.js new file mode 100644 index 00000000..3ba88280 --- /dev/null +++ b/examples/demo/FluentUI.Demo.Shared/wwwroot/js/blob.js @@ -0,0 +1,4 @@ +window.getBlobUrl = (contentType, byteArray) => { + const blob = new Blob([new Uint8Array(byteArray)], { type: contentType }); + return URL.createObjectURL(blob); +}; diff --git a/src/Community.Components/Components/Audio/AudioControls.razor b/src/Community.Components/Components/Audio/AudioControls.razor index ca097481..fa90e67c 100644 --- a/src/Community.Components/Components/Audio/AudioControls.razor +++ b/src/Community.Components/Components/Audio/AudioControls.razor @@ -11,9 +11,9 @@ - + - + diff --git a/src/Community.Components/Components/Audio/AudioControls.razor.cs b/src/Community.Components/Components/Audio/AudioControls.razor.cs index f851454f..9244aa7c 100644 --- a/src/Community.Components/Components/Audio/AudioControls.razor.cs +++ b/src/Community.Components/Components/Audio/AudioControls.razor.cs @@ -93,6 +93,18 @@ public AudioControls() [Parameter] public bool IsNextDisabled { get; set; } + /// + /// Gets or sets a value indicating whether the stop button is disabled. + /// + [Parameter] + public bool IsStopDisabled { get; set; } + + /// + /// Gets or sets a value indicating whether the play/pause button is disabled. + /// + [Parameter] + public bool IsPlayOrPauseDisabled { get; set; } + /// /// Sets the play/pause state of the associated control. /// diff --git a/src/Community.Components/Components/Audio/AudioPlaylist.razor b/src/Community.Components/Components/Audio/AudioPlaylist.razor index 5c15d68c..a8f06909 100644 --- a/src/Community.Components/Components/Audio/AudioPlaylist.razor +++ b/src/Community.Components/Components/Audio/AudioPlaylist.razor @@ -1,14 +1,16 @@ @namespace FluentUI.Blazor.Community.Components @inherits FluentComponentBase -
    - - - -
    - +@if (IsVisible) +{ +
    + + + +
    +} diff --git a/src/Community.Components/Components/Audio/AudioPlaylist.razor.cs b/src/Community.Components/Components/Audio/AudioPlaylist.razor.cs index 6f537be0..a348ceae 100644 --- a/src/Community.Components/Components/Audio/AudioPlaylist.razor.cs +++ b/src/Community.Components/Components/Audio/AudioPlaylist.razor.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Components; using Microsoft.FluentUI.AspNetCore.Components; +using Microsoft.FluentUI.AspNetCore.Components.Utilities; namespace FluentUI.Blazor.Community.Components; @@ -35,4 +36,23 @@ public AudioPlaylist() ///
    [Parameter] public EventCallback OnTrackSelected { get; set; } + + /// + /// Gets or sets a value indicating whether the playlist is visible. + /// + [Parameter] + public bool IsVisible { get; set; } + + /// + /// Gets or sets the height of the playlist component. + /// + [Parameter] + public int Height { get; set; } = 280; + + /// + /// Gets the internal style for the component, including the height. + /// + private string? InternalStyle => new StyleBuilder(Style) + .AddStyle("--audio-playlist-height", $"{Height}px") + .Build(); } diff --git a/src/Community.Components/Components/Audio/AudioPlaylist.razor.css b/src/Community.Components/Components/Audio/AudioPlaylist.razor.css index 40db84fc..33cdb504 100644 --- a/src/Community.Components/Components/Audio/AudioPlaylist.razor.css +++ b/src/Community.Components/Components/Audio/AudioPlaylist.razor.css @@ -1,5 +1,6 @@ .audio-playlist { overflow-y: auto; overflow-x: hidden; - height: 100%; + height: var(--audio-playlist-height, 100%); + width: 100%; } diff --git a/src/Community.Components/Components/Audio/AudioPlaylistItem.razor b/src/Community.Components/Components/Audio/AudioPlaylistItem.razor index e80c0151..0b37affa 100644 --- a/src/Community.Components/Components/Audio/AudioPlaylistItem.razor +++ b/src/Community.Components/Components/Audio/AudioPlaylistItem.razor @@ -9,19 +9,21 @@ VerticalAlignment="VerticalAlignment.Center"> @if (Track is not null) { - @if (!string.IsNullOrEmpty(Track.Cover)) - { + @if (!string.IsNullOrEmpty(Track?.Metadata?.Visual?.CoverUrl)) + { +
    - } + src="@Track?.Metadata?.Visual?.CoverUrl" + title="@Track?.Metadata?.Descriptive?.Title" + aria-label="Track?.Metadata?.Descriptive?.Title" /> +
    + } - - @Track.Title - @Track.Artist - + + @Track?.Metadata?.Descriptive?.Title + @_performers + } diff --git a/src/Community.Components/Components/Audio/AudioPlaylistItem.razor.cs b/src/Community.Components/Components/Audio/AudioPlaylistItem.razor.cs index 904def57..5d91c1d8 100644 --- a/src/Community.Components/Components/Audio/AudioPlaylistItem.razor.cs +++ b/src/Community.Components/Components/Audio/AudioPlaylistItem.razor.cs @@ -1,3 +1,4 @@ +using FluentUI.Blazor.Community.Extensions; using Microsoft.AspNetCore.Components; using Microsoft.FluentUI.AspNetCore.Components; @@ -22,6 +23,16 @@ public partial class AudioPlaylistItem ///