Menu
BifrOSt-Apps
publicLatest change f67fd8437552caf5eb9371b0012e81d27c807ca3 - Add native RÚV television application 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};
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::VideoPlayer;
use std::collections::{HashMap, VecDeque};
use std::time::Duration;
const IMAGE_CONCURRENCY: usize = 4;
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),
}
#[derive(Clone, Debug)]
enum ImageState {
Loading,
Ready(widget::image::Handle),
Failed,
}
#[derive(Clone, Debug)]
enum LivePreviewState {
Loading,
Ready(widget::image::Handle),
Failed,
}
#[derive(Clone, Debug)]
pub enum Message {
HomeLoaded(Result<HomeData, AppError>),
VodLoaded(Result<Vec<VodShelf>, AppError>),
GuideLoaded(Result<Vec<ScheduleEvent>, AppError>),
SearchChanged(String),
SubmitSearch,
SearchLoaded(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(Result<Program, AppError>, Request),
PlayLive(Channel),
LiveLoaded(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,
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: HashMap<String, ImageState>,
image_queue: VecDeque<String>,
image_inflight: usize,
live_previews: HashMap<Channel, LivePreviewState>,
live_previews_loading: bool,
player: PlayerState,
fullscreen: bool,
}
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: HashMap::new(),
image_queue: VecDeque::new(),
image_inflight: 0,
live_previews: HashMap::new(),
live_previews_loading: false,
player: PlayerState::default(),
fullscreen: false,
};
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);
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])
}
fn update(&mut self, message: Self::Message) -> Task<Self::Message> {
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) => {
let state = match result {
Ok(bytes) => ImageState::Ready(widget::image::Handle::from_bytes(bytes)),
Err(_) => ImageState::Failed,
};
self.images.insert(url, state);
self.image_inflight = self.image_inflight.saturating_sub(1);
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(_) => {
self.live_previews
.entry(channel)
.or_insert(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) => match result {
Ok(program) => {
let urls = program_image_urls(&program);
let image_task = self.load_images(urls);
if matches!(&request, Request::Program { play: true, .. }) {
self.start_episode(program, request);
} else {
self.active_request = None;
self.detail = Some(Detail::Program(program));
}
return Task::batch(vec![self.update_title(), image_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) => match result {
Ok(source) => {
self.active_request = Some(Request::Live(channel));
let _ = self.player.load(&source);
self.detail = Some(Detail::Player(channel.title().to_owned()));
return self.update_title();
}
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 => {
self.back();
let fullscreen = self.set_fullscreen(false);
return Task::batch(vec![self.update_title(), fullscreen]);
}
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::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() {
self.back();
return self.update_title();
}
}
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");
Task::perform(async move { client.home().await }, |result| {
cosmic::action::app(Message::HomeLoaded(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");
Task::perform(async move { client.vod().await }, |result| {
cosmic::action::app(Message::VodLoaded(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;
Task::perform(
async move { client.schedule(channel, date).await },
|result| cosmic::action::app(Message::GuideLoaded(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");
Task::perform(async move { client.search(&query).await }, |result| {
cosmic::action::app(Message::SearchLoaded(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());
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(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));
Task::perform(async move { client.live(channel).await }, move |result| {
cosmic::action::app(Message::LiveLoaded(channel, result))
})
}
fn start_episode(&mut self, program: Program, request: Request) {
let Request::Program {
episode_id: Some(episode_id),
..
} = &request
else {
self.detail = Some(Detail::Error);
self.active_request = Some(request);
return;
};
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;
};
let Some(source) = episode.stream_url.as_deref() else {
self.detail = Some(Detail::Error);
self.active_request = Some(request);
return;
};
let title = if episode.title.is_empty() {
program.title
} else {
episode.title.clone()
};
let _ = self.player.load(source);
self.detail = Some(Detail::Player(title));
self.active_request = Some(request);
}
fn load_images(&mut self, urls: Vec<String>) -> Task<Message> {
for url in urls {
if self.images.contains_key(&url) {
continue;
}
self.images.insert(url.clone(), ImageState::Loading);
self.image_queue.push_back(url);
}
self.pump_images()
}
fn pump_images(&mut self) -> Task<Message> {
let Some(client) = self.client.clone() else {
return Task::none();
};
let available = IMAGE_CONCURRENCY.saturating_sub(self.image_inflight);
let mut tasks = Vec::with_capacity(available);
for _ in 0..available {
let Some(url) = self.image_queue.pop_front() else {
break;
};
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)),
));
self.image_inflight += 1;
}
Task::batch(tasks)
}
fn close_detail(&mut self) {
self.player.clear();
self.detail = None;
self.return_program = None;
self.active_request = None;
}
fn back(&mut self) {
if matches!(self.detail, Some(Detail::Player(_))) {
self.player.clear();
if let Some(program) = self.return_program.take() {
self.detail = Some(Detail::Program(program));
self.active_request = None;
return;
}
}
self.detail = None;
self.active_request = None;
}
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);
if let Some(duration) = episode.duration_seconds {
let minutes = (duration / 60) as i64;
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)));
}
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()
}
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);
}
}