Skip to main content

atmos/os_lib/web_engine/
media_controls.rs

1//! 音声・動画メディア要素(<video> / <audio> / <source>)のソース選択および再生コントロール管理。
2//!
3//! W3C HTML5 §4.8.14 `HTMLMediaElement` および §4.8.14.8 Resource selection algorithm に準拠する。
4//! ハードウェア非依存の純粋ロジックであり、ホストユニットテスト環境から直接検証可能。
5
6extern crate alloc;
7
8use alloc::format;
9use alloc::string::String;
10
11/// `<source>` 要素の候補データ
12#[derive(Debug, Clone, PartialEq)]
13pub struct MediaSourceCandidate {
14    pub src: String,
15    pub type_attr: Option<String>,
16    pub media_attr: Option<String>,
17}
18
19/// MIME タイプおよびコーデックに対する再生可能性(W3C HTML5 canPlayType)
20/// - `"probably"`: 明確に再生可能(コーデックも対応)
21/// - `"maybe"`: コンテナ形式は対応(コーデック未指定のためデータを確認するまで確定不可)
22/// - `""`: 非対応
23pub fn can_play_type(mime_type: &str) -> &'static str {
24    let raw = mime_type.trim();
25    if raw.is_empty() {
26        return "";
27    }
28
29    let lower = raw.to_ascii_lowercase();
30
31    // セミコロンで分割して MIME タイプ本体とパラメータ(codecs)を取得
32    let mut parts = lower.split(';');
33    let base_mime = parts.next().unwrap_or("").trim();
34    let params_str = parts.next().unwrap_or("").trim();
35
36    // コーデックパラメータの抽出(例: codecs="avc1.42E01E" または codecs=avc1.42E01E)
37    let codec_param = if !params_str.is_empty() {
38        let p_trimmed = params_str.trim();
39        if let Some(rest) = p_trimmed.strip_prefix("codecs=") {
40            let val = rest.trim().trim_matches('"').trim_matches('\'').trim();
41            Some(val)
42        } else {
43            None
44        }
45    } else {
46        None
47    };
48
49    match base_mime {
50        "video/mp4" => {
51            if let Some(codec) = codec_param {
52                // AtmOS の H.264 Baseline/Main デコーダで対応可能なコーデック
53                if codec.starts_with("avc1") || codec.starts_with("mp4v") {
54                    "probably"
55                } else if codec.starts_with("mp4a") {
56                    // 音声専用コーデックが動画に指定された場合
57                    "maybe"
58                } else {
59                    "" // 非対応コーデック(HEVC, VP9, AV1 等)
60                }
61            } else {
62                "maybe"
63            }
64        }
65        "audio/mp4" => {
66            if let Some(codec) = codec_param {
67                if codec.starts_with("mp4a") || codec.starts_with("mp3") {
68                    "probably"
69                } else {
70                    ""
71                }
72            } else {
73                "maybe"
74            }
75        }
76        "video/webm" => {
77            if let Some(codec) = codec_param {
78                if codec.starts_with("vp8") || codec.starts_with("vorbis") {
79                    "probably"
80                } else {
81                    ""
82                }
83            } else {
84                "maybe"
85            }
86        }
87        "audio/webm" => {
88            if let Some(codec) = codec_param {
89                if codec.starts_with("vorbis") || codec.starts_with("opus") {
90                    "probably"
91                } else {
92                    ""
93                }
94            } else {
95                "maybe"
96            }
97        }
98        "audio/wav" | "audio/x-wav" | "audio/wave" => "probably",
99        "audio/mpeg" | "audio/mp3" => "probably",
100        _ => "",
101    }
102}
103
104/// 拡張子からメディア対応可否を判定(type 属性省略時のフォールバック用)
105fn is_supported_media_extension(path: &str, parent_tag: &str) -> bool {
106    let lower = path.to_ascii_lowercase();
107    let ext = lower.rsplit('.').next().unwrap_or("");
108    match ext {
109        "mp4" | "m4v" => true,
110        "webm" => true,
111        "wav" => parent_tag == "audio" || parent_tag == "video",
112        "mp3" => parent_tag == "audio" || parent_tag == "video",
113        _ => false,
114    }
115}
116
117/// 簡易メディアクエリの評価(min-width, max-width)
118fn evaluate_media_attr(media_query: &str, viewport_width: u32) -> bool {
119    let mq = media_query.trim().to_ascii_lowercase();
120    if mq.is_empty() || mq == "all" || mq == "screen" {
121        return true;
122    }
123
124    // (min-width: 800px) 形式の簡易パーサ
125    if let Some(idx) = mq.find("min-width") {
126        if let Some(colon) = mq.get(idx..).and_then(|s| s.find(':')) {
127            let rest = mq.get(idx + colon + 1..).unwrap_or("");
128            let num_str: String = rest.chars().filter(|c| c.is_ascii_digit()).collect();
129            if let Ok(val) = num_str.parse::<u32>() {
130                if viewport_width < val {
131                    return false;
132                }
133            }
134        }
135    }
136
137    if let Some(idx) = mq.find("max-width") {
138        if let Some(colon) = mq.get(idx..).and_then(|s| s.find(':')) {
139            let rest = mq.get(idx + colon + 1..).unwrap_or("");
140            let num_str: String = rest.chars().filter(|c| c.is_ascii_digit()).collect();
141            if let Ok(val) = num_str.parse::<u32>() {
142                if viewport_width > val {
143                    return false;
144                }
145            }
146        }
147    }
148
149    true
150}
151
152/// `<video>` / `<audio>` 要素の最適な `<source>` を選択する。
153///
154/// 1. 親要素自身に `src` 属性があれば最優先で採用。
155/// 2. 子要素 `<source>` を順に走査し、未対応形式(type / 拡張子)やメディアクエリ不一致をスキップ。
156/// 3. 最初に合致した有効なソースの URL を返す。
157pub fn select_media_source(
158    parent_tag: &str,
159    parent_src: Option<&str>,
160    sources: &[MediaSourceCandidate],
161    viewport_width: u32,
162    _viewport_height: u32,
163) -> Option<String> {
164    if let Some(src) = parent_src {
165        let trimmed = src.trim();
166        if !trimmed.is_empty() {
167            return Some(String::from(trimmed));
168        }
169    }
170
171    for candidate in sources {
172        let src = candidate.src.trim();
173        if src.is_empty() {
174            continue;
175        }
176
177        // media 属性の評価
178        if let Some(ref mq) = candidate.media_attr {
179            if !evaluate_media_attr(mq, viewport_width) {
180                continue;
181            }
182        }
183
184        // type 属性の評価
185        if let Some(ref type_str) = candidate.type_attr {
186            let result = can_play_type(type_str);
187            if result.is_empty() {
188                continue; // 非対応
189            }
190        } else {
191            // type 属性がない場合は拡張子からフォールバック判定
192            if !is_supported_media_extension(src, parent_tag) {
193                continue;
194            }
195        }
196
197        return Some(String::from(src));
198    }
199
200    None
201}
202
203/// 秒単位の時間を "MM:SS" または "HH:MM:SS" 表記にフォーマット
204pub fn format_media_time(seconds: f64) -> String {
205    let total_secs = seconds.max(0.0) as u64;
206    let h = total_secs / 3600;
207    let m = (total_secs % 3600) / 60;
208    let s = total_secs % 60;
209    if h > 0 {
210        format!("{:02}:{:02}:{:02}", h, m, s)
211    } else {
212        format!("{:02}:{:02}", m, s)
213    }
214}
215
216/// メディアプレイヤーの再生状態・コントロール計算
217#[derive(Debug, Clone, PartialEq)]
218pub struct MediaControlsState {
219    pub current_time: f64,
220    pub duration: f64,
221    pub volume: f32,
222    pub muted: bool,
223    pub paused: bool,
224}
225
226impl MediaControlsState {
227    pub fn new(duration: f64) -> Self {
228        Self {
229            current_time: 0.0,
230            duration: duration.max(0.0),
231            volume: 1.0,
232            muted: false,
233            paused: true,
234        }
235    }
236
237    /// 再生進行度(0.0〜1.0)
238    pub fn progress_ratio(&self) -> f32 {
239        if self.duration <= 0.0 {
240            0.0
241        } else {
242            ((self.current_time / self.duration) as f32).clamp(0.0, 1.0)
243        }
244    }
245
246    /// "00:30 / 02:00" 形式の文字列表現
247    pub fn format_display(&self) -> String {
248        format!(
249            "{} / {}",
250            format_media_time(self.current_time),
251            format_media_time(self.duration)
252        )
253    }
254
255    /// 進行度パーセント(0.0〜1.0)からシーク
256    pub fn seek_by_ratio(&mut self, ratio: f32) {
257        let clamped = ratio.clamp(0.0, 1.0) as f64;
258        self.current_time = (self.duration * clamped).clamp(0.0, self.duration);
259    }
260
261    /// シークバーのクリックX座標から目標秒数を計算
262    pub fn calculate_seek_time(click_x: i32, bar_x: i32, bar_width: i32, duration: f64) -> f64 {
263        if bar_width <= 0 || duration <= 0.0 {
264            return 0.0;
265        }
266        let offset = (click_x - bar_x).max(0);
267        let ratio = (offset as f64 / bar_width as f64).clamp(0.0, 1.0);
268        (duration * ratio).clamp(0.0, duration)
269    }
270
271    /// 実効音量(ミュート時は 0.0)
272    pub fn effective_volume(&self) -> f32 {
273        if self.muted {
274            0.0
275        } else {
276            self.volume
277        }
278    }
279
280    /// 音量の設定(0.0〜1.0)
281    pub fn set_volume(&mut self, vol: f32) {
282        self.volume = vol.clamp(0.0, 1.0);
283        if self.volume == 0.0 {
284            self.muted = true;
285        } else if self.muted {
286            self.muted = false;
287        }
288    }
289
290    /// ミュートトグル
291    pub fn toggle_mute(&mut self) -> bool {
292        self.muted = !self.muted;
293        self.muted
294    }
295
296    /// 再生/一時停止トグル
297    pub fn toggle_play(&mut self) -> bool {
298        self.paused = !self.paused;
299        !self.paused
300    }
301}