Skip to content

Commit 43be160

Browse files
authored
Update V1.9.2
1 parent b4bc662 commit 43be160

4 files changed

Lines changed: 316 additions & 38 deletions

File tree

AppSettings.h

Lines changed: 187 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
#include <JuceHeader.h>
77
#include <unordered_map>
88
#include <unordered_set>
9+
#include <functional>
910
#include <string>
1011

1112
//==============================================================================
@@ -120,6 +121,9 @@ struct TrackMapEntry
120121
juce::String timecodeOffset = "00:00:00:00"; // HH:MM:SS:FF
121122
juce::String notes;
122123

124+
// Sort order (0 = default/alphabetical, >0 = explicit position from playlist import)
125+
int sortOrder = 0;
126+
123127
// MIDI triggers (independent -- any combination can fire simultaneously)
124128
int midiChannel = 0; // 0-15 (displayed as 1-16), shared across all MIDI types
125129
int midiNoteNum = -1; // Note On: note number (-1 = disabled, 0-127)
@@ -182,6 +186,8 @@ struct TrackMapEntry
182186
obj->setProperty("durationSec", durationSec);
183187
obj->setProperty("timecodeOffset", timecodeOffset);
184188
obj->setProperty("notes", notes);
189+
if (sortOrder > 0)
190+
obj->setProperty("sortOrder", sortOrder);
185191

186192
// MIDI triggers (independent)
187193
obj->setProperty("midiChannel", midiChannel);
@@ -239,6 +245,7 @@ struct TrackMapEntry
239245
title = getString("title");
240246
durationSec = juce::jmax(0, getInt("durationSec", 0));
241247
notes = getString("notes");
248+
sortOrder = juce::jmax(0, getInt("sortOrder", 0));
242249

243250
// Legacy migration: if entry has trackId but no title, generate a placeholder
244251
// so imported v1.5 entries don't vanish (user can edit them later).
@@ -498,6 +505,45 @@ class TrackMap
498505
/// Clear all entries
499506
void clear() { entries.clear(); ++generation; }
500507

508+
/// Apply playlist order: reorder existing tracks, add missing ones.
509+
/// Does NOT touch cues, triggers, offsets, or notes of existing entries.
510+
/// Tracks not in the playlist have their sortOrder reset to 0 (appear after playlist).
511+
void applyPlaylistOrder(const std::vector<TrackMapEntry>& playlist)
512+
{
513+
// Reset all existing sortOrders
514+
for (auto& [k, entry] : entries)
515+
entry.sortOrder = 0;
516+
517+
// Apply playlist positions: update sortOrder on existing, add new
518+
int pos = 1;
519+
for (auto& pe : playlist)
520+
{
521+
if (!pe.hasValidKey()) continue;
522+
523+
// Try exact key (artist|title|duration) first, then fallback
524+
// to artist|title only — duration from XML may differ from CDJ
525+
auto key = pe.key();
526+
auto it = entries.find(key);
527+
if (it != entries.end())
528+
{
529+
it->second.sortOrder = pos;
530+
}
531+
else if (auto* existing = findIgnoringDuration(pe.artist, pe.title))
532+
{
533+
existing->sortOrder = pos;
534+
}
535+
else
536+
{
537+
// New entry — add with playlist position
538+
TrackMapEntry newEntry = pe;
539+
newEntry.sortOrder = pos;
540+
entries[key] = std::move(newEntry);
541+
}
542+
++pos;
543+
}
544+
++generation;
545+
}
546+
501547
//------------------------------------------------------------------
502548
// Iteration & info
503549
//------------------------------------------------------------------
@@ -529,8 +575,17 @@ class TrackMap
529575
for (auto& [k, entry] : entries)
530576
result.push_back(&entry);
531577

