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}