AkurAI Build
Menu

BifrOSt-Apps

public

Latest change f67fd8437552caf5eb9371b0012e81d27c807ca3 - Add native RÚV television application by Ólafur Búi Ólafsson

// SPDX-License-Identifier: MIT

use gst::prelude::*;
use iced_video_player::{Error as VideoError, Video, gst, gst_app};
use std::time::Duration;
use url::Url;

const INVALID_MEDIA_URL: &str = "The media address is invalid.";
const UNSUPPORTED_MEDIA_SOURCE: &str = "This media source is not supported.";
const PLAYBACK_START_FAILED: &str =
    "The stream could not be opened. Check the connection and media support.";

// Live resolver responses use short-lived hosts such as ed4.cdn.ruv.is, while
// fallback live and VOD streams use two fixed Akamai tenants. Accept only the
// RÚV-owned CDN suffix and those exact external tenants.
const MEDIA_HOSTS: &[&str] = &["ruvlive.akamaized.net", "ruv-vod.akamaized.net"];

#[derive(Debug)]
pub struct PlayerState {
    pub video: Option<Video>,
    pub source: Option<String>,
    pub paused: bool,
    pub muted: bool,
    pub volume: f64,
    pub error: Option<String>,
}

impl Default for PlayerState {
    fn default() -> Self {
        Self {
            video: None,
            source: None,
            paused: false,
            muted: false,
            volume: 1.0,
            error: None,
        }
    }
}

impl PlayerState {
    pub fn load(&mut self, source: &str) -> Result<(), String> {
        // Stop and drop the old pipeline before attempting to replace it. A
        // failed load must never leave an earlier stream playing invisibly.
        self.clear();

        let uri = match validate_media_url(source) {
            Ok(uri) => uri,
            Err(message) => return self.fail(message),
        };

        let mut video = match open_video(&uri) {
            Ok(video) => video,
            Err(error) => {
                eprintln!("RÚV playback initialization failed: {error}");
                return self.fail(PLAYBACK_START_FAILED);
            }
        };

        video.set_volume(self.volume);
        video.set_muted(self.muted);
        video.set_paused(false);

        self.video = Some(video);
        self.source = Some(source.to_owned());
        self.paused = false;
        self.error = None;
        Ok(())
    }

    pub fn clear(&mut self) {
        // Video's Drop implementation first moves its GStreamer pipeline to
        // Null and then joins its frame worker, so taking it here is the
        // synchronous playback teardown point.
        drop(self.video.take());
        self.source = None;
        self.paused = false;
        self.error = None;
    }

    pub fn toggle_pause(&mut self) {
        let Some(video) = self.video.as_mut() else {
            self.paused = false;
            return;
        };

        if video.eos() {
            if video.restart_stream().is_ok() {
                self.paused = false;
            }
            return;
        }

        self.paused = !self.paused;
        video.set_paused(self.paused);
    }

    pub fn toggle_mute(&mut self) {
        self.muted = !self.muted;
        if let Some(video) = self.video.as_mut() {
            video.set_muted(self.muted);
        }
    }

    pub fn set_volume(&mut self, volume: f64) {
        self.volume = if volume.is_nan() {
            0.0
        } else {
            volume.clamp(0.0, 1.0)
        };

        if let Some(video) = self.video.as_mut() {
            video.set_volume(self.volume);
        }
    }

    pub fn position_seconds(&self) -> f64 {
        self.video
            .as_ref()
            .map_or(0.0, |video| video.position().as_secs_f64())
    }

    pub fn duration_seconds(&self) -> f64 {
        self.video
            .as_ref()
            .map_or(0.0, |video| video.duration().as_secs_f64())
    }

    pub fn seek_to(&mut self, seconds: f64) {
        let Some(video) = self.video.as_mut() else {
            return;
        };
        let duration = video.duration().as_secs_f64();
        if !seconds.is_finite() || duration <= 0.0 {
            return;
        }
        let position = seconds.clamp(0.0, duration);
        let _ = video.seek(Duration::from_secs_f64(position), false);
    }

    pub fn skip_by(&mut self, seconds: f64) {
        self.seek_to(self.position_seconds() + seconds);
    }

    pub fn mark_ended(&mut self) {
        self.paused = true;
    }

