diff --git a/Directory.Packages.props b/Directory.Packages.props index baf94c10..41f6cc86 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -9,30 +9,31 @@ - - - + + + - + - - + + - + + - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/FluentUI.Blazor.Community.sln b/FluentUI.Blazor.Community.sln index c17bd5d7..33653870 100644 --- a/FluentUI.Blazor.Community.sln +++ b/FluentUI.Blazor.Community.sln @@ -1,9 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 - # 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.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 de68ed52..b6f4c951 100644 --- a/examples/demo/FluentUI.Demo.Shared/FluentUI.Blazor.Community.Components.xml +++ b/examples/demo/FluentUI.Demo.Shared/FluentUI.Blazor.Community.Components.xml @@ -2235,6 +2235,2251 @@ + + + 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 the labels for the audio control buttons. + + + + + 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 the callback to be invoked when the properties button is clicked. + + + + + Gets or sets a value indicating whether the previous button is disabled. + + + + + 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 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. + + 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. + + + + + 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. + + 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. + + + + + 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. + + 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. + + + + + Represents whether the track has changed since the last render. + + + + + Represents the performers of the 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 source URL of the audio track. + + + + + Gets or sets the audio metadata associated with the track. + + + + + Gets or sets the parent of this track item. + + + + + + + + + + + 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 a value indicating whether the visualizer is visible. + + + + + 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 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. + + + + + 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. + + + + + Gets or sets a value indicating whether the download button is disabled. + + + + + 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. + + + + + Gets or sets a value indicating whether the button is disabled. + + + + + 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 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. + + + + + Gets or sets a value indicating whether the download button is disabled. + + + + + 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. + + + + + 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 on button. + + + + + Gets or sets the label displayed when the shuffle feature is turned off. + + + + + 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. + + + + + Gets or sets a value indicating whether the stop button is disabled. + + + + + 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 a value indicating whether the stop button should be disabled. + + + + + 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. + + + + + 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. + + + + + Gets or sets the height of the visualizer or the playlist viewer in pixels. + + + + + 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. + + 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. + + + + 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 + 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 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. + + + + + 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. + + + + + 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. + + 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. + + + + + 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. + + + + + 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 easing functions available for color transitions in the ColorPalette component. @@ -9987,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. diff --git a/examples/demo/FluentUI.Demo.Shared/Layout/DemoNavProvider.cs b/examples/demo/FluentUI.Demo.Shared/Layout/DemoNavProvider.cs index c331033a..eb58d225 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; @@ -98,6 +97,11 @@ public DemoNavProvider() ) ] ), + new NavLink( + href: "audio", + icon: new Icons.Regular.Size20.SoundWaveCircle(), + title: "Audio" + ), new NavLink( href: "cookies", icon: new Icons.Regular.Size20.Cookies(), 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..a35091a4 --- /dev/null +++ b/examples/demo/FluentUI.Demo.Shared/Pages/Audio/AudioPage.razor @@ -0,0 +1,51 @@ +@page "/audio" +@using FluentUI.Demo.Shared.Pages.Audio.Examples + +

Audio

+ +

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

+ +

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 8 modes for now : +
    +
  • Spectrum
  • +
  • Waveform
  • +
  • Spatial
  • +
  • Vortex
  • +
  • Particles
  • +
  • RadialWaveform
  • +
  • Tunnel
  • +
  • Constellation
  • +
  • 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..5806f09d --- /dev/null +++ b/examples/demo/FluentUI.Demo.Shared/Pages/Audio/Examples/AudioDefault.razor @@ -0,0 +1,177 @@ +@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) + { + + } + +
