Rewild Your Web
18
fork

Configure Feed

Select the types of activity you want to include in your feed.

ui: media center UI with spatial navigation

Signed-off-by: webbeef <me@webbeef.org>

webbeef c25c2721 70675f2a

+4395 -166
+9
crates/beaver_shell/src/main.rs
··· 696 696 697 697 // Computes the index url depending on preferences and screen size. 698 698 fn index_url(event_loop: &ActiveEventLoop) -> String { 699 + // Media center mode takes priority 700 + let media_center = match get_embedder_pref("beaver.media_center_mode") { 701 + Some(PrefValue::Bool(value)) => value, 702 + _ => false, 703 + }; 704 + if media_center { 705 + return "beaver://system/mediacenter/index.html".into(); 706 + } 707 + 699 708 let pref_override = match get_embedder_pref("beaver.mobile_simulation") { 700 709 Some(PrefValue::Bool(value)) => value, 701 710 _ => false,
+5
crates/beaver_shell/src/prefs.rs
··· 26 26 /// Force mobile UI mode regardless of screen size (for development) 27 27 #[serde(default = "default_false")] 28 28 pub mobile_simulation: bool, 29 + /// Use the media center UI instead of the browser 30 + #[serde(default = "default_false")] 31 + pub media_center_mode: bool, 29 32 } 30 33 31 34 fn default_start_url() -> String { ··· 57 60 search_engine: String::new(), 58 61 ime_virtual_keyboard_enabled: false, 59 62 mobile_simulation: false, 63 + media_center_mode: false, 60 64 }; 61 65 } 62 66 ··· 69 73 search_engine: default_search_engine(), 70 74 ime_virtual_keyboard_enabled: false, 71 75 mobile_simulation: false, 76 + media_center_mode: false, 72 77 } 73 78 } 74 79 }
+21
patches/components/constellation/browsingcontext.rs.patch
··· 1 + --- original 2 + +++ modified 3 + @@ -71,6 +71,10 @@ 4 + /// All the pipelines that have been presented or will be presented in 5 + /// this browsing context. 6 + pub pipelines: FxHashSet<PipelineId>, 7 + + 8 + + /// Whether spatial navigation is enabled for this browsing context. 9 + + /// When set, new documents in this context will have spatial navigation enabled. 10 + + pub spatial_navigation: bool, 11 + } 12 + 13 + impl BrowsingContext { 14 + @@ -101,6 +105,7 @@ 15 + pipeline_id, 16 + parent_pipeline_id, 17 + pipelines, 18 + + spatial_navigation: false, 19 + } 20 + } 21 +
+112 -12
patches/components/constellation/constellation.rs.patch
··· 396 396 ScriptToConstellationMessage::MediaSessionEvent(pipeline_id, event) => { 397 397 // Unlikely at this point, but we may receive events coming from 398 398 // different media sessions, so we set the active media session based 399 - @@ -2052,7 +2204,12 @@ 399 + @@ -2052,8 +2204,13 @@ 400 400 } 401 401 self.active_media_session = Some(pipeline_id); 402 402 self.constellation_to_embedder_proxy.send( 403 403 - ConstellationToEmbedderMsg::MediaSessionEvent(webview_id, event), 404 404 + ConstellationToEmbedderMsg::MediaSessionEvent(webview_id, event.clone()), 405 - + ); 405 + ); 406 406 + // Also route to embedded webview parent iframe. 407 407 + self.handle_embedded_webview_notification( 408 408 + webview_id, 409 409 + EmbeddedWebViewEventType::MediaSessionEvent(event), 410 - ); 410 + + ); 411 411 }, 412 412 #[cfg(feature = "webgpu")] 413 - @@ -2127,7 +2284,842 @@ 413 + ScriptToConstellationMessage::RequestAdapter(response_sender, options, ids) => self 414 + @@ -2127,7 +2284,862 @@ 414 415 } 415 416 }, 416 417 }, ··· 490 491 + }, 491 492 + ScriptToConstellationMessage::EmbeddedWebViewFocus(embedded_webview_id) => { 492 493 + self.handle_focus_web_view(embedded_webview_id); 494 + + }, 495 + + ScriptToConstellationMessage::EmbeddedWebViewUseSpatialNavigation( 496 + + embedded_webview_id, 497 + + enabled, 498 + + ) => { 499 + + let browsing_context_id = BrowsingContextId::from(embedded_webview_id); 500 + + if let Some(browsing_context) = self.browsing_contexts.get_mut(&browsing_context_id) 501 + + { 502 + + // Store on the browsing context so it persists across navigations 503 + + browsing_context.spatial_navigation = enabled; 504 + + let pipeline_id = browsing_context.pipeline_id; 505 + + if let Some(pipeline) = self.pipelines.get(&pipeline_id) { 506 + + let _ = pipeline.event_loop.send( 507 + + script_traits::ScriptThreadMessage::SetSpatialNavigation( 508 + + pipeline_id, 509 + + enabled, 510 + + ), 511 + + ); 512 + + } 513 + + } 493 514 + }, 494 515 + ScriptToConstellationMessage::ForwardEventToEmbeddedWebView( 495 516 + embedded_webview_id, ··· 1219 1240 + } 1220 1241 + } 1221 1242 + return; 1222 - } 1243 + + } 1223 1244 + 1224 1245 + // Handle peer disconnect: clean up remote channel state. 1225 1246 + if let PairingEvent::PeerExpired { ref id } = event { ··· 1243 1264 + if !channels.is_empty() { 1244 1265 + self.pairing.sync_channels_to_peer(id, &channels); 1245 1266 + } 1246 - + } 1267 + } 1247 1268 + 1248 1269 + for event_loop in self.event_loops() { 1249 1270 + if self.embedder_error_listeners.contains(&event_loop.id()) { ··· 1253 1274 } 1254 1275 1255 1276 /// Check the origin of a message against that of the pipeline it came from. 1256 - @@ -2446,6 +3438,55 @@ 1277 + @@ -2446,6 +3458,55 @@ 1257 1278 TransferState::TransferInProgress(queue) => queue.push_back(task), 1258 1279 TransferState::CompletionFailed(queue) => queue.push_back(task), 1259 1280 TransferState::CompletionRequested(_, queue) => queue.push_back(task), ··· 1309 1330 } 1310 1331 } 1311 1332 1312 - @@ -3329,6 +4370,40 @@ 1333 + @@ -3329,6 +4390,40 @@ 1313 1334 /// <https://html.spec.whatwg.org/multipage/#destroy-a-top-level-traversable> 1314 1335 fn handle_close_top_level_browsing_context(&mut self, webview_id: WebViewId) { 1315 1336 debug!("{webview_id}: Closing"); ··· 1350 1371 let browsing_context_id = BrowsingContextId::from(webview_id); 1351 1372 // Step 5. Remove traversable from the user agent's top-level traversable set. 1352 1373 let browsing_context = 1353 - @@ -3605,8 +4680,27 @@ 1374 + @@ -3605,8 +4700,27 @@ 1354 1375 opener_webview_id, 1355 1376 opener_pipeline_id, 1356 1377 response_sender, ··· 1378 1399 let Some((webview_id_sender, webview_id_receiver)) = generic_channel::channel() else { 1379 1400 warn!("Failed to create channel"); 1380 1401 let _ = response_sender.send(None); 1381 - @@ -3705,6 +4799,395 @@ 1402 + @@ -3705,6 +4819,395 @@ 1382 1403 }); 1383 1404 } 1384 1405 ··· 1774 1795 #[servo_tracing::instrument(skip_all)] 1775 1796 fn handle_refresh_cursor(&self, pipeline_id: PipelineId) { 1776 1797 let Some(pipeline) = self.pipelines.get(&pipeline_id) else { 1777 - @@ -4830,7 +6313,7 @@ 1798 + @@ -4254,7 +5757,7 @@ 1799 + }, 1800 + }; 1801 + 1802 + - let (old_pipeline_id, parent_pipeline_id, webview_id) = 1803 + + let (old_pipeline_id, parent_pipeline_id, webview_id, spatial_navigation) = 1804 + match self.browsing_contexts.get_mut(&browsing_context_id) { 1805 + Some(browsing_context) => { 1806 + let old_pipeline_id = browsing_context.pipeline_id; 1807 + @@ -4263,6 +5766,7 @@ 1808 + old_pipeline_id, 1809 + browsing_context.parent_pipeline_id, 1810 + browsing_context.webview_id, 1811 + + browsing_context.spatial_navigation, 1812 + ) 1813 + }, 1814 + None => { 1815 + @@ -4272,6 +5776,15 @@ 1816 + 1817 + self.unload_document(old_pipeline_id); 1818 + 1819 + + // Propagate spatial navigation flag to the new pipeline 1820 + + if spatial_navigation { 1821 + + if let Some(pipeline) = self.pipelines.get(&new_pipeline_id) { 1822 + + let _ = pipeline.event_loop.send( 1823 + + script_traits::ScriptThreadMessage::SetSpatialNavigation(new_pipeline_id, true), 1824 + + ); 1825 + + } 1826 + + } 1827 + + 1828 + if let Some(new_pipeline) = self.pipelines.get(&new_pipeline_id) { 1829 + if let Some(ref chan) = self.devtools_sender { 1830 + let state = NavigationState::Start(new_pipeline.url.clone()); 1831 + @@ -4830,7 +6343,7 @@ 1778 1832 } 1779 1833 1780 1834 #[servo_tracing::instrument(skip_all)] ··· 1783 1837 // Send a flat projection of the history to embedder. 1784 1838 // The final vector is a concatenation of the URLs of the past 1785 1839 // entries, the current entry and the future entries. 1786 - @@ -4934,9 +6417,22 @@ 1840 + @@ -4934,9 +6447,22 @@ 1787 1841 self.constellation_to_embedder_proxy 1788 1842 .send(ConstellationToEmbedderMsg::HistoryChanged( 1789 1843 webview_id, ··· 1807 1861 } 1808 1862 1809 1863 #[servo_tracing::instrument(skip_all)] 1864 + @@ -4955,7 +6481,7 @@ 1865 + } 1866 + } 1867 + 1868 + - let (old_pipeline_id, webview_id) = 1869 + + let (old_pipeline_id, webview_id, spatial_navigation) = 1870 + match self.browsing_contexts.get_mut(&change.browsing_context_id) { 1871 + Some(browsing_context) => { 1872 + debug!("Adding pipeline to existing browsing context."); 1873 + @@ -4962,11 +6488,15 @@ 1874 + let old_pipeline_id = browsing_context.pipeline_id; 1875 + browsing_context.pipelines.insert(change.new_pipeline_id); 1876 + browsing_context.update_current_entry(change.new_pipeline_id); 1877 + - (Some(old_pipeline_id), Some(browsing_context.webview_id)) 1878 + + ( 1879 + + Some(old_pipeline_id), 1880 + + Some(browsing_context.webview_id), 1881 + + browsing_context.spatial_navigation, 1882 + + ) 1883 + }, 1884 + None => { 1885 + debug!("Adding pipeline to new browsing context."); 1886 + - (None, None) 1887 + + (None, None, false) 1888 + }, 1889 + }; 1890 + 1891 + @@ -4974,6 +6504,18 @@ 1892 + self.unload_document(old_pipeline_id); 1893 + } 1894 + 1895 + + // Propagate spatial navigation flag to the new pipeline 1896 + + if spatial_navigation { 1897 + + if let Some(pipeline) = self.pipelines.get(&change.new_pipeline_id) { 1898 + + let _ = pipeline.event_loop.send( 1899 + + script_traits::ScriptThreadMessage::SetSpatialNavigation( 1900 + + change.new_pipeline_id, 1901 + + true, 1902 + + ), 1903 + + ); 1904 + + } 1905 + + } 1906 + + 1907 + let Some(webview) = self.webviews.get_mut(&change.webview_id) else { 1908 + return warn!("Ignoring history change in non-existent WebView ({webview_id:?})."); 1909 + };
+4 -1
patches/components/constellation/tracing.rs.patch
··· 36 36 Self::ActivateDocument => target!("ActivateDocument"), 37 37 Self::SetDocumentState(..) => target!("SetDocumentState"), 38 38 Self::SetFinalUrl(..) => target!("SetFinalUrl"), 39 - @@ -191,6 +199,58 @@ 39 + @@ -191,6 +199,61 @@ 40 40 Self::TriggerGarbageCollection => target!("TriggerGarbageCollection"), 41 41 Self::AcquireWakeLock(..) => target!("AcquireWakeLock"), 42 42 Self::ReleaseWakeLock(..) => target!("ReleaseWakeLock"), ··· 56 56 + }, 57 57 + Self::EmbeddedWebViewFocus(..) => { 58 58 + target!("EmbeddedWebViewFocus") 59 + + }, 60 + + Self::EmbeddedWebViewUseSpatialNavigation(..) => { 61 + + target!("EmbeddedWebViewUseSpatialNavigation") 59 62 + }, 60 63 + Self::ForwardEventToEmbeddedWebView(..) => { 61 64 + target!("ForwardEventToEmbeddedWebView")
+40 -10
patches/components/script/dom/document/document.rs.patch
··· 34 34 /// All websockets created that are associated with this document. 35 35 websockets: DOMTracker<WebSocket>, 36 36 37 - @@ -858,6 +864,12 @@ 37 + @@ -639,6 +645,11 @@ 38 + 39 + /// <https://w3c.github.io/editing/docs/execCommand/#css-styling-flag> 40 + css_styling_flag: Cell<bool>, 41 + + 42 + + /// Whether spatial navigation is enabled for this document. 43 + + /// When enabled, arrow keys move focus to the nearest focusable element 44 + + /// in the given direction instead of scrolling. 45 + + spatial_navigation_enabled: Cell<bool>, 46 + } 47 + 48 + impl Document { 49 + @@ -858,6 +869,12 @@ 38 50 39 51 // Set the document's activity level, reflow if necessary, and suspend or resume timers. 40 52 self.activity.set(activity); ··· 47 59 let media = ServoMedia::get(); 48 60 let pipeline_id = self.window().pipeline_id(); 49 61 let client_context_id = 50 - @@ -871,6 +883,7 @@ 62 + @@ -871,6 +888,7 @@ 51 63 52 64 self.title_changed(); 53 65 self.notify_embedder_favicon(); ··· 55 67 self.dirty_all_nodes(); 56 68 self.window().resume(CanGc::from_cx(cx)); 57 69 media.resume(&client_context_id); 58 - @@ -1269,6 +1282,9 @@ 70 + @@ -1269,6 +1287,9 @@ 59 71 LoadStatus::Started, 60 72 )); 61 73 self.send_to_embedder(EmbedderMsg::Status(self.webview_id(), None)); ··· 65 77 } 66 78 }, 67 79 DocumentReadyState::Complete => { 68 - @@ -1277,6 +1293,9 @@ 80 + @@ -1277,6 +1298,9 @@ 69 81 self.webview_id(), 70 82 LoadStatus::Complete, 71 83 )); ··· 75 87 } 76 88 update_with_current_instant(&self.dom_complete); 77 89 }, 78 - @@ -1355,7 +1374,13 @@ 90 + @@ -1355,7 +1379,13 @@ 79 91 let window = self.window(); 80 92 if window.is_top_level() { 81 93 let title = self.title().map(String::from); ··· 90 102 } 91 103 } 92 104 93 - @@ -1364,6 +1389,18 @@ 105 + @@ -1364,6 +1394,18 @@ 94 106 window.send_to_embedder(msg); 95 107 } 96 108 ··· 109 121 pub(crate) fn dirty_all_nodes(&self) { 110 122 let root = match self.GetDocumentElement() { 111 123 Some(root) => root, 112 - @@ -2919,9 +2956,59 @@ 124 + @@ -2919,9 +2961,59 @@ 113 125 current_rendering_epoch, 114 126 ); 115 127 ··· 169 181 pub(crate) fn handle_no_longer_waiting_on_asynchronous_image_updates(&self) { 170 182 self.waiting_on_canvas_image_updates.set(false); 171 183 } 172 - @@ -3636,6 +3723,7 @@ 184 + @@ -3636,6 +3728,7 @@ 173 185 active_sandboxing_flag_set: Cell::new(creation_sandboxing_flag_set), 174 186 creation_sandboxing_flag_set: Cell::new(creation_sandboxing_flag_set), 175 187 favicon: RefCell::new(None), ··· 177 189 websockets: DOMTracker::new(), 178 190 details_name_groups: Default::default(), 179 191 protocol_handler_automation_mode: Default::default(), 180 - @@ -4587,6 +4675,36 @@ 192 + @@ -3644,6 +3737,7 @@ 193 + value_override: Default::default(), 194 + default_single_line_container_name: Default::default(), 195 + css_styling_flag: Default::default(), 196 + + spatial_navigation_enabled: Cell::new(false), 197 + } 198 + } 199 + 200 + @@ -4587,6 +4681,36 @@ 181 201 182 202 pub(crate) fn notify_embedder_favicon(&self) { 183 203 if let Some(ref image) = *self.favicon.borrow() { ··· 214 234 self.send_to_embedder(EmbedderMsg::NewFavicon(self.webview_id(), image.clone())); 215 235 } 216 236 } 217 - @@ -4657,6 +4775,20 @@ 237 + @@ -4657,6 +4781,30 @@ 218 238 pub(crate) fn set_css_styling_flag(&self, value: bool) { 219 239 self.css_styling_flag.set(value) 220 240 } 241 + + 242 + + /// Whether spatial navigation is enabled for this document. 243 + + pub(crate) fn spatial_navigation_enabled(&self) -> bool { 244 + + self.spatial_navigation_enabled.get() 245 + + } 246 + + 247 + + /// Enable or disable spatial navigation for this document. 248 + + pub(crate) fn set_spatial_navigation_enabled(&self, value: bool) { 249 + + self.spatial_navigation_enabled.set(value) 250 + + } 221 251 + 222 252 + pub(crate) fn notify_embedder_theme_color(&self) { 223 253 + if let Some(ref theme_color) = *self.theme_color.borrow() {
+29
patches/components/script/dom/document/document_event_handler.rs.patch
··· 742 742 let Some(el) = hit_test_result 743 743 .node 744 744 .inclusive_ancestors(ShadowIncluding::Yes) 745 + @@ -1952,6 +2471,28 @@ 746 + return; 747 + } 748 + 749 + + // Spatial navigation: arrow keys move focus, Enter activates. 750 + + // This is checked before scroll handling so it takes priority when enabled. 751 + + let document = self.window.Document(); 752 + + match event.key() { 753 + + Key::Named( 754 + + NamedKey::ArrowUp | 755 + + NamedKey::ArrowDown | 756 + + NamedKey::ArrowLeft | 757 + + NamedKey::ArrowRight, 758 + + ) => { 759 + + if super::spatial_navigation::handle_arrow_key(&document, node, event, can_gc) { 760 + + return; 761 + + } 762 + + }, 763 + + Key::Named(NamedKey::Enter) => { 764 + + if super::spatial_navigation::handle_enter_key(&document, node, event, can_gc) { 765 + + return; 766 + + } 767 + + }, 768 + + _ => {}, 769 + + } 770 + + 771 + let mut is_space = false; 772 + let scroll = match event.key() { 773 + Key::Named(NamedKey::ArrowDown) => KeyboardScroll::Down,
+9
patches/components/script/dom/document/mod.rs.patch
··· 1 + --- original 2 + +++ modified 3 + @@ -10,5 +10,6 @@ 4 + pub(crate) mod documentorshadowroot; 5 + pub(crate) mod documenttype; 6 + pub(crate) mod focus; 7 + +pub(crate) mod spatial_navigation; 8 + 9 + pub(crate) use self::document::*;
+333
patches/components/script/dom/document/spatial_navigation.rs.patch
··· 1 + --- original 2 + +++ modified 3 + @@ -0,0 +1,330 @@ 4 + +/* This Source Code Form is subject to the terms of the Mozilla Public 5 + + * License, v. 2.0. If a copy of the MPL was not distributed with this 6 + + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ 7 + + 8 + +//! Spatial navigation: navigate to the nearest focusable element using arrow keys. 9 + +//! 10 + +//! When enabled on a document, arrow keys move focus to the closest focusable 11 + +//! element in the given direction (up/down/left/right) instead of scrolling. 12 + +//! Enter activates the focused element with a synthetic click. 13 + +//! 14 + +//! Pages can opt out with `<meta name="spatial-navigation" content="disabled">`. 15 + + 16 + +use app_units::Au; 17 + +use euclid::Rect; 18 + +use html5ever::local_name; 19 + +use keyboard_types::{Key, NamedKey}; 20 + +use log::error; 21 + +use script_bindings::codegen::GenericBindings::DocumentBinding::DocumentMethods; 22 + +use script_bindings::codegen::GenericBindings::ElementBinding::ScrollLogicalPosition; 23 + +use script_bindings::codegen::GenericBindings::HTMLElementBinding::HTMLElementMethods; 24 + +use script_bindings::codegen::GenericBindings::WindowBinding::ScrollBehavior; 25 + +use script_bindings::inheritance::Castable; 26 + +use script_bindings::root::DomRoot; 27 + +use script_bindings::script_runtime::CanGc; 28 + +use style_traits::CSSPixel; 29 + + 30 + +use crate::dom::document::Document; 31 + +use crate::dom::element::element::Element; 32 + +use crate::dom::event::keyboardevent::KeyboardEvent; 33 + +use crate::dom::html::htmlelement::HTMLElement; 34 + +use crate::dom::html::htmlmetaelement::HTMLMetaElement; 35 + +use crate::dom::node::{Node, ShadowIncluding}; 36 + +use crate::dom::scrolling_box::{ScrollAxisState, ScrollRequirement}; 37 + + 38 + +/// Direction for spatial navigation. 39 + +#[derive(Clone, Copy, Debug)] 40 + +enum Direction { 41 + + Up, 42 + + Down, 43 + + Left, 44 + + Right, 45 + +} 46 + + 47 + +impl Direction { 48 + + fn from_key(key: &Key) -> Option<Self> { 49 + + match key { 50 + + Key::Named(NamedKey::ArrowUp) => Some(Direction::Up), 51 + + Key::Named(NamedKey::ArrowDown) => Some(Direction::Down), 52 + + Key::Named(NamedKey::ArrowLeft) => Some(Direction::Left), 53 + + Key::Named(NamedKey::ArrowRight) => Some(Direction::Right), 54 + + _ => None, 55 + + } 56 + + } 57 + +} 58 + + 59 + +/// Handle an arrow key press for spatial navigation. 60 + +/// Returns true if the event was handled (spatial nav is enabled and a candidate was found). 61 + +pub(crate) fn handle_arrow_key( 62 + + document: &Document, 63 + + _node: &Node, 64 + + event: &KeyboardEvent, 65 + + can_gc: CanGc, 66 + +) -> bool { 67 + + if !document.spatial_navigation_enabled() { 68 + + return false; 69 + + } 70 + + 71 + + // Check for page-level opt-out via <meta name="spatial-navigation" content="disabled"> 72 + + if is_opted_out(document) { 73 + + return false; 74 + + } 75 + + 76 + + let Some(direction) = Direction::from_key(&event.key()) else { 77 + + return false; 78 + + }; 79 + + 80 + + spatial_focus_navigation(document, direction, can_gc) 81 + +} 82 + + 83 + +/// Handle Enter key for spatial navigation activation. 84 + +/// Returns true if the event was handled. 85 + +pub(crate) fn handle_enter_key( 86 + + document: &Document, 87 + + _node: &Node, 88 + + _event: &KeyboardEvent, 89 + + can_gc: CanGc, 90 + +) -> bool { 91 + + if !document.spatial_navigation_enabled() { 92 + + return false; 93 + + } 94 + + 95 + + if is_opted_out(document) { 96 + + return false; 97 + + } 98 + + 99 + + // Get currently focused element 100 + + let focused: Option<DomRoot<Element>> = document 101 + + .focus_handler() 102 + + .focused_area() 103 + + .element() 104 + + .map(|e| DomRoot::from_ref(e)); 105 + + 106 + + let Some(focused) = focused else { 107 + + return false; 108 + + }; 109 + + 110 + + // Simulate click on the focused element 111 + + if let Some(html_element) = focused.downcast::<HTMLElement>() { 112 + + debug!("[SpatialNav] Enter: clicking <{}>", focused.local_name()); 113 + + html_element.Click(can_gc); 114 + + return true; 115 + + } 116 + + 117 + + debug!("[SpatialNav] Enter: focused element is not an HTMLElement"); 118 + + false 119 + +} 120 + + 121 + +/// Check if the page has opted out of spatial navigation via 122 + +/// `<meta name="spatial-navigation" content="disabled">`. 123 + +fn is_opted_out(document: &Document) -> bool { 124 + + let Some(root) = document.GetDocumentElement() else { 125 + + return false; 126 + + }; 127 + + for node in root.upcast::<Node>().traverse_preorder(ShadowIncluding::No) { 128 + + if let Some(meta) = node.downcast::<HTMLMetaElement>() { 129 + + let element = meta.upcast::<Element>(); 130 + + let name = element.get_string_attribute(&local_name!("name")); 131 + + if name.str().eq_ignore_ascii_case("spatial-navigation") { 132 + + let content = element.get_string_attribute(&local_name!("content")); 133 + + if content.str().eq_ignore_ascii_case("disabled") { 134 + + return true; 135 + + } 136 + + } 137 + + } 138 + + } 139 + + false 140 + +} 141 + + 142 + +/// Move focus to the nearest focusable element in the given direction. 143 + +/// Returns true if focus was moved. 144 + +fn spatial_focus_navigation(document: &Document, direction: Direction, can_gc: CanGc) -> bool { 145 + + // Get the currently focused element and its rect 146 + + let focused: Option<DomRoot<Element>> = document 147 + + .focus_handler() 148 + + .focused_area() 149 + + .element() 150 + + .map(|e| DomRoot::from_ref(e)); 151 + + 152 + + debug!( 153 + + "[SpatialNav] Currently focused: {:?}", 154 + + focused.as_ref().map(|e| e.local_name().to_string()) 155 + + ); 156 + + 157 + + let current_rect: Option<Rect<Au, CSSPixel>> = focused 158 + + .as_ref() 159 + + .and_then(|el: &DomRoot<Element>| el.upcast::<Node>().border_box()); 160 + + 161 + + // If no current focus, pick the first focusable element 162 + + if current_rect.is_none() { 163 + + debug!("[SpatialNav] No current focus, picking first element"); 164 + + return focus_first_element(document, can_gc); 165 + + } 166 + + 167 + + let current_rect = current_rect.unwrap(); 168 + + 169 + + // Collect all focusable elements with their rects 170 + + let Some(root) = document.GetDocumentElement() else { 171 + + debug!("[SpatialNav] No document element"); 172 + + return false; 173 + + }; 174 + + 175 + + let mut total_elements = 0; 176 + + let mut focusable_count = 0; 177 + + let mut in_direction_count = 0; 178 + + let mut best: Option<(DomRoot<Element>, i64)> = None; 179 + + 180 + + for node in root 181 + + .upcast::<Node>() 182 + + .traverse_preorder(ShadowIncluding::Yes) 183 + + { 184 + + let Some(element) = node.downcast::<Element>() else { 185 + + continue; 186 + + }; 187 + + total_elements += 1; 188 + + 189 + + // Skip the currently focused element 190 + + if focused.as_ref().is_some_and(|f| &**f == element) { 191 + + continue; 192 + + } 193 + + 194 + + // Must be sequentially focusable 195 + + if !element.is_sequentially_focusable() { 196 + + continue; 197 + + } 198 + + 199 + + // Must have a layout box 200 + + let Some(candidate_rect) = element.upcast::<Node>().border_box() else { 201 + + continue; 202 + + }; 203 + + 204 + + focusable_count += 1; 205 + + 206 + + // Check if candidate is in the target direction 207 + + if !is_in_direction(&current_rect, &candidate_rect, direction) { 208 + + continue; 209 + + } 210 + + 211 + + in_direction_count += 1; 212 + + 213 + + // Score the candidate 214 + + let score = distance_score(&current_rect, &candidate_rect, direction); 215 + + 216 + + if best 217 + + .as_ref() 218 + + .is_none_or(|(_, best_score)| score < *best_score) 219 + + { 220 + + best = Some((DomRoot::from_ref(element), score)); 221 + + } 222 + + } 223 + + 224 + + debug!( 225 + + "[SpatialNav] Total elements: {}, focusable: {}, in direction: {}", 226 + + total_elements, focusable_count, in_direction_count 227 + + ); 228 + + 229 + + if let Some((ref winner, score)) = best { 230 + + debug!( 231 + + "[SpatialNav] Winner: <{}> score={}", 232 + + winner.local_name(), 233 + + score 234 + + ); 235 + + focus_element(winner, can_gc); 236 + + true 237 + + } else { 238 + + debug!( 239 + + "[SpatialNav] No candidate found in direction {:?}", 240 + + direction 241 + + ); 242 + + false 243 + + } 244 + +} 245 + + 246 + +/// When no element is focused, pick a reasonable starting element. 247 + +fn focus_first_element(document: &Document, can_gc: CanGc) -> bool { 248 + + let Some(root) = document.GetDocumentElement() else { 249 + + debug!("[SpatialNav] focus_first: no document element"); 250 + + return false; 251 + + }; 252 + + 253 + + // Find the first sequentially focusable element in DOM order 254 + + let mut count = 0; 255 + + for node in root 256 + + .upcast::<Node>() 257 + + .traverse_preorder(ShadowIncluding::Yes) 258 + + { 259 + + let Some(element) = node.downcast::<Element>() else { 260 + + continue; 261 + + }; 262 + + if element.is_sequentially_focusable() && element.upcast::<Node>().border_box().is_some() { 263 + + debug!( 264 + + "[SpatialNav] focus_first: picked <{}> (#{} in DOM)", 265 + + element.local_name(), 266 + + count 267 + + ); 268 + + focus_element(element, can_gc); 269 + + return true; 270 + + } 271 + + count += 1; 272 + + } 273 + + 274 + + debug!("[SpatialNav] focus_first: no focusable elements found"); 275 + + false 276 + +} 277 + + 278 + +/// Check if a candidate rect is in the given direction from the current rect. 279 + +fn is_in_direction( 280 + + current: &Rect<Au, CSSPixel>, 281 + + candidate: &Rect<Au, CSSPixel>, 282 + + direction: Direction, 283 + +) -> bool { 284 + + // Use a small tolerance (1 Au) for elements that touch 285 + + let tolerance = Au::from_px(1); 286 + + 287 + + match direction { 288 + + Direction::Up => candidate.max_y() <= current.min_y() + tolerance, 289 + + Direction::Down => candidate.min_y() >= current.max_y() - tolerance, 290 + + Direction::Left => candidate.max_x() <= current.min_x() + tolerance, 291 + + Direction::Right => candidate.min_x() >= current.max_x() - tolerance, 292 + + } 293 + +} 294 + + 295 + +/// Compute a distance score between two rects for spatial navigation. 296 + +/// Lower score = better candidate. 297 + +/// Uses squared Euclidean distance with a penalty for perpendicular offset. 298 + +fn distance_score( 299 + + current: &Rect<Au, CSSPixel>, 300 + + candidate: &Rect<Au, CSSPixel>, 301 + + direction: Direction, 302 + +) -> i64 { 303 + + let cx = (current.min_x() + current.max_x()).0 as i64 / 2; 304 + + let cy = (current.min_y() + current.max_y()).0 as i64 / 2; 305 + + let tx = (candidate.min_x() + candidate.max_x()).0 as i64 / 2; 306 + + let ty = (candidate.min_y() + candidate.max_y()).0 as i64 / 2; 307 + + 308 + + let (axis_dist, perp_dist) = match direction { 309 + + Direction::Up | Direction::Down => ((ty - cy).abs(), (tx - cx).abs()), 310 + + Direction::Left | Direction::Right => ((tx - cx).abs(), (ty - cy).abs()), 311 + + }; 312 + + 313 + + // Heavily penalize off-axis candidates to prefer elements directly above/below/left/right 314 + + axis_dist * axis_dist + perp_dist * perp_dist * 2 315 + +} 316 + + 317 + +/// Focus an element and scroll it into view. 318 + +fn focus_element(element: &Element, can_gc: CanGc) { 319 + + element 320 + + .upcast::<Node>() 321 + + .run_the_focusing_steps(None, can_gc); 322 + + let scroll_axis = ScrollAxisState { 323 + + position: ScrollLogicalPosition::Center, 324 + + requirement: ScrollRequirement::IfNotVisible, 325 + + }; 326 + + element.scroll_into_view_with_options( 327 + + ScrollBehavior::Auto, 328 + + scroll_axis, 329 + + scroll_axis, 330 + + None, 331 + + None, 332 + + ); 333 + +}
+22 -1
patches/components/script/dom/html/htmlembeddedwebview.rs.patch
··· 1 1 --- original 2 2 +++ modified 3 - @@ -0,0 +1,1109 @@ 3 + @@ -0,0 +1,1130 @@ 4 4 +/* This Source Code Form is subject to the terms of the Mozilla Public 5 5 + * License, v. 2.0. If a copy of the MPL was not distributed with this 6 6 + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ ··· 1050 1050 + .send(ScriptToConstellationMessage::EmbeddedWebViewFocus( 1051 1051 + webview_id, 1052 1052 + )) 1053 + + .unwrap(); 1054 + + Ok(()) 1055 + + } 1056 + + 1057 + + /// Enable or disable spatial navigation for this embedded webview. 1058 + + pub(crate) fn embedded_use_spatial_navigation(&self, enabled: bool) -> Fallible<()> { 1059 + + let Some(webview_id) = self.embedded_webview_id() else { 1060 + + return Err(Error::InvalidState(Some( 1061 + + "This iframe is not an embedded webview".to_string(), 1062 + + ))); 1063 + + }; 1064 + + 1065 + + let window = self.owner_window(); 1066 + + window 1067 + + .as_global_scope() 1068 + + .script_to_constellation_chan() 1069 + + .send( 1070 + + ScriptToConstellationMessage::EmbeddedWebViewUseSpatialNavigation( 1071 + + webview_id, enabled, 1072 + + ), 1073 + + ) 1053 1074 + .unwrap(); 1054 1075 + Ok(()) 1055 1076 + }
+11 -8
patches/components/script/dom/html/htmliframeelement.rs.patch
··· 283 283 } 284 284 } 285 285 286 - @@ -721,7 +919,158 @@ 286 + @@ -721,6 +919,157 @@ 287 287 self.webview_id.get() 288 288 } 289 289 ··· 377 377 + 378 378 + /// Get the effective webview ID, taking into account embedded webview mode. 379 379 + /// Returns the embedded webview ID if in embed mode, otherwise the parent webview ID. 380 - #[inline] 380 + + #[inline] 381 381 + pub(crate) fn embedded_webview_id(&self) -> Option<WebViewId> { 382 382 + if self.is_embedded_webview.get() { 383 383 + self.embedded_webview_id.get() ··· 438 438 + self.page_zoom.set(zoom); 439 439 + } 440 440 + 441 - + #[inline] 441 + #[inline] 442 442 pub(crate) fn sandboxing_flag_set(&self) -> SandboxingFlagSet { 443 443 self.sandboxing_flag_set 444 - .get() 445 - @@ -1098,6 +1447,93 @@ 444 + @@ -1098,6 +1447,97 @@ 446 445 447 446 // https://html.spec.whatwg.org/multipage/#dom-iframe-longdesc 448 447 make_url_setter!(SetLongDesc, "longdesc"); ··· 533 532 + fn ForceFocus(&self) -> Fallible<()> { 534 533 + self.embedded_force_focus() 535 534 + } 535 + + 536 + + fn UseSpatialNavigation(&self, enabled: bool) -> Fallible<()> { 537 + + self.embedded_use_spatial_navigation(enabled) 538 + + } 536 539 } 537 540 538 541 impl VirtualMethods for HTMLIFrameElement { 539 - @@ -1153,9 +1589,54 @@ 542 + @@ -1153,9 +1593,54 @@ 540 543 // may be in a different script thread. Instead, we check to see if the parent 541 544 // is in a document tree and has a browsing context, which is what causes 542 545 // the child browsing context to be created. ··· 593 596 } 594 597 }, 595 598 local_name!("loading") => { 596 - @@ -1220,6 +1701,23 @@ 599 + @@ -1220,6 +1705,23 @@ 597 600 598 601 debug!("<iframe> running post connection steps"); 599 602 ··· 617 620 // Step 1. Create a new child navigable for insertedNode. 618 621 self.create_nested_browsing_context(cx); 619 622 620 - @@ -1243,11 +1741,25 @@ 623 + @@ -1243,11 +1745,25 @@ 621 624 fn unbind_from_tree(&self, context: &UnbindContext, can_gc: CanGc) { 622 625 self.super_type().unwrap().unbind_from_tree(context, can_gc); 623 626
+2 -1
patches/components/script/messaging.rs.patch
··· 1 1 --- original 2 2 +++ modified 3 - @@ -109,6 +109,13 @@ 3 + @@ -109,6 +109,14 @@ 4 4 ScriptThreadMessage::UpdatePinchZoomInfos(id, _) => Some(*id), 5 5 ScriptThreadMessage::SetAccessibilityActive(..) => None, 6 6 ScriptThreadMessage::TriggerGarbageCollection => None, ··· 8 8 + ScriptThreadMessage::DispatchServoError(..) => None, 9 9 + ScriptThreadMessage::DispatchPairingEvent(..) => None, 10 10 + ScriptThreadMessage::DispatchPeerStream(..) => None, 11 + + ScriptThreadMessage::SetSpatialNavigation(pipeline_id, ..) => Some(*pipeline_id), 11 12 + ScriptThreadMessage::ShowTaskChooser(..) => None, 12 13 + ScriptThreadMessage::OpenTaskProvider(..) => None, 13 14 + ScriptThreadMessage::TaskProvidersUpdate(..) => None,
+17 -13
patches/components/script/script_thread.rs.patch
··· 56 56 use crate::dom::servoparser::{ParserContext, ServoParser}; 57 57 use crate::dom::types::DebuggerGlobalScope; 58 58 #[cfg(feature = "webgpu")] 59 - @@ -1942,12 +1950,51 @@ 59 + @@ -1942,11 +1950,50 @@ 60 60 self.handle_refresh_cursor(pipeline_id); 61 61 }, 62 62 ScriptThreadMessage::PreferencesUpdated(updates) => { ··· 80 80 + 81 81 + // Dispatch preferencechanged events to all Embedder instances 82 82 + self.dispatch_preference_changed_to_embedders(&updates, CanGc::from_cx(cx)); 83 - }, 83 + + }, 84 84 + ScriptThreadMessage::ShowTaskChooser( 85 85 + request_id, 86 86 + task_name, ··· 108 108 + &providers_json, 109 109 + CanGc::from_cx(cx), 110 110 + ); 111 - + }, 111 + }, 112 112 ScriptThreadMessage::ForwardKeyboardScroll(pipeline_id, scroll) => { 113 113 if let Some(document) = self.documents.borrow().find_document(pipeline_id) { 114 - document.event_handler().do_keyboard_scroll(scroll); 115 - @@ -1982,6 +2029,35 @@ 114 + @@ -1982,6 +2029,40 @@ 116 115 ScriptThreadMessage::TriggerGarbageCollection => unsafe { 117 116 JS_GC(*GlobalScope::get_cx(), GCReason::API); 118 117 }, ··· 145 144 + cx, 146 145 + ); 147 146 + }, 147 + + ScriptThreadMessage::SetSpatialNavigation(pipeline_id, enabled) => { 148 + + if let Some(document) = self.documents.borrow().find_document(pipeline_id) { 149 + + document.set_spatial_navigation_enabled(enabled); 150 + + } 151 + + }, 148 152 } 149 153 } 150 154 151 - @@ -3009,6 +3085,9 @@ 155 + @@ -3009,6 +3090,9 @@ 152 156 .documents 153 157 .borrow() 154 158 .find_iframe(parent_pipeline_id, browsing_context_id); ··· 158 162 if let Some(frame_element) = frame_element { 159 163 frame_element.update_pipeline_id(new_pipeline_id, reason, cx); 160 164 } 161 - @@ -3028,6 +3107,7 @@ 165 + @@ -3028,6 +3112,7 @@ 162 166 // is no need to pass along existing opener information that 163 167 // will be discarded. 164 168 None, ··· 166 170 ); 167 171 } 168 172 } 169 - @@ -3312,6 +3392,155 @@ 173 + @@ -3312,6 +3397,155 @@ 170 174 } 171 175 } 172 176 ··· 322 326 fn ask_constellation_for_top_level_info( 323 327 &self, 324 328 sender_webview_id: WebViewId, 325 - @@ -3420,7 +3649,13 @@ 329 + @@ -3420,7 +3654,13 @@ 326 330 self.senders.pipeline_to_embedder_sender.clone(), 327 331 self.senders.constellation_sender.clone(), 328 332 incomplete.pipeline_id, ··· 337 341 incomplete.viewport_details, 338 342 origin.clone(), 339 343 final_url.clone(), 340 - @@ -3442,6 +3677,8 @@ 344 + @@ -3442,6 +3682,8 @@ 341 345 #[cfg(feature = "webgpu")] 342 346 self.gpu_id_hub.clone(), 343 347 incomplete.load_data.inherited_secure_context, ··· 346 350 incomplete.theme, 347 351 self.this.clone(), 348 352 ); 349 - @@ -3465,6 +3702,7 @@ 353 + @@ -3465,6 +3707,7 @@ 350 354 incomplete.webview_id, 351 355 incomplete.parent_info, 352 356 incomplete.opener, ··· 354 358 ); 355 359 if window_proxy.parent().is_some() { 356 360 // https://html.spec.whatwg.org/multipage/#navigating-across-documents:delaying-load-events-mode-2 357 - @@ -4299,10 +4537,78 @@ 361 + @@ -4299,10 +4542,78 @@ 358 362 document.event_handler().handle_refresh_cursor(); 359 363 } 360 364 ··· 433 437 fn handle_request_screenshot_readiness( 434 438 &self, 435 439 webview_id: WebViewId, 436 - @@ -4343,7 +4649,7 @@ 440 + @@ -4343,7 +4654,7 @@ 437 441 can_gc: CanGc, 438 442 ) { 439 443 let Some(window) = self.documents.borrow().find_window(pipeline_id) else {
+4 -1
patches/components/script_bindings/webidls/EmbeddedWebView.webidl.patch
··· 1 1 --- original 2 2 +++ modified 3 - @@ -0,0 +1,176 @@ 3 + @@ -0,0 +1,179 @@ 4 4 +/* This Source Code Form is subject to the terms of the Mozilla Public 5 5 + * License, v. 2.0. If a copy of the MPL was not distributed with this 6 6 + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ ··· 49 49 + 50 50 + // Focus control — transfers input focus to this embedded webview 51 51 + [Throws] undefined forceFocus(); 52 + + 53 + + // Spatial navigation — enable/disable arrow key focus navigation in this webview 54 + + [Throws] undefined useSpatialNavigation(boolean enabled); 52 55 +}; 53 56 + 54 57 +
+3 -1
patches/components/shared/constellation/from_script_message.rs.patch
··· 209 209 /// Mark a new document as active 210 210 ActivateDocument, 211 211 /// Set the document state for a pipeline (used by screenshot / reftests) 212 - @@ -738,6 +871,114 @@ 212 + @@ -738,6 +871,116 @@ 213 213 /// aggregate lock count and notify the provider only when the count transitions from N to 0. 214 214 /// <https://w3c.github.io/screen-wake-lock/#dfn-release-wake-lock> 215 215 ReleaseWakeLock(WakeLockType), ··· 238 238 + EmbeddedWebViewMediaSessionAction(embedder_traits::MediaSessionActionType), 239 239 + /// Transfer input focus to an embedded webview. 240 240 + EmbeddedWebViewFocus(WebViewId), 241 + + /// Enable or disable spatial navigation for an embedded webview. 242 + + EmbeddedWebViewUseSpatialNavigation(WebViewId, bool), 241 243 + /// Forward an input event to an embedded webview after the parent's DOM hit testing 242 244 + /// determined that the event target is an embedded iframe element. 243 245 + ForwardEventToEmbeddedWebView(WebViewId, InputEventAndId),
+3 -1
patches/components/shared/script/lib.rs.patch
··· 52 52 /// Notify the `ScriptThread` that the Servo renderer is no longer waiting on 53 53 /// asynchronous image uploads for the given `Pipeline`. These are mainly used 54 54 /// by canvas to perform uploads while the display list is being built. 55 - @@ -326,6 +344,24 @@ 55 + @@ -326,6 +344,26 @@ 56 56 SetAccessibilityActive(PipelineId, bool, Epoch), 57 57 /// Force a garbage collection in this script thread. 58 58 TriggerGarbageCollection, ··· 74 74 + /// Dispatch a peer stream event — a remote peer is offering a MessagePort. 75 75 + /// Contains (peer_id, serialized remote port_id bytes, stream_id, from_peer_id, target_url). 76 76 + DispatchPeerStream(String, Vec<u8>, String, String, String), 77 + + /// Enable or disable spatial navigation for a document. 78 + + SetSpatialNavigation(PipelineId, bool), 77 79 } 78 80 79 81 impl fmt::Debug for ScriptThreadMessage {
+42 -115
ui/system/index.js
··· 4 4 import { LayoutManager } from "./layout_manager.js"; 5 5 import { MobileLayoutManager } from "./mobile_layout_manager.js"; 6 6 import { PairingHandler } from "./p2p.js"; 7 + import { MediaBroadcaster } from "./media_broadcast.js"; 7 8 import "./system_menu.js"; 8 9 import "./mobile_action_bar.js"; 9 10 import "./mobile_notification_sheet.js"; ··· 920 921 let mediaSessionWebviewId = null; 921 922 922 923 // P2P media control: broadcast local media state and receive remote state. 923 - // Every broadcast includes the full accumulated state so late joiners get everything. 924 - const mediaChannel = new BroadcastChannel("beaver-media-control"); 925 - let localDeviceName = null; 926 - let localSessionId = null; 927 - let localMediaState = { 928 - title: "", 929 - artist: "", 930 - album: "", 931 - playbackState: "none", 932 - duration: 0, 933 - position: 0, 934 - }; 935 924 const remoteMediaControls = new Map(); // sessionId -> media-control element 936 925 937 - mediaChannel.onmessage = (e) => { 938 - handleRemoteMediaMessage(e.data); 939 - }; 926 + const mediaBroadcaster = new MediaBroadcaster({ 927 + deviceName: null, // resolved lazily below 928 + onRemoteAction: (action) => { 929 + // A remote device is requesting an action on our media session. 930 + if (mediaSessionWebviewId == null) return; 931 + const entry = layoutManager.webviews.get(mediaSessionWebviewId); 932 + if (entry) { 933 + entry.webview.ensureIframe(); 934 + entry.webview.iframe.mediaSessionAction(action); 935 + } 936 + }, 937 + onRemoteState: (data) => { 938 + // A remote device is broadcasting its media state. 939 + const ctrl = getOrCreateRemoteControl(data.sessionId, data.deviceName); 940 + ctrl.title = data.title || ""; 941 + ctrl.artist = data.artist || ""; 942 + ctrl.album = data.album || ""; 943 + ctrl.playbackState = data.playbackState || "none"; 944 + ctrl.duration = data.duration || 0; 945 + ctrl.position = data.position || 0; 946 + ctrl.hasMedia = data.playbackState !== "none"; 947 + updateMediaIcon(); 948 + }, 949 + }); 940 950 941 - // When a paired peer connects (or reconnects), send a hello so active 942 - // players on the other side respond with their current state. 943 - // Delayed to allow the P2P channel sync to complete first. 951 + mediaBroadcaster.resolveDeviceName(); 952 + 953 + // When a paired peer connects, send hello after channel sync. 944 954 navigator.embedder.pairing.addEventListener("peerjoined", () => { 945 955 setTimeout(() => { 946 - mediaChannel.postMessage({ type: "hello" }); 956 + mediaBroadcaster.sendHello(); 947 957 }, 5000); 948 958 }); 949 959 950 - // Lazily resolve local device name. 951 - navigator.embedder.pairing 952 - ?.local() 953 - .then((info) => { 954 - localDeviceName = info.displayName; 955 - }) 956 - .catch(() => {}); 957 - 958 - function broadcastMediaState(eventType, detail) { 959 - if (!localSessionId) return; 960 - // Merge into accumulated state. 961 - if (eventType === "metadata") { 962 - localMediaState.title = detail.title || ""; 963 - localMediaState.artist = detail.artist || ""; 964 - localMediaState.album = detail.album || ""; 965 - } else if (eventType === "playbackstate") { 966 - localMediaState.playbackState = detail.playbackState || "none"; 967 - } else if (eventType === "positionstate") { 968 - localMediaState.duration = detail.duration || 0; 969 - localMediaState.position = detail.position || 0; 970 - } 971 - mediaChannel.postMessage({ 972 - type: "state", 973 - sessionId: localSessionId, 974 - deviceName: localDeviceName || "Unknown device", 975 - ...localMediaState, 976 - }); 977 - } 978 - 979 960 function getOrCreateRemoteControl(sessionId, deviceName) { 980 961 let ctrl = remoteMediaControls.get(sessionId); 981 962 if (!ctrl) { ··· 984 965 ctrl.deviceId = sessionId; 985 966 ctrl.deviceName = deviceName; 986 967 ctrl.hasMedia = true; 987 - ctrl.addEventListener("media-action", handleRemoteMediaAction); 968 + ctrl.addEventListener("media-action", (event) => { 969 + mediaBroadcaster.sendAction(event.detail.deviceId, event.detail.action); 970 + }); 988 971 remoteMediaControls.set(sessionId, ctrl); 989 972 990 - // Insert into the media panel alongside local control. 973 + // Insert into the media panel (desktop) or notification sheet (mobile). 991 974 const panel = document.getElementById("media-panel"); 992 975 if (panel) { 993 976 panel.appendChild(ctrl); 977 + } else if (mobileNotificationSheet) { 978 + // On mobile, insert after the local media control in the sheet's shadow DOM 979 + const localCtrl = mobileNotificationSheet.shadowRoot?.querySelector("#mobile-media-control"); 980 + if (localCtrl) { 981 + localCtrl.after(ctrl); 982 + } 994 983 } 995 984 updateMediaIcon(); 996 985 } ··· 998 987 return ctrl; 999 988 } 1000 989 1001 - function handleRemoteMediaMessage(data) { 1002 - if (data.type === "state") { 1003 - // Ignore our own broadcasts. 1004 - if (data.sessionId === localSessionId) return; 1005 - 1006 - const ctrl = getOrCreateRemoteControl(data.sessionId, data.deviceName); 1007 - ctrl.title = data.title || ""; 1008 - ctrl.artist = data.artist || ""; 1009 - ctrl.album = data.album || ""; 1010 - ctrl.playbackState = data.playbackState || "none"; 1011 - ctrl.duration = data.duration || 0; 1012 - ctrl.position = data.position || 0; 1013 - ctrl.hasMedia = data.playbackState !== "none"; 1014 - updateMediaIcon(); 1015 - } else if (data.type === "action") { 1016 - // A remote device is requesting an action on our media session. 1017 - if (data.sessionId !== localSessionId) return; 1018 - if (mediaSessionWebviewId == null) return; 1019 - const entry = layoutManager.webviews.get(mediaSessionWebviewId); 1020 - if (entry) { 1021 - entry.webview.ensureIframe(); 1022 - entry.webview.iframe.mediaSessionAction(data.action); 1023 - } 1024 - } else if (data.type === "hello") { 1025 - // A peer just joined — send our current state if we have an active session. 1026 - if (localSessionId && localMediaState.playbackState !== "none") { 1027 - mediaChannel.postMessage({ 1028 - type: "state", 1029 - sessionId: localSessionId, 1030 - deviceName: localDeviceName || "Unknown device", 1031 - ...localMediaState, 1032 - }); 1033 - } 1034 - } 1035 - } 1036 - 1037 - function handleRemoteMediaAction(event) { 1038 - // Send the action back to the originating device via BroadcastChannel. 1039 - const msg = { 1040 - type: "action", 1041 - sessionId: event.detail.deviceId, // deviceId on the control is the sessionId 1042 - action: event.detail.action, 1043 - }; 1044 - console.warn("[P2P Media] Sending action:", msg); 1045 - mediaChannel.postMessage(msg); 1046 - } 1047 - 1048 990 if (mediaIcon) { 1049 991 mediaIcon.onclick = () => { 1050 992 const panel = document.getElementById("media-panel"); ··· 1100 1042 .addEventListener("webview-mediasession", (e) => { 1101 1043 const { webviewId, eventType } = e.detail; 1102 1044 mediaSessionWebviewId = webviewId; 1103 - // Generate a session ID on the first media event. 1104 - if (!localSessionId) { 1105 - localSessionId = `ms-${Date.now()}-${Math.random().toString(36).slice(2)}`; 1106 - } 1107 1045 updateMediaControl(mediaControl, eventType, e.detail); 1108 1046 // Also update mobile notification sheet's media control 1109 1047 if (mobileNotificationSheet) { ··· 1113 1051 updateMediaControl(mobileCtrl, eventType, e.detail); 1114 1052 } 1115 1053 // Broadcast to remote peers 1116 - broadcastMediaState(eventType, e.detail); 1117 - // Clear session ID when playback stops 1118 - if (eventType === "playbackstate" && e.detail.playbackState === "none") { 1119 - localSessionId = null; 1120 - localMediaState = { 1121 - title: "", 1122 - artist: "", 1123 - album: "", 1124 - playbackState: "none", 1125 - duration: 0, 1126 - position: 0, 1127 - }; 1128 - } 1054 + mediaBroadcaster.updateState(e.detail); 1129 1055 }); 1130 1056 1131 1057 function handleMediaAction(event) { ··· 1199 1125 const { peerId, url } = e.detail; 1200 1126 if (!peerId || !url) return; 1201 1127 1202 - // Try both system app URLs since we don't know the remote platform. 1128 + // Try all system app URLs since we don't know the remote platform. 1203 1129 const targetURLs = [ 1204 1130 "beaver://system/index.html", 1205 1131 "beaver://system/index_mobile.html", 1132 + "beaver://system/mediacenter/index.html", 1206 1133 ]; 1207 1134 try { 1208 1135 await Promise.allSettled(
+115
ui/system/media_broadcast.js
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 3 + /** 4 + * P2P media session broadcast — shared between desktop and media center. 5 + * Uses BroadcastChannel("beaver-media-control") to sync media state 6 + * with paired peers and handle remote control actions. 7 + * 8 + * Protocol messages: 9 + * { type: "state", sessionId, deviceName, title, artist, album, playbackState, duration, position } 10 + * { type: "action", sessionId, action } 11 + * { type: "hello" } 12 + */ 13 + 14 + export class MediaBroadcaster { 15 + #channel = new BroadcastChannel("beaver-media-control"); 16 + #sessionId = null; 17 + #deviceName = "Unknown device"; 18 + #state = { 19 + title: "", 20 + artist: "", 21 + album: "", 22 + playbackState: "none", 23 + duration: 0, 24 + position: 0, 25 + }; 26 + #onRemoteAction; // (action: string) => void 27 + #onRemoteState; // (data: object) => void — optional, for showing remote media 28 + 29 + /** 30 + * @param {object} opts 31 + * @param {string} opts.deviceName — display name for this device 32 + * @param {(action: string) => void} opts.onRemoteAction — called when a peer sends a control action 33 + * @param {(data: object) => void} [opts.onRemoteState] — called when a peer broadcasts its state 34 + */ 35 + constructor({ deviceName, onRemoteAction, onRemoteState }) { 36 + this.#deviceName = deviceName || "Unknown device"; 37 + this.#onRemoteAction = onRemoteAction; 38 + this.#onRemoteState = onRemoteState; 39 + 40 + this.#channel.onmessage = (e) => this.#handleMessage(e.data); 41 + } 42 + 43 + /** Resolve the device name lazily from the pairing API. */ 44 + async resolveDeviceName() { 45 + try { 46 + const info = await navigator.embedder.pairing.local(); 47 + this.#deviceName = info.displayName || this.#deviceName; 48 + } catch {} 49 + } 50 + 51 + /** Update local media state and broadcast to peers. */ 52 + updateState(detail) { 53 + // Generate session ID on first media event 54 + if (!this.#sessionId) { 55 + this.#sessionId = `ms-${Date.now()}-${Math.random().toString(36).slice(2)}`; 56 + } 57 + 58 + if (detail.title) this.#state.title = detail.title; 59 + if (detail.artist) this.#state.artist = detail.artist; 60 + if (detail.album) this.#state.album = detail.album; 61 + if (detail.playbackState) this.#state.playbackState = detail.playbackState; 62 + if (detail.duration) this.#state.duration = detail.duration; 63 + if (detail.position) this.#state.position = detail.position; 64 + 65 + this.#broadcast(); 66 + 67 + // Clear session on playback stop 68 + if (detail.playbackState === "none") { 69 + this.#sessionId = null; 70 + this.#state = { 71 + title: "", artist: "", album: "", 72 + playbackState: "none", duration: 0, position: 0, 73 + }; 74 + } 75 + } 76 + 77 + /** Send a hello to discover active sessions on peers. */ 78 + sendHello() { 79 + this.#channel.postMessage({ type: "hello" }); 80 + } 81 + 82 + /** Send a control action to a remote session. */ 83 + sendAction(sessionId, action) { 84 + this.#channel.postMessage({ type: "action", sessionId, action }); 85 + } 86 + 87 + #broadcast() { 88 + if (!this.#sessionId) return; 89 + this.#channel.postMessage({ 90 + type: "state", 91 + sessionId: this.#sessionId, 92 + deviceName: this.#deviceName, 93 + ...this.#state, 94 + }); 95 + } 96 + 97 + #handleMessage(data) { 98 + if (data.type === "hello") { 99 + // Peer joined — respond with our state if active 100 + if (this.#sessionId && this.#state.playbackState !== "none") { 101 + this.#broadcast(); 102 + } 103 + } else if (data.type === "action") { 104 + // Peer wants to control our media 105 + if (data.sessionId === this.#sessionId && this.#onRemoteAction) { 106 + this.#onRemoteAction(data.action); 107 + } 108 + } else if (data.type === "state") { 109 + // Peer is broadcasting its state — ignore our own 110 + if (data.sessionId !== this.#sessionId && this.#onRemoteState) { 111 + this.#onRemoteState(data); 112 + } 113 + } 114 + } 115 + }
+888
ui/system/mediacenter/ambient_view.js
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 3 + import { 4 + LitElement, 5 + html, 6 + css, 7 + } from "beaver://shared/third_party/lit/lit-all.min.js"; 8 + 9 + // --- Weather API (same as homescreen widget) --- 10 + 11 + const OPEN_METEO_URL = "https://api.open-meteo.com/v1/forecast"; 12 + const FIXED_LAT = -37.0675; 13 + const FIXED_LON = -12.3111; 14 + const LOCATION_NAME = "Tristan da Cunha"; 15 + 16 + function getWeatherIcon(code) { 17 + if (code === 0) return "sun"; 18 + if (code <= 3) return "cloud-sun"; 19 + if (code <= 48) return "cloud-fog"; 20 + if (code <= 67) return "cloud-rain"; 21 + if (code <= 77) return "cloud-snow"; 22 + if (code <= 99) return "cloud-lightning"; 23 + return "cloud"; 24 + } 25 + 26 + function getWeatherCondition(code) { 27 + if (code === 0) return "Clear sky"; 28 + if (code <= 3) return "Partly cloudy"; 29 + if (code <= 48) return "Foggy"; 30 + if (code <= 67) return "Rainy"; 31 + if (code <= 77) return "Snowy"; 32 + if (code <= 99) return "Thunderstorm"; 33 + return "Unknown"; 34 + } 35 + 36 + // --- WebGL Shader Background --- 37 + // Shadertoy-compatible engine. Supports standard Shadertoy uniforms: 38 + // iResolution, iTime, iTimeDelta, iFrame, iMouse 39 + // Also provides custom uniforms for theme integration: 40 + // iColorBase, iColorAccent, iColorSecondary 41 + 42 + const VERT_SRC = `#version 300 es 43 + in vec2 a_position; 44 + void main() { 45 + gl_Position = vec4(a_position, 0.0, 1.0); 46 + } 47 + `; 48 + 49 + // Wraps a Shadertoy mainImage() function into a WebGL fragment shader 50 + function wrapShadertoySource(source) { 51 + return `#version 300 es 52 + precision highp float; 53 + precision highp int; 54 + 55 + // Standard Shadertoy uniforms 56 + uniform vec3 iResolution; 57 + uniform float iTime; 58 + uniform float iTimeDelta; 59 + uniform int iFrame; 60 + uniform vec4 iMouse; 61 + 62 + // Texture channels (noise) 63 + uniform sampler2D iChannel0; 64 + uniform sampler2D iChannel1; 65 + uniform vec3 iChannelResolution[2]; 66 + 67 + // Custom: theme colors 68 + uniform vec3 iColorBase; 69 + uniform vec3 iColorAccent; 70 + uniform vec3 iColorSecondary; 71 + 72 + out vec4 _fragColor; 73 + 74 + ${source} 75 + 76 + void main() { 77 + vec4 c = vec4(0.0); 78 + mainImage(c, gl_FragCoord.xy); 79 + _fragColor = c; 80 + } 81 + `; 82 + } 83 + 84 + const SOME_SHADER_2 = ` 85 + //CBS 86 + //Parallax scrolling fractal galaxy. 87 + //Inspired by JoshP's Simplicity shader: https://www.shadertoy.com/view/lslGWr 88 + 89 + // http://www.fractalforums.com/new-theories-and-research/very-simple-formula-for-fractal-patterns/ 90 + float field(in vec3 p,float s) { 91 + float strength = 7. + .03 * log(1.e-6 + fract(sin(iTime) * 4373.11)); 92 + float accum = s/4.; 93 + float prev = 0.; 94 + float tw = 0.; 95 + for (int i = 0; i < 26; ++i) { 96 + float mag = dot(p, p); 97 + p = abs(p) / mag + vec3(-.5, -.4, -1.5); 98 + float w = exp(-float(i) / 7.); 99 + accum += w * exp(-strength * pow(abs(mag - prev), 2.2)); 100 + tw += w; 101 + prev = mag; 102 + } 103 + return max(0., 5. * accum / tw - .7); 104 + } 105 + 106 + // Less iterations for second layer 107 + float field2(in vec3 p, float s) { 108 + float strength = 7. + .03 * log(1.e-6 + fract(sin(iTime) * 4373.11)); 109 + float accum = s/4.; 110 + float prev = 0.; 111 + float tw = 0.; 112 + for (int i = 0; i < 18; ++i) { 113 + float mag = dot(p, p); 114 + p = abs(p) / mag + vec3(-.5, -.4, -1.5); 115 + float w = exp(-float(i) / 7.); 116 + accum += w * exp(-strength * pow(abs(mag - prev), 2.2)); 117 + tw += w; 118 + prev = mag; 119 + } 120 + return max(0., 5. * accum / tw - .7); 121 + } 122 + 123 + vec3 nrand3( vec2 co ) 124 + { 125 + vec3 a = fract( cos( co.x*8.3e-3 + co.y )*vec3(1.3e5, 4.7e5, 2.9e5) ); 126 + vec3 b = fract( sin( co.x*0.3e-3 + co.y )*vec3(8.1e5, 1.0e5, 0.1e5) ); 127 + vec3 c = mix(a, b, 0.5); 128 + return c; 129 + } 130 + 131 + 132 + void mainImage( out vec4 fragColor, in vec2 fragCoord ) { 133 + vec2 uv = 2. * fragCoord.xy / iResolution.xy - 1.; 134 + vec2 uvs = uv * iResolution.xy / max(iResolution.x, iResolution.y); 135 + vec3 p = vec3(uvs / 4., 0) + vec3(1., -1.3, 0.); 136 + p += .2 * vec3(sin(iTime / 16.), sin(iTime / 12.), sin(iTime / 128.)); 137 + 138 + float freqs[4]; 139 + //Sound 140 + freqs[0] = texture( iChannel0, vec2( 0.01, 0.25 ) ).x; 141 + freqs[1] = texture( iChannel0, vec2( 0.07, 0.25 ) ).x; 142 + freqs[2] = texture( iChannel0, vec2( 0.15, 0.25 ) ).x; 143 + freqs[3] = texture( iChannel0, vec2( 0.30, 0.25 ) ).x; 144 + 145 + float t = field(p,freqs[2]); 146 + float v = (1. - exp((abs(uv.x) - 1.) * 6.)) * (1. - exp((abs(uv.y) - 1.) * 6.)); 147 + 148 + //Second Layer 149 + vec3 p2 = vec3(uvs / (4.+sin(iTime*0.11)*0.2+0.2+sin(iTime*0.15)*0.3+0.4), 1.5) + vec3(2., -1.3, -1.); 150 + p2 += 0.25 * vec3(sin(iTime / 16.), sin(iTime / 12.), sin(iTime / 128.)); 151 + float t2 = field2(p2,freqs[3]); 152 + vec4 c2 = mix(.4, 1., v) * vec4(1.3 * t2 * t2 * t2 ,1.8 * t2 * t2 , t2* freqs[0], t2); 153 + 154 + 155 + //Let's add some stars 156 + //Thanks to http://glsl.heroku.com/e#6904.0 157 + vec2 seed = p.xy * 2.0; 158 + seed = floor(seed * iResolution.x); 159 + vec3 rnd = nrand3( seed ); 160 + vec4 starcolor = vec4(pow(rnd.y,40.0)); 161 + 162 + //Second Layer 163 + vec2 seed2 = p2.xy * 2.0; 164 + seed2 = floor(seed2 * iResolution.x); 165 + vec3 rnd2 = nrand3( seed2 ); 166 + starcolor += vec4(pow(rnd2.y,40.0)); 167 + 168 + fragColor = mix(freqs[3]-.3, 1., v) * vec4(1.5*freqs[2] * t * t* t , 1.2*freqs[1] * t * t, freqs[3]*t, 1.0)+c2+starcolor; 169 + }`; 170 + 171 + const SOME_SHADER = ` 172 + SYNTAX ERROR TO TRIGGER FALLBACK TO DEFAULT_SHADER 173 + 174 + // sun & grid functions 175 + // Shader License: CC BY 3.0 176 + // Author: Jan Mróz (jaszunio15) 177 + // Thanks 178 + 179 + float sun(vec2 uv, float battery) 180 + { 181 + float val = smoothstep(0.3, 0.29, length(uv)); 182 + float bloom = smoothstep(0.7, 0.0, length(uv)); 183 + float cut = 3.0 * sin((uv.y + iTime * 0.2 * (battery + 0.02)) * 100.0) 184 + + clamp(uv.y * 14.0 + 1.0, -6.0, 6.0); 185 + cut = clamp(cut, 0.0, 1.0); 186 + return clamp(val * cut, 0.0, 1.0) + bloom * 0.6; 187 + } 188 + 189 + float grid(vec2 uv, float battery) 190 + { 191 + vec2 size = vec2(uv.y, uv.y * uv.y * 0.2) * 0.01; 192 + uv += vec2(0.0, iTime * 4.0 * (battery + 0.05)); 193 + uv = abs(fract(uv) - 0.5); 194 + vec2 lines = smoothstep(size, vec2(0.0), uv); 195 + lines += smoothstep(size * 5.0, vec2(0.0), uv) * 0.4 * battery; 196 + return clamp(lines.x + lines.y, 0.0, 3.0); 197 + } 198 + 199 + float dot2(in vec2 v ) { return dot(v,v); } 200 + 201 + float sdTrapezoid( in vec2 p, in float r1, float r2, float he ) 202 + { 203 + vec2 k1 = vec2(r2,he); 204 + vec2 k2 = vec2(r2-r1,2.0*he); 205 + p.x = abs(p.x); 206 + vec2 ca = vec2(p.x-min(p.x,(p.y<0.0)?r1:r2), abs(p.y)-he); 207 + vec2 cb = p - k1 + k2*clamp( dot(k1-p,k2)/dot2(k2), 0.0, 1.0 ); 208 + float s = (cb.x<0.0 && ca.y<0.0) ? -1.0 : 1.0; 209 + return s*sqrt( min(dot2(ca),dot2(cb)) ); 210 + } 211 + 212 + float sdLine( in vec2 p, in vec2 a, in vec2 b ) 213 + { 214 + vec2 pa = p-a, ba = b-a; 215 + float h = clamp( dot(pa,ba)/dot(ba,ba), 0.0, 1.0 ); 216 + return length( pa - ba*h ); 217 + } 218 + 219 + float sdBox( in vec2 p, in vec2 b ) 220 + { 221 + vec2 d = abs(p)-b; 222 + return length(max(d,vec2(0))) + min(max(d.x,d.y),0.0); 223 + } 224 + 225 + float opSmoothUnion(float d1, float d2, float k){ 226 + float h = clamp(0.5 + 0.5 * (d2 - d1) /k,0.0,1.0); 227 + return mix(d2, d1 , h) - k * h * ( 1.0 - h); 228 + } 229 + 230 + float sdCloud(in vec2 p, in vec2 a1, in vec2 b1, in vec2 a2, in vec2 b2, float w) 231 + { 232 + //float lineVal1 = smoothstep(w - 0.0001, w, sdLine(p, a1, b1)); 233 + float lineVal1 = sdLine(p, a1, b1); 234 + float lineVal2 = sdLine(p, a2, b2); 235 + vec2 ww = vec2(w*1.5, 0.0); 236 + vec2 left = max(a1 + ww, a2 + ww); 237 + vec2 right = min(b1 - ww, b2 - ww); 238 + vec2 boxCenter = (left + right) * 0.5; 239 + //float boxW = right.x - left.x; 240 + float boxH = abs(a2.y - a1.y) * 0.5; 241 + //float boxVal = sdBox(p - boxCenter, vec2(boxW, boxH)) + w; 242 + float boxVal = sdBox(p - boxCenter, vec2(0.04, boxH)) + w; 243 + 244 + float uniVal1 = opSmoothUnion(lineVal1, boxVal, 0.05); 245 + float uniVal2 = opSmoothUnion(lineVal2, boxVal, 0.05); 246 + 247 + return min(uniVal1, uniVal2); 248 + } 249 + 250 + void mainImage( out vec4 fragColor, in vec2 fragCoord ) 251 + { 252 + vec2 uv = (2.0 * fragCoord.xy - iResolution.xy)/iResolution.y; 253 + float battery = 1.0; 254 + //if (iMouse.x > 1.0 && iMouse.y > 1.0) battery = iMouse.y / iResolution.y; 255 + //else battery = 0.8; 256 + 257 + //if (abs(uv.x) < (9.0 / 16.0)) 258 + { 259 + // Grid 260 + float fog = smoothstep(0.1, -0.02, abs(uv.y + 0.2)); 261 + vec3 col = vec3(0.0, 0.1, 0.2); 262 + if (uv.y < -0.2) 263 + { 264 + uv.y = 3.0 / (abs(uv.y + 0.2) + 0.05); 265 + uv.x *= uv.y * 1.0; 266 + float gridVal = grid(uv, battery); 267 + col = mix(col, vec3(1.0, 0.5, 1.0), gridVal); 268 + } 269 + else 270 + { 271 + float fujiD = min(uv.y * 4.5 - 0.5, 1.0); 272 + uv.y -= battery * 1.1 - 0.51; 273 + 274 + vec2 sunUV = uv; 275 + vec2 fujiUV = uv; 276 + 277 + // Sun 278 + sunUV += vec2(0.75, 0.2); 279 + //uv.y -= 1.1 - 0.51; 280 + col = vec3(1.0, 0.2, 1.0); 281 + float sunVal = sun(sunUV, battery); 282 + 283 + col = mix(col, vec3(1.0, 0.4, 0.1), sunUV.y * 2.0 + 0.2); 284 + col = mix(vec3(0.0, 0.0, 0.0), col, sunVal); 285 + 286 + // fuji 287 + float fujiVal = sdTrapezoid( uv + vec2(-0.75+sunUV.y * 0.0, 0.5), 1.75 + pow(uv.y * uv.y, 2.1), 0.2, 0.5); 288 + float waveVal = uv.y + sin(uv.x * 20.0 + iTime * 2.0) * 0.05 + 0.2; 289 + float wave_width = smoothstep(0.0,0.01,(waveVal)); 290 + 291 + // fuji color 292 + col = mix( col, mix(vec3(0.0, 0.0, 0.25), vec3(1.0, 0.0, 0.5), fujiD), step(fujiVal, 0.0)); 293 + // fuji top snow 294 + col = mix( col, vec3(1.0, 0.5, 1.0), wave_width * step(fujiVal, 0.0)); 295 + // fuji outline 296 + col = mix( col, vec3(1.0, 0.5, 1.0), 1.0-smoothstep(0.0,0.01,abs(fujiVal)) ); 297 + //col = mix( col, vec3(1.0, 1.0, 1.0), 1.0-smoothstep(0.03,0.04,abs(fujiVal)) ); 298 + //col = vec3(1.0, 1.0, 1.0) *(1.0-smoothstep(0.03,0.04,abs(fujiVal))); 299 + 300 + // horizon color 301 + col += mix( col, mix(vec3(1.0, 0.12, 0.8), vec3(0.0, 0.0, 0.2), clamp(uv.y * 3.5 + 3.0, 0.0, 1.0)), step(0.0, fujiVal) ); 302 + 303 + // cloud 304 + vec2 cloudUV = uv; 305 + cloudUV.x = mod(cloudUV.x + iTime * 0.1, 4.0) - 2.0; 306 + float cloudTime = iTime * 0.5; 307 + float cloudY = -0.5; 308 + float cloudVal1 = sdCloud(cloudUV, 309 + vec2(0.1 + sin(cloudTime + 140.5)*0.1,cloudY), 310 + vec2(1.05 + cos(cloudTime * 0.9 - 36.56) * 0.1, cloudY), 311 + vec2(0.2 + cos(cloudTime * 0.867 + 387.165) * 0.1,0.25+cloudY), 312 + vec2(0.5 + cos(cloudTime * 0.9675 - 15.162) * 0.09, 0.25+cloudY), 0.075); 313 + cloudY = -0.6; 314 + float cloudVal2 = sdCloud(cloudUV, 315 + vec2(-0.9 + cos(cloudTime * 1.02 + 541.75) * 0.1,cloudY), 316 + vec2(-0.5 + sin(cloudTime * 0.9 - 316.56) * 0.1, cloudY), 317 + vec2(-1.5 + cos(cloudTime * 0.867 + 37.165) * 0.1,0.25+cloudY), 318 + vec2(-0.6 + sin(cloudTime * 0.9675 + 665.162) * 0.09, 0.25+cloudY), 0.075); 319 + 320 + float cloudVal = min(cloudVal1, cloudVal2); 321 + 322 + //col = mix(col, vec3(1.0,1.0,0.0), smoothstep(0.0751, 0.075, cloudVal)); 323 + col = mix(col, vec3(0.0, 0.0, 0.2), 1.0 - smoothstep(0.075 - 0.0001, 0.075, cloudVal)); 324 + col += vec3(1.0, 1.0, 1.0)*(1.0 - smoothstep(0.0,0.01,abs(cloudVal - 0.075))); 325 + } 326 + 327 + col += fog * fog * fog; 328 + col = mix(vec3(col.r, col.r, col.r) * 0.5, col, battery * 0.7); 329 + 330 + fragColor = vec4(col,1.0); 331 + } 332 + //else fragColor = vec4(0.0); 333 + 334 + 335 + } 336 + `; 337 + 338 + // Default shader — warm metaballs using theme colors 339 + const DEFAULT_SHADER = ` 340 + float metaball(vec2 p, vec2 center, float radius) { 341 + float d = length(p - center); 342 + return radius * radius / (d * d + 0.001); 343 + } 344 + 345 + void mainImage(out vec4 fragColor, in vec2 fragCoord) { 346 + vec2 uv = fragCoord / iResolution.xy; 347 + float aspect = iResolution.x / iResolution.y; 348 + vec2 p = vec2((uv.x - 0.5) * aspect, uv.y - 0.5); 349 + 350 + float t = iTime * 0.25; 351 + 352 + float m1 = metaball(p, vec2(sin(t * 0.7) * 0.4, cos(t * 0.5) * 0.3), 0.18); 353 + float m2 = metaball(p, vec2(cos(t * 0.4) * 0.5, sin(t * 0.6) * 0.35), 0.22); 354 + float m3 = metaball(p, vec2(sin(t * 0.8 + 2.0) * 0.35, cos(t * 0.3 + 1.0) * 0.4), 0.15); 355 + float m4 = metaball(p, vec2(cos(t * 0.5 + 4.0) * 0.45, sin(t * 0.7 + 3.0) * 0.25), 0.20); 356 + float m5 = metaball(p, vec2(sin(t * 0.3 + 1.5) * 0.3, cos(t * 0.9 + 2.5) * 0.35), 0.16); 357 + float m6 = metaball(p, vec2(cos(t * 0.6 + 3.5) * 0.4, sin(t * 0.4 + 5.0) * 0.3), 0.19); 358 + 359 + float m = m1 + m2 + m3 + m4 + m5 + m6; 360 + float shape = smoothstep(0.8, 2.0, m); 361 + 362 + float s1 = metaball(p, vec2(sin(t * 0.2) * 0.5, cos(t * 0.15) * 0.4), 0.3); 363 + float s2 = metaball(p, vec2(cos(t * 0.25 + 3.0) * 0.4, sin(t * 0.2 + 2.0) * 0.45), 0.35); 364 + float colorMix = smoothstep(0.5, 1.5, s1 + s2); 365 + 366 + float drift = sin(t * 0.4) * 0.02; 367 + 368 + vec3 dark = iColorBase; 369 + vec3 warm = iColorAccent + vec3(drift, drift * 0.5, -drift); 370 + vec3 highlight = iColorSecondary; 371 + 372 + vec3 color = mix(dark, warm, shape); 373 + color = mix(color, highlight, colorMix * shape * 0.4); 374 + 375 + float glow = smoothstep(0.5, 1.2, m) * 0.15; 376 + color += warm * glow; 377 + 378 + float vig = 1.0 - 0.35 * length((uv - 0.5) * 1.4); 379 + color *= vig; 380 + 381 + fragColor = vec4(color, 1.0); 382 + } 383 + `; 384 + 385 + // Generate a 256x256 RGBA noise texture 386 + function createNoiseTexture(gl, seed) { 387 + const size = 256; 388 + const data = new Uint8Array(size * size * 4); 389 + let s = seed; 390 + for (let i = 0; i < data.length; i++) { 391 + s = (s * 1103515245 + 12345) & 0x7fffffff; 392 + data[i] = (s >> 16) & 0xff; 393 + } 394 + const tex = gl.createTexture(); 395 + gl.bindTexture(gl.TEXTURE_2D, tex); 396 + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, size, size, 0, gl.RGBA, gl.UNSIGNED_BYTE, data); 397 + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT); 398 + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT); 399 + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR); 400 + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); 401 + gl.generateMipmap(gl.TEXTURE_2D); 402 + return { tex, size }; 403 + } 404 + 405 + function initShaderEngine(canvas, fragmentSource) { 406 + const gl = canvas.getContext("webgl2", { alpha: false, antialias: false }); 407 + if (!gl) { 408 + console.error("[Shader] WebGL2 not available"); 409 + return null; 410 + } 411 + 412 + function compile(type, src) { 413 + const s = gl.createShader(type); 414 + gl.shaderSource(s, src); 415 + gl.compileShader(s); 416 + if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) { 417 + console.error("[Shader] Compile error:", gl.getShaderInfoLog(s)); 418 + return null; 419 + } 420 + return s; 421 + } 422 + 423 + const fragSrc = wrapShadertoySource(fragmentSource); 424 + const vs = compile(gl.VERTEX_SHADER, VERT_SRC); 425 + const fs = compile(gl.FRAGMENT_SHADER, fragSrc); 426 + if (!vs || !fs) return null; 427 + 428 + const prog = gl.createProgram(); 429 + gl.attachShader(prog, vs); 430 + gl.attachShader(prog, fs); 431 + gl.linkProgram(prog); 432 + if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) { 433 + console.error("[Shader] Link error:", gl.getProgramInfoLog(prog)); 434 + return null; 435 + } 436 + gl.useProgram(prog); 437 + 438 + // Fullscreen quad 439 + const buf = gl.createBuffer(); 440 + gl.bindBuffer(gl.ARRAY_BUFFER, buf); 441 + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([ 442 + -1, -1, 1, -1, -1, 1, 443 + -1, 1, 1, -1, 1, 1, 444 + ]), gl.STATIC_DRAW); 445 + 446 + const pos = gl.getAttribLocation(prog, "a_position"); 447 + gl.enableVertexAttribArray(pos); 448 + gl.vertexAttribPointer(pos, 2, gl.FLOAT, false, 0, 0); 449 + 450 + // Create noise textures for iChannel0 and iChannel1 451 + const noise0 = createNoiseTexture(gl, 42); 452 + const noise1 = createNoiseTexture(gl, 137); 453 + 454 + const ch0Loc = gl.getUniformLocation(prog, "iChannel0"); 455 + const ch1Loc = gl.getUniformLocation(prog, "iChannel1"); 456 + const chResLoc = gl.getUniformLocation(prog, "iChannelResolution"); 457 + 458 + if (ch0Loc !== null) gl.uniform1i(ch0Loc, 0); 459 + if (ch1Loc !== null) gl.uniform1i(ch1Loc, 1); 460 + 461 + gl.activeTexture(gl.TEXTURE0); 462 + gl.bindTexture(gl.TEXTURE_2D, noise0.tex); 463 + gl.activeTexture(gl.TEXTURE1); 464 + gl.bindTexture(gl.TEXTURE_2D, noise1.tex); 465 + 466 + if (chResLoc) { 467 + gl.uniform3fv(chResLoc, [ 468 + noise0.size, noise0.size, 1.0, 469 + noise1.size, noise1.size, 1.0, 470 + ]); 471 + } 472 + 473 + return { 474 + gl, 475 + uniforms: { 476 + iResolution: gl.getUniformLocation(prog, "iResolution"), 477 + iTime: gl.getUniformLocation(prog, "iTime"), 478 + iTimeDelta: gl.getUniformLocation(prog, "iTimeDelta"), 479 + iFrame: gl.getUniformLocation(prog, "iFrame"), 480 + iMouse: gl.getUniformLocation(prog, "iMouse"), 481 + iColorBase: gl.getUniformLocation(prog, "iColorBase"), 482 + iColorAccent: gl.getUniformLocation(prog, "iColorAccent"), 483 + iColorSecondary: gl.getUniformLocation(prog, "iColorSecondary"), 484 + }, 485 + }; 486 + } 487 + 488 + // --- 489 + 490 + class AmbientView extends LitElement { 491 + static properties = { 492 + mediaTitle: { type: String }, 493 + mediaArtist: { type: String }, 494 + playbackState: { type: String }, 495 + _time: { state: true }, 496 + _date: { state: true }, 497 + _weatherTemp: { state: true }, 498 + _weatherCondition: { state: true }, 499 + _weatherIcon: { state: true }, 500 + _weatherLocation: { state: true }, 501 + }; 502 + 503 + static styles = css` 504 + :host { 505 + display: flex; 506 + flex-direction: column; 507 + align-items: center; 508 + justify-content: center; 509 + width: 100%; 510 + height: 100%; 511 + font-family: var(--font-family-base); 512 + gap: 0.2em; 513 + position: relative; 514 + } 515 + 516 + canvas { 517 + position: absolute; 518 + inset: 0; 519 + width: 100%; 520 + height: 100%; 521 + } 522 + 523 + .content { 524 + position: relative; 525 + z-index: 1; 526 + display: flex; 527 + flex-direction: column; 528 + align-items: center; 529 + gap: 0.2em; 530 + } 531 + 532 + /* --- Clock --- */ 533 + 534 + .clock { 535 + font-family: Pacifico, cursive; 536 + font-size: clamp(5em, 12vw, 10em); 537 + line-height: 1; 538 + color: oklch(93% 0.03 85); 539 + text-shadow: 0 2px 30px oklch(30% 0.05 85 / 0.3); 540 + } 541 + 542 + .date { 543 + font-size: clamp(1em, 2.5vw, 1.5em); 544 + color: oklch(70% 0.02 85); 545 + margin-top: 0.1em; 546 + letter-spacing: 0.02em; 547 + } 548 + 549 + /* --- Weather --- */ 550 + 551 + .weather { 552 + display: flex; 553 + align-items: center; 554 + gap: 0.6em; 555 + margin-top: 1.5em; 556 + color: oklch(65% 0.02 85); 557 + font-size: clamp(0.9em, 1.8vw, 1.15em); 558 + } 559 + 560 + .weather lucide-icon { 561 + font-size: 1.3em; 562 + } 563 + 564 + .weather-temp { 565 + font-weight: var(--font-weight-bold); 566 + color: oklch(75% 0.02 85); 567 + } 568 + 569 + .weather-separator { 570 + opacity: 0.3; 571 + } 572 + 573 + /* --- Now playing pill --- */ 574 + 575 + .now-playing-pill { 576 + position: fixed; 577 + bottom: 2.5em; 578 + left: 50%; 579 + transform: translateX(-50%); 580 + display: flex; 581 + align-items: center; 582 + gap: 0.75em; 583 + padding: 0.7em 1.4em; 584 + background: oklch(18% 0.02 105 / 0.75); 585 + backdrop-filter: blur(16px); 586 + border-radius: 2em; 587 + border: 1px solid oklch(40% 0.02 105 / 0.2); 588 + color: oklch(85% 0.02 85); 589 + font-size: clamp(0.85em, 1.5vw, 1.05em); 590 + transition: opacity 0.4s cubic-bezier(0.16, 1, 0.3, 1); 591 + z-index: 1; 592 + } 593 + 594 + .now-playing-pill.hidden { 595 + opacity: 0; 596 + pointer-events: none; 597 + } 598 + 599 + .now-playing-pill .pulse { 600 + width: 8px; 601 + height: 8px; 602 + border-radius: 50%; 603 + background: var(--color-primary); 604 + flex-shrink: 0; 605 + animation: pulse 2s ease-in-out infinite; 606 + } 607 + 608 + .now-playing-pill .track-info { 609 + white-space: nowrap; 610 + overflow: hidden; 611 + text-overflow: ellipsis; 612 + max-width: 50vw; 613 + } 614 + 615 + .now-playing-pill .artist { 616 + color: oklch(65% 0.02 85); 617 + } 618 + 619 + @keyframes pulse { 620 + 0%, 100% { opacity: 1; transform: scale(1); } 621 + 50% { opacity: 0.4; transform: scale(0.8); } 622 + } 623 + `; 624 + 625 + constructor() { 626 + super(); 627 + this.mediaTitle = ""; 628 + this.mediaArtist = ""; 629 + this.playbackState = "none"; 630 + this._time = ""; 631 + this._date = ""; 632 + this._weatherTemp = null; 633 + this._weatherCondition = ""; 634 + this._weatherIcon = "cloud"; 635 + this._weatherLocation = ""; 636 + this._shader = null; 637 + this._animFrame = null; 638 + this._startTime = performance.now(); 639 + this._themeColors = { 640 + base: [0.4, 0.3, 0.2], 641 + accent: [0.3, 0.5, 0.35], 642 + secondary: [0.4, 0.5, 0.3], 643 + }; 644 + 645 + this._updateClock(); 646 + this._clockInterval = setInterval(() => this._updateClock(), 30_000); 647 + this._fetchWeather(); 648 + this._weatherInterval = setInterval(() => this._fetchWeather(), 30 * 60_000); 649 + } 650 + 651 + firstUpdated() { 652 + this._canvas = this.shadowRoot.querySelector("canvas"); 653 + if (this._canvas) { 654 + this._loadShader(SOME_SHADER); 655 + } 656 + } 657 + 658 + // Load a Shadertoy-compatible shader (GLSL source with mainImage function) 659 + loadShader(source) { 660 + this.stopShader(); 661 + this._shader = null; 662 + this._loadShader(source); 663 + } 664 + 665 + _loadShader(source) { 666 + if (!this._canvas) return; 667 + this._shader = initShaderEngine(this._canvas, source); 668 + if (this._shader) { 669 + this._resizeCanvas(this._canvas); 670 + if (!this._resizeObserver) { 671 + this._resizeObserver = new ResizeObserver(() => this._resizeCanvas(this._canvas)); 672 + this._resizeObserver.observe(this); 673 + } 674 + this._updateThemeColors(); 675 + this._frameCount = 0; 676 + this._lastFrameTime = performance.now(); 677 + this.startShader(); 678 + } else { 679 + console.error("[AmbientView] Shader failed to compile, falling back to default"); 680 + if (source !== DEFAULT_SHADER) { 681 + this._loadShader(DEFAULT_SHADER); 682 + } 683 + } 684 + } 685 + 686 + disconnectedCallback() { 687 + super.disconnectedCallback(); 688 + clearInterval(this._clockInterval); 689 + clearInterval(this._weatherInterval); 690 + this.stopShader(); 691 + if (this._resizeObserver) { 692 + this._resizeObserver.disconnect(); 693 + } 694 + } 695 + 696 + _resizeCanvas(canvas) { 697 + const dpr = window.devicePixelRatio || 1; 698 + // Render at half resolution for performance 699 + const scale = 0.5 * dpr; 700 + canvas.width = Math.floor(canvas.clientWidth * scale); 701 + canvas.height = Math.floor(canvas.clientHeight * scale); 702 + if (this._shader) { 703 + this._shader.gl.viewport(0, 0, canvas.width, canvas.height); 704 + } 705 + } 706 + 707 + // Parse a CSS color value to [r, g, b] in 0-1 range. 708 + // Uses a canvas 2D context which always resolves to sRGB, 709 + // even for oklch/lab/etc. that Servo doesn't convert in computed styles. 710 + _parseColor(cssValue) { 711 + if (!this._colorCanvas) { 712 + this._colorCanvas = document.createElement("canvas"); 713 + this._colorCanvas.width = 1; 714 + this._colorCanvas.height = 1; 715 + this._colorCtx = this._colorCanvas.getContext("2d"); 716 + } 717 + const ctx = this._colorCtx; 718 + ctx.clearRect(0, 0, 1, 1); 719 + ctx.fillStyle = cssValue; 720 + ctx.fillRect(0, 0, 1, 1); 721 + const data = ctx.getImageData(0, 0, 1, 1).data; 722 + return [data[0] / 255, data[1] / 255, data[2] / 255]; 723 + } 724 + 725 + _updateThemeColors() { 726 + if (!this._shader) return; 727 + const style = getComputedStyle(document.documentElement); 728 + 729 + const footer = this._parseColor(style.getPropertyValue("--bg-footer").trim()); 730 + const header = this._parseColor(style.getPropertyValue("--bg-header").trim()); 731 + const primary = this._parseColor(style.getPropertyValue("--color-primary").trim()); 732 + 733 + // Convert to HSL to extract hues, then rebuild dark saturated versions 734 + const footerHsl = this._rgbToHsl(footer); 735 + const headerHsl = this._rgbToHsl(header); 736 + const primaryHsl = this._rgbToHsl(primary); 737 + 738 + // Build dark, rich colors using theme hues 739 + // base: deep dark version of the footer hue 740 + const base = this._hslToRgb([footerHsl[0], 0.6, 0.18]); 741 + // accent: medium-dark version of the header hue, more saturated 742 + const accent = this._hslToRgb([headerHsl[0], 0.5, 0.28]); 743 + // secondary: a hint of the primary color, fairly saturated 744 + const secondary = this._hslToRgb([primaryHsl[0], 0.7, 0.24]); 745 + 746 + console.log("[AmbientView] Theme hues — footer:", footerHsl[0].toFixed(0) + "°", 747 + "header:", headerHsl[0].toFixed(0) + "°", "primary:", primaryHsl[0].toFixed(0) + "°"); 748 + console.log("[AmbientView] Shader colors — base:", base, "accent:", accent, "secondary:", secondary); 749 + this._themeColors = { base, accent, secondary }; 750 + } 751 + 752 + _rgbToHsl([r, g, b]) { 753 + const max = Math.max(r, g, b); 754 + const min = Math.min(r, g, b); 755 + const l = (max + min) / 2; 756 + if (max === min) return [0, 0, l]; 757 + const d = max - min; 758 + const s = l > 0.5 ? d / (2 - max - min) : d / (max + min); 759 + let h; 760 + if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6; 761 + else if (max === g) h = ((b - r) / d + 2) / 6; 762 + else h = ((r - g) / d + 4) / 6; 763 + return [h * 360, s, l]; 764 + } 765 + 766 + _hslToRgb([h, s, l]) { 767 + h = h / 360; 768 + if (s === 0) return [l, l, l]; 769 + const hue2rgb = (p, q, t) => { 770 + if (t < 0) t += 1; 771 + if (t > 1) t -= 1; 772 + if (t < 1/6) return p + (q - p) * 6 * t; 773 + if (t < 1/2) return q; 774 + if (t < 2/3) return p + (q - p) * (2/3 - t) * 6; 775 + return p; 776 + }; 777 + const q = l < 0.5 ? l * (1 + s) : l + s - l * s; 778 + const p = 2 * l - q; 779 + return [hue2rgb(p, q, h + 1/3), hue2rgb(p, q, h), hue2rgb(p, q, h - 1/3)]; 780 + } 781 + 782 + startShader() { 783 + if (!this._shader || this._animFrame) return; 784 + this._frameCount = this._frameCount || 0; 785 + this._lastFrameTime = this._lastFrameTime || performance.now(); 786 + 787 + const render = () => { 788 + const { gl, uniforms } = this._shader; 789 + const now = performance.now(); 790 + const t = (now - this._startTime) / 1000; 791 + const dt = (now - this._lastFrameTime) / 1000; 792 + this._lastFrameTime = now; 793 + 794 + // Standard Shadertoy uniforms 795 + gl.uniform3f(uniforms.iResolution, gl.canvas.width, gl.canvas.height, 1.0); 796 + gl.uniform1f(uniforms.iTime, t); 797 + gl.uniform1f(uniforms.iTimeDelta, dt); 798 + gl.uniform1i(uniforms.iFrame, this._frameCount++); 799 + gl.uniform4f(uniforms.iMouse, 0, 0, 0, 0); 800 + 801 + // Theme color uniforms 802 + if (this._themeColors) { 803 + gl.uniform3fv(uniforms.iColorBase, this._themeColors.base); 804 + gl.uniform3fv(uniforms.iColorAccent, this._themeColors.accent); 805 + gl.uniform3fv(uniforms.iColorSecondary, this._themeColors.secondary); 806 + } 807 + 808 + gl.drawArrays(gl.TRIANGLES, 0, 6); 809 + this._animFrame = requestAnimationFrame(render); 810 + }; 811 + this._animFrame = requestAnimationFrame(render); 812 + } 813 + 814 + stopShader() { 815 + if (this._animFrame) { 816 + cancelAnimationFrame(this._animFrame); 817 + this._animFrame = null; 818 + } 819 + } 820 + 821 + _updateClock() { 822 + const now = new Date(); 823 + const hours = now.getHours(); 824 + const minutes = now.getMinutes().toString().padStart(2, "0"); 825 + this._time = `${hours}:${minutes}`; 826 + this._date = now.toLocaleDateString(undefined, { 827 + weekday: "long", 828 + month: "long", 829 + day: "numeric", 830 + }); 831 + } 832 + 833 + async _fetchWeather() { 834 + try { 835 + const params = new URLSearchParams({ 836 + latitude: FIXED_LAT, 837 + longitude: FIXED_LON, 838 + current: "temperature_2m,weather_code", 839 + temperature_unit: "celsius", 840 + }); 841 + const res = await fetch(`${OPEN_METEO_URL}?${params}`); 842 + const data = await res.json(); 843 + this._weatherTemp = Math.round(data.current.temperature_2m); 844 + this._weatherCondition = getWeatherCondition(data.current.weather_code); 845 + this._weatherIcon = getWeatherIcon(data.current.weather_code); 846 + this._weatherLocation = LOCATION_NAME; 847 + } catch (e) { 848 + console.error("[AmbientView] Weather fetch failed:", e); 849 + } 850 + } 851 + 852 + render() { 853 + const hasMedia = this.playbackState === "playing"; 854 + 855 + return html` 856 + <canvas></canvas> 857 + <div class="content"> 858 + <div class="clock">${this._time}</div> 859 + <div class="date">${this._date}</div> 860 + 861 + ${this._weatherTemp !== null 862 + ? html` 863 + <div class="weather"> 864 + <lucide-icon name="${this._weatherIcon}"></lucide-icon> 865 + <span class="weather-temp">${this._weatherTemp}°</span> 866 + <span class="weather-separator">/</span> 867 + <span>${this._weatherCondition}</span> 868 + <span class="weather-separator">/</span> 869 + <span>${this._weatherLocation}</span> 870 + </div> 871 + ` 872 + : ""} 873 + </div> 874 + 875 + <div class="now-playing-pill ${hasMedia ? "" : "hidden"}"> 876 + <div class="pulse"></div> 877 + <div class="track-info"> 878 + ${this.mediaTitle} 879 + ${this.mediaArtist 880 + ? html`<span class="artist"> — ${this.mediaArtist}</span>` 881 + : ""} 882 + </div> 883 + </div> 884 + `; 885 + } 886 + } 887 + 888 + customElements.define("ambient-view", AmbientView);
+261
ui/system/mediacenter/content_browser.js
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 3 + import { 4 + LitElement, 5 + html, 6 + css, 7 + } from "beaver://shared/third_party/lit/lit-all.min.js"; 8 + 9 + class ContentBrowser extends LitElement { 10 + static properties = { 11 + category: { type: String }, 12 + _items: { state: true }, 13 + _isPages: { state: true }, 14 + }; 15 + 16 + static styles = css` 17 + :host { 18 + display: flex; 19 + flex-direction: column; 20 + width: 100%; 21 + height: 100%; 22 + background: oklch(12% 0.02 105 / 0.95); 23 + backdrop-filter: blur(20px); 24 + font-family: var(--font-family-base); 25 + } 26 + 27 + .header { 28 + padding: 2em 3em 1em; 29 + } 30 + 31 + .header-title { 32 + font-size: 1.8em; 33 + font-weight: var(--font-weight-bold); 34 + color: oklch(90% 0.02 85); 35 + font-family: Pacifico, cursive; 36 + } 37 + 38 + .list { 39 + flex: 1; 40 + overflow-y: auto; 41 + padding: 0 3em 2em; 42 + } 43 + 44 + .item { 45 + display: flex; 46 + align-items: center; 47 + gap: 1em; 48 + padding: 1em 1.2em; 49 + border-radius: var(--radius-md); 50 + color: oklch(80% 0.02 85); 51 + transition: background 0.18s cubic-bezier(0.16, 1, 0.3, 1); 52 + border-bottom: 1px solid oklch(25% 0.01 105 / 0.4); 53 + } 54 + 55 + .item:last-child { 56 + border-bottom: none; 57 + } 58 + 59 + .item.focused { 60 + background: oklch(25% 0.04 105 / 0.6); 61 + color: oklch(95% 0.02 85); 62 + outline: 2px solid var(--color-focus-ring); 63 + outline-offset: -2px; 64 + } 65 + 66 + .item lucide-icon { 67 + font-size: 1.4em; 68 + color: var(--color-primary); 69 + } 70 + 71 + .item-info { 72 + flex: 1; 73 + min-width: 0; 74 + } 75 + 76 + .item-title { 77 + font-size: 1.1em; 78 + font-weight: var(--font-weight-bold); 79 + } 80 + 81 + .item-subtitle { 82 + font-size: 0.85em; 83 + color: oklch(60% 0.02 85); 84 + white-space: nowrap; 85 + overflow: hidden; 86 + text-overflow: ellipsis; 87 + } 88 + 89 + .media-indicator { 90 + font-size: 0.75em; 91 + color: var(--color-primary); 92 + display: flex; 93 + align-items: center; 94 + gap: 0.3em; 95 + } 96 + 97 + .media-indicator .dot { 98 + width: 6px; 99 + height: 6px; 100 + border-radius: 50%; 101 + background: var(--color-primary); 102 + animation: pulse 2s ease-in-out infinite; 103 + } 104 + 105 + @keyframes pulse { 106 + 0%, 100% { opacity: 1; } 107 + 50% { opacity: 0.4; } 108 + } 109 + 110 + .close-hint { 111 + font-size: 0.75em; 112 + color: oklch(50% 0.02 85); 113 + opacity: 0; 114 + transition: opacity 0.18s cubic-bezier(0.16, 1, 0.3, 1); 115 + } 116 + 117 + .item.focused .close-hint { 118 + opacity: 1; 119 + } 120 + 121 + .empty { 122 + padding: 3em; 123 + text-align: center; 124 + color: oklch(55% 0.02 85); 125 + font-size: 1.1em; 126 + } 127 + `; 128 + 129 + constructor() { 130 + super(); 131 + this.category = ""; 132 + this._items = []; 133 + this._isPages = false; 134 + } 135 + 136 + async loadSources(category) { 137 + this.category = category; 138 + this._isPages = false; 139 + try { 140 + const res = await fetch("./sources.json"); 141 + const all = await res.json(); 142 + this._items = all.filter((s) => s.category === category); 143 + } catch { 144 + this._items = []; 145 + } 146 + // Wait for Lit to render the new items 147 + await this.updateComplete; 148 + } 149 + 150 + showPages(pages) { 151 + this.category = "pages"; 152 + this._isPages = true; 153 + this._items = pages; 154 + } 155 + 156 + getDpadItems() { 157 + return Array.from(this.shadowRoot.querySelectorAll(".item")); 158 + } 159 + 160 + selectItem(item, index) { 161 + const data = this._items[index]; 162 + if (!data) return; 163 + 164 + if (this._isPages) { 165 + // Selecting an open page — switch to it 166 + this.dispatchEvent( 167 + new CustomEvent("content-select", { 168 + bubbles: true, 169 + composed: true, 170 + detail: { pageIndex: data.index }, 171 + }), 172 + ); 173 + } else { 174 + this.dispatchEvent( 175 + new CustomEvent("content-select", { 176 + bubbles: true, 177 + composed: true, 178 + detail: data, 179 + }), 180 + ); 181 + } 182 + } 183 + 184 + closePageItem(index) { 185 + const data = this._items[index]; 186 + if (!data || !this._isPages) return; 187 + this.dispatchEvent( 188 + new CustomEvent("content-select", { 189 + bubbles: true, 190 + composed: true, 191 + detail: { closePageIndex: data.index }, 192 + }), 193 + ); 194 + } 195 + 196 + back() { 197 + this.dispatchEvent( 198 + new CustomEvent("content-back", { bubbles: true, composed: true }), 199 + ); 200 + } 201 + 202 + _categoryLabel() { 203 + const labels = { 204 + music: "Music", 205 + videos: "Videos", 206 + web: "Web", 207 + devices: "Devices", 208 + pages: "Open Pages", 209 + }; 210 + return labels[this.category] || this.category; 211 + } 212 + 213 + _renderSourceItem(item) { 214 + return html` 215 + <div class="item" data-url="${item.url}"> 216 + <lucide-icon name="${item.icon || "play"}"></lucide-icon> 217 + <div class="item-info"> 218 + <div class="item-title">${item.title}</div> 219 + <div class="item-subtitle">${item.url}</div> 220 + </div> 221 + </div> 222 + `; 223 + } 224 + 225 + _renderPageItem(page) { 226 + return html` 227 + <div class="item"> 228 + <lucide-icon name="${page.hasMedia ? "disc-3" : "globe"}"></lucide-icon> 229 + <div class="item-info"> 230 + <div class="item-title">${page.title}</div> 231 + <div class="item-subtitle">${page.url}</div> 232 + </div> 233 + ${page.hasMedia 234 + ? html`<div class="media-indicator"><div class="dot"></div> Playing</div>` 235 + : ""} 236 + <span class="close-hint">→ close</span> 237 + </div> 238 + `; 239 + } 240 + 241 + render() { 242 + return html` 243 + <div class="header"> 244 + <span class="header-title">${this._categoryLabel()}</span> 245 + </div> 246 + <div class="list"> 247 + ${this._items.length === 0 248 + ? html`<div class="empty"> 249 + ${this._isPages ? "No open pages" : "No sources configured"} 250 + </div>` 251 + : this._items.map((item) => 252 + this._isPages 253 + ? this._renderPageItem(item) 254 + : this._renderSourceItem(item), 255 + )} 256 + </div> 257 + `; 258 + } 259 + } 260 + 261 + customElements.define("content-browser", ContentBrowser);
+108
ui/system/mediacenter/dpad.js
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 3 + // D-pad focus manager for media center. 4 + // Each component registers a focus context. The manager routes keyboard 5 + // events to the active context and maintains a visual .focused class. 6 + 7 + export class DpadManager { 8 + #activeContext = null; 9 + #focusIndex = 0; 10 + 11 + constructor() { 12 + document.addEventListener("keydown", (e) => this.handleKeyDown(e)); 13 + } 14 + 15 + // Set the active focus context. 16 + // context: { items: () => Element[], orientation: "horizontal"|"vertical", 17 + // onSelect: (item, index) => void, onBack: () => void, 18 + // onSecondary: (item, index) => void, // optional: cross-axis action 19 + // wrap: bool } 20 + setContext(context) { 21 + this.#clearFocus(); 22 + this.#activeContext = context; 23 + this.#focusIndex = 0; 24 + this.#applyFocus(); 25 + } 26 + 27 + clearContext() { 28 + this.#clearFocus(); 29 + this.#activeContext = null; 30 + this.#focusIndex = 0; 31 + } 32 + 33 + getFocusIndex() { 34 + return this.#focusIndex; 35 + } 36 + 37 + setFocusIndex(index) { 38 + this.#clearFocus(); 39 + this.#focusIndex = index; 40 + this.#applyFocus(); 41 + } 42 + 43 + handleKeyDown(event) { 44 + if (!this.#activeContext) return; 45 + 46 + const ctx = this.#activeContext; 47 + const items = ctx.items(); 48 + if (!items || items.length === 0) return; 49 + 50 + const isHorizontal = ctx.orientation === "horizontal"; 51 + const prevKey = isHorizontal ? "ArrowLeft" : "ArrowUp"; 52 + const nextKey = isHorizontal ? "ArrowRight" : "ArrowDown"; 53 + const secondaryKey = isHorizontal ? "ArrowDown" : "ArrowRight"; 54 + 55 + switch (event.key) { 56 + case prevKey: 57 + event.preventDefault(); 58 + this.#moveFocus(-1, items, ctx.wrap); 59 + break; 60 + case nextKey: 61 + event.preventDefault(); 62 + this.#moveFocus(1, items, ctx.wrap); 63 + break; 64 + case secondaryKey: 65 + if (ctx.onSecondary) { 66 + event.preventDefault(); 67 + ctx.onSecondary(items[this.#focusIndex], this.#focusIndex); 68 + } 69 + break; 70 + case "Enter": 71 + event.preventDefault(); 72 + ctx.onSelect?.(items[this.#focusIndex], this.#focusIndex); 73 + break; 74 + case "Escape": 75 + event.preventDefault(); 76 + ctx.onBack?.(); 77 + break; 78 + } 79 + } 80 + 81 + #moveFocus(delta, items, wrap) { 82 + this.#clearFocus(); 83 + let next = this.#focusIndex + delta; 84 + if (wrap) { 85 + next = (next + items.length) % items.length; 86 + } else { 87 + next = Math.max(0, Math.min(next, items.length - 1)); 88 + } 89 + this.#focusIndex = next; 90 + this.#applyFocus(); 91 + } 92 + 93 + #applyFocus() { 94 + if (!this.#activeContext) return; 95 + const items = this.#activeContext.items(); 96 + const item = items?.[this.#focusIndex]; 97 + if (item) { 98 + item.classList.add("focused"); 99 + item.scrollIntoView?.({ block: "nearest", behavior: "smooth" }); 100 + } 101 + } 102 + 103 + #clearFocus() { 104 + if (!this.#activeContext) return; 105 + const items = this.#activeContext.items(); 106 + items?.forEach((el) => el.classList.remove("focused")); 107 + } 108 + }
+134
ui/system/mediacenter/index.css
··· 1 + /* SPDX-License-Identifier: AGPL-3.0-or-later */ 2 + 3 + /* ===== Reset & base ===== */ 4 + 5 + * { 6 + margin: 0; 7 + padding: 0; 8 + box-sizing: border-box; 9 + } 10 + 11 + html, 12 + body { 13 + width: 100%; 14 + height: 100%; 15 + overflow: hidden; 16 + font-family: var(--font-family-base); 17 + color: var(--color-text-on-header); 18 + background: oklch(15% 0.02 105); 19 + } 20 + 21 + #hearth { 22 + position: relative; 23 + width: 100%; 24 + height: 100%; 25 + } 26 + 27 + /* ===== Webview container ===== */ 28 + /* Shown only in playing state (toggled via hidden attribute in JS) */ 29 + 30 + #webview-container { 31 + position: fixed; 32 + inset: 0; 33 + z-index: 1; 34 + } 35 + 36 + #webview-container[hidden] { 37 + display: none; 38 + } 39 + 40 + #webview-container web-view { 41 + width: 100%; 42 + height: 100%; 43 + } 44 + 45 + /* ===== Component layer ordering ===== */ 46 + /* All media center UI lives above the webview layer */ 47 + 48 + ambient-view { 49 + position: fixed; 50 + inset: 0; 51 + z-index: 5; 52 + } 53 + 54 + nav-dock { 55 + position: fixed; 56 + bottom: 0; 57 + left: 0; 58 + right: 0; 59 + z-index: 10; 60 + } 61 + 62 + content-browser { 63 + position: fixed; 64 + inset: 0; 65 + z-index: 8; 66 + } 67 + 68 + now-playing { 69 + position: fixed; 70 + inset: 0; 71 + z-index: 6; 72 + pointer-events: none; 73 + } 74 + 75 + /* ===== State-based visibility ===== */ 76 + /* Only one layer is visible at a time. Playing state hides all UI — the webview is shown via JS. */ 77 + 78 + /* Ambient: only ambient-view visible */ 79 + body[data-state="ambient"] ambient-view { visibility: visible; opacity: 1; } 80 + body[data-state="ambient"] nav-dock { visibility: hidden; opacity: 0; transform: translateY(100%); } 81 + body[data-state="ambient"] content-browser { visibility: hidden; opacity: 0; } 82 + body[data-state="ambient"] now-playing { visibility: hidden; opacity: 0; } 83 + 84 + /* Dock: ambient dims behind dock */ 85 + body[data-state="dock"] ambient-view { visibility: visible; opacity: 0.4; } 86 + body[data-state="dock"] nav-dock { visibility: visible; opacity: 1; transform: translateY(0); } 87 + body[data-state="dock"] content-browser { visibility: hidden; opacity: 0; } 88 + body[data-state="dock"] now-playing { visibility: hidden; opacity: 0; } 89 + 90 + /* Browser: content browser fills screen */ 91 + body[data-state="browser"] ambient-view { visibility: hidden; opacity: 0; } 92 + body[data-state="browser"] nav-dock { visibility: hidden; opacity: 0; transform: translateY(100%); } 93 + body[data-state="browser"] content-browser { visibility: visible; opacity: 1; } 94 + body[data-state="browser"] now-playing { visibility: hidden; opacity: 0; } 95 + 96 + /* Playing: all media center UI hidden — webview takes over */ 97 + body[data-state="playing"] ambient-view { visibility: hidden; opacity: 0; } 98 + body[data-state="playing"] nav-dock { visibility: hidden; opacity: 0; transform: translateY(100%); } 99 + body[data-state="playing"] content-browser { visibility: hidden; opacity: 0; } 100 + body[data-state="playing"] now-playing { visibility: hidden; opacity: 0; } 101 + 102 + /* ===== Transitions ===== */ 103 + 104 + ambient-view, 105 + content-browser, 106 + now-playing { 107 + transition: 108 + opacity 0.4s cubic-bezier(0.16, 1, 0.3, 1), 109 + visibility 0.4s; 110 + } 111 + 112 + nav-dock { 113 + transition: 114 + opacity 0.3s cubic-bezier(0.16, 1, 0.3, 1), 115 + transform 0.3s cubic-bezier(0.16, 1, 0.3, 1), 116 + visibility 0.3s; 117 + } 118 + 119 + /* ===== Ambient gradient animation ===== */ 120 + 121 + @keyframes hearth-glow { 122 + 0% { background-color: oklch(18% 0.06 30); } 123 + 25% { background-color: oklch(15% 0.05 60); } 124 + 50% { background-color: oklch(14% 0.06 280); } 125 + 75% { background-color: oklch(16% 0.05 180); } 126 + 100% { background-color: oklch(18% 0.06 30); } 127 + } 128 + 129 + /* ===== D-pad focus ring ===== */ 130 + 131 + .focused { 132 + outline: 2px solid var(--color-focus-ring); 133 + outline-offset: 2px; 134 + }
+31
ui/system/mediacenter/index.html
··· 1 + <!DOCTYPE html> 2 + <!-- SPDX-License-Identifier: AGPL-3.0-or-later --> 3 + <html> 4 + <head> 5 + <meta charset="UTF-8" /> 6 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 7 + <title>Beaver</title> 8 + <link rel="stylesheet" href="beaver://theme/index.css" /> 9 + <link rel="stylesheet" href="beaver://shared/fonts/fonts.css" /> 10 + <link 11 + rel="stylesheet" 12 + href="beaver://shared/third_party/lucide/lucide.css" 13 + /> 14 + <script type="module" src="beaver://shared/lucide_icon.js"></script> 15 + <script type="module" src="beaver://shared/theme.js"></script> 16 + <link rel="stylesheet" href="index.css" /> 17 + <script type="module" src="index.js"></script> 18 + </head> 19 + <body data-state="ambient"> 20 + <main id="hearth"> 21 + <ambient-view id="ambient"></ambient-view> 22 + <nav-dock id="dock"></nav-dock> 23 + <content-browser id="browser"></content-browser> 24 + <now-playing id="player"></now-playing> 25 + </main> 26 + <div id="webview-container"></div> 27 + <notification-panel-mc id="notification-panel"></notification-panel-mc> 28 + <open-in-dialog id="open-in-dialog"></open-in-dialog> 29 + <system-menu-mc id="system-menu"></system-menu-mc> 30 + </body> 31 + </html>
+720
ui/system/mediacenter/index.js
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 3 + // The Hearth — Beaver Media Center orchestrator. 4 + // 5 + // Window management: only one thing is visible at a time. 6 + // In "playing" state, a webview owns the screen and all keyboard input. 7 + // Double-Escape exits back to the media center UI. 8 + // Multiple webviews can be alive — "Pages" dock item lists them. 9 + // "Now Playing" dock item jumps to the webview with active media. 10 + 11 + import { DpadManager } from "./dpad.js"; 12 + import { WebView } from "../web_view.js"; 13 + import { MediaBroadcaster } from "../media_broadcast.js"; 14 + import "./ambient_view.js"; 15 + import "./nav_dock.js"; 16 + import "./content_browser.js"; 17 + import "./now_playing.js"; 18 + import "./system_menu.js"; 19 + import "./notification_panel.js"; 20 + import "./open_in_dialog.js"; 21 + 22 + // --- State machine --- 23 + 24 + const STATES = { 25 + AMBIENT: "ambient", 26 + DOCK: "dock", 27 + BROWSER: "browser", 28 + PLAYING: "playing", 29 + }; 30 + 31 + let currentState = STATES.AMBIENT; 32 + let currentCategory = null; 33 + let stateBeforePlaying = null; 34 + 35 + const dpad = new DpadManager(); 36 + 37 + const ambient = document.getElementById("ambient"); 38 + const dock = document.getElementById("dock"); 39 + const browser = document.getElementById("browser"); 40 + const player = document.getElementById("player"); 41 + const webviewContainer = document.getElementById("webview-container"); 42 + const systemMenu = document.getElementById("system-menu"); 43 + const notificationPanel = document.getElementById("notification-panel"); 44 + const openInDialog = document.getElementById("open-in-dialog"); 45 + 46 + let systemMenuOpen = false; 47 + let stateBeforeMenu = null; 48 + 49 + let notificationPanelOpen = false; 50 + const pendingPairingRequests = new Map(); // id -> peer 51 + let mediaWasPausedForPairing = false; 52 + 53 + // --- Page management --- 54 + // Each open webview is tracked with metadata and a stable ID. 55 + 56 + let nextPageId = 1; 57 + const pages = []; // { id, webview, title, sourceUrl } 58 + let activePageIndex = -1; 59 + 60 + let mediaState = { 61 + title: "", 62 + artist: "", 63 + album: "", 64 + playbackState: "none", 65 + duration: 0, 66 + position: 0, 67 + webviewId: null, 68 + }; 69 + 70 + // P2P media broadcast 71 + const mediaBroadcaster = new MediaBroadcaster({ 72 + deviceName: "Beaver Media Center", 73 + onRemoteAction: (action) => { 74 + const mediaPage = pages.find( 75 + (p) => p.webview.webviewId === mediaState.webviewId, 76 + ); 77 + if (mediaPage?.webview?.iframe) { 78 + mediaPage.webview.iframe.mediaSessionAction(action); 79 + } 80 + }, 81 + }); 82 + 83 + // Idle management 84 + let idleTimer = null; 85 + const IDLE_DOCK_MS = 8000; 86 + 87 + function resetIdle() { 88 + clearTimeout(idleTimer); 89 + if (currentState === STATES.DOCK) { 90 + idleTimer = setTimeout(() => setState(STATES.AMBIENT), IDLE_DOCK_MS); 91 + } 92 + } 93 + 94 + // --- Double-Escape detection --- 95 + 96 + let lastEscapeTime = 0; 97 + const DOUBLE_ESCAPE_MS = 400; 98 + 99 + // --- State transitions --- 100 + 101 + function setState(newState, detail) { 102 + const prevState = currentState; 103 + currentState = newState; 104 + document.body.setAttribute("data-state", newState); 105 + dpad.clearContext(); 106 + 107 + // Show/hide webview container 108 + if (newState === STATES.PLAYING) { 109 + webviewContainer.hidden = false; 110 + showActivePage(); 111 + } else { 112 + webviewContainer.hidden = true; 113 + } 114 + 115 + switch (newState) { 116 + case STATES.AMBIENT: 117 + ambient.startShader(); 118 + break; 119 + 120 + case STATES.DOCK: 121 + // Ambient is visible (dimmed) behind the dock — ensure shader is running 122 + ambient.startShader(); 123 + // Update dock badges 124 + dock.hasActiveMedia = isMediaActive(); 125 + dock.pageCount = pages.length; 126 + dock.updateComplete.then(() => { 127 + dpad.setContext({ 128 + items: () => dock.getDpadItems(), 129 + orientation: "horizontal", 130 + onSelect: (item, index) => dock.selectItem(item, index), 131 + onBack: () => dock.dismiss(), 132 + wrap: true, 133 + }); 134 + }); 135 + break; 136 + 137 + case STATES.BROWSER: 138 + ambient.stopShader(); 139 + if (detail?.category) { 140 + currentCategory = detail.category; 141 + const isPages = detail.category === "pages"; 142 + 143 + const setDpadContext = () => { 144 + browser.updateComplete.then(() => { 145 + dpad.setContext({ 146 + items: () => browser.getDpadItems(), 147 + orientation: "vertical", 148 + onSelect: (item, index) => browser.selectItem(item, index), 149 + onBack: () => browser.back(), 150 + onSecondary: isPages 151 + ? (item, index) => browser.closePageItem(index) 152 + : undefined, 153 + wrap: false, 154 + }); 155 + }); 156 + }; 157 + 158 + if (isPages) { 159 + browser.showPages( 160 + pages.map((p, i) => ({ 161 + title: p.title, 162 + url: p.webview.currentUrl || p.sourceUrl, 163 + index: i, 164 + isActive: i === activePageIndex, 165 + hasMedia: p.webview.webviewId === mediaState.webviewId && 166 + (mediaState.playbackState === "playing" || mediaState.playbackState === "paused"), 167 + })), 168 + ); 169 + setDpadContext(); 170 + } else { 171 + // loadSources is async — wait for it, then wait for render 172 + browser.loadSources(detail.category).then(setDpadContext); 173 + } 174 + } 175 + break; 176 + 177 + case STATES.PLAYING: 178 + ambient.stopShader(); 179 + if (prevState !== STATES.PLAYING) { 180 + stateBeforePlaying = { state: prevState, category: currentCategory }; 181 + } 182 + // Webview owns all input — no d-pad context. 183 + // Focus the iframe so keyboard events reach the inner page. 184 + focusActiveWebview(); 185 + break; 186 + } 187 + 188 + resetIdle(); 189 + } 190 + 191 + function showActivePage() { 192 + // Hide all pages, show the active one 193 + for (let i = 0; i < pages.length; i++) { 194 + pages[i].webview.style.display = i === activePageIndex ? "flex" : "none"; 195 + } 196 + } 197 + 198 + function focusActiveWebview() { 199 + if (activePageIndex < 0 || activePageIndex >= pages.length) return; 200 + const webView = pages[activePageIndex].webview; 201 + // Give the container a frame to become visible, then focus 202 + requestAnimationFrame(() => webView.focusWebview()); 203 + } 204 + 205 + function exitPlaying() { 206 + // Pause media in the active webview before leaving 207 + if (activePageIndex >= 0 && activePageIndex < pages.length) { 208 + const webView = pages[activePageIndex].webview; 209 + if (webView?.iframe) { 210 + try { webView.iframe.mediaSessionAction("pause"); } catch (e) {} 211 + } 212 + } 213 + 214 + if (stateBeforePlaying) { 215 + const { state, category } = stateBeforePlaying; 216 + if (state === STATES.BROWSER && category) { 217 + setState(STATES.BROWSER, { category }); 218 + } else { 219 + setState(STATES.DOCK); 220 + } 221 + } else { 222 + setState(STATES.DOCK); 223 + } 224 + } 225 + 226 + function isMediaActive() { 227 + return mediaState.playbackState === "playing" || mediaState.playbackState === "paused"; 228 + } 229 + 230 + // --- Event listeners --- 231 + 232 + document.addEventListener("dock-select", (e) => { 233 + const { id } = e.detail; 234 + 235 + if (id === "now-playing") { 236 + // Find the webview with active media and switch to it 237 + const mediaPageIndex = pages.findIndex( 238 + (p) => p.webview.webviewId === mediaState.webviewId, 239 + ); 240 + if (mediaPageIndex >= 0) { 241 + activePageIndex = mediaPageIndex; 242 + setState(STATES.PLAYING); 243 + } 244 + } else if (id === "pages") { 245 + setState(STATES.BROWSER, { category: "pages" }); 246 + } else if (id === "notifications") { 247 + openNotificationPanel(); 248 + } else { 249 + setState(STATES.BROWSER, { category: id }); 250 + } 251 + }); 252 + 253 + document.addEventListener("dock-dismiss", () => { 254 + setState(STATES.AMBIENT); 255 + }); 256 + 257 + document.addEventListener("content-select", (e) => { 258 + const detail = e.detail; 259 + if (detail.pageIndex !== undefined) { 260 + // Switching to an existing page 261 + activePageIndex = detail.pageIndex; 262 + setState(STATES.PLAYING); 263 + } else if (detail.closePageIndex !== undefined) { 264 + // Closing a page 265 + closePage(detail.closePageIndex); 266 + } else { 267 + // Launching new content 268 + launchContent(detail); 269 + } 270 + }); 271 + 272 + document.addEventListener("content-back", () => { 273 + setState(STATES.DOCK); 274 + }); 275 + 276 + document.addEventListener("media-action", (e) => { 277 + const { action } = e.detail; 278 + // Send action to the webview that has active media 279 + const mediaPage = pages.find( 280 + (p) => p.webview.webviewId === mediaState.webviewId, 281 + ); 282 + if (mediaPage?.webview?.iframe) { 283 + mediaPage.webview.iframe.mediaSessionAction(action); 284 + } 285 + }); 286 + 287 + // --- System menu --- 288 + 289 + function openSystemMenu() { 290 + if (systemMenuOpen) return; 291 + systemMenuOpen = true; 292 + stateBeforeMenu = currentState; 293 + systemMenu.open = true; 294 + dpad.clearContext(); 295 + systemMenu.updateComplete.then(() => { 296 + dpad.setContext({ 297 + items: () => systemMenu.getDpadItems(), 298 + orientation: "vertical", 299 + onSelect: (item, index) => systemMenu.selectItem(item, index), 300 + onBack: () => closeSystemMenu(), 301 + wrap: true, 302 + }); 303 + }); 304 + } 305 + 306 + function closeSystemMenu() { 307 + if (!systemMenuOpen) return; 308 + systemMenuOpen = false; 309 + systemMenu.open = false; 310 + // Restore previous state 311 + if (stateBeforeMenu) { 312 + setState(stateBeforeMenu, stateBeforeMenu === STATES.BROWSER ? { category: currentCategory } : undefined); 313 + } 314 + stateBeforeMenu = null; 315 + } 316 + 317 + document.addEventListener("menu-action", (e) => { 318 + const { action } = e.detail; 319 + closeSystemMenu(); 320 + if (action === "quit") { 321 + navigator.embedder.exit(); 322 + } else if (action === "settings") { 323 + // Open settings as a page 324 + launchContent({ category: "system", url: "beaver://settings/index.html", title: "Settings" }); 325 + } 326 + }); 327 + 328 + document.addEventListener("menu-dismiss", () => { 329 + closeSystemMenu(); 330 + }); 331 + 332 + document.addEventListener("keydown", (e) => { 333 + // System menu takes priority — Q opens it from any UI state 334 + if (e.key === "q" || e.key === "Q") { 335 + if (!systemMenuOpen && !notificationPanelOpen && !openInDialogOpen && currentState !== STATES.PLAYING) { 336 + e.preventDefault(); 337 + openSystemMenu(); 338 + return; 339 + } 340 + } 341 + 342 + // If system menu is open, d-pad manager handles it (Escape = dismiss) 343 + if (systemMenuOpen) return; 344 + 345 + // If notification panel is open, d-pad manager handles it 346 + if (notificationPanelOpen) return; 347 + 348 + // If open-in dialog is open, d-pad manager handles it 349 + if (openInDialogOpen) return; 350 + 351 + if (currentState === STATES.PLAYING) { 352 + if (e.key === "Escape") { 353 + const now = Date.now(); 354 + if (now - lastEscapeTime < DOUBLE_ESCAPE_MS) { 355 + e.preventDefault(); 356 + e.stopPropagation(); 357 + lastEscapeTime = 0; 358 + exitPlaying(); 359 + } else { 360 + lastEscapeTime = now; 361 + } 362 + } else { 363 + lastEscapeTime = 0; 364 + } 365 + return; 366 + } 367 + 368 + resetIdle(); 369 + 370 + if (currentState === STATES.AMBIENT) { 371 + if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Enter"].includes(e.key)) { 372 + e.preventDefault(); 373 + setState(STATES.DOCK); 374 + } 375 + } 376 + }); 377 + 378 + // --- Page lifecycle --- 379 + 380 + function launchContent(source) { 381 + // Each source has a stable identity from its config (category + original URL). 382 + // If a page was launched from this same source, switch to it. 383 + const sourceId = `${source.category}:${source.url}`; 384 + const existingIndex = pages.findIndex((p) => p.sourceId === sourceId); 385 + if (existingIndex >= 0) { 386 + activePageIndex = existingIndex; 387 + setState(STATES.PLAYING); 388 + return; 389 + } 390 + 391 + const webView = new WebView(source.url, source.title || ""); 392 + webView.classList.add("mediacenter-mode"); 393 + webviewContainer.appendChild(webView); 394 + 395 + // Enable spatial navigation for regular web pages (not our own beaver:// pages 396 + // which have their own keyboard navigation) 397 + if (!source.url.startsWith("beaver://")) { 398 + webView.enableSpatialNavigation(); 399 + } 400 + 401 + const page = { 402 + id: nextPageId++, 403 + webview: webView, 404 + title: source.title || source.url, 405 + sourceId, 406 + sourceUrl: source.url, 407 + }; 408 + pages.push(page); 409 + activePageIndex = pages.length - 1; 410 + 411 + // Track media session events 412 + webView.addEventListener("webview-mediasession", (e) => { 413 + updateMediaState(e.detail); 414 + }); 415 + 416 + setState(STATES.PLAYING); 417 + } 418 + 419 + function closePage(index) { 420 + if (index < 0 || index >= pages.length) return; 421 + 422 + const page = pages[index]; 423 + page.webview.remove(); 424 + pages.splice(index, 1); 425 + 426 + // Adjust activePageIndex 427 + if (pages.length === 0) { 428 + activePageIndex = -1; 429 + } else if (index <= activePageIndex) { 430 + activePageIndex = Math.max(0, activePageIndex - 1); 431 + } 432 + 433 + // Clear media state if we closed the media webview 434 + if (page.webview.webviewId === mediaState.webviewId) { 435 + mediaState = { 436 + title: "", artist: "", album: "", 437 + playbackState: "none", duration: 0, position: 0, webviewId: null, 438 + }; 439 + ambient.mediaTitle = ""; 440 + ambient.mediaArtist = ""; 441 + ambient.playbackState = "none"; 442 + } 443 + 444 + // Refresh the pages list if we're viewing it 445 + if (currentState === STATES.BROWSER && currentCategory === "pages") { 446 + if (pages.length === 0) { 447 + // No pages left — go back to dock 448 + setState(STATES.DOCK); 449 + } else { 450 + setState(STATES.BROWSER, { category: "pages" }); 451 + } 452 + } 453 + 454 + // Update dock 455 + dock.hasActiveMedia = isMediaActive(); 456 + dock.pageCount = pages.length; 457 + } 458 + 459 + function updateMediaState(detail) { 460 + if (detail.title) mediaState.title = detail.title; 461 + if (detail.artist) mediaState.artist = detail.artist; 462 + if (detail.album) mediaState.album = detail.album; 463 + if (detail.playbackState) mediaState.playbackState = detail.playbackState; 464 + if (detail.duration) mediaState.duration = detail.duration; 465 + if (detail.position) mediaState.position = detail.position; 466 + if (detail.webviewId) mediaState.webviewId = detail.webviewId; 467 + 468 + // Update now-playing component 469 + player.title = mediaState.title; 470 + player.artist = mediaState.artist; 471 + player.playbackState = mediaState.playbackState; 472 + player.duration = mediaState.duration; 473 + player.position = mediaState.position; 474 + 475 + // Update ambient view 476 + ambient.mediaTitle = mediaState.title; 477 + ambient.mediaArtist = mediaState.artist; 478 + ambient.playbackState = mediaState.playbackState; 479 + 480 + // Update dock 481 + dock.hasActiveMedia = isMediaActive(); 482 + 483 + // Broadcast to paired peers 484 + mediaBroadcaster.updateState(detail); 485 + } 486 + 487 + // --- Notification panel (pairing requests) --- 488 + 489 + function openNotificationPanel() { 490 + if (notificationPanelOpen) return; 491 + notificationPanelOpen = true; 492 + notificationPanel.setRequests(pendingPairingRequests); 493 + notificationPanel.open = true; 494 + dpad.clearContext(); 495 + notificationPanel.updateComplete.then(() => { 496 + dpad.setContext({ 497 + items: () => notificationPanel.getDpadItems(), 498 + orientation: "vertical", 499 + onSelect: (item, index) => notificationPanel.selectAction(item, index), 500 + onBack: () => closeNotificationPanel(), 501 + wrap: false, 502 + }); 503 + }); 504 + } 505 + 506 + function closeNotificationPanel() { 507 + if (!notificationPanelOpen) return; 508 + notificationPanelOpen = false; 509 + notificationPanel.open = false; 510 + 511 + // Resume media if we paused it for the pairing dialog 512 + if (mediaWasPausedForPairing) { 513 + mediaWasPausedForPairing = false; 514 + const mediaPage = pages.find( 515 + (p) => p.webview.webviewId === mediaState.webviewId, 516 + ); 517 + if (mediaPage?.webview?.iframe) { 518 + try { mediaPage.webview.iframe.mediaSessionAction("play"); } catch (e) {} 519 + } 520 + } 521 + 522 + // Restore d-pad context for the current state (the state never changed) 523 + setState(currentState, 524 + currentState === STATES.BROWSER ? { category: currentCategory } : undefined); 525 + } 526 + 527 + function updateNotificationCount() { 528 + dock.notificationCount = pendingPairingRequests.size; 529 + } 530 + 531 + document.addEventListener("notifications-changed", () => { 532 + // Sync the pending map with what the panel removed 533 + for (const [id] of pendingPairingRequests) { 534 + // If the panel accepted/rejected it, it dispatched the event 535 + } 536 + updateNotificationCount(); 537 + }); 538 + 539 + document.addEventListener("notifications-dismiss", () => { 540 + closeNotificationPanel(); 541 + }); 542 + 543 + // --- P2P Pairing --- 544 + 545 + function initPairing() { 546 + const pairing = navigator.embedder?.pairing; 547 + if (!pairing) { 548 + console.warn("[MediaCenter] Pairing API not available"); 549 + return; 550 + } 551 + 552 + pairing.setName("Beaver Media Center").catch((e) => 553 + console.error("[P2P] setName failed:", e), 554 + ); 555 + 556 + pairing.start().catch((e) => 557 + console.error("[P2P] start failed:", e), 558 + ); 559 + 560 + pairing.addEventListener("pairingrequest", (e) => { 561 + const peer = e.peer; 562 + console.log(`[P2P] Pairing request from ${peer.displayName} (${peer.id})`); 563 + pendingPairingRequests.set(peer.id, peer); 564 + updateNotificationCount(); 565 + 566 + // Pause media if playing 567 + mediaWasPausedForPairing = false; 568 + if (isMediaActive()) { 569 + const mediaPage = pages.find( 570 + (p) => p.webview.webviewId === mediaState.webviewId, 571 + ); 572 + if (mediaPage?.webview?.iframe) { 573 + try { mediaPage.webview.iframe.mediaSessionAction("pause"); } catch (e) {} 574 + mediaWasPausedForPairing = true; 575 + } 576 + } 577 + 578 + // Show the pairing dialog on top of whatever is currently showing 579 + // (don't change states — just overlay) 580 + openNotificationPanel(); 581 + }); 582 + 583 + pairing.addEventListener("peerjoined", (e) => { 584 + if (pendingPairingRequests.delete(e.peer.id)) { 585 + updateNotificationCount(); 586 + notificationPanel.removeRequest(e.peer.id); 587 + } 588 + // Send hello so the peer responds with its media state 589 + setTimeout(() => { 590 + mediaBroadcaster.sendHello(); 591 + }, 5000); 592 + }); 593 + 594 + pairing.addEventListener("peerleft", (e) => { 595 + if (pendingPairingRequests.delete(e.peer.id)) { 596 + updateNotificationCount(); 597 + notificationPanel.removeRequest(e.peer.id); 598 + } 599 + }); 600 + 601 + console.log("[MediaCenter] P2P pairing initialized"); 602 + } 603 + 604 + // --- Initialize --- 605 + 606 + setState(STATES.AMBIENT); 607 + initPairing(); 608 + 609 + // --- Receive "Open in..." from paired devices via PeerStream --- 610 + 611 + const VIDEO_EXTENSIONS = ["mp4", "webm", "ogg", "ogv", "mkv", "avi", "mov", "m4v"]; 612 + 613 + function isLikelyVideoUrl(url) { 614 + try { 615 + const u = new URL(url); 616 + const ext = u.pathname.split(".").pop().toLowerCase(); 617 + return VIDEO_EXTENSIONS.includes(ext); 618 + } catch { 619 + return false; 620 + } 621 + } 622 + 623 + function openReceivedUrl(url, asVideo) { 624 + if (asVideo) { 625 + const playerUrl = `beaver://system/mediacenter/player.html?title=${encodeURIComponent(url)}&src=${encodeURIComponent(url)}`; 626 + launchContent({ category: "received", url: playerUrl, title: url }); 627 + } else { 628 + launchContent({ category: "received", url, title: url }); 629 + } 630 + } 631 + 632 + // D-pad dialog asking user to choose video player or web page 633 + let openInDialogOpen = false; 634 + let mediaWasPausedForOpenIn = false; 635 + 636 + function showOpenInDialog(peerName, url) { 637 + if (openInDialogOpen) return; 638 + openInDialogOpen = true; 639 + 640 + // Pause media if playing 641 + mediaWasPausedForOpenIn = false; 642 + if (isMediaActive()) { 643 + const mediaPage = pages.find( 644 + (p) => p.webview.webviewId === mediaState.webviewId, 645 + ); 646 + if (mediaPage?.webview?.iframe) { 647 + try { mediaPage.webview.iframe.mediaSessionAction("pause"); } catch (e) {} 648 + mediaWasPausedForOpenIn = true; 649 + } 650 + } 651 + 652 + openInDialog.peerName = peerName; 653 + openInDialog.url = url; 654 + openInDialog.open = true; 655 + dpad.clearContext(); 656 + openInDialog.updateComplete.then(() => { 657 + dpad.setContext({ 658 + items: () => openInDialog.getDpadItems(), 659 + orientation: "vertical", 660 + onSelect: (item) => openInDialog.selectItem(item), 661 + onBack: () => openInDialog.dismiss(), 662 + wrap: true, 663 + }); 664 + }); 665 + } 666 + 667 + function closeOpenInDialog() { 668 + if (!openInDialogOpen) return; 669 + openInDialogOpen = false; 670 + openInDialog.open = false; 671 + 672 + if (mediaWasPausedForOpenIn) { 673 + mediaWasPausedForOpenIn = false; 674 + const mediaPage = pages.find( 675 + (p) => p.webview.webviewId === mediaState.webviewId, 676 + ); 677 + if (mediaPage?.webview?.iframe) { 678 + try { mediaPage.webview.iframe.mediaSessionAction("play"); } catch (e) {} 679 + } 680 + } 681 + 682 + setState(currentState, 683 + currentState === STATES.BROWSER ? { category: currentCategory } : undefined); 684 + } 685 + 686 + document.addEventListener("openin-select", (e) => { 687 + const { action, url } = e.detail; 688 + closeOpenInDialog(); 689 + openReceivedUrl(url, action === "video"); 690 + }); 691 + 692 + document.addEventListener("openin-dismiss", () => { 693 + closeOpenInDialog(); 694 + }); 695 + 696 + window.onpeerstream = (e) => { 697 + const port = e.port; 698 + const peerId = e.peerId; 699 + port.onmessage = async (msg) => { 700 + const { action, url } = msg.data; 701 + if (action !== "open-view" || !url) return; 702 + 703 + // Resolve peer name 704 + let peerName = "paired device"; 705 + try { 706 + const peers = await navigator.embedder.pairing.peers(); 707 + const peer = peers.find((p) => p.id === peerId); 708 + if (peer?.displayName) peerName = peer.displayName; 709 + } catch {} 710 + 711 + // If it's clearly a video file, open directly in the player 712 + if (isLikelyVideoUrl(url)) { 713 + openReceivedUrl(url, true); 714 + return; 715 + } 716 + 717 + // Otherwise, ask the user 718 + showOpenInDialog(peerName, url); 719 + }; 720 + };
+147
ui/system/mediacenter/nav_dock.js
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 3 + import { 4 + LitElement, 5 + html, 6 + css, 7 + } from "beaver://shared/third_party/lit/lit-all.min.js"; 8 + 9 + class NavDock extends LitElement { 10 + static properties = { 11 + hasActiveMedia: { type: Boolean }, 12 + pageCount: { type: Number }, 13 + notificationCount: { type: Number }, 14 + }; 15 + 16 + static styles = css` 17 + :host { 18 + display: block; 19 + font-family: var(--font-family-base); 20 + } 21 + 22 + .dock { 23 + display: flex; 24 + justify-content: center; 25 + gap: 0.5em; 26 + padding: 1.2em 2em; 27 + background: oklch(15% 0.02 105 / 0.85); 28 + backdrop-filter: blur(20px); 29 + border-radius: var(--radius-lg) var(--radius-lg) 0 0; 30 + } 31 + 32 + .dock-item { 33 + display: flex; 34 + flex-direction: column; 35 + align-items: center; 36 + gap: 0.4em; 37 + padding: 0.8em 1.5em; 38 + border-radius: var(--radius-md); 39 + color: oklch(80% 0.02 85); 40 + cursor: pointer; 41 + transition: 42 + background 0.18s cubic-bezier(0.16, 1, 0.3, 1), 43 + color 0.18s cubic-bezier(0.16, 1, 0.3, 1); 44 + outline: none; 45 + position: relative; 46 + } 47 + 48 + .dock-item.focused { 49 + background: oklch(30% 0.04 105 / 0.6); 50 + color: oklch(95% 0.02 85); 51 + outline: 2px solid var(--color-focus-ring); 52 + outline-offset: -2px; 53 + } 54 + 55 + .dock-item lucide-icon { 56 + font-size: 1.8em; 57 + } 58 + 59 + .dock-item .label { 60 + font-size: 0.85em; 61 + font-weight: var(--font-weight-bold); 62 + } 63 + 64 + .badge { 65 + position: absolute; 66 + top: 0.3em; 67 + right: 0.3em; 68 + min-width: 1.2em; 69 + height: 1.2em; 70 + padding: 0 0.3em; 71 + border-radius: 0.6em; 72 + background: var(--color-primary); 73 + color: var(--color-text-on-header); 74 + font-size: 0.65em; 75 + font-weight: var(--font-weight-bold); 76 + display: flex; 77 + align-items: center; 78 + justify-content: center; 79 + } 80 + `; 81 + 82 + constructor() { 83 + super(); 84 + this.hasActiveMedia = false; 85 + this.pageCount = 0; 86 + this.notificationCount = 0; 87 + } 88 + 89 + get items() { 90 + const items = [ 91 + { id: "music", label: "Music", icon: "music" }, 92 + { id: "videos", label: "Videos", icon: "film" }, 93 + { id: "web", label: "Web", icon: "globe" }, 94 + ]; 95 + if (this.pageCount > 0) { 96 + items.push({ id: "pages", label: "Pages", icon: "layers", badge: this.pageCount }); 97 + } 98 + if (this.notificationCount > 0) { 99 + items.push({ id: "notifications", label: "Pairing", icon: "bell", badge: this.notificationCount }); 100 + } 101 + if (this.hasActiveMedia) { 102 + items.unshift({ id: "now-playing", label: "Now Playing", icon: "disc-3" }); 103 + } 104 + return items; 105 + } 106 + 107 + getDpadItems() { 108 + return Array.from(this.shadowRoot.querySelectorAll(".dock-item")); 109 + } 110 + 111 + selectItem(item, index) { 112 + const data = this.items[index]; 113 + if (data) { 114 + this.dispatchEvent( 115 + new CustomEvent("dock-select", { 116 + bubbles: true, 117 + composed: true, 118 + detail: { id: data.id }, 119 + }), 120 + ); 121 + } 122 + } 123 + 124 + dismiss() { 125 + this.dispatchEvent( 126 + new CustomEvent("dock-dismiss", { bubbles: true, composed: true }), 127 + ); 128 + } 129 + 130 + render() { 131 + return html` 132 + <div class="dock"> 133 + ${this.items.map( 134 + (item) => html` 135 + <div class="dock-item" data-id="${item.id}"> 136 + <lucide-icon name="${item.icon}"></lucide-icon> 137 + <span class="label">${item.label}</span> 138 + ${item.badge ? html`<span class="badge">${item.badge}</span>` : ""} 139 + </div> 140 + `, 141 + )} 142 + </div> 143 + `; 144 + } 145 + } 146 + 147 + customElements.define("nav-dock", NavDock);
+282
ui/system/mediacenter/notification_panel.js
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 3 + import { 4 + LitElement, 5 + html, 6 + css, 7 + } from "beaver://shared/third_party/lit/lit-all.min.js"; 8 + 9 + class NotificationPanelMC extends LitElement { 10 + static properties = { 11 + open: { type: Boolean, reflect: true }, 12 + _requests: { state: true }, 13 + }; 14 + 15 + static styles = css` 16 + :host { 17 + display: none; 18 + position: fixed; 19 + inset: 0; 20 + z-index: 90; 21 + font-family: var(--font-family-base); 22 + } 23 + 24 + :host([open]) { 25 + display: flex; 26 + align-items: center; 27 + justify-content: center; 28 + } 29 + 30 + .backdrop { 31 + position: absolute; 32 + inset: 0; 33 + background: oklch(8% 0.01 105 / 0.85); 34 + backdrop-filter: blur(20px); 35 + } 36 + 37 + .panel { 38 + position: relative; 39 + display: flex; 40 + flex-direction: column; 41 + min-width: 360px; 42 + max-width: 500px; 43 + max-height: 70vh; 44 + } 45 + 46 + .panel-title { 47 + font-family: Pacifico, cursive; 48 + font-size: 1.6em; 49 + color: oklch(85% 0.03 85); 50 + text-align: center; 51 + margin-bottom: 1em; 52 + } 53 + 54 + .list { 55 + display: flex; 56 + flex-direction: column; 57 + gap: 0.5em; 58 + overflow-y: auto; 59 + } 60 + 61 + /* Each request is a group: header + two action buttons */ 62 + .request-group { 63 + display: flex; 64 + flex-direction: column; 65 + gap: 0.15em; 66 + } 67 + 68 + .request-header { 69 + display: flex; 70 + align-items: center; 71 + gap: 0.8em; 72 + padding: 0.8em 1.5em 0.4em; 73 + color: oklch(85% 0.02 85); 74 + } 75 + 76 + .request-header lucide-icon { 77 + font-size: 1.3em; 78 + color: var(--color-primary); 79 + } 80 + 81 + .request-name { 82 + font-size: 1.1em; 83 + font-weight: var(--font-weight-bold); 84 + } 85 + 86 + .request-label { 87 + font-size: 0.8em; 88 + color: oklch(55% 0.02 85); 89 + margin-top: 0.1em; 90 + } 91 + 92 + .action-item { 93 + display: flex; 94 + align-items: center; 95 + gap: 0.8em; 96 + padding: 0.7em 1.5em 0.7em 3.5em; 97 + border-radius: var(--radius-md); 98 + color: oklch(75% 0.02 85); 99 + cursor: pointer; 100 + transition: background 0.18s cubic-bezier(0.16, 1, 0.3, 1); 101 + } 102 + 103 + .action-item.focused { 104 + background: oklch(25% 0.04 105 / 0.6); 105 + color: oklch(95% 0.02 85); 106 + outline: 2px solid var(--color-focus-ring); 107 + outline-offset: -2px; 108 + } 109 + 110 + .action-item lucide-icon { 111 + font-size: 1.1em; 112 + } 113 + 114 + .action-item.accept lucide-icon { 115 + color: var(--color-primary); 116 + } 117 + 118 + .action-item.reject lucide-icon { 119 + color: oklch(65% 0.1 25); 120 + } 121 + 122 + .action-item.focused.reject lucide-icon { 123 + color: oklch(80% 0.12 25); 124 + } 125 + 126 + .action-status { 127 + font-size: 0.85em; 128 + color: oklch(60% 0.02 85); 129 + padding: 0.7em 1.5em 0.7em 3.5em; 130 + } 131 + 132 + .empty { 133 + text-align: center; 134 + color: oklch(55% 0.02 85); 135 + padding: 2em; 136 + font-size: 1.1em; 137 + } 138 + `; 139 + 140 + constructor() { 141 + super(); 142 + this.open = false; 143 + this._requests = []; // { id, peer, status: "pending"|"accepting"|"rejecting" } 144 + } 145 + 146 + setRequests(pendingMap) { 147 + this._requests = Array.from(pendingMap.entries()).map(([id, peer]) => ({ 148 + id, 149 + peer, 150 + status: "pending", 151 + })); 152 + } 153 + 154 + getDpadItems() { 155 + return Array.from(this.shadowRoot.querySelectorAll(".action-item")); 156 + } 157 + 158 + selectAction(item, index) { 159 + const action = item.dataset.action; 160 + const reqId = item.dataset.reqId; 161 + const req = this._requests.find((r) => r.id === reqId); 162 + if (!req || req.status !== "pending") return; 163 + 164 + if (action === "accept") { 165 + this._doAccept(req); 166 + } else if (action === "reject") { 167 + this._doReject(req); 168 + } 169 + } 170 + 171 + async _doAccept(req) { 172 + req.status = "accepting"; 173 + this._requests = [...this._requests]; 174 + 175 + try { 176 + await navigator.embedder.pairing.acceptPairing(req.peer); 177 + console.log(`[P2P] Accepted pairing from ${req.peer.displayName}`); 178 + this._requests = this._requests.filter((r) => r.id !== req.id); 179 + this._dispatchUpdate(); 180 + } catch (e) { 181 + console.error("[P2P] Accept failed:", e); 182 + req.status = "pending"; 183 + this._requests = [...this._requests]; 184 + } 185 + } 186 + 187 + async _doReject(req) { 188 + req.status = "rejecting"; 189 + this._requests = [...this._requests]; 190 + 191 + try { 192 + await navigator.embedder.pairing.rejectPairing(req.peer); 193 + console.log(`[P2P] Rejected pairing from ${req.peer.displayName}`); 194 + this._requests = this._requests.filter((r) => r.id !== req.id); 195 + this._dispatchUpdate(); 196 + } catch (e) { 197 + console.error("[P2P] Reject failed:", e); 198 + req.status = "pending"; 199 + this._requests = [...this._requests]; 200 + } 201 + } 202 + 203 + removeRequest(peerId) { 204 + this._requests = this._requests.filter((r) => r.id !== peerId); 205 + this._dispatchUpdate(); 206 + } 207 + 208 + _dispatchUpdate() { 209 + this.dispatchEvent( 210 + new CustomEvent("notifications-changed", { 211 + bubbles: true, 212 + composed: true, 213 + detail: { count: this._requests.length }, 214 + }), 215 + ); 216 + if (this._requests.length === 0 && this.open) { 217 + this.dispatchEvent( 218 + new CustomEvent("notifications-dismiss", { bubbles: true, composed: true }), 219 + ); 220 + } 221 + } 222 + 223 + dismiss() { 224 + this.dispatchEvent( 225 + new CustomEvent("notifications-dismiss", { bubbles: true, composed: true }), 226 + ); 227 + } 228 + 229 + _renderRequest(req) { 230 + if (req.status !== "pending") { 231 + return html` 232 + <div class="request-group"> 233 + <div class="request-header"> 234 + <lucide-icon name="monitor-smartphone"></lucide-icon> 235 + <div> 236 + <div class="request-name">${req.peer.displayName || "Unknown device"}</div> 237 + </div> 238 + </div> 239 + <div class="action-status"> 240 + ${req.status === "accepting" ? "Accepting…" : "Rejecting…"} 241 + </div> 242 + </div> 243 + `; 244 + } 245 + 246 + return html` 247 + <div class="request-group"> 248 + <div class="request-header"> 249 + <lucide-icon name="monitor-smartphone"></lucide-icon> 250 + <div> 251 + <div class="request-name">${req.peer.displayName || "Unknown device"}</div> 252 + <div class="request-label">Wants to pair</div> 253 + </div> 254 + </div> 255 + <div class="action-item accept" data-action="accept" data-req-id="${req.id}"> 256 + <lucide-icon name="check"></lucide-icon> 257 + <span>Accept</span> 258 + </div> 259 + <div class="action-item reject" data-action="reject" data-req-id="${req.id}"> 260 + <lucide-icon name="x"></lucide-icon> 261 + <span>Reject</span> 262 + </div> 263 + </div> 264 + `; 265 + } 266 + 267 + render() { 268 + if (!this.open) return html``; 269 + 270 + return html` 271 + <div class="backdrop"></div> 272 + <div class="panel"> 273 + <div class="panel-title">Pairing Requests</div> 274 + ${this._requests.length === 0 275 + ? html`<div class="empty">No pending requests</div>` 276 + : html`<div class="list">${this._requests.map((req) => this._renderRequest(req))}</div>`} 277 + </div> 278 + `; 279 + } 280 + } 281 + 282 + customElements.define("notification-panel-mc", NotificationPanelMC);
+219
ui/system/mediacenter/now_playing.js
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 3 + import { 4 + LitElement, 5 + html, 6 + css, 7 + } from "beaver://shared/third_party/lit/lit-all.min.js"; 8 + 9 + class NowPlaying extends LitElement { 10 + static properties = { 11 + title: { type: String }, 12 + artist: { type: String }, 13 + playbackState: { type: String }, 14 + duration: { type: Number }, 15 + position: { type: Number }, 16 + overlayVisible: { type: Boolean }, 17 + }; 18 + 19 + static styles = css` 20 + :host { 21 + display: flex; 22 + align-items: center; 23 + justify-content: center; 24 + width: 100%; 25 + height: 100%; 26 + font-family: var(--font-family-base); 27 + /* Transparent by default — webview shows through */ 28 + } 29 + 30 + /* Audio-only background: only shown when media is playing but no video */ 31 + :host(.audio-mode) { 32 + animation: hearth-glow 60s ease-in-out infinite; 33 + } 34 + 35 + .content { 36 + display: none; 37 + flex-direction: column; 38 + align-items: center; 39 + text-align: center; 40 + padding: 2em; 41 + max-width: 80vw; 42 + } 43 + 44 + /* Only show centered track info in audio mode */ 45 + :host(.audio-mode) .content { 46 + display: flex; 47 + } 48 + 49 + .track-title { 50 + font-family: Pacifico, cursive; 51 + font-size: clamp(2em, 5vw, 4em); 52 + color: oklch(92% 0.03 85); 53 + line-height: 1.2; 54 + margin-bottom: 0.2em; 55 + } 56 + 57 + .track-artist { 58 + font-size: clamp(1em, 2.5vw, 1.6em); 59 + color: oklch(70% 0.02 85); 60 + } 61 + 62 + .overlay { 63 + position: fixed; 64 + bottom: 0; 65 + left: 0; 66 + right: 0; 67 + padding: 1.5em 3em; 68 + background: oklch(10% 0.02 105 / 0.8); 69 + backdrop-filter: blur(20px); 70 + display: flex; 71 + flex-direction: column; 72 + gap: 0.8em; 73 + transition: opacity 0.4s cubic-bezier(0.16, 1, 0.3, 1); 74 + } 75 + 76 + .overlay.hidden { 77 + opacity: 0; 78 + pointer-events: none; 79 + } 80 + 81 + .progress-row { 82 + display: flex; 83 + align-items: center; 84 + gap: 1em; 85 + color: oklch(70% 0.02 85); 86 + font-size: 0.85em; 87 + } 88 + 89 + .progress-bar { 90 + flex: 1; 91 + height: 4px; 92 + background: oklch(30% 0.02 105); 93 + border-radius: 2px; 94 + overflow: hidden; 95 + } 96 + 97 + .progress-fill { 98 + height: 100%; 99 + background: var(--color-primary); 100 + border-radius: 2px; 101 + transition: width 1s linear; 102 + } 103 + 104 + .controls { 105 + display: flex; 106 + justify-content: center; 107 + gap: 2em; 108 + } 109 + 110 + .control-btn { 111 + display: flex; 112 + align-items: center; 113 + justify-content: center; 114 + width: 3em; 115 + height: 3em; 116 + border-radius: 50%; 117 + background: none; 118 + border: none; 119 + color: oklch(85% 0.02 85); 120 + cursor: pointer; 121 + transition: background 0.18s cubic-bezier(0.16, 1, 0.3, 1); 122 + } 123 + 124 + .control-btn.focused { 125 + background: oklch(30% 0.04 105 / 0.6); 126 + outline: 2px solid var(--color-focus-ring); 127 + outline-offset: -2px; 128 + } 129 + 130 + .control-btn lucide-icon { 131 + font-size: 1.5em; 132 + } 133 + 134 + .control-btn.play lucide-icon { 135 + font-size: 2em; 136 + } 137 + `; 138 + 139 + constructor() { 140 + super(); 141 + this.title = ""; 142 + this.artist = ""; 143 + this.playbackState = "none"; 144 + this.duration = 0; 145 + this.position = 0; 146 + this.overlayVisible = true; 147 + } 148 + 149 + _formatTime(seconds) { 150 + if (!seconds || seconds <= 0) return "0:00"; 151 + const m = Math.floor(seconds / 60); 152 + const s = Math.floor(seconds % 60).toString().padStart(2, "0"); 153 + return `${m}:${s}`; 154 + } 155 + 156 + _progressPercent() { 157 + if (!this.duration || this.duration <= 0) return 0; 158 + return Math.min(100, (this.position / this.duration) * 100); 159 + } 160 + 161 + get hasMedia() { 162 + return this.playbackState === "playing" || this.playbackState === "paused"; 163 + } 164 + 165 + getDpadItems() { 166 + return Array.from(this.shadowRoot.querySelectorAll(".control-btn")); 167 + } 168 + 169 + doAction(action) { 170 + this.dispatchEvent( 171 + new CustomEvent("media-action", { 172 + bubbles: true, 173 + composed: true, 174 + detail: { action }, 175 + }), 176 + ); 177 + } 178 + 179 + back() { 180 + this.dispatchEvent( 181 + new CustomEvent("player-back", { bubbles: true, composed: true }), 182 + ); 183 + } 184 + 185 + render() { 186 + const isPlaying = this.playbackState === "playing"; 187 + const showOverlay = this.overlayVisible && this.hasMedia; 188 + 189 + return html` 190 + <div class="content"> 191 + <div class="track-title">${this.title || "Nothing playing"}</div> 192 + ${this.artist ? html`<div class="track-artist">${this.artist}</div>` : ""} 193 + </div> 194 + 195 + <div class="overlay ${showOverlay ? "" : "hidden"}"> 196 + <div class="progress-row"> 197 + <span>${this._formatTime(this.position)}</span> 198 + <div class="progress-bar"> 199 + <div class="progress-fill" style="width: ${this._progressPercent()}%"></div> 200 + </div> 201 + <span>${this._formatTime(this.duration)}</span> 202 + </div> 203 + <div class="controls"> 204 + <button class="control-btn" data-action="previoustrack"> 205 + <lucide-icon name="skip-back"></lucide-icon> 206 + </button> 207 + <button class="control-btn play" data-action="${isPlaying ? "pause" : "play"}"> 208 + <lucide-icon name="${isPlaying ? "pause" : "play"}"></lucide-icon> 209 + </button> 210 + <button class="control-btn" data-action="nexttrack"> 211 + <lucide-icon name="skip-forward"></lucide-icon> 212 + </button> 213 + </div> 214 + </div> 215 + `; 216 + } 217 + } 218 + 219 + customElements.define("now-playing", NowPlaying);
+157
ui/system/mediacenter/open_in_dialog.js
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 3 + import { 4 + LitElement, 5 + html, 6 + css, 7 + } from "beaver://shared/third_party/lit/lit-all.min.js"; 8 + 9 + class OpenInDialog extends LitElement { 10 + static properties = { 11 + open: { type: Boolean, reflect: true }, 12 + peerName: { type: String }, 13 + url: { type: String }, 14 + }; 15 + 16 + static styles = css` 17 + :host { 18 + display: none; 19 + position: fixed; 20 + inset: 0; 21 + z-index: 95; 22 + font-family: var(--font-family-base); 23 + } 24 + 25 + :host([open]) { 26 + display: flex; 27 + align-items: center; 28 + justify-content: center; 29 + } 30 + 31 + .backdrop { 32 + position: absolute; 33 + inset: 0; 34 + background: oklch(8% 0.01 105 / 0.85); 35 + backdrop-filter: blur(20px); 36 + } 37 + 38 + .panel { 39 + position: relative; 40 + display: flex; 41 + flex-direction: column; 42 + min-width: 360px; 43 + max-width: 500px; 44 + gap: 0.3em; 45 + } 46 + 47 + .panel-title { 48 + font-family: Pacifico, cursive; 49 + font-size: 1.6em; 50 + color: oklch(85% 0.03 85); 51 + text-align: center; 52 + margin-bottom: 0.5em; 53 + } 54 + 55 + .panel-url { 56 + text-align: center; 57 + color: oklch(60% 0.02 85); 58 + font-size: 0.85em; 59 + margin-bottom: 0.8em; 60 + word-break: break-all; 61 + } 62 + 63 + .choice-item { 64 + display: flex; 65 + align-items: center; 66 + gap: 1em; 67 + padding: 1em 1.5em; 68 + border-radius: var(--radius-md); 69 + color: oklch(80% 0.02 85); 70 + cursor: pointer; 71 + transition: background 0.18s cubic-bezier(0.16, 1, 0.3, 1); 72 + } 73 + 74 + .choice-item.focused { 75 + background: oklch(25% 0.04 105 / 0.6); 76 + color: oklch(95% 0.02 85); 77 + outline: 2px solid var(--color-focus-ring); 78 + outline-offset: -2px; 79 + } 80 + 81 + .choice-item lucide-icon { 82 + font-size: 1.3em; 83 + color: var(--color-primary); 84 + } 85 + 86 + .choice-label { 87 + font-size: 1.1em; 88 + font-weight: var(--font-weight-bold); 89 + } 90 + 91 + .choice-description { 92 + font-size: 0.8em; 93 + color: oklch(55% 0.02 85); 94 + margin-top: 0.15em; 95 + } 96 + 97 + .choice-item.focused .choice-description { 98 + color: oklch(70% 0.02 85); 99 + } 100 + `; 101 + 102 + constructor() { 103 + super(); 104 + this.open = false; 105 + this.peerName = ""; 106 + this.url = ""; 107 + } 108 + 109 + getDpadItems() { 110 + return Array.from(this.shadowRoot.querySelectorAll(".choice-item")); 111 + } 112 + 113 + selectItem(item) { 114 + const action = item.dataset.action; 115 + this.dispatchEvent( 116 + new CustomEvent("openin-select", { 117 + bubbles: true, 118 + composed: true, 119 + detail: { action, url: this.url }, 120 + }), 121 + ); 122 + } 123 + 124 + dismiss() { 125 + this.dispatchEvent( 126 + new CustomEvent("openin-dismiss", { bubbles: true, composed: true }), 127 + ); 128 + } 129 + 130 + render() { 131 + if (!this.open) return html``; 132 + 133 + return html` 134 + <div class="backdrop"></div> 135 + <div class="panel"> 136 + <div class="panel-title">Open from ${this.peerName}</div> 137 + <div class="panel-url">${this.url}</div> 138 + <div class="choice-item" data-action="video"> 139 + <lucide-icon name="film"></lucide-icon> 140 + <div> 141 + <div class="choice-label">Open as Video</div> 142 + <div class="choice-description">Play in the media player</div> 143 + </div> 144 + </div> 145 + <div class="choice-item" data-action="page"> 146 + <lucide-icon name="globe"></lucide-icon> 147 + <div> 148 + <div class="choice-label">Open as Web Page</div> 149 + <div class="choice-description">Browse in a webview</div> 150 + </div> 151 + </div> 152 + </div> 153 + `; 154 + } 155 + } 156 + 157 + customElements.define("open-in-dialog", OpenInDialog);
+394
ui/system/mediacenter/player.html
··· 1 + <!DOCTYPE html> 2 + <!-- SPDX-License-Identifier: AGPL-3.0-or-later --> 3 + <!-- 4 + Beaver Media Center — Video Player 5 + beaver://system/mediacenter/player.html?title=...&src=... 6 + --> 7 + <html> 8 + <head> 9 + <meta charset="UTF-8" /> 10 + <title>Video Player</title> 11 + <link rel="stylesheet" href="beaver://shared/fonts/fonts.css" /> 12 + <link rel="stylesheet" href="beaver://shared/third_party/lucide/lucide.css" /> 13 + <script type="module" src="beaver://shared/lucide_icon.js"></script> 14 + <style> 15 + * { margin: 0; padding: 0; box-sizing: border-box; } 16 + 17 + body { 18 + width: 100%; 19 + height: 100vh; 20 + background: #000; 21 + font-family: ReadexPro, system-ui, sans-serif; 22 + overflow: hidden; 23 + cursor: none; 24 + } 25 + 26 + body.controls-visible { cursor: default; } 27 + 28 + video { 29 + position: fixed; 30 + inset: 0; 31 + width: 100%; 32 + height: 100%; 33 + object-fit: contain; 34 + } 35 + 36 + /* --- Overlay --- */ 37 + 38 + .overlay { 39 + position: fixed; 40 + inset: 0; 41 + display: flex; 42 + flex-direction: column; 43 + justify-content: space-between; 44 + opacity: 0; 45 + transition: opacity 0.5s cubic-bezier(0.16, 1, 0.3, 1); 46 + z-index: 10; 47 + } 48 + 49 + .overlay.visible { opacity: 1; } 50 + 51 + .top-bar { 52 + padding: 2em 3em; 53 + background: linear-gradient(to bottom, rgba(0,0,0,0.8) 0%, transparent 100%); 54 + } 55 + 56 + .title { 57 + font-family: Pacifico, cursive; 58 + font-size: clamp(1.4em, 3vw, 2.2em); 59 + color: #fff; 60 + text-shadow: 0 1px 8px rgba(0,0,0,0.5); 61 + } 62 + 63 + .bottom-bar { 64 + padding: 0 3em 2.5em; 65 + padding-top: 5em; 66 + background: linear-gradient(to top, rgba(0,0,0,0.85) 0%, transparent 100%); 67 + display: flex; 68 + flex-direction: column; 69 + gap: 1em; 70 + } 71 + 72 + /* Progress */ 73 + .progress-container { 74 + display: flex; 75 + align-items: center; 76 + gap: 1em; 77 + } 78 + 79 + .time { 80 + font-size: 0.9em; 81 + color: rgba(255,255,255,0.7); 82 + min-width: 4em; 83 + font-variant-numeric: tabular-nums; 84 + } 85 + 86 + .time-end { text-align: right; } 87 + 88 + .progress-track { 89 + flex: 1; 90 + height: 5px; 91 + background: rgba(255,255,255,0.2); 92 + border-radius: 3px; 93 + position: relative; 94 + overflow: hidden; 95 + } 96 + 97 + .progress-buffered { 98 + position: absolute; 99 + top: 0; left: 0; 100 + height: 100%; 101 + background: rgba(255,255,255,0.12); 102 + border-radius: 3px; 103 + } 104 + 105 + .progress-fill { 106 + position: relative; 107 + height: 100%; 108 + background: #6ab47a; 109 + border-radius: 3px; 110 + width: 0%; 111 + transition: width 0.25s linear; 112 + } 113 + 114 + /* Transport */ 115 + .transport { 116 + display: flex; 117 + align-items: center; 118 + justify-content: center; 119 + gap: 1.5em; 120 + } 121 + 122 + .btn { 123 + display: flex; 124 + align-items: center; 125 + justify-content: center; 126 + background: none; 127 + border: none; 128 + color: #fff; 129 + cursor: pointer; 130 + border-radius: 50%; 131 + transition: 132 + background 0.2s cubic-bezier(0.16, 1, 0.3, 1), 133 + transform 0.15s cubic-bezier(0.16, 1, 0.3, 1); 134 + } 135 + 136 + .btn:active { transform: scale(0.9); } 137 + 138 + .btn-seek { 139 + width: 3em; 140 + height: 3em; 141 + } 142 + 143 + .btn-seek lucide-icon { font-size: 1.5em; } 144 + 145 + .btn-play { 146 + width: 4.5em; 147 + height: 4.5em; 148 + background: rgba(255,255,255,0.12); 149 + backdrop-filter: blur(8px); 150 + } 151 + 152 + .btn-play lucide-icon { font-size: 2em; } 153 + 154 + .btn.focused { 155 + background: rgba(255,255,255,0.2); 156 + outline: 2px solid #6ab47a; 157 + outline-offset: 3px; 158 + } 159 + 160 + .btn-play.focused { 161 + background: rgba(255,255,255,0.25); 162 + } 163 + 164 + /* Center flash indicator */ 165 + .center-indicator { 166 + position: fixed; 167 + top: 50%; 168 + left: 50%; 169 + transform: translate(-50%, -50%) scale(0.7); 170 + width: 5em; 171 + height: 5em; 172 + border-radius: 50%; 173 + background: rgba(0,0,0,0.5); 174 + backdrop-filter: blur(8px); 175 + display: flex; 176 + align-items: center; 177 + justify-content: center; 178 + opacity: 0; 179 + transition: opacity 0.25s, transform 0.25s cubic-bezier(0.16, 1, 0.3, 1); 180 + pointer-events: none; 181 + z-index: 20; 182 + color: #fff; 183 + } 184 + 185 + .center-indicator.flash { 186 + opacity: 1; 187 + transform: translate(-50%, -50%) scale(1); 188 + } 189 + 190 + .center-indicator lucide-icon { font-size: 2.5em; } 191 + </style> 192 + </head> 193 + <body> 194 + <video width="400" height="300" id="video"></video> 195 + 196 + <div class="center-indicator" id="indicator"> 197 + <lucide-icon name="play" id="indicator-icon"></lucide-icon> 198 + </div> 199 + 200 + <div class="overlay visible" id="overlay"> 201 + <div class="top-bar"> 202 + <div class="title" id="title"></div> 203 + </div> 204 + <div class="bottom-bar"> 205 + <div class="progress-container"> 206 + <span class="time" id="time-current">0:00</span> 207 + <div class="progress-track"> 208 + <div class="progress-buffered" id="progress-buffered"></div> 209 + <div class="progress-fill" id="progress-fill"></div> 210 + </div> 211 + <span class="time time-end" id="time-duration">0:00</span> 212 + </div> 213 + <div class="transport"> 214 + <button class="btn btn-seek" data-action="back"> 215 + <lucide-icon name="rewind"></lucide-icon> 216 + </button> 217 + <button class="btn btn-play" data-action="play" id="btn-play"> 218 + <lucide-icon name="play" id="play-icon"></lucide-icon> 219 + </button> 220 + <button class="btn btn-seek" data-action="forward"> 221 + <lucide-icon name="fast-forward"></lucide-icon> 222 + </button> 223 + </div> 224 + </div> 225 + </div> 226 + 227 + <script> 228 + var video = document.getElementById("video"); 229 + var overlay = document.getElementById("overlay"); 230 + var indicator = document.getElementById("indicator"); 231 + var indicatorIcon = document.getElementById("indicator-icon"); 232 + var titleEl = document.getElementById("title"); 233 + var timeCurrent = document.getElementById("time-current"); 234 + var timeDuration = document.getElementById("time-duration"); 235 + var progressFill = document.getElementById("progress-fill"); 236 + var progressBuffered = document.getElementById("progress-buffered"); 237 + var playIcon = document.getElementById("play-icon"); 238 + var buttons = Array.from(document.querySelectorAll(".btn")); 239 + 240 + // --- Init --- 241 + var params = new URLSearchParams(window.location.search); 242 + var videoSrc = params.get("src"); 243 + var videoTitle = params.get("title") || "Video"; 244 + titleEl.textContent = videoTitle; 245 + document.title = videoTitle; 246 + 247 + // --- Media Session API --- 248 + // Report playback state so the media center can show "Now Playing" 249 + function updateMediaSession() { 250 + if (navigator.mediaSession) { 251 + navigator.mediaSession.metadata = new MediaMetadata({ 252 + title: videoTitle, 253 + }); 254 + navigator.mediaSession.playbackState = video.paused ? "paused" : "playing"; 255 + } 256 + } 257 + 258 + // --- Helpers --- 259 + function formatTime(s) { 260 + if (!s || isNaN(s)) return "0:00"; 261 + var h = Math.floor(s / 3600); 262 + var m = Math.floor((s % 3600) / 60); 263 + var sec = Math.floor(s % 60).toString().padStart(2, "0"); 264 + return h > 0 ? h + ":" + m.toString().padStart(2, "0") + ":" + sec : m + ":" + sec; 265 + } 266 + 267 + var SEEK = 10; 268 + 269 + function doAction(action) { 270 + if (action === "play") { 271 + if (!video.src && videoSrc) { 272 + video.src = videoSrc; 273 + } 274 + if (video.paused) { 275 + video.play(); 276 + flashIndicator("play"); 277 + } else { 278 + video.pause(); 279 + flashIndicator("pause"); 280 + } 281 + } else if (action === "back") { 282 + video.currentTime = Math.max(0, video.currentTime - SEEK); 283 + flashIndicator("rewind"); 284 + } else if (action === "forward") { 285 + video.currentTime = Math.min(video.duration || 0, video.currentTime + SEEK); 286 + flashIndicator("fast-forward"); 287 + } 288 + } 289 + 290 + // --- Center indicator --- 291 + var indicatorTimeout = null; 292 + function flashIndicator(icon) { 293 + indicatorIcon.setAttribute("name", icon); 294 + indicator.classList.add("flash"); 295 + clearTimeout(indicatorTimeout); 296 + indicatorTimeout = setTimeout(function() { 297 + indicator.classList.remove("flash"); 298 + }, 600); 299 + } 300 + 301 + // --- Progress --- 302 + video.addEventListener("timeupdate", function() { 303 + if (!video.duration) return; 304 + progressFill.style.width = (video.currentTime / video.duration * 100) + "%"; 305 + timeCurrent.textContent = formatTime(video.currentTime); 306 + }); 307 + 308 + video.addEventListener("loadedmetadata", function() { 309 + timeDuration.textContent = formatTime(video.duration); 310 + }); 311 + 312 + video.addEventListener("progress", function() { 313 + if (!video.duration || !video.buffered.length) return; 314 + var end = video.buffered.end(video.buffered.length - 1); 315 + progressBuffered.style.width = (end / video.duration * 100) + "%"; 316 + }); 317 + 318 + video.addEventListener("play", function() { 319 + playIcon.setAttribute("name", "pause"); 320 + updateMediaSession(); 321 + }); 322 + 323 + video.addEventListener("pause", function() { 324 + playIcon.setAttribute("name", "play"); 325 + updateMediaSession(); 326 + showOverlay(); 327 + }); 328 + 329 + // --- Overlay --- 330 + var overlayTimer = null; 331 + var OVERLAY_TIMEOUT = 3500; 332 + 333 + function showOverlay() { 334 + overlay.classList.add("visible"); 335 + document.body.classList.add("controls-visible"); 336 + clearTimeout(overlayTimer); 337 + if (!video.paused && video.readyState >= 3) { 338 + overlayTimer = setTimeout(hideOverlay, OVERLAY_TIMEOUT); 339 + } 340 + } 341 + 342 + function hideOverlay() { 343 + overlay.classList.remove("visible"); 344 + document.body.classList.remove("controls-visible"); 345 + } 346 + 347 + function isVisible() { return overlay.classList.contains("visible"); } 348 + 349 + // --- D-pad --- 350 + var focusIndex = 1; 351 + function applyFocus() { 352 + buttons.forEach(function(b, i) { b.classList.toggle("focused", i === focusIndex); }); 353 + } 354 + applyFocus(); 355 + 356 + document.addEventListener("keydown", function(e) { 357 + if (!isVisible()) { 358 + showOverlay(); 359 + if (e.key !== "Enter" && e.key !== " ") { 360 + e.preventDefault(); 361 + return; 362 + } 363 + } else { 364 + showOverlay(); 365 + } 366 + 367 + if (e.key === "ArrowLeft") { 368 + e.preventDefault(); 369 + if (focusIndex > 0) { focusIndex--; applyFocus(); } 370 + else { doAction("back"); } 371 + } else if (e.key === "ArrowRight") { 372 + e.preventDefault(); 373 + if (focusIndex < buttons.length - 1) { focusIndex++; applyFocus(); } 374 + else { doAction("forward"); } 375 + } else if (e.key === "Enter" || e.key === " ") { 376 + e.preventDefault(); 377 + doAction(buttons[focusIndex].dataset.action); 378 + } 379 + }); 380 + 381 + video.addEventListener("playing", showOverlay); 382 + 383 + // --- Mouse --- 384 + overlay.addEventListener("click", function(e) { 385 + var btn = e.target.closest(".btn"); 386 + if (btn) { doAction(btn.dataset.action); } 387 + else { doAction("play"); } 388 + showOverlay(); 389 + }); 390 + 391 + document.addEventListener("mousemove", showOverlay); 392 + </script> 393 + </body> 394 + </html>
+50
ui/system/mediacenter/sources.json
··· 1 + [ 2 + { 3 + "category": "music", 4 + "title": "SomaFM Drone Zone", 5 + "url": "https://somafm.com/player/#/now-playing/dronezone", 6 + "icon": "radio" 7 + }, 8 + { 9 + "category": "music", 10 + "title": "SomaFM Groove Salad", 11 + "url": "https://somafm.com/player/#/now-playing/groovesalad", 12 + "icon": "radio" 13 + }, 14 + { 15 + "category": "music", 16 + "title": "Lofi Girl", 17 + "url": "https://www.youtube.com/watch?v=jfKfPfyJRdk", 18 + "icon": "music" 19 + }, 20 + { 21 + "category": "videos", 22 + "title": "Big Buck Bunny", 23 + "url": "beaver://system/mediacenter/player.html?title=Bunny&src=http%3A%2F%2Flocalhost%3A8888%2Fbig_buck_bunny_720p_stereo.ogg", 24 + "icon": "film" 25 + }, 26 + { 27 + "category": "videos", 28 + "title": "YouTube", 29 + "url": "https://youtube.com", 30 + "icon": "play-circle" 31 + }, 32 + { 33 + "category": "videos", 34 + "title": "PeerTube", 35 + "url": "https://joinpeertube.org", 36 + "icon": "play-circle" 37 + }, 38 + { 39 + "category": "web", 40 + "title": "Wikipedia", 41 + "url": "https://en.wikipedia.org", 42 + "icon": "book-open" 43 + }, 44 + { 45 + "category": "web", 46 + "title": "Bandcamp", 47 + "url": "https://bandcamp.com", 48 + "icon": "disc-3" 49 + } 50 + ]
+157
ui/system/mediacenter/system_menu.js
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 3 + import { 4 + LitElement, 5 + html, 6 + css, 7 + } from "beaver://shared/third_party/lit/lit-all.min.js"; 8 + 9 + class SystemMenu extends LitElement { 10 + static properties = { 11 + open: { type: Boolean, reflect: true }, 12 + }; 13 + 14 + static styles = css` 15 + :host { 16 + display: none; 17 + position: fixed; 18 + inset: 0; 19 + z-index: 100; 20 + font-family: var(--font-family-base); 21 + } 22 + 23 + :host([open]) { 24 + display: flex; 25 + align-items: center; 26 + justify-content: center; 27 + } 28 + 29 + .backdrop { 30 + position: absolute; 31 + inset: 0; 32 + background: oklch(8% 0.01 105 / 0.85); 33 + backdrop-filter: blur(20px); 34 + } 35 + 36 + .menu { 37 + position: relative; 38 + display: flex; 39 + flex-direction: column; 40 + gap: 0.3em; 41 + min-width: 280px; 42 + } 43 + 44 + .menu-title { 45 + font-family: Pacifico, cursive; 46 + font-size: 1.6em; 47 + color: oklch(85% 0.03 85); 48 + text-align: center; 49 + margin-bottom: 0.8em; 50 + } 51 + 52 + .menu-item { 53 + display: flex; 54 + align-items: center; 55 + gap: 1em; 56 + padding: 1em 1.5em; 57 + border-radius: var(--radius-md); 58 + color: oklch(80% 0.02 85); 59 + cursor: pointer; 60 + transition: background 0.18s cubic-bezier(0.16, 1, 0.3, 1); 61 + } 62 + 63 + .menu-item.focused { 64 + background: oklch(25% 0.04 105 / 0.6); 65 + color: oklch(95% 0.02 85); 66 + outline: 2px solid var(--color-focus-ring); 67 + outline-offset: -2px; 68 + } 69 + 70 + .menu-item lucide-icon { 71 + font-size: 1.3em; 72 + } 73 + 74 + .menu-item .label { 75 + font-size: 1.1em; 76 + font-weight: var(--font-weight-bold); 77 + } 78 + 79 + .menu-item .description { 80 + font-size: 0.8em; 81 + color: oklch(55% 0.02 85); 82 + margin-top: 0.15em; 83 + } 84 + 85 + .menu-item.focused .description { 86 + color: oklch(70% 0.02 85); 87 + } 88 + 89 + .menu-item.danger { 90 + color: oklch(70% 0.08 25); 91 + } 92 + 93 + .menu-item.danger.focused { 94 + color: oklch(90% 0.08 25); 95 + outline-color: oklch(60% 0.15 25 / 0.5); 96 + } 97 + `; 98 + 99 + constructor() { 100 + super(); 101 + this.open = false; 102 + } 103 + 104 + get menuItems() { 105 + return [ 106 + { id: "settings", label: "Settings", description: "Preferences and configuration", icon: "settings" }, 107 + { id: "quit", label: "Quit", description: "Exit Beaver", icon: "power", danger: true }, 108 + ]; 109 + } 110 + 111 + getDpadItems() { 112 + return Array.from(this.shadowRoot.querySelectorAll(".menu-item")); 113 + } 114 + 115 + selectItem(item, index) { 116 + const data = this.menuItems[index]; 117 + if (data) { 118 + this.dispatchEvent( 119 + new CustomEvent("menu-action", { 120 + bubbles: true, 121 + composed: true, 122 + detail: { action: data.id }, 123 + }), 124 + ); 125 + } 126 + } 127 + 128 + dismiss() { 129 + this.dispatchEvent( 130 + new CustomEvent("menu-dismiss", { bubbles: true, composed: true }), 131 + ); 132 + } 133 + 134 + render() { 135 + if (!this.open) return html``; 136 + 137 + return html` 138 + <div class="backdrop"></div> 139 + <div class="menu"> 140 + <div class="menu-title">Beaver</div> 141 + ${this.menuItems.map( 142 + (item) => html` 143 + <div class="menu-item ${item.danger ? "danger" : ""}" data-id="${item.id}"> 144 + <lucide-icon name="${item.icon}"></lucide-icon> 145 + <div> 146 + <div class="label">${item.label}</div> 147 + <div class="description">${item.description}</div> 148 + </div> 149 + </div> 150 + `, 151 + )} 152 + </div> 153 + `; 154 + } 155 + } 156 + 157 + customElements.define("system-menu-mc", SystemMenu);
+21
ui/system/web_view.css
··· 287 287 border-radius: 0; 288 288 } 289 289 290 + /* ============================================================================ 291 + Media center styles 292 + ============================================================================ */ 293 + 294 + :host(.mediacenter-mode) .wrapper { 295 + border-radius: 0; 296 + box-shadow: none; 297 + } 298 + 299 + :host(.mediacenter-mode) .wrapper.active { 300 + box-shadow: none; 301 + } 302 + 303 + :host(.mediacenter-mode) .bar { 304 + display: none !important; 305 + } 306 + 307 + :host(.mediacenter-mode) .iframe-container { 308 + border-radius: 0; 309 + } 310 + 290 311 /* Inline task provider overlay */ 291 312 :host .inline-provider-overlay { 292 313 position: absolute;
+45 -2
ui/system/web_view.js
··· 193 193 console.log("[WebView] Load status changed:", event.detail); 194 194 this.loadStatus = event.detail; 195 195 196 - // Auto-reset to idle after complete animation finishes 196 + // Apply pending spatial navigation after load completes 197 197 if (event.detail === "complete") { 198 + this._applySpatialNavigation(); 199 + 200 + // Auto-reset to idle after complete animation finishes 198 201 setTimeout(() => { 199 202 this.loadStatus = "idle"; 200 203 }, 500); ··· 1020 1023 ); 1021 1024 } 1022 1025 1026 + // Programmatically focus this webview's iframe. 1027 + focusWebview() { 1028 + this.ensureIframe(); 1029 + if (this.iframe) { 1030 + try { 1031 + this.iframe.forceFocus(); 1032 + } catch (e) { 1033 + console.warn("[WebView] forceFocus not available", e); 1034 + } 1035 + } 1036 + } 1037 + 1038 + // Enable or disable spatial navigation (arrow key focus movement) in this webview. 1039 + // Waits for the page to load before sending the message, since the child 1040 + // document doesn't exist until the pipeline is created. 1041 + enableSpatialNavigation(enabled = true) { 1042 + this._pendingSpatialNavigation = enabled; 1043 + // Don't apply immediately — wait for onloadstatuschange("complete") 1044 + // which means the child document exists and can receive the message. 1045 + // loadStatus "idle" means the page hasn't started loading yet. 1046 + if (this.loadStatus === "complete") { 1047 + this._applySpatialNavigation(); 1048 + } 1049 + } 1050 + 1051 + _applySpatialNavigation() { 1052 + if (this._pendingSpatialNavigation === undefined) return; 1053 + this.ensureIframe(); 1054 + if (this.iframe) { 1055 + try { 1056 + this.iframe.useSpatialNavigation(this._pendingSpatialNavigation); 1057 + } catch (e) { 1058 + console.warn("[WebView] useSpatialNavigation not available", e); 1059 + } 1060 + } 1061 + // Clear — the platform stores this on the browsing context 1062 + // and propagates to new documents automatically. 1063 + this._pendingSpatialNavigation = undefined; 1064 + } 1065 + 1023 1066 doReload() { 1024 1067 this.ensureIframe(); 1025 1068 this.iframe.reload(); ··· 1129 1172 this.attrs = {}; 1130 1173 1131 1174 return html` 1132 - <link rel="stylesheet" href="web_view.css" /> 1175 + <link rel="stylesheet" href="/web_view.css" /> 1133 1176 <div 1134 1177 class="wrapper ${this.active ? "active" : ""}" 1135 1178 @click=${this.onfocus}