Skip to content
1 change: 1 addition & 0 deletions wled00/const.h
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,7 @@ static_assert(WLED_MAX_BUSSES <= 32, "WLED_MAX_BUSSES exceeds hard limit");
//Playlist option byte
#define PL_OPTION_SHUFFLE 0x01
#define PL_OPTION_RESTORE 0x02
#define PL_OPTION_CLOCKED 0x04

// Segment capability byte
#define SEG_CAPABILITY_RGB 0x01
Expand Down
32 changes: 30 additions & 2 deletions wled00/data/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2008,6 +2008,7 @@ function plR(p)
{
var pl = plJson[p];
pl.r = gId(`pl${p}rtgl`).checked;
pl.clocked = gId(`pl${p}clocked`).checked;
if (gId(`pl${p}rptgl`).checked) { // infinite
pl.repeat = 0;
delete pl.end;
Expand All @@ -2019,6 +2020,25 @@ function plR(p)
}
}

function plClock(p)
{
const clocked = gId(`pl${p}clocked`).checked;
const manual = gId(`pl${p}manual`);
if (clocked && manual.checked) {
manual.checked = false;
plM(p);
} else if (clocked) {
plJson[p].dur.forEach((e,i)=>{
if (e > 0) return;
plJson[p].dur[i] = 100;
const d = gId(`pl${p}du${i}`);
if (d) d.value = 10;
});
}
manual.disabled = clocked;
plR(p);
}

function plM(p)
{
const man = gId(`pl${p}manual`).checked;
Expand All @@ -2041,17 +2061,24 @@ function makeP(i,pl)
transition: [tr],
repeat: 0,
r: false,
clocked: false,
end: 0
};
const rep = plJson[i].repeat ? plJson[i].repeat : 0;
const man = plJson[i].dur == 0;
const clocked = !!plJson[i].clocked;
if (clocked) plJson[i].dur = plJson[i].dur.map(d => d > 0 ? d : 100);
const man = !clocked && plJson[i].dur.every(d => d == 0);
content =
`<div id="ple${i}" style="margin-top:10px;"></div><label class="check revchkl">Shuffle
<input type="checkbox" id="pl${i}rtgl" onchange="plR(${i})" ${plJson[i].r||rep<0?"checked":""}>
<span class="checkmark"></span>
</label>
<label class="check revchkl">Clock sync
<input type="checkbox" id="pl${i}clocked" onchange="plClock(${i})" ${clocked?"checked":""}>
<span class="checkmark"></span>
</label>
<label class="check revchkl">Manual advance
<input type="checkbox" id="pl${i}manual" onchange="plM(${i})" ${man?"checked":""}>
<input type="checkbox" id="pl${i}manual" onchange="plM(${i})" ${man?"checked":""} ${clocked?"disabled":""}>
<span class="checkmark"></span>
</label>
<label class="check revchkl">Repeat indefinitely
Expand Down Expand Up @@ -3050,6 +3077,7 @@ function expand(i)
formatArr(plJson[p]);
if (isNaN(plJson[p].repeat)) plJson[p].repeat = 0;
if (!plJson[p].r) plJson[p].r = false;
if (!plJson[p].clocked) plJson[p].clocked = false;
if (isNaN(plJson[p].end)) plJson[p].end = 0;
gId('seg' +i).innerHTML = makeP(p,true);
refreshPlE(p);
Comment on lines +3113 to 3117

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Normalize persisted clock-sync playlists before rendering.

The PR explicitly says clock-sync playlists do not support zero-duration/manual-advance entries, but this path only backfills keys and then renders the editor. If a playlist is loaded from JSON with clockSync: true and all dur[] == 0, wled00/playlist.cpp will reject clock-sync because playlistTotalDur == 0, while the UI still shows Clock sync as enabled until the user toggles an option. Normalize that combination here (or clear clockSync) before makeP()/refreshPlE().

🧰 Tools
🪛 ast-grep (0.44.0)

[warning] 3115-3115: Avoid assigning untrusted data to innerHTML/outerHTML or document.write
Context: gId('seg' +i).innerHTML = makeP(p,true)
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting').

(inner-outer-html)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@wled00/data/index.js` around lines 3113 - 3117, The playlist editor path in
makeP()/refreshPlE() only backfills missing fields and can leave persisted
clock-sync playlists in an invalid state. Update the JSON load normalization so
that when a playlist has clockSync enabled but all durations are zero, clockSync
is cleared or otherwise normalized before rendering the segment UI. Make this
adjustment in the existing playlist parsing flow around the makeP(p, true) and
refreshPlE(p) calls so the editor reflects the same constraints enforced by
playlist.cpp.

Expand Down
103 changes: 103 additions & 0 deletions wled00/playlist.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ static PlaylistEntry *playlistEntries = nullptr;
static byte playlistLen; //number of playlist entries
static int8_t playlistIndex = -1;
static uint32_t playlistEntryDur = 0; //duration of the current entry in milliseconds
static bool clockedPlaylistSynced = false;

//values we need to keep about the parent playlist while inside sub-playlist
static int16_t parentPlaylistIndex = -1;
Expand All @@ -41,6 +42,24 @@ void shufflePlaylist() {
DEBUG_PRINTLN(F("Playlist shuffle."));
}

// AI: below section was generated by an AI
static uint32_t playlistShuffleRand(uint32_t &state) {
state ^= state << 13;
state ^= state >> 17;
state ^= state << 5;
return state;
}
Comment thread
softhack007 marked this conversation as resolved.
Outdated

static uint32_t clockedPlaylistSeed(uint32_t cycle) {
uint32_t seed = 2166136261UL;
seed = (seed ^ (uint32_t)currentPlaylist) * 16777619UL;
seed = (seed ^ cycle) * 16777619UL;
seed = (seed ^ playlistLen) * 16777619UL;
if (seed == 0) seed = 2166136261UL;
return seed;
}
// AI: end


