aboutsummaryrefslogtreecommitdiff
path: root/ui/src/node_page.rs
blob: 7fb299f6f33d9f33a852b956a7f96b7da749ea0b (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
/*
    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 <metamuffin.org>
*/

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 to_render(&self) -> markup::DynRender {
        markup::new!(@self)
    }
}

markup::define! {
    NodePage<'a>(
        node: &'a Node,
        udata: &'a NodeUserData,
        children: &'a [(Arc<Node>, NodeUserData)],
        parents: &'a [(Arc<Node>, NodeUserData)],
        similar: &'a [(Arc<Node>, 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<String> {
    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,
    })
}