diff --git a/Builder/Android/app/src/main/AndroidManifest.xml b/Builder/Android/app/src/main/AndroidManifest.xml index 339b0af..26fd249 100644 --- a/Builder/Android/app/src/main/AndroidManifest.xml +++ b/Builder/Android/app/src/main/AndroidManifest.xml @@ -104,6 +104,17 @@ + + + + + + + + + + + diff --git a/Builder/Linux/xm8.desktop b/Builder/Linux/xm8.desktop index e0c1e42..0b09c71 100644 --- a/Builder/Linux/xm8.desktop +++ b/Builder/Linux/xm8.desktop @@ -4,6 +4,7 @@ GenericName=XM8 Comment=PC-8801MA Emulator Comment[ja]=PC-8801MA エミュレーター Exec=xm8 +MimeType=application/x-m3u;audio/x-mpegurl;application/vnd.apple.mpegurl; Icon=xm8 Terminal=false Type=Application diff --git a/Builder/macOS/Info.plist b/Builder/macOS/Info.plist index 03c784d..0671c5e 100644 --- a/Builder/macOS/Info.plist +++ b/Builder/macOS/Info.plist @@ -26,6 +26,14 @@ ???? NSHighResolutionCapable + CFBundleDocumentTypes + + + CFBundleTypeNameXM8 Disk and Playlist + CFBundleTypeRoleViewer + CFBundleTypeExtensionsd88m3um3u8 + + CSResourcesFileMapped NSHumanReadableCopyright diff --git a/CMakeLists.txt b/CMakeLists.txt index 181125d..93fc432 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -236,6 +236,8 @@ option(ENABLE_PACKAGING "Enable CPack packaging" ON) target_link_libraries(${BIN_TARGET} PRIVATE XM8::SDL xbrz) if(APPLE) target_link_libraries(${BIN_TARGET} PRIVATE "-framework Foundation") + find_library(XM8_ICONV_LIBRARY iconv REQUIRED) + target_link_libraries(${BIN_TARGET} PRIVATE ${XM8_ICONV_LIBRARY}) endif() if(BUILD_TESTING) @@ -245,6 +247,9 @@ if(BUILD_TESTING) Source/UI/m3u.cpp ) target_include_directories(clidisk_test PRIVATE Source/UI) + if(APPLE) + target_link_libraries(clidisk_test PRIVATE ${XM8_ICONV_LIBRARY}) + endif() add_test(NAME clidisk_test COMMAND clidisk_test) add_executable(d88probe_test @@ -406,6 +411,9 @@ if(BUILD_TESTING) $ ) target_link_libraries(ra_dependency_test PRIVATE xm8_ra_dependencies) + if(APPLE) + target_link_libraries(ra_dependency_test PRIVATE ${XM8_ICONV_LIBRARY}) + endif() target_include_directories(ra_dependency_test PRIVATE Source/RA Source/UI) target_compile_definitions(ra_dependency_test PRIVATE XM8_ENABLE_RETROACHIEVEMENTS=1) diff --git a/Source/UI/app.cpp b/Source/UI/app.cpp index 287179b..284e8bc 100644 --- a/Source/UI/app.cpp +++ b/Source/UI/app.cpp @@ -43,6 +43,7 @@ #include "diskmgr.h" #include "tapemgr.h" #include "clidisk.h" +#include "m3u.h" #ifdef XM8_ENABLE_RETROACHIEVEMENTS #include "ra_media_change_policy.h" #include "ra_build_info.h" @@ -1110,6 +1111,38 @@ bool App::OpenDiskPairFromMenu(const std::string& path, bool *drive2_open, return true; } +bool App::OpenDiskSpecsFromMenu(const std::vector& specs, + std::string *error, bool close_drive2) +{ + struct Snapshot { bool open; std::string path; int bank; } snapshots[MAX_DRIVE]; + int banks; + if (specs.empty() || specs.size() > MAX_DRIVE) { + *error = "invalid disk selection"; + return false; + } + for (const DiskSpec& spec : specs) + if (!ProbeDisk(spec, &banks, error)) return false; + for (int drive = 0; drive < MAX_DRIVE; ++drive) { + snapshots[drive].open = diskmgr[drive]->IsOpen(); + if (snapshots[drive].open) { + snapshots[drive].path = diskmgr[drive]->GetPath(); + snapshots[drive].bank = diskmgr[drive]->GetBank(); + } + } + auto restore = [this, &snapshots]() { + for (int drive = 0; drive < MAX_DRIVE; ++drive) { + if (snapshots[drive].open) diskmgr[drive]->Open(snapshots[drive].path.c_str(), snapshots[drive].bank); + else diskmgr[drive]->Close(); + } + }; + for (const DiskSpec& spec : specs) { + if (!OpenDiskFromUser(spec, error)) { restore(); return false; } + } + if (close_drive2) diskmgr[1]->Close(); + RememberDiskOpenDir(specs.front().path.c_str()); + return true; +} + #ifdef XM8_ENABLE_RETROACHIEVEMENTS // // ResolveDiskForRaMode() @@ -4207,12 +4240,20 @@ bool App::OpenDroppedDisk(const char *path, std::string *error) int bank; }; Snapshot snapshots[MAX_DRIVE]; - DiskSpec first = {path, 0, 0}; + std::vector playlist_specs; + if (IsM3UPath(path) && !LoadPlaylistDiskSpecs(path, 0, MAX_DRIVE, + &playlist_specs, error)) return false; + DiskSpec first = playlist_specs.empty() ? DiskSpec{path, 0, 0} : playlist_specs[0]; int banks; if (ProbeDisk(first, &banks, error) == false) { return false; } + if (!playlist_specs.empty()) { + for (const DiskSpec& spec : playlist_specs) { + if (ProbeDisk(spec, &banks, error) == false) return false; + } + } for (int drive=0; driveIsOpen(); if (snapshots[drive].open) { @@ -4236,6 +4277,15 @@ bool App::OpenDroppedDisk(const char *path, std::string *error) restore(); return false; } + if (!playlist_specs.empty()) { + if (playlist_specs.size() > 1 && + !OpenDiskFromUser(playlist_specs[1], error)) { + restore(); + return false; + } + if (playlist_specs.size() == 1) diskmgr[1]->Close(); + return true; + } if (banks > 1) { DiskSpec second = {path, 1, 1}; if (OpenDiskFromUser(second, error) == false) { diff --git a/Source/UI/app.h b/Source/UI/app.h index 22a4ae7..c3b48b8 100644 --- a/Source/UI/app.h +++ b/Source/UI/app.h @@ -146,6 +146,8 @@ class App bool OpenDiskPairFromMenu(const std::string& path, bool *drive2_open, std::string *error); // open bank 0/1 as one transaction + bool OpenDiskSpecsFromMenu(const std::vector& specs, + std::string *error, bool close_drive2 = false); const char* GetTapeDir(); // get tape dir void Reset(); diff --git a/Source/UI/clidisk.cpp b/Source/UI/clidisk.cpp index 23f6513..82358e3 100644 --- a/Source/UI/clidisk.cpp +++ b/Source/UI/clidisk.cpp @@ -44,7 +44,7 @@ bool ParseBank(const std::string& value, int *bank) return true; } -bool ParseDiskSpec(const std::string& argument, int drive, DiskSpec *spec, +bool ParseDiskSpecInternal(const std::string& argument, int drive, DiskSpec *spec, std::string *error) { const std::string::size_type slash = argument.find_last_of("/\\"); @@ -120,17 +120,31 @@ bool ParseClock(const std::string& value, CliClockMode *clock) return true; } -bool IsM3U(const std::string& path) -{ - if (path.length() < 4) { - return false; - } +} // namespace - std::string ext = ToUpper(path.substr(path.length() - 4)); - return ext == ".M3U"; +bool ParseDiskSpec(const std::string& argument, int drive, DiskSpec *spec, + std::string *error) +{ + return ParseDiskSpecInternal(argument, drive, spec, error); } -} // namespace +bool LoadPlaylistDiskSpecs(const std::string& path, int first_drive, + size_t maximum, std::vector *specs, std::string *error) +{ + if (specs == nullptr || error == nullptr) return false; + M3UResult playlist = LoadM3U(path); + if (!playlist.success) { *error = playlist.error; return false; } + specs->clear(); + for (const std::string& entry : playlist.entries) { + if (specs->size() >= maximum) break; + DiskSpec spec; + if (!ParseDiskSpec(entry, first_drive + static_cast(specs->size()), + &spec, error)) return false; + specs->push_back(spec); + } + if (specs->empty()) { *error = "playlist contains no disk images: " + path; return false; } + return true; +} CliOptions ParseCommandLine(int argc, char *argv[]) { @@ -198,33 +212,12 @@ CliOptions ParseCommandLine(int argc, char *argv[]) return Error("too many disk images; maximum is 2"); } - if (IsM3U(argument)) { - - M3UResult playlist = LoadM3U(argument); - - if (!playlist.success) { - return Error(playlist.error); - } - - for (const auto& entry : playlist.entries) { - - if (options.disks.size() >=2) { - break; - } - - DiskSpec spec; - std::string error; - - if (!ParseDiskSpec(entry, - static_cast(options.disks.size()), - &spec, - &error)) { - return Error(error); - } - - options.disks.push_back(spec); - } - + if (IsM3UPath(argument)) { + std::vector specs; + std::string error; + if (!LoadPlaylistDiskSpecs(argument, static_cast(options.disks.size()), + 2 - options.disks.size(), &specs, &error)) return Error(error); + options.disks.insert(options.disks.end(), specs.begin(), specs.end()); } else { DiskSpec spec; diff --git a/Source/UI/clidisk.h b/Source/UI/clidisk.h index d3d794a..8ce078d 100644 --- a/Source/UI/clidisk.h +++ b/Source/UI/clidisk.h @@ -71,5 +71,9 @@ struct CliOptions { CliOptions ParseCommandLine(int argc, char *argv[]); const char* GetCommandLineHelp(); +bool ParseDiskSpec(const std::string& argument, int drive, DiskSpec *spec, + std::string *error); +bool LoadPlaylistDiskSpecs(const std::string& path, int first_drive, + size_t maximum, std::vector *specs, std::string *error); #endif diff --git a/Source/UI/m3u.cpp b/Source/UI/m3u.cpp index ab5d547..67d4bf4 100644 --- a/Source/UI/m3u.cpp +++ b/Source/UI/m3u.cpp @@ -1,11 +1,22 @@ #include "m3u.h" #include +#include +#include #ifdef _WIN32 #include +#include #endif #include #include +#if defined(__APPLE__) || (defined(__linux__) && !defined(__ANDROID__)) +#include +#endif +#ifdef __ANDROID__ +#include "os.h" +#include "common.h" +#include "converter.h" +#endif namespace { @@ -20,6 +31,135 @@ std::string Trim(const std::string& value) return value.substr(first, last - first + 1); } +bool IsValidUtf8(const std::string& value) +{ + for (size_t i = 0; i < value.size();) { + const unsigned char c = static_cast(value[i]); + size_t count = 0; + if (c < 0x80) count = 1; + else if (c >= 0xc2 && c <= 0xdf) count = 2; + else if (c >= 0xe0 && c <= 0xef) count = 3; + else if (c >= 0xf0 && c <= 0xf4) count = 4; + else return false; + if (i + count > value.size()) return false; + for (size_t j = 1; j < count; ++j) + if ((static_cast(value[i + j]) & 0xc0) != 0x80) return false; + i += count; + } + return true; +} + +void AppendUtf8(std::string *out, uint32_t codepoint) +{ + if (codepoint <= 0x7f) out->push_back(static_cast(codepoint)); + else if (codepoint <= 0x7ff) { + out->push_back(static_cast(0xc0 | (codepoint >> 6))); + out->push_back(static_cast(0x80 | (codepoint & 0x3f))); + } else if (codepoint <= 0xffff) { + out->push_back(static_cast(0xe0 | (codepoint >> 12))); + out->push_back(static_cast(0x80 | ((codepoint >> 6) & 0x3f))); + out->push_back(static_cast(0x80 | (codepoint & 0x3f))); + } else if (codepoint <= 0x10ffff) { + out->push_back(static_cast(0xf0 | (codepoint >> 18))); + out->push_back(static_cast(0x80 | ((codepoint >> 12) & 0x3f))); + out->push_back(static_cast(0x80 | ((codepoint >> 6) & 0x3f))); + out->push_back(static_cast(0x80 | (codepoint & 0x3f))); + } +} + +bool DecodeUtf16(const std::string& bytes, bool little_endian, std::string *out) +{ + if ((bytes.size() & 1) != 0) return false; + out->clear(); + for (size_t i = 0; i < bytes.size(); i += 2) { + const unsigned char a = static_cast(bytes[i]); + const unsigned char b = static_cast(bytes[i + 1]); + uint32_t value = little_endian ? a | (b << 8) : (a << 8) | b; + if (value >= 0xd800 && value <= 0xdbff) { + if (i + 3 >= bytes.size()) return false; + const unsigned char c = static_cast(bytes[i + 2]); + const unsigned char d = static_cast(bytes[i + 3]); + const uint32_t low = little_endian ? c | (d << 8) : (c << 8) | d; + if (low < 0xdc00 || low > 0xdfff) return false; + value = 0x10000 + ((value - 0xd800) << 10) + (low - 0xdc00); + i += 2; + } else if (value >= 0xdc00 && value <= 0xdfff) return false; + AppendUtf8(out, value); + } + return true; +} + +bool DecodeShiftJis(const std::string& bytes, std::string *out) +{ +#ifdef _WIN32 + const int wide_length = MultiByteToWideChar(932, MB_ERR_INVALID_CHARS, + bytes.data(), static_cast(bytes.size()), NULL, 0); + if (wide_length == 0) return false; + std::vector wide(wide_length); + if (MultiByteToWideChar(932, MB_ERR_INVALID_CHARS, bytes.data(), + static_cast(bytes.size()), wide.data(), wide_length) == 0) return false; + const int utf8_length = WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, + wide.data(), wide_length, NULL, 0, NULL, NULL); + if (utf8_length == 0) return false; + out->resize(utf8_length); + return WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, wide.data(), + wide_length, &(*out)[0], utf8_length, NULL, NULL) != 0; +#elif defined(__ANDROID__) + // Android's NDK does not provide iconv. Reuse XM8's CP932 table, which + // is already used for the application's Japanese file-name conversion. + std::string terminated = bytes; + terminated.push_back('\0'); + std::vector converted(bytes.size() * 3 + 1); + Converter converter; + converter.SjisToUtf(terminated.c_str(), converted.data()); + *out = converted.data(); + return true; +#elif defined(__APPLE__) || defined(__linux__) + iconv_t converter = iconv_open("UTF-8", "CP932"); + if (converter == reinterpret_cast(-1)) return false; + std::vector converted(bytes.size() * 4 + 4); + char *input = const_cast(bytes.data()); + size_t input_size = bytes.size(); + char *output = converted.data(); + size_t output_size = converted.size(); + const size_t result = iconv(converter, &input, &input_size, &output, &output_size); + iconv_close(converter); + if (result == static_cast(-1) || input_size != 0) return false; + out->assign(converted.data(), static_cast(output - converted.data())); + return true; +#else + (void)bytes; (void)out; + return false; +#endif +} + +bool DecodePlaylist(const std::string& path, const std::string& bytes, + std::string *text, std::string *error) +{ + if (bytes.size() >= 3 && bytes.compare(0, 3, "\xef\xbb\xbf") == 0) { + *text = bytes.substr(3); + if (IsValidUtf8(*text)) return true; + *error = "invalid playlist text encoding: " + path; + return false; + } + if (bytes.size() >= 2 && bytes.compare(0, 2, "\xff\xfe") == 0) { + if (DecodeUtf16(bytes.substr(2), true, text)) return true; + *error = "invalid playlist text encoding: " + path; + return false; + } + if (bytes.size() >= 2 && bytes.compare(0, 2, "\xfe\xff") == 0) { + if (DecodeUtf16(bytes.substr(2), false, text)) return true; + *error = "invalid playlist text encoding: " + path; + return false; + } + if (IsValidUtf8(bytes)) { *text = bytes; return true; } + if (IsM3UPath(path) && path.size() >= 4 && + std::tolower(static_cast(path[path.size() - 1])) != '8' && + DecodeShiftJis(bytes, text)) return true; + *error = "invalid playlist text encoding: " + path; + return false; +} + std::string ParentDirectory(const std::string& path) { const std::string::size_type slash = path.find_last_of("/\\"); @@ -51,6 +191,14 @@ std::string ResolveM3UEntry(const std::string& baseDir, } // namespace +bool IsM3UPath(const std::string& path) +{ + const auto dot = path.find_last_of('.'); + if (dot == std::string::npos) return false; + std::string extension = path.substr(dot); + for (char& ch : extension) ch = static_cast(std::tolower(static_cast(ch))); + return extension == ".m3u" || extension == ".m3u8"; +} M3UResult LoadM3U(const std::string& path) { @@ -58,20 +206,27 @@ M3UResult LoadM3U(const std::string& path) result.success = false; #ifdef _WIN32 - std::ifstream file(std::filesystem::u8path(path)); + std::ifstream file(std::filesystem::u8path(path), std::ios::binary); #else - std::ifstream file(path); + std::ifstream file(path, std::ios::binary); #endif if (!file.is_open()) { result.error = "unable to open m3u: " + path; return result; } - // 👇 get base directory ONCE + const std::string bytes((std::istreambuf_iterator(file)), + std::istreambuf_iterator()); + std::string text; + if (!DecodePlaylist(path, bytes, &text, &result.error)) return result; + const std::string baseDir = ParentDirectory(path); - std::string line; - while (std::getline(file, line)) { + size_t line_start = 0; + while (line_start <= text.size()) { + const size_t line_end = text.find('\n', line_start); + std::string line = text.substr(line_start, line_end - line_start); + line_start = line_end == std::string::npos ? text.size() + 1 : line_end + 1; line = Trim(line); if (line.empty()) { diff --git a/Source/UI/m3u.h b/Source/UI/m3u.h index b6b31ec..268c3d7 100644 --- a/Source/UI/m3u.h +++ b/Source/UI/m3u.h @@ -9,4 +9,5 @@ struct M3UResult { std::vector entries; }; +bool IsM3UPath(const std::string& path); M3UResult LoadM3U(const std::string& path); diff --git a/Source/UI/menu.cpp b/Source/UI/menu.cpp index 74dd936..e2f6b97 100644 --- a/Source/UI/menu.cpp +++ b/Source/UI/menu.cpp @@ -30,6 +30,9 @@ #endif // __ANDROID__ #include "menu.h" +#include "clidisk.h" +#include "m3u.h" + #include #ifdef XM8_ENABLE_RETROACHIEVEMENTS @@ -1667,7 +1670,7 @@ void Menu::EnterVmKey(int id) // EnterFile() // enter file menu // -void Menu::EnterFile() +void Menu::EnterFile() { const char *name; int id; @@ -1706,8 +1709,32 @@ void Menu::EnterFile() if (matched == true) { list->SetFocus(id_focus); } - file_expect[0] = '\0'; -} + file_expect[0] = '\0'; +} + +void Menu::EnterPlaylist(const char *path) +{ + std::string error; + playlist_entries.clear(); + if (!LoadPlaylistDiskSpecs(path, 0, + MENU_PLAYLIST_ENTRY_MAX - MENU_PLAYLIST_ENTRY_MIN + 1, + &playlist_entries, &error)) { + platform->MsgBox(NULL, error.c_str()); + EnterFile(); + return; + } + playlist_drive = file_id == MENU_DRIVE2_OPEN ? 1 : 0; + playlist_first = -1; + const bool pair = file_id == MENU_DRIVE1_BOTH || file_id == MENU_DRIVE2_BOTH; + list->SetTitle(pair ? "<< Playlist: select Drive 1 >>" : "<< Playlist >>", + MENU_PLAYLIST); + for (size_t i = 0; i < playlist_entries.size(); ++i) { + const std::string& entry = playlist_entries[i].path; + const size_t slash = entry.find_last_of("/\\"); + list->AddButton(entry.substr(slash == std::string::npos ? 0 : slash + 1).c_str(), + MENU_PLAYLIST_ENTRY_MIN + static_cast(i)); + } +} // // EnterJoyTest() @@ -1729,9 +1756,13 @@ void Menu::EnterJoyTest() // Command() // command // -void Menu::Command(bool down, int id) -{ - // back +void Menu::Command(bool down, int id) +{ + if (id >= MENU_PLAYLIST_ENTRY_MIN && id <= MENU_PLAYLIST_ENTRY_MAX) { + if (down == false) CmdPlaylist(id); + return; + } + // back if (id == MENU_BACK) { if (down == false) { CmdBack(); @@ -2057,8 +2088,8 @@ void Menu::CmdBack() EnterVideo(MENU_VIDEO_SCALEFILTER); break; - // file menu - case MENU_FILE: + // file menu + case MENU_FILE: switch (file_id) { // from drive1 menu case MENU_DRIVE1_OPEN: @@ -2625,6 +2656,10 @@ void Menu::CmdRa(int id) app->OpenRaWebsite(); break; + case MENU_PLAYLIST: + EnterFile(); + break; + default: break; } @@ -3539,11 +3574,28 @@ void Menu::CmdFile(int id) } // normal file - if (platform->MakePath(file_target, name, false) == false) { - return; - } - - // tape ? + if (platform->MakePath(file_target, name, false) == false) { + return; + } + if (IsM3UPath(file_target)) { + if (file_id == MENU_DRIVE1_BOTH || file_id == MENU_DRIVE2_BOTH) { + std::vector specs; + std::string error; + if (!LoadPlaylistDiskSpecs(file_target, 0, MAX_DRIVE, &specs, &error) || + !app->OpenDiskSpecsFromMenu(specs, &error, specs.size() == 1)) { + platform->MsgBox(NULL, error.c_str()); + return; + } + EnterDrive1(MENU_DRIVE1_BANK0); + return; + } + if (file_id == MENU_DRIVE1_OPEN || file_id == MENU_DRIVE2_OPEN) { + EnterPlaylist(file_target); + return; + } + } + + // tape ? if ((file_id == MENU_CMT_PLAY) || (file_id == MENU_CMT_REC)) { // eject tape tapemgr->Eject(); @@ -3610,10 +3662,50 @@ void Menu::CmdFile(int id) default: break; } -} - -// -// MakeExpect() +} + +void Menu::CmdPlaylist(int id) +{ + const size_t index = static_cast(id - MENU_PLAYLIST_ENTRY_MIN); + if (index >= playlist_entries.size()) return; + const bool pair = file_id == MENU_DRIVE1_BOTH || file_id == MENU_DRIVE2_BOTH; + if (!pair) { + DiskSpec spec = playlist_entries[index]; + spec.drive = playlist_drive; + std::string error; + if (!app->OpenDiskFromMenu(spec, &error)) { + platform->MsgBox(NULL, error.c_str()); + return; + } + if (playlist_drive == 0) EnterDrive1(MENU_DRIVE1_BANK0); + else EnterDrive2(MENU_DRIVE2_BANK0); + return; + } + if (playlist_first < 0) { + playlist_first = static_cast(index); + list->SetTitle("<< Playlist: select Drive 2 >>", MENU_PLAYLIST); + for (size_t entry_index = 0; entry_index < playlist_entries.size(); ++entry_index) { + const std::string& entry = playlist_entries[entry_index].path; + const size_t slash = entry.find_last_of("/\\"); + list->AddButton(entry.substr(slash == std::string::npos ? 0 : slash + 1).c_str(), + MENU_PLAYLIST_ENTRY_MIN + static_cast(entry_index)); + } + return; + } + DiskSpec first = playlist_entries[playlist_first]; + DiskSpec second = playlist_entries[index]; + first.drive = 0; + second.drive = 1; + std::string error; + if (!app->OpenDiskSpecsFromMenu({first, second}, &error)) { + platform->MsgBox(NULL, error.c_str()); + return; + } + EnterDrive1(MENU_DRIVE1_BANK0); +} + +// +// MakeExpect() // make file_exepct[] // void Menu::MakeExpect(const char *name) diff --git a/Source/UI/menu.h b/Source/UI/menu.h index e9c60df..29bd20d 100644 --- a/Source/UI/menu.h +++ b/Source/UI/menu.h @@ -8,7 +8,10 @@ // [ menu driver ] // -#ifdef SDL +#ifdef SDL + +#include +#include "clidisk.h" #ifndef MENU_H #define MENU_H @@ -91,8 +94,10 @@ class Menu // enter vmkey menu void EnterScalingFilter(); // enter scaling filter menu - void EnterFile(); - // enter file menu + void EnterFile(); + // enter file menu + void EnterPlaylist(const char *path); + void CmdPlaylist(int id); void EnterJoyTest(); // enter joytest menu void Command(bool down, int id); @@ -203,6 +208,9 @@ class Menu // parent joymap id bool ra_state_menu; // state menu was opened from RetroAchievements + std::vector playlist_entries; + int playlist_drive; + int playlist_first; static const int vmkey_table[62 * 2]; // MENU_VMKEY table static const Uint32 joytest_table[15 * 2]; diff --git a/Source/UI/menuid.h b/Source/UI/menuid.h index 42016e3..b7807b1 100644 --- a/Source/UI/menuid.h +++ b/Source/UI/menuid.h @@ -78,8 +78,9 @@ // RetroAchievements achievements menu #define MENU_RA_ACHIEVEMENT_DETAIL 28 // RetroAchievements achievement detail menu -#define MENU_RA_LEADERBOARDS_VIEW 29 - // RetroAchievements leaderboards menu +#define MENU_RA_LEADERBOARDS_VIEW 29 + // RetroAchievements leaderboards menu +#define MENU_PLAYLIST 30 // // main menu // @@ -812,8 +813,10 @@ // // file menu // -#define MENU_FILE_MIN 10000 - // minimum +#define MENU_FILE_MIN 10000 + // minimum +#define MENU_PLAYLIST_ENTRY_MIN 11000 +#define MENU_PLAYLIST_ENTRY_MAX 11999 #endif // MENUID_H diff --git a/Source/UI/platform.cpp b/Source/UI/platform.cpp index 6564ac6..86dd755 100644 --- a/Source/UI/platform.cpp +++ b/Source/UI/platform.cpp @@ -57,12 +57,16 @@ static bool IsSupportedFile(char *path) { - char d88[] = ".d88"; + char d88[] = ".d88"; + char m3u[] = ".m3u"; + char m3u8[] = ".m3u8"; char cmt[] = ".cmt"; char t88[] = ".t88"; char n80[] = ".n80"; - return check_file_extension(path, d88) || + return check_file_extension(path, d88) || + check_file_extension(path, m3u) || + check_file_extension(path, m3u8) || check_file_extension(path, cmt) || check_file_extension(path, t88) || check_file_extension(path, n80); @@ -395,7 +399,9 @@ const char* Platform::FindNext(Uint32 *info) } // supported ? - if (check_file_extension(find_name, _T(".d88")) == false && + if (check_file_extension(find_name, _T(".d88")) == false && + check_file_extension(find_name, _T(".m3u")) == false && + check_file_extension(find_name, _T(".m3u8")) == false && check_file_extension(find_name, _T(".cmt")) == false && check_file_extension(find_name, _T(".t88")) == false && check_file_extension(find_name, _T(".n80")) == false) { diff --git a/Tests/clidisk_test.cpp b/Tests/clidisk_test.cpp index 5a8ed1f..5e55bfe 100644 --- a/Tests/clidisk_test.cpp +++ b/Tests/clidisk_test.cpp @@ -1,4 +1,5 @@ #include "clidisk.h" +#include "m3u.h" #include #include @@ -49,6 +50,14 @@ bool WriteTextFile(const std::string& path, const std::string& content) return file.good(); } +bool WriteBinaryFile(const std::string& path, const std::string& content) +{ + std::ofstream file(path, std::ios::binary); + if (!file.is_open()) return false; + file.write(content.data(), static_cast(content.size())); + return file.good(); +} + } // namespace int main() @@ -129,6 +138,38 @@ int main() std::remove(playlist.c_str()); } CheckError({"xm8", "./clidisk_test_missing.m3u"}, "missing m3u"); + { + const std::string m3u8 = "./clidisk_test_playlist.m3u8"; + Check(WriteBinaryFile(m3u8, "\xef\xbb\xbfutf8.d88\n"), "write UTF-8 BOM m3u8"); + CliOptions options = Parse({"xm8", m3u8.c_str()}); + Check(options.action == CliAction::Run && options.disks.size() == 1 && + options.disks[0].path == "./utf8.d88", "m3u8 UTF-8 BOM"); + std::remove(m3u8.c_str()); + } + { + const std::string utf16 = "./clidisk_test_utf16.m3u"; + const char utf16_bytes[] = "\xff\xfe" "u\0t\0f\0" "1\0" ".\0d\0" "8\0" "8\0\n\0"; + Check(WriteBinaryFile(utf16, std::string(utf16_bytes, sizeof(utf16_bytes) - 1)), + "write UTF-16 m3u"); + M3UResult playlist = LoadM3U(utf16); + Check(playlist.success && playlist.entries.size() == 1 && + playlist.entries[0] == "./utf1.d88", "m3u UTF-16 LE"); + std::remove(utf16.c_str()); + } + { + const std::string sjis = "./clidisk_test_sjis.m3u"; + Check(WriteBinaryFile(sjis, "\x82\xa0.d88\n"), "write Shift-JIS m3u"); + M3UResult playlist = LoadM3U(sjis); + Check(playlist.success && playlist.entries.size() == 1 && + playlist.entries[0] == "./\xe3\x81\x82.d88", "m3u Shift-JIS fallback"); + std::remove(sjis.c_str()); + } + { + const std::string invalid = "./clidisk_test_invalid.m3u8"; + Check(WriteBinaryFile(invalid, "\x82\xa0.d88\n"), "write invalid m3u8"); + Check(!LoadM3U(invalid).success, "m3u8 rejects Shift-JIS fallback"); + std::remove(invalid.c_str()); + } for (const char *value : {"V1S", "v1h", "V2", "n"}) { Check(Parse({"xm8", "--system", value}).action == CliAction::Run, "valid system");