/* 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) 2026 metamuffin */ use crate::{ RenderInfo, components::{ node_card::{NodeCard, NodeCardWide}, props::Props, }, format::format_duration, page, }; use jellycommon::{ jellyobject::{EMPTY, Object, Tag, TypedTag}, routes::{u_image, u_node_slug_player, u_node_slug_player_time, u_node_slug_thumbnail}, *, }; use jellyui_locale::tr; use std::marker::PhantomData; page!(NodePage<'_>, |x| x .nku .node .get(NO_TITLE) .unwrap_or_default() .to_string() .into()); page!(Player<'_>, |x| x .nku .node .get(NO_TITLE) .unwrap_or_default() .to_string() .into()); markup::define! { NodePage<'a>( ri: &'a RenderInfo<'a>, nku: Nku<'a>, children: &'a [Nku<'a>], credits: &'a [(Tag, Vec>)], credited: &'a [Nku<'a>] ) { @let node = &nku.node; @let slug = node.get(NO_SLUG).unwrap_or_default(); @let pics = node.get(NO_PICTURES).unwrap_or(EMPTY); @if let Some(path) = pics.get(PICT_BACKDROP) { img.backdrop[src=u_image(path, 2048)]; } @if let Some(path) = pics.get(PICT_COVER) { @let cls = format!("bigposter {} {}", aspect_class(node), if pics.has(PICT_BACKDROP.0) { "has_backdrop" } else { "" }); div[class=cls] { img[src=u_image(path, 2048), loading="lazy"]; } } .title { h1 { @node.get(NO_TITLE).unwrap_or_default() } // ul.parents { @for (node, _) in *parents { li { // a.component[href=u_node_slug(&node.slug)] { @node.title } // }}} @if node.has(NO_TRACK.0) { a.play[href=u_node_slug_player(slug)] { @tr(ri.lang, "node.player_link") } // @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 { ri, nku, full: true } h3 { @node.get(NO_TAGLINE).unwrap_or_default() } @if let Some(description) = &node.get(NO_DESCRIPTION) { p { @for line in description.lines() { @line br; } } } @if node.has(NO_TRACK.0) { details { summary { @tr(ri.lang, "tag.trak") } ol { @for track in node.iter(NO_TRACK) { li { "track" @track.get(TR_NAME) } }} } } @if let Some(idents) = node.get(NO_IDENTIFIERS) { details { summary { @tr(ri.lang, "tag.iden") } table { @for (key, value) in idents.entries::() { tr { td { @tr(ri.lang, &format!("tag.iden.{key}")) } @if let Some(url) = external_id_url(key, value) { td { a[href=url] { pre { @value } } } } else { td { pre { @value } } } }} } } } @if node.has(NO_TAG.0) { details { summary { @tr(ri.lang, "tag.tag1") } ol { @for tag in node.iter(NO_TAG) { li { @tag } }} } } @if node.has(NO_METASOURCE.0) { details { summary { @tr(ri.lang, "tag.msrc") } table { tr { th {"Attribute"} th {"Source"} } @for (key, source) in node.get(NO_METASOURCE).unwrap_or(EMPTY).entries::() { tr { td { @tr(ri.lang, &format!("tag.{key}")) } td { @tr(ri.lang, &format!("tag.msrc.{source}")) } }} @for nkey in [NO_PICTURES, NO_IDENTIFIERS, NO_RATINGS] { @let nob = node.get(nkey).unwrap_or(EMPTY); @for (key, source) in nob.get(NO_METASOURCE).unwrap_or(EMPTY).entries::() { tr { td { @tr(ri.lang, &format!("tag.{nkey}")) ": " @tr(ri.lang, &format!("tag.{nkey}.{key}")) } td { @tr(ri.lang, &format!("tag.msrc.{source}")) } }} } } } } @if node.has(NO_CHAPTER.0) { h2 { @tr(ri.lang, "tag.chpt") } ul.nl.inline { @for chap in node.iter(NO_CHAPTER) { @let (inl, sub) = format_chapter(chap); li { .card."aspect-thumb" { .poster { a[href=u_node_slug_player_time(&slug, chap.get(CH_START).unwrap_or(0.))] { img[src=u_node_slug_thumbnail(&slug, chapter_key_time(chap, node.get(NO_DURATION).unwrap_or(1.)), 512), loading="lazy"]; } .overlay { .props { p { @inl } } } } .title { span { @sub } } }} }} } } @for (cat, items) in *credits { h2 { @tr(ri.lang, &format!("tag.cred.kind.{cat}")) } ul.nl.inline { @for nku in items { li { @NodeCard { ri, nku } } }} } @if !credited.is_empty() { h2 { @tr(ri.lang, "node.credited") } ul.nl.grid { @for nku in *credited { li { @NodeCard { ri, nku } } }} } @if !children.is_empty() { @if matches!(node.get(NO_KIND).unwrap_or(KIND_COLLECTION), KIND_SHOW | KIND_SEASON) { ul.nl.list { @for nku in *children { li { @NodeCardWide { ri, nku } } }} } else { ul.nl.grid { @for nku in *children { li { @NodeCard { ri, nku } } }} } } } Player<'a>(ri: &'a RenderInfo<'a>, nku: Nku<'a>) { @let _ = ri; @let pics = nku.node.get(NO_PICTURES).unwrap_or(EMPTY); video[id="player", poster=pics.get(PICT_COVER).map(|p| u_image(p, 2048))] {} } } fn chapter_key_time(c: &Object, dur: f64) -> f64 { let start = c.get(CH_START).unwrap_or(0.); let end = c.get(CH_END).unwrap_or(dur); start * 0.8 + end * 0.2 } fn format_chapter(c: &Object) -> (String, String) { ( format!( "{} - {}", format_duration(c.get(CH_START).unwrap_or(0.)), c.get(CH_END) .map(|x| format_duration(x)) .unwrap_or_default(), ), c.get(CH_NAME).map(|s| s.to_string()).unwrap_or_default(), ) } pub fn aspect_class(node: &Object) -> &'static str { let kind = node.get(NO_KIND).unwrap_or(KIND_COLLECTION); match kind { KIND_VIDEO | KIND_EPISODE => "aspect-thumb", KIND_COLLECTION => "aspect-land", KIND_SEASON | KIND_SHOW | KIND_PERSON | KIND_SERIES | KIND_MOVIE | KIND_SHORTFORMVIDEO => { "aspect-port" } KIND_CHANNEL | KIND_MUSIC => "aspect-square", _ => unreachable!(), } } fn external_id_url(key: Tag, value: &str) -> Option { Some(match TypedTag(key, PhantomData) { IDENT_YOUTUBE_VIDEO => format!("https://youtube.com/watch?v={value}"), IDENT_YOUTUBE_CHANNEL => format!("https://youtube.com/channel/{value}"), IDENT_YOUTUBE_CHANNEL_HANDLE => format!("https://youtube.com/channel/@{value}"), IDENT_MUSICBRAINZ_RELEASE => format!("https://musicbrainz.org/release/{value}"), IDENT_MUSICBRAINZ_ARTIST => format!("https://musicbrainz.org/artist/{value}"), IDENT_MUSICBRAINZ_RELEASE_GROUP => { format!("https://musicbrainz.org/release-group/{value}") } IDENT_MUSICBRAINZ_RECORDING => { format!("https://musicbrainz.org/recording/{value}") } _ => return None, }) }