diff --git a/versions/V1/modules/ParticleStream.qml b/versions/V1/modules/ParticleStream.qml index f1bd188..558d80a 100644 --- a/versions/V1/modules/ParticleStream.qml +++ b/versions/V1/modules/ParticleStream.qml @@ -28,6 +28,36 @@ Item { property string pendingUrgentAddr7: "" property string pendingUrgentCls7: "" property var recentOpen7: ({}) + // Persistent ambient particle field for the reactor (mode 7): the very + // particles that condense into event text stay visible drifting at rest and + // reorganise to form the text on events, then disperse. Set false to keep the + // pure zero-idle reactor (empty at rest, ~0% CPU). + property bool ambientField7: true + + // External-event feed: any tool can push a message to the reactor by writing + // one line "\tLEFT\tRIGHT" to ~/.cache/qs-reactor-event (truncate). + // First load only baselines, so pre-existing content is not replayed on start. + property double lastReactorTs: -1 + property bool reactorBaselined: false + FileView { + id: reactorEventFile + path: Quickshell.env("HOME") + "/.cache/qs-reactor-event" + watchChanges: true + onFileChanged: reactorEventFile.reload() + onLoaded: { + if (!root.reactorBaselined) { root.reactorBaselined = true; root.lastReactorTs = Date.now(); return } + var raw = (reactorEventFile.text() || "").trim() + if (raw === "") return + var nl = raw.lastIndexOf("\n") + if (nl >= 0) raw = raw.slice(nl + 1) + var parts = raw.split("\t") + var ts = parseFloat(parts[0]) + if (!isFinite(ts) || ts <= root.lastReactorTs) return + root.lastReactorTs = ts + if (root.armed7 && root.active && root.mode === 7) + root.pushText(parts[1] || "", parts[2] || "", 1, "long") + } + } onModeChanged: { warnQueue7 = [] @@ -44,7 +74,7 @@ Item { urgentProbe7.stop() } else { pulses = [] - animating7 = false + animating7 = root.ambientField7 // field needs a running paint loop var ws7 = Hyprland.focusedWorkspace lastWsId = ws7 && ws7.id > 0 ? ws7.id : -1 canvas.tick7 = 16 @@ -640,14 +670,28 @@ Item { onTriggered: root.warnCheck() } - // track change → swarm forms TITLE (left) and ARTIST - ALBUM (right) + // track change → swarm forms TITLE (left) and ARTIST - ALBUM (right). + // Debounced: web players (YouTube, YouTube Music, etc.) publish the metadata + // in stages while the next track buffers — a transient generic/channel title + // first, then the real one ~0.5s later. Firing on each stage restarts the + // swarm mid-flight (a visible "reset"). Wait for the title to settle, then + // announce once, and never re-announce the same track. MprisSelect { id: psSel } readonly property string psTrack: psSel.player ? (psSel.player.trackTitle || "") : "" - onPsTrackChanged: { - if (!armed7 || psTrack === "") return - var ar = psSel.player ? (psSel.player.trackArtist || "") : "" - var al = psSel.player ? (psSel.player.trackAlbum || "") : "" - pushText(psTrack, ar + (al ? " - " + al : ""), 1, "long") + property string lastAnnouncedTrack: "" + onPsTrackChanged: if (armed7) trackDebounce.restart() + Timer { + id: trackDebounce + interval: 800 + repeat: false + onTriggered: { + var t = root.psTrack + if (!root.armed7 || t === "" || t === root.lastAnnouncedTrack) return + root.lastAnnouncedTrack = t + var ar = psSel.player ? (psSel.player.trackArtist || "") : "" + var al = psSel.player ? (psSel.player.trackAlbum || "") : "" + root.pushText(t, ar + (al ? " - " + al : ""), 1, "long") + } } // new notification (the bar's mako poll counts up) → fetch newest, show @@ -769,6 +813,10 @@ Item { // mode 8 quotes cache, separate from the mode-7 event cache property var quoteData: null property var quoteSwarm: null + property real fieldFade: 1.0 // ambient-field dim while text is forming + property int recruited: 0 // pool particles currently spelling the text + property real fieldT: 0 // drift clock, advanced with a per-paint clamp + property real lastPaintNow: 0 // (stall-proof: see driftPos below) onPaint: { var ctx = getContext("2d") @@ -1158,14 +1206,6 @@ Item { "<": [[2,0],[1,1],[0,2],[1,3],[2,4]] } } } - var ps7 = root.pulses - if (ps7.length === 0) { - root.animating7 = false - canvas.tick7 = 250 - ctx.globalAlpha = 1.0 - return - } - var gaps7 = [] for (var gi = 0; gi + 1 < runs.length; gi++) { var gx1 = root.layout.runRightEdge(runs[gi].e) @@ -1248,6 +1288,66 @@ Item { } } + // ── ambient pool: one set of particles drifting through the bar; + // on an event these same particles reorganise into the text ── + var ps7 = root.pulses + var N9 = 110 + // Drift clock with a per-paint CLAMP. If Quickshell's loop stalls a + // moment (an MPRIS metadata burst, a media relayout on track change…) + // wall-clock keeps running but no frame paints — driving the drift by + // Date.now() would make the field JUMP on the next paint (reads as a + // "reset"). Advance at most ~1 frame per paint → a micro-pause instead + // of a jump. + var dtF = now - canvas.lastPaintNow + canvas.lastPaintNow = now + canvas.fieldT += (dtF > 0 && dtF < 60) ? dtF : (dtF >= 60 ? 16 : 0) + var ftF = canvas.fieldT + var driftPos = function(i) { + var r1 = hash(i * 3 + 1), r2 = hash(i * 3 + 2), r3 = hash(i * 3 + 3) + var bf = hash(i * 5 + 7) + // x in ABSOLUTE bar coords (constant width), not gap-relative, so a + // relayout doesn't remap the pool. Two harmonics per axis → organic. + return { x: bf * width + + 16 * Math.sin(ftF / (1700 + 900 * r1) + 6.283 * r2) + + 6 * Math.sin(ftF / (620 + 300 * r3) + r1 * 5.0), + y: cy + (height / 2 - 5) * 0.78 * Math.sin(ftF / (1300 + 800 * r3) + 6.283 * r1) + + 2.5 * Math.sin(ftF / (500 + 260 * r2) + r3 * 4.0) } + } + var paintField = function(fromIdx, alpha) { + if (alpha <= 0.01) return + for (var fi = fromIdx; fi < N9; fi++) { + var d = driftPos(fi) + var tw = 0.55 + 0.30 * Math.sin(ftF / 640 + fi * 1.3) + var sz = 1.25 + 0.28 * Math.sin(ftF / 820 + fi * 2.1) // breathing + dot7(d.x, d.y, sz, sz * 0.4, alpha * tw) + } + } + var hasText7 = false + for (var ht = 0; ht < ps7.length; ht++) + if (ps7[ht].k === "text" && now - ps7[ht].t < root.pulseLife7(ps7[ht])) hasText7 = true + if (!hasText7) canvas.recruited = 0 + if (ps7.length === 0) { + if (root.ambientField7) { + canvas.fieldFade += (1.0 - canvas.fieldFade) * 0.06 + canvas.recruited = 0 + paintField(0, 0.5 * canvas.fieldFade) + root.animating7 = true + canvas.tick7 = 30 // slow drift, ~33 fps + ctx.globalAlpha = 1.0 + return + } + root.animating7 = false + canvas.tick7 = 250 + ctx.globalAlpha = 1.0 + return + } + if (root.ambientField7) { + // recruited particles are drawn by the text swarm below; the rest + // keep drifting, dimmed while a message is on screen + canvas.fieldFade += ((hasText7 ? 0.18 : 1.0) - canvas.fieldFade) * 0.06 + paintField(canvas.recruited, 0.5 * canvas.fieldFade) + } + var alive7 = false var livePs7 = [] for (var pi7 = 0; pi7 < ps7.length; pi7++) { @@ -1379,17 +1479,29 @@ Item { var leave7 = age > p.r1 ? (age - p.r1) / (p.life - p.r1) : 0; leave7 = leave7 * leave7 var shift7 = p.d * (fx2 - fx1) * 0.45 * (leave7 - enter7) var sdT = p.t % 86400000 - for (var ti = 0; ti < G.pts.length; ti++) { + var Ptot = G.pts.length + var af7 = root.ambientField7 + // with the field on, each glyph dot starts from a pool drift + // position (driftPos(ti)); the first N9 ARE the visible pool, + // the extra (long text) emerge to complete the word — no + // subsampling, so the message always reads in full. Field off + // keeps the original fly-in from the edge. + for (var ti = 0; ti < Ptot; ti++) { var ptT = G.pts[ti] var gT = geoT[ptT[2]] - var wxT = fx1 + hash(sdT + ti * 7 + 5) * (fx2 - fx1) + shift7 + var pxT, pyT, rgT, rcT + if (af7) { + var d07 = driftPos(ti) // start = pool drift + pxT = d07.x; pyT = d07.y; rgT = 1.3; rcT = 0.5 + } else { + pxT = fx1 + hash(sdT + ti * 7 + 5) * (fx2 - fx1) + shift7 + 12 * Math.sin(now / (800 + 500 * hash(sdT + ti * 7 + 1)) + 6.283 * hash(sdT + ti * 7 + 2)) - var wyT = cy + (height / 2 - 6) * 0.85 + pyT = cy + (height / 2 - 6) * 0.85 * Math.sin(now / (600 + 500 * hash(sdT + ti * 7 + 3)) + 6.283 * hash(sdT + ti * 7 + 4)) - var rgT = 2.2, rcT = 1.0 - var pxT = wxT, pyT = wyT + rgT = 2.2; rcT = 1.0 + } if (gT && qT > 0) { pxT += (gT.ox + ptT[0] * gT.cell - pxT) * qT pyT += (gT.oy + ptT[1] * gT.cell - pyT) * qT @@ -1397,16 +1509,17 @@ Item { pxT += 0.4 * Math.sin(now / 240 + ti) pyT += 0.4 * Math.cos(now / 300 + ti * 1.7) } - rgT += (gT.cell * 0.62 - 2.2) * qT - rcT += (gT.cell * 0.30 - 1.0) * qT + rgT += (gT.cell * 0.62 - rgT) * qT + rcT += (gT.cell * 0.30 - rcT) * qT } dot7(pxT, pyT, rgT * wS, rcT * wS, alT * wA) } + canvas.recruited = af7 ? Math.min(Ptot, N9) : 0 } } if (livePs7.length !== ps7.length) root.pulses = livePs7 - root.animating7 = alive7 - canvas.tick7 = alive7 ? 16 : 250 + root.animating7 = root.ambientField7 ? true : alive7 + canvas.tick7 = alive7 ? 16 : (root.ambientField7 ? 30 : 250) ctx.globalAlpha = 1.0 return }