diff --git a/CHANGELOG.md b/CHANGELOG.md index a5f903361..57b143772 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,8 @@ - Using tmdbid for VOD from get_vod_streams if available, which makes using resolve_vod for STRM generation not necessary. - File length problem with strm generation fixed. - Series name empty paren problem fixed. -- Default sort removed +- Default sort removed +- Reconnect on WebSocket disconnect, WebSocket connection status icon in WEB-UI # 3.1.6 (2025-09-01) - EPG Config View added diff --git a/frontend/public/assets/icons.json b/frontend/public/assets/icons.json index ab85268c6..edaa1443e 100644 --- a/frontend/public/assets/icons.json +++ b/frontend/public/assets/icons.json @@ -491,5 +491,9 @@ { "keys": ["Moon"], "path": "M9.37 5.51c-.18.64-.27 1.31-.27 1.99 0 4.08 3.32 7.4 7.4 7.4.68 0 1.35-.09 1.99-.27C17.45 17.19 14.93 19 12 19c-3.86 0-7-3.14-7-7 0-2.93 1.81-5.45 4.37-6.49M12 3c-4.97 0-9 4.03-9 9s4.03 9 9 9 9-4.03 9-9c0-.46-.04-.92-.1-1.36-.98 1.37-2.58 2.26-4.4 2.26-2.98 0-5.4-2.42-5.4-5.4 0-1.81.89-3.42 2.26-4.4-.44-.06-.9-.1-1.36-.1" + }, + { + "keys": ["WsDisconnected", "Offline"], + "path": "M12 2.02c-5.51 0-9.98 4.47-9.98 9.98s4.47 9.98 9.98 9.98 9.98-4.47 9.98-9.98S17.51 2.02 12 2.02m0 17.96c-4.4 0-7.98-3.58-7.98-7.98S7.6 4.02 12 4.02 19.98 7.6 19.98 12 16.4 19.98 12 19.98M12.75 5l-4.5 8.5h3.14V19l4.36-8.5h-3z" } ] \ No newline at end of file diff --git a/frontend/scss/app/_component.scss b/frontend/scss/app/_component.scss index d1dc29fcc..baf00c0aa 100644 --- a/frontend/scss/app/_component.scss +++ b/frontend/scss/app/_component.scss @@ -71,3 +71,4 @@ @forward "components/api_user/api_user_view"; @forward "components/keyvalue_editor"; @forward "components/loading_screen"; +@forward "components/websocket_status"; diff --git a/frontend/scss/app/components/_websocket_status.scss b/frontend/scss/app/components/_websocket_status.scss new file mode 100644 index 000000000..6fd1bddb9 --- /dev/null +++ b/frontend/scss/app/components/_websocket_status.scss @@ -0,0 +1,7 @@ +.tp__websocket-status { + fill: var(--warn-color); + .svg-icon { + width: 1.5rem; + height: 1.5rem; + } +} \ No newline at end of file diff --git a/frontend/src/app/components/api_user/api_user_view.rs b/frontend/src/app/components/api_user/api_user_view.rs index b5af4308a..97f512431 100644 --- a/frontend/src/app/components/api_user/api_user_view.rs +++ b/frontend/src/app/components/api_user/api_user_view.rs @@ -1,6 +1,6 @@ use yew::{function_component, html, Callback, Html}; use crate::app::components::loading_indicator::BusyIndicator; -use crate::app::components::{AppIcon, IconButton, ToastrView}; +use crate::app::components::{AppIcon, IconButton, ToastrView, WebsocketStatus}; use crate::app::components::theme::Theme; use crate::hooks::use_service_context; use crate::provider::DialogProvider; @@ -44,6 +44,7 @@ pub fn ApiUserView() -> Html { }
+
diff --git a/frontend/src/app/components/home.rs b/frontend/src/app/components/home.rs index 1eeca24f5..4e8297ee8 100644 --- a/frontend/src/app/components/home.rs +++ b/frontend/src/app/components/home.rs @@ -1,5 +1,4 @@ -use crate::app::components::{AppIcon, DashboardView, IconButton, InputRow, Panel, PlaylistEditorView, - PlaylistExplorerView, PlaylistUpdateView, Sidebar, StatsView, ToastrView, UserlistView}; +use crate::app::components::{AppIcon, DashboardView, IconButton, InputRow, Panel, PlaylistEditorView, PlaylistExplorerView, PlaylistUpdateView, Sidebar, StatsView, ToastrView, UserlistView, WebsocketStatus}; use crate::app::context::{ConfigContext, PlaylistContext, StatusContext}; use crate::hooks::{use_server_status, use_service_context}; use crate::model::{EventMessage, ViewType}; @@ -160,6 +159,7 @@ pub fn Home() -> Html { }
+
diff --git a/frontend/src/app/components/mod.rs b/frontend/src/app/components/mod.rs index 1037ab6fe..bf4c4e1b3 100644 --- a/frontend/src/app/components/mod.rs +++ b/frontend/src/app/components/mod.rs @@ -44,6 +44,7 @@ mod cell_value; mod key_value_editor; mod loading_screen; mod theme; +mod websocket_status; // pub use self::input::*; // pub use self::menu_item::*; // pub use self::popup_menu::*; @@ -84,5 +85,6 @@ pub use self::role_based_content::*; pub use self::cell_value::*; pub use self::key_value_editor::*; pub use self::loading_screen::*; +pub use self::websocket_status::*; //pub use self::number_input::*; -//pub use self::date_input::*; \ No newline at end of file +//pub use self::date_input::*; diff --git a/frontend/src/app/components/websocket_status.rs b/frontend/src/app/components/websocket_status.rs new file mode 100644 index 000000000..996f3753e --- /dev/null +++ b/frontend/src/app/components/websocket_status.rs @@ -0,0 +1,38 @@ +use yew::prelude::*; +use crate::app::components::AppIcon; +use crate::hooks::use_service_context; +use crate::model::EventMessage; + +#[derive(Properties, PartialEq)] +pub struct WebsocketStatusProps { +} + +#[function_component] +pub fn WebsocketStatus(props: &WebsocketStatusProps) -> Html { + let status = use_state(|| true); + let services = use_service_context(); + + { + let status_clone = status.clone(); + use_effect_with((), move |_| { + let services_ctx = services.clone(); + let status_clone = status_clone.clone(); + let subid = services_ctx.event.subscribe(move |msg| { + if let EventMessage::WebSocketStatus(active) = msg { + status_clone.set(active); + } + }); + move || services_ctx.event.unsubscribe(subid) + }); + } + + if *status { + return html! { <> } + } + + html! { +
+ +
+ } +} diff --git a/frontend/src/model/event_message.rs b/frontend/src/model/event_message.rs index 31ea81359..7061a6be7 100644 --- a/frontend/src/model/event_message.rs +++ b/frontend/src/model/event_message.rs @@ -13,4 +13,5 @@ pub enum EventMessage { Busy(BusyStatus), PlaylistUpdate(PlaylistUpdateState), PlaylistUpdateProgress(String, String), + WebSocketStatus(bool), } \ No newline at end of file diff --git a/frontend/src/services/websocket_service.rs b/frontend/src/services/websocket_service.rs index 4e2c32d09..639d4f4d1 100644 --- a/frontend/src/services/websocket_service.rs +++ b/frontend/src/services/websocket_service.rs @@ -11,6 +11,8 @@ use shared::utils::{concat_path_leading_slash}; use crate::model::EventMessage; use crate::services::{get_base_href, get_token, EventService, StatusService}; +const WS_RECONNECT_MS:i32 = 2000; + pub struct WebSocketService { connected: Rc, ws: Rc>>, @@ -30,6 +32,16 @@ impl WebSocketService { ws_path: concat_path_leading_slash(&base_href, "ws"), } } + /// Helper function to allow cloning the service into JS closures for reconnect + fn clone_for_reconnect(&self) -> Self { + Self { + connected: self.connected.clone(), + ws: self.ws.clone(), + status_service: self.status_service.clone(), + event_service: self.event_service.clone(), + ws_path: self.ws_path.clone(), + } + } pub fn connect_ws(&self) { if self.connected.load(Ordering::SeqCst) { @@ -102,31 +114,67 @@ impl WebSocketService { let ws_open_clone = Rc::clone(&ws_clone); let connected_clone = self.connected.clone(); + let event_service_clone = Rc::clone(&self.event_service); // onopen let onopen_callback = Closure::::wrap(Box::new(move |_event: Event| { trace!("WebSocket connection opened."); connected_clone.store(true, Ordering::SeqCst); Self::try_send_message(ws_open_clone.borrow().as_ref(), ProtocolMessage::Version(PROTOCOL_VERSION)); + event_service_clone.broadcast(EventMessage::WebSocketStatus(true)); })); socket.set_onopen(Some(onopen_callback.as_ref().unchecked_ref())); onopen_callback.forget(); let ws_close_rc = self.ws.clone(); let connected_clone = self.connected.clone(); + let ws_service_reconnect = Rc::new(self.clone_for_reconnect()); + let event_service_clone = Rc::clone(&self.event_service); let onclose_callback = Closure::::wrap(Box::new(move |e: CloseEvent| { debug!("Websocket closed, Code: {}, Reason: {}, WasClean: {}", e.code(), e.reason(), e.was_clean()); *ws_close_rc.borrow_mut() = None; connected_clone.store(false, Ordering::SeqCst); + event_service_clone.broadcast(EventMessage::WebSocketStatus(false)); + + // 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(); })); socket.set_onclose(Some(onclose_callback.as_ref().unchecked_ref())); onclose_callback.forget(); let connected_clone = self.connected.clone(); + let ws_service_reconnect = Rc::new(self.clone_for_reconnect()); + let event_service_clone = Rc::clone(&self.event_service); // onerror let onerror_callback = Closure::::wrap(Box::new(move |e: ErrorEvent| { error!("WebSocket error"); connected_clone.store(false, Ordering::SeqCst); + event_service_clone.broadcast(EventMessage::WebSocketStatus(false)); web_sys::console::error_1(&e); + + // 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(); })); socket.set_onerror(Some(onerror_callback.as_ref().unchecked_ref())); onerror_callback.forget();