578+
// Sort by sortOrder first (0 = unordered, sorts after explicit positions).
579+
// Within the same sortOrder (or both 0), sort alphabetically by artist/title.
532580
std::sort(result.begin(), result.end(),
533581
[](const TrackMapEntry* a, const TrackMapEntry* b) {
582+
// Both have explicit order → compare by order
583+
if (a->sortOrder > 0 && b->sortOrder > 0)
584+
return a->sortOrder < b->sortOrder;
585+
// Only one has explicit order → it comes first
586+
if (a->sortOrder > 0) return true;
587+
if (b->sortOrder > 0) return false;
588+
// Neither has order → alphabetical
534589
int cmp = a->artist.compareIgnoreCase(b->artist);
535590
return cmp != 0 ? cmp < 0 : a->title.compareIgnoreCase(b->title) < 0;
536591
});
@@ -597,6 +652,14 @@ class TrackMap
597652
// Artwork and waveform will be fetched from the CDJ on first play.
598653
//------------------------------------------------------------------
599654
static std::vector<TrackMapEntry> parseRekordboxXml(const juce::File& file)
655+
{
656+
return parseRekordboxXml(file, "");
657+
}
658+
659+
/// Parse rekordbox XML export. If playlistName is non-empty, only return
660+
/// tracks from that playlist in playlist order. Otherwise return all tracks.
661+
static std::vector<TrackMapEntry> parseRekordboxXml(const juce::File& file,
662+
const juce::String& playlistName)
600663
{
601664
std::vector<TrackMapEntry> result;
602665
auto xml = juce::XmlDocument::parse(file);
@@ -605,32 +668,148 @@ class TrackMap
605668
auto* collection = xml->getChildByName("COLLECTION");
606669
if (!collection) return result;
607670

608-
// Deduplicate by key (same artist+title can appear from multiple
609-
// locations, e.g. local library + USB export in the same XML).
610-
std::unordered_set<std::string> seen;
611-
671+
// Build TrackID → entry map from COLLECTION
672+
std::unordered_map<int, TrackMapEntry> trackById;
612673
for (auto* track = collection->getChildByName("TRACK");
613674
track != nullptr;
614675
track = track->getNextElementWithTagName("TRACK"))
615676
{
616677
juce::String title = track->getStringAttribute("Name").trim();
617678
juce::String artist = track->getStringAttribute("Artist").trim();
618679
int duration = track->getIntAttribute("TotalTime", 0);
680+
int trackId = track->getIntAttribute("TrackID", 0);
619681

620-
if (title.isEmpty()) continue; // hasValidKey requires title
682+
if (title.isEmpty() || trackId <= 0) continue;
621683

622684
TrackMapEntry e;
623685
e.title = title;
624686
e.artist = artist;
625687
e.durationSec = duration;
688+
trackById[trackId] = std::move(e);
689+
}
690+
691+
// If a playlist is requested, filter and order by playlist
692+
if (playlistName.isNotEmpty())
693+
{
694+
auto* playlists = xml->getChildByName("PLAYLISTS");
695+
if (!playlists) return result;
696+
697+
// Traverse by full path (e.g. "Shows / Saturday") to handle
698+
// duplicate playlist names in different folders.
699+
// Path segments skip ROOT (same as listRekordboxPlaylists).
700+
juce::XmlElement* playlistNode = nullptr;
701+
{
702+
// Split path into segments: "Shows / Saturday" → ["Shows", "Saturday"]
703+
juce::StringArray segments;
704+
if (playlistName.contains(" / "))
705+
segments.addTokens(playlistName, " / ", "");
706+
else
707+
segments.add(playlistName);
708+
// Remove empty tokens from split
709+
for (int i = segments.size() - 1; i >= 0; --i)
710+
if (segments[i].isEmpty()) segments.remove(i);
711+
712+
// Start from ROOT node (first Type=0 child of PLAYLISTS)
713+
juce::XmlElement* current = nullptr;
714+
for (auto* child = playlists->getFirstChildElement();
715+
child != nullptr; child = child->getNextElement())
716+
{
717+
if (child->getTagName() == "NODE"
718+
&& child->getIntAttribute("Type", -1) == 0)
719+
{ current = child; break; }
720+
}
626721

627-
auto k = e.key();
722+
// Walk path segments: folders first, last segment is the playlist
723+
for (int si = 0; current != nullptr && si < segments.size(); ++si)
724+
{
725+
bool isLast = (si == segments.size() - 1);
726+
int wantType = isLast ? 1 : 0; // 1=playlist, 0=folder
727+
juce::XmlElement* found = nullptr;
728+
for (auto* child = current->getFirstChildElement();
729+
child != nullptr; child = child->getNextElement())
730+
{
731+
if (child->getTagName() == "NODE"
732+
&& child->getStringAttribute("Name") == segments[si]
733+
&& child->getIntAttribute("Type", -1) == wantType)
734+
{ found = child; break; }
735+
}
736+
current = found;
737+
}
738+
playlistNode = current;
739+
}
740+
if (!playlistNode) return result;
741+
742+
// Collect tracks in playlist order
743+
std::unordered_set<std::string> seen;
744+
for (auto* tr = playlistNode->getChildByName("TRACK");
745+
tr != nullptr;
746+
tr = tr->getNextElementWithTagName("TRACK"))
747+
{
748+
int key = tr->getIntAttribute("Key", 0);
749+
auto it = trackById.find(key);
750+
if (it != trackById.end())
751+
{
752+
auto k = it->second.key();
753+
if (!seen.count(k))
754+
{
755+
seen.insert(k);
756+
result.push_back(it->second);
757+
}
758+
}
759+
}
760+
return result;
761+
}
762+
763+
// No playlist specified — return all tracks from COLLECTION
764+
std::unordered_set<std::string> seen;
765+
for (auto& [id, entry] : trackById)
766+
{
767+
auto k = entry.key();
628768
if (seen.count(k)) continue;
629769
seen.insert(k);
630-
631-
result.push_back(std::move(e));
770+
result.push_back(std::move(entry));
632771
}
772+
return result;
773+
}
633774