    pub fn fail_playback(&mut self) {
        self.clear();
        self.error = Some(PLAYBACK_START_FAILED.to_owned());
    }

    pub fn video(&self) -> Option<&Video> {
        self.video.as_ref()
    }

    fn fail(&mut self, message: &str) -> Result<(), String> {
        let message = message.to_owned();
        self.error = Some(message.clone());
        Err(message)
    }
}

impl Drop for PlayerState {
    fn drop(&mut self) {
        // Make teardown explicit rather than relying on field drop order.
        drop(self.video.take());
        self.source = None;
    }
}

fn open_video(uri: &Url) -> Result<Video, VideoError> {
    gst::init()?;
    let pipeline = format!(
        "playbin3 uri=\"{}\" text-sink=\"appsink name=iced_text sync=true caps=text/x-raw\" video-sink=\"videoscale ! videoconvert ! appsink name=iced_video drop=true caps=video/x-raw,format=NV12,pixel-aspect-ratio=1/1\"",
        uri.as_str()
    );
    let pipeline = gst::parse::launch(&pipeline)?
        .downcast::<gst::Pipeline>()
        .map_err(|_| VideoError::Cast)?;
    let video_sink = appsink_from_property(&pipeline, "video-sink", "iced_video")?;
    let text_sink = pipeline
        .property::<gst::Element>("text-sink")
        .downcast::<gst_app::AppSink>()
        .map_err(|_| VideoError::AppSink("iced_text".to_owned()))?;

    Video::from_gst_pipeline(pipeline, video_sink, Some(text_sink))
}

#[derive(Clone, Debug)]
pub struct PreviewFrame {
    pub width: u32,
    pub height: u32,
    pub pixels: Vec<u8>,
}

pub fn capture_live_preview(source: &str) -> Result<PreviewFrame, String> {
    let uri = validate_media_url(source).map_err(str::to_owned)?;
    gst::init().map_err(|error| error.to_string())?;
    let pipeline = format!(
        "playbin3 uri=\"{}\" audio-sink=\"fakesink sync=false\" text-sink=\"fakesink sync=false\" video-sink=\"videoscale ! videoconvert ! appsink name=preview caps=video/x-raw,format=RGBA,width=640,height=360,pixel-aspect-ratio=1/1\"",
        uri.as_str()
    );
    let pipeline = gst::parse::launch(&pipeline)
        .map_err(|error| error.to_string())?
        .downcast::<gst::Pipeline>()
        .map_err(|_| "The preview pipeline has an unexpected type.".to_owned())?;
    let sink = appsink_from_property(&pipeline, "video-sink", "preview")
        .map_err(|error| error.to_string())?;

    pipeline
        .set_state(gst::State::Playing)
        .map_err(|error| error.to_string())?;
    pipeline
        .state(gst::ClockTime::from_seconds(12))
        .0
        .map_err(|error| error.to_string())?;
    let result = (|| {
        let sample = sink.pull_sample().map_err(|_| pipeline_error(&pipeline))?;
        let caps = sample
            .caps()
            .ok_or_else(|| "The preview frame has no media capabilities.".to_owned())?;
        let structure = caps
            .structure(0)
            .ok_or_else(|| "The preview frame has invalid media capabilities.".to_owned())?;
        let width = structure
            .get::<i32>("width")
            .map_err(|_| "The preview width is unavailable.".to_owned())?;
        let height = structure
            .get::<i32>("height")
            .map_err(|_| "The preview height is unavailable.".to_owned())?;
        let width = u32::try_from(width).map_err(|_| "The preview width is invalid.".to_owned())?;
        let height =
            u32::try_from(height).map_err(|_| "The preview height is invalid.".to_owned())?;
        let buffer = sample
            .buffer()
            .ok_or_else(|| "The preview frame has no pixel buffer.".to_owned())?;
        let map = buffer
            .map_readable()
            .map_err(|_| "The preview frame could not be read.".to_owned())?;
        let expected = width as usize * height as usize * 4;
        if map.as_slice().len() != expected {
            return Err("The preview frame has an unexpected pixel layout.".to_owned());
        }
        Ok(PreviewFrame {
            width,
            height,
            pixels: map.as_slice().to_vec(),
        })
    })();
    let _ = pipeline.set_state(gst::State::Null);
    result
}

