Merge pull request #356 from euzu/feature/websocket_reconnect

Reconnect on WebSocket disconnect
This commit is contained in:
euzu
2025-09-24 12:47:15 +02:00
committed by GitHub
10 changed files with 108 additions and 5 deletions
+2 -1
View File
@@ -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
+4
View File
@@ -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"
}
]
+1
View File
@@ -71,3 +71,4 @@
@forward "components/api_user/api_user_view";
@forward "components/keyvalue_editor";
@forward "components/loading_screen";
@forward "components/websocket_status";
@@ -0,0 +1,7 @@
.tp__websocket-status {
fill: var(--warn-color);
.svg-icon {
width: 1.5rem;
height: 1.5rem;
}
}
@@ -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 {
}
</div>
<div class={"tp__app-header-toolbar"}>
<WebsocketStatus/>
<IconButton name="Theme" icon={if *theme == Theme::Bright {"Moon"} else {"Sun"}} onclick={handle_theme_switch} />
<IconButton name="Logout" icon="Logout" onclick={handle_logout} />
</div>
+2 -2
View File
@@ -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 {
}
</div>
<div class={"tp__app-header-toolbar"}>
<WebsocketStatus/>
<IconButton name="Theme" icon={if *theme == Theme::Bright {"Moon"} else {"Sun"}} onclick={handle_theme_switch} />
<IconButton name="Logout" icon="Logout" onclick={handle_logout} />
</div>
+3 -1
View File
@@ -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::*;
//pub use self::date_input::*;
@@ -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! {
<div class="tp__websocket-status">
<AppIcon name="WsDisconnected" />
</div>
}
}
+1
View File
@@ -13,4 +13,5 @@ pub enum EventMessage {
Busy(BusyStatus),
PlaylistUpdate(PlaylistUpdateState),
PlaylistUpdateProgress(String, String),
WebSocketStatus(bool),
}
@@ -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<AtomicBool>,
ws: Rc<RefCell<Option<WebSocket>>>,
@@ -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::<dyn FnMut(_)>::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::<dyn FnMut(_)>::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<dyn FnOnce()>);
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::<dyn FnMut(_)>::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<dyn FnOnce()>);
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();