775+
/// List available playlist names in a rekordbox XML file
776+
static juce::StringArray listRekordboxPlaylists(const juce::File& file)
777+
{
778+
juce::StringArray result;
779+
auto xml = juce::XmlDocument::parse(file);
780+
if (!xml || xml->getTagName() != "DJ_PLAYLISTS") return result;
781+
782+
auto* playlists = xml->getChildByName("PLAYLISTS");
783+
if (!playlists) return result;
784+
785+
// Recursive scan for Type=1 (playlist) nodes with entries.
786+
// ROOT is always the top-level folder in rekordbox — skip it in the path.
787+
std::function<void(juce::XmlElement*, const juce::String&)> scan =
788+
[&](juce::XmlElement* node, const juce::String& path)
789+
{
790+
for (auto* child = node->getFirstChildElement();
791+
child != nullptr; child = child->getNextElement())
792+
{
793+
if (child->getTagName() != "NODE") continue;
794+
juce::String name = child->getStringAttribute("Name");
795+
int type = child->getIntAttribute("Type", -1);
796+
797+
if (type == 1) // playlist
798+
{
799+
int entries = child->getIntAttribute("Entries", 0);
800+
if (entries > 0)
801+
result.add(path.isEmpty() ? name : path + " / " + name);
802+
}
803+
else if (type == 0) // folder
804+
{
805+
// Skip ROOT — it's always the top-level node in rekordbox XML
806+
juce::String childPath = name.equalsIgnoreCase("ROOT") ? path
807+
: (path.isEmpty() ? name : path + " / " + name);
808+
scan(child, childPath);
809+
}
810+
}
811+
};
812+
scan(playlists, "");
634813
return result;
635814
}
636815

Main.cpp

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ class SuperTimecodeConverterApplication : public juce::JUCEApplication
1111
SuperTimecodeConverterApplication() {}
1212

1313
const juce::String getApplicationName() override { return "Super Timecode Converter"; }
14-
const juce::String getApplicationVersion() override { return "1.9.1"; }
14+
const juce::String getApplicationVersion() override { return "1.9.2"; }
1515
bool moreThanOneInstanceAllowed() override { return false; }
1616

1717
void initialise(const juce::String&) override

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ Map tracks by **title** (and optionally artist and duration) to timecode offsets
138138
- Duration-based track identification -- same artist+title with different lengths are treated as separate tracks
139139
- Learn mode: capture tracks live from any CDJ or Denon deck (auto-captures duration)
140140
- Auto-fill artist/title from CDJ metadata
141-
- **Import from rekordbox XML:** import your entire rekordbox collection into the Track Map from an XML export (File → Export Collection in xml format). Artist, title and duration are imported for each track. Artwork, waveform and cue points populate automatically the first time each track plays on a CDJ.
141+
- **Import from rekordbox XML:** import your entire rekordbox collection into the Track Map from an XML export (File → Export Collection in xml format). Artist, title and duration are imported for each track. If the XML contains playlists, STC offers to **apply a playlist order** — reordering the Track Map to match the setlist while preserving all existing cue points, triggers and offsets. Artwork, waveform and cue points populate automatically the first time each track plays on a CDJ.
142142
- **Clear All:** remove all entries from the Track Map with a single click (with confirmation dialog)
143143
- **Enter/Return to save:** press Enter in any form field to save the entry directly, without clicking the Save button
144144

@@ -367,7 +367,7 @@ The sections below are for developers who want to build STC from source.
367367
3. **Create a `CMakeLists.txt`** in the project root:
368368
```cmake
369369
cmake_minimum_required(VERSION 3.22)
370-
project(SuperTimecodeConverter VERSION 1.9.1)
370+
project(SuperTimecodeConverter VERSION 1.9.2)
371371
372372
set(CMAKE_CXX_STANDARD 17)
373373
set(CMAKE_CXX_STANDARD_REQUIRED ON)
@@ -377,7 +377,7 @@ The sections below are for developers who want to build STC from source.
377377
juce_add_gui_app(SuperTimecodeConverter
378378
PRODUCT_NAME "Super Timecode Converter"
379379
COMPANY_NAME "Fiverecords"
380-
VERSION "1.9.1"
380+
VERSION "1.9.2"
381381
HARDENED_RUNTIME_ENABLED TRUE
382382
HARDENED_RUNTIME_OPTIONS com.apple.security.device.audio-input
383383
MICROPHONE_PERMISSION_ENABLED TRUE

0 commit comments

Comments
 (0)