+
+
+ +
+ +@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) + { + 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)}"; + var filePath = newFileName; + + if (System.OperatingSystem.IsBrowser()) + { + var provider = new FileExtensionContentTypeProvider(); + if (!provider.TryGetContentType(file.Name, out var contentType)) + { + contentType = "application/octet-stream"; + } + + filePath = await Runtime.InvokeAsync("getBlobUrl", contentType, File.ReadAllBytes(file.LocalFile!.FullName)); + } + else + { + 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); + } + + if (!_audioFiles.ContainsKey(file.Name)) + { + _audioFiles.Add(file.Name, filePath); + } + + 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(500); + 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/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/examples/demo/FluentUI.Demo.Shared/wwwroot/audio/cover.webp b/examples/demo/FluentUI.Demo.Shared/wwwroot/audio/cover.webp new file mode 100644 index 00000000..45858b22 Binary files /dev/null and b/examples/demo/FluentUI.Demo.Shared/wwwroot/audio/cover.webp differ 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/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..2ba7c399 --- /dev/null +++ b/src/Community.Components/Components/Audio/AudioControls.razor @@ -0,0 +1,55 @@ +@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..fd755f10 --- /dev/null +++ b/src/Community.Components/Components/Audio/AudioControls.razor.cs @@ -0,0 +1,141 @@ +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 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. + /// + [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 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. + /// + [Parameter] + public bool IsPreviousDisabled { get; set; } + + /// + /// Gets or sets a value indicating whether the next button is disabled. + /// + [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 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. + /// + /// 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/AudioLabels.cs b/src/Community.Components/Components/Audio/AudioLabels.cs new file mode 100644 index 00000000..43124d91 --- /dev/null +++ b/src/Community.Components/Components/Audio/AudioLabels.cs @@ -0,0 +1,178 @@ +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", + 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" + }; + + /// + /// 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"; + + /// + /// 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/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..a8f06909 --- /dev/null +++ b/src/Community.Components/Components/Audio/AudioPlaylist.razor @@ -0,0 +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 new file mode 100644 index 00000000..a348ceae --- /dev/null +++ b/src/Community.Components/Components/Audio/AudioPlaylist.razor.cs @@ -0,0 +1,58 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.FluentUI.AspNetCore.Components; +using Microsoft.FluentUI.AspNetCore.Components.Utilities; + +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; } + + /// + /// 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 new file mode 100644 index 00000000..33cdb504 --- /dev/null +++ b/src/Community.Components/Components/Audio/AudioPlaylist.razor.css @@ -0,0 +1,6 @@ +.audio-playlist { + overflow-y: auto; + overflow-x: hidden; + 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 new file mode 100644 index 00000000..0b37affa --- /dev/null +++ b/src/Community.Components/Components/Audio/AudioPlaylistItem.razor @@ -0,0 +1,34 @@ +@namespace FluentUI.Blazor.Community.Components +@inherits FluentComponentBase + +
+ + @if (Track is not null) + { + @if (!string.IsNullOrEmpty(Track?.Metadata?.Visual?.CoverUrl)) + { +
+ +
+ } + + + @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 new file mode 100644 index 00000000..5d91c1d8 --- /dev/null +++ b/src/Community.Components/Components/Audio/AudioPlaylistItem.razor.cs @@ -0,0 +1,110 @@ +using FluentUI.Blazor.Community.Extensions; +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(); + + /// + /// 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. + /// + 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); + } + } + + /// + 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/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..8add9d05 --- /dev/null +++ b/src/Community.Components/Components/Audio/AudioTrackItem.cs @@ -0,0 +1,47 @@ +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, IAsyncDisposable +{ + /// + /// Gets or sets the source URL of the audio track. + /// + [Parameter] + public string? Source { get; set; } + + /// + /// Gets or sets the audio metadata associated with the track. + /// + [Parameter] + public AudioMetadata? Metadata { get; set; } + + /// + /// Gets or sets the parent of this track item. + /// + [CascadingParameter] + private FluentCxAudio? Parent { get; set; } + + /// + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync(); + + if (Parent is not null) + { + await Parent.AddTrackAsync(this); + } + } + + /// + public async ValueTask DisposeAsync() + { + 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 new file mode 100644 index 00000000..a2e3b9de --- /dev/null +++ b/src/Community.Components/Components/Audio/AudioVisualizer.razor @@ -0,0 +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 new file mode 100644 index 00000000..34b10b58 --- /dev/null +++ b/src/Community.Components/Components/Audio/AudioVisualizer.razor.cs @@ -0,0 +1,138 @@ +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 _hasChanged; + + /// + /// 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 a value indicating whether the visualizer is visible. + /// + [Parameter] + public bool IsVisible { 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); + } + + if (_hasChanged && + _module is not null && + IsVisible) + { + _hasChanged = false; + 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."); + } + } + + /// + public override Task SetParametersAsync(ParameterView parameters) + { + _hasChanged = parameters.HasValueChanged(nameof(Mode), Mode) || + parameters.HasValueChanged(nameof(IsVisible), IsVisible); + + 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..13a05e91 --- /dev/null +++ b/src/Community.Components/Components/Audio/AudioVisualizer.razor.js @@ -0,0 +1,380 @@ +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); + + 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); + analyser.fftSize = 2048; + + 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, + hueBase: 200, + time: 0 + }; +} + +function setMode(id, mode) { + const instance = _instances[id]; + const canvas = document.getElementById(id); + + if (instance && canvas) { + const analyser = instance.analyzer; + const ctx = canvas.getContext("2d"); + 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; + let last = performance.now(); + + 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); + const now = performance.now(); + const dt = Math.min(0.05, (now - last) / 1000); + last = now; + instance.time += dt; + analyser.smoothingTimeConstant = 0.85; + analyser.getByteFrequencyData(dataArray); + + ctx.clearRect(0, 0, width, height); + + 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) { + const bass = bandEnergy(dataArray, 20, 200) / 255; + const mid = bandEnergy(dataArray, 200, 2000) / 255; + const high = bandEnergy(dataArray, 2000, 12000) / 255; + + 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 -= 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 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; + + const px = s.x / s.z; + const py = s.y / s.z; + 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(); + } + 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; + } + + 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 === 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..2fbe1247 --- /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?.Metadata?.Visual?.CoverUrl)) + { + + } + +
+ + + + + + + +
+ +
+ +
+
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..edf4c6c3 --- /dev/null +++ b/src/Community.Components/Components/Audio/CompactPlayer.razor.cs @@ -0,0 +1,131 @@ +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 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. + /// + 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..aa851cbe --- /dev/null +++ b/src/Community.Components/Components/Audio/CompactPlayer.razor.css @@ -0,0 +1,45 @@ +.compact-player { + width: 400px; + height: 400px; + position: relative; + 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%; + 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..c8002dd5 --- /dev/null +++ b/src/Community.Components/Components/Audio/Controls/DownloadButton.razor @@ -0,0 +1,9 @@ +@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..d68715b8 --- /dev/null +++ b/src/Community.Components/Components/Audio/Controls/DownloadButton.razor.cs @@ -0,0 +1,56 @@ +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 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 OnDownload { get; set; } + + /// + /// Gets or sets the label for the download button. + /// + [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. + /// + public DownloadButton() + { + Id = $"download-button-{Identifier.NewId()}"; + } + + /// + /// Occurs when the download button is clicked. + /// + /// Returns a task which invokes the callback. + private async Task OnClickAsync() + { + 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..52a3f905 --- /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..64673380 --- /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 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. + /// + [Parameter] + public EventCallback OnNext { get; set; } + + /// + /// Gets or sets the label for the next button. + /// + [Parameter] + public string? Label { 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 OnClickAsync() + { + 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..af9e6ae8 --- /dev/null +++ b/src/Community.Components/Components/Audio/Controls/PlayOrPauseButton.razor @@ -0,0 +1,10 @@ +@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..fade1280 --- /dev/null +++ b/src/Community.Components/Components/Audio/Controls/PlayOrPauseButton.razor.cs @@ -0,0 +1,84 @@ +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()}"; + } + + /// + /// Gets or sets a value indicating whether the button is disabled. + /// + [Parameter] + public bool IsDisabled { get; set; } + + /// + /// 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..7f8ed46f --- /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..bdcdf49c --- /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 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. + /// + [Parameter] + public EventCallback OnPlaylist { get; set; } + + /// + /// Gets or sets the label for the previous button. + /// + [Parameter] + public string? Label { 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..06405901 --- /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..dd82ca92 --- /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 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. + /// + [Parameter] + public EventCallback OnPrevious { get; set; } + + /// + /// Gets or sets the label for the previous button. + /// + [Parameter] + public string? Label { 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/PropertiesButton.razor b/src/Community.Components/Components/Audio/Controls/PropertiesButton.razor new file mode 100644 index 00000000..c26e5306 --- /dev/null +++ b/src/Community.Components/Components/Audio/Controls/PropertiesButton.razor @@ -0,0 +1,9 @@ +@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..5f72fb34 --- /dev/null +++ b/src/Community.Components/Components/Audio/Controls/PropertiesButton.razor.cs @@ -0,0 +1,53 @@ +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.Info(); + + /// + /// 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"; + + /// + /// 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. + /// + 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/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..33ac2227 --- /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..f2dbf096 --- /dev/null +++ b/src/Community.Components/Components/Audio/Controls/ShuffleButton.razor.cs @@ -0,0 +1,63 @@ +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 on button. + /// + [Parameter] + 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. + /// + 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..e66841b7 --- /dev/null +++ b/src/Community.Components/Components/Audio/Controls/StopButton.razor @@ -0,0 +1,9 @@ +@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..51ac489a --- /dev/null +++ b/src/Community.Components/Components/Audio/Controls/StopButton.razor.cs @@ -0,0 +1,54 @@ +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 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. + /// + [Parameter] + public EventCallback OnStop { get; set; } + + /// + /// Gets or sets the label for the stop button. + /// + [Parameter] + 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. + /// + 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..5924a092 --- /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..bb0a4ba6 --- /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 Label { get; set; } = "Volume"; + + /// + /// Gets the ARIA label for the volume button, combining the volume label and the current volume percentage. + /// + private string AriaLabel => $"{Label} ({_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..0b03471d --- /dev/null +++ b/src/Community.Components/Components/Audio/FluentCxAudio.razor @@ -0,0 +1,96 @@ +@namespace FluentUI.Blazor.Community.Components +@inherits FluentComponentBase + + +
+ @ChildContent + + @if (View == AudioPlayerView.Default) + { + + + + + } + else if (View == AudioPlayerView.Compact || + View == AudioPlayerView.Floating) + { +
+
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..48bb3765 --- /dev/null +++ b/src/Community.Components/Components/Audio/FluentCxAudio.razor.cs @@ -0,0 +1,670 @@ +using System.Drawing; +using System.Globalization; +using System.Threading.Tasks; +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 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 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. + /// + [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!; + + /// + /// Gets or sets the height of the visualizer or the playlist viewer in pixels. + /// + [Parameter] + public int Height { get; set; } = 280; + + /// + /// Gets or sets the labels for the audio control buttons. + /// + [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. + /// + /// 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?.Metadata?.Descriptive?.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(); + } + + /// + /// 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. + /// + /// + 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 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 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); + } + + /// + 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..201ce1b0 --- /dev/null +++ b/src/Community.Components/Components/Audio/FluentCxAudio.razor.css @@ -0,0 +1,55 @@ +.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; +} + +@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/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/MinimalPlayer.razor b/src/Community.Components/Components/Audio/MinimalPlayer.razor new file mode 100644 index 00000000..a334661c --- /dev/null +++ b/src/Community.Components/Components/Audio/MinimalPlayer.razor @@ -0,0 +1,11 @@ +@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..7967ea8d --- /dev/null +++ b/src/Community.Components/Components/Audio/MinimalPlayer.razor.cs @@ -0,0 +1,36 @@ +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; } + + /// + /// 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/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..b84d51e1 --- /dev/null +++ b/src/Community.Components/Components/Audio/TrackInfo.razor @@ -0,0 +1,27 @@ +@namespace FluentUI.Blazor.Community.Components +@inherits FluentComponentBase + + + @if (Track is not null) + { + @if (!string.IsNullOrEmpty(Track?.Metadata?.Visual?.CoverUrl)) + { +
+ +
+ } + + + @Track?.Metadata?.Descriptive?.Title + @(string.Join(", ", Track?.Metadata?.Descriptive?.Performers)) + + } +
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/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!; +} 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/src/Community.Components/Extensions/ServiceCollectionExtensions.cs b/src/Community.Components/Extensions/ServiceCollectionExtensions.cs index 6b00666c..bd974d7c 100644 --- a/src/Community.Components/Extensions/ServiceCollectionExtensions.cs +++ b/src/Community.Components/Extensions/ServiceCollectionExtensions.cs @@ -20,6 +20,8 @@ public static IServiceCollection AddFluentCxUIComponents(this IServiceCollection .AddScoped() .AddScoped() .AddScoped() - .AddSingleton(); + .AddSingleton() + .AddScoped() + .AddScoped(); } } diff --git a/src/Community.Components/FluentUI.Blazor.Community.Components.csproj b/src/Community.Components/FluentUI.Blazor.Community.Components.csproj index 5263d950..2f5e4ec0 100644 --- a/src/Community.Components/FluentUI.Blazor.Community.Components.csproj +++ b/src/Community.Components/FluentUI.Blazor.Community.Components.csproj @@ -66,6 +66,7 @@ + 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..0654993c --- /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 { Source = "track.mp3" }; + + // 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 { Source = "track.mp3" }; + 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 { Source = "track.mp3" }; + 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..ac0e4ec0 --- /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 { Source = "track1.mp3" }; + var track2 = new AudioTrackItem { Source = "track2.mp3" }; + 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 { Source = "current.mp3" }; + + // 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 { Source = "selected.mp3" }; + 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..53bf0646 --- /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 { Source = "src.mp3" }; + + // 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..fb90fb6e --- /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.Label); + } + + [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("OnClickAsync", 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("OnClickAsync", 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..61007fc2 --- /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("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/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..ee99a507 --- /dev/null +++ b/tests/FluentUI.Blazor.Community.Components.Tests/Components/Audio/TrackInfoTests.cs @@ -0,0 +1,90 @@ +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 + { + 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) + ); + + var img = cut.Find("img"); + + 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 + { + Source = "track.mp3", + Metadata = new() + }; + + var cut = RenderComponent(parameters => parameters + .Add(p => p.Track, track) + ); + + Assert.Empty(cut.FindAll("img")); + } + + [Fact] + public void CallsOnHandleClickAsync_WhenClicked() + { + var track = new AudioTrackItem + { + Source = "track.mp3", + Metadata = new() + }; + + 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..d25f5493 --- /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.Label); + } + + [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 }); + } +} 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 +