🔒 Repository is read-only – file editing is disabled.

luna/crates/libluna-widgets/src/media_controls.rs main

292 lines Raw ← Back
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292
//! `MediaControls` — kontrolki odtwarzania (odpowiednik GTK4 `GtkMediaControls`).
//!
//! Widget immediate: [`MediaControls::rects`] liczy rozmieszczenie (przycisk play/pauza,
//! tytuł/artysta, pasek postępu z uchwytem, czasy), [`MediaControls::seek_at`] tłumaczy
//! klik na pozycję 0..1, a [`MediaControls::paint`] rysuje całość.

use libluna_core::style::weight;
use libluna_core::{Insets, Point, Rect, Theme};
use libluna_render::TextEngine;

use crate::ctx::PaintCtx;

/// Szerokość paska postępu.
const SEEK_W: f64 = 180.0;

/// Kontrolki odtwarzania.
#[derive(Debug, Clone, Default)]
pub struct MediaControls {
    playing: bool,
    position: f64,
    title: String,
    artist: String,
    elapsed: String,
    total: String,
}

/// Rozmieszczenie elementów.
#[derive(Debug, Clone, PartialEq)]
pub struct MediaRects {
    pub play: Rect,
    pub title: Rect,
    pub artist: Rect,
    pub seek: Rect,
    pub knob: Rect,
    pub elapsed: Rect,
    pub total: Rect,
}

impl MediaControls {
    pub fn new(title: impl Into<String>, artist: impl Into<String>) -> Self {
        Self {
            playing: true,
            position: 0.0,
            title: title.into(),
            artist: artist.into(),
            elapsed: "0:00".to_owned(),
            total: "0:00".to_owned(),
        }
    }

    pub fn playing(&self) -> bool {
        self.playing
    }

    pub fn set_playing(&mut self, playing: bool) {
        self.playing = playing;
    }

    pub fn toggle(&mut self) {
        self.playing = !self.playing;
    }

    /// Pozycja 0..1.
    pub fn position(&self) -> f64 {
        self.position
    }

    pub fn set_position(&mut self, position: f64) {
        self.position = position.clamp(0.0, 1.0);
    }

    pub fn set_times(&mut self, elapsed: impl Into<String>, total: impl Into<String>) {
        self.elapsed = elapsed.into();
        self.total = total.into();
    }

    pub fn height(&self, theme: &Theme) -> f64 {
        theme.metrics.banner.min_height
    }

