web ui playlist browser

This commit is contained in:
euzu
2025-07-31 19:08:57 +02:00
parent a6f719b3c4
commit b1cba385bc
18 changed files with 256 additions and 56 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
:root {
--media-size-mobile: 600px;
--media-size-mobile: 720px;
--text-color: #ffffff;
--hint-color: #888888;
--label-color: #73d6fe;
+1 -1
View File
@@ -29,7 +29,7 @@ bytes = "1"
[dependencies.web-sys]
version = "0.3"
features = ["Document", "Element", "Node", "Window", "HtmlCollection", "HtmlInputElement", "DomRect", "HtmlDialogElement"]
features = ["Document", "Element", "Node", "Window", "HtmlCollection", "HtmlInputElement", "DomRect", "HtmlDialogElement", "CssStyleDeclaration"]
[dev-dependencies]
js-sys = "0.3"
+2 -2
View File
@@ -87,13 +87,13 @@
"keys": [
"PlayArrow"
],
"path": "M8 5v14l11-7z"
"path": "M 2,3.5077234 C 2,2.3622575 3.3299194,1.6369741 4.4176398,2.1882965 L 21.15403,10.681605 a 1.6315805,1.5054313 0 0 1 0,2.638854 L 4.4176398,21.813768 A 1.6315805,1.5054313 0 0 1 2,20.494341 Z"
},
{
"keys": [
"PlayArrowOutline"
],
"path": "m 6.9091055,7.1999977 7.6654235,4.7999983 -7.6654235,4.8 z M 4,2 V 22 L 20,11.999996 Z"
"path": "M 20.805539,10.144378 5.5830037,2.2804593 c -0.7254261,-0.3744562 -1.6646379,-0.3734349 -2.3874011,0 C 2.4583841,2.6603621 2,3.3701266 2,4.1323151 V 19.861175 c 0,0.762529 0.4580037,1.472293 1.1910377,1.850153 C 3.5535606,21.900259 3.967818,22 4.3889227,22 4.8088862,22 5.2227632,21.9006 5.5837645,21.71303 L 20.806299,13.84843 C 21.542757,13.467506 22,12.758082 22,11.996915 c -3.8e-4,-0.760146 -0.457243,-1.46957 -1.194461,-1.852537 z m -1.141205,1.935939 -15.2236767,7.86494 c -0.031193,0.016 -0.068472,0.0177 -0.106132,-0.0017 -0.031954,-0.01668 -0.052115,-0.04834 -0.052115,-0.08238 V 4.1319747 c 0,-0.034041 0.019781,-0.06536 0.053256,-0.082721 0.015977,-0.00851 0.034236,-0.012936 0.052876,-0.012936 0.0194,0 0.03766,0.00443 0.054017,0.012936 L 19.662432,11.912152 c 0.03386,0.0177 0.05516,0.05004 0.05516,0.08476 -3.81e-4,0.03506 -0.02016,0.06604 -0.05326,0.0834 z"
},
{
"keys": [
+14 -4
View File
@@ -1,4 +1,6 @@
:root {
--mobile-size: 720px;
--dashboard-mobile-size: 1280px;
--sidebar-width-expaned: 270px;
--sidebar-width-collapsed: 42px;
--max-view-width: 1280px;
@@ -8,6 +10,7 @@
--app-header-height: 56px;
--scrollbar-width: 8px;
--scrollbar-border-radius: 4px;
--gap-small: 0.3rem;
--gap-default: 0.5rem;
--gap-large: 1rem;
@@ -20,6 +23,9 @@
--padding-large: 1.5rem;
--padding-larger: 2rem;
--margin-micro: 0.2rem;
--margin-mini: 0.5rem;
--margin-small: 0.875rem;
--margin-default: 1rem;
--primary-color: #124a9b;
@@ -50,7 +56,8 @@
--text-button-background-color: var(--action-item-background-color);
--text-button-color: #ffffff;
--text-button-border-color: #2F2F31;
--text-button-hover-color: #303032;
--text-button-hover-color: #ffffff;
--text-button-hover-background-color: #303032;
--text-button-primary-background-color: var(--primary-color);
--text-button-primary-color: #ffffff;
--text-button-primary-hover-background-color: #2f68be;
@@ -132,6 +139,12 @@
--output-hdhomerun-color: #6366f1;
--output-strm-color: #f43f5e;
--tag-live-background-color: #075b41;
--tag-live-border-color: #10b981;
--tag-video-background-color: #035b6e;
--tag-video-border-color: #06b6d4;
--tag-series-background-color: #3c3f9f;
--tag-series-border-color: #6366f1;
--hint-color: #888888;
--label-color: #73d6fe;
@@ -180,9 +193,6 @@
--menu-button-warn-hover-color: #dd7573;
--menu-button-warn-hover-background-color: #4c3434;
--text-button-border-radius: var(--border-radius);
--text-button-hover-background-color: #0d6efd;
--scrollbar-track-color: #838383;
--scrollbar-handle-color: #505050;
@@ -2,13 +2,14 @@
display: flex;
flex-flow: column;
overflow: hidden;
flex-shrink: 0;
&__header {
display: flex;
flex-flow: row nowrap;
color: var(--modest-text-color);
fill: var(--modest-text-color);
max-height: 1.2rem;
//max-height: 1.2rem;
cursor: pointer;
box-sizing: border-box;
width: 100%;
@@ -34,6 +35,7 @@
&__body {
display: flex;
flex-flow: column;
flex-shrink: 0;
width: 100%;
box-sizing: border-box;
height: fit-content;
@@ -5,6 +5,7 @@
fill: var(--menu-icon-color);
background-color: transparent;
border: none;
border-radius: var(--border-radius);
aspect-ratio: 1 / 1;
cursor: pointer;
box-sizing: border-box;
+1 -6
View File
@@ -2,7 +2,6 @@
display: flex;
flex-flow: column;
box-sizing: border-box;
flex: 1 1 auto;
width: 100%;
//max-width: var(--max-view-width);
padding: var(--padding-default) 0;
@@ -13,7 +12,6 @@
display: flex;
flex-flow: column;
box-sizing: border-box;
flex: 1 1 auto;
width: 100%;
overflow: hidden;
}
@@ -25,9 +23,8 @@
flex-flow: column;
box-sizing: border-box;
overflow: hidden;
flex: 1 1 auto;
width: 100%;
height: 100%;
flex-shrink: 0;
//max-width: var(--max-view-width);
gap: var(--gap-default);
@@ -35,7 +32,6 @@
display: flex;
flex-flow: row nowrap;
box-sizing: border-box;
flex: 1 1 auto;
width: 100%;
justify-content: space-between;
align-items: center;
@@ -45,7 +41,6 @@
display: flex;
flex-flow: column;
box-sizing: border-box;
flex: 1 1 auto;
width: 100%;
overflow: auto;
gap: var(--gap-default);
+1 -1
View File
@@ -4,5 +4,5 @@
overflow: hidden;
background-color: transparent;
box-sizing: border-box;
gap: var(--gap-default);
}
+6
View File
@@ -56,6 +56,12 @@
}
}
.tp__menu-item.active,
.tp__icon-button.active {
background-color: var(--menu-item-hover-background-color);
color: var(--menu-item-hover-color)
}
&__content {
display: flex;
flex-flow: column;
+2 -1
View File
@@ -26,7 +26,8 @@
&:focus,
&:hover {
background-color: var(--text-button-hover-color);
background-color: var(--text-button-hover-background-color);
color: var(--text-button-hover-color);
outline: none;
}
}
@@ -1,3 +1,5 @@
$mobile-size: 720px;
.tp__playlist-explorer {
display: flex;
flex-flow: column;
@@ -20,18 +22,18 @@
overflow: hidden;
&-list {
display: flex;
flex-flow: column;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: var(--gap-default);
box-sizing: border-box;
overflow: auto;
padding: var(--padding-default);
overflow-y: auto;
width: 100%;
}
}
&__category {
display: flex;
justify-content: center;
justify-content: flex-start;
align-items: center;
fill: var(--menu-icon-color);
background-color: var(--text-button-background-color);
@@ -46,6 +48,39 @@
padding: 0 var(--padding-default);
font-weight: bold;
font-size: 1rem;
overflow: hidden;
text-overflow: ellipsis;
&:hover {
background-color: var(--text-button-hover-background-color);
color: var(--text-button-hover-color);
}
&-live:before {
content: "L";
background-color: var(--tag-live-background-color);
border: 1px solid var(--tag-live-border-color);
}
&-video:before {
content: "V";
background-color: var(--tag-video-background-color);
border: 1px solid var(--tag-video-border-color);
}
&-series:before {
content: "S";
background-color: var(--tag-series-background-color);
border: 1px solid var(--tag-series-border-color);
}
&-live:before,
&-video:before,
&-series:before {
display: flex;
justify-content: center;
align-items: center;
width: 1.2rem;
height: 1.2rem;
border-radius: 50%;
}
}
}
+4
View File
@@ -23,6 +23,10 @@ body {
font-family: inherit;
}
h1 {
margin: var(--margin-mini) 0;
}
}
#root {
+14 -1
View File
@@ -17,12 +17,25 @@ pub struct CollapsePanelProps {
#[function_component]
pub fn CollapsePanel(props: &CollapsePanelProps) -> Html {
let expanded = use_state(|| props.expanded);
// let panel_ref = use_node_ref();
let toggle = {
let expanded = expanded.clone();
Callback::from(move |_| expanded.set(!*expanded))
};
// use_effect_with((expanded.clone(), panel_ref.clone()),move |(expanded, panel_ref)| {
// if let Some(element) = panel_ref.cast::<HtmlElement>() {
// if **expanded {
// let scroll_height = element.scroll_height();
// element.style().set_property("height", &format!("{scroll_height}px")).unwrap();
// } else {
// element.style().set_property("height", "0px").unwrap();
// }
// }
// || ()
// });
html! {
<div class={classes!("tp__collapse-panel", if *expanded {""} else {"tp__collapsed"}, props.class.to_string())}>
<div class="tp__collapse-panel__header" onclick={toggle}>
@@ -31,7 +44,7 @@ pub fn CollapsePanel(props: &CollapsePanelProps) -> Html {
</span>
<AppIcon name={ if *expanded { "ChevronUp" } else {"ChevronDown"} }/>
</div>
<div class="tp__collapse-panel__body">
<div /*ref={panel_ref} */class="tp__collapse-panel__body">
{ for props.children.iter() }
</div>
</div>
+4 -2
View File
@@ -1,5 +1,5 @@
use web_sys::MouseEvent;
use yew::{function_component, html, Callback, Html, Properties};
use yew::{classes, function_component, html, Callback, Html, Properties};
use crate::app::components::AppIcon;
#[derive(Properties, Clone, PartialEq, Debug)]
@@ -7,6 +7,8 @@ pub struct IconButtonProps {
pub name: String,
pub icon: String,
pub onclick: Callback<String>,
#[prop_or_default]
pub style: String,
}
#[function_component]
@@ -22,7 +24,7 @@ pub fn IconButton(props: &IconButtonProps) -> Html {
};
html! {
<button class="tp__icon-button" onclick={handle_click}>
<button class={classes!("tp__icon-button", props.style.clone())} onclick={handle_click}>
<AppIcon name={props.icon.clone()}></AppIcon>
</button>
}
+1 -1
View File
@@ -26,7 +26,7 @@ pub fn MenuItem(props: &MenuItemProps) -> Html {
};
html! {
<div class={classes!("tp__menu-item", props.style.to_string())} onclick={ handle_click }>
<div class={classes!("tp__menu-item", props.style.clone())} onclick={ handle_click }>
<AppIcon name={props.icon.clone()}></AppIcon>
<label>{props.label.clone()}</label>
</div>
@@ -2,25 +2,149 @@ use std::rc::Rc;
use crate::app::context::PlaylistExplorerContext;
use yew::prelude::*;
use yew_i18n::use_translation;
use shared::model::{PlaylistResponseGroup, UiPlaylistGroup, XtreamCluster};
use shared::model::{UiPlaylistGroup, XtreamCluster};
enum ExplorerLevel {
Categories,
Groups(XtreamCluster),
Group(Rc<PlaylistResponseGroup>),
Group(Rc<UiPlaylistGroup>),
}
const HEADERS: [&str; 11] = [
"TABLE.EMPTY",
"TABLE.ENABLED",
"TABLE.NAME",
"TABLE.OUTPUT",
"TABLE.OPTIONS",
"TABLE.SORT",
"TABLE.FILTER",
"TABLE.RENAME",
"TABLE.MAPPING",
"TABLE.PROCESSING_ORDER",
"TABLE.WATCH",
];
#[function_component]
pub fn PlaylistExplorer() -> Html {
let translate = use_translation();
let context = use_context::<PlaylistExplorerContext>().expect("PlaylistExlorer context not found");
let current_item = use_state(|| ExplorerLevel::Categories);
//
// let popup_anchor_ref = use_state(|| None::<web_sys::Element>);
// let popup_is_open = use_state(|| false);
//
// let handle_popup_close = {
// let set_is_open = popup_is_open.clone();
// Callback::from(move |()| {
// set_is_open.set(false);
// })
// };
//
// let handle_popup_onclick = {
// let set_selected_dto = selected_dto.clone();
// let set_anchor_ref = popup_anchor_ref.clone();
// let set_is_open = popup_is_open.clone();
// Callback::from(move |(dto, event): (Rc<ConfigTargetDto>, MouseEvent)| {
// if let Some(target) = event.target_dyn_into::<web_sys::Element>() {
// set_selected_dto.set(Some(dto.clone()));
// set_anchor_ref.set(Some(target));
// set_is_open.set(true);
// }
// })
// };
//
// let render_header_cell = {
// let translator = translate.clone();
// Callback::<usize, Html>::from(move |col| {
// html! {
// {
// if col < HEADERS.len() {
// translator.t(HEADERS[col])
// } else {
// String::new()
// }
// }
// }
// })
// };
//
// let render_data_cell = {
// let translator = translate.clone();
// let popup_onclick = handle_popup_onclick.clone();
// Callback::<(usize, usize, Rc<ConfigTargetDto>), Html>::from(
// move |(row, col, dto): (usize, usize, Rc<ConfigTargetDto>)| {
// match col {
// 0 => {
// let popup_onclick = popup_onclick.clone();
// html! {
// <button class="tp__icon-button"
// onclick={Callback::from(move |event: MouseEvent| popup_onclick.emit((dto.clone(), event)))}
// data-row={row.to_string()}>
// <AppIcon name="Popup"></AppIcon>
// </button>
// }
// }
// 1 => html! { <Chip class={ convert_bool_to_chip_style(dto.enabled) }
// label={if dto.enabled {translator.t("LABEL.ACTIVE")} else { translator.t("LABEL.DISABLED")} }
// /> },
// 2 => html! { dto.name.as_str() },
// 3 => html! { <TargetOutput target={Rc::clone(&dto)} /> },
// 4 => html! { <TargetOptions target={Rc::clone(&dto)} /> },
// 5 => dto.sort.as_ref().map_or_else(|| html! {}, |_s| html! { <RevealContent><TargetSort target={Rc::clone(&dto)} /></RevealContent> }),
// 6 => dto.t_filter.as_ref().map_or_else(|| html! {}, |f| html! { <RevealContent preview={Some(html!{<FilterView inline={true} filter={f.clone()} />})}><FilterView pretty={true} filter={f.clone()} /></RevealContent> }),
// 7 => dto.rename.as_ref().map_or_else(|| html! {}, |_r| html! { <RevealContent><TargetRename target={Rc::clone(&dto)} /></RevealContent> }),
// 8 => html! { <PlaylistMappings mappings={dto.mapping.clone()} /> },
// 9 => html! { <PlaylistProcessing order={dto.processing_order} /> },
// 10 => html! { <TargetWatch target={Rc::clone(&dto)} /> },
// _ => html! {""},
// }
// })
// };
//
// let table_definition = {
// // first register for config update
// let render_header_cell_cb = render_header_cell.clone();
// let render_data_cell_cb = render_data_cell.clone();
// let num_cols = crate::app::components::playlist::target_table::HEADERS.len();
// use_memo(props.targets.clone(), move |targets| {
// targets.as_ref().map(|list|
// Rc::new(TableDefinition::<ConfigTargetDto> {
// items: Rc::new(list.clone()),
// num_cols,
// render_header_cell: render_header_cell_cb,
// render_data_cell: render_data_cell_cb,
// }))
// })
// };
let render_cluster = |cluster: &Vec<Rc<UiPlaylistGroup>>| {
cluster.iter()
.map(|group| html! {
<div class="tp__playlist-explorer__category">{ group.title.clone() }</div>
})
let handle_category_select = {
let set_current_item = current_item.clone();
Callback::from(move |(group, event): (Rc<UiPlaylistGroup>, MouseEvent)| {
set_current_item.set(ExplorerLevel::Group(group));
})
};
let render_cluster = |cluster: XtreamCluster, list: &Vec<Rc<UiPlaylistGroup>>| {
list.iter()
.map(|group| {
let group_clone = group.clone();
let on_click = {
let category_select = handle_category_select.clone();
Callback::from(move |event: MouseEvent| {
category_select.emit((group_clone.clone(), event));
})
};
html! {
<span class="tp__playlist-explorer__category" onclick={on_click}>
{
match cluster {
XtreamCluster::Live => html! {<span class="tp__playlist-explorer__category-live"></span>},
XtreamCluster::Video => html! {<span class="tp__playlist-explorer__category-video"></span>},
XtreamCluster::Series => html! {<span class="tp__playlist-explorer__category-series"></span>},
}
}
{ group.title.clone() }</span>
}})
.collect::<Html>()
};
@@ -30,21 +154,17 @@ pub fn PlaylistExplorer() -> Html {
<div class="tp__playlist-explorer__categories-list">
{ context.playlist.as_ref()
.and_then(|response| response.live.as_ref())
.map(render_cluster)
.map(|list| render_cluster(XtreamCluster::Live, list))
.unwrap_or_default()
}
</div>
<div class="tp__playlist-explorer__categories-list">
{ context.playlist.as_ref()
.and_then(|response| response.vod.as_ref())
.map(render_cluster)
.map(|list| render_cluster(XtreamCluster::Video, list))
.unwrap_or_default()
}
</div>
<div class="tp__playlist-explorer__categories-list">
{ context.playlist.as_ref()
.and_then(|response| response.series.as_ref())
.map(render_cluster)
.map(|list| render_cluster(XtreamCluster::Series, list))
.unwrap_or_default()
}
</div>
@@ -52,14 +172,25 @@ pub fn PlaylistExplorer() -> Html {
}
};
let render_group = |group: &Rc<UiPlaylistGroup>| {
html! {
<div class="tp__playlist-explorer__group">
{
group.channels.iter().map(|c| {
html! { c.title.clone() }
}).collect::<Html>()
}
</div>
}
};
html! {
<div class="tp__playlist-explorer">
<div class="tp__playlist-explorer__body">
{
match *current_item {
ExplorerLevel::Categories => html!{render_categories()} ,
ExplorerLevel::Groups(ref cluster) => html!{},
ExplorerLevel::Group(ref groups) => html!{},
ExplorerLevel::Group(ref group) => html!{ render_group(group) },
}
}
</div>
@@ -105,9 +105,6 @@ pub fn PlaylistSourceSelector() -> Html {
</div>
</Card>
</CollapsePanel>
<div class="tp__playlist-source-selector__body tp__list-list__selector">
</div>
</div>
</div>
}
+13 -10
View File
@@ -33,11 +33,14 @@ pub fn Sidebar(props: &SidebarProps) -> Html {
let translate = use_translation();
let collapsed = use_state(|| CollapseState::AutoExpanded);
let block_sidebar_toggle = use_state(|| false);
let active_menu = use_state(|| ViewType::Dashboard);
let handle_menu_click = {
let viewchange = props.onview.clone();
let active_menu = active_menu.clone();
Callback::from(move |name: String| {
if let Ok(view_type) = ViewType::from_str(&name) {
active_menu.set(view_type);
viewchange.emit(view_type);
}
})
@@ -130,14 +133,14 @@ pub fn Sidebar(props: &SidebarProps) -> Html {
let render_expanded = || {
html! {
<div class="tp__app-sidebar__content">
<MenuItem icon="DashboardOutline" name={ViewType::Dashboard.to_string()} label={translate.t("LABEL.DASHBOARD")} onclick={&handle_menu_click}></MenuItem>
<MenuItem icon="Stats" name={ViewType::Stats.to_string()} label={translate.t("LABEL.STATS")} onclick={&handle_menu_click}></MenuItem>
<MenuItem style={if *active_menu == ViewType::Dashboard { "active" } else {""}} icon="DashboardOutline" name={ViewType::Dashboard.to_string()} label={translate.t("LABEL.DASHBOARD")} onclick={&handle_menu_click}></MenuItem>
<MenuItem style={if *active_menu == ViewType::Stats { "active" } else {""}} icon="Stats" name={ViewType::Stats.to_string()} label={translate.t("LABEL.STATS")} onclick={&handle_menu_click}></MenuItem>
<CollapsePanel title={translate.t("LABEL.SETTINGS")}>
<MenuItem icon="UserOutline" name={ViewType::Users.to_string()} label={translate.t("LABEL.USER")} onclick={&handle_menu_click}></MenuItem>
<MenuItem style={if *active_menu == ViewType::Users { "active" } else {""}} icon="UserOutline" name={ViewType::Users.to_string()} label={translate.t("LABEL.USER")} onclick={&handle_menu_click}></MenuItem>
</CollapsePanel>
<CollapsePanel title={translate.t("LABEL.PLAYLIST")}>
<MenuItem icon="PlayArrowOutline" name={ViewType::PlaylistEditor.to_string()} label={translate.t("LABEL.PLAYLIST")} onclick={&handle_menu_click}></MenuItem>
<MenuItem icon="Live" name={ViewType::PlaylistExplorer.to_string()} label={translate.t("LABEL.PLAYLIST_VIEWER")} onclick={&handle_menu_click}></MenuItem>
<MenuItem style={if *active_menu == ViewType::PlaylistEditor { "active" } else {""}} icon="PlayArrowOutline" name={ViewType::PlaylistEditor.to_string()} label={translate.t("LABEL.PLAYLIST")} onclick={&handle_menu_click}></MenuItem>
<MenuItem style={if *active_menu == ViewType::PlaylistExplorer { "active" } else {""}} icon="Live" name={ViewType::PlaylistExplorer.to_string()} label={translate.t("LABEL.PLAYLIST_VIEWER")} onclick={&handle_menu_click}></MenuItem>
</CollapsePanel>
</div>
}
@@ -146,13 +149,13 @@ pub fn Sidebar(props: &SidebarProps) -> Html {
let render_collapsed = || {
html! {
<div class="tp__app-sidebar__content">
<IconButton icon="DashboardOutline" name={ViewType::Dashboard.to_string()} onclick={&handle_menu_click}></IconButton>
<IconButton icon="Stats" name={ViewType::Stats.to_string()} onclick={&handle_menu_click}></IconButton>
<IconButton style={if *active_menu == ViewType::Dashboard { "active" } else {""}} icon="DashboardOutline" name={ViewType::Dashboard.to_string()} onclick={&handle_menu_click}></IconButton>
<IconButton style={if *active_menu == ViewType::Stats { "active" } else {""}} icon="Stats" name={ViewType::Stats.to_string()} onclick={&handle_menu_click}></IconButton>
<span class="tp__app-sidebar__content-space"></span>
<IconButton icon="UserOutline" name={ViewType::Users.to_string()} onclick={&handle_menu_click}></IconButton>
<IconButton style={if *active_menu == ViewType::Users { "active" } else {""}} icon="UserOutline" name={ViewType::Users.to_string()} onclick={&handle_menu_click}></IconButton>
<span class="tp__app-sidebar__content-space"></span>
<IconButton icon="PlayArrowOutline" name={ViewType::PlaylistEditor.to_string()} onclick={&handle_menu_click}></IconButton>
<IconButton icon="Live" name={ViewType::PlaylistExplorer.to_string()} onclick={&handle_menu_click}></IconButton>
<IconButton style={if *active_menu == ViewType::PlaylistEditor { "active" } else {""}} icon="PlayArrowOutline" name={ViewType::PlaylistEditor.to_string()} onclick={&handle_menu_click}></IconButton>
<IconButton style={if *active_menu == ViewType::PlaylistExplorer { "active" } else {""}} icon="Live" name={ViewType::PlaylistExplorer.to_string()} onclick={&handle_menu_click}></IconButton>
</div>
}
};