AkurAI Build
Menu

BifrOSt-Apps

public

Latest 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);
    }
}