Skip to content

Commit 8d4f7dc

Browse files
authored
Game mode (#167)
* Game mode * Add game mode * Improve ordering and parsing * Add game mode info
1 parent fd69731 commit 8d4f7dc

14 files changed

Lines changed: 2516 additions & 19 deletions

.gitignore

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Secrets — signing / notarization credentials. Never commit.
2+
.env
3+
.env.*
4+
!.env.example
5+
6+
# Rust build output
7+
/target
8+
/target-linux
9+
10+
# Packaged build artifacts (produced by build-macos.sh / release workflow)
11+
/dist/
12+
/*.zip
13+
/*.dmg
14+
/*.universal
15+
/*.exe
16+
17+
# AppImage build tooling (downloaded, not source)
18+
/*.AppImage
19+
/tools/*.AppImage
20+
21+
# macOS cruft
22+
.DS_Store

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@ https://github.com/sandlbn/ultimate64-manager/releases
2323
- **Disk Image Viewer** – Display **D64/D71 directory contents** (C64-style listing)
2424
- **Disk Management** – Mount D64, D71, D81, G64, G71, G81 images to Drive A/B
2525
- **Run Programs** – Direct load and run for PRG, CRT, and SID files (PRG files also offer **Load** without running)
26+
- **Game Mode** – A full-screen list for browsing and running game collections
27+
- Reads game folders on the device (FTP) or on local disk, configured in Settings (or add the current device folder with **🎮+**)
28+
- Handles common collection layouts: flat files, letter buckets, one-folder-per-game, and nested folders
29+
- Shows a folder's box art and screenshot when present, including a central art folder such as OneLoad64's `Extras/Images`
30+
- A–Z jump, keyboard navigation, and Run (local games are uploaded to the device first)
31+
- Caches the scanned list and re-scans on change or via **Refresh**; **Fullscreen** hides the app chrome
2632
- **Supported File Types** – D64, D71, D81, G64, G71, G81, PRG, P00, CRT, SID, MOD, XM, S3M, TAP, T64, REU, ROM, BIN, CFG, ZIP, and firmware updates (U2L, U2P, U2R, U64, UE2)
2733
- **Music Player** – Play SID and MOD files with playlist support
2834
- Shuffle and repeat modes
@@ -106,6 +112,15 @@ https://github.com/sandlbn/ultimate64-manager/releases
106112
| `a``z`, `0``9` | Quick search — type to jump to first matching file |
107113
| `Cmd/Ctrl+A` | Select all in active pane |
108114

115+
### Game Mode
116+
117+
| Shortcut | Action |
118+
|----------|--------|
119+
| `` / `` | Move selection |
120+
| `a``z`, `0``9` | Jump to that letter section |
121+
| `Enter` | Run the selected game |
122+
| `Esc` | Exit fullscreen, then exit Game Mode |
123+
109124
## Song Length Database
110125

111126
The music player can use the HVSC **Songlengths.md5** database for accurate song durations.

src/api.rs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,43 @@ pub async fn run_disk(
153153
}
154154
}
155155

156+
/// Upload a *local* disk image, mount it (readonly) on `drive`, then reset +
157+
/// autoload — the local-file equivalent of [`run_disk`]. Used by Game Mode to
158+
/// launch disk images from an on-disk collection.
159+
pub async fn run_local_disk_async(
160+
host: &str,
161+
local_path: &Path,
162+
drive: &str,
163+
password: Option<&str>,
164+
connection: Option<Arc<Mutex<dyn RemoteDevice>>>,
165+
) -> Result<String, String> {
166+
let filename = local_path
167+
.file_name()
168+
.and_then(|s| s.to_str())
169+
.unwrap_or("disk")
170+
.to_string();
171+
172+
upload_mount_disk_async(host, local_path, drive, "readonly", password).await?;
173+
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
174+
175+
let device_num = if drive == "a" { "8" } else { "9" };
176+
if let Some(conn) = connection {
177+
let device = device_num.to_string();
178+
tokio::task::spawn_blocking(move || {
179+
let c = conn.lock().unwrap();
180+
crate::run_ops::autoload_mounted_disk(&*c, &device)?;
181+
Ok::<String, String>(format!("Running: {}", filename))
182+
})
183+
.await
184+
.map_err(|e| format!("Task error: {}", e))?
185+
} else {
186+
Ok(format!(
187+
"Mounted: {} (no connection for auto-run)",
188+
filename
189+
))
190+
}
191+
}
192+
156193
// ─────────────────────────────────────────────────────────────────
157194
// Memory read/write operations (via ultimate64 crate)
158195
// ─────────────────────────────────────────────────────────────────

src/app/settings.rs

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,84 @@ impl Ultimate64Browser {
246246
Task::none()
247247
}
248248

249+
/// Settings: user is typing a new Game Mode library root path.
250+
pub(crate) fn handle_game_library_input_changed(&mut self, value: String) -> Task<Message> {
251+
self.game_library_input = value;
252+
Task::none()
253+
}
254+
255+
/// Settings: add the staged path as a Game Mode library root (deduped),
256+
/// persist, and clear the input.
257+
pub(crate) fn handle_game_library_add_root(&mut self) -> Task<Message> {
258+
let root = self
259+
.game_library_input
260+
.trim()
261+
.trim_end_matches('/')
262+
.to_string();
263+
if root.is_empty() {
264+
return Task::none();
265+
}
266+
let root = if root.starts_with('/') {
267+
root
268+
} else {
269+
format!("/{}", root)
270+
};
271+
{
272+
let prefs = &mut self.profile_manager.active_settings_mut().preferences;
273+
if !prefs.game_library_roots.iter().any(|r| r == &root) {
274+
prefs.game_library_roots.push(root);
275+
}
276+
}
277+
self.settings = self.profile_manager.active_settings().clone();
278+
self.game_library_input.clear();
279+
if let Err(e) = self.profile_manager.save() {
280+
log::error!("Failed to save profiles: {}", e);
281+
}
282+
Task::none()
283+
}
284+
285+
/// Settings: a local folder was picked — add its absolute path as a library
286+
/// root (deduped) and persist.
287+
pub(crate) fn handle_game_library_local_picked(
288+
&mut self,
289+
path: Option<PathBuf>,
290+
) -> Task<Message> {
291+
let Some(path) = path else {
292+
return Task::none();
293+
};
294+
let root = path.to_string_lossy().to_string();
295+
{
296+
let prefs = &mut self.profile_manager.active_settings_mut().preferences;
297+
if !prefs.game_library_roots.iter().any(|r| r == &root) {
298+
prefs.game_library_roots.push(root);
299+
}
300+
}
301+
self.settings = self.profile_manager.active_settings().clone();
302+
if let Err(e) = self.profile_manager.save() {
303+
log::error!("Failed to save profiles: {}", e);
304+
}
305+
Task::none()
306+
}
307+
308+
/// Settings: remove the library root at `idx`, persist.
309+
pub(crate) fn handle_game_library_remove_root(&mut self, idx: usize) -> Task<Message> {
310+
{
311+
let roots = &mut self
312+
.profile_manager
313+
.active_settings_mut()
314+
.preferences
315+
.game_library_roots;
316+
if idx < roots.len() {
317+
roots.remove(idx);
318+
}
319+
}
320+
self.settings = self.profile_manager.active_settings().clone();
321+
if let Err(e) = self.profile_manager.save() {
322+
log::error!("Failed to save profiles: {}", e);
323+
}
324+
Task::none()
325+
}
326+
249327
pub(crate) fn handle_file_browser_start_dir_selected(
250328
&mut self,
251329
path: Option<PathBuf>,

src/app/view/dual_pane.rs

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ use iced::widget::{
55
};
66
use iced::{Element, Length};
77

8+
use crate::remote_browser::RemoteBrowserMessage;
89
use crate::{Message, Pane, Ultimate64Browser};
910

1011
impl Ultimate64Browser {
@@ -14,7 +15,10 @@ impl Ultimate64Browser {
1415
fn device_drive_control_strip(&self) -> Element<'_, Message> {
1516
let fs = crate::styles::FontSizes::from_base(self.settings.preferences.font_size);
1617
let dim = iced::Color::from_rgb(0.55, 0.57, 0.62);
17-
let connected = self.status.connected;
18+
// Gate on "we have a connection", not "the last status poll succeeded":
19+
// the poll can transiently fail on a reachable device, and gating device
20+
// actions on it would wrongly lock the user out.
21+
let connected = self.connection.is_some();
1822
const TYPES: [&str; 3] = ["1541", "1571", "1581"];
1923

2024
// One drive: a type dropdown, a state-aware power toggle, and a reset.
@@ -90,6 +94,16 @@ impl Ultimate64Browser {
9094
}
9195

9296
pub(crate) fn view_dual_pane_browser(&self) -> Element<'_, Message> {
97+
// Game Mode takes over the whole tab — a full-width immersive launcher
98+
// instead of the two file panes.
99+
if self.remote_browser.game.active {
100+
return self
101+
.remote_browser
102+
.game
103+
.view(self.settings.preferences.font_size)
104+
.map(|m| Message::RemoteBrowser(RemoteBrowserMessage::Game(m)));
105+
}
106+
93107
// Left pane - Local files
94108
let left_content = container(
95109
self.left_browser
@@ -186,9 +200,23 @@ impl Ultimate64Browser {
186200
// Device-control quick actions — gated on connection so an
187201
// offline click can't fire a hopeless REST request.
188202
button(text("⏏ Eject A+B").size(small))
189-
.on_press_maybe(self.status.connected.then_some(Message::EjectAllDrives),)
203+
.on_press_maybe(self.connection.is_some().then_some(Message::EjectAllDrives),)
190204
.padding([4, 8])
191205
.style(crate::styles::nav_button),
206+
// Game Mode — full-width EmulationStation-style launcher over
207+
// the folders in the configured game library. Always available:
208+
// local libraries need no device, and a device library shows a
209+
// clear error if it can't be reached.
210+
button(text("🎮 Games").size(small))
211+
.on_press(Message::RemoteBrowser(
212+
crate::remote_browser::RemoteBrowserMessage::Game(
213+
crate::game_mode::GameModeMessage::Toggle(
214+
self.settings.preferences.game_library_roots.clone(),
215+
),
216+
),
217+
))
218+
.padding([4, 8])
219+
.style(crate::styles::action_button),
192220
// Run last — re-fires the most recent PRG/CRT/SID/disk
193221
// the local browser sent. Greys out when nothing's been
194222
// run yet OR when the device is offline.
@@ -201,7 +229,7 @@ impl Ultimate64Browser {
201229
.size(small),
202230
)
203231
.on_press_maybe(
204-
(self.status.connected && self.left_browser.last_run().is_some())
232+
(self.connection.is_some() && self.left_browser.last_run().is_some())
205233
.then_some(Message::RunLast),
206234
)
207235
.padding([4, 8])

src/app/view/settings.rs

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,61 @@ impl Ultimate64Browser {
298298
.spacing(8)
299299
);
300300

301+
// ── Game library (Game Mode) ─────────────────────────────────────
302+
// Each configured device folder's subfolders become games in the
303+
// File Browser's "🎮 Games" launcher.
304+
let roots = &self.settings.preferences.game_library_roots;
305+
let mut roots_col = column![].spacing(4);
306+
if roots.is_empty() {
307+
roots_col = roots_col.push(
308+
text("No library folders yet — add a device path like /Usb0/Games")
309+
.size(fs.small)
310+
.color(dim),
311+
);
312+
} else {
313+
for (i, root) in roots.iter().enumerate() {
314+
roots_col = roots_col.push(
315+
row![
316+
text(root.clone()).size(fs.small),
317+
Space::new().width(Length::Fill),
318+
button(text("Remove").size(fs.tiny))
319+
.on_press(Message::GameLibraryRemoveRoot(i))
320+
.padding([2, 8])
321+
.style(crate::styles::nav_button),
322+
]
323+
.align_y(iced::Alignment::Center),
324+
);
325+
}
326+
}
327+
let game_library_section = section!(
328+
"Game library",
329+
column![
330+
text("Device folders (e.g. /Usb0/Games) or local disk folders whose games appear in Game Mode.")
331+
.size(fs.small)
332+
.color(dim),
333+
roots_col,
334+
row![
335+
text_input("/Usb0/Games", &self.game_library_input)
336+
.on_input(Message::GameLibraryInputChanged)
337+
.on_submit(Message::GameLibraryAddRoot)
338+
.padding(6)
339+
.size(fs.small as f32)
340+
.width(Length::Fixed(260.0)),
341+
button(text("Add device path").size(fs.small))
342+
.on_press(Message::GameLibraryAddRoot)
343+
.padding([4, 12])
344+
.style(crate::styles::action_button),
345+
button(text("📁 Add local folder…").size(fs.small))
346+
.on_press(Message::GameLibraryBrowseLocal)
347+
.padding([4, 12])
348+
.style(crate::styles::nav_button),
349+
]
350+
.spacing(8)
351+
.align_y(iced::Alignment::Center),
352+
]
353+
.spacing(8)
354+
);
355+
301356
// ── Debug ────────────────────────────────────────────────────────
302357
let debug_section = section!(
303358
"Debug",
@@ -321,6 +376,7 @@ impl Ultimate64Browser {
321376
connection_section,
322377
dirs_section,
323378
prefs_section,
379+
game_library_section,
324380
debug_section,
325381
]
326382
.spacing(10)

src/app/view/status_bar.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,10 @@ impl Ultimate64Browser {
9999
text(video_status).size(fs.normal).into()
100100
};
101101

102-
let connected = self.status.connected;
102+
// Enable machine control whenever a connection exists, rather than
103+
// only when the last status poll succeeded — a transient poll failure
104+
// on a reachable device shouldn't disable Reset/Reboot/etc.
105+
let connected = self.connection.is_some();
103106

104107
container(
105108
row![

src/app/window_modals.rs

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,20 @@ impl Ultimate64Browser {
4242
self.pending_drop = None;
4343
return Task::none();
4444
}
45+
// In Game Mode: Esc first drops out of fullscreen (restoring the app
46+
// chrome), then a second Esc leaves the launcher.
47+
if self.remote_browser.game.active {
48+
if self.remote_browser.game.fullscreen {
49+
self.remote_browser.game.fullscreen = false;
50+
if let Some(id) = self.main_window_id {
51+
return iced::window::set_mode(id, iced::window::Mode::Windowed)
52+
.map(|_: ()| Message::RefreshStatus);
53+
}
54+
return Task::none();
55+
}
56+
self.remote_browser.game.exit();
57+
return Task::none();
58+
}
4559
if self.video_streaming.is_fullscreen {
4660
return self.update(Message::ExitFullscreen);
4761
}
@@ -153,12 +167,15 @@ impl Ultimate64Browser {
153167
// Mark main window as gone so subscriptions stop
154168
self.main_window_id = None;
155169

156-
// Close any remaining windows and exit
157-
if let Some(streaming_id) = self.streaming_window_id {
158-
self.streaming_window_id = None;
159-
return Task::batch(vec![iced::window::close(streaming_id), iced::exit()]);
160-
}
161-
iced::exit()
170+
// Hard-exit the process. A graceful `iced::exit()` would let the
171+
// tokio runtime wait on `spawn_blocking` threads at shutdown — and
172+
// when the device is unreachable those threads sit stuck in a
173+
// blocking network call (their outer timeout cancels the future but
174+
// can't kill the thread), so the app appears to hang on close.
175+
// Exiting immediately sidesteps that; nothing here needs a graceful
176+
// teardown (settings persist as they change).
177+
log::info!("Exiting.");
178+
std::process::exit(0)
162179
} else {
163180
Task::none()
164181
}

0 commit comments

Comments
 (0)