Skip to main content

fission_core/ui/widgets/
video.rs

1use crate::internal::InternalLower;
2use crate::lowering::{InternalIrBuilder, InternalLoweringCx};
3use fission_ir::{
4    op::{EmbedKind, LayoutOp, Op},
5    WidgetId,
6};
7use serde::{Deserialize, Serialize};
8use std::path::Path;
9
10/// A platform-native video player widget.
11///
12/// The video is rendered by the platform's native player and embedded into the
13/// Fission layout as an opaque surface. Use
14/// [`crate::internal::BuildCtx::video_controls`] to create play/pause/seek
15/// action envelopes.
16///
17/// # Example
18///
19/// ```rust,ignore
20/// Video::network("https://example.com/clip.mp4")
21///     .size(640.0, 360.0)
22///     .autoplay(true)
23///     .loop_playback(false)
24///     .audio(VideoAudioOptions::playback())
25///     .into();
26/// ```
27#[derive(Debug, Clone, Serialize, Deserialize)]
28#[non_exhaustive]
29pub struct Video {
30    /// Stable widget identity (auto-derived from `source` if `None`).
31    pub id: Option<WidgetId>,
32    /// Typed video source consumed by the active shell.
33    pub source: VideoSource,
34    /// Fixed width in layout points.
35    pub width: Option<f32>,
36    /// Fixed height in layout points.
37    pub height: Option<f32>,
38    /// Whether to start playing immediately.
39    pub autoplay: bool,
40    /// Whether to loop playback when the video ends.
41    pub loop_playback: bool,
42    /// Platform-neutral audio-session behavior for this video.
43    ///
44    /// The default is [`VideoAudioOptions::system_default`], which lets the
45    /// host platform keep its normal audio policy. Use
46    /// [`VideoAudioOptions::playback`] for foreground media playback that
47    /// should behave like a video player rather than incidental UI audio.
48    #[serde(default)]
49    pub audio: VideoAudioOptions,
50}
51
52impl Default for Video {
53    fn default() -> Self {
54        Self {
55            id: None,
56            source: VideoSource::default(),
57            width: None,
58            height: None,
59            autoplay: false,
60            loop_playback: false,
61            audio: VideoAudioOptions::default(),
62        }
63    }
64}
65
66/// Source of media for a [`Video`] widget.
67///
68/// The source is platform-neutral. Shells translate it into the native media
69/// API they own: static and SSR targets emit HTML `<video>` sources, web uses
70/// `HtmlVideoElement`, and native shells use their platform video backends.
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72#[serde(tag = "kind", rename_all = "snake_case")]
73pub enum VideoSource {
74    /// App-bundled asset path, relative to the app/project asset root.
75    Asset { path: String },
76    /// Local filesystem path.
77    File { path: String },
78    /// Network URL. Backend support depends on the active shell and platform.
79    Network { url: String },
80}
81
82impl Default for VideoSource {
83    fn default() -> Self {
84        Self::Asset {
85            path: String::new(),
86        }
87    }
88}
89
90impl VideoSource {
91    /// Returns the shell-facing source string.
92    pub fn as_str(&self) -> &str {
93        match self {
94            Self::Asset { path } | Self::File { path } => path,
95            Self::Network { url } => url,
96        }
97    }
98
99    /// Returns a stable source key for fallback IDs and runtime comparisons.
100    pub fn key(&self) -> String {
101        match self {
102            Self::Asset { path } => format!("asset:{path}"),
103            Self::File { path } => format!("file:{path}"),
104            Self::Network { url } => format!("network:{url}"),
105        }
106    }
107}
108
109impl From<&str> for VideoSource {
110    fn from(source: &str) -> Self {
111        infer_video_source(source)
112    }
113}
114
115impl From<String> for VideoSource {
116    fn from(source: String) -> Self {
117        infer_video_source(&source)
118    }
119}
120
121fn infer_video_source(source: &str) -> VideoSource {
122    if source.contains("://") {
123        VideoSource::Network {
124            url: source.to_string(),
125        }
126    } else if Path::new(source).is_absolute() {
127        VideoSource::File {
128            path: source.to_string(),
129        }
130    } else {
131        VideoSource::Asset {
132            path: source.to_string(),
133        }
134    }
135}
136
137impl Video {
138    /// Creates a video from an app-bundled asset path.
139    pub fn asset(path: impl Into<String>) -> Self {
140        Self::from_source(VideoSource::Asset { path: path.into() })
141    }
142
143    /// Creates a video from a local filesystem path.
144    pub fn file(path: impl Into<String>) -> Self {
145        Self::from_source(VideoSource::File { path: path.into() })
146    }
147
148    /// Creates a video from a network URL.
149    pub fn network(url: impl Into<String>) -> Self {
150        Self::from_source(VideoSource::Network { url: url.into() })
151    }
152
153    /// Creates a video from a typed source.
154    pub fn from_source(source: impl Into<VideoSource>) -> Self {
155        Self {
156            source: source.into(),
157            ..Self::default()
158        }
159    }
160
161    /// Sets an explicit widget identity.
162    pub fn id(mut self, id: WidgetId) -> Self {
163        self.id = Some(id);
164        self
165    }
166
167    /// Sets a fixed width in layout points.
168    pub fn width(mut self, width: f32) -> Self {
169        self.width = Some(width);
170        self
171    }
172
173    /// Sets a fixed height in layout points.
174    pub fn height(mut self, height: f32) -> Self {
175        self.height = Some(height);
176        self
177    }
178
179    /// Sets a fixed width and height in layout points.
180    pub fn size(mut self, width: f32, height: f32) -> Self {
181        self.width = Some(width);
182        self.height = Some(height);
183        self
184    }
185
186    /// Sets whether playback starts automatically.
187    pub fn autoplay(mut self, autoplay: bool) -> Self {
188        self.autoplay = autoplay;
189        self
190    }
191
192    /// Sets whether playback loops after reaching the end.
193    pub fn loop_playback(mut self, loop_playback: bool) -> Self {
194        self.loop_playback = loop_playback;
195        self
196    }
197
198    /// Sets platform-neutral audio behavior.
199    pub fn audio(mut self, audio: VideoAudioOptions) -> Self {
200        self.audio = audio;
201        self
202    }
203}
204
205/// Audio-session behavior requested by a [`Video`] widget.
206///
207/// This describes product intent independently from any single host API. Shells
208/// translate the policy into their native platform API where one exists, and
209/// ignore unsupported fields rather than changing the product default globally.
210#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
211pub struct VideoAudioOptions {
212    /// Cross-platform audio-session policy.
213    pub policy: VideoAudioPolicy,
214    /// When the shell should activate the platform audio session.
215    pub activation: VideoAudioActivation,
216    /// Whether this video may mix with other active audio where supported.
217    pub mix_with_others: bool,
218    /// Whether this video may temporarily duck other active audio where supported.
219    pub duck_others: bool,
220    /// iOS-specific AVAudioSession overrides.
221    #[serde(default)]
222    pub ios: IosVideoAudioOptions,
223}
224
225impl Default for VideoAudioOptions {
226    fn default() -> Self {
227        Self::system_default()
228    }
229}
230
231impl VideoAudioOptions {
232    /// Uses the platform's default audio-session behavior.
233    ///
234    /// This is Fission's default because whether video should ignore the iOS
235    /// silent switch, mix with background audio, or claim exclusive playback is
236    /// a product decision.
237    pub fn system_default() -> Self {
238        Self {
239            policy: VideoAudioPolicy::SystemDefault,
240            activation: VideoAudioActivation::OnDemand,
241            mix_with_others: false,
242            duck_others: false,
243            ios: IosVideoAudioOptions::default(),
244        }
245    }
246
247    /// Requests ambient media behavior: mixable, non-disruptive playback that
248    /// respects platform silent/mute conventions where supported.
249    pub fn ambient() -> Self {
250        Self {
251            policy: VideoAudioPolicy::Ambient,
252            mix_with_others: true,
253            ..Self::system_default()
254        }
255    }
256
257    /// Requests foreground playback behavior for a primary media player.
258    ///
259    /// On iOS this maps to `AVAudioSessionCategoryPlayback`, so it may play
260    /// even when the silent switch is engaged.
261    pub fn playback() -> Self {
262        Self {
263            policy: VideoAudioPolicy::Playback,
264            ..Self::system_default()
265        }
266    }
267
268    /// Adds the platform "mix with others" hint.
269    pub fn mix_with_others(mut self, value: bool) -> Self {
270        self.mix_with_others = value;
271        self
272    }
273
274    /// Adds the platform "duck others" hint.
275    pub fn duck_others(mut self, value: bool) -> Self {
276        self.duck_others = value;
277        self
278    }
279
280    /// Overrides the default activation behavior.
281    pub fn activation(mut self, activation: VideoAudioActivation) -> Self {
282        self.activation = activation;
283        self
284    }
285
286    /// Adds iOS-specific AVAudioSession overrides.
287    pub fn ios(mut self, ios: IosVideoAudioOptions) -> Self {
288        self.ios = ios;
289        self
290    }
291}
292
293/// Cross-platform audio-session policy for host-backed video.
294#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
295pub enum VideoAudioPolicy {
296    /// Do not configure platform audio-session state.
297    SystemDefault,
298    /// Non-disruptive media that should respect silent/mute conventions where possible.
299    Ambient,
300    /// Foreground media playback that may ignore silent/mute conventions where supported.
301    Playback,
302}
303
304/// When a platform audio session should be activated.
305#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
306pub enum VideoAudioActivation {
307    /// Activate only when playback is requested.
308    OnDemand,
309    /// Configure eagerly when the host player is created.
310    OnPlayerCreate,
311    /// Configure category/options, but do not activate automatically.
312    Manual,
313}
314
315/// iOS AVAudioSession-specific overrides for [`VideoAudioOptions`].
316///
317/// Use this only when the cross-platform policy is not precise enough. Raw
318/// strings are accepted so apps can adopt new AVAudioSession categories or
319/// modes before Fission grows first-class enum variants.
320#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
321pub struct IosVideoAudioOptions {
322    /// Overrides the AVAudioSession category derived from [`VideoAudioPolicy`].
323    pub category: Option<IosAudioSessionCategory>,
324    /// Overrides the AVAudioSession mode. Defaults to `Default`.
325    pub mode: Option<IosAudioSessionMode>,
326    /// Additional AVAudioSession category options.
327    #[serde(default)]
328    pub category_options: Vec<IosAudioSessionCategoryOption>,
329}
330
331impl IosVideoAudioOptions {
332    /// Creates an override that forces an AVAudioSession category.
333    pub fn category(category: IosAudioSessionCategory) -> Self {
334        Self {
335            category: Some(category),
336            ..Default::default()
337        }
338    }
339
340    /// Creates an override that forces an AVAudioSession mode.
341    pub fn mode(mode: IosAudioSessionMode) -> Self {
342        Self {
343            mode: Some(mode),
344            ..Default::default()
345        }
346    }
347
348    /// Adds an AVAudioSession category option.
349    pub fn with_option(mut self, option: IosAudioSessionCategoryOption) -> Self {
350        self.category_options.push(option);
351        self
352    }
353}
354
355/// iOS AVAudioSession categories exposed by Fission.
356#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
357pub enum IosAudioSessionCategory {
358    Ambient,
359    SoloAmbient,
360    Playback,
361    Record,
362    PlayAndRecord,
363    MultiRoute,
364    /// Raw AVAudioSession category constant name, for example
365    /// `"AVAudioSessionCategoryPlayback"`.
366    Raw(String),
367}
368
369/// iOS AVAudioSession modes exposed by Fission.
370#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
371pub enum IosAudioSessionMode {
372    Default,
373    MoviePlayback,
374    SpokenAudio,
375    VideoRecording,
376    Measurement,
377    VoiceChat,
378    VideoChat,
379    GameChat,
380    /// Raw AVAudioSession mode constant name, for example
381    /// `"AVAudioSessionModeMoviePlayback"`.
382    Raw(String),
383}
384
385/// iOS AVAudioSession category options exposed by Fission.
386#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
387pub enum IosAudioSessionCategoryOption {
388    MixWithOthers,
389    DuckOthers,
390    AllowBluetoothHfp,
391    DefaultToSpeaker,
392    InterruptSpokenAudioAndMixWithOthers,
393    AllowBluetoothA2dp,
394    AllowAirPlay,
395    OverrideMutedMicrophoneInterruption,
396    /// Raw AVAudioSessionCategoryOptions bit.
397    Raw(u64),
398}
399
400impl InternalLower for Video {
401    fn lower(&self, cx: &mut InternalLoweringCx) -> WidgetId {
402        let widget_id = self
403            .id
404            .unwrap_or_else(|| WidgetId::explicit(&self.source.key()));
405        let layout_id = cx.widget_node_id(widget_id);
406
407        let embed_id = InternalIrBuilder::new(
408            cx.next_node_id(),
409            Op::Layout(LayoutOp::Embed {
410                kind: EmbedKind::Video,
411                widget_id,
412                width: self.width,
413                height: self.height,
414            }),
415        )
416        .build(cx);
417
418        let mut layout_builder = InternalIrBuilder::new(
419            layout_id,
420            Op::Layout(LayoutOp::Box {
421                width: self.width,
422                height: self.height,
423                min_width: None,
424                max_width: None,
425                min_height: None,
426                max_height: None,
427                padding: [0.0; 4],
428                flex_grow: 0.0,
429                flex_shrink: 0.0,
430                aspect_ratio: None,
431            }),
432        );
433        layout_builder.add_child(embed_id);
434        layout_builder.build(cx)
435    }
436}