Menu
BifrOSt-Apps
publicLatest 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());
}
}