waterui_media/
video.rs

1//! Video components and playback controls.
2//!
3//! This module provides two distinct video components:
4//!
5//! - [`Video`]: A raw view that displays video without controls (uses AVPlayerLayer/SurfaceView)
6//! - [`VideoPlayer`]: A full-featured player with native controls (uses AVPlayerViewController/ExoPlayer)
7//!
8//! ## Volume Control System
9//!
10//! Both video components use a special volume encoding:
11//! - Positive values (> 0): Audible volume level
12//! - Negative values (< 0): Muted state that preserves the original volume level
13//! - When unmuting, the absolute value is restored
14//!
15//! ## Examples
16//!
17//! ```ignore
18//! use waterui_media::{Video, VideoPlayer};
19//! use waterui_core::binding;
20//!
21//! // Raw video view - no controls, just displays video
22//! let video = Video::new("https://example.com/video.mp4")
23//!     .aspect_ratio(AspectRatio::Fill);
24//!
25//! // Full-featured video player with native controls
26//! let player = VideoPlayer::new("https://example.com/video.mp4")
27//!     .show_controls(true);
28//!
29//! // Control volume/mute state
30//! let muted = binding(false);
31//! let video = Video::new("https://example.com/video.mp4").muted(&muted);
32//! muted.set(true);  // Mute - preserves volume level
33//! muted.set(false); // Unmute - restores original volume
34//! ```
35
36use waterui_core::{
37    Binding, Computed, binding, configurable, layout::StretchAxis, reactive::signal::IntoComputed,
38};
39
40use crate::Url;
41
42/// Aspect ratio mode for video playback.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
44#[repr(i32)]
45pub enum AspectRatio {
46    /// Fit the video within the bounds while maintaining aspect ratio (letterbox/pillarbox).
47    #[default]
48    Fit = 0,
49    /// Fill the entire bounds, potentially cropping the video.
50    Fill = 1,
51    /// Stretch the video to fill the bounds, ignoring aspect ratio.
52    Stretch = 2,
53}
54
55/// A Volume value represents the audio volume level of a player.
56///
57/// In a non-muted state, the volume is represented as a positive value (> 0).
58/// When muted, the volume is stored as a negative value (< 0),
59/// which preserves the original volume level. This allows the player
60/// to return to the previous volume setting when unmuted.
61///
62/// # Examples
63///
64/// - Volume 0.7 (70%) is stored as `0.7`
65/// - When muted, 0.7 becomes `-0.7`
66/// - When unmuted, `-0.7` becomes `0.7` again
67pub type Volume = f32;
68
69/// Events emitted by video components.
70#[derive(Debug, Clone)]
71pub enum Event {
72    /// The video is ready to play.
73    ReadyToPlay,
74    /// The video has finished playing.
75    Ended,
76    /// The video is buffering due to slow network or disk.
77    Buffering,
78    /// The video has resumed playing after buffering.
79    BufferingEnded,
80    /// An error occurred while loading or playing the video.
81    Error {
82        /// The error message describing what went wrong.
83        message: String,
84    },
85}
86
87type OnEvent = Box<dyn Fn(Event) + 'static>;
88
89// =============================================================================
90// Video - Raw view without controls
91// =============================================================================
92
93/// Configuration for the [`Video`] component (raw video view).
94///
95/// This is a raw video view that displays video content without any native controls.
96/// Use this when you want to build your own custom video UI.
97pub struct VideoConfig {
98    /// The URL of the video source.
99    pub source: Computed<Url>,
100    /// The volume of the video.
101    pub volume: Binding<Volume>,
102    /// The aspect ratio mode for video playback.
103    pub aspect_ratio: AspectRatio,
104    /// Whether the video should loop when it ends.
105    pub loops: bool,
106    /// The event handler for video events.
107    pub on_event: OnEvent,
108}
109
110impl core::fmt::Debug for VideoConfig {
111    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
112        f.debug_struct("VideoConfig")
113            .field("aspect_ratio", &self.aspect_ratio)
114            .field("loops", &self.loops)
115            .finish_non_exhaustive()
116    }
117}
118
119configurable!(
120    /// A raw video view that displays video without native controls.
121    ///
122    /// Use this component when you want to display video content and build
123    /// your own custom UI controls. For a full-featured player with native
124    /// controls, use [`VideoPlayer`] instead.
125    ///
126    /// # Platform Implementation
127    ///
128    /// - **iOS/macOS**: Uses `AVPlayerLayer` directly
129    /// - **Android**: Uses `SurfaceView` with ExoPlayer
130    Video,
131    VideoConfig,
132    |config| match config.aspect_ratio {
133        AspectRatio::Fit => StretchAxis::Horizontal,
134        AspectRatio::Fill | AspectRatio::Stretch => StretchAxis::Both,
135    }
136);
137
138impl Video {
139    /// Creates a new raw video view.
140    pub fn new(source: impl IntoComputed<Url>) -> Self {
141        Self(VideoConfig {
142            source: source.into_computed(),
143            volume: binding(0.5),
144            aspect_ratio: AspectRatio::default(),
145            loops: true,
146            on_event: Box::new(|_| {}),
147        })
148    }
149
150    /// Sets the aspect ratio mode for the video.
151    #[must_use]
152    pub const fn aspect_ratio(mut self, aspect_ratio: AspectRatio) -> Self {
153        self.0.aspect_ratio = aspect_ratio;
154        self
155    }
156
157    /// Sets whether the video should loop when it ends.
158    #[must_use]
159    pub const fn loops(mut self, loops: bool) -> Self {
160        self.0.loops = loops;
161        self
162    }
163
164    /// Sets the event handler for video events.
165    #[must_use]
166    pub fn on_event(mut self, handler: impl Fn(Event) + 'static) -> Self {
167        self.0.on_event = Box::new(handler);
168        self
169    }
170
171    /// Mutes or unmutes the video based on the provided boolean binding.
172    #[must_use]
173    pub fn muted(mut self, muted: &Binding<bool>) -> Self {
174        let volume_binding = self.0.volume;
175        self.0.volume = Binding::mapping(
176            muted,
177            {
178                let volume_binding = volume_binding.clone();
179                move |value| {
180                    if value {
181                        -volume_binding.get().abs()
182                    } else {
183                        volume_binding.get().abs()
184                    }
185                }
186            },
187            move |binding, value| {
188                binding.set(value <= 0.0);
189                volume_binding.set(value);
190            },
191        );
192        self
193    }
194
195    /// Sets the volume binding for the video.
196    #[must_use]
197    pub fn volume(mut self, volume: &Binding<Volume>) -> Self {
198        self.0.volume = volume.clone();
199        self
200    }
201}
202
203// =============================================================================
204// VideoPlayer - Full-featured player with native controls
205// =============================================================================
206
207/// Configuration for the [`VideoPlayer`] component.
208///
209/// This configuration defines a full-featured video player with native controls.
210pub struct VideoPlayerConfig {
211    /// The URL of the video source.
212    pub source: Computed<Url>,
213    /// The volume of the video player.
214    pub volume: Binding<Volume>,
215    /// The aspect ratio mode for video playback.
216    pub aspect_ratio: AspectRatio,
217    /// Whether to show native playback controls.
218    pub show_controls: bool,
219    /// The event handler for the video player.
220    pub on_event: OnEvent,
221}
222
223impl core::fmt::Debug for VideoPlayerConfig {
224    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
225        f.debug_struct("VideoPlayerConfig")
226            .field("aspect_ratio", &self.aspect_ratio)
227            .field("show_controls", &self.show_controls)
228            .finish_non_exhaustive()
229    }
230}
231
232configurable!(
233    /// A full-featured video player with native playback controls.
234    ///
235    /// Use this component when you want a complete video playback experience
236    /// with platform-native controls (play/pause, seek, fullscreen, etc.).
237    /// For a raw video view without controls, use [`Video`] instead.
238    ///
239    /// # Platform Implementation
240    ///
241    /// - **iOS/tvOS**: Uses `AVPlayerViewController` with standard iOS controls
242    /// - **macOS**: Uses `AVPlayerView` with inline controls
243    /// - **Android**: Uses ExoPlayer with `PlayerView`
244    VideoPlayer,
245    VideoPlayerConfig,
246    |config| match config.aspect_ratio {
247        AspectRatio::Fit => StretchAxis::Horizontal,
248        AspectRatio::Fill | AspectRatio::Stretch => StretchAxis::Both,
249    }
250);
251
252impl VideoPlayer {
253    /// Creates a new video player with native controls.
254    pub fn new(source: impl IntoComputed<Url>) -> Self {
255        Self(VideoPlayerConfig {
256            source: source.into_computed(),
257            volume: binding(0.5),
258            aspect_ratio: AspectRatio::default(),
259            show_controls: true,
260            on_event: Box::new(|_| {}),
261        })
262    }
263
264    /// Sets the aspect ratio mode for the video player.
265    #[must_use]
266    pub const fn aspect_ratio(mut self, aspect_ratio: AspectRatio) -> Self {
267        self.0.aspect_ratio = aspect_ratio;
268        self
269    }
270
271    /// Sets whether to show native playback controls.
272    #[must_use]
273    pub const fn show_controls(mut self, show_controls: bool) -> Self {
274        self.0.show_controls = show_controls;
275        self
276    }
277
278    /// Sets the event handler for the video player.
279    #[must_use]
280    pub fn on_event(mut self, handler: impl Fn(Event) + 'static) -> Self {
281        self.0.on_event = Box::new(handler);
282        self
283    }
284
285    /// Mutes or unmutes the video player based on the provided boolean binding.
286    #[must_use]
287    pub fn muted(mut self, muted: &Binding<bool>) -> Self {
288        let volume_binding = self.0.volume;
289        self.0.volume = Binding::mapping(
290            muted,
291            {
292                let volume_binding = volume_binding.clone();
293                move |value| {
294                    if value {
295                        -volume_binding.get().abs()
296                    } else {
297                        volume_binding.get().abs()
298                    }
299                }
300            },
301            move |binding, value| {
302                binding.set(value <= 0.0);
303                volume_binding.set(value);
304            },
305        );
306        self
307    }
308
309    /// Sets the volume binding for the video player.
310    #[must_use]
311    pub fn volume(mut self, volume: &Binding<Volume>) -> Self {
312        self.0.volume = volume.clone();
313        self
314    }
315}