void unloadPlaylist() {
if (playlistEntries != nullptr) {
Expand All @@ -51,9 +70,58 @@ void unloadPlaylist() {
playlistLen = 0;
playlistOptions = 0;
playlistEntryDur = 0;
clockedPlaylistSynced = false;
DEBUG_PRINTLN(F("Playlist unloaded."));
}

// AI: below section was generated by an AI
static bool getClockedPlaylistState(uint8_t &entryIndex, uint32_t &entryOffset) {
if (!(playlistOptions & PL_OPTION_CLOCKED) || playlistLen == 0 || playlistEntries == nullptr) return false;
if (toki.getTimeSource() == TOKI_TS_NONE) return false;

// Clocked playlists live on an absolute Toki timeline; every entry must have finite duration.
uint64_t cycleDur = 0;
for (byte i = 0; i < playlistLen; i++) {
if (playlistEntries[i].dur == 0) return false; // infinite entries cannot be placed on a repeating wall-clock timeline
cycleDur += playlistEntries[i].dur;
}
if (cycleDur == 0) return false;

Toki::Time tm = toki.getTime();
uint64_t absoluteMs = (uint64_t)tm.sec * 1000ULL + tm.ms;
uint32_t cycle = (uint32_t)(absoluteMs / cycleDur);
uint64_t cycleOffset = absoluteMs % cycleDur;

uint8_t order[100];
for (byte i = 0; i < playlistLen; i++) order[i] = i;
if (playlistOptions & PL_OPTION_SHUFFLE) {
// Keep shuffle deterministic for all controllers within the same wall-clock cycle.
uint32_t seed = clockedPlaylistSeed(cycle);
for (int i = playlistLen - 1; i > 0; i--) {
int j = playlistShuffleRand(seed) % (i + 1);
uint8_t tmp = order[i];
order[i] = order[j];
order[j] = tmp;
}
}

for (byte i = 0; i < playlistLen; i++) {
uint8_t orderedIndex = order[i];
uint32_t dur = playlistEntries[orderedIndex].dur;
if (cycleOffset < dur) {
entryIndex = orderedIndex;
entryOffset = (uint32_t)cycleOffset;
return true;
}
cycleOffset -= dur;
}

entryIndex = order[playlistLen - 1];
entryOffset = playlistEntries[entryIndex].dur - 1;
return true;
}
// AI: end


int16_t loadPlaylist(JsonObject playlistObj, byte presetId) {
if (currentPlaylist > 0 && parentPlaylistPresetId > 0) return -1; // we are already in nested playlist, do nothing
Expand Down Expand Up @@ -127,6 +195,7 @@ int16_t loadPlaylist(JsonObject playlistObj, byte presetId) {
}
if (playlistEndPreset > 250) playlistEndPreset = 0;
shuffle = shuffle || playlistObj["r"];
if (playlistObj[F("clocked")] | false) playlistOptions |= PL_OPTION_CLOCKED;
if (shuffle) playlistOptions |= PL_OPTION_SHUFFLE;

if (parentPlaylistPresetId == 0 && parentPlaylistIndex > -1) {
Expand All @@ -152,6 +221,39 @@ void handlePlaylist() {
static unsigned long presetCycledTime = 0;
if (currentPlaylist < 0 || playlistEntries == nullptr) return;

if (playlistOptions & PL_OPTION_CLOCKED) {
if (toki.getTimeSource() == TOKI_TS_NONE) {
if (playlistIndex < 0 && !nightlightActive) {
playlistIndex = 0;
jsonTransitionOnce = true;
strip.setTransition(playlistEntries[playlistIndex].tr * 100);
playlistEntryDur = playlistEntries[playlistIndex].dur > 0 ? playlistEntries[playlistIndex].dur : UINT32_MAX;
strip.resetTimebase();
applyPresetFromPlaylist(playlistEntries[playlistIndex].preset);
}
return;
}

uint8_t derivedIndex = 0;
uint32_t entryOffset = 0;
if (!getClockedPlaylistState(derivedIndex, entryOffset)) return;

if (nightlightActive) return;

strip.timebase = (unsigned long)entryOffset - millis();

if (!clockedPlaylistSynced || playlistIndex != (int8_t)derivedIndex) {
playlistIndex = derivedIndex;
jsonTransitionOnce = true;
strip.setTransition(playlistEntries[playlistIndex].tr * 100);
playlistEntryDur = playlistEntries[playlistIndex].dur;
applyPresetFromPlaylist(playlistEntries[playlistIndex].preset);
}
clockedPlaylistSynced = true;
doAdvancePlaylist = false;
return;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

if ((playlistEntryDur < UINT32_MAX && millis() - presetCycledTime > playlistEntryDur) || doAdvancePlaylist) {
presetCycledTime = millis();
if (bri == 0 || nightlightActive) return;
Expand Down Expand Up @@ -190,6 +292,7 @@ void serializePlaylist(JsonObject sObj) {
playlist[F("repeat")] = (playlistIndex < 0 && playlistRepeat > 0) ? playlistRepeat - 1 : playlistRepeat; // remove added repetition count (if not yet running)
playlist["end"] = playlistOptions & PL_OPTION_RESTORE ? 255 : playlistEndPreset;
playlist["r"] = playlistOptions & PL_OPTION_SHUFFLE;
playlist[F("clocked")] = (playlistOptions & PL_OPTION_CLOCKED) != 0;
for (int i=0; i<playlistLen; i++) {
ps.add(playlistEntries[i].preset);
dur.add((playlistEntries[i].dur) / 100); // convert ms back to tenths of seconds (backwards compatibility)
Expand Down