Improve Switch-only MTP UI with drag export, i18n, and polish.

Ship Finder-style drag-out for files and folders, system-language packs with an Options override, About/menu bar support, and sorting plus selection UX fixes.
This commit is contained in:
2026-07-26 11:24:14 +02:00
parent b3bbba3f2e
commit a69a72cf81
17 changed files with 1562 additions and 746 deletions

View File

@@ -78,6 +78,7 @@ find_library(FW_APPKIT AppKit REQUIRED)
find_library(FW_FOUNDATION Foundation REQUIRED)
find_library(FW_WEBKIT WebKit REQUIRED)
find_library(FW_CORETEXT CoreText REQUIRED)
find_library(FW_UTTYPES UniformTypeIdentifiers REQUIRED)
# ─── Application ─────────────────────────────────────────────────────────────
file(GLOB_RECURSE APP_SOURCES CONFIGURE_DEPENDS
@@ -87,9 +88,17 @@ file(GLOB_RECURSE APP_SOURCES CONFIGURE_DEPENDS
# webroot resource files — copied into the bundle at Resources/webroot/
file(GLOB WEBROOT_FILES "src/ui/webroot/*")
file(GLOB WEBROOT_LANG_FILES "src/ui/webroot/lang/*.json")
foreach(WF ${WEBROOT_FILES})
# Skip the lang directory entry if glob picks it up as a file path oddly
if(IS_DIRECTORY "${WF}")
continue()
endif()
set_source_files_properties(${WF} PROPERTIES MACOSX_PACKAGE_LOCATION Resources/webroot)
endforeach()
foreach(LF ${WEBROOT_LANG_FILES})
set_source_files_properties(${LF} PROPERTIES MACOSX_PACKAGE_LOCATION Resources/webroot/lang)
endforeach()
# App icon — generated from assets/AppIcon.png at build time
set(APP_ICON_SOURCE "${CMAKE_SOURCE_DIR}/assets/AppIcon.png")
@@ -103,7 +112,7 @@ add_custom_command(
VERBATIM
)
add_executable(OmniMTP MACOSX_BUNDLE ${APP_SOURCES} ${WEBROOT_FILES} ${APP_ICON_ICNS})
add_executable(OmniMTP MACOSX_BUNDLE ${APP_SOURCES} ${WEBROOT_FILES} ${WEBROOT_LANG_FILES} ${APP_ICON_ICNS})
target_include_directories(OmniMTP PRIVATE
${CMAKE_SOURCE_DIR}/src
@@ -111,7 +120,7 @@ target_include_directories(OmniMTP PRIVATE
)
target_link_libraries(OmniMTP PRIVATE
usb_static
${FW_APPKIT} ${FW_FOUNDATION} ${FW_METAL} ${FW_WEBKIT} ${FW_CORETEXT}
${FW_APPKIT} ${FW_FOUNDATION} ${FW_METAL} ${FW_WEBKIT} ${FW_CORETEXT} ${FW_UTTYPES}
)
target_compile_options(OmniMTP PRIVATE
@@ -125,7 +134,7 @@ set_target_properties(OmniMTP PROPERTIES
MACOSX_BUNDLE_BUNDLE_NAME "OmniMTP"
MACOSX_BUNDLE_BUNDLE_VERSION "${PROJECT_VERSION}"
MACOSX_BUNDLE_SHORT_VERSION_STRING "${PROJECT_VERSION}"
MACOSX_BUNDLE_COPYRIGHT "Copyright © 2025 OmniMTP"
MACOSX_BUNDLE_COPYRIGHT "Copyright © 2026 NiklasCFW"
MACOSX_BUNDLE_INFO_PLIST "${CMAKE_SOURCE_DIR}/cmake/Info.plist.in"
MACOSX_BUNDLE_ICON_FILE AppIcon
)

View File

@@ -7,7 +7,6 @@
#include <filesystem>
#include <memory>
#include <mutex>
#include <set>
#include <string>
#include <thread>
#include <vector>
@@ -16,22 +15,6 @@ namespace omniMTP {
namespace fs = std::filesystem;
// ── Entry in a file browser panel ────────────────────────────────────────────
struct LocalEntry {
fs::path path;
std::string name;
bool is_dir = false;
uint64_t size = 0;
std::string size_str;
std::string date_str;
};
struct LocalDrive {
std::string path;
std::string name;
bool removable = false;
};
struct RemoteEntry {
uint32_t handle = 0;
uint32_t storage_id = 0;
@@ -60,9 +43,6 @@ public:
// ── Web bridge API ────────────────────────────────────────────────────────
std::string state_json() const;
std::string current_local_path() const;
void navigate_local(const std::string& path);
void navigate_local_up();
void navigate_remote(uint32_t handle, uint32_t storage_id, const std::string& name);
void navigate_remote_up();
void navigate_remote_to(size_t idx);
@@ -71,8 +51,10 @@ public:
void do_upload(const std::string& src_path);
void do_download(uint32_t handle, uint32_t storage_id, const std::string& filename, uint64_t size);
// Blocking download used by Finder drag-export (NSFilePromiseProvider).
// When is_dir is true, dest_path is a folder that will be created and filled recursively.
void download_remote_to_path(uint32_t handle, uint32_t storage_id,
const std::string& dest_path, uint64_t size);
const std::string& dest_path, uint64_t size,
bool is_dir = false);
void do_cancel(const std::string& id);
void do_cancel_all();
void do_delete(uint32_t handle);
@@ -81,21 +63,15 @@ public:
bool connected() const { return connected_.load(); }
// Drop-zone rects (CSS pixels, origin top-left) updated by the web UI.
void update_drop_zones(double rx, double ry, double rw, double rh,
double lx, double ly, double lw, double lh);
// Returns "remote", "local", or "none".
// Drop-zone rect (CSS pixels, origin top-left) updated by the web UI.
void update_drop_zone(double x, double y, double w, double h);
// Returns "remote" or "none".
std::string drop_target_at(double x, double y) const;
// Called from the OS drag-and-drop callback (may be on a different thread).
void enqueue_finder_drop(const std::string& path);
private:
// ── Local filesystem ──────────────────────────────────────────────────────
void refresh_local(const fs::path& path);
void refresh_local_drives();
std::string active_local_drive_path() const;
// ── Remote filesystem ─────────────────────────────────────────────────────
void refresh_remote();
@@ -103,6 +79,8 @@ private:
void start_download(uint32_t handle, uint32_t storage_id,
const std::string& filename, uint64_t size);
void start_upload(const fs::path& src_path, uint64_t file_size);
// Recursively download an MTP association (folder) into dest (must hold device_mtx_).
void download_remote_tree(uint32_t handle, uint32_t storage_id, const fs::path& dest);
// ── Device connection loop (runs on background thread) ────────────────────
void device_monitor_loop(std::stop_token st);
@@ -110,7 +88,9 @@ private:
void on_device_disconnected();
// ── Status bar ────────────────────────────────────────────────────────────
void set_status(const std::string& msg, float duration_sec = 5.f);
// key is an i18n id (e.g. "status.connected"); args fill {name}/{error}/… placeholders.
void set_status(const std::string& key, std::vector<std::string> args = {},
float duration_sec = 5.f);
// ── Finder drop processing (called from state_json) ───────────────────────
void process_finder_drops();
@@ -128,11 +108,8 @@ private:
std::vector<std::pair<uint32_t, mtp::StorageInfo>> storages_;
uint32_t active_storage_ = 0;
// ── State: local panel ────────────────────────────────────────────────────
fs::path local_path_;
std::vector<LocalEntry> local_entries_;
std::vector<LocalDrive> local_drives_;
mutable std::atomic<bool> local_needs_refresh_{false};
// Download destination (defaults to home directory).
fs::path download_path_;
// ── State: remote panel ───────────────────────────────────────────────────
std::vector<NavEntry> remote_nav_stack_;
@@ -140,19 +117,18 @@ private:
mutable bool remote_needs_refresh_ = true;
bool remote_refreshing_ = false;
// Status bar message with timeout
std::string status_msg_;
float status_msg_timer_ = 0.f;
// Status bar message with timeout (i18n key + args)
std::string status_key_;
std::vector<std::string> status_args_;
float status_msg_timer_ = 0.f;
// Files/folders dropped from macOS Finder.
// Written from the drop callback thread, read + cleared elsewhere.
// Files dropped from macOS Finder.
std::vector<std::string> finder_drops_;
std::mutex finder_drops_mtx_;
struct DropZone { double x = 0, y = 0, w = 0, h = 0; };
DropZone remote_drop_zone_;
DropZone local_drop_zone_;
bool drop_zones_valid_ = false;
bool drop_zone_valid_ = false;
};
} // namespace omniMTP

View File

@@ -7,7 +7,6 @@
#include <algorithm>
#include <chrono>
#include <thread>
#include <sys/stat.h>
#include <cstdlib>
#include <cstring>
@@ -16,29 +15,6 @@ namespace omniMTP {
// ─── Static helpers ───────────────────────────────────────────────────────────
static std::string date_str_from_fs(const fs::directory_entry& e) {
struct ::stat st{};
if (::stat(e.path().c_str(), &st) == 0) {
char buf[32];
std::strftime(buf, sizeof(buf), "%b %e, %Y", std::localtime(&st.st_mtime));
return buf;
}
return "";
}
using CrumbList = std::vector<std::pair<std::string, fs::path>>;
static CrumbList build_crumbs(const fs::path& path) {
std::vector<std::pair<std::string, fs::path>> segs;
fs::path p = path;
while (true) {
auto par = p.parent_path();
if (par == p) break; // reached root — skip it
segs.insert(segs.begin(), {p.filename().string(), p});
p = par;
}
return segs;
}
static std::string jstr(const std::string& s) {
std::string r; r.reserve(s.size()+4);
for (char c : s) {
@@ -76,12 +52,11 @@ static std::string fmt_eta(double s) {
}
// ─── App lifecycle ────────────────────────────────────────────────────────────
App::App() : local_path_(fs::path(std::getenv("HOME") ? std::getenv("HOME") : "/")) {}
App::App() : download_path_(fs::path(std::getenv("HOME") ? std::getenv("HOME") : "/")) {}
App::~App() { shutdown(); }
void App::init() {
local_path_ = fs::path(std::getenv("HOME") ? std::getenv("HOME") : "/");
refresh_local(local_path_);
download_path_ = fs::path(std::getenv("HOME") ? std::getenv("HOME") : "/");
monitor_thread_ = std::jthread([this](std::stop_token st) { device_monitor_loop(st); });
}
@@ -106,9 +81,7 @@ void App::device_monitor_loop(std::stop_token st) {
engine_ = std::make_unique<transfer::TransferEngine>(*session_);
engine_->set_completion_handler([this](const transfer::TransferItem& item) {
if (item.state.load() != transfer::State::Done) return;
if (item.direction == transfer::Direction::Download)
local_needs_refresh_.store(true);
else
if (item.direction != transfer::Direction::Download)
remote_needs_refresh_ = true;
});
}
@@ -162,7 +135,7 @@ void App::on_device_connected(mtp::DeviceInfo di) {
}
connected_.store(true);
remote_needs_refresh_ = true;
set_status(std::format("Connected {}", device_info_.model));
set_status("status.connected", {device_info_.model});
refresh_remote();
}
@@ -178,104 +151,7 @@ void App::on_device_disconnected() {
}
connected_.store(false);
remote_needs_refresh_ = false;
set_status("Switch disconnected");
}
// ─── Local filesystem ─────────────────────────────────────────────────────────
void App::refresh_local(const fs::path& path) {
local_entries_.clear();
try {
for (auto& e : fs::directory_iterator(path,
fs::directory_options::skip_permission_denied)) {
LocalEntry le{};
le.path = e.path();
le.name = e.path().filename().string();
le.is_dir = e.is_directory();
if (!le.is_dir) { le.size = e.file_size(); le.size_str = fmt_size(le.size); }
le.date_str = date_str_from_fs(e);
local_entries_.push_back(std::move(le));
}
std::sort(local_entries_.begin(), local_entries_.end(), [](auto& a, auto& b) {
if (a.is_dir != b.is_dir) return a.is_dir > b.is_dir;
return a.name < b.name;
});
} catch (...) {}
local_path_ = path;
}
void App::refresh_local_drives() {
local_drives_.clear();
@autoreleasepool {
NSFileManager* fm = NSFileManager.defaultManager;
NSArray* keys = @[
NSURLVolumeNameKey,
NSURLVolumeIsRemovableKey,
];
NSArray<NSURL*>* urls =
[fm mountedVolumeURLsIncludingResourceValuesForKeys:keys
options:NSVolumeEnumerationSkipHiddenVolumes];
bool has_data_volume = false;
for (NSURL* url in urls) {
if ([url.path isEqualToString:@"/System/Volumes/Data"])
has_data_volume = true;
}
for (NSURL* url in urls) {
NSString* path = url.path;
if (!path || path.length == 0) continue;
std::string mount = path.UTF8String;
if (mount == "/dev" || mount == "/home" || mount == "/net") continue;
if (mount.rfind("/private/", 0) == 0) continue;
if (mount.rfind("/System/Volumes/", 0) == 0 && mount != "/System/Volumes/Data") continue;
if (mount == "/" && has_data_volume) continue;
NSString* vol_name = nil;
NSNumber* removable = nil;
[url getResourceValue:&vol_name forKey:NSURLVolumeNameKey error:nil];
[url getResourceValue:&removable forKey:NSURLVolumeIsRemovableKey error:nil];
LocalDrive drive{};
drive.path = mount;
drive.name = vol_name.length ? vol_name.UTF8String : fs::path(mount).filename().string();
if (drive.name.empty()) drive.name = mount;
drive.removable = removable.boolValue;
local_drives_.push_back(std::move(drive));
}
}
std::sort(local_drives_.begin(), local_drives_.end(),
[](const LocalDrive& a, const LocalDrive& b) {
if (a.removable != b.removable) return a.removable < b.removable;
return a.name < b.name;
});
}
std::string App::active_local_drive_path() const {
std::error_code ec;
fs::path current = fs::weakly_canonical(local_path_, ec);
if (ec) current = local_path_;
const std::string current_str = current.string();
std::string best;
size_t best_len = 0;
for (const auto& drive : local_drives_) {
fs::path root = fs::path(drive.path);
fs::path root_canonical = fs::weakly_canonical(root, ec);
if (ec) root_canonical = root;
const std::string root_str = root_canonical.string();
const bool under =
current == root_canonical ||
current_str.rfind(root_str + "/", 0) == 0;
if (under && root_str.size() >= best_len) {
best = drive.path;
best_len = root_str.size();
}
}
if (!best.empty()) return best;
return local_drives_.empty() ? "" : local_drives_.front().path;
set_status("status.disconnected");
}
// ─── Remote filesystem ────────────────────────────────────────────────────────
@@ -305,7 +181,7 @@ void App::refresh_remote() {
return a.name < b.name;
});
} catch (const mtp::MTPException& e) {
set_status(std::format("Error listing: {}", e.what()));
set_status("status.listing_error", {e.what()});
}
remote_refreshing_ = false;
remote_needs_refresh_ = false;
@@ -316,60 +192,48 @@ void App::start_download(uint32_t handle, uint32_t storage_id,
const std::string& filename, uint64_t size) {
if (!engine_) return;
engine_->enqueue_download(handle, storage_id,
(local_path_ / filename).string(), filename, size);
set_status(std::format("Downloading {}", filename));
(download_path_ / filename).string(), filename, size);
set_status("status.downloading", {filename});
}
void App::start_upload(const fs::path& src, uint64_t sz) {
if (!engine_ || remote_nav_stack_.empty()) return;
const auto& top = remote_nav_stack_.back();
engine_->enqueue_upload(src.string(), top.handle, top.storage_id,
src.filename().string(), sz);
set_status(std::format("Uploading {}", src.filename().string()));
set_status("status.uploading", {src.filename().string()});
}
void App::set_status(const std::string& msg, float dur) {
status_msg_ = msg; status_msg_timer_ = dur;
void App::set_status(const std::string& key, std::vector<std::string> args, float dur) {
status_key_ = key;
status_args_ = std::move(args);
status_msg_timer_ = dur;
}
void App::enqueue_finder_drop(const std::string& path) {
std::lock_guard lock(finder_drops_mtx_);
finder_drops_.push_back(path);
}
void App::update_drop_zones(double rx, double ry, double rw, double rh,
double lx, double ly, double lw, double lh) {
remote_drop_zone_ = {rx, ry, rw, rh};
local_drop_zone_ = {lx, ly, lw, lh};
drop_zones_valid_ = rw > 0 && rh > 0 && lw > 0 && lh > 0;
void App::update_drop_zone(double x, double y, double w, double h) {
remote_drop_zone_ = {x, y, w, h};
drop_zone_valid_ = w > 0 && h > 0;
}
std::string App::drop_target_at(double x, double y) const {
if (!drop_zones_valid_) return "none";
auto inside = [](const DropZone& z, double px, double py) {
return px >= z.x && px <= z.x + z.w && py >= z.y && py <= z.y + z.h;
};
if (inside(remote_drop_zone_, x, y)) return "remote";
if (inside(local_drop_zone_, x, y)) return "local";
if (!drop_zone_valid_) return "none";
const auto& z = remote_drop_zone_;
if (x >= z.x && x <= z.x + z.w && y >= z.y && y <= z.y + z.h) return "remote";
return "none";
}
// ─── Web bridge API ───────────────────────────────────────────────────────────
std::string App::current_local_path() const { return local_path_.string(); }
std::string App::state_json() const {
// Process any pending finder drops
{
const_cast<App*>(this)->process_finder_drops();
}
if (local_needs_refresh_.load()) {
local_needs_refresh_.store(false);
const_cast<App*>(this)->refresh_local(local_path_);
}
if (remote_needs_refresh_ && connected_.load()) {
remote_needs_refresh_ = false;
const_cast<App*>(this)->refresh_remote();
}
const_cast<App*>(this)->refresh_local_drives();
const std::string active_drive = active_local_drive_path();
bool conn = connected_.load();
std::string dn;
@@ -403,22 +267,6 @@ std::string App::state_json() const {
j+="{\"handle\":"+std::to_string(nav[i].handle)+",\"storageId\":"+std::to_string(nav[i].storage_id)+",\"label\":\""+jstr(nav[i].label)+"\"}";
if (i+1<nav.size()) j+=",";
}
j+="],\"localDrives\":[";
for (size_t i = 0; i < local_drives_.size(); ++i) {
auto& d = local_drives_[i];
j += "{\"path\":\"" + jstr(d.path) + "\",\"name\":\"" + jstr(d.name) + "\",";
j += "\"removable\":" + std::string(d.removable ? "true" : "false") + "}";
if (i + 1 < local_drives_.size()) j += ",";
}
j += "],\"activeLocalDrive\":\"" + jstr(active_drive) + "\",";
j+="\"localPath\":\""+jstr(local_path_.string())+"\",\"localFiles\":[";
for (size_t i=0;i<local_entries_.size();++i) {
auto& e=local_entries_[i];
j+="{\"name\":\""+jstr(e.name)+"\",\"isDir\":"+std::string(e.is_dir?"true":"false")+",";
j+="\"size\":"+std::to_string(e.size)+",\"sizeStr\":\""+jstr(e.size_str)+"\",";
j+="\"date\":\""+jstr(e.date_str)+"\",\"path\":\""+jstr(e.path.string())+"\"}";
if (i+1<local_entries_.size()) j+=",";
}
j+="],\"remoteRefreshing\":"+std::string(refreshing?"true":"false")+",\"remoteFiles\":[";
for (size_t i=0;i<remote_entries_.size();++i) {
auto& e=remote_entries_[i];
@@ -443,9 +291,13 @@ std::string App::state_json() const {
if (i+1<items.size()) j+=",";
}
}
j+="],\"statusMessage\":\""+jstr(status_msg_)+"\",";
j+="],\"statusKey\":\""+jstr(status_key_)+"\",\"statusArgs\":[";
for (size_t i=0;i<status_args_.size();++i) {
j+="\""+jstr(status_args_[i])+"\"";
if (i+1<status_args_.size()) j+=",";
}
char tm[16]; snprintf(tm,sizeof(tm),"%.2f",(double)status_msg_timer_);
j+="\"statusTimer\":"+std::string(tm)+"}";
j+="],\"statusTimer\":"+std::string(tm)+"}";
return j;
}
@@ -458,22 +310,14 @@ void App::process_finder_drops() {
for (auto& dropped : drops) {
fs::path p(dropped);
std::error_code ec;
if (fs::is_directory(p, ec)) {
refresh_local(p);
} else if (!ec && connected_.load()) {
if (fs::is_directory(p, ec)) continue;
if (!ec && connected_.load()) {
auto sz = fs::file_size(p, ec);
if (!ec) start_upload(p, sz);
}
}
}
void App::navigate_local(const std::string& p) {
refresh_local(fs::path(p == "~" ? (std::getenv("HOME") ? std::getenv("HOME") : "/") : p));
}
void App::navigate_local_up() {
if (local_path_.has_parent_path() && local_path_ != local_path_.parent_path())
refresh_local(local_path_.parent_path());
}
void App::navigate_remote(uint32_t h, uint32_t s, const std::string& name) {
remote_nav_stack_.push_back({h, s, name});
refresh_remote();
@@ -510,10 +354,36 @@ void App::do_upload(const std::string& src) {
void App::do_download(uint32_t h, uint32_t s, const std::string& fn, uint64_t sz) {
start_download(h, s, fn, sz);
}
void App::download_remote_tree(uint32_t handle, uint32_t storage_id, const fs::path& dest) {
std::error_code ec;
fs::create_directories(dest, ec);
if (ec) throw std::runtime_error("Failed to create folder: " + dest.string());
auto child_handles = ops_->get_object_handles(storage_id, handle);
std::atomic<bool> cancel{false};
mtp::ProgressFn noop = [](uint64_t, uint64_t) {};
for (uint32_t h : child_handles) {
auto oi = ops_->get_object_info(h);
if (oi.filename.empty()) continue;
fs::path child = dest / oi.filename;
if (oi.is_directory()) {
download_remote_tree(h, storage_id, child);
} else {
ops_->get_object(h, child.string(), oi.compressed_size, noop, cancel);
}
}
}
void App::download_remote_to_path(uint32_t handle, uint32_t storage_id,
const std::string& dest_path, uint64_t size) {
const std::string& dest_path, uint64_t size,
bool is_dir) {
std::lock_guard lock(device_mtx_);
if (!ops_) throw std::runtime_error("Not connected");
if (is_dir) {
download_remote_tree(handle, storage_id, fs::path(dest_path));
return;
}
std::atomic<bool> cancel{false};
mtp::ProgressFn noop = [](uint64_t, uint64_t) {};
ops_->get_object(handle, dest_path, size, noop, cancel);

File diff suppressed because it is too large Load Diff

View File

@@ -113,15 +113,25 @@ body {
flex-shrink: 0;
gap: 8px;
}
.device-left, .device-right { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
.device-center {
.device-left, .device-right {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
min-width: 0;
}
.device-left { justify-content: flex-start; }
.device-right { justify-content: flex-end; }
.device-center {
flex: 0 1 auto;
max-width: 50%;
text-align: center;
font-size: 12px;
color: var(--label-2);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
/* ── Main two-panel area ──────────────────────────────────────────────────── */
@@ -301,6 +311,22 @@ body {
white-space: nowrap;
overflow: hidden;
}
.ftable th.sortable {
cursor: pointer;
user-select: none;
-webkit-user-select: none;
}
.ftable th.sortable:hover { color: var(--label-1); }
.ftable th .sort-ind {
display: inline-block;
min-width: 1em;
margin-left: 4px;
font-size: 9px;
opacity: 0;
vertical-align: middle;
}
.ftable th.sortable:hover .sort-ind { opacity: 0.35; }
.ftable th .sort-ind.active { opacity: 1; color: var(--accent, #0a84ff); }
.ftable th:first-child { padding-left: 12px; }
.col-size { width: 72px; text-align: right; }
.col-date { width: 110px; }
@@ -545,8 +571,8 @@ body {
.marquee-box {
position: fixed;
z-index: 9998;
border: 1px solid var(--accent);
background: rgba(10,132,255,0.12);
border: 1px solid rgba(10, 132, 255, 0.85);
background: rgba(10, 132, 255, 0.06);
pointer-events: none;
}

View File

@@ -10,7 +10,6 @@ function call(action, params = {}) {
try {
window.webkit.messageHandlers.bridge.postMessage({id, action, ...params});
} catch(e) {
// Dev fallback
setTimeout(() => { if (_pending[id]) { _pending[id](null); delete _pending[id]; } }, 100);
}
});
@@ -19,55 +18,189 @@ window.bridgeResponse = function(id, data) {
if (_pending[id]) { _pending[id](data); delete _pending[id]; }
};
// ── i18n ──────────────────────────────────────────────────────────────────────
const I18N = {
lang: 'en',
strings: {},
fallback: {},
};
function t(key, vars = {}) {
let s = I18N.strings[key] ?? I18N.fallback[key] ?? key;
for (const [k, v] of Object.entries(vars))
s = s.replaceAll(`{${k}}`, String(v));
return s;
}
function tPlural(oneKey, manyKey, n, extra = {}) {
return t(n === 1 ? oneKey : manyKey, { n, ...extra });
}
function localeCandidates(preferred) {
const out = [];
const add = (c) => { if (c && !out.includes(c)) out.push(c); };
for (const raw of preferred || []) {
const tag = String(raw || '').replace(/_/g, '-');
if (!tag) continue;
add(tag);
const parts = tag.split('-');
if (parts[0] === 'zh') {
const script = (parts[1] || '').toLowerCase();
if (script.startsWith('hant') || script === 'tw' || script === 'hk' || script === 'mo')
add('zh-Hant');
else
add('zh-Hans');
add('zh');
} else {
add(parts[0]);
}
}
add('en');
return out;
}
async function fetchLang(code) {
const pack = await call('get_lang_pack', { code });
return (pack && typeof pack === 'object') ? pack : null;
}
async function initI18n() {
// Native injects packs at document start (fetch() is blocked on file://).
if (window.__OMNI_I18N__ && typeof window.__OMNI_I18N__ === 'object') {
const boot = window.__OMNI_I18N__;
I18N.lang = boot.lang || 'en';
I18N.fallback = boot.fallback || {};
I18N.strings = boot.strings || { ...I18N.fallback };
} else {
I18N.fallback = (await fetchLang('en')) || {};
I18N.strings = { ...I18N.fallback };
let preferred = await call('get_preferred_languages');
if (!Array.isArray(preferred)) preferred = ['en'];
for (const code of localeCandidates(preferred)) {
if (code === 'en') {
I18N.lang = 'en';
break;
}
const pack = await fetchLang(code);
if (pack) {
I18N.lang = code;
I18N.strings = { ...I18N.fallback, ...pack };
break;
}
}
}
document.documentElement.lang = I18N.lang;
applyStaticI18n();
}
function applyStaticI18n() {
document.querySelectorAll('[data-i18n]').forEach(el => {
el.textContent = t(el.dataset.i18n);
});
document.querySelectorAll('[data-i18n-title]').forEach(el => {
el.title = t(el.dataset.i18nTitle);
});
document.title = t('app.title');
}
window.omniApplyI18n = function(payload) {
if (!payload || typeof payload !== 'object') return;
window.__OMNI_I18N__ = payload;
I18N.lang = payload.lang || 'en';
I18N.fallback = payload.fallback || {};
I18N.strings = payload.strings || { ...I18N.fallback };
document.documentElement.lang = I18N.lang;
applyStaticI18n();
_remoteFingerprint = '';
if (typeof render === 'function') render();
};
async function confirmDialog(message) {
const result = await call('confirm', {
message,
ok: t('confirm.delete_btn'),
cancel: t('confirm.cancel_btn'),
});
return result === true || result === 'true';
}
// ── State ─────────────────────────────────────────────────────────────────────
const S = {
connected: false, deviceName: '',
storages: [], activeStorageId: 0,
remoteNavStack: [],
localPath: '', localFiles: [],
localDrives: [], activeLocalDrive: '',
remoteFiles: [], remoteRefreshing: false,
transfers: [],
statusMessage: '', statusTimer: 0,
selLocal: new Set(), // keys = file.path strings
selRemote: new Set(), // keys = String(file.handle)
lastLocalIdx: -1, // for shift+click range
statusKey: '', statusArgs: [], statusTimer: 0,
selRemote: new Set(),
lastRemoteIdx: -1,
sortKey: 'name', // name | size | date
sortAsc: true,
};
// ── Smart-render fingerprints ──────────────────────────────────────────────────
// The file lists are only fully rebuilt when the files themselves change.
// Selection and toolbar updates run every poll without touching the table DOM,
// which eliminates the 400 ms hover-flash from innerHTML replacement.
let _localFingerprint = '';
let _remoteFingerprint = '';
function fingerprintFiles(files) {
// Fast hash: join paths/handles — changes on any navigation or refresh
return files.map(f => f.path || f.handle || f.name).join('\0');
return files.map(f => f.path || f.handle || f.name).join('\0')
+ '\0' + I18N.lang + '\0' + S.sortKey + '\0' + (S.sortAsc ? 'a' : 'd');
}
function sortedRemoteFiles() {
const files = (S.remoteFiles || []).slice();
const dir = S.sortAsc ? 1 : -1;
const key = S.sortKey;
files.sort((a, b) => {
if (!!a.isDir !== !!b.isDir) return a.isDir ? -1 : 1;
let cmp = 0;
if (key === 'size') {
cmp = (a.size || 0) - (b.size || 0);
} else if (key === 'date') {
cmp = String(a.date || '').localeCompare(String(b.date || ''));
} else {
cmp = String(a.name || '').localeCompare(String(b.name || ''), undefined, {
sensitivity: 'base', numeric: true,
});
}
if (cmp === 0) {
cmp = String(a.name || '').localeCompare(String(b.name || ''), undefined, {
sensitivity: 'base', numeric: true,
});
}
return cmp * dir;
});
return files;
}
function setSortColumn(key) {
if (S.sortKey === key) S.sortAsc = !S.sortAsc;
else {
S.sortKey = key;
S.sortAsc = key !== 'size'; // size defaults to largest-first feels odd; use ascending
}
_remoteFingerprint = '';
renderRemoteFiles();
}
// ── Polling ───────────────────────────────────────────────────────────────────
async function poll() {
try {
const data = await call('get_state');
if (data) {
const sl = S.selLocal, sr = S.selRemote;
const lli = S.lastLocalIdx, lri = S.lastRemoteIdx;
const sr = S.selRemote;
const lri = S.lastRemoteIdx;
Object.assign(S, data);
S.selLocal = sl; S.selRemote = sr;
S.lastLocalIdx = lli; S.lastRemoteIdx = lri;
S.selRemote = sr;
S.lastRemoteIdx = lri;
render();
}
} catch(e) {}
setTimeout(poll, 400);
}
// ── Rendering ─────────────────────────────────────────────────────────────────
function render() {
renderDeviceBar();
renderLocalToolbar();
renderLocalFiles();
renderRemoteToolbar();
renderRemoteFiles();
renderTransfers();
@@ -77,11 +210,9 @@ function render() {
function updateDropZones() {
const rem = document.getElementById('panel-remote')?.getBoundingClientRect();
const loc = document.getElementById('panel-local')?.getBoundingClientRect();
if (!rem || !loc) return;
if (!rem) return;
call('update_drop_zones', {
remote: {x: rem.left, y: rem.top, w: rem.width, h: rem.height},
local: {x: loc.left, y: loc.top, w: loc.width, h: loc.height},
});
}
@@ -100,18 +231,24 @@ function fileColor(f) {
return 'color-file';
}
// ── File table builder ────────────────────────────────────────────────────────
const FILE_TABLE_HEADER = `<thead><tr>
<th>Name</th><th class="col-size">Size</th><th class="col-date">Modified</th>
</tr></thead>`;
function fileTableHeader() {
const ind = (key) => {
if (S.sortKey !== key) return '<span class="sort-ind"></span>';
return `<span class="sort-ind active">${S.sortAsc ? '▲' : '▼'}</span>`;
};
return `<thead><tr>
<th class="sortable" data-sort="name">${esc(t('col.name'))}${ind('name')}</th>
<th class="col-size sortable" data-sort="size">${esc(t('col.size'))}${ind('size')}</th>
<th class="col-date sortable" data-sort="date">${esc(t('col.modified'))}${ind('date')}</th>
</tr></thead>`;
}
function buildFileRows(files, selSet, idKey, allowDrag = false) {
function buildFileRows(files, selSet, idKey) {
return files.map((f, i) => {
const key = String(f[idKey] != null ? f[idKey] : (f.path || f.name));
const sel = selSet.has(key) ? ' selected' : '';
const cc = fileColor(f);
const drag = allowDrag && !f.isDir ? ' draggable="true"' : '';
return `<tr class="frow${sel}" data-idx="${i}" data-key="${esc(key)}" data-is-dir="${f.isDir ? '1' : '0'}"${drag}>
return `<tr class="frow${sel}" data-idx="${i}" data-key="${esc(key)}" data-is-dir="${f.isDir ? '1' : '0'}">
<td class="col-name"><div class="fname-cell">
<span class="dot ${cc}"></span>
<span class="${cc}">${esc(f.name)}</span>
@@ -122,7 +259,6 @@ function buildFileRows(files, selSet, idKey, allowDrag = false) {
}).join('');
}
// ── Selection helpers ─────────────────────────────────────────────────────────
function applySelectionToDOM(container, selSet) {
container.querySelectorAll('.frow').forEach(r => {
r.classList.toggle('selected', selSet.has(r.dataset.key));
@@ -133,43 +269,28 @@ function selectionKeys(files, idKey) {
return files.map(f => String(f[idKey] != null ? f[idKey] : (f.path || f.name)));
}
// ── Local panel ───────────────────────────────────────────────────────────────
function renderLocalFiles() {
const el = document.getElementById('local-files');
const fp = fingerprintFiles(S.localFiles);
if (fp !== _localFingerprint) {
_localFingerprint = fp;
// Full rebuild only when file list actually changed
if (!S.localFiles.length) {
el.innerHTML = `<div class="empty-state">
<svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="1.5" class="empty-icon">
<path d="M6 38V14a2 2 0 012-2h12l4 4h16a2 2 0 012 2v20a2 2 0 01-2 2H8a2 2 0 01-2-2z"/>
</svg>
<div class="empty-title">No files in this folder</div></div>`;
return;
}
el.innerHTML = `<table class="ftable">${FILE_TABLE_HEADER}
<tbody>${buildFileRows(S.localFiles, S.selLocal, 'path', true)}</tbody></table>`;
attachLocalEvents(el);
} else {
// Cheap path: just sync selection highlight
applySelectionToDOM(el, S.selLocal);
}
}
function attachMarqueeSelect(el, selSet) {
let active = false, startX = 0, startY = 0, box = null, additive = false;
let active = false, startX = 0, startY = 0, box = null, additive = false, moved = false;
const finish = () => {
if (!active) return;
const clearOnClick = !moved && !additive;
active = false;
if (box) { box.remove(); box = null; }
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', finish);
if (clearOnClick) {
selSet.clear();
applySelectionToDOM(el, selSet);
S.lastRemoteIdx = -1;
}
};
const onMove = e => {
if (!active) return;
const dx = e.clientX - startX, dy = e.clientY - startY;
if (!moved && Math.hypot(dx, dy) < 4) return;
moved = true;
window.omniClearSelection();
const x1 = Math.min(startX, e.clientX), y1 = Math.min(startY, e.clientY);
const x2 = Math.max(startX, e.clientX), y2 = Math.max(startY, e.clientY);
@@ -187,9 +308,10 @@ function attachMarqueeSelect(el, selSet) {
};
el.addEventListener('mousedown', e => {
if (e.button !== 0 || e.target.closest('.frow')) return;
if (e.button !== 0 || e.target.closest('.frow') || e.target.closest('thead')) return;
e.preventDefault();
active = true;
moved = false;
additive = e.metaKey || e.shiftKey;
startX = e.clientX;
startY = e.clientY;
@@ -201,68 +323,6 @@ function attachMarqueeSelect(el, selSet) {
});
}
function attachLocalEvents(el) {
const files = S.localFiles;
const keys = selectionKeys(files, 'path');
el.querySelectorAll('.frow').forEach((row, i) => {
const f = files[i];
const key = keys[i];
// Click → select
row.addEventListener('click', e => {
handleRowClick(e, i, key, S.selLocal, keys, 'lastLocalIdx');
applySelectionToDOM(el, S.selLocal);
});
// Double-click → navigate dir OR upload file to Switch
row.addEventListener('dblclick', () => {
if (f.isDir) { call('navigate_local', {path: f.path}); }
else if (S.connected) { call('start_upload', {srcPath: f.path}); }
});
// Right-click → local context menu
row.addEventListener('contextmenu', e => {
e.preventDefault();
showLocalCtxMenu(f, e);
});
// Drag source: drag selected local file(s) to the remote panel
if (!f.isDir) {
row.addEventListener('dragstart', e => {
const dragFiles = (S.selLocal.has(key)
? files.filter((_, j) => S.selLocal.has(keys[j]))
: [f]).filter(df => !df.isDir);
if (!dragFiles.length) { e.preventDefault(); return; }
window.omniClearSelection();
e.dataTransfer.clearData();
e.dataTransfer.setData('application/x-omnimtp-local',
JSON.stringify(dragFiles.map(df => ({path: df.path, name: df.name}))));
e.dataTransfer.effectAllowed = 'copy';
e.dataTransfer.setDragImage(dragBadge(dragFiles.length, dragFiles[0].name), 0, 0);
});
}
});
attachMarqueeSelect(el, S.selLocal);
// Drop target: receive remote files dropped here → download
el.addEventListener('dragover', e => { e.preventDefault(); el.classList.add('drag-over'); });
el.addEventListener('dragleave', e => { if (!el.contains(e.relatedTarget)) el.classList.remove('drag-over'); });
el.addEventListener('drop', e => {
e.preventDefault();
el.classList.remove('drag-over');
const d = e.dataTransfer.getData('application/x-omnimtp-remote');
if (d) {
const items = JSON.parse(d);
(Array.isArray(items) ? items : [items]).forEach(rf => {
call('start_download', {handle: rf.handle, storageId: rf.storageId, filename: rf.name, size: rf.size});
});
}
});
}
// ── Remote panel ──────────────────────────────────────────────────────────────
function renderRemoteFiles() {
const el = document.getElementById('remote-files');
const empty = document.getElementById('remote-empty-state');
@@ -277,8 +337,8 @@ function renderRemoteFiles() {
if (empty) empty.style.display = 'none';
if (S.remoteRefreshing) {
el.innerHTML = '<div class="loading">Loading…</div>';
_remoteFingerprint = '__loading__';
el.innerHTML = `<div class="loading">${esc(t('loading'))}</div>`;
_remoteFingerprint = '__loading__' + I18N.lang;
return;
}
@@ -290,10 +350,11 @@ function renderRemoteFiles() {
<svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="1.5" class="empty-icon">
<path d="M6 38V14a2 2 0 012-2h12l4 4h16a2 2 0 012 2v20a2 2 0 01-2 2H8a2 2 0 01-2-2z"/>
</svg>
<div class="empty-title">Empty folder</div></div>`;
<div class="empty-title">${esc(t('empty.folder'))}</div></div>`;
} else {
el.innerHTML = `<table class="ftable">${FILE_TABLE_HEADER}
<tbody>${buildFileRows(S.remoteFiles, S.selRemote, 'handle', false)}</tbody></table>`;
const files = sortedRemoteFiles();
el.innerHTML = `<table class="ftable">${fileTableHeader()}
<tbody>${buildFileRows(files, S.selRemote, 'handle')}</tbody></table>`;
}
attachRemoteEvents(el);
} else {
@@ -302,9 +363,17 @@ function renderRemoteFiles() {
}
function attachRemoteEvents(el) {
const files = S.remoteFiles;
const files = sortedRemoteFiles();
const keys = selectionKeys(files, 'handle');
el.querySelectorAll('th.sortable').forEach(th => {
th.addEventListener('click', e => {
e.preventDefault();
e.stopPropagation();
setSortColumn(th.dataset.sort);
});
});
el.querySelectorAll('.frow').forEach((row, i) => {
const f = files[i];
const key = keys[i];
@@ -323,22 +392,11 @@ function attachRemoteEvents(el) {
attachMarqueeSelect(el, S.selRemote);
// Drop target: receive local files → upload
el.addEventListener('dragover', e => { e.preventDefault(); el.classList.add('drag-over'); });
el.addEventListener('dragleave', e => { if (!el.contains(e.relatedTarget)) el.classList.remove('drag-over'); });
el.addEventListener('drop', e => {
e.preventDefault();
el.classList.remove('drag-over');
// Internal drag (local app files)
const d = e.dataTransfer.getData('application/x-omnimtp-local');
if (d) {
const items = JSON.parse(d);
(Array.isArray(items) ? items : [items]).forEach(lf => {
call('start_upload', {srcPath: lf.path});
});
return;
}
// Finder drop
for (const file of (e.dataTransfer.files || [])) {
const p = file.path || '';
if (p) call('start_upload', {srcPath: p});
@@ -346,7 +404,6 @@ function attachRemoteEvents(el) {
});
}
// ── Shared row click with shift-range support ─────────────────────────────────
function handleRowClick(e, idx, key, selSet, allKeys, lastIdxProp) {
if (e.shiftKey && S[lastIdxProp] >= 0) {
const lo = Math.min(idx, S[lastIdxProp]);
@@ -363,53 +420,6 @@ function handleRowClick(e, idx, key, selSet, allKeys, lastIdxProp) {
}
}
// ── Drag badge ghost image ─────────────────────────────────────────────────────
function dragBadge(count, firstName) {
const el = document.createElement('div');
el.style.cssText = [
'position:fixed', 'top:-200px', 'left:-200px',
'background:rgba(10,132,255,0.85)', 'color:#fff',
'font:600 12px -apple-system', 'padding:4px 10px',
'border-radius:10px', 'white-space:nowrap',
].join(';');
el.textContent = count > 1 ? `${count} files` : firstName;
document.body.appendChild(el);
setTimeout(() => el.remove(), 0);
return el;
}
// ── Toolbars ──────────────────────────────────────────────────────────────────
function renderLocalToolbar() {
const sel = document.getElementById('local-drive-sel');
if (S.localDrives && S.localDrives.length) {
const prev = sel.value;
sel.innerHTML = S.localDrives.map(d =>
`<option value="${esc(d.path)}">${esc(d.name)}</option>`
).join('');
const active = S.activeLocalDrive || prev;
if ([...sel.options].some(o => o.value === active)) sel.value = active;
sel.style.display = '';
} else {
sel.style.display = 'none';
}
const crumb = document.getElementById('local-breadcrumb');
const parts = (S.localPath || '').split('/').filter(Boolean);
const show = parts.slice(-4);
let html = show.length < parts.length ? '<span class="crumb-ellipsis">…</span><span class="crumb-sep"></span>' : '';
show.forEach((p, i) => {
const fullIdx = parts.length - show.length + i;
const path = '/' + parts.slice(0, fullIdx + 1).join('/');
html += i === show.length - 1
? `<span class="crumb-cur">${esc(p)}</span>`
: `<span class="crumb-link" data-path="${esc(path)}">${esc(p)}</span><span class="crumb-sep"></span>`;
});
crumb.innerHTML = html;
crumb.querySelectorAll('.crumb-link').forEach(el =>
el.addEventListener('click', () => call('navigate_local', {path: el.dataset.path})));
document.getElementById('btn-local-up').disabled = parts.length === 0;
}
function renderRemoteToolbar() {
const sel = document.getElementById('storage-sel');
if (S.storages && S.storages.length) {
@@ -435,22 +445,21 @@ function renderRemoteToolbar() {
document.getElementById('btn-remote-up').disabled = stack.length <= 1;
}
// ── Device bar ────────────────────────────────────────────────────────────────
function renderDeviceBar() {
const badge = document.getElementById('conn-badge');
const status = document.getElementById('device-status-text');
if (S.connected) {
const name = S.deviceName || t('device.nintendo_switch');
badge.className = 'badge connected';
badge.textContent = S.deviceName || 'Nintendo Switch';
status.textContent = S.deviceName || 'Nintendo Switch connected';
badge.textContent = name;
status.textContent = t('device.connected', { name });
} else {
badge.className = 'badge disconnected';
badge.textContent = 'No Device';
status.textContent = 'Waiting for Nintendo Switch…';
badge.textContent = t('badge.no_device');
status.textContent = t('device.waiting');
}
}
// ── Transfers panel ───────────────────────────────────────────────────────────
function renderTransfers() {
const list = document.getElementById('transfers-list');
const label = document.getElementById('transfers-label');
@@ -458,87 +467,87 @@ function renderTransfers() {
const panel = document.getElementById('transfers-panel');
const active = (S.transfers || []).filter(t => t.state <= 1);
label.textContent = active.length ? `${active.length} Transfer${active.length > 1 ? 's' : ''}` : 'Transfers';
label.textContent = active.length
? tPlural('transfers.count', 'transfers.count_plural', active.length)
: t('transfers');
cancelAll.style.display = active.length ? '' : 'none';
cancelAll.textContent = t('btn.cancel_all');
if (!S.transfers || !S.transfers.length) { list.innerHTML = ''; panel.style.display = 'none'; return; }
panel.style.display = '';
const stateNames = ['Queued','','Done','Failed','Cancelled'];
list.innerHTML = S.transfers.map(t => {
const dir = t.direction === 0 ? 'DL' : 'UP';
const dc = t.direction === 0 ? 'dir-dl' : 'dir-ul';
const pct = ((t.progress || 0) * 100).toFixed(0);
const showBar = t.state <= 1;
const statText = t.state === 1 ? esc(t.speedStr)
: t.state === 3 ? '⚠ ' + esc(t.error || 'Failed')
: stateNames[t.state] || '';
const stateNames = {
0: t('transfer.queued'),
2: t('transfer.done'),
3: t('transfer.failed'),
4: t('transfer.cancelled'),
};
list.innerHTML = S.transfers.map(tx => {
const dir = tx.direction === 0 ? t('transfer.dl') : t('transfer.up');
const dc = tx.direction === 0 ? 'dir-dl' : 'dir-ul';
const pct = ((tx.progress || 0) * 100).toFixed(0);
const showBar = tx.state <= 1;
const statText = tx.state === 1 ? esc(tx.speedStr)
: tx.state === 3 ? '⚠ ' + esc(tx.error || t('transfer.failed'))
: (stateNames[tx.state] || '');
return `<div class="txfer-item">
<div class="txfer-row">
<span class="txfer-dir ${dc}">${dir}</span>
<span class="txfer-name" title="${esc(t.filename)}">${esc(t.filename)}</span>
<span class="txfer-dir ${dc}">${esc(dir)}</span>
<span class="txfer-name" title="${esc(tx.filename)}">${esc(tx.filename)}</span>
<span class="txfer-stat">${statText}</span>
${showBar ? `<button class="txfer-cancel" data-id="${esc(t.id)}">×</button>` : ''}
${showBar ? `<button class="txfer-cancel" data-id="${esc(tx.id)}">×</button>` : ''}
</div>
${showBar ? `<div class="pbar"><div class="pbar-fill" style="width:${pct}%"></div></div>
<div class="txfer-meta">${pct}%${t.etaStr ? ' · ' + t.etaStr : ''}</div>` : ''}
<div class="txfer-meta">${pct}%${tx.etaStr ? ' · ' + tx.etaStr : ''}</div>` : ''}
</div>`;
}).join('');
list.querySelectorAll('.txfer-cancel').forEach(btn =>
btn.addEventListener('click', () => call('cancel_transfer', {id: btn.dataset.id})));
}
// ── Status bar ────────────────────────────────────────────────────────────────
function statusArgsMap(args) {
const a = args || [];
return {
name: a[0] || '',
error: a[0] || '',
label: a[0] || '',
n: a[0] || '',
};
}
function renderStatusBar() {
const lf = (S.localFiles || []).length;
const rf = (S.remoteFiles || []).length;
document.getElementById('sb-left').textContent = `${lf} item${lf !== 1 ? 's' : ''}`;
document.getElementById('sb-right').textContent = S.connected ? `${rf} item${rf !== 1 ? 's' : ''}` : '';
document.getElementById('sb-left').textContent = S.connected
? tPlural('items.count', 'items.count_plural', rf)
: '';
document.getElementById('sb-right').textContent = '';
let center = '';
if (S.statusTimer > 0 && S.statusMessage) {
center = S.statusMessage;
const skipStatus = S.statusKey === 'status.connected' || S.statusKey === 'status.disconnected';
if (S.statusTimer > 0 && S.statusKey && !skipStatus) {
center = t(S.statusKey, statusArgsMap(S.statusArgs));
} else if (S.connected) {
const act = (S.transfers || []).filter(t => t.state === 1);
const act = (S.transfers || []).filter(tx => tx.state === 1);
if (act.length) {
const spd = act.reduce((s, t) => s + (t.speedBps || 0), 0);
center = `${act.length} active · ` + (spd > 1048576
const spd = act.reduce((s, tx) => s + (tx.speedBps || 0), 0);
const speed = spd > 1048576
? (spd/1048576).toFixed(0) + ' MB/s'
: (spd/1024).toFixed(0) + ' KB/s');
: (spd/1024).toFixed(0) + ' KB/s';
center = t('status.active', { n: act.length, speed });
}
}
document.getElementById('sb-center').textContent = center;
}
// ── Context menus ─────────────────────────────────────────────────────────────
let _ctxFile = null, _ctxIsLocal = false;
function showLocalCtxMenu(file, e) {
_ctxFile = file;
_ctxIsLocal = true;
const m = document.getElementById('ctx-menu');
document.getElementById('ctx-upload').style.display = (!file.isDir && S.connected) ? '' : 'none';
document.getElementById('ctx-download').style.display = 'none';
document.getElementById('ctx-delete').style.display = 'none';
document.getElementById('ctx-sep-del').style.display = 'none';
m.style.left = e.clientX + 'px';
m.style.top = e.clientY + 'px';
m.classList.remove('hidden');
}
let _ctxFile = null;
function showRemoteCtxMenu(file, e) {
_ctxFile = file;
_ctxIsLocal = false;
_ctxFile = file;
if (!S.selRemote.has(String(file.handle))) {
S.selRemote.clear();
S.selRemote.add(String(file.handle));
applySelectionToDOM(document.getElementById('remote-files'), S.selRemote);
}
const m = document.getElementById('ctx-menu');
const showDownload = !file.isDir;
document.getElementById('ctx-upload').style.display = 'none';
document.getElementById('ctx-download').style.display = showDownload ? '' : 'none';
document.getElementById('ctx-delete').style.display = '';
document.getElementById('ctx-sep-del').style.display = showDownload ? '' : 'none';
m.style.left = e.clientX + 'px';
m.style.top = e.clientY + 'px';
m.classList.remove('hidden');
@@ -550,58 +559,50 @@ document.addEventListener('click', e => {
});
document.addEventListener('contextmenu', e => { if (!e.target.closest('.frow')) { e.preventDefault(); document.getElementById('ctx-menu').classList.add('hidden'); } });
// ── Init ──────────────────────────────────────────────────────────────────────
window.omniClearSelection = function() {
const sel = window.getSelection();
if (sel && sel.rangeCount) sel.removeAllRanges();
};
window.omniRemoteDragFilesAt = function(x, y) {
if (!S.connected) return '';
const hit = document.elementFromPoint(x, y);
const row = hit?.closest('#remote-files .frow');
if (!row || row.dataset.isDir === '1') return '';
const f = S.remoteFiles[+row.dataset.idx];
if (!f || f.isDir) return '';
function exportDragFilesForRow(row) {
if (!S.connected || !row) return [];
const files = sortedRemoteFiles();
const f = files[+row.dataset.idx];
if (!f) return [];
const key = String(f.handle);
const dragFiles = S.selRemote.has(key)
? S.remoteFiles.filter(rf => S.selRemote.has(String(rf.handle)) && !rf.isDir)
? files.filter(rf => S.selRemote.has(String(rf.handle)))
: [f];
if (!dragFiles.length) return '';
return JSON.stringify(dragFiles.map(df => ({
handle: df.handle, storageId: df.storageId, name: df.name, size: df.size,
})));
};
return dragFiles.map(df => ({
handle: df.handle,
storageId: df.storageId,
name: df.name,
size: df.size || 0,
isDir: !!df.isDir,
}));
}
// Block text selection and text-drag; only explicit file drags allowed.
document.addEventListener('selectstart', e => {
if (e.target.closest('.file-list')) e.preventDefault();
}, true);
document.addEventListener('mousedown', e => {
if (e.button !== 0) return;
if (e.target.closest('.file-list .frow')) e.preventDefault();
const row = e.target.closest('#remote-files .frow');
if (!row) return;
e.preventDefault();
const files = exportDragFilesForRow(row);
if (!files.length) return;
call('prepare_export_drag', { files, x: e.clientX, y: e.clientY });
}, true);
document.addEventListener('dragstart', e => {
const row = e.target.closest('.file-list .frow');
if (!row) return;
if (row.closest('#remote-files') || row.getAttribute('draggable') !== 'true') {
e.preventDefault();
return;
}
window.omniClearSelection();
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'copy';
e.dataTransfer.setData('text/plain', '');
}
if (e.target.closest('.file-list .frow')) e.preventDefault();
}, true);
document.addEventListener('DOMContentLoaded', () => {
document.getElementById('btn-local-up') .addEventListener('click', () => call('navigate_local_up'));
document.getElementById('btn-local-home') .addEventListener('click', () => call('navigate_local', {path: '~'}));
document.getElementById('btn-local-refresh').addEventListener('click', () => call('refresh_local'));
document.getElementById('local-drive-sel') .addEventListener('change', e => call('navigate_local', {path: e.target.value}));
document.addEventListener('DOMContentLoaded', async () => {
await initI18n();
document.getElementById('btn-remote-up') .addEventListener('click', () => call('navigate_remote_up'));
document.getElementById('btn-remote-refresh').addEventListener('click', () => call('refresh_remote'));
document.getElementById('btn-scan') .addEventListener('click', () => call('reconnect'));
@@ -609,7 +610,6 @@ document.addEventListener('DOMContentLoaded', () => {
document.getElementById('btn-cancel-all') .addEventListener('click', () => call('cancel_all'));
document.getElementById('storage-sel') .addEventListener('change', e => call('set_storage', {storageId: +e.target.value}));
// Context menu actions
const bindCtx = (id, fn) => {
document.getElementById(id).addEventListener('click', e => {
e.stopPropagation();
@@ -617,27 +617,14 @@ document.addEventListener('DOMContentLoaded', () => {
});
};
bindCtx('ctx-upload', () => {
bindCtx('ctx-delete', async () => {
if (!_ctxFile) return;
const uploadFiles = S.selLocal.size > 1 && S.selLocal.has(_ctxFile.path)
? S.localFiles.filter(f => S.selLocal.has(f.path))
: [_ctxFile];
uploadFiles.forEach(f => call('start_upload', {srcPath: f.path}));
});
bindCtx('ctx-download', () => {
if (!_ctxFile) return;
const downloadFiles = S.selRemote.size > 1 && S.selRemote.has(String(_ctxFile.handle))
? S.remoteFiles.filter(f => S.selRemote.has(String(f.handle)))
: [_ctxFile];
downloadFiles.forEach(f => call('start_download', {handle: f.handle, storageId: f.storageId, filename: f.name, size: f.size}));
});
bindCtx('ctx-delete', () => {
if (!_ctxFile) return;
const label = S.selRemote.size > 1 && S.selRemote.has(String(_ctxFile.handle))
? `${S.selRemote.size} items`
: `"${_ctxFile.name}"`;
if (window.confirm(`Delete ${label}?`)) {
const handles = S.selRemote.size > 1 && S.selRemote.has(String(_ctxFile.handle))
const multi = S.selRemote.size > 1 && S.selRemote.has(String(_ctxFile.handle));
const message = multi
? t('confirm.delete_items', { n: S.selRemote.size })
: t('confirm.delete', { label: `"${_ctxFile.name}"` });
if (await confirmDialog(message)) {
const handles = multi
? S.remoteFiles.filter(f => S.selRemote.has(String(f.handle))).map(f => f.handle)
: [_ctxFile.handle];
handles.forEach(h => call('delete_remote', {handle: h}));
@@ -645,7 +632,6 @@ document.addEventListener('DOMContentLoaded', () => {
document.getElementById('ctx-menu').classList.add('hidden');
});
// Keyboard
document.addEventListener('keydown', e => {
if (e.target.matches('input,textarea,select')) return;
if (e.key === 'Backspace' || e.key === 'Delete') {
@@ -654,24 +640,14 @@ document.addEventListener('DOMContentLoaded', () => {
if (e.metaKey && e.key === 'r') {
e.preventDefault();
call('refresh_remote');
call('refresh_local');
}
// Cmd+A = select all
if (e.metaKey && e.key === 'a') {
e.preventDefault();
const active = document.activeElement;
if (active && active.closest('#local-files')) {
S.localFiles.forEach(f => S.selLocal.add(f.path));
applySelectionToDOM(document.getElementById('local-files'), S.selLocal);
} else {
S.remoteFiles.forEach(f => S.selRemote.add(String(f.handle)));
applySelectionToDOM(document.getElementById('remote-files'), S.selRemote);
}
S.remoteFiles.forEach(f => S.selRemote.add(String(f.handle)));
applySelectionToDOM(document.getElementById('remote-files'), S.selRemote);
}
// Escape = deselect
if (e.key === 'Escape') {
S.selLocal.clear(); S.selRemote.clear();
applySelectionToDOM(document.getElementById('local-files'), S.selLocal);
S.selRemote.clear();
applySelectionToDOM(document.getElementById('remote-files'), S.selRemote);
}
});

View File

@@ -9,57 +9,31 @@
<body>
<div id="titlebar">
<div class="tl-spacer"></div>
<span id="app-title">OmniMTP</span>
<div id="conn-badge" class="badge disconnected">No Device</div>
<span id="app-title" data-i18n="app.title">OmniMTP</span>
<div id="conn-badge" class="badge disconnected" data-i18n="badge.no_device">No Device</div>
</div>
<div id="device-bar">
<div class="device-left">
<button class="action-btn" id="btn-open-picker">Add Files</button>
<button class="action-btn" id="btn-open-picker" data-i18n="btn.add_files">Add Files</button>
</div>
<div class="device-center" id="device-status-text">Waiting for Nintendo Switch…</div>
<div class="device-center" id="device-status-text" data-i18n="device.waiting">Waiting for Nintendo Switch…</div>
<div class="device-right">
<button class="action-btn primary" id="btn-scan">Scan</button>
<button class="action-btn primary" id="btn-scan" data-i18n="btn.scan">Scan</button>
</div>
</div>
<div id="main">
<!-- Left: Local -->
<div class="panel" id="panel-local">
<div class="panel-toolbar">
<div class="nav-group">
<button class="nav-btn" id="btn-local-up" title="Go Up">
<svg viewBox="0 0 16 16"><path d="M8 3L3 8h3v5h4V8h3L8 3z" fill="currentColor"/></svg>
</button>
<button class="nav-btn" id="btn-local-home" title="Home">
<svg viewBox="0 0 16 16"><path d="M8 2L2 7h2v6h4v-4h4v4h2V7l2 0-6-5z" fill="currentColor"/></svg>
</button>
</div>
<select id="local-drive-sel" class="storage-select" title="Drive"></select>
<div id="local-breadcrumb" class="breadcrumb"></div>
<button class="nav-btn icon-only" id="btn-local-refresh" title="Refresh">
<svg viewBox="0 0 16 16"><path d="M13.5 8A5.5 5.5 0 1 1 8 2.5V1l3 2.5L8 6V4.5A3.5 3.5 0 1 0 11.5 8h2z" fill="currentColor"/></svg>
</button>
</div>
<div class="file-list" id="local-files">
<div class="loading">Loading…</div>
</div>
</div>
<!-- Divider -->
<div id="panel-divider"></div>
<!-- Right: Switch -->
<div class="panel" id="panel-remote">
<div class="panel-toolbar">
<div class="nav-group">
<button class="nav-btn" id="btn-remote-up" title="Go Up" disabled>
<button class="nav-btn" id="btn-remote-up" data-i18n-title="btn.go_up" title="Go Up" disabled>
<svg viewBox="0 0 16 16"><path d="M8 3L3 8h3v5h4V8h3L8 3z" fill="currentColor"/></svg>
</button>
</div>
<select id="storage-sel" class="storage-select"></select>
<div id="remote-breadcrumb" class="breadcrumb"></div>
<button class="nav-btn icon-only" id="btn-remote-refresh" title="Refresh">
<button class="nav-btn icon-only" id="btn-remote-refresh" data-i18n-title="btn.refresh" title="Refresh">
<svg viewBox="0 0 16 16"><path d="M13.5 8A5.5 5.5 0 1 1 8 2.5V1l3 2.5L8 6V4.5A3.5 3.5 0 1 0 11.5 8h2z" fill="currentColor"/></svg>
</button>
</div>
@@ -70,8 +44,8 @@
<line x1="24" y1="12" x2="24" y2="18"/>
<circle cx="24" cy="22" r="1.5" fill="currentColor"/>
</svg>
<div class="empty-title">No Switch Connected</div>
<div class="empty-sub">Connect via USB-C and open DBI or Sphaira MTP</div>
<div class="empty-title" data-i18n="empty.no_switch_title">No Switch Connected</div>
<div class="empty-sub" data-i18n="empty.no_switch_sub">Connect via USB-C and open DBI or Sphaira MTP</div>
</div>
</div>
</div>
@@ -80,8 +54,8 @@
<!-- Transfers -->
<div id="transfers-panel">
<div id="transfers-header">
<span id="transfers-label">Transfers</span>
<button class="action-btn danger small" id="btn-cancel-all" style="display:none">Cancel All</button>
<span id="transfers-label" data-i18n="transfers">Transfers</span>
<button class="action-btn danger small" id="btn-cancel-all" data-i18n="btn.cancel_all" style="display:none">Cancel All</button>
</div>
<div id="transfers-list"></div>
</div>
@@ -95,10 +69,7 @@
<!-- Context menu -->
<div id="ctx-menu" class="ctx-menu hidden">
<div class="ctx-item" id="ctx-upload">Upload to Switch</div>
<div class="ctx-item" id="ctx-download">Download to Mac</div>
<div class="ctx-item ctx-sep" id="ctx-sep-del"></div>
<div class="ctx-item danger" id="ctx-delete">Delete…</div>
<div class="ctx-item danger" id="ctx-delete" data-i18n="ctx.delete">Delete…</div>
</div>
<script src="app.js"></script>

View File

@@ -0,0 +1,50 @@
{
"app.title": "OmniMTP",
"badge.no_device": "Kein Gerät",
"device.waiting": "Warte auf Nintendo Switch…",
"device.connected": "{name} verbunden",
"device.nintendo_switch": "Nintendo Switch",
"btn.add_files": "Dateien hinzufügen",
"btn.scan": "Suchen",
"btn.cancel_all": "Alle abbrechen",
"btn.go_up": "Nach oben",
"btn.refresh": "Aktualisieren",
"empty.no_switch_title": "Keine Switch verbunden",
"empty.no_switch_sub": "Per USB-C verbinden und DBI oder Sphaira MTP öffnen",
"empty.folder": "Leerer Ordner",
"loading": "Laden…",
"col.name": "Name",
"col.size": "Größe",
"col.modified": "Geändert",
"transfers": "Übertragungen",
"transfers.count": "{n} Übertragung",
"transfers.count_plural": "{n} Übertragungen",
"transfer.queued": "Warteschlange",
"transfer.done": "Fertig",
"transfer.failed": "Fehlgeschlagen",
"transfer.cancelled": "Abgebrochen",
"transfer.dl": "DL",
"transfer.up": "UP",
"ctx.delete": "Löschen…",
"confirm.delete": "{label} löschen?",
"confirm.delete_items": "{n} Elemente löschen?",
"confirm.delete_btn": "Löschen",
"confirm.cancel_btn": "Abbrechen",
"confirm.ok_btn": "OK",
"items.count": "{n} Element",
"items.count_plural": "{n} Elemente",
"status.active": "{n} aktiv · {speed}",
"status.connected": "Verbunden {name}",
"status.disconnected": "Switch getrennt",
"status.listing_error": "Fehler beim Auflisten: {error}",
"status.downloading": "Herunterladen {name}",
"status.uploading": "Hochladen {name}",
"menu.options": "Optionen",
"menu.language": "Sprache",
"menu.language_system": "Systemstandard",
"menu.quit": "OmniMTP beenden",
"menu.about": "Über OmniMTP",
"about.tagline": "Nintendo Switch MTP-Client für macOS",
"about.version": "Version {version}",
"about.copyright": "Copyright © 2026 NiklasCFW"
}

View File

@@ -0,0 +1,50 @@
{
"app.title": "OmniMTP",
"badge.no_device": "No Device",
"device.waiting": "Waiting for Nintendo Switch…",
"device.connected": "{name} connected",
"device.nintendo_switch": "Nintendo Switch",
"btn.add_files": "Add Files",
"btn.scan": "Scan",
"btn.cancel_all": "Cancel All",
"btn.go_up": "Go Up",
"btn.refresh": "Refresh",
"empty.no_switch_title": "No Switch Connected",
"empty.no_switch_sub": "Connect via USB-C and open DBI or Sphaira MTP",
"empty.folder": "Empty folder",
"loading": "Loading…",
"col.name": "Name",
"col.size": "Size",
"col.modified": "Modified",
"transfers": "Transfers",
"transfers.count": "{n} Transfer",
"transfers.count_plural": "{n} Transfers",
"transfer.queued": "Queued",
"transfer.done": "Done",
"transfer.failed": "Failed",
"transfer.cancelled": "Cancelled",
"transfer.dl": "DL",
"transfer.up": "UP",
"ctx.delete": "Delete…",
"confirm.delete": "Delete {label}?",
"confirm.delete_items": "Delete {n} items?",
"confirm.delete_btn": "Delete",
"confirm.cancel_btn": "Cancel",
"confirm.ok_btn": "OK",
"items.count": "{n} item",
"items.count_plural": "{n} items",
"status.active": "{n} active · {speed}",
"status.connected": "Connected {name}",
"status.disconnected": "Switch disconnected",
"status.listing_error": "Error listing: {error}",
"status.downloading": "Downloading {name}",
"status.uploading": "Uploading {name}",
"menu.options": "Options",
"menu.language": "Language",
"menu.language_system": "System Default",
"menu.quit": "Quit OmniMTP",
"menu.about": "About OmniMTP",
"about.tagline": "Nintendo Switch MTP Client for macOS",
"about.version": "Version {version}",
"about.copyright": "Copyright © 2026 NiklasCFW"
}

View File

@@ -0,0 +1,50 @@
{
"app.title": "OmniMTP",
"badge.no_device": "Sin dispositivo",
"device.waiting": "Esperando Nintendo Switch…",
"device.connected": "{name} conectada",
"device.nintendo_switch": "Nintendo Switch",
"btn.add_files": "Añadir archivos",
"btn.scan": "Buscar",
"btn.cancel_all": "Cancelar todo",
"btn.go_up": "Subir",
"btn.refresh": "Actualizar",
"empty.no_switch_title": "Ninguna Switch conectada",
"empty.no_switch_sub": "Conecta por USB-C y abre DBI o Sphaira MTP",
"empty.folder": "Carpeta vacía",
"loading": "Cargando…",
"col.name": "Nombre",
"col.size": "Tamaño",
"col.modified": "Modificado",
"transfers": "Transferencias",
"transfers.count": "{n} transferencia",
"transfers.count_plural": "{n} transferencias",
"transfer.queued": "En cola",
"transfer.done": "Hecho",
"transfer.failed": "Error",
"transfer.cancelled": "Cancelado",
"transfer.dl": "DL",
"transfer.up": "UP",
"ctx.delete": "Eliminar…",
"confirm.delete": "¿Eliminar {label}?",
"confirm.delete_items": "¿Eliminar {n} elementos?",
"confirm.delete_btn": "Eliminar",
"confirm.cancel_btn": "Cancelar",
"confirm.ok_btn": "OK",
"items.count": "{n} elemento",
"items.count_plural": "{n} elementos",
"status.active": "{n} activo · {speed}",
"status.connected": "Conectado {name}",
"status.disconnected": "Switch desconectada",
"status.listing_error": "Error al listar: {error}",
"status.downloading": "Descargando {name}",
"status.uploading": "Subiendo {name}",
"menu.options": "Opciones",
"menu.language": "Idioma",
"menu.language_system": "Idioma del sistema",
"menu.quit": "Salir de OmniMTP",
"menu.about": "Acerca de OmniMTP",
"about.tagline": "Cliente MTP de Nintendo Switch para macOS",
"about.version": "Versión {version}",
"about.copyright": "Copyright © 2026 NiklasCFW"
}

View File

@@ -0,0 +1,50 @@
{
"app.title": "OmniMTP",
"badge.no_device": "Aucun appareil",
"device.waiting": "En attente de la Nintendo Switch…",
"device.connected": "{name} connectée",
"device.nintendo_switch": "Nintendo Switch",
"btn.add_files": "Ajouter des fichiers",
"btn.scan": "Rechercher",
"btn.cancel_all": "Tout annuler",
"btn.go_up": "Remonter",
"btn.refresh": "Actualiser",
"empty.no_switch_title": "Aucune Switch connectée",
"empty.no_switch_sub": "Connectez en USB-C et ouvrez DBI ou Sphaira MTP",
"empty.folder": "Dossier vide",
"loading": "Chargement…",
"col.name": "Nom",
"col.size": "Taille",
"col.modified": "Modifié",
"transfers": "Transferts",
"transfers.count": "{n} transfert",
"transfers.count_plural": "{n} transferts",
"transfer.queued": "En file",
"transfer.done": "Terminé",
"transfer.failed": "Échoué",
"transfer.cancelled": "Annulé",
"transfer.dl": "DL",
"transfer.up": "UP",
"ctx.delete": "Supprimer…",
"confirm.delete": "Supprimer {label} ?",
"confirm.delete_items": "Supprimer {n} éléments ?",
"confirm.delete_btn": "Supprimer",
"confirm.cancel_btn": "Annuler",
"confirm.ok_btn": "OK",
"items.count": "{n} élément",
"items.count_plural": "{n} éléments",
"status.active": "{n} actif · {speed}",
"status.connected": "Connecté {name}",
"status.disconnected": "Switch déconnectée",
"status.listing_error": "Erreur de listage : {error}",
"status.downloading": "Téléchargement {name}",
"status.uploading": "Envoi {name}",
"menu.options": "Options",
"menu.language": "Langue",
"menu.language_system": "Langue du système",
"menu.quit": "Quitter OmniMTP",
"menu.about": "À propos dOmniMTP",
"about.tagline": "Client MTP Nintendo Switch pour macOS",
"about.version": "Version {version}",
"about.copyright": "Copyright © 2026 NiklasCFW"
}

View File

@@ -0,0 +1,50 @@
{
"app.title": "OmniMTP",
"badge.no_device": "Nessun dispositivo",
"device.waiting": "In attesa della Nintendo Switch…",
"device.connected": "{name} connessa",
"device.nintendo_switch": "Nintendo Switch",
"btn.add_files": "Aggiungi file",
"btn.scan": "Cerca",
"btn.cancel_all": "Annulla tutto",
"btn.go_up": "Su",
"btn.refresh": "Aggiorna",
"empty.no_switch_title": "Nessuna Switch collegata",
"empty.no_switch_sub": "Collega via USB-C e apri DBI o Sphaira MTP",
"empty.folder": "Cartella vuota",
"loading": "Caricamento…",
"col.name": "Nome",
"col.size": "Dimensione",
"col.modified": "Modificato",
"transfers": "Trasferimenti",
"transfers.count": "{n} trasferimento",
"transfers.count_plural": "{n} trasferimenti",
"transfer.queued": "In coda",
"transfer.done": "Completato",
"transfer.failed": "Non riuscito",
"transfer.cancelled": "Annullato",
"transfer.dl": "DL",
"transfer.up": "UP",
"ctx.delete": "Elimina…",
"confirm.delete": "Eliminare {label}?",
"confirm.delete_items": "Eliminare {n} elementi?",
"confirm.delete_btn": "Elimina",
"confirm.cancel_btn": "Annulla",
"confirm.ok_btn": "OK",
"items.count": "{n} elemento",
"items.count_plural": "{n} elementi",
"status.active": "{n} attivi · {speed}",
"status.connected": "Connesso {name}",
"status.disconnected": "Switch scollegata",
"status.listing_error": "Errore elenco: {error}",
"status.downloading": "Download {name}",
"status.uploading": "Upload {name}",
"menu.options": "Opzioni",
"menu.language": "Lingua",
"menu.language_system": "Predefinita di sistema",
"menu.quit": "Esci da OmniMTP",
"menu.about": "Informazioni su OmniMTP",
"about.tagline": "Client MTP Nintendo Switch per macOS",
"about.version": "Versione {version}",
"about.copyright": "Copyright © 2026 NiklasCFW"
}

View File

@@ -0,0 +1,50 @@
{
"app.title": "OmniMTP",
"badge.no_device": "デバイスなし",
"device.waiting": "Nintendo Switchを待機中…",
"device.connected": "{name} 接続中",
"device.nintendo_switch": "Nintendo Switch",
"btn.add_files": "ファイルを追加",
"btn.scan": "スキャン",
"btn.cancel_all": "すべてキャンセル",
"btn.go_up": "上へ",
"btn.refresh": "更新",
"empty.no_switch_title": "Switch未接続",
"empty.no_switch_sub": "USB-Cで接続し、DBIまたはSphaira MTPを開いてください",
"empty.folder": "空のフォルダ",
"loading": "読み込み中…",
"col.name": "名前",
"col.size": "サイズ",
"col.modified": "更新日時",
"transfers": "転送",
"transfers.count": "{n} 件の転送",
"transfers.count_plural": "{n} 件の転送",
"transfer.queued": "待機中",
"transfer.done": "完了",
"transfer.failed": "失敗",
"transfer.cancelled": "キャンセル",
"transfer.dl": "DL",
"transfer.up": "UP",
"ctx.delete": "削除…",
"confirm.delete": "{label} を削除しますか?",
"confirm.delete_items": "{n} 項目を削除しますか?",
"confirm.delete_btn": "削除",
"confirm.cancel_btn": "キャンセル",
"confirm.ok_btn": "OK",
"items.count": "{n} 項目",
"items.count_plural": "{n} 項目",
"status.active": "{n} 実行中 · {speed}",
"status.connected": "接続済み {name}",
"status.disconnected": "Switch切断",
"status.listing_error": "一覧エラー: {error}",
"status.downloading": "ダウンロード中 {name}",
"status.uploading": "アップロード中 {name}",
"menu.options": "オプション",
"menu.language": "言語",
"menu.language_system": "システムデフォルト",
"menu.quit": "OmniMTPを終了",
"menu.about": "OmniMTPについて",
"about.tagline": "macOS向け Nintendo Switch MTPクライアント",
"about.version": "バージョン {version}",
"about.copyright": "Copyright © 2026 NiklasCFW"
}

View File

@@ -0,0 +1,50 @@
{
"app.title": "OmniMTP",
"badge.no_device": "Geen apparaat",
"device.waiting": "Wachten op Nintendo Switch…",
"device.connected": "{name} verbonden",
"device.nintendo_switch": "Nintendo Switch",
"btn.add_files": "Bestanden toevoegen",
"btn.scan": "Zoeken",
"btn.cancel_all": "Alles annuleren",
"btn.go_up": "Omhoog",
"btn.refresh": "Vernieuwen",
"empty.no_switch_title": "Geen Switch verbonden",
"empty.no_switch_sub": "Verbind via USB-C en open DBI of Sphaira MTP",
"empty.folder": "Lege map",
"loading": "Laden…",
"col.name": "Naam",
"col.size": "Grootte",
"col.modified": "Gewijzigd",
"transfers": "Overdrachten",
"transfers.count": "{n} overdracht",
"transfers.count_plural": "{n} overdrachten",
"transfer.queued": "Wachtrij",
"transfer.done": "Klaar",
"transfer.failed": "Mislukt",
"transfer.cancelled": "Geannuleerd",
"transfer.dl": "DL",
"transfer.up": "UP",
"ctx.delete": "Verwijderen…",
"confirm.delete": "{label} verwijderen?",
"confirm.delete_items": "{n} items verwijderen?",
"confirm.delete_btn": "Verwijderen",
"confirm.cancel_btn": "Annuleren",
"confirm.ok_btn": "OK",
"items.count": "{n} item",
"items.count_plural": "{n} items",
"status.active": "{n} actief · {speed}",
"status.connected": "Verbonden {name}",
"status.disconnected": "Switch verbroken",
"status.listing_error": "Fout bij weergeven: {error}",
"status.downloading": "Downloaden {name}",
"status.uploading": "Uploaden {name}",
"menu.options": "Opties",
"menu.language": "Taal",
"menu.language_system": "Systeemstandaard",
"menu.quit": "OmniMTP afsluiten",
"menu.about": "Over OmniMTP",
"about.tagline": "Nintendo Switch MTP-client voor macOS",
"about.version": "Versie {version}",
"about.copyright": "Copyright © 2026 NiklasCFW"
}

View File

@@ -0,0 +1,50 @@
{
"app.title": "OmniMTP",
"badge.no_device": "Sem dispositivo",
"device.waiting": "Aguardando Nintendo Switch…",
"device.connected": "{name} conectada",
"device.nintendo_switch": "Nintendo Switch",
"btn.add_files": "Adicionar arquivos",
"btn.scan": "Procurar",
"btn.cancel_all": "Cancelar tudo",
"btn.go_up": "Subir",
"btn.refresh": "Atualizar",
"empty.no_switch_title": "Nenhuma Switch conectada",
"empty.no_switch_sub": "Conecte via USB-C e abra o DBI ou Sphaira MTP",
"empty.folder": "Pasta vazia",
"loading": "Carregando…",
"col.name": "Nome",
"col.size": "Tamanho",
"col.modified": "Modificado",
"transfers": "Transferências",
"transfers.count": "{n} transferência",
"transfers.count_plural": "{n} transferências",
"transfer.queued": "Na fila",
"transfer.done": "Concluído",
"transfer.failed": "Falhou",
"transfer.cancelled": "Cancelado",
"transfer.dl": "DL",
"transfer.up": "UP",
"ctx.delete": "Excluir…",
"confirm.delete": "Excluir {label}?",
"confirm.delete_items": "Excluir {n} itens?",
"confirm.delete_btn": "Excluir",
"confirm.cancel_btn": "Cancelar",
"confirm.ok_btn": "OK",
"items.count": "{n} item",
"items.count_plural": "{n} itens",
"status.active": "{n} ativo · {speed}",
"status.connected": "Conectado {name}",
"status.disconnected": "Switch desconectada",
"status.listing_error": "Erro ao listar: {error}",
"status.downloading": "Baixando {name}",
"status.uploading": "Enviando {name}",
"menu.options": "Opções",
"menu.language": "Idioma",
"menu.language_system": "Padrão do sistema",
"menu.quit": "Sair do OmniMTP",
"menu.about": "Sobre o OmniMTP",
"about.tagline": "Cliente MTP Nintendo Switch para macOS",
"about.version": "Versão {version}",
"about.copyright": "Copyright © 2026 NiklasCFW"
}

View File

@@ -0,0 +1,50 @@
{
"app.title": "OmniMTP",
"badge.no_device": "Нет устройства",
"device.waiting": "Ожидание Nintendo Switch…",
"device.connected": "{name} подключена",
"device.nintendo_switch": "Nintendo Switch",
"btn.add_files": "Добавить файлы",
"btn.scan": "Поиск",
"btn.cancel_all": "Отменить всё",
"btn.go_up": "Вверх",
"btn.refresh": "Обновить",
"empty.no_switch_title": "Switch не подключена",
"empty.no_switch_sub": "Подключите по USB-C и откройте DBI или Sphaira MTP",
"empty.folder": "Пустая папка",
"loading": "Загрузка…",
"col.name": "Имя",
"col.size": "Размер",
"col.modified": "Изменён",
"transfers": "Передачи",
"transfers.count": "{n} передача",
"transfers.count_plural": "{n} передач",
"transfer.queued": "В очереди",
"transfer.done": "Готово",
"transfer.failed": "Ошибка",
"transfer.cancelled": "Отменено",
"transfer.dl": "DL",
"transfer.up": "UP",
"ctx.delete": "Удалить…",
"confirm.delete": "Удалить {label}?",
"confirm.delete_items": "Удалить {n} элементов?",
"confirm.delete_btn": "Удалить",
"confirm.cancel_btn": "Отмена",
"confirm.ok_btn": "OK",
"items.count": "{n} элемент",
"items.count_plural": "{n} элементов",
"status.active": "{n} активно · {speed}",
"status.connected": "Подключено {name}",
"status.disconnected": "Switch отключена",
"status.listing_error": "Ошибка списка: {error}",
"status.downloading": "Скачивание {name}",
"status.uploading": "Загрузка {name}",
"menu.options": "Параметры",
"menu.language": "Язык",
"menu.language_system": "Системный",
"menu.quit": "Завершить OmniMTP",
"menu.about": "О программе OmniMTP",
"about.tagline": "MTP-клиент Nintendo Switch для macOS",
"about.version": "Версия {version}",
"about.copyright": "Copyright © 2026 NiklasCFW"
}

View File

@@ -0,0 +1,50 @@
{
"app.title": "OmniMTP",
"badge.no_device": "无设备",
"device.waiting": "正在等待 Nintendo Switch…",
"device.connected": "{name} 已连接",
"device.nintendo_switch": "Nintendo Switch",
"btn.add_files": "添加文件",
"btn.scan": "扫描",
"btn.cancel_all": "全部取消",
"btn.go_up": "上级",
"btn.refresh": "刷新",
"empty.no_switch_title": "未连接 Switch",
"empty.no_switch_sub": "通过 USB-C 连接并打开 DBI 或 Sphaira MTP",
"empty.folder": "空文件夹",
"loading": "加载中…",
"col.name": "名称",
"col.size": "大小",
"col.modified": "修改时间",
"transfers": "传输",
"transfers.count": "{n} 个传输",
"transfers.count_plural": "{n} 个传输",
"transfer.queued": "排队中",
"transfer.done": "完成",
"transfer.failed": "失败",
"transfer.cancelled": "已取消",
"transfer.dl": "下载",
"transfer.up": "上传",
"ctx.delete": "删除…",
"confirm.delete": "删除 {label}",
"confirm.delete_items": "删除 {n} 项?",
"confirm.delete_btn": "删除",
"confirm.cancel_btn": "取消",
"confirm.ok_btn": "确定",
"items.count": "{n} 项",
"items.count_plural": "{n} 项",
"status.active": "{n} 进行中 · {speed}",
"status.connected": "已连接 {name}",
"status.disconnected": "Switch 已断开",
"status.listing_error": "列出错误:{error}",
"status.downloading": "正在下载 {name}",
"status.uploading": "正在上传 {name}",
"menu.options": "选项",
"menu.language": "语言",
"menu.language_system": "跟随系统",
"menu.quit": "退出 OmniMTP",
"menu.about": "关于 OmniMTP",
"about.tagline": "适用于 macOS 的 Nintendo Switch MTP 客户端",
"about.version": "版本 {version}",
"about.copyright": "Copyright © 2026 NiklasCFW"
}