Rockbox open source high quality audio player as a Music Player Daemon
mpris rockbox mpd libadwaita audio rust zig deno
2
fork

Configure Feed

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

gpui: setup startup gate

+377 -9
+6 -9
gpui/src/app.rs
··· 1 - use crate::controller::Controller; 2 - use crate::state::AppState; 3 1 use crate::ui::global_keybinds::{Hide, HideOthers, Next, PlayPause, Prev, Quit, Repeat, Shuffle}; 4 2 use crate::ui::global_keybinds::{Library, Player, Queue}; 5 - use crate::ui::{assets::Assets, rockbox::Rockbox}; 3 + use crate::ui::startup_gate::StartupGate; 4 + use crate::ui::{assets::Assets, theme::Theme}; 6 5 use gpui::{ 7 6 px, size, AppContext, Application, Bounds, Menu, MenuItem, SystemMenuType, TitlebarOptions, 8 7 WindowBounds, WindowOptions, ··· 24 23 .run(move |cx| { 25 24 let bounds = Bounds::centered(None, size(px(1280.0), px(760.0)), cx); 26 25 assets.load_fonts(cx).expect("failed to load fonts"); 26 + // Theme is set as a global inside StartupGate / Rockbox::new. 27 + // Pre-set it here so the error screen can read it before Rockbox initialises. 28 + cx.set_global(Theme::default()); 27 29 28 30 cx.open_window( 29 31 WindowOptions { ··· 41 43 }), 42 44 ..Default::default() 43 45 }, 44 - |_window, cx| { 45 - let state = cx.new(|_| AppState::new()); 46 - let controller = Controller::new(state, cx); 47 - cx.set_global(controller); 48 - cx.new(Rockbox::new) 49 - }, 46 + |_window, cx| cx.new(StartupGate::new), 50 47 ) 51 48 .expect("failed to open window"); 52 49
+1
gpui/src/main.rs
··· 3 3 pub mod client; 4 4 pub mod controller; 5 5 pub mod http_client; 6 + pub mod startup; 6 7 pub mod state; 7 8 pub mod ui; 8 9
+48
gpui/src/startup.rs
··· 1 + use std::net::TcpStream; 2 + use std::time::Duration; 3 + 4 + const GRPC_ADDR: &str = "127.0.0.1:6061"; 5 + const CONNECT_TIMEOUT: Duration = Duration::from_millis(500); 6 + 7 + #[derive(Clone, Copy, Debug)] 8 + pub enum StartupError { 9 + /// `rockboxd` binary not found anywhere in PATH or common locations. 10 + NotInstalled, 11 + /// Binary found but daemon is not listening on the gRPC port. 12 + NotRunning, 13 + } 14 + 15 + /// Run all pre-flight checks. Returns `None` when everything is ready. 16 + pub fn check() -> Option<StartupError> { 17 + if !is_installed() { 18 + return Some(StartupError::NotInstalled); 19 + } 20 + if !is_running() { 21 + return Some(StartupError::NotRunning); 22 + } 23 + None 24 + } 25 + 26 + pub fn is_installed() -> bool { 27 + // Walk PATH explicitly — app bundles have a stripped environment. 28 + if let Ok(path_var) = std::env::var("PATH") { 29 + for dir in path_var.split(':') { 30 + if std::path::Path::new(dir).join("rockboxd").exists() { 31 + return true; 32 + } 33 + } 34 + } 35 + // Fallback: common macOS install locations regardless of PATH. 36 + [ 37 + "/usr/local/bin/rockboxd", 38 + "/opt/homebrew/bin/rockboxd", 39 + "/usr/bin/rockboxd", 40 + "/usr/local/sbin/rockboxd", 41 + ] 42 + .iter() 43 + .any(|p| std::path::Path::new(p).exists()) 44 + } 45 + 46 + pub fn is_running() -> bool { 47 + TcpStream::connect_timeout(&GRPC_ADDR.parse().unwrap(), CONNECT_TIMEOUT).is_ok() 48 + }
+1
gpui/src/ui/mod.rs
··· 4 4 pub mod global_keybinds; 5 5 pub mod helpers; 6 6 pub mod rockbox; 7 + pub mod startup_gate; 7 8 pub mod theme;
+321
gpui/src/ui/startup_gate.rs
··· 1 + use crate::controller::Controller; 2 + use crate::startup::StartupError; 3 + use crate::state::AppState; 4 + use crate::ui::rockbox::Rockbox; 5 + use crate::ui::theme::Theme; 6 + use gpui::prelude::FluentBuilder; 7 + use gpui::{ 8 + actions, div, px, App, AppContext, ClipboardItem, Context, Entity, FontWeight, 9 + InteractiveElement, IntoElement, ParentElement, Render, StatefulInteractiveElement, Styled, 10 + WeakEntity, Window, 11 + }; 12 + 13 + actions!(startup, [Retry, Quit]); 14 + 15 + /// Which copy button (if any) is currently showing "Copied!". 16 + #[derive(Clone, Copy, PartialEq, Eq)] 17 + enum CopiedState { 18 + None, 19 + Main, 20 + Start, 21 + } 22 + 23 + pub struct StartupGate { 24 + error: Option<StartupError>, 25 + /// Populated once the startup check passes (either initially or on retry). 26 + rockbox: Option<Entity<Rockbox>>, 27 + /// True while a background retry check is in flight. 28 + checking: bool, 29 + copied: CopiedState, 30 + } 31 + 32 + impl StartupGate { 33 + pub fn new(cx: &mut Context<Self>) -> Self { 34 + match crate::startup::check() { 35 + None => Self::boot(cx), 36 + Some(error) => StartupGate { 37 + error: Some(error), 38 + rockbox: None, 39 + checking: false, 40 + copied: CopiedState::None, 41 + }, 42 + } 43 + } 44 + 45 + fn boot(cx: &mut Context<Self>) -> Self { 46 + let state = cx.new(|_| AppState::new()); 47 + let controller = Controller::new(state, cx); 48 + cx.set_global(controller); 49 + StartupGate { 50 + error: None, 51 + rockbox: Some(cx.new(Rockbox::new)), 52 + checking: false, 53 + copied: CopiedState::None, 54 + } 55 + } 56 + 57 + fn retry(&mut self, cx: &mut Context<Self>) { 58 + if self.checking { 59 + return; 60 + } 61 + self.checking = true; 62 + cx.notify(); 63 + 64 + cx.spawn(async move |this: WeakEntity<StartupGate>, cx| { 65 + let result = cx 66 + .background_executor() 67 + .spawn(async { crate::startup::check() }) 68 + .await; 69 + 70 + let _ = this.update(cx, |this, cx| { 71 + this.checking = false; 72 + match result { 73 + None => { 74 + let state = cx.new(|_| AppState::new()); 75 + let controller = Controller::new(state, cx); 76 + cx.set_global(controller); 77 + this.rockbox = Some(cx.new(Rockbox::new)); 78 + this.error = None; 79 + } 80 + Some(e) => { 81 + this.error = Some(e); 82 + } 83 + } 84 + cx.notify(); 85 + }); 86 + }) 87 + .detach(); 88 + } 89 + 90 + fn copy_command(&mut self, text: &str, which: CopiedState, cx: &mut Context<Self>) { 91 + cx.write_to_clipboard(ClipboardItem::new_string(text.to_string())); 92 + self.copied = which; 93 + cx.notify(); 94 + cx.spawn(async move |this: WeakEntity<StartupGate>, cx| { 95 + cx.background_executor() 96 + .timer(std::time::Duration::from_millis(1500)) 97 + .await; 98 + let _ = this.update(cx, |this, cx| { 99 + // Only reset if this button is still the one showing "Copied!" 100 + if this.copied == which { 101 + this.copied = CopiedState::None; 102 + cx.notify(); 103 + } 104 + }); 105 + }) 106 + .detach(); 107 + } 108 + 109 + /// Renders a code block row: truncated monospace text + a per-button copy indicator. 110 + fn code_block( 111 + &self, 112 + id: &'static str, 113 + text: &'static str, 114 + which: CopiedState, 115 + theme: Theme, 116 + cx: &mut Context<Self>, 117 + ) -> impl IntoElement { 118 + let is_copied = self.copied == which; 119 + div() 120 + .w_full() 121 + .px(px(14.0)) 122 + .py(px(10.0)) 123 + .rounded_lg() 124 + .bg(theme.app_bg) 125 + .border_1() 126 + .border_color(theme.border) 127 + .flex() 128 + .items_center() 129 + .gap_x_3() 130 + // Text shrinks to make room for the button 131 + .child( 132 + div() 133 + .flex_1() 134 + .min_w_0() 135 + .truncate() 136 + .text_sm() 137 + .font_family("monospace") 138 + .text_color(gpui::rgb(0xFFFFFF)) 139 + .child(text), 140 + ) 141 + // Copy button — fixed width so it never gets squeezed 142 + .child( 143 + div() 144 + .id(id) 145 + .flex_shrink_0() 146 + .px(px(10.0)) 147 + .py(px(4.0)) 148 + .rounded_md() 149 + .text_xs() 150 + .font_weight(FontWeight(500.0)) 151 + .cursor_pointer() 152 + .text_color(if is_copied { 153 + gpui::rgb(0x6F00FF) 154 + } else { 155 + theme.library_header_text 156 + }) 157 + .bg(theme.titlebar_bg) 158 + .border_1() 159 + .border_color(theme.border) 160 + .hover(|this| this.border_color(gpui::rgb(0x6F00FF))) 161 + .on_click(cx.listener(move |this, _, _, cx| { 162 + this.copy_command(text, which, cx); 163 + })) 164 + .child(if is_copied { "Copied!" } else { "Copy" }), 165 + ) 166 + } 167 + } 168 + 169 + impl Render for StartupGate { 170 + fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { 171 + // Happy path — render the full app transparently. 172 + if let Some(rockbox) = &self.rockbox { 173 + return rockbox.clone().into_any_element(); 174 + } 175 + 176 + let theme = *cx.global::<Theme>(); 177 + let checking = self.checking; 178 + 179 + let (title, subtitle, code) = match self.error { 180 + Some(StartupError::NotInstalled) | None => ( 181 + "rockboxd is not installed", 182 + "Install the Rockbox CLI, then start the daemon:", 183 + "curl -fsSL https://raw.githubusercontent.com/tsirysndr/rockbox-zig/HEAD/install.sh | bash", 184 + ), 185 + Some(StartupError::NotRunning) => ( 186 + "rockboxd is not running", 187 + "Start the Rockbox daemon by running:", 188 + "rockboxd", 189 + ), 190 + }; 191 + 192 + let start_hint = matches!(self.error, Some(StartupError::NotInstalled)); 193 + 194 + let main_code_block = 195 + self.code_block("copy-main-cmd", code, CopiedState::Main, theme, cx); 196 + let start_code_block = 197 + self.code_block("copy-start-cmd", "rockboxd", CopiedState::Start, theme, cx); 198 + 199 + div() 200 + .size_full() 201 + .flex() 202 + .items_center() 203 + .justify_center() 204 + .bg(theme.app_bg) 205 + .child( 206 + div() 207 + .w(px(520.0)) 208 + .flex() 209 + .flex_col() 210 + .gap_y_5() 211 + .p(px(36.0)) 212 + .rounded_xl() 213 + .bg(theme.titlebar_bg) 214 + .border_1() 215 + .border_color(theme.border) 216 + // Warning header 217 + .child( 218 + div() 219 + .flex() 220 + .items_center() 221 + .gap_x_3() 222 + .child( 223 + div() 224 + .w(px(36.0)) 225 + .h(px(36.0)) 226 + .flex() 227 + .items_center() 228 + .justify_center() 229 + .rounded_full() 230 + .bg(gpui::rgba(0x6F00FF20)) 231 + .text_color(gpui::rgb(0x6F00FF)) 232 + .text_xl() 233 + .font_weight(FontWeight::BOLD) 234 + .child("⚠"), 235 + ) 236 + .child( 237 + div() 238 + .flex() 239 + .flex_col() 240 + .gap_y_0p5() 241 + .child( 242 + div() 243 + .text_base() 244 + .font_weight(FontWeight::BOLD) 245 + .text_color(theme.library_text) 246 + .child(title), 247 + ) 248 + .child( 249 + div() 250 + .text_sm() 251 + .text_color(theme.library_header_text) 252 + .child(subtitle), 253 + ), 254 + ), 255 + ) 256 + // Main command code block 257 + .child(main_code_block) 258 + // "Then start:" — NotInstalled only 259 + .when(start_hint, |this| { 260 + this.child( 261 + div() 262 + .flex() 263 + .flex_col() 264 + .gap_y_2() 265 + .child( 266 + div() 267 + .text_sm() 268 + .text_color(theme.library_header_text) 269 + .child("Then start the daemon:"), 270 + ) 271 + .child(start_code_block), 272 + ) 273 + }) 274 + // Action buttons 275 + .child( 276 + div() 277 + .flex() 278 + .justify_end() 279 + .gap_x_3() 280 + .child( 281 + div() 282 + .id("startup-quit") 283 + .px_5() 284 + .py_2() 285 + .rounded_lg() 286 + .text_sm() 287 + .font_weight(FontWeight(500.0)) 288 + .text_color(theme.library_header_text) 289 + .bg(theme.app_bg) 290 + .border_1() 291 + .border_color(theme.border) 292 + .cursor_pointer() 293 + .hover(|this| this.bg(theme.border)) 294 + .on_click(|_, _, cx: &mut App| cx.quit()) 295 + .child("Quit"), 296 + ) 297 + .child( 298 + div() 299 + .id("startup-retry") 300 + .px_5() 301 + .py_2() 302 + .rounded_lg() 303 + .text_sm() 304 + .font_weight(FontWeight(500.0)) 305 + .text_color(gpui::rgb(0x0F1117)) 306 + .bg(gpui::rgb(0x6F00FF)) 307 + .cursor_pointer() 308 + .hover(|this| this.bg(gpui::rgb(0x5A00D6))) 309 + .when(checking, |this| { 310 + this.opacity(0.6).cursor_default() 311 + }) 312 + .on_click(cx.listener(|this, _, _, cx| { 313 + this.retry(cx); 314 + })) 315 + .child(if checking { "Checking…" } else { "Retry" }), 316 + ), 317 + ), 318 + ) 319 + .into_any_element() 320 + } 321 + }