feat: improve slop UI

This commit is contained in:
Niklas Haiden
2026-09-09 22:19:22 +02:00
parent 7cda80d840
commit 32fdf8d2e3
3 changed files with 499 additions and 293 deletions
+4 -2
View File
@@ -10,13 +10,15 @@ The media mode is H.264 with selectable **720p/30**, **1080p/30** (default), or
./scripts/run.sh ./scripts/run.sh
``` ```
Select your Apple TV, enter the code if prompted, choose a screen/window, then click **Start sharing**. **Disconnect / cancel** or Escape stops the session. The initial pairing is saved for subsequent connections. Use **Connection** to select your Apple TV and enter its code if prompted. Once authorization succeeds, Spielab opens **Sharing**, where you choose a screen/window or extended display, adjust stream quality, and click **Start sharing**. Sharing controls remain unavailable before authorization. The prominent **Disconnect** / **Cancel** button in the top-right corner, or Escape, stops the session. The initial pairing is saved for subsequent connections.
**Settings → Appearance → Dark mode** switches between light and dark colors immediately. The preference is saved in `preferences.json` alongside the application's state and restored on the next launch. Settings remain accessible while connected or sharing.
In the desktop picker, **single-click exactly one source, then press Share**. Avoid double-clicking: KDE portal 6.7.4 can abort when a source card accepts the dialog from its own click handler. Spielab requests checkbox selection to avoid that path; the workaround still needs interactive verification on this host. In the desktop picker, **single-click exactly one source, then press Share**. Avoid double-clicking: KDE portal 6.7.4 can abort when a source card accepts the dialog from its own click handler. Spielab requests checkbox selection to avoid that path; the workaround still needs interactive verification on this host.
Choose quality before starting. The presets cap video bitrate at 8, 20, and 30 Mbps respectively. The 1080p presets preserve more detail; 60 fps needs more CPU and network capacity. Capture is scaled with the original aspect ratio preserved. Choose quality before starting. The presets cap video bitrate at 8, 20, and 30 Mbps respectively. The 1080p presets preserve more detail; 60 fps needs more CPU and network capacity. Capture is scaled with the original aspect ratio preserved.
GPU H.264 encoding is selected automatically through [FFmpeg's VA-API encoder](https://ffmpeg.org/ffmpeg-codecs.html#VAAPI-encoders) when available. Before sharing, Spielab tests actual encoded frames at the selected resolution and frame rate on accessible DRM render devices. If none pass, it uses software H.264. The streaming status shows **GPU H.264 · VA-API** or **Software H.264**. AMD Radeon 890M hardware encoding has been verified on this host; other VA-API drivers are selected only if their probe passes. NVENC and other hardware APIs are not implemented. GPU H.264 encoding is selected automatically through [FFmpeg's VA-API encoder](https://ffmpeg.org/ffmpeg-codecs.html#VAAPI-encoders) when available. Before sharing, Spielab tests actual encoded frames at the selected resolution and frame rate on accessible DRM render devices. If none pass, it uses software H.264. The command-line mirror example reports the selected encoder. AMD Radeon 890M hardware encoding has been verified on this host; other VA-API drivers are selected only if their probe passes. NVENC and other hardware APIs are not implemented.
The GPU path uses VBR at a target of 75% of the selected bitrate cap, no B-frames, and an async depth of one. Software uses the existing CRF presets. Encoding runs on the GPU; desktop capture, scaling and transfer to GPU memory still involve the CPU. A device failure after the startup probe stops sharing with an error; it does not switch codecs or encoders in a running session. The GPU path uses VBR at a target of 75% of the selected bitrate cap, no B-frames, and an async depth of one. Software uses the existing CRF presets. Encoding runs on the GPU; desktop capture, scaling and transfer to GPU memory still involve the CPU. A device failure after the startup probe stops sharing with an error; it does not switch codecs or encoders in a running session.
+1
View File
@@ -156,6 +156,7 @@ impl Worker {
self.emit(Event::Authenticated(name)); self.emit(Event::Authenticated(name));
} }
Command::ChooseScreen(mode) => { Command::ChooseScreen(mode) => {
anyhow::ensure!(self.authenticated, "Connect to Apple TV first");
self.emit(Event::Busy( self.emit(Event::Busy(
match mode { match mode {
CaptureMode::Mirror => "Single-click one screen or window, then press Share…", CaptureMode::Mirror => "Single-click one screen or window, then press Share…",
+494 -291
View File
@@ -9,8 +9,65 @@ use spielab::{
}; };
use std::path::PathBuf; use std::path::PathBuf;
#[derive(Clone, Copy, PartialEq, Eq)]
enum Page {
Connection,
Sharing,
Settings,
}
#[derive(Clone, Copy)]
struct Theme {
background: u32,
panel: u32,
text: u32,
muted: u32,
border: u32,
button: u32,
hover: u32,
accent: u32,
danger: u32,
}
impl Theme {
fn new(dark: bool) -> Self {
if dark {
Self {
background: 0x101817,
panel: 0x1a2522,
text: 0xedf5ef,
muted: 0xa1b5a9,
border: 0x34473e,
button: 0x283d32,
hover: 0x365340,
accent: 0x9ee4b1,
danger: 0xa73443,
}
} else {
Self {
background: 0xf3f6f3,
panel: 0xffffff,
text: 0x192c20,
muted: 0x526859,
border: 0xcdd9d0,
button: 0xe2eee5,
hover: 0xd1e5d6,
accent: 0x206838,
danger: 0xb3273a,
}
}
}
}
#[derive(serde::Serialize, serde::Deserialize)]
struct Preferences {
dark_mode: bool,
}
struct Spielab { struct Spielab {
backend: Backend, backend: Backend,
page: Page,
dark_mode: bool,
preferences_path: PathBuf,
devices: Vec<Device>, devices: Vec<Device>,
status: String, status: String,
receiver: Option<String>, receiver: Option<String>,
@@ -30,7 +87,7 @@ struct Spielab {
} }
impl Spielab { impl Spielab {
fn new(backend: Backend, cx: &mut Context<Self>) -> Self { fn new(backend: Backend, preferences_path: PathBuf, cx: &mut Context<Self>) -> Self {
let events = backend.events.clone(); let events = backend.events.clone();
cx.spawn(async move |view, cx| { cx.spawn(async move |view, cx| {
while let Ok(event) = events.recv().await { while let Ok(event) = events.recv().await {
@@ -46,9 +103,17 @@ impl Spielab {
} }
}) })
.detach(); .detach();
let dark_mode = std::fs::read(&preferences_path)
.ok()
.and_then(|bytes| serde_json::from_slice::<Preferences>(&bytes).ok())
.map(|preferences| preferences.dark_mode)
.unwrap_or(true);
let _ = backend.send(Command::Discover); let _ = backend.send(Command::Discover);
Self { Self {
backend, backend,
page: Page::Connection,
dark_mode,
preferences_path,
devices: vec![], devices: vec![],
status: "Looking for AirPlay receivers…".into(), status: "Looking for AirPlay receivers…".into(),
receiver: None, receiver: None,
@@ -103,6 +168,7 @@ impl Spielab {
self.status = format!("Authenticated with {name}"); self.status = format!("Authenticated with {name}");
self.receiver = Some(name); self.receiver = Some(name);
self.authenticated = true; self.authenticated = true;
self.page = Page::Sharing;
self.pin_required = false; self.pin_required = false;
self.pin.clear(); self.pin.clear();
} }
@@ -119,14 +185,12 @@ impl Spielab {
CaptureMode::Extend => "Extended display created. Move windows onto it, then start sharing. Arrange it in your desktop’s Display Settings.", CaptureMode::Extend => "Extended display created. Move windows onto it, then start sharing. Arrange it in your desktop’s Display Settings.",
}.into(); }.into();
} }
Event::Streaming(frames) => { Event::Streaming(_) => {
self.streaming = true; self.streaming = true;
self.busy = true; self.busy = true;
self.status = format!( self.status = format!(
"{} · {} · {} · System audio · {frames} frames", "Sharing to {} · System audio included",
self.capture_mode.label(), self.receiver.as_deref().unwrap_or("Apple TV")
self.quality.label(),
self.encoder,
); );
} }
Event::Disconnected => self.reset("Disconnected".into()), Event::Disconnected => self.reset("Disconnected".into()),
@@ -145,6 +209,9 @@ impl Spielab {
self.pin_required = false; self.pin_required = false;
self.pin.clear(); self.pin.clear();
self.screen = None; self.screen = None;
if self.page == Page::Sharing {
self.page = Page::Connection;
}
} }
fn send(&mut self, command: Command, cx: &mut Context<Self>) { fn send(&mut self, command: Command, cx: &mut Context<Self>) {
@@ -173,7 +240,7 @@ impl Spielab {
if key == "escape" { if key == "escape" {
self.backend.disconnect(); self.backend.disconnect();
} }
if !self.pin_required || self.busy { if !self.pin_required || self.busy || self.page != Page::Connection {
return; return;
} }
match key { match key {
@@ -194,6 +261,7 @@ impl Spielab {
} }
fn button( fn button(
theme: Theme,
id: impl Into<gpui::ElementId>, id: impl Into<gpui::ElementId>,
label: impl Into<SharedString>, label: impl Into<SharedString>,
enabled: bool, enabled: bool,
@@ -201,336 +269,470 @@ fn button(
div() div()
.id(id) .id(id)
.px_4() .px_4()
.py_2() .py_3()
.rounded_lg() .rounded_lg()
.bg(rgb(if enabled { 0x29433c } else { 0x202a2a })) .border_1()
.text_color(rgb(if enabled { 0xc6f7d9 } else { 0x75807b })) .border_color(rgb(theme.border))
.bg(rgb(theme.button))
.text_color(rgb(if enabled { theme.text } else { theme.muted }))
.when(enabled, |d| { .when(enabled, |d| {
d.cursor_pointer().hover(|s| s.bg(rgb(0x35584b))) d.cursor_pointer().hover(move |s| s.bg(rgb(theme.hover)))
}) })
.when(!enabled, |d| d.opacity(0.55))
.child(label.into()) .child(label.into())
} }
fn card(theme: Theme, title: &str, description: &str) -> gpui::Div {
div()
.flex()
.flex_col()
.gap_4()
.p_6()
.rounded_xl()
.border_1()
.border_color(rgb(theme.border))
.bg(rgb(theme.panel))
.child(div().text_xl().child(title.to_owned()))
.child(
div()
.text_sm()
.text_color(rgb(theme.muted))
.child(description.to_owned()),
)
}
impl Spielab {
fn connection_page(&self, theme: Theme, cx: &mut Context<Self>) -> gpui::Div {
let idle = !self.busy;
if self.authenticated {
return card(
theme,
"You're connected",
self.receiver.as_deref().unwrap_or("Apple TV"),
)
.child(
button(theme, "continue-sharing", "Continue to sharing →", true).on_click(
cx.listener(|view, _, _, cx| {
view.page = Page::Sharing;
cx.notify();
}),
),
);
}
if self.pin_required {
let mut pairing = card(
theme,
"Authorize Apple TV",
"Enter the four-digit code shown on your TV. You can use your keyboard.",
)
.child(div().text_3xl().child(format!(
"{}{}",
"● ".repeat(self.pin.len()),
"○ ".repeat(4 - self.pin.len())
)));
for (index, row) in [
["1", "2", "3"],
["4", "5", "6"],
["7", "8", "9"],
["Clear", "0", "⌫"],
]
.into_iter()
.enumerate()
{
pairing = pairing.child(div().flex().gap_2().children(
row.into_iter().enumerate().map(|(column, digit)| {
button(theme, ("pin", index * 3 + column), digit, idle)
.w(px(80.))
.text_center()
.on_click(cx.listener(move |view, _, _, cx| {
if view.busy {
return;
}
match digit {
"Clear" => view.pin.clear(),
"⌫" => {
view.pin.pop();
}
value => view.digit(value, cx),
}
cx.notify();
}))
}),
));
}
return pairing.child(
button(
theme,
"authorize",
"Authorize and continue →",
idle && self.pin.len() == 4,
)
.on_click(cx.listener(|view, _, _, cx| {
if !view.busy && view.pin.len() == 4 {
let pin = std::mem::take(&mut view.pin);
view.send(Command::Pair(pin), cx);
}
})),
);
}
let mut content = card(
theme,
"Connect your Apple TV",
"Choose a TV to authorize. Sharing options appear once you're connected.",
)
.child(
button(
theme,
"refresh",
if self.busy {
"Looking for TVs…"
} else {
"Refresh devices"
},
idle,
)
.on_click(cx.listener(|view, _, _, cx| {
if !view.busy {
view.send(Command::Discover, cx);
}
})),
);
if self.devices.is_empty() {
content = content.child(
div()
.py_6()
.text_color(rgb(theme.muted))
.child("Keep Apple TV awake and connected to the same network."),
);
}
for (index, device) in self.devices.iter().enumerate() {
let device = device.clone();
content = content.child(
button(theme, ("device", index), device.name.clone(), idle)
.flex()
.justify_between()
.items_center()
.child(div().text_sm().child("Connect →"))
.on_click(cx.listener(move |view, _, _, cx| {
if !view.busy && !view.authenticated {
view.receiver = Some(device.name.clone());
view.send(Command::Connect(Box::new(device.clone())), cx);
}
})),
);
}
content
}
fn sharing_page(&self, theme: Theme, cx: &mut Context<Self>) -> gpui::Div {
if !self.authenticated {
return self.connection_page(theme, cx);
}
if self.streaming {
return card(
theme,
"You're sharing",
self.receiver.as_deref().unwrap_or("Apple TV"),
)
.child(
div()
.text_lg()
.child(self.screen.clone().unwrap_or_default()),
)
.child(
div()
.text_color(rgb(theme.muted))
.child(format!("{} · System audio on", self.quality.label())),
)
.child(
div()
.text_sm()
.child("Use Disconnect at the top right to stop sharing."),
);
}
let idle = !self.busy;
let source = card(theme, "1. Choose what to share", "Single-click one source in the desktop dialog, then press Share.")
.child(div().flex().flex_wrap().gap_3()
.child(button(theme, "mirror", "Screen or window", idle)
.on_click(cx.listener(|view, _, _, cx| {
if view.authenticated && !view.busy { view.send(Command::ChooseScreen(CaptureMode::Mirror), cx); }
})))
.child(button(theme, "extend", "Extended display", idle && self.virtual_supported == Some(true))
.on_click(cx.listener(|view, _, _, cx| {
if view.authenticated && !view.busy && view.virtual_supported == Some(true) { view.send(Command::ChooseScreen(CaptureMode::Extend), cx); }
}))))
.child(div().text_sm().text_color(rgb(theme.muted)).child(match self.virtual_supported {
Some(true) => "Extended display adds a separate desktop for windows you move onto your TV.",
Some(false) => "Extended display isn't supported by this desktop.",
None => "Checking extended-display support…",
}))
.when(self.screen.is_some(), |d| d.child(div().text_color(rgb(theme.accent)).child(format!("Selected: {}", self.screen.as_deref().unwrap_or_default()))));
let quality = card(
theme,
"2. Tune your stream",
"Choose picture quality and responsiveness before sharing.",
)
.child(div().text_sm().child("Video quality"))
.child(
div().flex().flex_wrap().gap_2().children(
VideoQuality::ALL
.into_iter()
.enumerate()
.map(|(index, quality)| {
button(theme, ("quality", index), quality.label(), idle)
.when(self.quality == quality, |d| {
d.border_color(rgb(theme.accent))
.text_color(rgb(theme.accent))
})
.on_click(cx.listener(move |view, _, _, cx| {
if !view.busy {
view.quality = quality;
cx.notify();
}
}))
}),
),
)
.child(
div()
.text_sm()
.text_color(rgb(theme.muted))
.child(self.quality.description()),
)
.child(div().text_sm().child("Playback buffer"))
.child(
div().flex().flex_wrap().gap_2().children(
LatencyMode::ALL
.into_iter()
.enumerate()
.map(|(index, latency)| {
button(theme, ("latency", index), latency.label(), idle)
.when(self.latency == latency, |d| {
d.border_color(rgb(theme.accent))
.text_color(rgb(theme.accent))
})
.on_click(cx.listener(move |view, _, _, cx| {
if !view.busy {
view.latency = latency;
cx.notify();
}
}))
}),
),
)
.child(
div()
.text_sm()
.text_color(rgb(theme.muted))
.child("Lower feels faster. Increase the buffer if playback stutters."),
);
div()
.flex()
.flex_col()
.gap_4()
.child(source)
.child(quality)
.child(
button(
theme,
"start",
if self.busy {
"Preparing sharing…"
} else {
"Start sharing →"
},
idle && self.screen.is_some(),
)
.text_center()
.text_lg()
.border_color(rgb(theme.accent))
.on_click(cx.listener(|view, _, _, cx| {
if view.authenticated && !view.busy && view.screen.is_some() {
view.send(Command::StartMirroring(view.quality, view.latency), cx);
}
})),
)
.child(
div()
.text_sm()
.text_color(rgb(theme.muted))
.child("System audio is included automatically."),
)
}
fn settings_page(&self, theme: Theme, cx: &mut Context<Self>) -> gpui::Div {
card(
theme,
"Appearance",
"Make Spielab comfortable for your workspace.",
)
.child(
div()
.flex()
.justify_between()
.items_center()
.gap_4()
.child(
div().child("Dark mode").child(
div()
.text_sm()
.text_color(rgb(theme.muted))
.child("Saved for your next visit."),
),
)
.child(
button(
theme,
"dark-mode",
if self.dark_mode {
"● On"
} else {
"○ Off"
},
true,
)
.border_color(rgb(theme.accent))
.on_click(cx.listener(|view, _, _, cx| {
view.dark_mode = !view.dark_mode;
let preferences = Preferences {
dark_mode: view.dark_mode,
};
let result = (|| -> anyhow::Result<()> {
let temporary = view.preferences_path.with_extension("tmp");
std::fs::write(&temporary, serde_json::to_vec(&preferences)?)?;
std::fs::rename(temporary, &view.preferences_path)?;
Ok(())
})();
if let Err(error) = result {
view.status = format!("Could not save appearance: {error}");
view.error = true;
}
cx.notify();
})),
),
)
}
}
impl Render for Spielab { impl Render for Spielab {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let idle = !self.busy; let theme = Theme::new(self.dark_mode);
let quality_controls = let can_stop = self.busy || self.authenticated || self.pin_required;
div() let stop_theme = Theme {
.flex() button: theme.danger,
.flex_col() hover: 0x8f2433,
.gap_2() text: 0xffffff,
.child( ..theme
div() };
.flex() let mut navigation = div().w(px(180.)).flex_shrink_0().flex().flex_col().gap_2();
.gap_2() for (index, page, label) in [
.children(VideoQuality::ALL.into_iter().enumerate().map( (0, Page::Connection, "Connection"),
|(index, quality)| { (1, Page::Sharing, "Sharing"),
button(("quality", index), quality.label(), idle) (2, Page::Settings, "Settings"),
.border_1() ] {
.border_color(rgb(if self.quality == quality { let enabled = page != Page::Sharing || self.authenticated;
0x85d8ae navigation = navigation.child(
} else { button(theme, ("nav", index as usize), label, enabled)
0x35433e .when(self.page == page, |d| {
})) d.border_color(rgb(theme.accent))
.on_click(cx.listener(move |view, _, _, cx| { .text_color(rgb(theme.accent))
if !view.busy {
view.quality = quality;
cx.notify();
}
}))
},
)),
)
.child(
div()
.text_sm()
.text_color(rgb(0x9bafa5))
.child(self.quality.description()),
);
let latency_controls =
div()
.flex()
.flex_col()
.gap_2()
.child(
div()
.flex()
.gap_2()
.children(LatencyMode::ALL.into_iter().enumerate().map(
|(index, latency)| {
button(("latency", index), latency.label(), idle)
.border_1()
.border_color(rgb(if self.latency == latency {
0x85d8ae
} else {
0x35433e
}))
.on_click(cx.listener(move |view, _, _, cx| {
if !view.busy {
view.latency = latency;
cx.notify();
}
}))
},
)),
)
.child(
div().text_sm().text_color(rgb(0x9bafa5)).child(
"Playback buffer, not total delay. Increase it if playback stutters.",
),
);
let extend_control = div().flex().flex_col().gap_2()
.child(button("extend", "Create extended display", idle && self.virtual_supported == Some(true))
.on_click(cx.listener(|view, _, _, cx| {
if !view.busy && view.virtual_supported == Some(true) {
view.send(Command::ChooseScreen(CaptureMode::Extend), cx);
}
})))
.child(div().text_sm().text_color(rgb(0x9bafa5)).child(match self.virtual_supported {
Some(true) => "Single-click the virtual monitor, then press Share. Move windows there; disconnecting removes it.",
Some(false) => "Your desktop’s screen-sharing portal does not provide virtual monitors.",
None => "Checking virtual-monitor support…",
}));
let mut receivers = div().flex().flex_col().gap_2();
for (index, device) in self.devices.iter().enumerate() {
let device = device.clone();
let detail = format!(
"{} · {}",
device.model,
device
.socket_addr()
.map(|a| a.to_string())
.unwrap_or_default()
);
let name = device.name.clone();
receivers = receivers.child(
div()
.id(("receiver", index))
.p_4()
.rounded_lg()
.bg(rgb(0x202a2b))
.when(idle && !self.authenticated, |d| {
d.cursor_pointer()
.hover(|s| s.bg(rgb(0x293a37)))
.on_click(cx.listener(move |view, _, _, cx| {
if !view.busy && !view.authenticated {
view.send(Command::Connect(Box::new(device.clone())), cx);
}
}))
}) })
.child(div().text_lg().child(name)) .on_click(cx.listener(move |view, _, _, cx| {
.child(div().text_sm().text_color(rgb(0x99ada5)).child(detail)), if page != Page::Sharing || view.authenticated {
); view.page = page;
}
let mut pairing = div().flex().flex_col().gap_3();
if self.pin_required {
pairing = pairing.child(div().text_lg().child("Apple TV code")).child(
div().text_3xl().child(format!(
"{}{}",
"● ".repeat(self.pin.len()),
"○ ".repeat(4 - self.pin.len())
)),
);
for row in [
vec!["1", "2", "3"],
vec!["4", "5", "6"],
vec!["7", "8", "9"],
vec!["0"],
] {
pairing =
pairing.child(div().flex().gap_2().children(row.into_iter().map(|digit| {
button(digit, digit, idle)
.on_click(cx.listener(move |view, _, _, cx| view.digit(digit, cx)))
})));
}
pairing = pairing.child(
div()
.flex()
.gap_2()
.child(button("clear", "Clear", idle).on_click(cx.listener(
|view, _, _, cx| {
view.pin.clear();
cx.notify(); cx.notify();
}, }
))) })),
.child(
button("pair", "Pair Apple TV", idle && self.pin.len() == 4).on_click(
cx.listener(|view, _, _, cx| {
if !view.busy && view.pin.len() == 4 {
let pin = std::mem::take(&mut view.pin);
view.send(Command::Pair(pin), cx);
}
}),
),
),
); );
} }
let content = match self.page {
Page::Connection => self.connection_page(theme, cx),
Page::Sharing => self.sharing_page(theme, cx),
Page::Settings => self.settings_page(theme, cx),
};
div() div()
.size_full() .size_full()
.bg(rgb(0x141c1e)) .bg(rgb(theme.background))
.text_color(rgb(0xe6efe9)) .text_color(rgb(theme.text))
.font_family("sans-serif") .font_family("sans-serif")
.track_focus(&self.focus) .track_focus(&self.focus)
.on_key_down(cx.listener(Self::key)) .on_key_down(cx.listener(Self::key))
.flex() .flex()
.flex_col() .flex_col()
.p_8() .p_6()
.gap_6() .gap_5()
.child( .child(
div() div()
.flex() .flex()
.justify_between() .justify_between()
.items_center() .items_center()
.gap_4()
.child( .child(
div().child(div().text_3xl().child("Spielab")).child( div().child(div().text_3xl().child("Spielab")).child(
div() div().text_sm().text_color(rgb(theme.muted)).child(
.text_sm() self.receiver
.text_color(rgb(0x9db3a8)) .clone()
.child("Your desktop, on Apple TV"), .unwrap_or("Your desktop, on Apple TV".into()),
),
), ),
) )
.child( .child(
div() button(
.text_sm() stop_theme,
.text_color(rgb(0x85d8ae)) "disconnect",
.child("WAYLAND · AIRPLAY"), if self.authenticated {
"Disconnect"
} else {
"Cancel"
},
can_stop,
)
.bg(rgb(theme.danger))
.text_color(rgb(0xffffff))
.border_color(rgb(theme.danger))
.on_click(cx.listener(|view, _, _, cx| {
if view.busy || view.authenticated || view.pin_required {
view.backend.disconnect();
view.status = "Disconnecting…".into();
view.busy = true;
cx.notify();
}
})),
), ),
) )
.child( .child(
div() div()
.flex() .flex()
.gap_6()
.flex_1() .flex_1()
.min_h_0() .min_h_0()
.gap_5()
.child(navigation)
.child( .child(
div() div()
.w(px(310.)) .id(match self.page {
.flex() Page::Connection => "connection-page",
.flex_col() Page::Sharing => "sharing-page",
.gap_4() Page::Settings => "settings-page",
.child( })
div()
.flex()
.justify_between()
.items_center()
.child("RECEIVERS")
.child(
button("scan", "Refresh", idle && !self.authenticated)
.on_click(cx.listener(|view, _, _, cx| {
if !view.busy && !view.authenticated {
view.send(Command::Discover, cx);
}
})),
),
)
.child(div().id("receivers").overflow_y_scroll().child(receivers)),
)
.child(
div()
.id("sharing-settings")
.flex_1() .flex_1()
.min_h_0() .min_w_0()
.overflow_y_scroll() .overflow_y_scroll()
.flex() .child(content),
.flex_col()
.gap_5()
.p_5()
.rounded_xl()
.bg(rgb(0x1b2527))
.child(
div().text_xl().child(
self.receiver
.clone()
.unwrap_or("Connect to your Apple TV".into()),
),
)
.child(
div()
.text_color(rgb(0xa7b8b0))
.child(if self.authenticated {
"Pairing verified over an encrypted connection."
} else {
"Select a receiver on your local network to begin."
}),
)
.child(pairing)
.child(
div()
.border_t_1()
.border_color(rgb(0x35433e))
.pt_4()
.flex()
.flex_col()
.gap_3()
.child("DISPLAY & QUALITY")
.child(quality_controls)
.child(latency_controls)
.child(
self.screen.clone().unwrap_or("No screen selected".into()),
)
.child(
button("screen", "Choose screen or window", idle).on_click(
cx.listener(|view, _, _, cx| {
if !view.busy {
view.send(Command::ChooseScreen(CaptureMode::Mirror), cx);
}
}),
),
)
.child(extend_control)
.child(
button(
"start-mirroring",
if self.streaming {
"Sharing…"
} else {
"Start sharing"
},
idle && self.authenticated && self.screen.is_some(),
)
.on_click(
cx.listener(|view, _, _, cx| {
if !view.busy
&& view.authenticated
&& view.screen.is_some()
{
view.send(Command::StartMirroring(view.quality, view.latency), cx);
}
}),
),
)
.child(
div()
.text_sm()
.text_color(rgb(0x9bafa5))
.child("System audio included · Settings apply when sharing starts"),
),
),
), ),
) )
.child( .child(
div() div()
.p_4() .p_3()
.rounded_lg() .rounded_lg()
.bg(rgb(if self.error { 0x442b2b } else { 0x202e29 })) .border_1()
.border_color(rgb(if self.error {
theme.danger
} else {
theme.border
}))
.bg(rgb(theme.panel))
.text_sm()
.child(self.status.clone()), .child(self.status.clone()),
) )
.child(
div()
.flex()
.justify_between()
.items_center()
.child(
div()
.text_sm()
.text_color(rgb(0x8d9f96))
.child("Esc cancels · Your desktop controls screen-sharing permission"),
)
.child(
button("disconnect", "Disconnect / cancel", true)
.on_click(cx.listener(|view, _, _, _| view.backend.disconnect())),
),
)
} }
} }
@@ -544,6 +746,7 @@ fn main() -> anyhow::Result<()> {
.or_else(|| std::env::var_os("XDG_STATE_HOME").map(|p| PathBuf::from(p).join("spielab"))) .or_else(|| std::env::var_os("XDG_STATE_HOME").map(|p| PathBuf::from(p).join("spielab")))
.or_else(|| std::env::var_os("HOME").map(|p| PathBuf::from(p).join(".local/state/spielab"))) .or_else(|| std::env::var_os("HOME").map(|p| PathBuf::from(p).join(".local/state/spielab")))
.ok_or_else(|| anyhow::anyhow!("Set SPIELAB_STATE_DIR or XDG_STATE_HOME"))?; .ok_or_else(|| anyhow::anyhow!("Set SPIELAB_STATE_DIR or XDG_STATE_HOME"))?;
let preferences_path = state_directory.join("preferences.json");
let backend = Backend::start(state_directory)?; let backend = Backend::start(state_directory)?;
Application::new().run(move |cx: &mut App| { Application::new().run(move |cx: &mut App| {
let bounds = Bounds::centered(None, size(px(980.), px(800.)), cx); let bounds = Bounds::centered(None, size(px(980.), px(800.)), cx);
@@ -554,7 +757,7 @@ fn main() -> anyhow::Result<()> {
..Default::default() ..Default::default()
}, },
|window, cx| { |window, cx| {
let view = cx.new(|cx| Spielab::new(backend, cx)); let view = cx.new(|cx| Spielab::new(backend, preferences_path, cx));
view.read(cx).focus.focus(window); view.read(cx).focus.focus(window);
view view
}, },