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:
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
238
src/ui/App.mm
238
src/ui/App.mm
@@ -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);
|
||||
|
||||
818
src/ui/WebUI.mm
818
src/ui/WebUI.mm
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
50
src/ui/webroot/lang/de.json
Normal file
50
src/ui/webroot/lang/de.json
Normal 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"
|
||||
}
|
||||
50
src/ui/webroot/lang/en.json
Normal file
50
src/ui/webroot/lang/en.json
Normal 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"
|
||||
}
|
||||
50
src/ui/webroot/lang/es.json
Normal file
50
src/ui/webroot/lang/es.json
Normal 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"
|
||||
}
|
||||
50
src/ui/webroot/lang/fr.json
Normal file
50
src/ui/webroot/lang/fr.json
Normal 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 d’OmniMTP",
|
||||
"about.tagline": "Client MTP Nintendo Switch pour macOS",
|
||||
"about.version": "Version {version}",
|
||||
"about.copyright": "Copyright © 2026 NiklasCFW"
|
||||
}
|
||||
50
src/ui/webroot/lang/it.json
Normal file
50
src/ui/webroot/lang/it.json
Normal 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"
|
||||
}
|
||||
50
src/ui/webroot/lang/ja.json
Normal file
50
src/ui/webroot/lang/ja.json
Normal 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"
|
||||
}
|
||||
50
src/ui/webroot/lang/nl.json
Normal file
50
src/ui/webroot/lang/nl.json
Normal 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"
|
||||
}
|
||||
50
src/ui/webroot/lang/pt.json
Normal file
50
src/ui/webroot/lang/pt.json
Normal 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"
|
||||
}
|
||||
50
src/ui/webroot/lang/ru.json
Normal file
50
src/ui/webroot/lang/ru.json
Normal 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"
|
||||
}
|
||||
50
src/ui/webroot/lang/zh-Hans.json
Normal file
50
src/ui/webroot/lang/zh-Hans.json
Normal 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"
|
||||
}
|
||||
Reference in New Issue
Block a user