diff --git a/.gitignore b/.gitignore index d60b130e5..9a6bc9140 100644 --- a/.gitignore +++ b/.gitignore @@ -55,5 +55,6 @@ STEPS.md CLAUDE.md CODEX.md .claude +.crush .dual-graph/ .dual-graph-context/ diff --git a/backend/src/api/main_api.rs b/backend/src/api/main_api.rs index 4fcbf22c3..175de5b33 100644 --- a/backend/src/api/main_api.rs +++ b/backend/src/api/main_api.rs @@ -364,9 +364,17 @@ fn create_cors_layer() -> tower_http::cors::CorsLayer { fn allow_response_compression( _status: axum::http::StatusCode, _version: axum::http::Version, - _headers: &axum::http::HeaderMap, + headers: &axum::http::HeaderMap, extensions: &axum::http::Extensions, ) -> bool { + if let Some(content_type) = headers.get(axum::http::header::CONTENT_TYPE) { + if let Ok(ct) = content_type.to_str() { + // Disable compression for wasm , WebKit browser dont like it. + if ct.starts_with("application/wasm") { + return false; + } + } + } crate::api::api_utils::should_compress_response_extensions(extensions) } diff --git a/backend/src/api/model/active_user_manager.rs b/backend/src/api/model/active_user_manager.rs index 7612b46c8..d91a24791 100644 --- a/backend/src/api/model/active_user_manager.rs +++ b/backend/src/api/model/active_user_manager.rs @@ -517,8 +517,18 @@ impl ActiveUserManager { user_connections .by_key .values() - .filter(|c| c.connections > 0) - .fold((0usize, 0usize), |(user_count, conn_count), c| (user_count + 1, conn_count + c.connections as usize)) + .filter_map(|c| { + // Preserved adaptive (HLS/DASH) streams keep the logical connection alive between + // segment requests so the web-UI entry is not cleared during the brief disconnect. + let preserved_adaptive = c + .streams + .iter() + .filter(|s| s.preserved && s.session_token.is_some() && s.channel.item_type.is_live_adaptive()) + .count(); + let effective = c.connections as usize + preserved_adaptive; + if effective > 0 { Some(effective) } else { None } + }) + .fold((0usize, 0usize), |(user_count, conn_count), effective| (user_count + 1, conn_count + effective)) } pub async fn update_stream_detail( @@ -1072,63 +1082,77 @@ impl ActiveUserManager { return; } - let mut expiry_index = self.adaptive_expiry_index.lock().await; - let mut user_connections = self.connections.write().await; - for entry in due_entries { - let key = AdaptiveExpiryKey { - username: entry.username.clone(), - session_token: entry.session_token.clone(), - uid: entry.uid, - }; - let Some(current_expires_at) = expiry_index.get(&key).copied() else { - continue; - }; - if current_expires_at != entry.expires_at { - continue; - } - - let mut remove_user = false; - if let Some(connection_data) = user_connections.by_key.get_mut(&entry.username) { - let stream_idx_opt = connection_data - .streams - .iter() - .position(|stream| { - stream.uid == entry.uid - && stream.preserved - && stream.session_token.as_deref() == Some(entry.session_token.as_str()) - }); - - if let Some(stream_idx) = stream_idx_opt { - let should_remove = self.is_preserved_stream_expired( - &connection_data.streams[stream_idx], - &connection_data.sessions, - now, - ); - - if should_remove { - connection_data.streams.swap_remove(stream_idx); - expiry_index.remove(&key); - } else if let Some(replacement_entry) = self.build_preserved_stream_expiry( - &entry.username, - &connection_data.streams[stream_idx], - &connection_data.sessions, - ) { - if replacement_entry.expires_at != current_expires_at { - expiry_index.insert(key.clone(), replacement_entry.expires_at); - self.adaptive_expiry_queue.lock().await.push(Reverse(replacement_entry)); - self.adaptive_expiry_notify.notify_one(); - } - } + let mut removed_addrs: Vec = Vec::new(); + { + let mut expiry_index = self.adaptive_expiry_index.lock().await; + let mut user_connections = self.connections.write().await; + for entry in due_entries { + let key = AdaptiveExpiryKey { + username: entry.username.clone(), + session_token: entry.session_token.clone(), + uid: entry.uid, + }; + let Some(current_expires_at) = expiry_index.get(&key).copied() else { + continue; + }; + if current_expires_at != entry.expires_at { + continue; } - remove_user = connection_data.connections == 0 - && connection_data.streams.is_empty() - && connection_data.sessions.is_empty(); - } + let mut remove_user = false; + if let Some(connection_data) = user_connections.by_key.get_mut(&entry.username) { + let stream_idx_opt = connection_data + .streams + .iter() + .position(|stream| { + stream.uid == entry.uid + && stream.preserved + && stream.session_token.as_deref() == Some(entry.session_token.as_str()) + }); - if remove_user { - user_connections.by_key.remove(&entry.username); + if let Some(stream_idx) = stream_idx_opt { + let should_remove = self.is_preserved_stream_expired( + &connection_data.streams[stream_idx], + &connection_data.sessions, + now, + ); + + if should_remove { + let addr = connection_data.streams[stream_idx].addr; + connection_data.streams.swap_remove(stream_idx); + expiry_index.remove(&key); + removed_addrs.push(addr); + } else if let Some(replacement_entry) = self.build_preserved_stream_expiry( + &entry.username, + &connection_data.streams[stream_idx], + &connection_data.sessions, + ) { + if replacement_entry.expires_at != current_expires_at { + expiry_index.insert(key.clone(), replacement_entry.expires_at); + self.adaptive_expiry_queue.lock().await.push(Reverse(replacement_entry)); + self.adaptive_expiry_notify.notify_one(); + } + } + } + + remove_user = connection_data.connections == 0 + && connection_data.streams.is_empty() + && connection_data.sessions.is_empty(); + } + + if remove_user { + user_connections.by_key.remove(&entry.username); + } } + } // locks released here + + let had_removals = !removed_addrs.is_empty(); + for addr in removed_addrs { + self.event_manager + .send_event(EventMessage::ActiveUser(ActiveUserConnectionChange::Disconnected(addr))); + } + if had_removals { + self.log_active_user().await; } } @@ -1671,8 +1695,11 @@ mod tests { let released = manager.release_stream(&addr).await; assert!(released.is_none(), "adaptive stream should remain logically preserved"); + // Preserved adaptive streams keep the logical connection alive between segments. + // The connections counter drops to 0, but active_users_and_connections counts the + // preserved adaptive stream, so the reported count must remain 1. let event = events.recv().await.unwrap(); - assert_eq!(event, EventMessage::ActiveUser(ActiveUserConnectionChange::Connections(0, 0))); + assert_eq!(event, EventMessage::ActiveUser(ActiveUserConnectionChange::Connections(1, 1))); } #[tokio::test] diff --git a/backend/src/api/setup_api.rs b/backend/src/api/setup_api.rs index 493e993e7..90bcd48c3 100644 --- a/backend/src/api/setup_api.rs +++ b/backend/src/api/setup_api.rs @@ -39,7 +39,7 @@ use std::{ }, }; use tokio::sync::{oneshot, Mutex, RwLock}; -use tower_http::services::ServeDir; +use tower_http::{compression::predicate::{DefaultPredicate, Predicate}, services::ServeDir}; use shared::utils::DEFAULT_CUSTOM_STREAM_RESPONSE_PATH; const DEFAULT_SETUP_HOST: &str = "0.0.0.0"; @@ -869,8 +869,29 @@ fn create_cors_layer() -> tower_http::cors::CorsLayer { .max_age(std::time::Duration::from_secs(3600)) } -fn create_compression_layer() -> tower_http::compression::CompressionLayer { - tower_http::compression::CompressionLayer::new().br(true).deflate(true).gzip(true).zstd(true) +fn create_compression_layer() -> tower_http::compression::CompressionLayer { + let predicate = DefaultPredicate::new().and(| + _status: axum::http::StatusCode, + _version: axum::http::Version, + headers: &axum::http::HeaderMap, + _extensions: &axum::http::Extensions, + | { + if let Some(content_type) = headers.get(axum::http::header::CONTENT_TYPE) { + if let Ok(ct) = content_type.to_str() { + // Disable compression for wasm , WebKit browser dont like it. + if ct.starts_with("application/wasm") { + return false; + } + } + } + true + }); + tower_http::compression::CompressionLayer::new() + .br(true) + .deflate(true) + .gzip(true) + .zstd(true) + .compress_when(predicate) } pub async fn start_setup_server(paths: &ConfigPaths, missing_files: &[String]) -> Result<(), TuliproxError> { diff --git a/backend/src/model/config/epg_smart_match.rs b/backend/src/model/config/epg_smart_match.rs index d15d22b99..cb493c680 100644 --- a/backend/src/model/config/epg_smart_match.rs +++ b/backend/src/model/config/epg_smart_match.rs @@ -1,7 +1,7 @@ use std::sync::Arc; -use shared::utils::CONSTANTS; use regex::Regex; use shared::model::{EpgNamePrefix, EpgSmartMatchConfigDto}; +use shared::utils::{default_epg_name_prefix_separator, default_epg_strip, CONSTANTS}; use crate::model::macros; #[derive(Debug, Clone)] @@ -28,15 +28,20 @@ impl From<&EpgSmartMatchConfigDto> for EpgSmartMatchConfig { }), None => CONSTANTS.re_epg_normalize.clone(), }, - strip: match &dto.strip { - Some(list) => list.iter().map(|s| s.to_lowercase()).collect(), - None => ["3840p", "uhd", "fhd", "hd", "sd", "4k", "plus", "raw", "full hd"].iter().map(std::string::ToString::to_string).collect(), - }, + strip: dto + .strip + .clone() + .or_else(default_epg_strip) + .unwrap_or_default() + .into_iter() + .map(|item| item.to_lowercase()) + .collect(), name_prefix: dto.name_prefix.clone(), - name_prefix_separator: match &dto.name_prefix_separator { - None => vec![':', '|', '-'], - Some(list) => list.clone(), - }, + name_prefix_separator: dto + .name_prefix_separator + .clone() + .or_else(default_epg_name_prefix_separator) + .unwrap_or_default(), fuzzy_matching: dto.fuzzy_matching, match_threshold: dto.match_threshold, best_match_threshold: dto.best_match_threshold, diff --git a/backend/src/processing/parser/hls.rs b/backend/src/processing/parser/hls.rs index edca58d8f..f5fe0b511 100644 --- a/backend/src/processing/parser/hls.rs +++ b/backend/src/processing/parser/hls.rs @@ -70,7 +70,7 @@ pub fn rewrite_hls_url<'a>(base: &'a str, reference: &'a str) -> Cow<'a, str> { base_url.join(reference).map_or_else(|_| Cow::Borrowed(reference), |u| Cow::Owned(u.to_string())) } -fn rewrite_uri_attrib<'a>(line: &'a str, props: &RewriteHlsProps) -> Cow<'a, str> { +fn rewrite_uri_attrib<'a>(line: &'a str, props: &RewriteHlsProps, user: &ProxyUserCredentials) -> Cow<'a, str> { let Some(caps) = CONSTANTS.re_hls_uri.captures(line) else { return Cow::Borrowed(line); }; @@ -78,16 +78,22 @@ fn rewrite_uri_attrib<'a>(line: &'a str, props: &RewriteHlsProps) -> Cow<'a, str let uri = &caps[1]; let rewritten = rewrite_hls_url(&props.hls_url, uri); - let final_uri = if let Some(user_token) = &props.user_token { - Cow::Owned(create_hls_session_token_and_url( - props.secret, - user_token, - &rewritten, - )) + let token = if let Some(user_token) = &props.user_token { + create_hls_session_token_and_url(props.secret, user_token, &rewritten) } else { - rewritten + create_hls_url_without_session_token(props.secret, &rewritten) }; + let final_uri = format!( + "{}/{HLS_PREFIX}/{}/{}/{}/{}/{}", + props.base_url, + user.username, + user.password, + props.input_id, + props.virtual_id, + token + ); + Cow::Owned(CONSTANTS .re_hls_uri .replace(line, format!(r#"URI="{final_uri}""#)) @@ -105,7 +111,7 @@ pub fn rewrite_hls(user: &ProxyUserCredentials, props: &RewriteHlsProps) -> Stri // skip comments if line.starts_with('#') { - let rewritten = rewrite_uri_attrib(line, props); + let rewritten = rewrite_uri_attrib(line, props, user); result.push(rewritten.to_string()); continue; } diff --git a/backend/src/processing/processor/playlist.rs b/backend/src/processing/processor/playlist.rs index ab52692a8..66c27eb8f 100644 --- a/backend/src/processing/processor/playlist.rs +++ b/backend/src/processing/processor/playlist.rs @@ -498,11 +498,8 @@ async fn playlist_download_from_input( let mut save_status = false; if hybrid { if needs_m3u_download { - input_cache::update_cluster_status( - &mut status, - "default", - if m3u_error_count == 0 { ClusterState::Ok } else { ClusterState::Failed }, - ); + let m3u_state = if m3u_error_count == 0 { ClusterState::Ok } else { ClusterState::Failed }; + input_cache::update_cluster_status(&mut status, "default", m3u_state); save_status = true; } @@ -528,6 +525,9 @@ async fn playlist_download_from_input( input_cache::update_cluster_status(&mut status, &cluster.to_string(), ClusterState::Failed); } save_status = !xtream_clusters_to_download.is_empty(); + } else { + input_cache::update_cluster_status(&mut status, "default", ClusterState::Failed); + save_status = true; } if save_status { @@ -687,6 +687,30 @@ async fn download_input_epg( tvguide } +/// `invalidate_input_cache_status` performs a non-atomic file I/O sequence +/// (`input_cache::load_input_status` + `input_cache::save_input_status`). +/// Call this only while holding the per-input lock from +/// `PlaylistProcessingContext::get_input_lock` (as done in `download_input`). +async fn invalidate_input_cache_status(ctx: &PlaylistProcessingContext, input: &ConfigInput) { + let storage_dir = { ctx.config.config.load().storage_dir.clone() }; + let storage_path = input_cache::resolve_input_storage_path(&storage_dir, &input.name).await; + let mut status = input_cache::load_input_status(&storage_path); + if !status.clusters.is_empty() { + status.clusters.clear(); + input_cache::save_input_status(&storage_path, &status); + } +} + +async fn load_cached_input_playlist( + ctx: &PlaylistProcessingContext, + input: &Arc, +) -> (Box, Option) { + match load_input_playlist(ctx, input, None).await { + Ok(pl_source) => (pl_source, None), + Err(err) => (MemoryPlaylistSource::default().boxed(), Some(err)), + } +} + async fn download_input( ctx: &PlaylistProcessingContext, input: &Arc, @@ -695,10 +719,10 @@ async fn download_input( let need_download = !ctx.is_input_downloaded(&input.name).await; // Keep this lock for the whole critical section (download + persist/load + mark processed) // so parallel sources sharing the same input cannot observe a half-written state. - let _input_lock = if need_download { Some(ctx.get_input_lock(&input.name).await) } else { None }; + let mut input_lock = if need_download { Some(ctx.get_input_lock(&input.name).await) } else { None }; let mut mark_as_processed = false; - let playlist_download_result = if need_download { + let mut playlist_download_result = if need_download { // Check again after lock let already_processed = ctx.is_input_downloaded(&input.name).await; @@ -718,7 +742,50 @@ async fn download_input( PlaylistDownloadResult::new(vec![], vec![], true, false) }; - let (playlist, error) = if playlist_download_result.was_cached || playlist_download_result.persisted { + let mut preloaded_playlist: Option<(Box, Option)> = None; + if playlist_download_result.was_cached { + let (cached_playlist, cached_error) = load_cached_input_playlist(ctx, input).await; + // Defensive fallback: if cache metadata says "valid" but persisted data is unreadable, + // retry once before forcing a refresh. + let must_force_refresh = cached_error.is_some(); + if must_force_refresh { + warn!( + "Input '{}' cache hit produced unreadable playlist; retrying cached load once", + input.name + ); + let (retry_playlist, retry_error) = load_cached_input_playlist(ctx, input).await; + if retry_error.is_none() { + preloaded_playlist = Some((retry_playlist, None)); + } else { + if input_lock.is_none() { + input_lock = Some(ctx.get_input_lock(&input.name).await); + } + // Re-check immediately after locking to avoid duplicate refreshes when another worker + // repaired the cache between our earlier retry and lock acquisition. + let (locked_retry_playlist, locked_retry_error) = load_cached_input_playlist(ctx, input).await; + if locked_retry_error.is_none() { + warn!( + "Input '{}' cache became readable after lock re-check; skipping refresh", + input.name + ); + preloaded_playlist = Some((locked_retry_playlist, None)); + } else { + warn!( + "Input '{}' cached playlist remained unreadable after retry and lock re-check; invalidating cache and forcing refresh", + input.name + ); + invalidate_input_cache_status(ctx, input).await; + playlist_download_result = playlist_download_from_input(&ctx.client, &ctx.config, input).await; + } + } + } else { + preloaded_playlist = Some((cached_playlist, None)); + } + } + + let (playlist, error) = if let Some(preloaded) = preloaded_playlist { + preloaded + } else if playlist_download_result.was_cached || playlist_download_result.persisted { match load_input_playlist(ctx, input, None).await { Ok(pl_source) => (pl_source, None), Err(e) => (MemoryPlaylistSource::default().boxed(), Some(e)), @@ -736,6 +803,9 @@ async fn download_input( ctx.mark_input_downloaded(input.name.clone()).await; } + // Explicitly release per-input lock after load/persist/mark steps are completed. + drop(input_lock); + (playlist_download_result.download_err, playlist, error) } diff --git a/backend/src/utils/network/xtream.rs b/backend/src/utils/network/xtream.rs index b69eba0a2..a32feee20 100644 --- a/backend/src/utils/network/xtream.rs +++ b/backend/src/utils/network/xtream.rs @@ -652,4 +652,22 @@ mod tests { assert!(staged_clusters.is_empty()); assert_eq!(main_clusters, vec![XtreamCluster::Video]); } + + #[test] + fn partition_routes_clusters_by_staged_xtream_cluster_sources() { + let staged = StagedInput { + enabled: true, + input_type: InputType::Xtream, + live_source: ClusterSource::Staged, + vod_source: ClusterSource::Input, + series_source: ClusterSource::Skip, + ..StagedInput::default() + }; + let input = test_input_with_staged(staged); + + let (staged_clusters, main_clusters) = partition_clusters_by_source(&input, None, &[]); + + assert_eq!(staged_clusters, vec![XtreamCluster::Live]); + assert_eq!(main_clusters, vec![XtreamCluster::Video]); + } } diff --git a/frontend/public/assets/i18n/en.json b/frontend/public/assets/i18n/en.json index fddc60661..0d5b4402a 100644 --- a/frontend/public/assets/i18n/en.json +++ b/frontend/public/assets/i18n/en.json @@ -341,9 +341,48 @@ "TEMPLATES": "Handlebars templates for Discord message bodies.", "URL": "Discord webhook URL for sending notification messages." }, + "BEST_MATCH_THRESHOLD": "High-confidence threshold (0-100). Matches above this value are accepted immediately as the best candidate.", "DNS_MAX_ADDRS": "Optional limit of resolved IP addresses kept per hostname. Leave empty for no explicit limit.", + "FUZZY_MATCHING": "Allows fuzzy similarity matching if exact normalized ID matching fails.", + "MATCH_THRESHOLD": "Minimum similarity score (0-100) required for a candidate EPG match.", + "NAME_PREFIX": "Defines how channel prefixes are treated during normalization (ignore/prefix/suffix).", + "NAME_PREFIX_SEPARATOR": "List of characters used to split prefixes from channel names before matching.", + "NAME_PREFIX_VALUE": "Prefix/suffix text used by the Name Prefix mode.", + "NORMALIZE_REGEX": "Optional regex used to normalize channel names before matching.", + "STRIP": "Terms to ignore during EPG Smart Matching (e.g. '4K', 'HD', 'HEVC').", "EPG_CONFIG": { + "BEST_MATCH_THRESHOLD": "High-confidence threshold (0-100). Matches above this value are accepted immediately as the best candidate.", + "ENABLED": "Enables EPG smart matching. If disabled, no normalization/fuzzy matching rules are applied.", + "FUZZY_MATCHING": "Allows fuzzy similarity matching if exact normalized ID matching fails.", + "MATCH_THRESHOLD": "Minimum similarity score (0-100) required for a candidate EPG match.", + "NAME_PREFIX": "Defines how channel prefixes are treated during normalization (ignore/prefix/suffix).", "NAME_PREFIX_SEPARATOR": "List of characters (e.g. ':', '|') that separate channel prefixes from the main title during Smart Match.", + "NAME_PREFIX_VALUE": "Prefix/suffix text that should be removed or appended according to Name Prefix mode.", + "NORMALIZE_REGEX": "Optional regex used to normalize channel names before matching.", + "STRIP": "Terms to ignore during EPG Smart Matching (e.g. '4K', 'HD', 'HEVC')." + }, + "EPG_SMART_MATCH_CONFIG": { + "BEST_MATCH_THRESHOLD": "High-confidence threshold (0-100). Matches above this value are accepted immediately as the best candidate.", + "ENABLED": "Enables EPG smart matching. If disabled, no normalization/fuzzy matching rules are applied.", + "FUZZY_MATCHING": "Allows fuzzy similarity matching if exact normalized ID matching fails.", + "MATCH_THRESHOLD": "Minimum similarity score (0-100) required for a candidate EPG match.", + "NAME_PREFIX": "Defines how channel prefixes are treated during normalization (ignore/prefix/suffix).", + "NAME_PREFIX_SEPARATOR": "List of characters used to split prefixes from channel names before matching.", + "NAME_PREFIX_VALUE": "Prefix/suffix text used by the Name Prefix mode.", + "NORMALIZE_REGEX": "Optional regex used to normalize channel names before matching.", + "STRIP": "Terms to ignore during EPG Smart Matching (e.g. '4K', 'HD', 'HEVC')." + }, + "EPG_SMART_MATCH_FORM": { + "NAME_PREFIX_MODE": "Defines how channel prefixes are treated during normalization (ignore/prefix/suffix)." + }, + "EPG_SMART_MATCH_FORM_DATA": { + "BEST_MATCH_THRESHOLD": "High-confidence threshold (0-100). Matches above this value are accepted immediately as the best candidate.", + "ENABLED": "Enables EPG smart matching. If disabled, no normalization/fuzzy matching rules are applied.", + "FUZZY_MATCHING": "Allows fuzzy similarity matching if exact normalized ID matching fails.", + "MATCH_THRESHOLD": "Minimum similarity score (0-100) required for a candidate EPG match.", + "NAME_PREFIX_SEPARATOR": "List of characters used to split prefixes from channel names before matching.", + "NAME_PREFIX_VALUE": "Prefix/suffix text used by the Name Prefix mode.", + "NORMALIZE_REGEX": "Optional regex used to normalize channel names before matching.", "STRIP": "Terms to ignore during EPG Smart Matching (e.g. '4K', 'HD', 'HEVC')." }, "EPG_SOURCE": { @@ -738,6 +777,7 @@ "ADD_PROVIDER": "Add Provider", "ADD_QUALITY_TO_FILENAME": "Quality", "ADD_SERVER": "Add Server", + "ADD_STRIP_ENTRY": "Add Strip Entry", "ADD_TRAKT_LIST": "Add a Trakt list", "ADD_URL": "Add Url", "ADD_WATCH": "Add Watch", @@ -846,6 +886,7 @@ "EMPTY": "", "ENABLED": "Enabled", "EPG": "Epg", + "EDIT_EPG_SMART_MATCH": "Edit EPG Smart Match", "EPG_LOGO_OVERRIDE": "Logo Override", "EPG_REQUEST_TIMESHIFT": "Epg Request Timeshift", "EPG_SMART_MATCH": "EPG Smart Match", @@ -982,6 +1023,7 @@ "MSG_KIND_WATCH": "Watch", "NAME": "Name", "NAME_PREFIX": "Name Prefix", + "NAME_PREFIX_VALUE": "Name Prefix Value", "NAME_PREFIX_SEPARATOR": "Name Prefix Separator", "NETWORK": "Network", "NEW_NAME": "New Name", diff --git a/frontend/scss/_default.scss b/frontend/scss/_default.scss index f7ecc2dd2..171d9de90 100644 --- a/frontend/scss/_default.scss +++ b/frontend/scss/_default.scss @@ -21,21 +21,23 @@ font-size: 1rem; } -::-webkit-scrollbar-track { - -webkit-box-shadow: var(--scrollbar-track-shadow); - border-radius: var(--scrollbar-border-radius); - background-color: var(--scrollbar-track-color); -} +@media (pointer: fine) { + ::-webkit-scrollbar-track { + -webkit-box-shadow: var(--scrollbar-track-shadow); + border-radius: var(--scrollbar-border-radius); + background-color: var(--scrollbar-track-color); + } -::-webkit-scrollbar { - width: var(--scrollbar-width); - height: var(--scrollbar-width); - background-color: transparent; -} + ::-webkit-scrollbar { + width: var(--scrollbar-width); + height: var(--scrollbar-width); + background-color: transparent; + } -::-webkit-scrollbar-thumb { - border-radius: var(--scrollbar-width); - background-color: var(--scrollbar-handle-color); + ::-webkit-scrollbar-thumb { + border-radius: var(--scrollbar-width); + background-color: var(--scrollbar-handle-color); + } } button { @@ -73,6 +75,7 @@ option { } .svg-icon { + display: block; pointer-events: none; width: 1rem; } @@ -141,3 +144,8 @@ li { } } } + +::selection { + background-color: #0078d4; + color: #ffffff; +} \ No newline at end of file diff --git a/frontend/scss/_theme.scss b/frontend/scss/_theme.scss index bacfefe37..157c9b73d 100644 --- a/frontend/scss/_theme.scss +++ b/frontend/scss/_theme.scss @@ -243,6 +243,7 @@ body { --theme-brand-positive-title: var(--theme-palette-success-tone-500); --theme-brand-warning: var(--theme-palette-warning-tone-420); + --theme-brand-warning-background: var(--theme-palette-warning-tone-background); --theme-brand-warning-strong: var(--theme-palette-warning-tone-520); --theme-brand-warning-base: var(--theme-palette-warning-tone-500); --theme-brand-danger: var(--theme-palette-danger-tone-520); @@ -558,6 +559,7 @@ body { --attention-color: var(--theme-brand-danger); --warn-color: var(--theme-brand-warning); + --warn-background-color: var(--theme-brand-warning-background); --active-color: var(--theme-brand-active); --ok-color: var(--theme-brand-positive); --output-xtream-color: var(--theme-brand-info); diff --git a/frontend/scss/app/_app.scss b/frontend/scss/app/_app.scss index 70212b877..c8b3fa397 100644 --- a/frontend/scss/app/_app.scss +++ b/frontend/scss/app/_app.scss @@ -43,6 +43,8 @@ overflow-y: auto; overflow-x: hidden; -webkit-overflow-scrolling: touch; + touch-action: pan-y; + overscroll-behavior: contain; } } diff --git a/frontend/scss/app/_app_header.scss b/frontend/scss/app/_app_header.scss index ab8267502..943b8eee6 100644 --- a/frontend/scss/app/_app_header.scss +++ b/frontend/scss/app/_app_header.scss @@ -9,9 +9,14 @@ $mobile-header-sidebar-offset: 3rem; height: var(--app-header-height); min-height: var(--app-header-height); max-height: var(--app-header-height); + overflow: hidden; background-color: var(--app-header-background-color); border-bottom: 1px solid var(--border-color); fill: var(--menu-icon-color); + -webkit-text-size-adjust: none; + text-size-adjust: none; + box-sizing: border-box; + .icon-AppTitle { width: 100px !important; } diff --git a/frontend/scss/app/components/_custom_dialog.scss b/frontend/scss/app/components/_custom_dialog.scss index 014898d72..9081a5571 100644 --- a/frontend/scss/app/components/_custom_dialog.scss +++ b/frontend/scss/app/components/_custom_dialog.scss @@ -14,6 +14,7 @@ } .tp__custom-dialog-modal { + padding-top: 15vh; padding-top: clamp(0vh, 15vh, 30vh); } diff --git a/frontend/scss/app/components/_popup_menu.scss b/frontend/scss/app/components/_popup_menu.scss index 918ba6346..f5a189283 100644 --- a/frontend/scss/app/components/_popup_menu.scss +++ b/frontend/scss/app/components/_popup_menu.scss @@ -4,7 +4,9 @@ position: fixed; top: var(--popup-top); left: var(--popup-left); + max-width: 24rem; max-width: min(24rem, calc(100vw - 1rem)); + max-height: 70vh; max-height: min(70vh, calc(100vh - 1rem)); padding: var(--padding-mini); border: 1px solid var(--border-color); @@ -22,6 +24,8 @@ padding: 0; max-height: inherit; overflow-y: auto; + -webkit-overflow-scrolling: touch; + touch-action: pan-y; overscroll-behavior: contain; list-style: none; } diff --git a/frontend/scss/app/components/_sidebar.scss b/frontend/scss/app/components/_sidebar.scss index 8a015b88a..d419289b3 100644 --- a/frontend/scss/app/components/_sidebar.scss +++ b/frontend/scss/app/components/_sidebar.scss @@ -51,6 +51,7 @@ gap: var(--gap-default); min-height: 0; height: 100vh; + height: 100dvh; overflow: hidden; &__scroll { @@ -58,6 +59,9 @@ min-height: 0; overflow-y: auto; overflow-x: hidden; + -webkit-overflow-scrolling: touch; + touch-action: pan-y; + overscroll-behavior: contain; } .tp__menu-item { diff --git a/frontend/scss/app/components/config/_panel_api_config.scss b/frontend/scss/app/components/config/_panel_api_config.scss index a4768a6c9..728acb3e9 100644 --- a/frontend/scss/app/components/config/_panel_api_config.scss +++ b/frontend/scss/app/components/config/_panel_api_config.scss @@ -43,7 +43,7 @@ &__credits-chip { color: var(--warn-color); border-color: var(--warn-color); - background: color-mix(in srgb, var(--warn-color) 18%, transparent); + background: var(--warn-background-color); } &__toggle { diff --git a/frontend/scss/app/components/dashboard/_streams_view.scss b/frontend/scss/app/components/dashboard/_streams_view.scss index 6ff11c0d6..d803dee36 100644 --- a/frontend/scss/app/components/dashboard/_streams_view.scss +++ b/frontend/scss/app/components/dashboard/_streams_view.scss @@ -99,6 +99,8 @@ overflow-y: auto; overflow-x: hidden; -webkit-overflow-scrolling: touch; + touch-action: pan-y; + overscroll-behavior: contain; padding-right: var(--padding-small); } @@ -228,7 +230,6 @@ height: 0.75rem; overflow: hidden; border-radius: 2px; - box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--card-border-color) 65%, transparent); svg { display: block; @@ -253,18 +254,21 @@ border: 1px solid var(--card-border-color); } - &__stat-label, - &__detail-label { + &__stat-label { color: var(--tertiary-title-text-color); font-size: 0.72rem; font-weight: 700; text-transform: uppercase; } - &__stat-value, - &__detail-value { + &__stat-value { font-weight: 600; min-width: 0; + + .tp__toggle-switch { + height: 1rem; + max-height: 1rem; + } } &__stat-value--ip { @@ -274,9 +278,6 @@ line-height: 1.3; } - &__detail { - min-width: 0; - } &__duration { letter-spacing: 0.12em; diff --git a/frontend/scss/app/components/playlist/_epg_view.scss b/frontend/scss/app/components/playlist/_epg_view.scss index 410b7fb6f..f31d20bc7 100644 --- a/frontend/scss/app/components/playlist/_epg_view.scss +++ b/frontend/scss/app/components/playlist/_epg_view.scss @@ -19,6 +19,9 @@ $epg-border-size: 4px; overflow-y: auto; position: relative; min-height: 0; + -webkit-overflow-scrolling: touch; + touch-action: pan-x pan-y; + overscroll-behavior: contain; } &__channels-header, diff --git a/frontend/scss/app/components/playlist/_playlist_explorer.scss b/frontend/scss/app/components/playlist/_playlist_explorer.scss index 57a9900e2..9257fa5ee 100644 --- a/frontend/scss/app/components/playlist/_playlist_explorer.scss +++ b/frontend/scss/app/components/playlist/_playlist_explorer.scss @@ -118,6 +118,9 @@ $series-backdrop-mobile-height: 150px; gap: var(--gap-default); box-sizing: border-box; overflow-y: auto; + -webkit-overflow-scrolling: touch; + touch-action: pan-y; + overscroll-behavior: contain; } &-list { diff --git a/frontend/scss/app/components/playlist/_playlist_update_view.scss b/frontend/scss/app/components/playlist/_playlist_update_view.scss index 32938ff4f..f7025d78c 100644 --- a/frontend/scss/app/components/playlist/_playlist_update_view.scss +++ b/frontend/scss/app/components/playlist/_playlist_update_view.scss @@ -23,5 +23,8 @@ box-sizing: border-box; overflow-y: auto; width: 100%; + -webkit-overflow-scrolling: touch; + touch-action: pan-y; + overscroll-behavior: contain; } } \ No newline at end of file diff --git a/frontend/scss/app/components/source_editor/_source_editor.scss b/frontend/scss/app/components/source_editor/_source_editor.scss index f1ef25c48..cd868bf16 100644 --- a/frontend/scss/app/components/source_editor/_source_editor.scss +++ b/frontend/scss/app/components/source_editor/_source_editor.scss @@ -37,6 +37,7 @@ flex-flow: column; box-sizing: border-box; height: 100%; + min-height: 0; gap: var(--gap-small); transition: min-width 0.2s ease, max-width 0.2s ease, width 0.2s ease; @@ -65,7 +66,15 @@ display: flex; flex-flow: column; box-sizing: border-box; + flex: 1 1 auto; + min-height: 0; gap: var(--gap-large); + overflow-y: auto; + overflow-x: hidden; + + .tp__collapse-panel { + flex-shrink: 0; + } &-group { display: flex; @@ -104,7 +113,7 @@ padding: var(--padding-micro) var(--padding-small); border: 1px solid var(--border-color); border-radius: var(--border-radius); - background-color: color-mix(in srgb, var(--card-background-color) 86%, transparent); + background-color: var(--card-background-color); color: var(--text-color); font-size: 0.85rem; text-align: center; @@ -167,6 +176,8 @@ &-bricks { overflow-y: auto; + -webkit-overflow-scrolling: touch; + touch-action: pan-y; .tp__source-editor__brick { padding: var(--padding-small) var(--padding-mini); diff --git a/frontend/scss/themes/_theme-aurora.scss b/frontend/scss/themes/_theme-aurora.scss index 47aaa3df7..a9080307e 100644 --- a/frontend/scss/themes/_theme-aurora.scss +++ b/frontend/scss/themes/_theme-aurora.scss @@ -137,6 +137,7 @@ body[data-theme="aurora"] { --theme-palette-warning-tone-500: #f59e0b; --theme-palette-warning-tone-460: #ffd166; --theme-palette-warning-tone-420: #f4b942; + --theme-palette-warning-tone-background: #f4b94244; --theme-palette-warning-tone-surface: #4b371d88; --theme-palette-warning-tone-border: #c28f38; diff --git a/frontend/scss/themes/_theme-banana-yellow.scss b/frontend/scss/themes/_theme-banana-yellow.scss index 598bb184a..f9c8ec1db 100644 --- a/frontend/scss/themes/_theme-banana-yellow.scss +++ b/frontend/scss/themes/_theme-banana-yellow.scss @@ -147,6 +147,7 @@ body[data-theme="banana-yellow"] { --theme-palette-warning-tone-500: #d09126; --theme-palette-warning-tone-460: #e5bb76; --theme-palette-warning-tone-420: #b88746; + --theme-palette-warning-tone-background: #b8874644; --theme-palette-warning-tone-surface: #fff8d8; --theme-palette-warning-tone-border: #efcf9d; diff --git a/frontend/scss/themes/_theme-bright.scss b/frontend/scss/themes/_theme-bright.scss index c5872ceb9..f8c903a11 100644 --- a/frontend/scss/themes/_theme-bright.scss +++ b/frontend/scss/themes/_theme-bright.scss @@ -147,6 +147,7 @@ body[data-theme="bright"] { --theme-palette-warning-tone-500: #f59e0b; --theme-palette-warning-tone-460: #fbbf24; --theme-palette-warning-tone-420: #eab308; + --theme-palette-warning-tone-background: #eab30844; --theme-palette-warning-tone-surface: #fffbeb; --theme-palette-warning-tone-border: #fde68a; diff --git a/frontend/scss/themes/_theme-clubroom-contrast.scss b/frontend/scss/themes/_theme-clubroom-contrast.scss index 0f890a1c2..f63738906 100644 --- a/frontend/scss/themes/_theme-clubroom-contrast.scss +++ b/frontend/scss/themes/_theme-clubroom-contrast.scss @@ -137,6 +137,7 @@ body[data-theme="clubroom-contrast"] { --theme-palette-warning-tone-500: #c18a42; --theme-palette-warning-tone-460: #ffd98e; --theme-palette-warning-tone-420: #ffc15e; + --theme-palette-warning-tone-background: #ffc15e44; --theme-palette-warning-tone-surface: #4f392088; --theme-palette-warning-tone-border: #b88746; diff --git a/frontend/scss/themes/_theme-cool-elegance.scss b/frontend/scss/themes/_theme-cool-elegance.scss index b5808ffe7..2193c0472 100644 --- a/frontend/scss/themes/_theme-cool-elegance.scss +++ b/frontend/scss/themes/_theme-cool-elegance.scss @@ -147,6 +147,7 @@ body[data-theme="cool-elegance"] { --theme-palette-warning-tone-500: #b99563; --theme-palette-warning-tone-460: #e5bb76; --theme-palette-warning-tone-420: #b88746; + --theme-palette-warning-tone-background: #b8874644; --theme-palette-warning-tone-surface: #f7efe1; --theme-palette-warning-tone-border: #efcf9d; diff --git a/frontend/scss/themes/_theme-dark.scss b/frontend/scss/themes/_theme-dark.scss index a274bff1d..5e5026d51 100644 --- a/frontend/scss/themes/_theme-dark.scss +++ b/frontend/scss/themes/_theme-dark.scss @@ -137,6 +137,7 @@ body[data-theme="dark"] { --theme-palette-warning-tone-500: #f39c12; --theme-palette-warning-tone-460: #ffc078; --theme-palette-warning-tone-420: #e1b842; + --theme-palette-warning-tone-background: #e1b84244; --theme-palette-warning-tone-surface: #39332088; --theme-palette-warning-tone-border: #b69e5c; diff --git a/frontend/scss/themes/_theme-dopamine.scss b/frontend/scss/themes/_theme-dopamine.scss index 2af6e1136..43154bc87 100644 --- a/frontend/scss/themes/_theme-dopamine.scss +++ b/frontend/scss/themes/_theme-dopamine.scss @@ -137,6 +137,7 @@ body[data-theme="dopamine"] { --theme-palette-warning-tone-500: #ff9f1c; --theme-palette-warning-tone-460: #ffd98e; --theme-palette-warning-tone-420: #ffc15e; + --theme-palette-warning-tone-background: #ffc15e44; --theme-palette-warning-tone-surface: #4f392088; --theme-palette-warning-tone-border: #b88746; diff --git a/frontend/scss/themes/_theme-dracula.scss b/frontend/scss/themes/_theme-dracula.scss index dcc0531b2..011c022fe 100644 --- a/frontend/scss/themes/_theme-dracula.scss +++ b/frontend/scss/themes/_theme-dracula.scss @@ -137,6 +137,7 @@ body[data-theme="dracula"] { --theme-palette-warning-tone-500: #f1fa8c; --theme-palette-warning-tone-460: #fff0a6; --theme-palette-warning-tone-420: #ffe97a; + --theme-palette-warning-tone-background: #ffe97a44; --theme-palette-warning-tone-surface: #4b492688; --theme-palette-warning-tone-border: #c9d36f; diff --git a/frontend/scss/themes/_theme-graperoot-dark.scss b/frontend/scss/themes/_theme-graperoot-dark.scss index a0f4b680b..ce3c48b66 100644 --- a/frontend/scss/themes/_theme-graperoot-dark.scss +++ b/frontend/scss/themes/_theme-graperoot-dark.scss @@ -137,6 +137,7 @@ body[data-theme="graperoot-dark"] { --theme-palette-warning-tone-500: #eab308; --theme-palette-warning-tone-460: #fde68a; --theme-palette-warning-tone-420: #f4d35e; + --theme-palette-warning-tone-background: #f4d35e44; --theme-palette-warning-tone-surface: #43391588; --theme-palette-warning-tone-border: #baa24f; diff --git a/frontend/scss/themes/_theme-mermaidcore.scss b/frontend/scss/themes/_theme-mermaidcore.scss index a1f6cd379..2b41ef801 100644 --- a/frontend/scss/themes/_theme-mermaidcore.scss +++ b/frontend/scss/themes/_theme-mermaidcore.scss @@ -137,6 +137,7 @@ body[data-theme="mermaidcore"] { --theme-palette-warning-tone-500: #ffc887; --theme-palette-warning-tone-460: #ffd166; --theme-palette-warning-tone-420: #f4b942; + --theme-palette-warning-tone-background: #f4b94244; --theme-palette-warning-tone-surface: #4b371d88; --theme-palette-warning-tone-border: #c28f38; diff --git a/frontend/scss/themes/_theme-monokai.scss b/frontend/scss/themes/_theme-monokai.scss index 46a2d0eda..e63c5f368 100644 --- a/frontend/scss/themes/_theme-monokai.scss +++ b/frontend/scss/themes/_theme-monokai.scss @@ -137,6 +137,7 @@ body[data-theme="monokai"] { --theme-palette-warning-tone-500: #ffb86c; --theme-palette-warning-tone-460: #ffd98e; --theme-palette-warning-tone-420: #ffc15e; + --theme-palette-warning-tone-background: #ffc15e44; --theme-palette-warning-tone-surface: #4f392088; --theme-palette-warning-tone-border: #b88746; diff --git a/frontend/scss/themes/_theme-nature-pure.scss b/frontend/scss/themes/_theme-nature-pure.scss index 6a6820e27..587e7da3c 100644 --- a/frontend/scss/themes/_theme-nature-pure.scss +++ b/frontend/scss/themes/_theme-nature-pure.scss @@ -147,6 +147,7 @@ body[data-theme="nature-pure"] { --theme-palette-warning-tone-500: #ba7a3a; --theme-palette-warning-tone-460: #e5bb76; --theme-palette-warning-tone-420: #b88746; + --theme-palette-warning-tone-background: #b8874644; --theme-palette-warning-tone-surface: #f7ecd9; --theme-palette-warning-tone-border: #efcf9d; diff --git a/frontend/scss/themes/_theme-nord.scss b/frontend/scss/themes/_theme-nord.scss index d39a1bb2c..5391f51ce 100644 --- a/frontend/scss/themes/_theme-nord.scss +++ b/frontend/scss/themes/_theme-nord.scss @@ -138,6 +138,7 @@ body[data-theme="nord"] { --theme-palette-warning-tone-500: #d8b66f; --theme-palette-warning-tone-460: #ebcb8b; --theme-palette-warning-tone-420: #d8b66f; + --theme-palette-warning-tone-background: #d8b66f44; --theme-palette-warning-tone-surface: #4c566a88; --theme-palette-warning-tone-border: #d8b66f; diff --git a/frontend/scss/themes/_theme-paper.scss b/frontend/scss/themes/_theme-paper.scss index b79e9fa10..2d13c95eb 100644 --- a/frontend/scss/themes/_theme-paper.scss +++ b/frontend/scss/themes/_theme-paper.scss @@ -147,6 +147,7 @@ body[data-theme="paper"] { --theme-palette-warning-tone-500: #c0822f; --theme-palette-warning-tone-460: #e5bb76; --theme-palette-warning-tone-420: #b88746; + --theme-palette-warning-tone-background: #b8874644; --theme-palette-warning-tone-surface: #fff3e0; --theme-palette-warning-tone-border: #efcf9d; diff --git a/frontend/scss/themes/_theme-refined-dark.scss b/frontend/scss/themes/_theme-refined-dark.scss index a919ce117..b5068e86a 100644 --- a/frontend/scss/themes/_theme-refined-dark.scss +++ b/frontend/scss/themes/_theme-refined-dark.scss @@ -137,6 +137,7 @@ body[data-theme="refined-dark"] { --theme-palette-warning-tone-500: #f59e0b; --theme-palette-warning-tone-460: #ffc078; --theme-palette-warning-tone-420: #f6c453; + --theme-palette-warning-tone-background: #f6c45344; --theme-palette-warning-tone-surface: #42341888; --theme-palette-warning-tone-border: #c49b4a; diff --git a/frontend/scss/themes/_theme-sun-washed-soft.scss b/frontend/scss/themes/_theme-sun-washed-soft.scss index ca01066cb..c67cb7b47 100644 --- a/frontend/scss/themes/_theme-sun-washed-soft.scss +++ b/frontend/scss/themes/_theme-sun-washed-soft.scss @@ -147,6 +147,7 @@ body[data-theme="sun-washed-soft"] { --theme-palette-warning-tone-500: #d6a474; --theme-palette-warning-tone-460: #e5bb76; --theme-palette-warning-tone-420: #b88746; + --theme-palette-warning-tone-background: #b8874644; --theme-palette-warning-tone-surface: #fff1e8; --theme-palette-warning-tone-border: #efcf9d; diff --git a/frontend/scss/themes/_theme-vibrant-elegance.scss b/frontend/scss/themes/_theme-vibrant-elegance.scss index c84ba9e72..b0e6a4fc6 100644 --- a/frontend/scss/themes/_theme-vibrant-elegance.scss +++ b/frontend/scss/themes/_theme-vibrant-elegance.scss @@ -137,6 +137,7 @@ body[data-theme="vibrant-elegance"] { --theme-palette-warning-tone-500: #e9b949; --theme-palette-warning-tone-460: #ffd166; --theme-palette-warning-tone-420: #f4b942; + --theme-palette-warning-tone-background: #f4b94244; --theme-palette-warning-tone-surface: #4b371d88; --theme-palette-warning-tone-border: #c28f38; diff --git a/frontend/scss/themes/_theme-vintage-neutral.scss b/frontend/scss/themes/_theme-vintage-neutral.scss index 8a26a462d..817489fbc 100644 --- a/frontend/scss/themes/_theme-vintage-neutral.scss +++ b/frontend/scss/themes/_theme-vintage-neutral.scss @@ -147,6 +147,7 @@ body[data-theme="vintage-neutral"] { --theme-palette-warning-tone-500: #b28b68; --theme-palette-warning-tone-460: #e5bb76; --theme-palette-warning-tone-420: #b88746; + --theme-palette-warning-tone-background: #b8874644; --theme-palette-warning-tone-surface: #f7efdf; --theme-palette-warning-tone-border: #efcf9d; diff --git a/frontend/src/app/components/dashboard/stream_display/item.rs b/frontend/src/app/components/dashboard/stream_display/item.rs index a4ecf9178..b9515b4f8 100644 --- a/frontend/src/app/components/dashboard/stream_display/item.rs +++ b/frontend/src/app/components/dashboard/stream_display/item.rs @@ -73,7 +73,9 @@ pub fn StreamDisplayItem(props: &StreamDisplayItemProps) -> Html { -
+
+
+
{translate.t("LABEL.GROUP")} {stream.channel.group.to_string()} diff --git a/frontend/src/app/components/source_editor/block_view.rs b/frontend/src/app/components/source_editor/block_view.rs index bf966b445..85dff51d4 100644 --- a/frontend/src/app/components/source_editor/block_view.rs +++ b/frontend/src/app/components/source_editor/block_view.rs @@ -4,7 +4,11 @@ use crate::{ i18n::use_translation, }; use web_sys::{HtmlElement, MouseEvent, TouchEvent}; -use yew::{classes, component, html, use_effect_with, use_node_ref, Callback, Html, Properties, TargetCast}; +use yew::{ + classes, component, html, use_effect_with, use_mut_ref, use_node_ref, Callback, Html, Properties, TargetCast, +}; + +const DOUBLE_TAP_THRESHOLD_MS: f64 = 320.0; #[derive(Properties, PartialEq)] pub struct BlockProps { pub(crate) block: Block, @@ -68,7 +72,6 @@ pub fn BlockView(props: &BlockProps) -> Html { let handle_touch_start = { let on_block_touch_start = props.on_touch_start.clone(); Callback::from(move |e: TouchEvent| { - e.prevent_default(); if let Some(target) = e.target_dyn_into::() { let tag = target.tag_name().to_lowercase(); if &tag == "span" { @@ -79,6 +82,34 @@ pub fn BlockView(props: &BlockProps) -> Html { on_block_touch_start.emit((block_id, e)) }) }; + let last_touch_end_ts = use_mut_ref(|| None::); + let handle_touch_end = { + let on_edit = props.on_edit.clone(); + let last_touch_end_ts = last_touch_end_ts.clone(); + Callback::from(move |e: TouchEvent| { + if let Some(target) = e.target_dyn_into::() { + let tag = target.tag_name().to_lowercase(); + if &tag == "span" { + return; + } + } + + let mut last_touch_end_ts = last_touch_end_ts.borrow_mut(); + if e.touches().length() > 0 || e.changed_touches().length() != 1 { + *last_touch_end_ts = None; + return; + } + let now = web_sys::js_sys::Date::now(); + if let Some(prev) = *last_touch_end_ts { + if now - prev <= DOUBLE_TAP_THRESHOLD_MS { + *last_touch_end_ts = None; + on_edit.emit(block_id); + return; + } + } + *last_touch_end_ts = Some(now); + }) + }; { let block_ref = block_ref.clone(); let position = block.position; @@ -130,7 +161,7 @@ pub fn BlockView(props: &BlockProps) -> Html { }) }
-
+
{ title } diff --git a/frontend/src/app/components/source_editor/editor_view.rs b/frontend/src/app/components/source_editor/editor_view.rs index d8deb55ab..13d859fad 100644 --- a/frontend/src/app/components/source_editor/editor_view.rs +++ b/frontend/src/app/components/source_editor/editor_view.rs @@ -1051,7 +1051,6 @@ pub fn SourceEditor(props: &SourceEditorProps) -> Html { if !can_write_sources { return; } - e.prevent_default(); e.stop_propagation(); if editor_state_ref.borrow().pending_line.is_some() { @@ -1201,13 +1200,27 @@ pub fn SourceEditor(props: &SourceEditorProps) -> Html { move_connections.insert((conn.from, conn.to), (conn.from, conn.to)); } } + let update_delete_circles = !editor_state.is_panning; + let document = if update_delete_circles { window().and_then(|w| w.document()) } else { None }; for (from, to) in move_connections.values() { if let Some(path_el) = editor_state.connection_elements.get(&(*from, *to)) { if let (Some(from_block), Some(to_block)) = (&editor_state.get_block(*from), &editor_state.get_block(*to)) { - let (d, _) = update_connection(canvas_ox, canvas_oy, zoom_factor, from_block, to_block); + let (d, (fx, fy, tx, ty)) = + update_connection(canvas_ox, canvas_oy, zoom_factor, from_block, to_block); let _ = path_el.set_attribute("d", &d); + if update_delete_circles { + if let Some(doc) = &document { + if let Some(circle_el) = doc.get_element_by_id(&format!("conn-del-{}-{}", from, to)) + { + let mid_x = (fx + tx) / 2.0; + let mid_y = (fy + ty) / 2.0; + let _ = circle_el.set_attribute("cx", &mid_x.to_string()); + let _ = circle_el.set_attribute("cy", &mid_y.to_string()); + } + } + } } } } @@ -1412,7 +1425,6 @@ pub fn SourceEditor(props: &SourceEditorProps) -> Html { Callback::from(move |e: TouchEvent| { if e.touches().length() == 2 { if let (Some(first), Some(second)) = (e.touches().item(0), e.touches().item(1)) { - e.prevent_default(); e.stop_propagation(); let dx = second.client_x() as f32 - first.client_x() as f32; let dy = second.client_y() as f32 - first.client_y() as f32; @@ -1432,7 +1444,6 @@ pub fn SourceEditor(props: &SourceEditorProps) -> Html { let tag = target.tag_name().to_lowercase(); if target.is_same_node(Some(&canvas)) || tag == "svg" { if let Some(touch) = e.touches().item(0) { - e.prevent_default(); e.stop_propagation(); let mut editor_state = editor_state_ref.borrow_mut(); editor_state.selection.reset_selection(); @@ -1465,7 +1476,6 @@ pub fn SourceEditor(props: &SourceEditorProps) -> Html { if let (Some(first), Some(second), Some(canvas)) = (e.touches().item(0), e.touches().item(1), canvas_ref.cast::()) { - e.prevent_default(); e.stop_propagation(); let rect = canvas.get_bounding_client_rect(); let mid_x = ((first.client_x() + second.client_x()) as f32 / 2.0) - rect.left() as f32; @@ -1502,7 +1512,6 @@ pub fn SourceEditor(props: &SourceEditorProps) -> Html { ) }; if is_panning { - e.prevent_default(); e.stop_propagation(); let initial_positions: Vec<(BlockId, Position)> = @@ -1520,7 +1529,6 @@ pub fn SourceEditor(props: &SourceEditorProps) -> Html { move_blocks.emit((0.0, 0.0, (0.0, 0.0), initial_positions)); } else if let Some(block_id) = drag_block_id { - e.prevent_default(); e.stop_propagation(); if let Some(canvas) = canvas_ref.cast::() { @@ -1956,12 +1964,12 @@ pub fn SourceEditor(props: &SourceEditorProps) -> Html { { - { if *delete_mode { + { if *delete_mode && !editor_state.is_panning { let mid_x = (from_x + to_x) / 2.0; let mid_y = (from_y + to_y) / 2.0; let on_delete_connection = handle_delete_connection.clone(); html! { - , + pub strip: Option>, + pub name_prefix_mode: String, + pub name_prefix_value: Option, + pub name_prefix_separator: Option, + pub fuzzy_matching: bool, + pub match_threshold: u16, + pub best_match_threshold: u16, +} + +impl Default for EpgSmartMatchFormData { + fn default() -> Self { Self::from(EpgSmartMatchConfigDto::default()) } +} + +impl From for EpgSmartMatchFormData { + fn from(value: EpgSmartMatchConfigDto) -> Self { + let (name_prefix_mode, name_prefix_value) = match value.name_prefix { + EpgNamePrefix::Ignore => (NAME_PREFIX_MODE_IGNORE.to_string(), None), + EpgNamePrefix::Suffix(v) => (NAME_PREFIX_MODE_SUFFIX.to_string(), Some(v)), + EpgNamePrefix::Prefix(v) => (NAME_PREFIX_MODE_PREFIX.to_string(), Some(v)), + }; + let separator = value + .name_prefix_separator + .as_ref() + .map(|chars| chars.iter().map(char::to_string).collect::>().join(",")); + Self { + enabled: value.enabled, + normalize_regex: value.normalize_regex, + strip: value.strip, + name_prefix_mode, + name_prefix_value, + name_prefix_separator: separator, + fuzzy_matching: value.fuzzy_matching, + match_threshold: value.match_threshold, + best_match_threshold: value.best_match_threshold, + } + } +} + +impl From<&EpgSmartMatchFormData> for EpgSmartMatchConfigDto { + fn from(value: &EpgSmartMatchFormData) -> Self { + let prefix_value = value.name_prefix_value.clone().map(|v| v.trim().to_string()).filter(|v| !v.is_empty()); + let name_prefix = match value.name_prefix_mode.as_str() { + NAME_PREFIX_MODE_SUFFIX => EpgNamePrefix::Suffix(prefix_value.unwrap_or_default()), + NAME_PREFIX_MODE_PREFIX => EpgNamePrefix::Prefix(prefix_value.unwrap_or_default()), + _ => EpgNamePrefix::Ignore, + }; + let name_prefix_separator = value.name_prefix_separator.as_ref().and_then(|separator| { + let chars = separator.split(',').filter_map(|part| part.trim().chars().next()).collect::>(); + if chars.is_empty() { + None + } else { + Some(chars) + } + }); + EpgSmartMatchConfigDto { + enabled: value.enabled, + normalize_regex: value.normalize_regex.clone().map(|v| v.trim().to_string()).filter(|v| !v.is_empty()), + strip: value.strip.clone(), + name_prefix, + name_prefix_separator, + fuzzy_matching: value.fuzzy_matching, + match_threshold: value.match_threshold, + best_match_threshold: value.best_match_threshold, + } + } +} + +generate_form_reducer!( + state: EpgSmartMatchFormState { form: EpgSmartMatchFormData }, + action_name: EpgSmartMatchFormAction, + fields { + Enabled => enabled: bool, + NormalizeRegex => normalize_regex: Option, + Strip => strip: Option>, + NamePrefixMode => name_prefix_mode: String, + NamePrefixValue => name_prefix_value: Option, + NamePrefixSeparator => name_prefix_separator: Option, + FuzzyMatching => fuzzy_matching: bool, + MatchThreshold => match_threshold: u16, + BestMatchThreshold => best_match_threshold: u16, + } +); + +#[derive(Properties, PartialEq, Clone)] +pub struct EpgSmartMatchFormProps { + pub on_submit: Callback, + pub on_cancel: Callback<()>, + #[prop_or_default] + pub initial: Option, + #[prop_or(false)] + pub readonly: bool, +} + +#[component] +pub fn EpgSmartMatchForm(props: &EpgSmartMatchFormProps) -> Html { + let translate = use_translation(); + + let initial = props.initial.clone().unwrap_or_default(); + + let form_state: UseReducerHandle = + use_reducer(|| EpgSmartMatchFormState { form: EpgSmartMatchFormData::from(initial), modified: false }); + + let name_prefix_options = use_memo(form_state.form.name_prefix_mode.clone(), |selected_mode| { + vec![ + DropDownOption::new(NAME_PREFIX_MODE_IGNORE, html! { "Ignore" }, selected_mode == NAME_PREFIX_MODE_IGNORE), + DropDownOption::new(NAME_PREFIX_MODE_SUFFIX, html! { "Suffix" }, selected_mode == NAME_PREFIX_MODE_SUFFIX), + DropDownOption::new(NAME_PREFIX_MODE_PREFIX, html! { "Prefix" }, selected_mode == NAME_PREFIX_MODE_PREFIX), + ] + }); + + let handle_submit = { + let form_state = form_state.clone(); + let on_submit = props.on_submit.clone(); + Callback::from(move |_| { + on_submit.emit(EpgSmartMatchConfigDto::from(&form_state.form)); + }) + }; + + let handle_cancel = { + let on_cancel = props.on_cancel.clone(); + Callback::from(move |_| on_cancel.emit(())) + }; + + html! { + + if props.readonly { + { config_field_bool!(form_state.form, translate.t(LABEL_ENABLED), enabled) } + { config_field_bool!(form_state.form, translate.t(LABEL_FUZZY_MATCHING), fuzzy_matching) } + { config_field_custom!(translate.t(LABEL_MATCH_THRESHOLD), form_state.form.match_threshold.to_string()) } + { config_field_custom!(translate.t(LABEL_BEST_MATCH_THRESHOLD), form_state.form.best_match_threshold.to_string()) } + { config_field_optional!(form_state.form, translate.t(LABEL_NORMALIZE_REGEX), normalize_regex) } + { config_field_custom!( + translate.t(LABEL_STRIP), + form_state.form.strip.as_ref().map(|values| values.join(", ")).unwrap_or_default() + ) } + { config_field_custom!( + translate.t(LABEL_NAME_PREFIX), + EpgSmartMatchConfigDto::from(&form_state.form).name_prefix.to_string() + ) } + { config_field_optional!(form_state.form, translate.t(LABEL_NAME_PREFIX_SEPARATOR), name_prefix_separator) } + } else { + { edit_field_bool!(form_state, translate.t(LABEL_ENABLED), enabled, EpgSmartMatchFormAction::Enabled) } + { edit_field_bool!(form_state, translate.t(LABEL_FUZZY_MATCHING), fuzzy_matching, EpgSmartMatchFormAction::FuzzyMatching) } + { edit_field_number_u16!(form_state, translate.t(LABEL_MATCH_THRESHOLD), match_threshold, EpgSmartMatchFormAction::MatchThreshold) } + { edit_field_number_u16!(form_state, translate.t(LABEL_BEST_MATCH_THRESHOLD), best_match_threshold, EpgSmartMatchFormAction::BestMatchThreshold) } + { edit_field_text_option!(form_state, translate.t(LABEL_NORMALIZE_REGEX), normalize_regex, EpgSmartMatchFormAction::NormalizeRegex) } + { edit_field_list_option!(form_state, translate.t(LABEL_STRIP), strip, EpgSmartMatchFormAction::Strip, translate.t(LABEL_ADD_STRIP_ENTRY)) } + + { config_field_child!(translate.t(LABEL_NAME_PREFIX), "EPG_SMART_MATCH_FORM.NAME_PREFIX_MODE", { + let form_state_mode = form_state.clone(); + html! { +