Menu
BifrOSt-Apps
publicLatest change f35619cfe0419c596645328bf0e1b682ad3a4473 - Harden RÚV for the 0.1.1 candidate by Ólafur Búi Ólafsson
// SPDX-License-Identifier: MIT
use crate::api::RuvClient;
use crate::fl;
use crate::model::{
AppError, Article, Channel, HomeData, Program, ProgramCard, ScheduleEvent, SearchResults,
VodShelf,
};
use crate::player::{
PlayerState, PreviewFrame, capture_live_preview, discard_video, prepare_video,
};
use chrono::{Days, NaiveDate, Utc};
use cosmic::app::Task;
use cosmic::iced::keyboard::{Key, key::Named};
use cosmic::iced::{Alignment, ContentFit, Event, Length, Subscription, event, keyboard, window};
use cosmic::prelude::*;
use cosmic::widget::{self, nav_bar};
use cosmic::{Core, Element, executor};
use iced_video_player::{Video, VideoPlayer};
use std::collections::{HashMap, VecDeque};
use std::sync::{Arc, Mutex};
use std::time::Duration;
pub const IMAGE_CONCURRENCY: usize = 4;
const IMAGE_CACHE_MAX_BYTES: usize = 256 * 1024 * 1024;
const IMAGE_CACHE_MAX_ENTRIES: usize = 512;
const MAX_IMAGE_DIMENSION: u32 = 8192;
const MAX_IMAGE_PIXELS: u64 = 40_000_000;
const HOME_SHELF_LIMIT: usize = 5;
const PROGRAMS_PER_PAGE: usize = 4;
const SHELVES_PER_PAGE: usize = 5;
const COMPACT_IMAGE_WIDTH: f32 = 240.0;
const MIN_IMAGE_HEIGHT: f32 = 120.0;
const MAX_CARD_IMAGE_HEIGHT: f32 = 320.0;
const MAX_CONTENT_IMAGE_HEIGHT: f32 = 480.0;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum Page {
Home,
Live,
Guide,
Vod,
Search,
}
#[derive(Clone, Debug)]
enum LoadState<T> {
Idle,
Loading,
Ready(T),
Error,
}
#[derive(Clone, Debug)]
enum Detail {
Article(Article),
Program(Program),
Player(String),
Loading,
Error,
}
#[derive(Clone, Debug)]
pub enum Request {
Program {
program_id: i64,
episode_id: Option<String>,
play: bool,
},
Live(Channel),
}
/// Families of asynchronous requests whose completions can go stale (APP-2).
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum RequestFamily {
Home,
Vod,
Guide,
Search,
/// Program/live detail loads, including loads that lead into playback.
Detail,
/// Off-thread video pipeline preparation.
Player,
}
/// Monotonically increasing request generations (APP-2).
///
/// `spawn` allocates the next generation and marks it current for a family;
/// a completion message carrying anything but the family's current
/// generation is dropped in `update`. `invalidate` retires the current
/// generation without spawning, so navigating away guarantees in-flight
/// results can never land.
#[derive(Debug, Default)]
struct Generations {
counter: u64,
home: u64,
vod: u64,
guide: u64,
search: u64,
detail: u64,
player: u64,
}
impl Generations {
fn current(&self, family: RequestFamily) -> u64 {
match family {
RequestFamily::Home => self.home,
RequestFamily::Vod => self.vod,
RequestFamily::Guide => self.guide,
RequestFamily::Search => self.search,
RequestFamily::Detail => self.detail,
RequestFamily::Player => self.player,
}
}
fn spawn(&mut self, family: RequestFamily) -> u64 {
self.counter += 1;
let generation = self.counter;
let slot = match family {
RequestFamily::Home => &mut self.home,
RequestFamily::Vod => &mut self.vod,
RequestFamily::Guide => &mut self.guide,
RequestFamily::Search => &mut self.search,
RequestFamily::Detail => &mut self.detail,
RequestFamily::Player => &mut self.player,
};
*slot = generation;
generation
}
fn invalidate(&mut self, family: RequestFamily) {
self.spawn(family);
}
fn is_current(&self, family: RequestFamily, generation: u64) -> bool {
self.current(family) == generation
}
}
#[derive(Clone, Debug)]
enum ImageState {
Loading,
Ready(widget::image::Handle),
Failed,
}
/// URL-keyed image cache bounded by decoded byte size and entry count.
///
/// Eviction is least-recently-used first. `touch` runs every time a page
/// requests its visible URL set, so on-screen images carry the newest
/// timestamps and eviction reclaims off-screen entries first. In-flight
/// `Loading` entries are never evicted because the download queue and the
/// pending fetch still reference them.
#[derive(Debug, Default)]
struct ImageCache {
entries: HashMap<String, ImageEntry>,
total_bytes: usize,
clock: u64,
}
#[derive(Debug)]
struct ImageEntry {
state: ImageState,
bytes: usize,
last_used: u64,
}
impl ImageCache {
fn get(&self, url: &str) -> Option<&ImageState> {
self.entries.get(url).map(|entry| &entry.state)
}
/// Marks a cached URL as most recently used. Returns whether it exists.
fn touch(&mut self, url: &str) -> bool {
self.clock += 1;
let clock = self.clock;
match self.entries.get_mut(url) {
Some(entry) => {
entry.last_used = clock;
true
}
None => false,
}
}
fn insert(&mut self, url: String, state: ImageState, bytes: usize) {
self.clock += 1;
let entry = ImageEntry {
state,
bytes,
last_used: self.clock,
};
if let Some(previous) = self.entries.insert(url, entry) {
self.total_bytes = self.total_bytes.saturating_sub(previous.bytes);
}
self.total_bytes = self.total_bytes.saturating_add(bytes);
self.evict();
}
fn evict(&mut self) {
while self.total_bytes > IMAGE_CACHE_MAX_BYTES
|| self.entries.len() > IMAGE_CACHE_MAX_ENTRIES
{
let victim = self
.entries
.iter()
.filter(|(_, entry)| !matches!(entry.state, ImageState::Loading))
.min_by_key(|(_, entry)| entry.last_used)
.map(|(url, _)| url.clone());
let Some(url) = victim else {
break;
};
if let Some(entry) = self.entries.remove(&url) {
self.total_bytes = self.total_bytes.saturating_sub(entry.bytes);
}
}
}
}
/// Pure admission accounting for image downloads: at most
/// `IMAGE_CONCURRENCY` fetches may be in flight at any time, and completed
/// fetches free a slot for the next queued URL.
#[derive(Debug, Default)]
pub struct ImagePump {
queue: VecDeque<String>,
inflight: usize,
}
impl ImagePump {
pub fn enqueue(&mut self, url: String) {
self.queue.push_back(url);
}
/// Pops queued URLs up to the free concurrency slots and counts them as
/// in flight.
pub fn admit(&mut self) -> Vec<String> {
let available = IMAGE_CONCURRENCY.saturating_sub(self.inflight);
let mut admitted = Vec::with_capacity(available.min(self.queue.len()));
for _ in 0..available {
let Some(url) = self.queue.pop_front() else {
break;
};
admitted.push(url);
}
self.inflight += admitted.len();
admitted
}
/// Records one finished fetch, freeing an admission slot.
pub fn complete(&mut self) {
self.inflight = self.inflight.saturating_sub(1);
}
pub fn inflight(&self) -> usize {
self.inflight
}
}
#[derive(Clone, Debug)]
enum LivePreviewState {
Loading,
Ready(widget::image::Handle),
Failed,
}
/// Async completion variants carry the `u64` request generation captured
/// when their task was spawned; `update` drops results whose generation is
/// no longer current for the request family (APP-2).
#[derive(Clone, Debug)]
pub enum Message {
HomeLoaded(u64, Result<HomeData, AppError>),
VodLoaded(u64, Result<Vec<VodShelf>, AppError>),
GuideLoaded(u64, Result<Vec<ScheduleEvent>, AppError>),
SearchChanged(String),
SubmitSearch,
SearchLoaded(u64, Result<SearchResults, AppError>),
ImageLoaded(String, Result<Vec<u8>, AppError>),
RefreshLivePreviews,
LivePreviewsLoaded(Vec<(Channel, Result<PreviewFrame, String>)>),
OpenArticle(Article),
OpenProgram(i64, Option<String>),
PlayEpisode(i64, String),
ProgramLoaded(u64, Result<Program, AppError>, Request),
PlayLive(Channel),
LiveLoaded(u64, Channel, Result<String, AppError>),
OpenScheduleEvent(ScheduleEvent),
SelectGuideChannel(Channel),
PreviousDay,
NextDay,
Today,
ShowMoreShelves,
ShowMorePrograms(String),
RetryHome,
RetryVod,
RetryGuide,
RetrySearch,
RetryDetail,
Back,
TogglePause,
ToggleMute,
SeekChanged(f64),
SkipBackward,
SkipForward,
PlaybackTick,
PlaybackEnded,
PlaybackFailed,
/// A pipeline prepared off the UI executor is ready (APP-5.1). The
/// `Arc<Mutex<..>>` wrapper exists only because `Message` must be
/// `Clone`; the video is taken out exactly once. The `String` is the
/// player title.
PlayerReady(u64, String, Arc<Mutex<Option<Video>>>),
/// Off-thread pipeline preparation failed (APP-5.1); the `String` is
/// the player title.
PlayerFailed(u64, String),
ToggleFullscreen,
VolumeChanged(f64),
KeyPressed(Key),
}
pub struct App {
core: Core,
nav_model: nav_bar::Model,
client: Option<RuvClient>,
home: LoadState<HomeData>,
vod: LoadState<Vec<VodShelf>>,
guide: LoadState<Vec<ScheduleEvent>>,
search: LoadState<SearchResults>,
search_query: String,
last_search: String,
guide_date: NaiveDate,
guide_channel: Channel,
visible_vod_shelves: usize,
visible_programs: HashMap<String, usize>,
detail: Option<Detail>,
return_program: Option<Program>,
active_request: Option<Request>,
images: ImageCache,
image_pump: ImagePump,
live_previews: HashMap<Channel, LivePreviewState>,
live_previews_loading: bool,
player: PlayerState,
fullscreen: bool,
generations: Generations,
}
impl cosmic::Application for App {
type Executor = executor::Default;
type Flags = ();
type Message = Message;
const APP_ID: &'static str = "org.bifrost.Ruv";
fn core(&self) -> &Core {
&self.core
}
fn core_mut(&mut self) -> &mut Core {
&mut self.core
}
fn init(core: Core, _flags: Self::Flags) -> (Self, Task<Self::Message>) {
let mut nav_model = nav_bar::Model::default();
nav_model.insert().text(fl!("nav-home")).data(Page::Home);
nav_model.insert().text(fl!("nav-live")).data(Page::Live);
nav_model.insert().text(fl!("nav-guide")).data(Page::Guide);
nav_model.insert().text(fl!("nav-vod")).data(Page::Vod);
nav_model
.insert()
.text(fl!("nav-search"))
.data(Page::Search);
nav_model.activate_position(0);
let client = RuvClient::new().ok();
let mut app = Self {
core,
nav_model,
client,
home: LoadState::Idle,
vod: LoadState::Idle,
guide: LoadState::Idle,
search: LoadState::Idle,
search_query: String::new(),
last_search: String::new(),
guide_date: Utc::now().date_naive(),
guide_channel: Channel::Ruv,
visible_vod_shelves: SHELVES_PER_PAGE,
visible_programs: HashMap::new(),
detail: None,
return_program: None,
active_request: None,
images: ImageCache::default(),
image_pump: ImagePump::default(),
live_previews: HashMap::new(),
live_previews_loading: false,
player: PlayerState::default(),
fullscreen: false,
generations: Generations::default(),
};
let title = app.update_title();
let home = app.load_home();
let previews = app.load_live_previews();
(app, Task::batch(vec![title, home, previews]))
}
fn nav_model(&self) -> Option<&nav_bar::Model> {
Some(&self.nav_model)
}
fn on_nav_select(&mut self, id: nav_bar::Id) -> Task<Self::Message> {
self.nav_model.activate(id);
let exit = self.close_detail();
let load = match self.active_page() {
Page::Home if matches!(self.home, LoadState::Idle) => self.load_home(),
Page::Vod if matches!(self.vod, LoadState::Idle) => self.load_vod(),
Page::Guide if matches!(self.guide, LoadState::Idle) => self.load_guide(),
_ => Task::none(),
};
Task::batch(vec![self.update_title(), load, exit])
}
fn update(&mut self, message: Self::Message) -> Task<Self::Message> {
if self.is_stale(&message) {
// A stale prepared pipeline still needs teardown, and it must
// happen off the UI thread (APP-5.1).
if let Message::PlayerReady(_, _, video) = message
&& let Some(video) = video.lock().ok().and_then(|mut slot| slot.take())
{
discard_video(video);
}
return Task::none();
}
match message {
Message::HomeLoaded(_, result) => match result {
Ok(data) => {
let urls = visible_shelf_image_urls(
&data.shelves,
HOME_SHELF_LIMIT,
&self.visible_programs,
);
let urls = data
.articles
.iter()
.take(5)
.filter_map(|article| article.image_url.clone())
.chain(urls)
.collect();
self.home = LoadState::Ready(data);
return self.load_images(urls);
}
Err(_) => self.home = LoadState::Error,
},
Message::VodLoaded(_, result) => match result {
Ok(shelves) => {
let urls = visible_shelf_image_urls(
&shelves,
self.visible_vod_shelves,
&self.visible_programs,
);
self.vod = LoadState::Ready(shelves);
return self.load_images(urls);
}
Err(_) => self.vod = LoadState::Error,
},
Message::GuideLoaded(_, result) => match result {
Ok(events) => {
let urls = events
.iter()
.filter_map(|event| event.image_url.clone())
.collect();
self.guide = LoadState::Ready(events);
return self.load_images(urls);
}
Err(_) => self.guide = LoadState::Error,
},
Message::SearchChanged(value) => self.search_query = value,
Message::SubmitSearch => return self.load_search(),
Message::SearchLoaded(_, result) => match result {
Ok(results) => {
let mut urls: Vec<String> = results
.articles
.iter()
.filter_map(|article| article.image_url.clone())
.collect();
urls.extend(
results
.programs
.iter()
.filter_map(|program| program.image_url.clone()),
);
self.search = LoadState::Ready(results);
return self.load_images(urls);
}
Err(_) => self.search = LoadState::Error,
},
Message::ImageLoaded(url, result) => {
match result
.ok()
.and_then(|bytes| validated_image_size(&bytes).map(|size| (bytes, size)))
{
Some((bytes, size)) => self.images.insert(
url,
ImageState::Ready(widget::image::Handle::from_bytes(bytes)),
size,
),
None => self.images.insert(url, ImageState::Failed, 0),
}
self.image_pump.complete();
return self.pump_images();
}
Message::RefreshLivePreviews => return self.load_live_previews(),
Message::LivePreviewsLoaded(results) => {
self.live_previews_loading = false;
for (channel, result) in results {
match result {
Ok(frame) => {
self.live_previews.insert(
channel,
LivePreviewState::Ready(widget::image::Handle::from_rgba(
frame.width,
frame.height,
frame.pixels,
)),
);
}
Err(_) => {
// Keep an already captured frame on screen, but
// anything else (including the initial Loading
// placeholder) must surface the failure.
match self.live_previews.get(&channel) {
Some(LivePreviewState::Ready(_)) => {}
_ => {
self.live_previews.insert(channel, LivePreviewState::Failed);
}
}
}
}
}
}
Message::OpenArticle(article) => {
self.detail = Some(Detail::Article(article));
return self.update_title();
}
Message::OpenProgram(program_id, episode_id) => {
return self.request_program(Request::Program {
program_id,
episode_id,
play: false,
});
}
Message::PlayEpisode(program_id, episode_id) => {
self.return_program = match self.detail.as_ref() {
Some(Detail::Program(program)) => Some(program.clone()),
_ => None,
};
return self.request_program(Request::Program {
program_id,
episode_id: Some(episode_id),
play: true,
});
}
Message::ProgramLoaded(_, result, request) => {
// Navigation and playback transitions additionally require
// that the originating loading view is still on screen
// (APP-2).
if !matches!(self.detail, Some(Detail::Loading)) {
return Task::none();
}
match result {
Ok(program) => {
let urls = program_image_urls(&program);
let image_task = self.load_images(urls);
let player_task = if matches!(&request, Request::Program { play: true, .. })
{
self.start_episode(program, request)
} else {
self.active_request = None;
self.detail = Some(Detail::Program(program));
Task::none()
};
return Task::batch(vec![self.update_title(), image_task, player_task]);
}
Err(_) => {
self.detail = Some(Detail::Error);
self.active_request = Some(request);
return self.update_title();
}
}
}
Message::PlayLive(channel) => {
self.return_program = None;
return self.request_live(channel);
}
Message::LiveLoaded(_, channel, result) => {
// See ProgramLoaded: the originating view must still be
// active (APP-2).
if !matches!(self.detail, Some(Detail::Loading)) {
return Task::none();
}
match result {
Ok(source) => {
self.active_request = Some(Request::Live(channel));
// The view stays on Detail::Loading until the
// pipeline prepared off the UI executor posts
// PlayerReady or PlayerFailed (APP-5.1).
return self.load_player(source, channel.title().to_owned());
}
Err(_) => {
self.detail = Some(Detail::Error);
self.active_request = Some(Request::Live(channel));
return self.update_title();
}
}
}
Message::OpenScheduleEvent(event) => {
if event.is_live {
return self.request_live(self.guide_channel);
}
if let Some(program_id) = event.program_id {
let play = event.episode_id.is_some();
return self.request_program(Request::Program {
program_id,
episode_id: event.episode_id,
play,
});
}
}
Message::SelectGuideChannel(channel) => {
self.guide_channel = channel;
return self.load_guide();
}
Message::PreviousDay => {
if let Some(date) = self.guide_date.checked_sub_days(Days::new(1)) {
self.guide_date = date;
return self.load_guide();
}
}
Message::NextDay => {
if let Some(date) = self.guide_date.checked_add_days(Days::new(1)) {
self.guide_date = date;
return self.load_guide();
}
}
Message::Today => {
self.guide_date = Utc::now().date_naive();
return self.load_guide();
}
Message::ShowMoreShelves => {
self.visible_vod_shelves += SHELVES_PER_PAGE;
let urls = match &self.vod {
LoadState::Ready(shelves) => visible_shelf_image_urls(
shelves,
self.visible_vod_shelves,
&self.visible_programs,
),
_ => Vec::new(),
};
return self.load_images(urls);
}
Message::ShowMorePrograms(slug) => {
let visible = self
.visible_programs
.entry(slug.clone())
.or_insert(PROGRAMS_PER_PAGE);
*visible += PROGRAMS_PER_PAGE;
let limit = *visible;
let mut urls = Vec::new();
if let LoadState::Ready(data) = &self.home {
urls.extend(shelf_program_image_urls(&data.shelves, &slug, limit));
}
if let LoadState::Ready(shelves) = &self.vod {
urls.extend(shelf_program_image_urls(shelves, &slug, limit));
}
return self.load_images(urls);
}
Message::RetryHome => return self.load_home(),
Message::RetryVod => return self.load_vod(),
Message::RetryGuide => return self.load_guide(),
Message::RetrySearch => return self.load_search(),
Message::RetryDetail => {
if let Some(request) = self.active_request.clone() {
return match request {
Request::Live(channel) => self.request_live(channel),
request @ Request::Program { .. } => self.request_program(request),
};
}
}
Message::Back => {
let exit = self.back();
return Task::batch(vec![self.update_title(), exit]);
}
Message::TogglePause => self.player.toggle_pause(),
Message::ToggleMute => self.player.toggle_mute(),
Message::SeekChanged(position) => self.player.seek_to(position),
Message::SkipBackward => self.player.skip_by(-10.0),
Message::SkipForward => self.player.skip_by(10.0),
Message::PlaybackTick => {}
Message::PlaybackEnded => self.player.mark_ended(),
Message::PlaybackFailed => self.player.fail_playback(),
Message::PlayerReady(_, title, video) => {
// Playback may only start while its loading view is still
// on screen (APP-2).
if !matches!(self.detail, Some(Detail::Loading)) {
if let Some(video) = video.lock().ok().and_then(|mut slot| slot.take()) {
discard_video(video);
}
return Task::none();
}
match video.lock().ok().and_then(|mut slot| slot.take()) {
Some(video) => self.player.attach(video),
// The payload was already drained; treat it as a
// failed start rather than showing a frozen player.
None => self.player.fail_playback(),
}
self.detail = Some(Detail::Player(title));
return self.update_title();
}
Message::PlayerFailed(_, title) => {
if !matches!(self.detail, Some(Detail::Loading)) {
return Task::none();
}
// Show the player surface in its error state, matching the
// previous synchronous-load failure behavior.
self.player.fail_playback();
self.detail = Some(Detail::Player(title));
return self.update_title();
}
Message::ToggleFullscreen => {
return self.set_fullscreen(!self.fullscreen);
}
Message::VolumeChanged(volume) => self.player.set_volume(volume),
Message::KeyPressed(key) => match key.as_ref() {
Key::Named(Named::Escape) if self.fullscreen => {
return self.set_fullscreen(false);
}
Key::Named(Named::Escape) => {
if self.detail.is_some() {
let exit = self.back();
return Task::batch(vec![self.update_title(), exit]);
}
}
Key::Named(Named::ArrowLeft) if matches!(self.detail, Some(Detail::Player(_))) => {
self.player.skip_by(-10.0);
}
Key::Named(Named::ArrowRight) if matches!(self.detail, Some(Detail::Player(_))) => {
self.player.skip_by(10.0);
}
Key::Character(" ") if matches!(self.detail, Some(Detail::Player(_))) => {
self.player.toggle_pause();
}
Key::Character("m" | "M") if matches!(self.detail, Some(Detail::Player(_))) => {
self.player.toggle_mute();
}
Key::Character("f" | "F") if matches!(self.detail, Some(Detail::Player(_))) => {
return self.set_fullscreen(!self.fullscreen);
}
_ => {}
},
}
Task::none()
}
fn subscription(&self) -> Subscription<Self::Message> {
let keyboard = event::listen_with(|event, status, _window| {
if status != event::Status::Ignored {
return None;
}
match event {
Event::Keyboard(keyboard::Event::KeyPressed { key, .. }) => {
Some(Message::KeyPressed(key))
}
_ => None,
}
});
if matches!(self.detail, Some(Detail::Player(_))) {
Subscription::batch(vec![
keyboard,
cosmic::iced::time::every(Duration::from_millis(500))
.map(|_| Message::PlaybackTick),
])
} else {
Subscription::batch(vec![
keyboard,
cosmic::iced::time::every(Duration::from_secs(60))
.map(|_| Message::RefreshLivePreviews),
])
}
}
fn view(&self) -> Element<'_, Self::Message> {
widget::responsive(|size| self.view_at_width(size.width)).into()
}
}
impl App {
fn active_page(&self) -> Page {
self.nav_model
.active_data::<Page>()
.copied()
.unwrap_or(Page::Home)
}
fn page_title(&self) -> String {
match self.active_page() {
Page::Home => fl!("nav-home"),
Page::Live => fl!("nav-live"),
Page::Guide => fl!("nav-guide"),
Page::Vod => fl!("nav-vod"),
Page::Search => fl!("nav-search"),
}
}
fn update_title(&mut self) -> Task<Message> {
let title = match self.detail.as_ref() {
Some(Detail::Article(article)) => article.title.clone(),
Some(Detail::Program(program)) => program.title.clone(),
Some(Detail::Player(title)) => title.clone(),
Some(Detail::Loading) => fl!("loading-title"),
Some(Detail::Error) => fl!("error-title"),
None => self.page_title(),
};
self.set_header_title(title.clone());
if let Some(window) = self.core.main_window_id() {
self.set_window_title(fl!("window-title", title = title), window)
} else {
Task::none()
}
}
fn set_fullscreen(&mut self, fullscreen: bool) -> Task<Message> {
self.fullscreen = fullscreen;
let Some(id) = self.core.main_window_id() else {
return Task::none();
};
let mode = if fullscreen {
window::Mode::Fullscreen
} else {
window::Mode::Windowed
};
window::set_mode(id, mode)
}
fn ensure_client(&mut self) -> bool {
if self.client.is_none() {
self.client = RuvClient::new().ok();
}
self.client.is_some()
}
fn load_home(&mut self) -> Task<Message> {
self.home = LoadState::Loading;
if !self.ensure_client() {
self.home = LoadState::Error;
return Task::none();
}
let client = self.client.clone().expect("client was checked");
let generation = self.generations.spawn(RequestFamily::Home);
Task::perform(async move { client.home().await }, move |result| {
cosmic::action::app(Message::HomeLoaded(generation, result))
})
}
fn load_live_previews(&mut self) -> Task<Message> {
if self.live_previews_loading || !self.ensure_client() {
return Task::none();
}
self.live_previews_loading = true;
for channel in [Channel::Ruv, Channel::Ruv2] {
self.live_previews
.entry(channel)
.or_insert(LivePreviewState::Loading);
}
let client = self.client.clone().expect("client was checked");
Task::perform(
async move {
let mut results = Vec::with_capacity(2);
for channel in [Channel::Ruv, Channel::Ruv2] {
let result = match client.live(channel).await {
Ok(source) => {
tokio::task::spawn_blocking(move || capture_live_preview(&source))
.await
.map_err(|error| error.to_string())
.and_then(|result| result)
}
Err(error) => Err(error.to_string()),
};
results.push((channel, result));
}
results
},
|results| cosmic::action::app(Message::LivePreviewsLoaded(results)),
)
}
fn load_vod(&mut self) -> Task<Message> {
self.vod = LoadState::Loading;
if !self.ensure_client() {
self.vod = LoadState::Error;
return Task::none();
}
let client = self.client.clone().expect("client was checked");
let generation = self.generations.spawn(RequestFamily::Vod);
Task::perform(async move { client.vod().await }, move |result| {
cosmic::action::app(Message::VodLoaded(generation, result))
})
}
fn load_guide(&mut self) -> Task<Message> {
self.guide = LoadState::Loading;
if !self.ensure_client() {
self.guide = LoadState::Error;
return Task::none();
}
let client = self.client.clone().expect("client was checked");
let channel = self.guide_channel;
let date = self.guide_date;
let generation = self.generations.spawn(RequestFamily::Guide);
Task::perform(
async move { client.schedule(channel, date).await },
move |result| cosmic::action::app(Message::GuideLoaded(generation, result)),
)
}
fn load_search(&mut self) -> Task<Message> {
let query = self.search_query.trim().to_owned();
if query.is_empty() {
self.last_search.clear();
self.search = LoadState::Idle;
return Task::none();
}
self.last_search = query.clone();
self.search = LoadState::Loading;
if !self.ensure_client() {
self.search = LoadState::Error;
return Task::none();
}
let client = self.client.clone().expect("client was checked");
let generation = self.generations.spawn(RequestFamily::Search);
Task::perform(async move { client.search(&query).await }, move |result| {
cosmic::action::app(Message::SearchLoaded(generation, result))
})
}
fn request_program(&mut self, request: Request) -> Task<Message> {
if !self.ensure_client() {
self.detail = Some(Detail::Error);
self.active_request = Some(request);
return Task::none();
}
let Request::Program {
program_id,
episode_id,
..
} = &request
else {
return Task::none();
};
let program_id = *program_id;
let episode_id = episode_id.clone();
let client = self.client.clone().expect("client was checked");
self.detail = Some(Detail::Loading);
self.active_request = Some(request.clone());
let generation = self.generations.spawn(RequestFamily::Detail);
Task::perform(
async move {
match episode_id {
Some(episode_id) => client.episode(program_id, &episode_id).await,
None => client.program(program_id).await,
}
},
move |result| cosmic::action::app(Message::ProgramLoaded(generation, result, request)),
)
}
fn request_live(&mut self, channel: Channel) -> Task<Message> {
if !self.ensure_client() {
self.detail = Some(Detail::Error);
self.active_request = Some(Request::Live(channel));
return Task::none();
}
let client = self.client.clone().expect("client was checked");
self.detail = Some(Detail::Loading);
self.active_request = Some(Request::Live(channel));
let generation = self.generations.spawn(RequestFamily::Detail);
Task::perform(async move { client.live(channel).await }, move |result| {
cosmic::action::app(Message::LiveLoaded(generation, channel, result))
})
}
/// Resolves the requested episode and hands its stream to the
/// off-thread pipeline loader (APP-5.1). The detail view stays on
/// `Detail::Loading` until PlayerReady/PlayerFailed arrives.
fn start_episode(&mut self, program: Program, request: Request) -> Task<Message> {
let Request::Program {
episode_id: Some(episode_id),
..
} = &request
else {
self.detail = Some(Detail::Error);
self.active_request = Some(request);
return Task::none();
};
let episode = program
.episodes
.iter()
.find(|episode| episode.id == *episode_id)
.or_else(|| program.episodes.first());
let Some(episode) = episode else {
self.detail = Some(Detail::Error);
self.active_request = Some(request);
return Task::none();
};
let Some(source) = episode.stream_url.clone() else {
self.detail = Some(Detail::Error);
self.active_request = Some(request);
return Task::none();
};
let title = if episode.title.is_empty() {
program.title
} else {
episode.title.clone()
};
self.active_request = Some(request);
self.load_player(source, title)
}
/// True when an async completion carries a generation that is no longer
/// current for its request family, meaning a newer request or a
/// navigation superseded it (APP-2).
fn is_stale(&self, message: &Message) -> bool {
let (family, generation) = match message {
Message::HomeLoaded(generation, _) => (RequestFamily::Home, *generation),
Message::VodLoaded(generation, _) => (RequestFamily::Vod, *generation),
Message::GuideLoaded(generation, _) => (RequestFamily::Guide, *generation),
Message::SearchLoaded(generation, _) => (RequestFamily::Search, *generation),
Message::ProgramLoaded(generation, _, _) | Message::LiveLoaded(generation, _, _) => {
(RequestFamily::Detail, *generation)
}
Message::PlayerReady(generation, _, _) | Message::PlayerFailed(generation, _) => {
(RequestFamily::Player, *generation)
}
_ => return false,
};
!self.generations.is_current(family, generation)
}
/// Builds the playback pipeline on the blocking executor and posts
/// PlayerReady/PlayerFailed back to `update` (APP-5.1), tagged with a
/// Player-family generation (APP-2).
fn load_player(&mut self, source: String, title: String) -> Task<Message> {
// Stop whatever is playing; teardown runs on a background thread.
self.player.clear();
let generation = self.generations.spawn(RequestFamily::Player);
Task::perform(
async move {
let result = tokio::task::spawn_blocking(move || prepare_video(&source))
.await
.map_err(|error| error.to_string())
.and_then(|result| result);
(title, result)
},
move |(title, result)| {
cosmic::action::app(match result {
Ok(video) => {
Message::PlayerReady(generation, title, Arc::new(Mutex::new(Some(video))))
}
Err(error) => {
eprintln!("RÚV playback start failed: {error}");
Message::PlayerFailed(generation, title)
}
})
},
)
}
/// The single exit path away from the player (APP-5.2): retires any
/// in-flight pipeline preparation, stops and clears playback, and
/// always leaves fullscreen so the mode can never leak into other
/// views.
fn exit_player(&mut self) -> Task<Message> {
self.generations.invalidate(RequestFamily::Player);
self.player.clear();
if self.fullscreen {
self.set_fullscreen(false)
} else {
Task::none()
}
}
fn load_images(&mut self, urls: Vec<String>) -> Task<Message> {
for url in urls {
if self.images.touch(&url) {
continue;
}
self.images.insert(url.clone(), ImageState::Loading, 0);
self.image_pump.enqueue(url);
}
self.pump_images()
}
fn pump_images(&mut self) -> Task<Message> {
let Some(client) = self.client.clone() else {
return Task::none();
};
let admitted = self.image_pump.admit();
let mut tasks = Vec::with_capacity(admitted.len());
for url in admitted {
let client = client.clone();
let message_url = url.clone();
tasks.push(Task::perform(
async move { client.image(&url).await },
move |result| cosmic::action::app(Message::ImageLoaded(message_url, result)),
));
}
Task::batch(tasks)
}
fn close_detail(&mut self) -> Task<Message> {
// Leaving the detail surface retires in-flight detail loads so
// their completions are dropped (APP-2).
self.generations.invalidate(RequestFamily::Detail);
self.detail = None;
self.return_program = None;
self.active_request = None;
self.exit_player()
}
fn back(&mut self) -> Task<Message> {
// See close_detail: pending detail completions must never land
// after navigating back (APP-2).
self.generations.invalidate(RequestFamily::Detail);
let exit = self.exit_player();
if matches!(self.detail, Some(Detail::Player(_)))
&& let Some(program) = self.return_program.take()
{
self.detail = Some(Detail::Program(program));
self.active_request = None;
return exit;
}
self.detail = None;
self.active_request = None;
exit
}
fn view_at_width(&self, width: f32) -> Element<'_, Message> {
if let Some(detail) = &self.detail {
return self.view_detail(detail, width);
}
match self.active_page() {
Page::Home => self.view_home(width),
Page::Live => self.view_live(width),
Page::Guide => self.view_guide(width),
Page::Vod => self.view_vod(width),
Page::Search => self.view_search(width),
}
}
fn view_home(&self, width: f32) -> Element<'_, Message> {
match &self.home {
LoadState::Idle | LoadState::Loading => self.state_loading("home-loading"),
LoadState::Error => self.state_error("home-error", Message::RetryHome),
LoadState::Ready(data) if data.articles.is_empty() && data.shelves.is_empty() => {
self.state_empty("home-empty")
}
LoadState::Ready(data) => {
let spacing = cosmic::theme::spacing();
let mut page = widget::column::with_capacity(data.shelves.len() + 4)
.spacing(spacing.space_l)
.push(widget::text::title1(fl!("home-title")))
.push(self.live_strip(width));
if !data.articles.is_empty() {
let mut news = widget::column::with_capacity(data.articles.len() + 1)
.spacing(spacing.space_s)
.push(widget::text::title3(fl!("latest-news")));
for article in data.articles.iter().take(5) {
news = news.push(self.article_row(article, width));
}
page = page.push(news);
}
for shelf in data.shelves.iter().take(HOME_SHELF_LIMIT) {
page = page.push(self.shelf(shelf, width));
}
self.scroll_page(page.into())
}
}
}
fn view_live(&self, width: f32) -> Element<'_, Message> {
let spacing = cosmic::theme::spacing();
let channel_cards = self.live_channel_cards(width, MAX_CONTENT_IMAGE_HEIGHT);
let page = widget::column::with_capacity(4)
.spacing(spacing.space_l)
.push(widget::text::title1(fl!("live-title")))
.push(widget::text::body(fl!("live-description")))
.push(channel_cards);
self.scroll_page(page.into())
}
fn view_guide(&self, width: f32) -> Element<'_, Message> {
let spacing = cosmic::theme::spacing();
let channel_buttons = widget::flex_row(vec![
guide_channel_button(self.guide_channel, Channel::Ruv),
guide_channel_button(self.guide_channel, Channel::Ruv2),
])
.spacing(spacing.space_xs);
let date_controls = widget::flex_row(vec![
widget::button::standard(fl!("previous-day"))
.on_press(Message::PreviousDay)
.into(),
widget::button::standard(fl!("today"))
.on_press(Message::Today)
.into(),
widget::button::standard(fl!("next-day"))
.on_press(Message::NextDay)
.into(),
])
.spacing(spacing.space_xs);
let mut page = widget::column::with_capacity(6)
.spacing(spacing.space_m)
.push(widget::text::title1(fl!("guide-title")))
.push(channel_buttons)
.push(widget::text::title3(fl!(
"schedule-date",
date = self.guide_date.format("%d.%m.%Y").to_string()
)))
.push(date_controls);
match &self.guide {
LoadState::Idle | LoadState::Loading => {
page = page.push(self.inline_loading("guide-loading"))
}
LoadState::Error => {
page = page.push(self.inline_error("guide-error", Message::RetryGuide))
}
LoadState::Ready(events) if events.is_empty() => {
page = page
.push(widget::text::heading(fl!("guide-empty")))
.push(widget::button::suggested(fl!("today")).on_press(Message::Today));
}
LoadState::Ready(events) => {
for event in events {
page = page.push(self.schedule_row(event, width));
}
}
}
self.scroll_page(page.into())
}
fn view_vod(&self, width: f32) -> Element<'_, Message> {
match &self.vod {
LoadState::Idle | LoadState::Loading => self.state_loading("vod-loading"),
LoadState::Error => self.state_error("vod-error", Message::RetryVod),
LoadState::Ready(shelves) if shelves.is_empty() => self.state_empty("vod-empty"),
LoadState::Ready(shelves) => {
let spacing = cosmic::theme::spacing();
let mut page = widget::column::with_capacity(shelves.len() + 3)
.spacing(spacing.space_l)
.push(widget::text::title1(fl!("vod-title")))
.push(widget::text::body(fl!("vod-description")));
for shelf in shelves.iter().take(self.visible_vod_shelves) {
page = page.push(self.shelf(shelf, width));
}
if shelves.len() > self.visible_vod_shelves {
page = page.push(
widget::button::standard(fl!("show-more-shelves"))
.on_press(Message::ShowMoreShelves),
);
}
self.scroll_page(page.into())
}
}
}
fn view_search(&self, width: f32) -> Element<'_, Message> {
let spacing = cosmic::theme::spacing();
let search_box =
widget::text_input::search_input(fl!("search-placeholder"), &self.search_query)
.on_input(Message::SearchChanged)
.on_submit(|_| Message::SubmitSearch)
.on_clear(Message::SearchChanged(String::new()));
let mut page = widget::column::with_capacity(8)
.spacing(spacing.space_m)
.push(widget::text::title1(fl!("search-title")))
.push(widget::text::body(fl!("search-description")))
.push(search_box)
.push(
widget::button::suggested(fl!("search-action")).on_press_maybe(
(!self.search_query.trim().is_empty()).then_some(Message::SubmitSearch),
),
);
match &self.search {
LoadState::Idle => page = page.push(widget::text::body(fl!("search-pristine"))),
LoadState::Loading => page = page.push(self.inline_loading("search-loading")),
LoadState::Error => {
page = page.push(self.inline_error("search-error", Message::RetrySearch))
}
LoadState::Ready(results)
if results.articles.is_empty() && results.programs.is_empty() =>
{
page = page
.push(widget::text::title3(fl!(
"search-empty",
query = self.last_search.as_str()
)))
.push(widget::text::body(fl!("search-empty-help")));
}
LoadState::Ready(results) => {
page = page.push(widget::text::title3(fl!(
"news-results",
count = results.articles.len()
)));
if results.articles.is_empty() {
page = page.push(widget::text::body(fl!("group-empty")));
} else {
for article in &results.articles {
page = page.push(self.article_row(article, width));
}
}
page = page.push(widget::text::title3(fl!(
"vod-results",
count = results.programs.len()
)));
if results.programs.is_empty() {
page = page.push(widget::text::body(fl!("group-empty")));
} else {
page = page.push(self.program_grid(&results.programs, width));
}
}
}
self.scroll_page(page.into())
}
fn view_detail<'a>(&'a self, detail: &'a Detail, width: f32) -> Element<'a, Message> {
match detail {
Detail::Article(article) => self.view_article(article, width),
Detail::Program(program) => self.view_program(program, width),
Detail::Player(title) => self.view_player(title, width),
Detail::Loading => self.detail_state(self.inline_loading("detail-loading")),
Detail::Error => {
self.detail_state(self.inline_error("detail-error", Message::RetryDetail))
}
}
}
fn view_article<'a>(&'a self, article: &'a Article, width: f32) -> Element<'a, Message> {
let spacing = cosmic::theme::spacing();
let mut page = widget::column::with_capacity(10)
.spacing(spacing.space_m)
.push(widget::button::text(fl!("back")).on_press(Message::Back))
.push(widget::text::title1(&article.title));
if let Some(author) = &article.author {
page = page.push(widget::text::heading(fl!(
"by-author",
author = author.as_str()
)));
}
if let Some(published) = article.published {
page = page.push(widget::text::caption(fl!(
"published-value",
date = published.format("%d.%m.%Y · %H:%M").to_string()
)));
}
if article.image_url.is_some() {
page = page.push(self.content_image(article.image_url.as_deref(), width));
}
page = page
.push(widget::text::body(&article.body))
.push(widget::text::heading(fl!("source-attribution")))
.push(widget::text::body(&article.url));
self.scroll_page(page.into())
}
fn view_program<'a>(&'a self, program: &'a Program, width: f32) -> Element<'a, Message> {
let spacing = cosmic::theme::spacing();
let mut page = widget::column::with_capacity(program.episodes.len() + 9)
.spacing(spacing.space_m)
.push(widget::button::text(fl!("back")).on_press(Message::Back));
if program.image_url.is_some() {
page = page.push(self.content_image(program.image_url.as_deref(), width));
}
page = page.push(widget::text::title1(&program.title));
if let Some(foreign_title) = &program.foreign_title {
page = page.push(widget::text::title4(foreign_title));
}
if !program.description.is_empty() {
page = page.push(widget::text::body(&program.description));
}
page = page.push(widget::text::title3(fl!("episodes-title")));
if program.episodes.is_empty() {
page = page.push(widget::text::body(fl!("episodes-empty")));
} else {
for episode in &program.episodes {
let mut metadata = widget::row::with_capacity(3).spacing(spacing.space_xs);
// Durations are validated at DTO conversion; the checked
// conversion still guards the i64 handoff to Fluent
// (APP-5.4).
if let Some(minutes) = episode
.duration_seconds
.and_then(|duration| i64::try_from(duration / 60).ok())
{
metadata = metadata.push(widget::text::caption(fl!(
"duration-minutes",
minutes = minutes
)));
}
if let Some(first_run) = episode.first_run {
metadata = metadata.push(widget::text::caption(fl!(
"published-value",
date = first_run.format("%d.%m.%Y").to_string()
)));
}
if let Some(rating) = episode.rating {
metadata =
metadata.push(widget::text::caption(fl!("rating-label", rating = rating)));
}
// Missing titles stay empty in the model; the fallback is
// localized at presentation time (APP-5.3).
let episode_title = if episode.title.is_empty() {
fl!("unknown-episode")
} else {
episode.title.clone()
};
let mut content = widget::column::with_capacity(4)
.spacing(spacing.space_xs)
.push(widget::text::title4(episode_title))
.push(metadata);
if !episode.description.is_empty() {
content = content.push(widget::text::body(&episode.description));
}
if episode.stream_url.is_some() {
content = content
.push(widget::button::suggested(fl!("play")).on_press(
Message::PlayEpisode(program.program_id, episode.id.clone()),
));
} else {
content = content.push(widget::text::caption(fl!("episode-unavailable")));
}
let content: Element<'_, Message> = if episode.image_url.is_some() && width >= 640.0
{
widget::row::with_children(vec![
self.compact_image(episode.image_url.as_deref(), width),
content.width(Length::Fill).into(),
])
.align_y(Alignment::Center)
.spacing(spacing.space_s)
.into()
} else if episode.image_url.is_some() {
widget::column::with_children(vec![
self.card_image(episode.image_url.as_deref(), width),
content.into(),
])
.spacing(spacing.space_s)
.into()
} else {
content.into()
};
page = page.push(
widget::container(content)
.padding(spacing.space_s)
.class(cosmic::theme::Container::Card)
.width(Length::Fill),
);
}
}
self.scroll_page(page.into())
}
fn view_player<'a>(&'a self, title: &'a str, width: f32) -> Element<'a, Message> {
let spacing = cosmic::theme::spacing();
let duration = self.player.duration_seconds();
let position = self.player.position_seconds().clamp(0.0, duration.max(0.0));
let seekable = duration.is_finite() && duration > 0.0;
let timeline: Element<'_, Message> = if seekable {
widget::row::with_children(vec![
widget::text::caption(format_playback_time(position)).into(),
widget::slider(0.0..=duration, position, Message::SeekChanged)
.width(Length::Fill)
.into(),
widget::text::caption(format_playback_time(duration)).into(),
])
.align_y(Alignment::Center)
.spacing(spacing.space_s)
.into()
} else {
widget::text::heading(fl!("live-stream")).into()
};
let playback_controls = self.player_transport_controls(seekable, width < 520.0);
let navigation_controls = widget::row::with_children(vec![
widget::button::standard(fl!("back"))
.on_press(Message::Back)
.into(),
widget::space::horizontal().into(),
widget::button::standard(if self.fullscreen {
fl!("exit-fullscreen")
} else {
fl!("fullscreen")
})
.on_press(Message::ToggleFullscreen)
.into(),
])
.align_y(Alignment::Center)
.spacing(spacing.space_s);
let audio_controls = widget::row::with_children(vec![
widget::text::body(fl!(
"volume-value",
percent = ((self.player.volume * 100.0).round() as i64)
))
.into(),
widget::slider(0.0..=1.0, self.player.volume, Message::VolumeChanged)
.width(Length::Fill)
.into(),
])
.align_y(Alignment::Center)
.spacing(spacing.space_s);
let mut page = widget::column::with_capacity(6)
.spacing(spacing.space_s)
.width(Length::Fill)
.height(Length::Fill)
.push(widget::text::title3(title));
if let Some(video) = self.player.video() {
page = page.push(
VideoPlayer::new(video)
.content_fit(ContentFit::Contain)
.on_end_of_stream(Message::PlaybackEnded)
.on_error(|_| Message::PlaybackFailed)
.width(Length::Fill)
.height(Length::FillPortion(1)),
);
} else {
page = page.push(
widget::container(widget::text::heading(fl!("playback-error")))
.width(Length::Fill)
.height(Length::FillPortion(1)),
);
}
page = page
.push(timeline)
.push(playback_controls)
.push(audio_controls)
.push(navigation_controls);
widget::container(page)
.padding(spacing.space_m)
.width(Length::Fill)
.height(Length::Fill)
.into()
}
fn player_transport_controls(&self, seekable: bool, compact: bool) -> Element<'_, Message> {
let spacing = cosmic::theme::spacing();
let play =
widget::button::suggested(if self.player.video().is_some_and(|video| video.eos()) {
fl!("replay")
} else if self.player.paused {
fl!("play")
} else {
fl!("pause")
})
.on_press(Message::TogglePause);
let mute = widget::button::standard(if self.player.muted {
fl!("unmute")
} else {
fl!("mute")
})
.on_press(Message::ToggleMute);
if compact {
let primary = widget::row::with_children(vec![play.into(), mute.into()])
.align_y(Alignment::Center)
.spacing(spacing.space_s);
let mut controls = widget::column::with_capacity(2)
.width(Length::Fill)
.spacing(spacing.space_s)
.push(primary);
if seekable {
controls = controls.push(
widget::row::with_children(vec![
widget::button::standard(fl!("skip-backward"))
.on_press(Message::SkipBackward)
.into(),
widget::button::standard(fl!("skip-forward"))
.on_press(Message::SkipForward)
.into(),
])
.align_y(Alignment::Center)
.spacing(spacing.space_s),
);
}
controls.into()
} else {
let mut controls = widget::row::with_capacity(4)
.align_y(Alignment::Center)
.spacing(spacing.space_s);
if seekable {
controls = controls.push(
widget::button::standard(fl!("skip-backward")).on_press(Message::SkipBackward),
);
}
controls = controls.push(play);
if seekable {
controls = controls.push(
widget::button::standard(fl!("skip-forward")).on_press(Message::SkipForward),
);
}
controls.push(mute).into()
}
}
fn live_strip(&self, width: f32) -> Element<'_, Message> {
let spacing = cosmic::theme::spacing();
let channel_cards = self.live_channel_cards(width, MAX_CARD_IMAGE_HEIGHT);
let content = widget::column::with_capacity(2)
.spacing(spacing.space_l)
.push(widget::text::title3(fl!("live-now")))
.push(channel_cards);
widget::container(content)
.padding(spacing.space_s)
.class(cosmic::theme::Container::Card)
.width(Length::Fill)
.into()
}
fn live_channel_cards(&self, width: f32, maximum_height: f32) -> Element<'_, Message> {
let spacing = cosmic::theme::spacing();
let side_by_side = width >= 720.0;
let card_width = if side_by_side { width / 2.0 } else { width };
let preview_height = aspect_image_height(card_width, maximum_height);
let cards = [Channel::Ruv, Channel::Ruv2]
.into_iter()
.map(|channel| self.live_channel_card(channel, preview_height));
if side_by_side {
widget::row::with_children(cards)
.spacing(spacing.space_l)
.width(Length::Fill)
.into()
} else {
widget::column::with_children(cards)
.spacing(spacing.space_l)
.width(Length::Fill)
.into()
}
}
fn live_channel_card(&self, channel: Channel, preview_height: f32) -> Element<'_, Message> {
let spacing = cosmic::theme::spacing();
let controls = widget::row::with_children(vec![
widget::text::heading(channel.title()).into(),
widget::text::caption(fl!("live-badge")).into(),
widget::space::horizontal().into(),
widget::button::suggested(fl!("watch"))
.on_press(Message::PlayLive(channel))
.into(),
])
.align_y(Alignment::Center)
.spacing(spacing.space_s);
widget::column::with_children(vec![
self.live_preview(channel, preview_height),
controls.into(),
widget::text::caption(fl!("live-preview-refresh")).into(),
])
.spacing(spacing.space_xs)
.width(Length::Fill)
.into()
}
fn live_preview(&self, channel: Channel, height: f32) -> Element<'_, Message> {
match self.live_previews.get(&channel) {
Some(LivePreviewState::Ready(handle)) => widget::container(
widget::image(handle.clone())
.content_fit(ContentFit::Contain)
.width(Length::Fill)
.height(Length::Fixed(height)),
)
.width(Length::Fill)
.height(Length::Fixed(height))
.class(cosmic::theme::Container::Card)
.into(),
Some(LivePreviewState::Failed) => preview_placeholder(false, height),
Some(LivePreviewState::Loading) | None => preview_placeholder(true, height),
}
}
fn shelf<'a>(&'a self, shelf: &'a VodShelf, width: f32) -> Element<'a, Message> {
let spacing = cosmic::theme::spacing();
let visible = self
.visible_programs
.get(&shelf.slug)
.copied()
.unwrap_or(PROGRAMS_PER_PAGE);
let mut content = widget::column::with_capacity(3)
.spacing(spacing.space_s)
.push(widget::text::title3(&shelf.title))
.push(if shelf.programs.is_empty() {
widget::text::body(fl!("shelf-empty")).into()
} else {
self.program_grid(&shelf.programs[..shelf.programs.len().min(visible)], width)
});
if shelf.programs.len() > visible {
content = content.push(
widget::button::standard(fl!("show-more-programs"))
.on_press(Message::ShowMorePrograms(shelf.slug.clone())),
);
}
content.into()
}
fn program_grid<'a>(&'a self, programs: &'a [ProgramCard], width: f32) -> Element<'a, Message> {
let spacing = cosmic::theme::spacing();
let columns = responsive_columns(width);
let gap = f32::from(spacing.space_s) * columns.saturating_sub(1) as f32;
let card_width = ((width - gap) / columns as f32).max(200.0);
let mut grid = widget::column::with_capacity(programs.len().div_ceil(columns))
.spacing(spacing.space_s);
for chunk in programs.chunks(columns) {
let mut row = widget::row::with_capacity(columns).spacing(spacing.space_s);
for program in chunk {
row = row.push(self.program_card(program, card_width));
}
for _ in chunk.len()..columns {
row = row.push(widget::space::horizontal().width(Length::FillPortion(1)));
}
grid = grid.push(row);
}
grid.into()
}
fn program_card<'a>(
&'a self,
program: &'a ProgramCard,
card_width: f32,
) -> Element<'a, Message> {
let spacing = cosmic::theme::spacing();
let mut content = widget::column::with_capacity(5)
.spacing(spacing.space_xs)
.push(self.card_image(program.image_url.as_deref(), card_width))
.push(widget::text::title4(&program.title));
if let Some(episode_title) = &program.episode_title {
content = content.push(widget::text::caption(episode_title));
} else if let Some(foreign_title) = &program.foreign_title {
content = content.push(widget::text::caption(foreign_title));
}
if let Some(rating) = program.rating {
content = content.push(widget::text::caption(fl!("rating-label", rating = rating)));
}
widget::button::custom(content)
.on_press(Message::OpenProgram(
program.program_id,
program.episode_id.clone(),
))
.width(Length::FillPortion(1))
.class(cosmic::theme::Button::Image)
.into()
}
fn article_row<'a>(&'a self, article: &'a Article, width: f32) -> Element<'a, Message> {
let spacing = cosmic::theme::spacing();
let mut text = widget::column::with_capacity(4)
.spacing(spacing.space_xs)
.push(widget::text::title4(&article.title));
if let Some(author) = &article.author {
text = text.push(widget::text::caption(fl!(
"by-author",
author = author.as_str()
)));
}
if let Some(published) = article.published {
text = text.push(widget::text::caption(fl!(
"published-value",
date = published.format("%d.%m.%Y · %H:%M").to_string()
)));
}
let preview: String = article.body.chars().take(180).collect();
if !preview.is_empty() {
text = text.push(widget::text::body(preview));
}
let content: Element<'_, Message> = if article.image_url.is_some() && width >= 640.0 {
widget::row::with_children(vec![
self.compact_image(article.image_url.as_deref(), width),
text.width(Length::Fill).into(),
])
.align_y(Alignment::Center)
.spacing(spacing.space_s)
.into()
} else if article.image_url.is_some() {
widget::column::with_children(vec![
self.card_image(article.image_url.as_deref(), width),
text.into(),
])
.spacing(spacing.space_s)
.into()
} else {
text.into()
};
widget::button::custom(content)
.on_press(Message::OpenArticle(article.clone()))
.width(Length::Fill)
.class(cosmic::theme::Button::Image)
.into()
}
fn schedule_row<'a>(&'a self, event: &'a ScheduleEvent, width: f32) -> Element<'a, Message> {
let spacing = cosmic::theme::spacing();
let end = event
.end
.map(|end| end.format("%H:%M").to_string())
.unwrap_or_else(|| fl!("time-unknown"));
let mut details = widget::column::with_capacity(4)
.spacing(spacing.space_xs)
.push(widget::text::heading(fl!(
"schedule-time",
start = event.start.format("%H:%M").to_string(),
end = end
)))
.push(widget::text::title4(&event.title));
if let Some(subtitle) = &event.subtitle {
details = details.push(widget::text::body(subtitle));
}
if event.is_live {
details = details.push(widget::text::caption(fl!("now-live")));
}
let details: Element<'_, Message> = if event.image_url.is_some() && width >= 640.0 {
widget::row::with_children(vec![
self.compact_image(event.image_url.as_deref(), width),
details.width(Length::Fill).into(),
])
.align_y(Alignment::Center)
.spacing(spacing.space_s)
.into()
} else if event.image_url.is_some() {
widget::column::with_children(vec![
self.card_image(event.image_url.as_deref(), width),
details.into(),
])
.spacing(spacing.space_s)
.into()
} else {
details.into()
};
if event.program_id.is_some() || event.is_live {
widget::button::custom(details)
.on_press(Message::OpenScheduleEvent(event.clone()))
.width(Length::Fill)
.class(cosmic::theme::Button::Image)
.into()
} else {
widget::container(details)
.padding(spacing.space_s)
.class(cosmic::theme::Container::Card)
.width(Length::Fill)
.into()
}
}
fn card_image<'a>(&'a self, url: Option<&str>, available_width: f32) -> Element<'a, Message> {
let height = aspect_image_height(available_width, MAX_CARD_IMAGE_HEIGHT);
self.image(url, Length::Fill, height)
}
fn compact_image<'a>(
&'a self,
url: Option<&str>,
available_width: f32,
) -> Element<'a, Message> {
let width = COMPACT_IMAGE_WIDTH.min(available_width);
self.image(
url,
Length::Fixed(width),
aspect_image_height(width, MAX_CARD_IMAGE_HEIGHT),
)
}
fn content_image<'a>(
&'a self,
url: Option<&str>,
available_width: f32,
) -> Element<'a, Message> {
self.image(
url,
Length::Fill,
aspect_image_height(available_width, MAX_CONTENT_IMAGE_HEIGHT),
)
}
fn image<'a>(&'a self, url: Option<&str>, width: Length, height: f32) -> Element<'a, Message> {
if let Some(ImageState::Ready(handle)) = url.and_then(|url| self.images.get(url)) {
return widget::container(
widget::image(handle.clone())
.content_fit(ContentFit::Contain)
.width(width)
.height(Length::Fixed(height)),
)
.width(width)
.height(Length::Fixed(height))
.class(cosmic::theme::Container::Card)
.into();
}
let label = match url.and_then(|url| self.images.get(url)) {
Some(ImageState::Loading) => fl!("image-loading"),
Some(ImageState::Failed) => fl!("image-unavailable"),
_ => fl!("image-placeholder"),
};
widget::container(
widget::column::with_capacity(2)
.align_x(Alignment::Center)
.push(widget::icon::from_name("image-x-generic-symbolic"))
.push(widget::text::caption(label)),
)
.width(width)
.height(Length::Fixed(height))
.class(cosmic::theme::Container::Card)
.into()
}
fn scroll_page<'a>(&self, content: Element<'a, Message>) -> Element<'a, Message> {
let spacing = cosmic::theme::spacing();
widget::scrollable(
widget::container(content)
.padding(spacing.space_m)
.width(Length::Fill),
)
.width(Length::Fill)
.height(Length::Fill)
.into()
}
fn state_loading(&self, message: &'static str) -> Element<'_, Message> {
self.detail_state(self.inline_loading(message))
}
fn state_error(&self, message: &'static str, retry: Message) -> Element<'_, Message> {
self.detail_state(self.inline_error(message, retry))
}
fn state_empty(&self, message: &'static str) -> Element<'_, Message> {
self.detail_state(widget::text::heading(state_text(message)).into())
}
fn inline_loading(&self, message: &'static str) -> Element<'_, Message> {
let spacing = cosmic::theme::spacing();
widget::row::with_children(vec![
widget::progress_bar::circular::Circular::new()
.size(f32::from(spacing.space_l))
.into(),
widget::text::heading(state_text(message)).into(),
])
.align_y(Alignment::Center)
.spacing(spacing.space_s)
.into()
}
fn inline_error(&self, message: &'static str, retry: Message) -> Element<'_, Message> {
let spacing = cosmic::theme::spacing();
widget::column::with_capacity(3)
.spacing(spacing.space_s)
.push(widget::text::title3(fl!("connection-error")))
.push(widget::text::body(state_text(message)))
.push(widget::button::suggested(fl!("retry")).on_press(retry))
.into()
}
fn detail_state<'a>(&self, content: Element<'a, Message>) -> Element<'a, Message> {
let spacing = cosmic::theme::spacing();
widget::container(
widget::column::with_capacity(4)
.spacing(spacing.space_m)
.push(widget::button::text(fl!("back")).on_press(Message::Back))
.push(widget::space::vertical())
.push(content)
.push(widget::space::vertical()),
)
.padding(spacing.space_m)
.width(Length::Fill)
.height(Length::Fill)
.into()
}
}
fn state_text(id: &str) -> String {
match id {
"home-loading" => fl!("home-loading"),
"home-error" => fl!("home-error"),
"home-empty" => fl!("home-empty"),
"vod-loading" => fl!("vod-loading"),
"vod-error" => fl!("vod-error"),
"vod-empty" => fl!("vod-empty"),
"guide-loading" => fl!("guide-loading"),
"guide-error" => fl!("guide-error"),
"search-loading" => fl!("search-loading"),
"search-error" => fl!("search-error"),
"detail-loading" => fl!("detail-loading"),
"detail-error" => fl!("detail-error"),
_ => fl!("connection-error"),
}
}
fn guide_channel_button(selected: Channel, channel: Channel) -> Element<'static, Message> {
if selected == channel {
widget::button::suggested(channel.title())
.on_press(Message::SelectGuideChannel(channel))
.into()
} else {
widget::button::standard(channel.title())
.on_press(Message::SelectGuideChannel(channel))
.into()
}
}
fn visible_shelf_image_urls(
shelves: &[VodShelf],
shelf_limit: usize,
visible_programs: &HashMap<String, usize>,
) -> Vec<String> {
shelves
.iter()
.take(shelf_limit)
.flat_map(|shelf| {
let limit = visible_programs
.get(&shelf.slug)
.copied()
.unwrap_or(PROGRAMS_PER_PAGE);
shelf
.programs
.iter()
.take(limit)
.filter_map(|program| program.image_url.clone())
})
.collect()
}
fn shelf_program_image_urls(shelves: &[VodShelf], slug: &str, limit: usize) -> Vec<String> {
shelves
.iter()
.find(|shelf| shelf.slug == slug)
.into_iter()
.flat_map(|shelf| shelf.programs.iter().take(limit))
.filter_map(|program| program.image_url.clone())
.collect()
}
fn program_image_urls(program: &Program) -> Vec<String> {
program
.image_url
.iter()
.cloned()
.chain(
program
.episodes
.iter()
.filter_map(|episode| episode.image_url.clone()),
)
.collect()
}
/// Reads the image header and returns the decoded RGBA size in bytes, or
/// `None` when the data is not a decodable image or its width, height, or
/// pixel count exceeds the hard limits. Only the header is parsed, so
/// rejection is cheap even for hostile inputs.
fn validated_image_size(bytes: &[u8]) -> Option<usize> {
let reader = image::ImageReader::new(std::io::Cursor::new(bytes))
.with_guessed_format()
.ok()?;
let (width, height) = reader.into_dimensions().ok()?;
if width == 0
|| height == 0
|| width > MAX_IMAGE_DIMENSION
|| height > MAX_IMAGE_DIMENSION
|| u64::from(width) * u64::from(height) > MAX_IMAGE_PIXELS
{
return None;
}
Some(width as usize * height as usize * 4)
}
fn preview_placeholder(loading: bool, height: f32) -> Element<'static, Message> {
let label = if loading {
fl!("live-preview-loading")
} else {
fl!("live-preview-unavailable")
};
widget::container(widget::text::body(label))
.align_x(Alignment::Center)
.align_y(Alignment::Center)
.width(Length::Fill)
.height(Length::Fixed(height))
.class(cosmic::theme::Container::Card)
.into()
}
fn responsive_columns(width: f32) -> usize {
if width < 520.0 {
1
} else if width < 840.0 {
2
} else if width < 1_160.0 {
3
} else {
4
}
}
fn aspect_image_height(width: f32, maximum: f32) -> f32 {
if width.is_finite() {
(width.max(0.0) * 9.0 / 16.0).clamp(MIN_IMAGE_HEIGHT, maximum)
} else {
maximum
}
}
fn format_playback_time(seconds: f64) -> String {
let total = if seconds.is_finite() {
seconds.max(0.0).round() as u64
} else {
0
};
let hours = total / 3_600;
let minutes = (total % 3_600) / 60;
let seconds = total % 60;
if hours > 0 {
format!("{hours}:{minutes:02}:{seconds:02}")
} else {
format!("{minutes}:{seconds:02}")
}
}
#[cfg(test)]
mod tests {
use super::{aspect_image_height, format_playback_time, responsive_columns};
#[test]
fn playback_time_formats_short_and_long_media() {
assert_eq!(format_playback_time(0.0), "0:00");
assert_eq!(format_playback_time(65.0), "1:05");
assert_eq!(format_playback_time(3_661.0), "1:01:01");
assert_eq!(format_playback_time(f64::NAN), "0:00");
}
#[test]
fn responsive_grid_uses_all_supported_column_counts() {
assert_eq!(responsive_columns(400.0), 1);
assert_eq!(responsive_columns(700.0), 2);
assert_eq!(responsive_columns(1_000.0), 3);
assert_eq!(responsive_columns(1_280.0), 4);
}
#[test]
fn image_height_preserves_widescreen_ratio_with_safe_bounds() {
assert_eq!(aspect_image_height(640.0, 480.0), 360.0);
assert_eq!(aspect_image_height(100.0, 480.0), 120.0);
assert_eq!(aspect_image_height(2_000.0, 480.0), 480.0);
}
}