Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Secrets — signing / notarization credentials. Never commit.
.env
.env.*
!.env.example

# Rust build output
/target
/target-linux

# Packaged build artifacts (produced by build-macos.sh / release workflow)
/dist/
/*.zip
/*.dmg
/*.universal
/*.exe

# AppImage build tooling (downloaded, not source)
/*.AppImage
/tools/*.AppImage

# macOS cruft
.DS_Store
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ https://github.com/sandlbn/ultimate64-manager/releases
- **Disk Image Viewer** – Display **D64/D71 directory contents** (C64-style listing)
- **Disk Management** – Mount D64, D71, D81, G64, G71, G81 images to Drive A/B
- **Run Programs** – Direct load and run for PRG, CRT, and SID files (PRG files also offer **Load** without running)
- **Game Mode** – A full-screen list for browsing and running game collections
- Reads game folders on the device (FTP) or on local disk, configured in Settings (or add the current device folder with **🎮+**)
- Handles common collection layouts: flat files, letter buckets, one-folder-per-game, and nested folders
- Shows a folder's box art and screenshot when present, including a central art folder such as OneLoad64's `Extras/Images`
- A–Z jump, keyboard navigation, and Run (local games are uploaded to the device first)
- Caches the scanned list and re-scans on change or via **Refresh**; **Fullscreen** hides the app chrome
- **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)
- **Music Player** – Play SID and MOD files with playlist support
- Shuffle and repeat modes
Expand Down Expand Up @@ -106,6 +112,15 @@ https://github.com/sandlbn/ultimate64-manager/releases
| `a`–`z`, `0`–`9` | Quick search — type to jump to first matching file |
| `Cmd/Ctrl+A` | Select all in active pane |

### Game Mode

| Shortcut | Action |
|----------|--------|
| `↑` / `↓` | Move selection |
| `a`–`z`, `0`–`9` | Jump to that letter section |
| `Enter` | Run the selected game |
| `Esc` | Exit fullscreen, then exit Game Mode |

## Song Length Database

The music player can use the HVSC **Songlengths.md5** database for accurate song durations.
Expand Down
37 changes: 37 additions & 0 deletions src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,43 @@ pub async fn run_disk(
}
}

/// Upload a *local* disk image, mount it (readonly) on `drive`, then reset +
/// autoload — the local-file equivalent of [`run_disk`]. Used by Game Mode to
/// launch disk images from an on-disk collection.
pub async fn run_local_disk_async(
host: &str,
local_path: &Path,
drive: &str,
password: Option<&str>,
connection: Option<Arc<Mutex<dyn RemoteDevice>>>,
) -> Result<String, String> {
let filename = local_path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("disk")
.to_string();

upload_mount_disk_async(host, local_path, drive, "readonly", password).await?;
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;

let device_num = if drive == "a" { "8" } else { "9" };
if let Some(conn) = connection {
let device = device_num.to_string();
tokio::task::spawn_blocking(move || {
let c = conn.lock().unwrap();
crate::run_ops::autoload_mounted_disk(&*c, &device)?;
Ok::<String, String>(format!("Running: {}", filename))
})
.await
.map_err(|e| format!("Task error: {}", e))?
} else {
Ok(format!(
"Mounted: {} (no connection for auto-run)",
filename
))
}
}

// ─────────────────────────────────────────────────────────────────
// Memory read/write operations (via ultimate64 crate)
// ─────────────────────────────────────────────────────────────────
Expand Down
78 changes: 78 additions & 0 deletions src/app/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,84 @@ impl Ultimate64Browser {
Task::none()
}

/// Settings: user is typing a new Game Mode library root path.
pub(crate) fn handle_game_library_input_changed(&mut self, value: String) -> Task<Message> {
self.game_library_input = value;
Task::none()
}

/// Settings: add the staged path as a Game Mode library root (deduped),
/// persist, and clear the input.
pub(crate) fn handle_game_library_add_root(&mut self) -> Task<Message> {
let root = self
.game_library_input
.trim()
.trim_end_matches('/')
.to_string();
if root.is_empty() {
return Task::none();
}
let root = if root.starts_with('/') {
root
} else {
format!("/{}", root)
};
{
let prefs = &mut self.profile_manager.active_settings_mut().preferences;
if !prefs.game_library_roots.iter().any(|r| r == &root) {
prefs.game_library_roots.push(root);
}
}
self.settings = self.profile_manager.active_settings().clone();
self.game_library_input.clear();
if let Err(e) = self.profile_manager.save() {
log::error!("Failed to save profiles: {}", e);
}
Task::none()
}

/// Settings: a local folder was picked — add its absolute path as a library
/// root (deduped) and persist.
pub(crate) fn handle_game_library_local_picked(
&mut self,
path: Option<PathBuf>,
) -> Task<Message> {
let Some(path) = path else {
return Task::none();
};
let root = path.to_string_lossy().to_string();
{
let prefs = &mut self.profile_manager.active_settings_mut().preferences;
if !prefs.game_library_roots.iter().any(|r| r == &root) {
prefs.game_library_roots.push(root);
}
}
self.settings = self.profile_manager.active_settings().clone();
if let Err(e) = self.profile_manager.save() {
log::error!("Failed to save profiles: {}", e);
}
Task::none()
}

/// Settings: remove the library root at `idx`, persist.
pub(crate) fn handle_game_library_remove_root(&mut self, idx: usize) -> Task<Message> {
{
let roots = &mut self
.profile_manager
.active_settings_mut()
.preferences
.game_library_roots;
if idx < roots.len() {
roots.remove(idx);
}
}
self.settings = self.profile_manager.active_settings().clone();
if let Err(e) = self.profile_manager.save() {
log::error!("Failed to save profiles: {}", e);
}
Task::none()
}

pub(crate) fn handle_file_browser_start_dir_selected(
&mut self,
path: Option<PathBuf>,
Expand Down
34 changes: 31 additions & 3 deletions src/app/view/dual_pane.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ use iced::widget::{
};
use iced::{Element, Length};

use crate::remote_browser::RemoteBrowserMessage;
use crate::{Message, Pane, Ultimate64Browser};

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

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

pub(crate) fn view_dual_pane_browser(&self) -> Element<'_, Message> {
// Game Mode takes over the whole tab — a full-width immersive launcher
// instead of the two file panes.
if self.remote_browser.game.active {
return self
.remote_browser
.game
.view(self.settings.preferences.font_size)
.map(|m| Message::RemoteBrowser(RemoteBrowserMessage::Game(m)));
}

// Left pane - Local files
let left_content = container(
self.left_browser
Expand Down Expand Up @@ -186,9 +200,23 @@ impl Ultimate64Browser {
// Device-control quick actions — gated on connection so an
// offline click can't fire a hopeless REST request.
button(text("⏏ Eject A+B").size(small))
.on_press_maybe(self.status.connected.then_some(Message::EjectAllDrives),)
.on_press_maybe(self.connection.is_some().then_some(Message::EjectAllDrives),)
.padding([4, 8])
.style(crate::styles::nav_button),
// Game Mode — full-width EmulationStation-style launcher over
// the folders in the configured game library. Always available:
// local libraries need no device, and a device library shows a
// clear error if it can't be reached.
button(text("🎮 Games").size(small))
.on_press(Message::RemoteBrowser(
crate::remote_browser::RemoteBrowserMessage::Game(
crate::game_mode::GameModeMessage::Toggle(
self.settings.preferences.game_library_roots.clone(),
),
),
))
.padding([4, 8])
.style(crate::styles::action_button),
// Run last — re-fires the most recent PRG/CRT/SID/disk
// the local browser sent. Greys out when nothing's been
// run yet OR when the device is offline.
Expand All @@ -201,7 +229,7 @@ impl Ultimate64Browser {
.size(small),
)
.on_press_maybe(
(self.status.connected && self.left_browser.last_run().is_some())
(self.connection.is_some() && self.left_browser.last_run().is_some())
.then_some(Message::RunLast),
)
.padding([4, 8])
Expand Down
56 changes: 56 additions & 0 deletions src/app/view/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,61 @@ impl Ultimate64Browser {
.spacing(8)
);

// ── Game library (Game Mode) ─────────────────────────────────────
// Each configured device folder's subfolders become games in the
// File Browser's "🎮 Games" launcher.
let roots = &self.settings.preferences.game_library_roots;
let mut roots_col = column![].spacing(4);
if roots.is_empty() {
roots_col = roots_col.push(
text("No library folders yet — add a device path like /Usb0/Games")
.size(fs.small)
.color(dim),
);
} else {
for (i, root) in roots.iter().enumerate() {
roots_col = roots_col.push(
row![
text(root.clone()).size(fs.small),
Space::new().width(Length::Fill),
button(text("Remove").size(fs.tiny))
.on_press(Message::GameLibraryRemoveRoot(i))
.padding([2, 8])
.style(crate::styles::nav_button),
]
.align_y(iced::Alignment::Center),
);
}
}
let game_library_section = section!(
"Game library",
column![
text("Device folders (e.g. /Usb0/Games) or local disk folders whose games appear in Game Mode.")
.size(fs.small)
.color(dim),
roots_col,
row![
text_input("/Usb0/Games", &self.game_library_input)
.on_input(Message::GameLibraryInputChanged)
.on_submit(Message::GameLibraryAddRoot)
.padding(6)
.size(fs.small as f32)
.width(Length::Fixed(260.0)),
button(text("Add device path").size(fs.small))
.on_press(Message::GameLibraryAddRoot)
.padding([4, 12])
.style(crate::styles::action_button),
button(text("📁 Add local folder…").size(fs.small))
.on_press(Message::GameLibraryBrowseLocal)
.padding([4, 12])
.style(crate::styles::nav_button),
]
.spacing(8)
.align_y(iced::Alignment::Center),
]
.spacing(8)
);

// ── Debug ────────────────────────────────────────────────────────
let debug_section = section!(
"Debug",
Expand All @@ -321,6 +376,7 @@ impl Ultimate64Browser {
connection_section,
dirs_section,
prefs_section,
game_library_section,
debug_section,
]
.spacing(10)
Expand Down
5 changes: 4 additions & 1 deletion src/app/view/status_bar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,10 @@ impl Ultimate64Browser {
text(video_status).size(fs.normal).into()
};

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

container(
row![
Expand Down
29 changes: 23 additions & 6 deletions src/app/window_modals.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,20 @@ impl Ultimate64Browser {
self.pending_drop = None;
return Task::none();
}
// In Game Mode: Esc first drops out of fullscreen (restoring the app
// chrome), then a second Esc leaves the launcher.
if self.remote_browser.game.active {
if self.remote_browser.game.fullscreen {
self.remote_browser.game.fullscreen = false;
if let Some(id) = self.main_window_id {
return iced::window::set_mode(id, iced::window::Mode::Windowed)
.map(|_: ()| Message::RefreshStatus);
}
return Task::none();
}
self.remote_browser.game.exit();
return Task::none();
}
if self.video_streaming.is_fullscreen {
return self.update(Message::ExitFullscreen);
}
Expand Down Expand Up @@ -153,12 +167,15 @@ impl Ultimate64Browser {
// Mark main window as gone so subscriptions stop
self.main_window_id = None;

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