    fn glyph(&self) -> &'static str {
        if self.playing {
            "▮▮"
        } else {
            "▶"
        }
    }

    pub fn rects(&self, area: Rect, theme: &Theme, text: &TextEngine) -> MediaRects {
        let m = theme.metrics.banner;
        let body = theme.typography.body;
        let caption = theme.typography.caption.size;
        let inner = area.deflate(Insets::splat(m.pad));
        let cy = inner.center().y;

        let play = Rect::from_xywh(inner.x(), cy - m.button_h / 2.0, m.button_h, m.button_h);

        // Prawa strona: total | seek | elapsed.
        let total_w = text
            .measure(&self.total, caption, weight::NORMAL)
            .width
            .max(28.0);
        let total = Rect::from_xywh(
            inner.right() - total_w,
            cy - caption,
            total_w,
            caption * 2.0,
        );
        let seek = Rect::from_xywh(total.x() - m.gap - SEEK_W, cy - 3.0, SEEK_W, 6.0);
        let elapsed_w = text
            .measure(&self.elapsed, caption, weight::NORMAL)
            .width
            .max(28.0);
        let elapsed = Rect::from_xywh(
            seek.x() - m.gap - elapsed_w,
            cy - caption,
            elapsed_w,
            caption * 2.0,
        );

        let tx = play.right() + m.gap;
        let tw = (elapsed.x() - m.gap - tx).max(0.0);
        let title = Rect::from_xywh(tx, cy - body.line_height, tw, body.line_height);
        let artist = Rect::from_xywh(tx, cy, tw, body.line_height);

        let knob = Rect::from_xywh(
            seek.x() + seek.width() * self.position - 5.0,
            cy - 5.0,
            10.0,
            10.0,
        );

        MediaRects {
            play,
            title,
            artist,
            seek,
            knob,
            elapsed,
            total,
        }
    }

    /// Czy kliknięto przycisk play/pauza.
    pub fn play_at(&self, area: Rect, theme: &Theme, text: &TextEngine, p: Point) -> bool {
        self.rects(area, theme, text).play.contains(p)
    }

    /// Pozycja 0..1 odpowiadająca kliknięciu na pasku postępu (jeśli trafiono).
    pub fn seek_at(&self, area: Rect, theme: &Theme, text: &TextEngine, p: Point) -> Option<f64> {
        let r = self.rects(area, theme, text);
        // Powiększamy obszar pionowo dla wygody klikania.
        let hit = Rect::from_xywh(r.seek.x(), r.seek.center().y - 12.0, r.seek.width(), 24.0);
        if !hit.contains(p) {
            return None;
        }
        Some(((p.x - r.seek.x()) / r.seek.width()).clamp(0.0, 1.0))
    }

    pub fn paint(&self, ctx: &mut PaintCtx, area: Rect) {
        let theme = *ctx.theme;
        let r = self.rects(area, &theme, ctx.text);

        // Tło pigułki.
        ctx.scene
            .fill_rounded_rect(area, theme.radius.medium, theme.colors.card_bg);
        ctx.scene
            .stroke_rounded_rect(area, theme.radius.medium, 1.0, theme.colors.border);

        // Przycisk play/pauza.
        ctx.scene
            .fill_rounded_rect(r.play, r.play.width() / 2.0, theme.colors.accent);
        ctx.label_center(
            r.play.center().x,
            r.play.center().y,
            self.glyph(),
            theme.typography.caption.size,
            weight::BOLD,
            theme.colors.accent_fg,
        );

        // Tytuł / artysta.
        ctx.label_left(
            r.title.x(),
            r.title.center().y,
            &self.title,
            theme.typography.body.size,
            weight::MEDIUM,
            theme.colors.text,
        );
        ctx.label_left(
            r.artist.x(),
            r.artist.center().y,
            &self.artist,
            theme.typography.caption.size,
            weight::NORMAL,
            theme.colors.dim_text,
        );

        // Czasy.
        ctx.label_left(
            r.elapsed.x(),
            r.elapsed.center().y,
            &self.elapsed,
            theme.typography.caption.size,
            weight::NORMAL,
            theme.colors.dim_text,
        );
        ctx.label_right(
            r.total.right(),
            r.total.center().y,
            &self.total,
            theme.typography.caption.size,
            weight::NORMAL,
            theme.colors.dim_text,
        );

        // Pasek postępu + uchwyt.
        ctx.scene.fill_rounded_rect(r.seek, 3.0, theme.colors.shade);
        let fill = Rect::from_xywh(
            r.seek.x(),
            r.seek.y(),
            r.seek.width() * self.position,
            r.seek.height(),
        );
        ctx.scene.fill_rounded_rect(fill, 3.0, theme.colors.accent);
        ctx.scene
            .fill_rounded_rect(r.knob, 5.0, theme.colors.accent);
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use libluna_render::{Scene, TextEngine};

    fn area() -> Rect {
        Rect::from_xywh(0.0, 0.0, 520.0, 56.0)
    }

    #[test]
    fn layout_puts_play_left_and_times_right() {
        let theme = Theme::light();
        let text = TextEngine::none();
        let mc = MediaControls::new("Utwór", "Wykonawca");
        let r = mc.rects(area(), &theme, &text);
        assert!(r.play.x() < r.title.x());
        assert!(r.title.x() < r.seek.x());
        assert!(r.elapsed.right() <= r.seek.x());
        assert!(r.total.right() <= area().right());
    }

    #[test]
    fn seek_hit_maps_to_position() {
        let theme = Theme::light();
        let text = TextEngine::none();
        let mc = MediaControls::new("T", "A");
        let r = mc.rects(area(), &theme, &text);
        let mid = Point::new(r.seek.center().x, r.seek.center().y);
        let p = mc.seek_at(area(), &theme, &text, mid).unwrap();
        assert!((p - 0.5).abs() < 0.02);
        assert!(mc
            .seek_at(
                area(),
                &theme,
                &text,
                Point::new(r.play.center().x, r.play.center().y)
            )
            .is_none());
    }

    #[test]
    fn play_hit_and_toggle() {
        let theme = Theme::light();
        let text = TextEngine::none();
        let mut mc = MediaControls::new("T", "A");
        let r = mc.rects(area(), &theme, &text);
        assert!(mc.play_at(area(), &theme, &text, r.play.center()));
        mc.toggle();
        assert!(!mc.playing());
    }

    #[test]
    fn paints_without_window() {
        let theme = Theme::dark();
        let text = TextEngine::none();
        let mut scene = Scene::new();
        let mut ctx = PaintCtx::new(&mut scene, &theme, &text);
        MediaControls::new("Utwór", "Wykonawca").paint(&mut ctx, area());
        assert!(scene.len() > 8);
    }
}