diff --git a/backend/src/processing/processor/sort.rs b/backend/src/processing/processor/sort.rs index 5640765a3..ca7fabbc4 100644 --- a/backend/src/processing/processor/sort.rs +++ b/backend/src/processing/processor/sort.rs @@ -4,7 +4,7 @@ use shared::model::{PlaylistGroup, SortOrder, SortTarget}; use std::cmp::Ordering; use std::sync::Arc; -fn direction(order: SortOrder, ordering: Ordering) -> Ordering { +fn apply_sort_order(order: SortOrder, ordering: Ordering) -> Ordering { match (order, ordering) { (SortOrder::None, _) | (_, Ordering::Equal) => Ordering::Equal, (SortOrder::Asc, o) => o, @@ -12,6 +12,220 @@ fn direction(order: SortOrder, ordering: Ordering) -> Ordering { } } +fn parse_capture_group_rank(name: &str) -> Option { + let suffix = name.strip_prefix('c')?; + if suffix.is_empty() || !suffix.bytes().all(|c| c.is_ascii_digit()) { + return None; + } + suffix.parse::().ok() +} + +#[derive(Debug)] +struct SequencePattern { + regex: Arc, + ordered_capture_names: Vec, +} + +#[derive(Debug)] +struct SequencePlan { + patterns: Vec, +} + +impl SequencePlan { + fn new(sequence: &[Arc]) -> Self { + let patterns = sequence + .iter() + .map(|regex| { + let mut ordered_capture_names: Vec<(u32, String)> = regex + .capture_names() + .flatten() + .filter_map(|name| parse_capture_group_rank(name).map(|rank| (rank, name.to_owned()))) + .collect(); + ordered_capture_names.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1))); + + SequencePattern { + regex: Arc::clone(regex), + ordered_capture_names: ordered_capture_names.into_iter().map(|(_, name)| name).collect(), + } + }) + .collect(); + + Self { patterns } + } +} + +#[derive(Debug)] +enum SequenceMatch { + Matched { sequence_idx: usize, captures: Vec>> }, + Unmatched, +} + +#[derive(Debug)] +struct PreparedRule<'a> { + rule: &'a ConfigSortRule, + sequence_plan: Option, +} + +impl<'a> PreparedRule<'a> { + fn new(rule: &'a ConfigSortRule) -> Self { + let sequence_plan = rule.sequence.as_ref().map(|sequence| SequencePlan::new(sequence)); + Self { rule, sequence_plan } + } +} + +#[derive(Debug)] +struct RuleCacheEntry { + filter_pass: bool, + value: Option>, + sequence_match: Option, +} + +fn evaluate_sequence(plan: &SequencePlan, value: &str) -> SequenceMatch { + for (sequence_idx, pattern) in plan.patterns.iter().enumerate() { + if let Some(captures) = pattern.regex.captures(value) { + let ordered_captures = pattern + .ordered_capture_names + .iter() + .map(|name| captures.name(name).map(|capture| Arc::::from(capture.as_str()))) + .collect(); + return SequenceMatch::Matched { sequence_idx, captures: ordered_captures }; + } + } + SequenceMatch::Unmatched +} + +fn compare_sequence_match(a: &SequenceMatch, b: &SequenceMatch, order: SortOrder) -> Ordering { + match (a, b) { + ( + SequenceMatch::Matched { + sequence_idx: idx_a, + captures: captures_a, + }, + SequenceMatch::Matched { + sequence_idx: idx_b, + captures: captures_b, + }, + ) => { + // Sequence index is an explicit priority list and is never reversed by DESC. + let idx_ord = idx_a.cmp(idx_b); + if idx_ord != Ordering::Equal { + return idx_ord; + } + + let capture_count = captures_a.len().max(captures_b.len()); + for index in 0..capture_count { + let ord = match (captures_a.get(index), captures_b.get(index)) { + (Some(Some(v1)), Some(Some(v2))) => v1.cmp(v2), + (Some(Some(_)), Some(None) | None) => Ordering::Greater, + (Some(None) | None, Some(Some(_))) => Ordering::Less, + _ => Ordering::Equal, + }; + if ord != Ordering::Equal { + return apply_sort_order(order, ord); + } + } + Ordering::Equal + } + (SequenceMatch::Matched { .. }, SequenceMatch::Unmatched) => Ordering::Less, + (SequenceMatch::Unmatched, SequenceMatch::Matched { .. }) => Ordering::Greater, + (SequenceMatch::Unmatched, SequenceMatch::Unmatched) => Ordering::Equal, + } +} + +fn compare_rule_entries(rule: &PreparedRule, left: &RuleCacheEntry, right: &RuleCacheEntry) -> Ordering { + match (left.filter_pass, right.filter_pass) { + (false, false) => return Ordering::Equal, + (true, false) => return Ordering::Less, + (false, true) => return Ordering::Greater, + (true, true) => {} + } + + match (&left.value, &right.value) { + (None, None) => Ordering::Equal, + (Some(_), None) => Ordering::Less, + (None, Some(_)) => Ordering::Greater, + (Some(value_left), Some(value_right)) => { + if rule.sequence_plan.is_some() { + match (&left.sequence_match, &right.sequence_match) { + (Some(seq_left), Some(seq_right)) => compare_sequence_match(seq_left, seq_right, rule.rule.order), + _ => Ordering::Equal, + } + } else { + apply_sort_order(rule.rule.order, value_left.cmp(value_right)) + } + } + } +} + +fn build_rule_cache_entry( + rule: &PreparedRule, + item: Option<&shared::model::PlaylistItem>, + match_as_ascii: bool, +) -> RuleCacheEntry { + let Some(item) = item else { + return RuleCacheEntry { + filter_pass: false, + value: None, + sequence_match: None, + }; + }; + + let provider = ValueProvider { pli: item, match_as_ascii }; + let filter_pass = rule.rule.filter.filter(&provider); + if !filter_pass { + return RuleCacheEntry { + filter_pass: false, + value: None, + sequence_match: None, + }; + } + + let value = provider.get(rule.rule.field.as_str()); + let sequence_match = match (&rule.sequence_plan, value.as_deref()) { + (Some(sequence_plan), Some(value)) => Some(evaluate_sequence(sequence_plan, value)), + _ => None, + }; + + RuleCacheEntry { + filter_pass: true, + value, + sequence_match, + } +} + +fn build_group_rule_cache(rule: &PreparedRule, groups: &[PlaylistGroup], match_as_ascii: bool) -> Vec { + groups + .iter() + .map(|group| build_rule_cache_entry(rule, group.channels.first(), match_as_ascii)) + .collect() +} + +fn build_channel_rule_cache( + rule: &PreparedRule, + channels: &[shared::model::PlaylistItem], + match_as_ascii: bool, +) -> Vec { + channels + .iter() + .map(|channel| build_rule_cache_entry(rule, Some(channel), match_as_ascii)) + .collect() +} + +fn reorder_by_indices(items: &mut Vec, indices: &[usize]) { + debug_assert_eq!(items.len(), indices.len()); + + let mut original: Vec> = std::mem::take(items).into_iter().map(Some).collect(); + items.reserve(indices.len()); + + for &idx in indices { + let item = original[idx] + .take() + .expect("index permutation must contain each source position exactly once"); + items.push(item); + } +} + +#[cfg(test)] fn playlist_comparator( sequence: Option<&Vec>>, order: SortOrder, @@ -21,80 +235,14 @@ fn playlist_comparator( if matches!(order, SortOrder::None) { return Ordering::Equal; } - if let Some(regex_list) = sequence { - let mut match_a = None; - let mut match_b = None; - for (i, regex) in regex_list.iter().enumerate() { - if match_a.is_none() { - if let Some(caps) = regex.captures(value_a) { - match_a = Some((i, caps)); - } - } - if match_b.is_none() { - if let Some(caps) = regex.captures(value_b) { - match_b = Some((i, caps)); - } - } - - // If both matches found → break - if match_a.is_some() && match_b.is_some() { - break; - } - } - - match (match_a, match_b) { - (Some((idx_a, caps_a)), Some((idx_b, caps_b))) => { - if idx_a != idx_b { - return match order { - SortOrder::Asc => idx_a.cmp(&idx_b), - SortOrder::Desc => idx_b.cmp(&idx_a), - SortOrder::None => Ordering::Equal, - }; - } - - // Same regex → sort by captures (c1, c2, …) - let mut named: Vec<_> = regex_list[idx_a] - .capture_names() - .flatten() - .filter(|name| name.starts_with('c')) - .collect(); - - named.sort_by_key(|name| name[1..].parse::().unwrap_or(0)); - - for name in named { - let va = caps_a.name(name).map(|m| m.as_str()); - let vb = caps_b.name(name).map(|m| m.as_str()); - let o = match (va, vb) { - (Some(a), Some(b)) => a.cmp(b), - (Some(_), None) => Ordering::Greater, - (None, Some(_)) => Ordering::Less, - _ => Ordering::Equal, - }; - - if o != Ordering::Equal { - return match order { - SortOrder::Asc => o, - SortOrder::Desc => o.reverse(), - SortOrder::None => Ordering::Equal, - }; - } - } - - Ordering::Equal - } - (Some(_), None) => direction(order, Ordering::Less), - (None, Some(_)) => direction(order, Ordering::Greater), - (None, None) => Ordering::Equal, - } + if let Some(sequence) = sequence { + let plan = SequencePlan::new(sequence); + let left = evaluate_sequence(&plan, value_a); + let right = evaluate_sequence(&plan, value_b); + compare_sequence_match(&left, &right, order) } else { - // No Regex-Sequence defined → fallback - let o = value_a.cmp(value_b); - match order { - SortOrder::Asc => o, - SortOrder::Desc => o.reverse(), - SortOrder::None => Ordering::Equal, - } + apply_sort_order(order, value_a.cmp(value_b)) } } @@ -108,9 +256,37 @@ macro_rules! sort_groups_by_source_order { }; } +fn compare_cached_rule_entries( + rules: &[PreparedRule<'_>], + rule_caches: &[Vec], + left_idx: usize, + right_idx: usize, +) -> Ordering { + for (rule, cache) in rules.iter().zip(rule_caches.iter()) { + let ord = compare_rule_entries(rule, &cache[left_idx], &cache[right_idx]); + if ord != Ordering::Equal { + return ord; + } + } + + // Apply raw-value fallback only after all rule-level comparisons are exhausted. + for (rule, cache) in rules.iter().zip(rule_caches.iter()) { + let left = &cache[left_idx]; + let right = &cache[right_idx]; + if let (Some(va), Some(vb)) = (&left.value, &right.value) { + let fallback = apply_sort_order(rule.rule.order, va.cmp(vb)); + if fallback != Ordering::Equal { + return fallback; + } + } + } + + Ordering::Equal +} + pub(in crate::processing::processor) fn sort_playlist( target: &ConfigTarget, - playlist: &mut [PlaylistGroup], + playlist: &mut Vec, ) -> bool { let Some(sort) = &target.sort else { for group in &mut *playlist { @@ -122,14 +298,14 @@ pub(in crate::processing::processor) fn sort_playlist( let rules = &sort.rules; let match_as_ascii = sort.match_as_ascii; - sort_channels_in_groups(playlist, rules, match_as_ascii); + sort_channels_in_groups(playlist.as_mut_slice(), rules, match_as_ascii); sort_groups(playlist, rules, match_as_ascii); true } fn sort_groups( - groups: &mut [PlaylistGroup], + groups: &mut Vec, rules: &[ConfigSortRule], match_as_ascii: bool, ) { @@ -137,6 +313,7 @@ fn sort_groups( .iter() .filter(|r| matches!(r.target, SortTarget::Group)) .filter(|r| r.order != SortOrder::None) + .map(PreparedRule::new) .collect(); if group_rules.is_empty() { @@ -144,51 +321,30 @@ fn sort_groups( return; } - groups.sort_by(|a_grp, b_grp| { - let a_chan = a_grp.channels.first(); - let b_chan = b_grp.channels.first(); + let rule_caches: Vec> = group_rules + .iter() + .map(|rule| build_group_rule_cache(rule, groups.as_slice(), match_as_ascii)) + .collect(); - for rule in &group_rules { - let (vp_a, vp_b) = match (a_chan, b_chan) { - (Some(a), Some(b)) => ( - ValueProvider { pli: a, match_as_ascii }, - ValueProvider { pli: b, match_as_ascii }, - ), - (Some(_), None) => return direction(rule.order, Ordering::Less), - (None, Some(_)) => return direction(rule.order, Ordering::Greater), - (None, None) => continue, - }; - - let fa = rule.filter.filter(&vp_a); - let fb = rule.filter.filter(&vp_b); - - match (fa, fb) { - (false, false) => continue, - (true, false) => return direction(rule.order, Ordering::Less), - (false, true) => return direction(rule.order, Ordering::Greater), - _ => {} - } - - let va = vp_a.get(rule.field.as_str()); - let vb = vp_b.get(rule.field.as_str()); - let ord = match (va, vb) { - (None, None) => Ordering::Equal, - (Some(_), None) => direction(rule.order, Ordering::Less), - (None, Some(_)) => direction(rule.order, Ordering::Greater), - (Some(va), Some(vb)) => { - playlist_comparator(rule.sequence.as_ref(), rule.order, &va, &vb) - } - }; - - if ord != Ordering::Equal { - return ord; - } + let mut group_indices: Vec = (0..groups.len()).collect(); + group_indices.sort_by(|left_idx, right_idx| { + let ord = compare_cached_rule_entries(&group_rules, &rule_caches, *left_idx, *right_idx); + if ord != Ordering::Equal { + return ord; } - let order1 = a_chan.as_ref().map_or(u32::MAX, |c| c.header.source_ordinal); - let order2 = b_chan.as_ref().map_or(u32::MAX, |c| c.header.source_ordinal); + let order1 = groups[*left_idx] + .channels + .first() + .map_or(u32::MAX, |c| c.header.source_ordinal); + let order2 = groups[*right_idx] + .channels + .first() + .map_or(u32::MAX, |c| c.header.source_ordinal); order1.cmp(&order2) }); + + reorder_by_indices(groups, &group_indices); } fn sort_channels_in_groups( @@ -200,6 +356,7 @@ fn sort_channels_in_groups( .iter() .filter(|r| matches!(r.target, SortTarget::Channel)) .filter(|r| r.order != SortOrder::None) + .map(PreparedRule::new) .collect(); if channel_rules.is_empty() { @@ -212,64 +369,51 @@ fn sort_channels_in_groups( } for group in groups { - group.channels.sort_by(|a, b| { - let vp_a = ValueProvider { pli: a, match_as_ascii }; - let vp_b = ValueProvider { pli: b, match_as_ascii }; + let rule_caches: Vec> = channel_rules + .iter() + .map(|rule| build_channel_rule_cache(rule, &group.channels, match_as_ascii)) + .collect(); - for rule in &channel_rules { - let fa = rule.filter.filter(&vp_a); - let fb = rule.filter.filter(&vp_b); - - match (fa, fb) { - (false, false) => continue, - (true, false) => return direction(rule.order, Ordering::Less), - (false, true) => return direction(rule.order, Ordering::Greater), - _ => {} - } - - let va = vp_a.get(rule.field.as_str()); - let vb = vp_b.get(rule.field.as_str()); - - let ord = match (&va, &vb) { - (None, None) => Ordering::Equal, - (Some(_), None) => direction(rule.order, Ordering::Less), - (None, Some(_)) => direction(rule.order, Ordering::Greater), - (Some(va), Some(vb)) => { - playlist_comparator(rule.sequence.as_ref(), rule.order, va, vb) - } - }; - - if ord == Ordering::Equal { - if let (Some(va), Some(vb)) = (&va, &vb) { - let fallback = direction(rule.order, va.cmp(vb)); - if fallback != Ordering::Equal { - return fallback; - } - } - } - - if ord != Ordering::Equal { - return ord; - } + let mut channel_indices: Vec = (0..group.channels.len()).collect(); + channel_indices.sort_by(|left_idx, right_idx| { + let ord = compare_cached_rule_entries(&channel_rules, &rule_caches, *left_idx, *right_idx); + if ord != Ordering::Equal { + return ord; } - a.header.source_ordinal.cmp(&b.header.source_ordinal) + group.channels[*left_idx] + .header + .source_ordinal + .cmp(&group.channels[*right_idx].header.source_ordinal) }); + + reorder_by_indices(&mut group.channels, &channel_indices); } } #[cfg(test)] mod tests { + use super::{ + compare_rule_entries, playlist_comparator, sort_channels_in_groups, sort_groups, PreparedRule, RuleCacheEntry, + }; use crate::model::ConfigSortRule; - use crate::processing::processor::sort::playlist_comparator; use shared::foundation::Filter; - use shared::model::{ItemField, PlaylistItem, PlaylistItemHeader, SortOrder, SortTarget}; + use shared::model::{ItemField, PlaylistGroup, PlaylistItem, PlaylistItemHeader, SortOrder, SortTarget, XtreamCluster}; use std::cmp::Ordering; use std::sync::Arc; + fn make_group(id: u32, title: &str, channels: Vec) -> PlaylistGroup { + PlaylistGroup { + id, + title: title.into(), + channels, + xtream_cluster: XtreamCluster::Live, + } + } + #[test] fn test_sort() { - let mut channels: Vec = vec![ + let channels: Vec = vec![ ("D", "HD"), ("A", "FHD"), ("Z", "HD"), @@ -306,19 +450,8 @@ mod tests { filter: Filter::default(), }; - channels.sort_by(|a, b| { - let va = &a.header.title; - let vb = &b.header.title; - - let ord = - playlist_comparator(channel_sort.sequence.as_ref(), channel_sort.order, va, vb); - - if ord == Ordering::Equal { - a.header.source_ordinal.cmp(&b.header.source_ordinal) - } else { - ord - } - }); + let mut groups = vec![make_group(1, "G1", channels)]; + sort_channels_in_groups(groups.as_mut_slice(), &[channel_sort], false); let expected = vec![ "Chanel K [UHD]", @@ -335,7 +468,11 @@ mod tests { "Chanel T [SD]", ].into_iter().map(Into::into).collect::>>(); - let sorted = channels + let sorted = groups + .into_iter() + .next() + .expect("group should exist") + .channels .into_iter() .map(|pli| pli.header.title) .collect::>(); @@ -345,7 +482,7 @@ mod tests { #[test] fn test_sort2() { - let mut channels: Vec = vec![ + let channels: Vec = vec![ "US| EAST [FHD] abc", "US| EAST [FHD] def", "US| EAST [FHD] ghi", @@ -399,20 +536,8 @@ mod tests { filter: Filter::default(), }; - channels.sort_by(|a, b| { - let ord = playlist_comparator( - channel_sort.sequence.as_ref(), - channel_sort.order, - &a.header.title, - &b.header.title, - ); - - if ord == Ordering::Equal { - a.header.source_ordinal.cmp(&b.header.source_ordinal) - } else { - ord - } - }); + let mut groups = vec![make_group(1, "G1", channels)]; + sort_channels_in_groups(groups.as_mut_slice(), &[channel_sort], false); let expected = vec![ "US| EAST [UHD] m", @@ -441,11 +566,188 @@ mod tests { "US| West f", ].into_iter().map(Into::into).collect::>>(); - let sorted = channels + let sorted = groups + .into_iter() + .next() + .expect("group should exist") + .channels .into_iter() .map(|pli| pli.header.title) .collect::>(); assert_eq!(expected, sorted); } + + #[test] + fn test_sequence_priority_is_not_reversed_for_desc() { + let mut channels: Vec = vec!["A-1", "B-9", "A-7", "B-2"] + .into_iter() + .enumerate() + .map(|(i, title)| PlaylistItem { + header: PlaylistItemHeader { + title: title.to_string().into(), + source_ordinal: u32::try_from(i).unwrap(), + ..Default::default() + }, + }) + .collect(); + + let sequence = vec![ + shared::model::REGEX_CACHE.get_or_compile(r"^A-(?P\d+)$").unwrap(), + shared::model::REGEX_CACHE.get_or_compile(r"^B-(?P\d+)$").unwrap(), + ]; + + channels.sort_by(|a, b| { + let ord = playlist_comparator( + Some(&sequence), + SortOrder::Desc, + &a.header.title, + &b.header.title, + ); + + if ord == Ordering::Equal { + a.header.source_ordinal.cmp(&b.header.source_ordinal) + } else { + ord + } + }); + + let sorted = channels + .into_iter() + .map(|pli| pli.header.title) + .collect::>(); + + let expected = vec!["A-7", "A-1", "B-9", "B-2"] + .into_iter() + .map(Into::into) + .collect::>>(); + + assert_eq!(expected, sorted); + } + + #[test] + fn test_compare_rule_entries_desc_filter_mismatch() { + let rule = ConfigSortRule { + target: SortTarget::Channel, + field: ItemField::Caption, + order: SortOrder::Desc, + sequence: Some(vec![shared::model::REGEX_CACHE.get_or_compile(r"^A-(?P\d+)$").unwrap()]), + filter: Filter::default(), + }; + + let prepared = PreparedRule::new(&rule); + let pass_with_value = RuleCacheEntry { + filter_pass: true, + value: Some(Arc::from("A-2")), + sequence_match: None, + }; + let pass_without_value = RuleCacheEntry { + filter_pass: true, + value: None, + sequence_match: None, + }; + let fail_filter = RuleCacheEntry { + filter_pass: false, + value: None, + sequence_match: None, + }; + + assert_eq!(compare_rule_entries(&prepared, &pass_with_value, &fail_filter), Ordering::Less); + assert_eq!(compare_rule_entries(&prepared, &fail_filter, &pass_with_value), Ordering::Greater); + assert_eq!(compare_rule_entries(&prepared, &pass_with_value, &pass_without_value), Ordering::Less); + assert_eq!(compare_rule_entries(&prepared, &pass_without_value, &pass_with_value), Ordering::Greater); + } + + #[test] + fn test_sort_channels_does_not_short_circuit_later_rules_with_raw_value_fallback() { + let channels: Vec = vec!["A-2-x", "A-1-y"] + .into_iter() + .enumerate() + .map(|(i, title)| PlaylistItem { + header: PlaylistItemHeader { + title: title.to_string().into(), + source_ordinal: u32::try_from(i).unwrap(), + ..Default::default() + }, + }) + .collect(); + + let first_rule = ConfigSortRule { + target: SortTarget::Channel, + field: ItemField::Caption, + order: SortOrder::Asc, + // Both values match the same sequence item and produce equal sequence priority. + sequence: Some(vec![shared::model::REGEX_CACHE.get_or_compile(r"^A-\d+-.$").unwrap()]), + filter: Filter::default(), + }; + let second_rule = ConfigSortRule { + target: SortTarget::Channel, + field: ItemField::Caption, + order: SortOrder::Desc, + sequence: None, + filter: Filter::default(), + }; + + let mut groups = vec![make_group(1, "G1", channels)]; + sort_channels_in_groups(groups.as_mut_slice(), &[first_rule, second_rule], false); + let sorted = groups[0] + .channels + .iter() + .map(|pli| pli.header.title.clone()) + .collect::>(); + + let expected = vec!["A-2-x", "A-1-y"] + .into_iter() + .map(Into::into) + .collect::>>(); + assert_eq!(expected, sorted); + } + + #[test] + fn test_sort_groups_uses_raw_value_fallback_before_source_ordinal() { + let group_a = make_group( + 1, + "A", + vec![PlaylistItem { + header: PlaylistItemHeader { + title: Arc::from("A-2-x"), + source_ordinal: 0, + ..Default::default() + }, + }], + ); + let group_b = make_group( + 2, + "B", + vec![PlaylistItem { + header: PlaylistItemHeader { + title: Arc::from("A-1-y"), + source_ordinal: 1, + ..Default::default() + }, + }], + ); + + let group_rule = ConfigSortRule { + target: SortTarget::Group, + field: ItemField::Caption, + order: SortOrder::Asc, + // Both groups match the same sequence item and produce equal sequence priority. + sequence: Some(vec![shared::model::REGEX_CACHE.get_or_compile(r"^A-\d+-.$").unwrap()]), + filter: Filter::default(), + }; + + let mut groups = vec![group_a, group_b]; + sort_groups(&mut groups, &[group_rule], false); + + let sorted = groups + .iter() + .map(|group| group.channels[0].header.title.clone()) + .collect::>(); + let expected = vec!["A-1-y", "A-2-x"] + .into_iter() + .map(Into::into) + .collect::>>(); + assert_eq!(expected, sorted); + } } diff --git a/frontend/Cargo.toml b/frontend/Cargo.toml index ed28190c2..839e6a596 100644 --- a/frontend/Cargo.toml +++ b/frontend/Cargo.toml @@ -40,7 +40,8 @@ indexmap = "2.13.0" version = "0.3" features = ["Document", "Element", "Node", "Window", "HtmlCollection", "HtmlImageElement", "HtmlInputElement", "DomRect", "HtmlDialogElement", "CssStyleDeclaration", - "Event", "WebGlRenderingContext", "WebGlProgram", "WebGlShader", "WebGlBuffer", "WebGlUniformLocation", + "Event", "MutationObserver", "MutationObserverInit", + "WebGlRenderingContext", "WebGlProgram", "WebGlShader", "WebGlBuffer", "WebGlUniformLocation", "HtmlCanvasElement"] [dev-dependencies] diff --git a/frontend/scss/_theme.scss b/frontend/scss/_theme.scss index 2f1236b1a..c70fed133 100644 --- a/frontend/scss/_theme.scss +++ b/frontend/scss/_theme.scss @@ -66,45 +66,46 @@ } :root { - --primary-color: rgba(50, 130, 255, 0.65); + --primary-color: rgba(96, 165, 250, 0.72); /* Icy Blue */ - --secondary-color: rgba(240, 95, 95, 0.6); + --secondary-color: rgba(251, 113, 133, 0.62); /* Soft Coral Red */ - --action-item-background-color: rgba(45, 70, 120, 0.35); + --action-item-background-color: rgba(37, 99, 235, 0.18); --action-item-color: #ffffff; - --text-color: #ffffff; - --modest-text-color: #a7abb4; - --background-color: #0a0b10; - --floating-background-color: #071529; - --floating-background-accent-color-1: #6aa7ff; - --floating-background-accent-color-2: #7ab6ff; - --border-color: rgba(60, 100, 195, 0.25); - --input-background-color: rgba(255, 255, 255, 0); + --text-color: #e6edf7; + --modest-text-color: #9aa9bf; + --background-color: #0b1220; + --particle-flow-background-color: #070f1d; + --particle-flow-background-accent-color-1: #6ea8ff; + --particle-flow-background-accent-color-2: #9ec7ff; + --particle-flow-background-alpha-boost: 1.0; + --border-color: rgba(96, 165, 250, 0.24); + --input-background-color: rgba(15, 23, 42, 0.3); --input-border-color: var(--border-color); - --input-focus-border-color: rgba(80, 150, 255, 0.8); + --input-focus-border-color: rgba(96, 165, 250, 0.88); --button-background-color: var(--primary-color); - --menu-text-color: #e2e5ea; - --menu-icon-color: #8a97b1; - --menu-item-hover-background-color: rgba(45, 70, 120, 0.4); + --menu-text-color: #d8e1ef; + --menu-icon-color: #8ea2bf; + --menu-item-hover-background-color: rgba(59, 130, 246, 0.16); --menu-item-hover-color: #ffffff; --menu-text-active-color: var(--primary-color); - --menu-item-active-background-color: rgba(45, 70, 120, 0.55); - --icon-button-hover-color: #a8a8b3; - --card-background-color: rgba(18, 22, 33, 0.9); + --menu-item-active-background-color: rgba(59, 130, 246, 0.24); + --icon-button-hover-color: #e6edf7; + --card-background-color: rgba(15, 23, 42, 0.78); --card-color: var(--modest-text-color); - --sub-card-background-color: rgba(18, 22, 33, 0.8); + --sub-card-background-color: rgba(15, 23, 42, 0.62); - --text-button-background-color: rgba(50, 80, 140, 0.3); + --text-button-background-color: rgba(30, 64, 175, 0.28); --text-button-color: #ffffff; - --text-button-border-color: rgba(80, 120, 200, 0.3); + --text-button-border-color: rgba(96, 165, 250, 0.28); --text-button-hover-color: #ffffff; - --text-button-hover-background-color: rgba(50, 80, 140, 0.5); + --text-button-hover-background-color: rgba(37, 99, 235, 0.42); --text-button-primary-background-color: var(--primary-color); --text-button-primary-color: #ffffff; - --text-button-primary-hover-background-color: rgba(70, 150, 255, 0.8); + --text-button-primary-hover-background-color: rgba(59, 130, 246, 0.9); --text-button-primary-hover-color: #ffffff; --text-button-primary-border-color: rgba(80, 150, 255, 0.8); --text-button-secondary-background-color: rgba(220, 70, 70, 0.25); @@ -113,29 +114,29 @@ --text-button-secondary-hover-color: #ffffff; --text-button-secondary-border-color: rgba(220, 70, 70, 0.5); - --text-button-active-background-color: rgba(50, 180, 120, 0.6); + --text-button-active-background-color: rgba(34, 197, 94, 0.58); --text-button-active-color: #fdfdfd; - --text-button-active-hover-background-color: rgba(40, 150, 100, 0.75); - --text-button-active-hover-color: #fdfdfd; - --text-button-active-border-color: rgba(60, 200, 140, 0.8); + --text-button-active-hover-background-color: rgba(22, 163, 74, 0.72); + --text-button-active-hover-color: #ffffff; + --text-button-active-border-color: rgba(74, 222, 128, 0.82); - --toggle-switch-on-background-color: rgba(60, 200, 140, 0.7); - --toggle-switch-off-background-color: rgba(60, 60, 70, 0.9); + --toggle-switch-on-background-color: rgba(34, 197, 94, 0.72); + --toggle-switch-off-background-color: rgba(51, 65, 85, 0.9); --toggle-switch-toggle-color: #ffffff; - --table-header-background-color: rgba(40, 50, 80, 0.55); - --table-card-background-color: rgba(35, 35, 45, 0.55); - --table-alternate-background-color: rgba(30, 30, 60, 0.55); + --table-header-background-color: rgba(30, 41, 59, 0.62); + --table-card-background-color: rgba(15, 23, 42, 0.56); + --table-alternate-background-color: rgba(30, 41, 59, 0.42); --discord-color: #5865f2; --documentation-color: #2bbf5a; --github-color: #ffffff; - --app-header-background-color: rgba(15, 18, 26, 0.85); - --app-header-color: #73d6fe; + --app-header-background-color: rgba(7, 12, 24, 0.82); + --app-header-color: #93c5fd; - --pulse-color-1: #868686; - --pulse-color-2: #4c4c4c; + --pulse-color-1: #64748b; + --pulse-color-2: #334155; --tag-padding: 0.3rem 0.5rem; --tag-border-radius: 0.5rem; @@ -171,15 +172,15 @@ --tag-status-pending-background-color: #1e252d88; --tag-status-pending-border-color: #2b4c61; - --popup-background-color: rgba(30, 34, 48, 0.7); + --popup-background-color: rgba(15, 23, 42, 0.78); --popup-shadow: 0 8px 24px rgba(0, 0, 0, 0.4), inset 0 0 10px rgba(255, 255, 255, 0.08); - --popup-border-color: rgba(80, 100, 160, 0.25); - --popup-color: #ffffff; + --popup-border-color: rgba(96, 165, 250, 0.26); + --popup-color: #e6edf7; - --tooltip-background-color: rgba(30, 34, 48, 0.9); - --tooltip-color: #ffffff; + --tooltip-background-color: rgba(15, 23, 42, 0.94); + --tooltip-color: #e6edf7; --attention-color: #f57170; --warn-color: #e1b842; @@ -198,41 +199,41 @@ --tag-series-border-color: #6366f1; --toastr-success-color: #ffffff; - --toastr-success-background-color: #3fa85a88; + --toastr-success-background-color: #22c55e66; --toastr-error-color: #ffffff; - --toastr-error-background-color: #c03a3488; + --toastr-error-background-color: #ef444466; --toastr-info-color: #ffffff; - --toastr-info-background-color: #2b8ab388; + --toastr-info-background-color: #3b82f666; --toastr-warning-color: #ffffff; - --toastr-warning-background-color: #f6a30688; + --toastr-warning-background-color: #f59e0b66; - --hint-color: #888888; - --label-color: #74c6ff; + --hint-color: #7f8ea5; + --label-color: #8ec5ff; --playlist-color: #aeb0b2; --playlist-rating-color: #ffffff; --playlist-rating-background-color: #121621; --sidebar-toggle-color: #ffffff; - --sidebar-toggle-hover-color: #47495d; - --hover-border-color: rgba(100, 140, 255, 0.4); + --sidebar-toggle-hover-color: #8ea2bf; + --hover-border-color: rgba(96, 165, 250, 0.46); --spinner-color: rgba(255, 255, 255, 0.4); - --spinner-highlight-color: #5599f0; + --spinner-highlight-color: #60a5fa; - --tree-count-color: #7b7b7e; - --tree-hover-background-color: #004db488; + --tree-count-color: #7f8ea5; + --tree-hover-background-color: #2563eb66; --tree-hover-color: white; --tree-expander-hover-color: white; - --tool-button-background-color: #43434388; + --tool-button-background-color: #33415588; --tool-button-color: #fff; --tool-button-hover-color: #ffffff; - --tool-button-hover-background-color: #0c8a1388; + --tool-button-hover-background-color: #2563eb88; - --toolbar-background-color: #282A3688; + --toolbar-background-color: #1e293b88; --toolbar-button-hover-color: #ffffff; - --toolbar-button-hover-background-color: #004db488; + --toolbar-button-hover-background-color: #2563eb88; - --ok-color: #21e821; + --ok-color: #22c55e; --card-background-color-darker: rgba(22, 26, 36, 0.55); --card-border-color: rgba(80, 130, 255, 0.2); @@ -244,31 +245,31 @@ --icon-button-border-radius: 4px; --icon-button-background-color: var(--button-background-color); --icon-button-color: #ffffff; - --icon-button-hover-background-color: #0d6efd88; + --icon-button-hover-background-color: #2563eb88; - --icon-button-hover-border-color: #ced9fb; + --icon-button-hover-border-color: #93c5fd; - --menu-button-color: #171d31; - --menu-button-background-color: rgba(32, 39, 51, 0.25); + --menu-button-color: #d8e1ef; + --menu-button-background-color: rgba(30, 41, 59, 0.45); - --menu-button-hover-color: #131212; - --menu-button-hover-background-color: rgba(32, 39, 51, 0.8); + --menu-button-hover-color: #93c5fd; + --menu-button-hover-background-color: rgba(37, 99, 235, 0.3); --menu-button-warn-color: #b96665; --menu-button-warn-hover-color: #dd7573; --menu-button-warn-hover-background-color: #4c3434; - --scrollbar-track-color: #838383; - --scrollbar-handle-color: #505050; + --scrollbar-track-color: #334155; + --scrollbar-handle-color: #64748b; --tabset-background-color: rgba(68, 71, 90, 0.3); --tabset-border-color: #535353; --tab-background-color: #44475A88; - --tab-hover-background-color: #2bb24988; + --tab-hover-background-color: #2f6db788; --tab-hover-color: #fdfdfd; - --tab-active-background-color: #1a672c88; + --tab-active-background-color: #1f4f8f88; --tab-active-color: #fdfdfd; - --tab-active-border-color: #35934d; + --tab-active-border-color: #4f8ffb; --table-border-color: #44475a; @@ -332,8 +333,6 @@ --glass-background-gradient-color-2: rgba(255, 255, 255, 0.1); --glass-input-border-color: rgba(255, 255, 255, 0.15); --glass-input-background-color: rgba(0, 0, 0, 0.15); - --floating-particle-gradient-color-1: rgba(255, 255, 255, 0.3); - --floating-particle-gradient-color-2: rgba(255, 255, 255, 0); --graph-paper-line-color-1: #e0e0e030; --graph-paper-line-color-2: #c0c0c030; @@ -387,84 +386,79 @@ --brick-port-background-color: #587293; --brick-port-border-color: #ffffff; - --brick-port-valid-background-color: #00ff00; + --brick-port-valid-background-color: #22c55e; --brick-port-invalid-background-color: #e74c3c; - --source-editor-delete-color: #FF0000FF; + --source-editor-delete-color: #ef4444; --source-editor-line-color: #ffffff; - --source-editor-active-line-color: #00ff00; + --source-editor-active-line-color: #22c55e; - --source-editor-pending-line-color: yellow; - --brick-port-hover-color: yellow; + --source-editor-pending-line-color: #f59e0b; + --brick-port-hover-color: #f59e0b; --selection-area-border-color: rgba(0, 120, 215, 0.9); --selection-area-background-color: rgba(0, 120, 215, 0.12); } body[data-theme="bright"] { - /* Core Palette */ - --primary-color: #3b82f6; - /* Modern Blue */ - --secondary-color: #f43f5e; - /* Vibrant Rose */ - --background-color: #f8fafc; - /* Very light slate */ - --floating-background-color: #ffffff; - --floating-background-accent-color-1: #6aa7ff; - --floating-background-accent-color-2: #7ab6ff; - --text-color: #0f172a; - /* Slate 900 */ - --modest-text-color: #64748b; - /* Slate 500 */ - --border-color: #e2e8f0; - /* Slate 200 */ + + --primary-color: #2f6fa8; + --secondary-color: #d46a6a; + --background-color: #e9eff4; + --particle-flow-background-color: #edf3f8; + --particle-flow-background-accent-color-1: #5f8fb8; + --particle-flow-background-accent-color-2: #8aaecf; + --particle-flow-background-alpha-boost: 1.45; + + --text-color: #1f2f3f; + --modest-text-color: #5a7084; + --border-color: #cfdbe6; + + + --card-color: #475569; + --card-background-color: #f7fbff; + --sub-card-background-color: #f1f6fb; + --card-background-color-darker: #eaf1f8; + --card-border-color: #cfdae6; /* Shadows & Depth */ --popup-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.05), 0 8px 10px -6px rgba(0, 0, 0, 0.05); --card-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -2px rgba(0, 0, 0, 0.05); /* Menu & Navigation */ - --menu-text-color: #334155; - --menu-icon-color: #444242; - --menu-item-hover-background-color: rgba(59, 130, 246, 0.05); - --menu-item-hover-color: #2563eb; - --menu-text-active-color: #3b82f6; - --menu-item-active-background-color: rgba(59, 130, 246, 0.08); + --menu-text-color: #30465b; + --menu-icon-color: #597286; + --menu-item-hover-background-color: rgba(47, 111, 168, 0.1); + --menu-item-hover-color: #2b5f8a; + --menu-item-active-background-color: rgba(47, 111, 168, 0.16); + --menu-text-active-color: #2f6fa8; --sidebar-toggle-color: #475569; --sidebar-toggle-hover-color: #3b82f6; /* UI Components */ - --action-item-background-color: rgba(15, 23, 42, 0.03); + --action-item-background-color: rgba(37, 99, 235, 0.06); --action-item-color: #0f172a; --input-background-color: #ffffff; --input-border-color: #cbd5e1; --input-focus-border-color: #3b82f6; - --card-background-color: #ffffff; - --card-color: #475569; - --card-background-color-darker: #f1f5f9; - --card-border-color: #e2e8f0; - --sub-card-background-color: #f9fafb; + --button-background-color: #2f6fa8; + --icon-button-background-color: #2f6fa8; + --icon-button-hover-background-color: #25597e; + --icon-button-hover-border-color: #8fb0cb; + --icon-button-color: #121525; + --icon-button-hover-color: #0046ff; - /* Buttons */ - --button-background-color: #3b82f6; - --icon-button-color: #232f54; - --icon-button-background-color: #3b82f6; - --icon-button-hover-background-color: #212020; - --icon-button-hover-color: #0f172a; - --icon-button-hover-border-color: #93c5fd; - - --text-button-background-color: #f1f5f9; - --text-button-color: #475569; - --text-button-border-color: #e2e8f0; - --text-button-hover-background-color: #e2e8f0; - --text-button-hover-color: #1e293b; - - --text-button-primary-background-color: #3b82f6; + --text-button-background-color: #e6eef6; + --text-button-border-color: #c7d7e5; + --text-button-hover-background-color: #d8e5f1; + --text-button-color: #3c5368; + --text-button-hover-color: #2c4256; + --text-button-primary-background-color: #2f6fa8; + --text-button-primary-hover-background-color: #275f90; --text-button-primary-color: #ffffff; - --text-button-primary-hover-background-color: #2563eb; --text-button-primary-hover-color: #ffffff; --text-button-primary-border-color: #3b82f6; @@ -490,10 +484,10 @@ body[data-theme="bright"] { --checkbox-checked-background-color: #3b82f6; /* Tables */ - --table-header-background-color: #f1f5f9; - --table-card-background-color: #ffffff; - --table-alternate-background-color: #f8fafc; - --table-border-color: #f1f5f9; + --table-header-background-color: #e4ecf4; + --table-card-background-color: #f7fbff; + --table-alternate-background-color: #f0f6fc; + --table-border-color: #cfdae6; /* Tags & Statuses */ --tag-background-color: #f1f5f9; @@ -536,29 +530,30 @@ body[data-theme="bright"] { --tag-series-border-color: #6366f1; /* Toastr Notifications */ - --toastr-success-background-color: #ffffff; - --toastr-success-color: #15803d; - --toastr-error-background-color: #ffffff; - --toastr-error-color: #b91c1c; - --toastr-info-background-color: #ffffff; - --toastr-info-color: #1d4ed8; - --toastr-warning-background-color: #ffffff; - --toastr-warning-color: #a16207; + --toastr-success-background-color: #d7eadf; + --toastr-error-background-color: #f1dcdc; + --toastr-info-background-color: #dbe8f3; + --toastr-warning-background-color: #f3ead4; + + --toastr-success-color: #166534; + --toastr-error-color: #991b1b; + --toastr-info-color: #1e40af; + --toastr-warning-color: #92400e; /* Popups & Tooltips */ - --popup-background-color: #ffffff; - --popup-border-color: #e2e8f0; + --popup-background-color: #f6fafeef; --popup-color: #1e293b; - --tooltip-background-color: #1e293b; - --tooltip-color: #f8fafc; + --popup-border-color: #cfdae6; + --tooltip-background-color: #2a3f54; + --tooltip-color: #eff5fb; /* Header */ - --app-header-background-color: #ffffff; + --app-header-background-color: #ffffffdd; --app-header-color: #2563eb; /* Utilities */ --pulse-color-1: #e2e8f0; - --pulse-color-2: #f1f5f9; + --pulse-color-2: #edf2f7; --hint-color: #94a3b8; --label-color: #3b82f6; --title-label-color: #64748b; @@ -576,14 +571,15 @@ body[data-theme="bright"] { --epg-program-active-time-color: #475569; /* Tabs */ - --tabset-background-color: #f8fafc; + --tabset-background-color: #e6edf4; + --tab-background-color: #f3f8fd; + --tab-hover-background-color: #dce8f3; + --tab-hover-color: #2f6fa8; + --tab-active-background-color: #d1e1ef; + --tab-active-color: #25557e; + --tab-active-border-color: #4f81ad; + --tabset-border-color: #e2e8f0; - --tab-background-color: #f1f5f9; - --tab-hover-background-color: #ffffff; - --tab-hover-color: #3b82f6; - --tab-active-background-color: #ffffff; - --tab-active-color: #3b82f6; - --tab-active-border-color: #3b82f6; /* Code Editor (Fancy Light) */ --code-editor-background-color: #fdfdfd; @@ -650,4 +646,5 @@ body[data-theme="bright"] { --breadcrumb-color: #64748b; --breadcrumb-active-background-color: #dcfce7; --breadcrumb-active-color: #166534; + } diff --git a/frontend/scss/app/_app.scss b/frontend/scss/app/_app.scss index 563711d6b..88964f9bf 100644 --- a/frontend/scss/app/_app.scss +++ b/frontend/scss/app/_app.scss @@ -6,7 +6,7 @@ box-sizing: border-box; max-width: 100%; color: var(--text-color); - background-color: inherit; + background-color: transparent; &-main { display: flex; diff --git a/frontend/scss/app/_component.scss b/frontend/scss/app/_component.scss index b3d40d6db..7b8c9fdf5 100644 --- a/frontend/scss/app/_component.scss +++ b/frontend/scss/app/_component.scss @@ -21,7 +21,7 @@ @forward "components/list_view"; @forward "components/reveal_content"; @forward "components/hide_content"; -@forward "components/playlist/playlist_editor_view"; +@forward "components/playlist/playlist_settings_view"; @forward "components/playlist/playlist_explorer_view"; @forward "components/playlist/playlist_explorer"; @forward "components/playlist/assistant/playlist_assistant"; diff --git a/frontend/scss/app/components/_input.scss b/frontend/scss/app/components/_input.scss index c50353133..0dc1d8c82 100644 --- a/frontend/scss/app/components/_input.scss +++ b/frontend/scss/app/components/_input.scss @@ -31,7 +31,6 @@ } .tp__icon-button.active { - color: var(--tag-active-color); fill: currentColor; } } diff --git a/frontend/scss/app/components/_particle_flow_background.scss b/frontend/scss/app/components/_particle_flow_background.scss index 496d8cdb4..25f8f2284 100644 --- a/frontend/scss/app/components/_particle_flow_background.scss +++ b/frontend/scss/app/components/_particle_flow_background.scss @@ -1,23 +1,23 @@ .tp__particle-flow-background, .tp__floating-background { - position: fixed; + position: absolute; inset: 0; width: 100%; height: 100%; overflow: hidden; pointer-events: none; - z-index: 0; + z-index: -1; isolation: isolate; background: radial-gradient( ellipse at 20% 8%, - color-mix(in srgb, var(--floating-background-color), var(--floating-background-accent-color-1) 20%) 0%, + color-mix(in srgb, var(--particle-flow-background-color), var(--particle-flow-background-accent-color-1) 20%) 0%, transparent 50% ), radial-gradient( ellipse at 78% -5%, - color-mix(in srgb, var(--floating-background-color), var(--floating-background-accent-color-2) 12%) 0%, + color-mix(in srgb, var(--particle-flow-background-color), var(--particle-flow-background-accent-color-2) 12%) 0%, transparent 45% ), - linear-gradient(180deg, var(--floating-background-color) 0%, var(--background-color) 84%); + linear-gradient(180deg, var(--particle-flow-background-color) 0%, var(--background-color) 84%); } diff --git a/frontend/scss/app/components/_reveal_content.scss b/frontend/scss/app/components/_reveal_content.scss index 41c5ffe67..9f4f14356 100644 --- a/frontend/scss/app/components/_reveal_content.scss +++ b/frontend/scss/app/components/_reveal_content.scss @@ -1,6 +1,6 @@ .tp__reveal-content { display: flex; - justify-content: flex-start; + justify-content: space-between; align-items: center; gap: var(--gap-default); cursor: pointer; diff --git a/frontend/scss/app/components/playlist/_playlist_explorer.scss b/frontend/scss/app/components/playlist/_playlist_explorer.scss index f5f2bb0ea..1637af9c4 100644 --- a/frontend/scss/app/components/playlist/_playlist_explorer.scss +++ b/frontend/scss/app/components/playlist/_playlist_explorer.scss @@ -106,7 +106,6 @@ $series-backdrop-mobile-height: 150px; &-sidebar { .tp__icon-button.active { - color: var(--tag-active-color); fill: currentColor; } } diff --git a/frontend/scss/app/components/playlist/_playlist_editor_view.scss b/frontend/scss/app/components/playlist/_playlist_settings_view.scss similarity index 94% rename from frontend/scss/app/components/playlist/_playlist_editor_view.scss rename to frontend/scss/app/components/playlist/_playlist_settings_view.scss index 5de601079..aaed3e350 100644 --- a/frontend/scss/app/components/playlist/_playlist_editor_view.scss +++ b/frontend/scss/app/components/playlist/_playlist_settings_view.scss @@ -1,5 +1,4 @@ -.tp__playlist-editor-create, -.tp__playlist-editor-list { +.tp__playlist-settings-list { &__body { diff --git a/frontend/scss/app/components/playlist/target/_target_output.scss b/frontend/scss/app/components/playlist/target/_target_output.scss index 556dda26f..de48a7d6e 100644 --- a/frontend/scss/app/components/playlist/target/_target_output.scss +++ b/frontend/scss/app/components/playlist/target/_target_output.scss @@ -11,22 +11,22 @@ } } -.tp__target-output__xtream.tp__target-output__has_options .tp__collapse-panel__header { +.tp__target-output__xtream.tp__target-output__has_options { color: var(--output-xtream-color); } -.tp__target-output__m3u.tp__target-output__has_options .tp__collapse-panel__header { +.tp__target-output__m3u.tp__target-output__has_options { color: var(--output-m3u-color); } -.tp__target-output__strm .tp__collapse-panel__header { +.tp__target-output__strm { color: var(--output-strm-color); } -.tp__target-output__hdhomerun .tp__collapse-panel__header { +.tp__target-output__hdhomerun { color: var(--output-hdhomerun-color); } -.tp__target-output__xtream .tp__collapse-panel__header, -.tp__target-output__m3u .tp__collapse-panel__header, -.tp__target-output__strm .tp__collapse-panel__header, -.tp__target-output__hdhomerun .tp__collapse-panel__header { +.tp__target-output__xtream, +.tp__target-output__m3u, +.tp__target-output__strm, +.tp__target-output__hdhomerun{ &:hover { color: var(--menu-button-hover-color); } diff --git a/frontend/src/app/components/home.rs b/frontend/src/app/components/home.rs index 5a41f91e7..da5d0c7f1 100644 --- a/frontend/src/app/components/home.rs +++ b/frontend/src/app/components/home.rs @@ -2,7 +2,7 @@ use crate::{ app::{ components::{ config::ConfigView, loading_indicator::BusyIndicator, theme::Theme, AppIcon, DashboardView, EpgView, - IconButton, InputRow, Panel, PlaylistEditorView, PlaylistExplorerView, PlaylistUpdateView, Sidebar, + IconButton, InputRow, Panel, PlaylistExplorerView, PlaylistSettingsView, PlaylistUpdateView, Sidebar, SourceEditor, StatsView, StreamsView, ToastrView, UserlistView, WebsocketStatus, }, context::{ConfigContext, PlaylistContext, StatusContext}, @@ -210,16 +210,16 @@ pub fn Home() -> Html { - + - - + + - + - + diff --git a/frontend/src/app/components/particle_flow_background.rs b/frontend/src/app/components/particle_flow_background.rs index 473d4c12f..d25428d8a 100644 --- a/frontend/src/app/components/particle_flow_background.rs +++ b/frontend/src/app/components/particle_flow_background.rs @@ -1,10 +1,10 @@ use fastrand::Rng; -use js_sys::Float32Array; +use js_sys::{Array, Float32Array}; use std::{cell::RefCell, f32::consts::TAU, rc::Rc}; use wasm_bindgen::{closure::Closure, JsCast}; use web_sys::{ - window, Event, HtmlCanvasElement, WebGlBuffer, WebGlProgram, WebGlRenderingContext, WebGlShader, - WebGlUniformLocation, + window, Element, Event, HtmlCanvasElement, MutationObserver, MutationObserverInit, WebGlBuffer, WebGlProgram, + WebGlRenderingContext, WebGlShader, WebGlUniformLocation, }; use yew::prelude::*; @@ -29,8 +29,11 @@ struct ParticleState { struct RenderRuntime { gl: WebGlRenderingContext, - _program: WebGlProgram, + program: WebGlProgram, buffer: WebGlBuffer, + u_accent_1: Option, + u_accent_2: Option, + u_alpha_boost: Option, u_dpr: Option, } @@ -44,7 +47,7 @@ varying float v_layer; void main() { gl_Position = vec4(a_pos, 0.0, 1.0); - gl_PointSize = a_meta.x * u_dpr * (1.0 + a_meta.z * 0.38); + gl_PointSize = a_meta.x * u_dpr * (1.0 + a_meta.z * 0.56); v_alpha = a_meta.y; v_layer = a_meta.z; } @@ -55,6 +58,7 @@ precision highp float; uniform vec3 u_accent1; uniform vec3 u_accent2; +uniform float u_alpha_boost; varying float v_alpha; varying float v_layer; @@ -68,7 +72,8 @@ void main() { float core = 1.0 - smoothstep(0.0, 0.32, dist); float halo = 1.0 - smoothstep(0.2, 0.5, dist); - float alpha = (core * 0.75 + halo * 0.25) * v_alpha; + float alpha = (core * 0.75 + halo * 0.25) * v_alpha * u_alpha_boost; + alpha = clamp(alpha, 0.0, 1.0); vec3 color = mix(u_accent1, u_accent2, clamp(v_layer * 0.35, 0.0, 1.0)); gl_FragColor = vec4(color, alpha); } @@ -140,50 +145,53 @@ fn parse_css_color_to_rgb(value: &str) -> Option<[f32; 3]> { Some([r, g, b]) } -fn read_accent_colors_from_css() -> ([f32; 3], [f32; 3]) { +fn read_theme_style() -> Option { + let win = window()?; + let document = win.document()?; + document + .body() + .and_then(|body| { + body.dyn_into::().ok().and_then(|element| win.get_computed_style(&element).ok().flatten()) + }) + .or_else(|| document.document_element().and_then(|root| win.get_computed_style(&root).ok().flatten())) +} + +fn read_accent_colors_from_style(style: Option<&web_sys::CssStyleDeclaration>) -> ([f32; 3], [f32; 3]) { let fallback_1 = [0.45, 0.63, 0.88]; let fallback_2 = [0.95, 0.97, 1.0]; - let Some(win) = window() else { - return (fallback_1, fallback_2); - }; - let Some(document) = win.document() else { - return (fallback_1, fallback_2); - }; - let Some(root) = document.document_element() else { - return (fallback_1, fallback_2); - }; - let Some(style) = win.get_computed_style(&root).ok().flatten() else { + let Some(style) = style else { return (fallback_1, fallback_2); }; - // Prefer new particle-flow variables, but keep legacy names as fallback for existing themes. let accent_1 = style .get_property_value("--particle-flow-background-accent-color-1") .ok() .and_then(|v| parse_css_color_to_rgb(&v)) - .or_else(|| { - style - .get_property_value("--floating-background-accent-color-1") - .ok() - .and_then(|v| parse_css_color_to_rgb(&v)) - }) .unwrap_or(fallback_1); let accent_2 = style .get_property_value("--particle-flow-background-accent-color-2") .ok() .and_then(|v| parse_css_color_to_rgb(&v)) - .or_else(|| { - style - .get_property_value("--floating-background-accent-color-2") - .ok() - .and_then(|v| parse_css_color_to_rgb(&v)) - }) .unwrap_or(fallback_2); (accent_1, accent_2) } +fn read_alpha_boost_from_style(style: Option<&web_sys::CssStyleDeclaration>) -> f32 { + let fallback = 1.0_f32; + let Some(style) = style else { + return fallback; + }; + + style + .get_property_value("--particle-flow-background-alpha-boost") + .ok() + .and_then(|v| v.trim().parse::().ok()) + .map(|v| v.clamp(0.0, 4.0)) + .unwrap_or(fallback) +} + fn pos_to_cell(pos: [f32; 2]) -> (usize, usize) { let ux = ((pos[0] + WORLD_BOUND) / (WORLD_BOUND * 2.0)).clamp(0.0, 0.999_9); let uy = ((pos[1] + WORLD_BOUND) / (WORLD_BOUND * 2.0)).clamp(0.0, 0.999_9); @@ -386,6 +394,26 @@ fn upload_accent_uniform(gl: &WebGlRenderingContext, location: Option, value: f32) { + if let Some(location) = location.as_ref() { + gl.uniform1f(Some(location), value); + } +} + +fn upload_theme_uniforms( + gl: &WebGlRenderingContext, + u_accent_1: Option, + u_accent_2: Option, + u_alpha_boost: Option, +) { + let style = read_theme_style(); + let (accent_1, accent_2) = read_accent_colors_from_style(style.as_ref()); + let alpha_boost = read_alpha_boost_from_style(style.as_ref()); + upload_accent_uniform(gl, u_accent_1, accent_1); + upload_accent_uniform(gl, u_accent_2, accent_2); + upload_alpha_boost_uniform(gl, u_alpha_boost, alpha_boost); +} + fn init_renderer(canvas: &HtmlCanvasElement, initial_data: &[f32]) -> Result { let context = canvas .get_context("webgl") @@ -403,12 +431,11 @@ fn init_renderer(canvas: &HtmlCanvasElement, initial_data: &[f32]) -> Result Result Html { })); let _ = win.add_event_listener_with_callback("resize", on_resize.as_ref().unchecked_ref()); + let runtime_ref_theme = runtime_ref.clone(); + let on_theme_change = + Closure::::wrap(Box::new(move |_records, _observer| { + if let Some(runtime) = runtime_ref_theme.borrow().as_ref() { + runtime.gl.use_program(Some(&runtime.program)); + upload_theme_uniforms( + &runtime.gl, + runtime.u_accent_1.clone(), + runtime.u_accent_2.clone(), + runtime.u_alpha_boost.clone(), + ); + } + })); + + let theme_observer = win.document().and_then(|doc| { + MutationObserver::new(on_theme_change.as_ref().unchecked_ref()).ok().and_then(|observer| { + let options = MutationObserverInit::new(); + options.set_attributes(true); + let attribute_filter = Array::new(); + attribute_filter.push(&"class".into()); + attribute_filter.push(&"data-theme".into()); + options.set_attribute_filter(&attribute_filter); + // Observe both and to cover all theme-toggle conventions. + if let Some(root) = doc.document_element() { + let _ = observer.observe_with_options(&root, &options); + } + if let Some(body) = doc.body() { + let _ = observer.observe_with_options(&body, &options); + } + Some(observer) + }) + }); + let runtime_ref_lost = runtime_ref.clone(); let on_context_lost = Closure::::wrap(Box::new(move |event: Event| { event.prevent_default(); @@ -600,6 +660,9 @@ pub fn ParticleFlowBackground() -> Html { } let _ = win.remove_event_listener_with_callback("resize", on_resize.as_ref().unchecked_ref()); } + if let Some(observer) = theme_observer.as_ref() { + observer.disconnect(); + } let _ = canvas_cleanup .remove_event_listener_with_callback("webglcontextlost", on_context_lost.as_ref().unchecked_ref()); let _ = canvas_cleanup.remove_event_listener_with_callback( @@ -608,6 +671,7 @@ pub fn ParticleFlowBackground() -> Html { ); *runtime_ref.borrow_mut() = None; *animation.borrow_mut() = None; + drop(on_theme_change); }) as Box }); } diff --git a/frontend/src/app/components/playlist/create.rs b/frontend/src/app/components/playlist/create.rs deleted file mode 100644 index dd750c822..000000000 --- a/frontend/src/app/components/playlist/create.rs +++ /dev/null @@ -1,29 +0,0 @@ -use crate::app::components::{PlaylistEditorContext, PlaylistEditorPage, TextButton}; -use yew::prelude::*; -use yew_i18n::use_translation; - -#[function_component] -pub fn PlaylistCreate() -> Html { - let translate = use_translation(); - let playlist_editor_ctx = use_context::().expect("PlaylistEditor context not found"); - - let handle_back = { - Callback::from(move |_| { - playlist_editor_ctx.active_page.set(PlaylistEditorPage::List); - }) - }; - - html! { -
-
-

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

- -
-
-
-
- } -} diff --git a/frontend/src/app/components/playlist/input/input_options.rs b/frontend/src/app/components/playlist/input/input_options.rs index 89964d2ce..7658c6661 100644 --- a/frontend/src/app/components/playlist/input/input_options.rs +++ b/frontend/src/app/components/playlist/input/input_options.rs @@ -1,4 +1,4 @@ -use crate::app::components::{chip::convert_bool_to_chip_style, make_tags, CollapsePanel, Tag, TagList}; +use crate::app::components::{chip::convert_bool_to_chip_style, make_tags, Tag, TagList}; use shared::{ model::ConfigInputDto, utils::{default_probe_delay_secs, default_probe_live_interval, default_resolve_delay_secs}, @@ -125,15 +125,12 @@ pub fn InputOptions(props: &InputOptionsProps) -> Html { (has_options, make_tags(&options1, &translate), make_tags(&options2, &translate), resolve_tags, probe_tags) }); - let has_options = tags.0; let opts1: Vec> = tags.1.clone(); let opts2: Vec> = tags.2.clone(); let resolve: Vec> = tags.3.clone(); let probe: Vec> = tags.4.clone(); html! { -
@@ -151,6 +148,5 @@ pub fn InputOptions(props: &InputOptionsProps) -> Html {
-
} } diff --git a/frontend/src/app/components/playlist/input_table.rs b/frontend/src/app/components/playlist/input_table.rs index 59ecb0a6a..25eea6a17 100644 --- a/frontend/src/app/components/playlist/input_table.rs +++ b/frontend/src/app/components/playlist/input_table.rs @@ -127,7 +127,9 @@ pub fn InputTable(props: &InputTableProps) -> Html { .as_ref() .map_or_else(|| html! {}, |pwd| html! { }), 7 => dto.persist.as_ref().map_or_else(|| html! {}, |p| html! {p}), - 8 => html! { }, + 8 => { + html! { } + } 9 => html! { dto.priority.to_string() }, 10 => html! { dto.max_connections.to_string() }, 11 => html! { dto.method.to_string() }, diff --git a/frontend/src/app/components/playlist/list.rs b/frontend/src/app/components/playlist/list.rs index 0727d096d..5a2a45262 100644 --- a/frontend/src/app/components/playlist/list.rs +++ b/frontend/src/app/components/playlist/list.rs @@ -1,7 +1,4 @@ -use crate::app::{ - components::{AppIcon, CollapsePanel, InputTable, PlaylistContext, PlaylistEditorPage, TargetTable, TextButton}, - context::PlaylistEditorContext, -}; +use crate::app::components::{AppIcon, CollapsePanel, InputTable, PlaylistContext, TargetTable}; use yew::prelude::*; use yew_i18n::use_translation; @@ -9,13 +6,6 @@ use yew_i18n::use_translation; pub fn PlaylistList() -> Html { let translate = use_translation(); let playlist_ctx = use_context::().expect("Playlist context not found"); - let playlist_editor_ctx = use_context::().expect("PlaylistEditor context not found"); - - let handle_create = { - Callback::from(move |_| { - playlist_editor_ctx.active_page.set(PlaylistEditorPage::Create); - }) - }; let playlist_body = if let Some(data) = playlist_ctx.sources.as_ref() { html! { @@ -39,10 +29,6 @@ pub fn PlaylistList() -> Html {

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

-
{ playlist_body } diff --git a/frontend/src/app/components/playlist/mod.rs b/frontend/src/app/components/playlist/mod.rs index b927cb879..9b833ae87 100644 --- a/frontend/src/app/components/playlist/mod.rs +++ b/frontend/src/app/components/playlist/mod.rs @@ -1,5 +1,4 @@ mod assistant; -mod create; mod epg_config_view; mod epg_source_selector; mod epg_view; @@ -10,11 +9,10 @@ mod list; mod mapper_counter_view; mod mapper_script_view; mod mappings; -mod playlist_editor_page; -mod playlist_editor_view; mod playlist_explorer; mod playlist_explorer_page; mod playlist_explorer_view; +mod playlist_settings_view; mod playlist_source_selector; mod playlist_update_view; mod processing; @@ -22,10 +20,10 @@ mod target; mod target_table; pub use self::{ - assistant::*, create::*, epg_config_view::*, epg_source_selector::*, epg_view::*, filter_view::*, input::*, - input_table::*, list::*, mapper_counter_view::*, mapper_script_view::*, mappings::*, playlist_editor_page::*, - playlist_editor_view::*, playlist_explorer_page::*, playlist_explorer_view::*, playlist_source_selector::*, - playlist_update_view::*, processing::*, target::*, target_table::*, + assistant::*, epg_config_view::*, epg_source_selector::*, epg_view::*, filter_view::*, input::*, input_table::*, + list::*, mapper_counter_view::*, mapper_script_view::*, mappings::*, playlist_explorer_page::*, + playlist_explorer_view::*, playlist_settings_view::*, playlist_source_selector::*, playlist_update_view::*, + processing::*, target::*, target_table::*, }; use crate::app::components::{convert_bool_to_chip_style, Tag}; pub use crate::app::context::*; diff --git a/frontend/src/app/components/playlist/playlist_editor_page.rs b/frontend/src/app/components/playlist/playlist_editor_page.rs deleted file mode 100644 index 921663bfc..000000000 --- a/frontend/src/app/components/playlist/playlist_editor_page.rs +++ /dev/null @@ -1,33 +0,0 @@ -use shared::error::{info_err_res, TuliproxError}; -use std::{fmt::Display, str::FromStr}; - -#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum PlaylistEditorPage { - List, - Create, -} - -impl FromStr for PlaylistEditorPage { - type Err = TuliproxError; - - fn from_str(s: &str) -> Result { - match s.to_lowercase().as_str() { - "list" => Ok(PlaylistEditorPage::List), - "create" => Ok(PlaylistEditorPage::Create), - _ => info_err_res!("Unknown page type: {s}"), - } - } -} - -impl Display for PlaylistEditorPage { - fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - write!( - f, - "{}", - match *self { - Self::List => "list", - Self::Create => "create", - } - ) - } -} diff --git a/frontend/src/app/components/playlist/playlist_editor_view.rs b/frontend/src/app/components/playlist/playlist_editor_view.rs deleted file mode 100644 index c51a7f0be..000000000 --- a/frontend/src/app/components/playlist/playlist_editor_view.rs +++ /dev/null @@ -1,56 +0,0 @@ -use crate::app::{ - components::{Breadcrumbs, Panel, PlaylistCreate, PlaylistEditorPage, PlaylistList}, - context::PlaylistEditorContext, -}; -use std::rc::Rc; -use yew::prelude::*; -use yew_i18n::use_translation; - -#[function_component] -pub fn PlaylistEditorView() -> Html { - let translate = use_translation(); - let breadcrumbs = use_state(|| Rc::new(vec![translate.t("LABEL.PLAYLISTS"), translate.t("LABEL.LIST")])); - let active_page = use_state(|| PlaylistEditorPage::List); - - let handle_breadcrumb_select = { - let view_visible = active_page.clone(); - Callback::from(move |(_name, index)| { - if index == 0 && *view_visible != PlaylistEditorPage::List { - view_visible.set(PlaylistEditorPage::List); - } - }) - }; - - { - let breadcrumbs = breadcrumbs.clone(); - let view_visible_dep = active_page.clone(); - let view_visible = active_page.clone(); - let translate = translate.clone(); - use_effect_with(view_visible_dep, move |_| match *view_visible { - PlaylistEditorPage::List => { - breadcrumbs.set(Rc::new(vec![translate.t("LABEL.PLAYLISTS"), translate.t("LABEL.LIST")])) - } - PlaylistEditorPage::Create => { - breadcrumbs.set(Rc::new(vec![translate.t("LABEL.PLAYLISTS"), translate.t("LABEL.CREATE")])) - } - }); - }; - - let context = PlaylistEditorContext { active_page: active_page.clone() }; - - html! { - context={context}> -
- -
- - - - - - -
-
-
> - } -} diff --git a/frontend/src/app/components/playlist/playlist_explorer_view.rs b/frontend/src/app/components/playlist/playlist_explorer_view.rs index ec800563c..82f8571f4 100644 --- a/frontend/src/app/components/playlist/playlist_explorer_view.rs +++ b/frontend/src/app/components/playlist/playlist_explorer_view.rs @@ -33,7 +33,7 @@ pub fn PlaylistExplorerView() -> Html { let translate = translate.clone(); use_effect_with(view_visible_dep, move |_| match *view_visible { PlaylistExplorerPage::SourceSelector => { - breadcrumbs.set(Rc::new(vec![translate.t("LABEL.PLAYLIST_EXPLORER"), translate.t("LABEL.SOURCES")])) + breadcrumbs.set(Rc::new(vec![translate.t("LABEL.PLAYLISTS"), translate.t("LABEL.SOURCES")])) } }); }; diff --git a/frontend/src/app/components/playlist/playlist_settings_view.rs b/frontend/src/app/components/playlist/playlist_settings_view.rs new file mode 100644 index 000000000..384831151 --- /dev/null +++ b/frontend/src/app/components/playlist/playlist_settings_view.rs @@ -0,0 +1,19 @@ +use crate::app::components::{Breadcrumbs, PlaylistList}; +use std::rc::Rc; +use yew::prelude::*; +use yew_i18n::use_translation; + +#[function_component] +pub fn PlaylistSettingsView() -> Html { + let translate = use_translation(); + let breadcrumbs = use_state(|| Rc::new(vec![translate.t("LABEL.PLAYLISTS"), translate.t("LABEL.LIST")])); + + html! { +
+ +
+ +
+
+ } +} diff --git a/frontend/src/app/components/playlist/target/target_options.rs b/frontend/src/app/components/playlist/target/target_options.rs index d4d69adea..b2693c403 100644 --- a/frontend/src/app/components/playlist/target/target_options.rs +++ b/frontend/src/app/components/playlist/target/target_options.rs @@ -1,4 +1,4 @@ -use crate::app::components::{make_tags, CollapsePanel, Tag, TagList}; +use crate::app::components::{make_tags, Tag, TagList}; use shared::model::{ClusterFlags, ConfigTargetDto}; use std::rc::Rc; use yew::prelude::*; @@ -55,13 +55,10 @@ pub fn TargetOptions(props: &TargetOptionsProps) -> Html { (flags.iter().any(|&v| v), make_tags(&options, &translate), make_tags(&redirect, &translate)) }); - let has_options = tags.0; let opts: Vec> = (tags.1).clone(); let redirect: Vec> = (tags.2).clone(); html! { -
@@ -71,6 +68,5 @@ pub fn TargetOptions(props: &TargetOptionsProps) -> Html {
-
} } diff --git a/frontend/src/app/components/playlist/target/target_output.rs b/frontend/src/app/components/playlist/target/target_output.rs index 5ef786010..48d9a7a88 100644 --- a/frontend/src/app/components/playlist/target/target_output.rs +++ b/frontend/src/app/components/playlist/target/target_output.rs @@ -1,4 +1,4 @@ -use crate::app::components::{CollapsePanel, HdHomeRunOutput, M3uOutput, StrmOutput, XtreamOutput}; +use crate::app::components::{HdHomeRunOutput, M3uOutput, RevealContent, StrmOutput, XtreamOutput}; use shared::model::{ConfigTargetDto, TargetOutputDto}; use std::rc::Rc; use yew::prelude::*; @@ -19,26 +19,40 @@ pub fn TargetOutput(props: &TargetOutputProps) -> Html { props.target.output.iter().map(|output: &TargetOutputDto| { match output { TargetOutputDto::Xtream(xc) => html! { - + + {translate.t("LABEL.XTREAM")} + + }}> - + }, TargetOutputDto::M3u(m3u) => html! { - + + {translate.t("LABEL.M3U")} + + }}> - + }, TargetOutputDto::Strm(strm) => html! { - + + {translate.t("LABEL.STRM")} + + }}> - + }, TargetOutputDto::HdHomeRun(hdhr) => html! { - + + {translate.t("LABEL.HDHOMERUN")} + + }}> - + }, } }).collect::() diff --git a/frontend/src/app/components/playlist/target_table.rs b/frontend/src/app/components/playlist/target_table.rs index 7837ccd98..f8e401706 100644 --- a/frontend/src/app/components/playlist/target_table.rs +++ b/frontend/src/app/components/playlist/target_table.rs @@ -102,7 +102,7 @@ pub fn TargetTable(props: &TargetTableProps) -> Html { /> }, 2 => html! { dto.name.as_str() }, 3 => html! { }, - 4 => html! { }, + 4 => html! { }, 5 => dto.sort.as_ref().map_or_else(|| html! {}, |_s| html! { }), 6 => dto.t_filter.as_ref().map_or_else(|| html! {}, |f| html! { })}> }), 7 => dto.rename.as_ref().map_or_else(|| html! {}, |_r| html! { }), diff --git a/frontend/src/app/components/sidebar.rs b/frontend/src/app/components/sidebar.rs index 11f226eee..f224f8dea 100644 --- a/frontend/src/app/components/sidebar.rs +++ b/frontend/src/app/components/sidebar.rs @@ -136,7 +136,7 @@ pub fn Sidebar(props: &SidebarProps) -> Html { - + @@ -156,7 +156,7 @@ pub fn Sidebar(props: &SidebarProps) -> Html { - +
diff --git a/frontend/src/app/context.rs b/frontend/src/app/context.rs index 69f5fea9b..5971a4745 100644 --- a/frontend/src/app/context.rs +++ b/frontend/src/app/context.rs @@ -1,4 +1,4 @@ -use crate::app::components::{InputRow, PlaylistEditorPage, PlaylistExplorerPage, UserlistPage}; +use crate::app::components::{InputRow, PlaylistExplorerPage, UserlistPage}; use shared::model::{ ApiProxyConfigDto, AppConfigDto, ConfigTargetDto, PlaylistRequest, ProxyUserCredentialsDto, SearchRequest, StatusCheck, SystemInfo, UiPlaylistCategories, @@ -13,11 +13,6 @@ pub struct PlaylistContext { pub sources: Rc>>>, } -#[derive(Clone, PartialEq)] -pub struct PlaylistEditorContext { - pub active_page: UseStateHandle, -} - #[derive(Clone, PartialEq)] pub struct PlaylistExplorerContext { pub active_page: UseStateHandle, diff --git a/frontend/src/model/view_type.rs b/frontend/src/model/view_type.rs index bf928109a..972011147 100644 --- a/frontend/src/model/view_type.rs +++ b/frontend/src/model/view_type.rs @@ -7,7 +7,10 @@ const STREAMS: &str = "streams"; const USERS: &str = "users"; const CONFIG: &str = "config"; const PLAYLIST_UPDATE: &str = "playlist_update"; -const PLAYLIST_EDITOR: &str = "playlist_editor"; +const PLAYLIST_SETTINGS: &str = "PlaylistSettings"; +const PLAYLIST_SETTINGS_LOWER: &str = "playlistsettings"; +const PLAYLIST_SETTINGS_SNAKE: &str = "playlist_settings"; +const PLAYLIST_EDITOR_LEGACY: &str = "playlist_editor"; const PLAYLIST_EXPLORER: &str = "playlist_explorer"; const PLAYLIST_EPG: &str = "playlist_epg"; const SOURCE_EDITOR: &str = "source_editor"; @@ -21,7 +24,7 @@ pub enum ViewType { Config, SourceEditor, PlaylistUpdate, - PlaylistEditor, + PlaylistSettings, PlaylistExplorer, PlaylistEpg, } @@ -38,7 +41,9 @@ impl FromStr for ViewType { CONFIG => Ok(ViewType::Config), SOURCE_EDITOR => Ok(ViewType::SourceEditor), PLAYLIST_UPDATE => Ok(ViewType::PlaylistUpdate), - PLAYLIST_EDITOR => Ok(ViewType::PlaylistEditor), + PLAYLIST_SETTINGS_LOWER | PLAYLIST_SETTINGS_SNAKE | PLAYLIST_EDITOR_LEGACY => { + Ok(ViewType::PlaylistSettings) + } PLAYLIST_EXPLORER => Ok(ViewType::PlaylistExplorer), PLAYLIST_EPG => Ok(ViewType::PlaylistEpg), _ => info_err_res!("Unknown view type: {s}"), @@ -56,7 +61,7 @@ impl fmt::Display for ViewType { ViewType::Config => CONFIG, ViewType::SourceEditor => SOURCE_EDITOR, ViewType::PlaylistUpdate => PLAYLIST_UPDATE, - ViewType::PlaylistEditor => PLAYLIST_EDITOR, + ViewType::PlaylistSettings => PLAYLIST_SETTINGS, ViewType::PlaylistExplorer => PLAYLIST_EXPLORER, ViewType::PlaylistEpg => PLAYLIST_EPG, };