diff --git a/.gitignore b/.gitignore index c03e0bdb1..0451a9fa7 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ dist /dataSources/ /dataSources.local.xml +docker/binaries .DS_Store *.m3u diff --git a/backend/src/model/xmltv.rs b/backend/src/model/xmltv.rs index a9ef618b5..5ce284bf6 100644 --- a/backend/src/model/xmltv.rs +++ b/backend/src/model/xmltv.rs @@ -197,7 +197,7 @@ fn concat_text(t1: &String, t2: &str) -> String { } else if t1.ends_with('\\') { let mut t = t1.to_string(); t.pop(); - format!("{t}'{t2}") + format!("{t}'{t2}") } else { format!("{t1}{t2}") } diff --git a/backend/src/processing/parser/xmltv.rs b/backend/src/processing/parser/xmltv.rs index 0adbb7d1d..3ca8097a9 100644 --- a/backend/src/processing/parser/xmltv.rs +++ b/backend/src/processing/parser/xmltv.rs @@ -393,7 +393,7 @@ fn handle_text_tag(stack: &mut [XmlTag], e: &BytesText) { let t_fixed: Cow = if t.ends_with('\\') { let mut owned = t.to_string(); owned.pop(); - owned.push('\''); + owned.push_str("'"); Cow::Owned(owned) } else { Cow::Borrowed(t) diff --git a/bin/build_docker.sh b/bin/build_docker.sh old mode 100644 new mode 100755 index 79a7d167b..84b718ff9 --- a/bin/build_docker.sh +++ b/bin/build_docker.sh @@ -174,12 +174,19 @@ for IMAGE_NAME in "${!MULTI_PLATFORM_IMAGES[@]}"; do fi # Build and push multi-platform image directly with cache + BUILDX_CACHE_ARGS=() + [ -n "$BUILDX_CACHE_FROM" ] && BUILDX_CACHE_ARGS+=(--cache-from "$BUILDX_CACHE_FROM") + [ -n "$BUILDX_CACHE_TO" ] && BUILDX_CACHE_ARGS+=(--cache-to "$BUILDX_CACHE_TO") + + # If you build on local, you need to activate buildx multiarch builds + # > docker buildx create --name multiarch --driver docker-container --use --bootstrap + # > docker run --rm --privileged tonistiigi/binfmt:latest --install all + docker buildx build -f Dockerfile-manual \ ${DOCKER_TAGS} \ --target "$BUILD_TARGET" \ --platform "linux/amd64,linux/arm64" \ - --cache-from "${BUILDX_CACHE_FROM:-}" \ - --cache-to "${BUILDX_CACHE_TO:-}" \ + "${BUILDX_CACHE_ARGS[@]}" \ --push \ . done diff --git a/frontend/src/app/components/playlist/epg_view.rs b/frontend/src/app/components/playlist/epg_view.rs index 30dda4f0e..7c4a0c034 100644 --- a/frontend/src/app/components/playlist/epg_view.rs +++ b/frontend/src/app/components/playlist/epg_view.rs @@ -1,18 +1,22 @@ -use std::rc::Rc; -use chrono::{Datelike, Local, TimeZone, Utc}; -use gloo_timers::callback::Interval; -use web_sys::HtmlElement; -use crate::app::components::{Breadcrumbs, NoContent, EpgSourceSelector}; +use crate::app::components::{Breadcrumbs, EpgSourceSelector, NoContent}; use crate::hooks::use_service_context; -use yew::platform::spawn_local; -use yew::prelude::*; -use yew_i18n::use_translation; -use shared::model::{EpgTv, PlaylistEpgRequest}; use crate::model::{BusyStatus, EventMessage}; +use crate::utils::set_timeout; +use chrono::{Datelike, Local, TimeZone, Utc}; +use gloo_timers::callback::{Interval, Timeout}; +use shared::model::{EpgTv, PlaylistEpgRequest}; +use std::cell::RefCell; +use std::rc::Rc; +use wasm_bindgen::prelude::Closure; +use wasm_bindgen::JsCast; +use web_sys::{window, HtmlElement}; +use yew::platform::spawn_local; +use yew::{classes, function_component, html, use_effect_with, use_memo, use_node_ref, use_state, Callback, Html, UseStateHandle}; +use yew_i18n::use_translation; const TIME_BLOCK_WIDTH: f64 = 210.0; const TIME_BLOCK_MINS: i64 = 30; -const PIXEL_PER_MIN:f64 = TIME_BLOCK_WIDTH / TIME_BLOCK_MINS as f64; +const PIXEL_PER_MIN: f64 = TIME_BLOCK_WIDTH / TIME_BLOCK_MINS as f64; fn get_pos(secs: i64, start_mins: i64) -> i64 { let mins = secs / 60; @@ -29,6 +33,9 @@ pub fn EpgView() -> Html { let container_ref = use_node_ref(); let now_line_ref = use_node_ref(); + // State to keep track of visible channel range + let visible_range = use_state(|| (0, 20)); // (start_index, end_index) + let handle_select_source = { let service_ctx = services.clone(); let epg_set = epg.clone(); @@ -39,8 +46,8 @@ pub fn EpgView() -> Html { service_ctx.event.broadcast(EventMessage::Busy(BusyStatus::Show)); spawn_local(async move { let playlist_epg = service_ctx.playlist.get_playlist_epg(req).await; - epg_set.set(playlist_epg); service_ctx.event.broadcast(EventMessage::Busy(BusyStatus::Hide)); + set_timeout(move || { epg_set.set(playlist_epg); }, 16); }); }) }; @@ -49,21 +56,26 @@ pub fn EpgView() -> Html { let container_ref = container_ref.clone(); let now_line_ref = now_line_ref.clone(); use_effect_with(epg.clone(), move |epg_tv| { - // TODO Active elements color change after time update + // Updates the now-line position let epg_tv_clone = epg_tv.clone(); - let calculate_position = Rc::new(move |epg_tv: &UseStateHandle>, recenter: bool| { + let calculate_position = Rc::new(move |epg_tv: &UseStateHandle>, recenter: bool| { if let Some(tv) = &**epg_tv { if let (Some(div), Some(now_line)) = (container_ref.cast::(), now_line_ref.cast::()) { - let start_window_secs = (tv.start / (TIME_BLOCK_MINS*60)) * (TIME_BLOCK_MINS*60); - let start_window = (start_window_secs / 60).max(0); let now = Utc::now().timestamp(); - let now_line_pos = get_pos(now, start_window); - now_line.style().set_property("left", &format!("{now_line_pos}px")).unwrap(); - if recenter { - let container_width = div.client_width(); - let scroll_pos = (now_line_pos as i32 - (container_width >> 1)).max(0); - div.set_scroll_left(scroll_pos); + if now >= tv.start && now <= tv.stop { + let start_window_secs = (tv.start / (TIME_BLOCK_MINS * 60)) * (TIME_BLOCK_MINS * 60); + let start_window = (start_window_secs / 60).max(0); + let now_line_pos = get_pos(now, start_window); + now_line.style().set_property("left", &format!("{now_line_pos}px")).unwrap(); + now_line.style().set_property("display", "block").unwrap(); + if recenter { + let container_width = div.client_width(); + let scroll_pos = (now_line_pos as i32 - (container_width >> 1)).max(0); + div.set_scroll_left(scroll_pos); + } + } else { + now_line.style().set_property("display", "none").unwrap(); } } } @@ -78,6 +90,58 @@ pub fn EpgView() -> Html { }); } + let row_height = use_memo((), move |_| { + let doc = window().unwrap().document().unwrap(); + let root = doc.document_element().unwrap(); // + let style = window().unwrap().get_computed_style(&root).unwrap().unwrap(); + + let row_height = style.get_property_value("--epg-row-height") + .unwrap_or_else(|_| String::new()); // fallback if not set + + + row_height.trim_end_matches("px").parse::().unwrap_or(60) + }); + + // Add scroll listener to calculate visible channels + { + let container_ref = container_ref.clone(); + let visible_range = visible_range.clone(); + let channel_row_height = *row_height; + use_effect_with((), move |_| { + if let Some(div) = container_ref.cast::() { + let visible_range = visible_range.clone(); + + // Store debounce timer in Rc + let debounce_handle: Rc>> = Rc::new(RefCell::new(None)); + let debounce_handle_clone = debounce_handle.clone(); + let onscroll = Closure::wrap(Box::new(move |_event: web_sys::Event| { + // Cancel previous scheduled update + if let Some(prev) = debounce_handle_clone.borrow_mut().take() { + prev.cancel(); + } + if let Some(div) = container_ref.cast::() { + let scroll_top = div.scroll_top(); + let client_height = div.client_height(); // Calculate which channel rows are visible + let vr = visible_range.clone(); + + // Schedule a new update after 80ms (debounce) + let handle = Timeout::new(80, move || { + let start_index = (scroll_top / (channel_row_height as i32)).max(0); + let end_index = ((scroll_top + client_height) / (channel_row_height as i32) + 1).max(0); + vr.set((start_index as usize, end_index as usize)); + }); + + *debounce_handle_clone.borrow_mut() = Some(handle); + } + }) as Box); + + div.add_event_listener_with_callback("scroll", onscroll.as_ref().unchecked_ref()).unwrap(); + onscroll.forget(); + } + || {} + }); + } + html! {
@@ -85,12 +149,10 @@ pub fn EpgView() -> Html {

{translate.t("LABEL.PLAYLIST_EPG")}

-
+
{ if epg.is_none() { - html! { - - } + html! { } } else { let tv = epg.as_ref().unwrap(); let start_window_secs = (tv.start / (TIME_BLOCK_MINS*60)) * (TIME_BLOCK_MINS*60); @@ -99,33 +161,34 @@ pub fn EpgView() -> Html { let window_duration = (end_window - start_window).max(0); let num_blocks = (window_duration + TIME_BLOCK_MINS - 1) / TIME_BLOCK_MINS; let now = Utc::now().timestamp(); - let now_line_pos = get_pos(now, start_window); let block_style= format!("width:{TIME_BLOCK_WIDTH}px; min-width:{TIME_BLOCK_WIDTH}px; max-width:{TIME_BLOCK_WIDTH}px"); - html! { + + let (start_index, end_index) = *visible_range; + let total_channels = tv.channels.len(); + let channel_row_height = *row_height; + + html! { <>
-
-
- { for tv.channels.iter().map(|ch| { +
+
+ { for tv.channels.iter().enumerate().skip(start_index).take(end_index - start_index).map(|(_i, ch)| { html! { -
+
- { - if let Some(icon) = &ch.icon { - html! { {ch.title.clone()} } - } else { - html! {} - } + { if let Some(icon) = &ch.icon { + html! { {ch.title.clone()} } + } else { html!{} } }
-
- { &ch.title } -
+
{ &ch.title }
} }) } +
+
{ for (0..num_blocks).map(|i| { @@ -142,23 +205,22 @@ pub fn EpgView() -> Html {
} } else { - html!{ -
-
- } + html!{
} } }) }
- { for tv.channels.iter().map(|ch| { +
+ { for tv.channels.iter().enumerate().skip(start_index).take(end_index - start_index).map(|(_i, ch)| { html! { -
+
{ for ch.programmes.iter().map(|p| { let is_active = now >= p.start && now < p.stop; let left = get_pos(p.start, start_window); let right = get_pos(p.stop, start_window); let width = (right - left).max(0); - if let (Some(pstart_time), Some(pend_time)) = ( + + if let (Some(pstart_time), Some(pend_time)) = ( Utc.timestamp_opt(p.start, 0).single(), Utc.timestamp_opt(p.stop, 0).single()) { let pstart_time_local = pstart_time.with_timezone(&Local); @@ -181,7 +243,8 @@ pub fn EpgView() -> Html { } }) } -
+
+
} diff --git a/frontend/src/services/websocket_service.rs b/frontend/src/services/websocket_service.rs index 6033a295d..b685d41a3 100644 --- a/frontend/src/services/websocket_service.rs +++ b/frontend/src/services/websocket_service.rs @@ -10,6 +10,7 @@ use shared::model::{ProtocolMessage, PROTOCOL_VERSION}; use shared::utils::{concat_path_leading_slash}; use crate::model::EventMessage; use crate::services::{get_base_href, get_token, EventService, StatusService}; +use crate::utils::set_timeout; const WS_RECONNECT_MS:i32 = 2000; @@ -137,17 +138,7 @@ impl WebSocketService { // schedule reconnect after 3 seconds let ws_service_inner = ws_service_reconnect.clone(); - let timeout_cb = Closure::once_into_js(Box::new(move || { - ws_service_inner.connect_ws(); - }) as Box); - - web_sys::window() - .unwrap() - .set_timeout_with_callback_and_timeout_and_arguments_0( - timeout_cb.unchecked_ref(), - WS_RECONNECT_MS, - ) - .unwrap(); + set_timeout(move || { ws_service_inner.connect_ws(); }, WS_RECONNECT_MS); })); socket.set_onclose(Some(onclose_callback.as_ref().unchecked_ref())); onclose_callback.forget(); @@ -164,17 +155,7 @@ impl WebSocketService { // schedule reconnect after 3 seconds let ws_service_inner = ws_service_reconnect.clone(); - let timeout_cb = Closure::once_into_js(Box::new(move || { - ws_service_inner.connect_ws(); - }) as Box); - - web_sys::window() - .unwrap() - .set_timeout_with_callback_and_timeout_and_arguments_0( - timeout_cb.unchecked_ref(), - WS_RECONNECT_MS, - ) - .unwrap(); + set_timeout(move || { ws_service_inner.connect_ws(); }, WS_RECONNECT_MS); })); socket.set_onerror(Some(onerror_callback.as_ref().unchecked_ref())); onerror_callback.forget(); diff --git a/frontend/src/utils/mod.rs b/frontend/src/utils/mod.rs index 41949b50b..936654121 100644 --- a/frontend/src/utils/mod.rs +++ b/frontend/src/utils/mod.rs @@ -1,5 +1,8 @@ mod storage; +use wasm_bindgen::JsCast; +use wasm_bindgen::prelude::Closure; +use web_sys::window; pub use storage::*; #[macro_export] @@ -13,4 +16,18 @@ macro_rules! html_if { }; } -pub use html_if; \ No newline at end of file +pub use html_if; + +pub fn set_timeout(callback: F, millis: i32) +where + F: FnOnce() + 'static, +{ + let cb = Closure::once_into_js(Box::new(callback) as Box); + window() + .unwrap() + .set_timeout_with_callback_and_timeout_and_arguments_0( + cb.unchecked_ref(), + millis, + ) + .unwrap(); +} \ No newline at end of file