blinc_layout 0.5.1

Blinc layout engine - Flexbox layout powered by Taffy
Documentation
//! Shared media playback controls
//!
//! Generic over any `Player` implementation (audio or video).

use std::rc::Rc;

use blinc_core::Color;
use blinc_media::Player;

use crate::div::{div, Div};
use crate::svg::svg;
use crate::text::text;

use super::format_time::format_time_ms;

const PLAY_SVG: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>"#;
const PAUSE_SVG: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>"#;

const ACCENT: Color = Color {
    r: 0.3,
    g: 0.6,
    b: 1.0,
    a: 1.0,
};
const TRACK_BG: Color = Color {
    r: 0.2,
    g: 0.2,
    b: 0.25,
    a: 1.0,
};
const BTN_BG: Color = Color {
    r: 0.2,
    g: 0.2,
    b: 0.25,
    a: 1.0,
};
const TIME_COLOR: Color = Color {
    r: 0.6,
    g: 0.6,
    b: 0.7,
    a: 1.0,
};
const VOL_COLOR: Color = Color {
    r: 0.5,
    g: 0.5,
    b: 0.55,
    a: 1.0,
};

/// Shared media controls driven by State signals.
pub struct MediaControls {
    inner: Div,
}

impl MediaControls {
    /// Build controls from individual signals + a player for actions.
    pub fn from_signals<P: Player + 'static>(
        player: Rc<P>,
        is_playing: bool,
        position: u64,
        duration: u64,
        volume: f32,
        is_live: bool,
    ) -> Self {
        let play_icon = if is_playing { PAUSE_SVG } else { PLAY_SVG };
        let player_for_click = Rc::clone(&player);

        let mut row = div()
            .flex_row()
            .items_center()
            .gap_px(8.0)
            .p_px(6.0)
            .class("blinc-media-controls")
            .child(
                div()
                    .w(28.0)
                    .h(28.0)
                    .rounded(14.0)
                    .bg(BTN_BG)
                    .items_center()
                    .justify_center()
                    .cursor_pointer()
                    .class("blinc-media-play-btn")
                    .child(svg(play_icon).square(14.0).color(Color::WHITE))
                    .on_click(move |_| {
                        tracing::info!("play/pause clicked, is_playing={}", is_playing);
                        if is_playing {
                            player_for_click.pause();
                        } else {
                            player_for_click.play();
                        }
                    }),
            );

        if is_live {
            row = row.child(
                div()
                    .bg(Color::rgba(0.8, 0.2, 0.2, 1.0))
                    .rounded(3.0)
                    .p_px(4.0)
                    .class("blinc-media-live-badge")
                    .child(text("LIVE").size(9.0).color(Color::WHITE).bold()),
            );
        } else {
            row = row.child(
                text(format!(
                    "{} / {}",
                    format_time_ms(position),
                    format_time_ms(duration)
                ))
                .size(11.0)
                .color(TIME_COLOR)
                .monospace()
                .class("blinc-media-time"),
            );
        }

        if !is_live {
            let progress = if duration > 0 {
                (position as f32 / duration as f32).clamp(0.0, 1.0)
            } else {
                0.0
            };

            let player_for_seek = Rc::clone(&player);
            let seek_duration = duration;

            let fill = div()
                .h(4.0)
                .bg(ACCENT)
                .rounded(2.0)
                .flex_grow_value(progress.max(0.001))
                .class("blinc-media-seek-fill");

            let remainder = div().h(4.0).flex_grow_value((1.0 - progress).max(0.001));

            row = row.child(
                div()
                    .flex_grow()
                    .h(12.0)
                    .items_center()
                    .cursor_pointer()
                    .class("blinc-media-seek-track")
                    .on_click(move |evt| {
                        if seek_duration > 0 && evt.bounds_width > 0.0 {
                            let ratio = (evt.local_x / evt.bounds_width).clamp(0.0, 1.0);
                            let target_ms = (ratio * seek_duration as f32) as u64;
                            player_for_seek.seek(target_ms);
                        }
                    })
                    .child(
                        div()
                            .w_full()
                            .h(4.0)
                            .bg(TRACK_BG)
                            .rounded(2.0)
                            .overflow_clip()
                            .flex_row()
                            .child(fill)
                            .child(remainder),
                    ),
            );
        }

        row = row.child(
            text(format!("{}%", (volume * 100.0) as u32))
                .size(10.0)
                .color(VOL_COLOR)
                .monospace()
                .class("blinc-media-volume"),
        );

        Self { inner: row }
    }

    /// Legacy constructor — reads values from player once.
    pub fn new<P: Player + 'static>(player: Rc<P>) -> Self {
        let is_playing = player.is_playing();
        let position = player.position_ms();
        let duration = player.duration_ms();
        let volume = player.volume();
        let is_live = player.is_live();
        Self::from_signals(player, is_playing, position, duration, volume, is_live)
    }

    pub fn class(mut self, class: &str) -> Self {
        self.inner = self.inner.class(class);
        self
    }

    pub fn into_div(self) -> Div {
        self.inner
    }
}