/* This file is part of jellything (https://codeberg.org/metamuffin/jellything) which is licensed under the GNU Affero General Public License (version 3); see /COPYING. Copyright (C) 2025 metamuffin */ use crate::{ Page, filter_sort::NodeFilterSortForm, format::format_chapter, locale::{Language, trs}, node_card::{NodeCard, NodeCardWide}, props::Props, }; use jellycommon::{ Chapter, Node, NodeKind, PeopleGroup, api::NodeFilterSort, routes::{ u_node_slug, u_node_slug_backdrop, u_node_slug_person_asset, u_node_slug_player, u_node_slug_player_time, u_node_slug_poster, u_node_slug_thumbnail, u_node_slug_update_rating, u_node_slug_watched, }, user::{ApiWatchedState, NodeUserData, WatchedState}, }; use std::sync::Arc; impl Page for NodePage<'_> { fn title(&self) -> String { self.node.title.clone().unwrap_or_default() } fn class(&self) -> Option<&'static str> { if self.player { Some("player") } else { Some("node-page") } } fn to_render(&self) -> markup::DynRender { markup::new!(@self) } } markup::define! { NodePage<'a>( node: &'a Node, udata: &'a NodeUserData, children: &'a [(Arc, NodeUserData)], parents: &'a [(Arc, NodeUserData)], similar: &'a [(Arc, NodeUserData)], filter: &'a NodeFilterSort, lang: &'a Language, player: bool, ) { @if !matches!(node.kind, NodeKind::Collection) && !player { img.backdrop[src=u_node_slug_backdrop(&node.slug, 2048), loading="lazy"]; } .page.node { @if !matches!(node.kind, NodeKind::Collection) && !player { @let cls = format!("bigposter {}", aspect_class(node.kind)); div[class=cls] { img[src=u_node_slug_poster(&node.slug, 2048), loading="lazy"]; } } .title { h1 { @node.title } ul.parents { @for (node, _) in *parents { li { a.component[href=u_node_slug(&node.slug)] { @node.title } }}} @if node.media.is_some() { a.play[href=u_node_slug_player(&node.slug)] { @trs(lang, "node.player_link") } } @if !matches!(node.kind, NodeKind::Collection | NodeKind::Channel) { @if matches!(udata.watched, WatchedState::None | WatchedState::Pending | WatchedState::Progress(_)) { form.mark_watched[method="POST", action=u_node_slug_watched(&node.slug, ApiWatchedState::Watched)] { input[type="submit", value=trs(lang, "node.watched.set")]; } } @if matches!(udata.watched, WatchedState::Watched) { form.mark_unwatched[method="POST", action=u_node_slug_watched(&node.slug, ApiWatchedState::None)] { input[type="submit", value=trs(lang, "node.watched.unset")]; } } @if matches!(udata.watched, WatchedState::None) { form.mark_unwatched[method="POST", action=u_node_slug_watched(&node.slug, ApiWatchedState::Pending)] { input[type="submit", value=trs(lang, "node.watchlist.set")]; } } @if matches!(udata.watched, WatchedState::Pending) { form.mark_unwatched[method="POST", action=u_node_slug_watched(&node.slug, ApiWatchedState::None)] { input[type="submit", value=trs(lang, "node.watchlist.unset")]; } } form.rating[method="POST", action=u_node_slug_update_rating(&node.slug)] { input[type="range", name="rating", min=-10, max=10, step=1, value=udata.rating]; input[type="submit", value=trs(lang, "node.update_rating")]; } } } .details { @Props { node, udata, full: true, lang } h3 { @node.tagline } @if let Some(description) = &node.description { p { @for line in description.lines() { @line br; } } } @if let Some(media) = &node.media { @if !media.chapters.is_empty() { h2 { @trs(lang, "node.chapters") } ul.children.hlist { @for chap in &media.chapters { @let (inl, sub) = format_chapter(chap); li { .card."aspect-thumb" { .poster { a[href=u_node_slug_player_time(&node.slug, chap.time_start.unwrap_or(0.))] { img[src=u_node_slug_thumbnail(&node.slug, chapter_key_time(chap, media.duration), 1024), loading="lazy"]; } .cardhover { .props { p { @inl } } } } .title { span { @sub } } }} }} } @if !node.people.is_empty() { h2 { @trs(lang, "node.people") } @for (group, people) in &node.people { details[open=group==&PeopleGroup::Cast] { summary { h3 { @format!("{}", group) } } ul.children.hlist { @for (i, pe) in people.iter().enumerate() { li { .card."aspect-port" { .poster { a[href="#"] { img[src=u_node_slug_person_asset(&node.slug, *group, i, 1024), loading="lazy"]; } } .title { span { @pe.person.name } br; @if let Some(c) = pe.characters.first() { span.subtitle { @c } } @if let Some(c) = pe.jobs.first() { span.subtitle { @c } } } }} }} } } } details { summary { @trs(lang, "media.tracks") } ol { @for track in &media.tracks { li { @format!("{track}") } }} } } @if !node.external_ids.is_empty() { details { summary { @trs(lang, "node.external_ids") } table { @for (key, value) in &node.external_ids { tr { tr { td { @trs(lang, &format!("eid.{}", key)) } @if let Some(url) = external_id_url(key, value) { td { a[href=url] { pre { @value } } } } else { td { pre { @value } } } } }} } } } @if !node.tags.is_empty() { details { summary { @trs(lang, "node.tags") } ol { @for tag in &node.tags { li { @tag } }} } } } @if matches!(node.kind, NodeKind::Collection | NodeKind::Channel) { @NodeFilterSortForm { f: filter, lang } } @if !similar.is_empty() { h2 { @trs(lang, "node.similar") } ul.children.hlist {@for (node, udata) in similar.iter() { li { @NodeCard { node, udata, lang } } }} } @match node.kind { NodeKind::Show | NodeKind::Series | NodeKind::Season => { ol { @for (node, udata) in children.iter() { li { @NodeCardWide { node, udata, lang } } }} } NodeKind::Collection | NodeKind::Channel | _ => { ul.children {@for (node, udata) in children.iter() { li { @NodeCard { node, udata, lang } } }} } } } } } fn chapter_key_time(c: &Chapter, dur: f64) -> f64 { let start = c.time_start.unwrap_or(0.); let end = c.time_end.unwrap_or(dur); start * 0.8 + end * 0.2 } pub fn aspect_class(kind: NodeKind) -> &'static str { use NodeKind::*; match kind { Video | Episode => "aspect-thumb", Collection => "aspect-land", Season | Show | Series | Movie | ShortFormVideo => "aspect-port", Channel | Music | Unknown => "aspect-square", } } fn external_id_url(key: &str, value: &str) -> Option { Some(match key { "youtube.video" => format!("https://youtube.com/watch?v={value}"), "youtube.channel" => format!("https://youtube.com/channel/{value}"), "youtube.channelname" => format!("https://youtube.com/channel/@{value}"), "musicbrainz.release" => format!("https://musicbrainz.org/release/{value}"), "musicbrainz.albumartist" => format!("https://musicbrainz.org/artist/{value}"), "musicbrainz.artist" => format!("https://musicbrainz.org/artist/{value}"), "musicbrainz.releasegroup" => format!("https://musicbrainz.org/release-group/{value}"), "musicbrainz.recording" => format!("https://musicbrainz.org/recording/{value}"), _ => return None, }) }