fn pipeline_error(pipeline: &gst::Pipeline) -> String {
    let Some(bus) = pipeline.bus() else {
        return "The live preview ended before a frame was available.".to_owned();
    };
    for message in bus.iter_timed(gst::ClockTime::ZERO) {
        if let gst::MessageView::Error(error) = message.view() {
            return format!("The live preview pipeline failed: {}", error.error());
        }
    }
    "The live preview ended before a frame was available.".to_owned()
}

fn appsink_from_property(
    pipeline: &gst::Pipeline,
    property: &str,
    name: &str,
) -> Result<gst_app::AppSink, VideoError> {
    pipeline
        .property::<gst::Element>(property)
        .pads()
        .first()
        .and_then(|pad| pad.clone().dynamic_cast::<gst::GhostPad>().ok())
        .and_then(|pad| pad.parent_element())
        .and_then(|element| element.downcast::<gst::Bin>().ok())
        .and_then(|bin| bin.by_name(name))
        .and_then(|element| element.downcast::<gst_app::AppSink>().ok())
        .ok_or_else(|| VideoError::AppSink(name.to_owned()))
}

fn validate_media_url(source: &str) -> Result<Url, &'static str> {
    // url::Url accepts some surrounding ASCII whitespace; rejecting it here
    // keeps the accepted address identical to the value retained in `source`.
    if source.is_empty() || source.trim() != source {
        return Err(INVALID_MEDIA_URL);
    }

    let uri = Url::parse(source).map_err(|_| INVALID_MEDIA_URL)?;

    if uri.scheme() != "https"
        || !uri.username().is_empty()
        || uri.password().is_some()
        || uri.port().is_some_and(|port| port != 443)
        || uri.fragment().is_some()
    {
        return Err(UNSUPPORTED_MEDIA_SOURCE);
    }

    let host = uri.host_str().ok_or(INVALID_MEDIA_URL)?;
    if !host.ends_with(".cdn.ruv.is") && !MEDIA_HOSTS.contains(&host) {
        return Err(UNSUPPORTED_MEDIA_SOURCE);
    }

    if !uri.path().to_ascii_lowercase().ends_with(".m3u8") {
        return Err(UNSUPPORTED_MEDIA_SOURCE);
    }

    Ok(uri)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn accepts_only_https_ruv_hls_sources() {
        assert!(
            validate_media_url("https://ruvlive.akamaized.net/out/v1/channel/index.m3u8").is_ok()
        );
        assert!(validate_media_url("https://switcher.cdn.ruv.is/resolve/ruv/index.m3u8").is_ok());
        assert!(
            validate_media_url("https://ed4.cdn.ruv.is/token/live/d0/ruv/channel/index.m3u8")
                .is_ok()
        );

        for source in [
            "http://ruvlive.akamaized.net/out/v1/channel/index.m3u8",
            "https://example.com/out/v1/channel/index.m3u8",
            "https://user@ruvlive.akamaized.net/out/v1/channel/index.m3u8",
            "https://ruvlive.akamaized.net/out/v1/channel/index.mp4",
            " https://ruvlive.akamaized.net/out/v1/channel/index.m3u8",
        ] {
            assert!(validate_media_url(source).is_err(), "{source}");
        }
    }

    #[test]
    fn volume_and_mute_state_are_bounded_without_a_loaded_stream() {
        let mut player = PlayerState::default();
        player.set_volume(2.0);
        assert_eq!(player.volume, 1.0);
        player.set_volume(-1.0);
        assert_eq!(player.volume, 0.0);
        player.set_volume(f64::NAN);
        assert_eq!(player.volume, 0.0);

        assert!(!player.muted);
        player.toggle_mute();
        assert!(player.muted);
        player.toggle_mute();
        assert!(!player.muted);
    }

    #[test]
    fn seeking_without_media_is_a_safe_no_op() {
        let mut player = PlayerState::default();
        player.seek_to(120.0);
        player.skip_by(-10.0);
        assert_eq!(player.position_seconds(), 0.0);
        assert_eq!(player.duration_seconds(), 0.0);
        assert!(player.error.is_none());
    }
}